From 755c7f574da2bf4f606d15c32bb26a5d1d516dcf Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Thu, 14 Nov 2024 14:02:46 +0100 Subject: [PATCH 01/12] Refactor the JSON-RPC connection --- CHANGELOG.md | 18 + config/dev.config | 10 +- config/local.config | 13 +- config/test.config | 13 + src/grisp_connect.app.src | 30 +- src/grisp_connect_api.erl | 162 ++-- src/grisp_connect_client.erl | 313 ++++++-- src/grisp_connect_connection.erl | 741 ++++++++++++++++++ src/grisp_connect_internal.hrl | 25 + src/grisp_connect_jsonrpc.erl | 6 +- src/grisp_connect_logger_bin.erl | 23 - src/grisp_connect_sup.erl | 1 - src/grisp_connect_updater.erl | 7 + src/grisp_connect_utils.erl | 45 ++ src/grisp_connect_ws.erl | 147 ---- test/grisp_connect_connection_SUITE.erl | 326 ++++++++ .../jsonrpc_examples.txt | 96 +++ test/grisp_connect_jsonrpc_SUITE.erl | 8 +- test/grisp_connect_log_SUITE.erl | 28 +- test/grisp_connect_reconnect_SUITE.erl | 21 +- test/grisp_connect_test.hrl | 46 +- test/grisp_connect_test_client.erl | 2 - test/grisp_connect_test_server.erl | 88 ++- 23 files changed, 1735 insertions(+), 434 deletions(-) create mode 100644 src/grisp_connect_connection.erl create mode 100644 src/grisp_connect_internal.hrl create mode 100644 src/grisp_connect_utils.erl delete mode 100644 src/grisp_connect_ws.erl create mode 100644 test/grisp_connect_connection_SUITE.erl create mode 100644 test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6081b..b36a110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ and this project adheres to ## [Unreleased] +### Changed + +- The name of the grisp_connect configuration key to control the timout of +individual JSON-RPC requests changed from ws_requests_timeout ot +ws_request_timeout. +- Le default log filter changed to trying to filter out only some messages to +filtering out all progress messages, as it wasn't working reliably. +- The connection is not a persistent process anymore, it is now a transiant +process handling a connection and dying when the connection is closed. +- Internally, the JSON-RPC is parsed into a list of atom or binaries to pave the +road for namespaces. foo.bar.Buz is parsed into [foo, bar, <<"Buz">>] (if foo +and bar are already existing atoms, but 'Buz' is not). + +## Fixed + +- The client is now waiting 1 second before trying to reconnect when it gets +disconnected fomr the server. + ## [1.1.0] - 2024-10-12 ### Added diff --git a/config/dev.config b/config/dev.config index e22e04c..a626e7f 100644 --- a/config/dev.config +++ b/config/dev.config @@ -10,18 +10,16 @@ ]}, {kernel, [ - {logger_level, info}, + {logger_level, debug}, {logger, [ {handler, default, logger_std_h, #{ config => #{type => standard_io}, + filter_default => log, filters => [ - % Filter out supervisor progress reports so - % TLS certificates are not swamping the console... - {filter_out_progress, { - fun grisp_connect_logger_bin:filter_out/2, - {supervisor, report_progress}}} + {disable_progress, {fun logger_filters:progress/2, stop}} ] }} ]} ]} + ]. diff --git a/config/local.config b/config/local.config index 7394944..f663e70 100644 --- a/config/local.config +++ b/config/local.config @@ -26,5 +26,16 @@ {port, 8443} ]}, - {kernel, [{logger_level, debug}]} + {kernel, [ + {logger_level, debug}, + {logger, [ + {handler, default, logger_std_h, #{ + config => #{type => standard_io}, + filter_default => log, + filters => [ + {disable_progress, {fun logger_filters:progress/2, stop}} + ] + }} + ]} + ]} ]. diff --git a/config/test.config b/config/test.config index fe5cd15..d84962f 100644 --- a/config/test.config +++ b/config/test.config @@ -14,5 +14,18 @@ {grisp_connect, [ {domain, localhost}, {port, 3030} + ]}, + + {kernel, [ + {logger_level, debug}, + {logger, [ + {handler, default, logger_std_h, #{ + config => #{type => standard_io}, + filter_default => log, + filters => [ + {disable_progress, {fun logger_filters:progress/2, stop}} + ] + }} + ]} ]} ]. diff --git a/src/grisp_connect.app.src b/src/grisp_connect.app.src index 110e439..ce7076c 100644 --- a/src/grisp_connect.app.src +++ b/src/grisp_connect.app.src @@ -21,26 +21,24 @@ {port, 443}, {connect, true}, % keeps a constant connection with grisp.io {ntp, false}, % if set to true, starts the NTP client - {ws_requests_timeout, 5_000}, + {ws_transport, tls}, + {ws_path, "/grisp-connect/ws"}, + {ws_request_timeout, 5_000}, {ws_ping_timeout, 60_000}, {logs_interval, 2_000}, {logs_batch_size, 100}, {logger, [ - % Enable our own default handler, - % which will receive all events from boot - {handler, - grisp_connect_log_handler, - grisp_connect_logger_bin, - #{formatter => {grisp_connect_logger_bin, #{}}, - % Filter out supervisor progress reports to prevent the ones - % from tls_dyn_connection_sup that logs all the certificates - % to crash the connection... - filters => [ - {filter_out_progress, - {fun grisp_connect_logger_bin:filter_out/2, - {supervisor, report_progress}}} - ] - }} + % Enable our own default handler, + % which will receive all events from boot + {handler, + grisp_connect_log_handler, + grisp_connect_logger_bin, + #{formatter => {grisp_connect_logger_bin, #{}}, + filter_default => log, + filters => [ + {disable_progress, {fun logger_filters:progress/2, stop}} + ] + }} ]} ]}, {modules, []}, diff --git a/src/grisp_connect_api.erl b/src/grisp_connect_api.erl index f9bb96c..46bcb7d 100644 --- a/src/grisp_connect_api.erl +++ b/src/grisp_connect_api.erl @@ -1,152 +1,80 @@ %% @doc Library module containing the jsonrpc API logic -module(grisp_connect_api). --export([request/3]). --export([notify/3]). -export([handle_msg/1]). -include_lib("kernel/include/logger.hrl"). %--- Macros -------------------------------------------------------------------- --define(method_get, <<"get">>). --define(method_post, <<"post">>). --define(method_patch, <<"patch">>). --define(method_delete, <<"delete">>). +-define(method_get, get). +-define(method_post, post). -%--- API ----------------------------------------------------------------------- -% #doc Assembles a jsonrpc request and its uuid --spec request(Method :: atom() | binary(), - Type :: atom() | binary(), - Params :: map()) -> {ID :: binary(), Encoded :: binary()}. -request(Method, Type, Params) -> - ID = id(), - Rpc = {request, Method, maps:put(type, Type, Params), ID}, - Encoded = grisp_connect_jsonrpc:encode(Rpc), - {ID, Encoded}. +%--- API ----------------------------------------------------------------------- -% #doc Assembles a jsonrpc notification --spec notify(Method :: atom() | binary(), - Type :: atom() | binary(), - Params :: map()) -> Encoded :: binary(). -notify(Method, Type, Params) -> - Rpc = {notification, Method, maps:put(type, Type, Params)}, - grisp_connect_jsonrpc:encode(Rpc). +% @doc Handles requests, notifications and errors from grisp.io. +-spec handle_msg(Msg) -> + ok | {reply, Result :: term(), ReqRef :: binary() | integer()} + when Msg :: {request, Method :: grisp_connect_connection:method(), Params :: map() | list(), ReqRef :: binary() | integer()} + | {notification, grisp_connect_connection:method(), Params :: map() | list()} + | {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term()}. +handle_msg({notification, M, Params}) -> + ?LOG_ERROR("Received unexpected notification ~p: ~p", [M, Params]), + ok; +handle_msg({remote_error, Code, Message, _Data}) -> + ?LOG_ERROR("Received JSON-RPC error ~p: ~s", [Code, Message]), + ok; +handle_msg({request, M, Params, ID}) + when M == [?method_post]; M == [?method_get] -> + handle_request(M, Params, ID). -% @doc Indentifies if the message is a request or a reply to a previous request. -% In case it was a request, returns the reply to be sent to the peer. -% In case it was a response, returns the parsed ID and content to be handled by -% the caller. --spec handle_msg(JSON :: binary()) -> - {send_response, Response :: binary()} | - {handle_response, ID :: binary(), {ok, Result :: map()} | {error, atom()}}. -handle_msg(JSON) -> - JSON_RPC = grisp_connect_jsonrpc:decode(JSON), - handle_jsonrpc(JSON_RPC). %--- Internal Funcitons -------------------------------------------------------- -format_error({internal_error, parse_error, ID}) -> - {error, -32700, <<"Parse error">>, undefined, ID}; -format_error({internal_error, invalid_request, ID}) -> - {error, -32600, <<"Invalid request">>, undefined, ID}; -format_error({internal_error, method_not_found, ID}) -> - {error, -32601, <<"Method not found">>, undefined, ID}; -format_error({internal_error, invalid_params, ID}) -> - {error, -32602, <<"Invalid params">>, undefined, ID}; -format_error({internal_error, Reason, ID}) -> - {error, -32603, <<"Internal error">>, Reason, ID}. - -%FIXME: Batch are not supported yet. When receiving a batch of messages, as per -% the JSON-RPC standard, all the responses should goes in a single batch -% of responses. -handle_jsonrpc(Messages) when is_list(Messages) -> - handle_rpc_messages(Messages, []); -handle_jsonrpc(Message) -> - handle_rpc_messages([Message], []). - -handle_rpc_messages([], Replies) -> lists:reverse(Replies); -handle_rpc_messages([{request, M, Params, ID} | Batch], Replies) - when M == ?method_post; - M == ?method_get -> - handle_rpc_messages(Batch, [handle_request(M, Params, ID) | Replies]); -handle_rpc_messages([{result, _, _} = Res| Batch], Replies) -> - handle_rpc_messages(Batch, [handle_response(Res)| Replies]); -handle_rpc_messages([{error, _Code, _Msg, _Data, _ID} = E | Batch], Replies) -> - ?LOG_INFO("Received JsonRPC error: ~p",[E]), - handle_rpc_messages(Batch, [handle_response(E)| Replies]); -handle_rpc_messages([{decoding_error, _, _, _, _} = E | Batch], Replies) -> - ?LOG_ERROR("JsonRPC decoding error: ~p",[E]), - handle_rpc_messages(Batch, Replies). - -handle_request(?method_get, #{type := <<"system_info">>} = _Params, ID) -> +handle_request([?method_get], #{type := <<"system_info">>} = _Params, ID) -> Info = grisp_connect_updater:system_info(), - {send_response, grisp_connect_jsonrpc:encode({result, Info, ID})}; -handle_request(?method_post, #{type := <<"start_update">>} = Params, ID) -> + {reply, Info, ID}; +handle_request([?method_post], #{type := <<"start_update">>} = Params, ID) -> try URL = maps:get(url, Params), - Reply = case grisp_connect_updater:start_update(URL) of + case grisp_connect_updater:start_update(URL) of {error, grisp_updater_unavailable} -> - {error, -10, grisp_updater_unavailable, undefined, ID}; + {error, grisp_updater_unavailable, undefined, undefined, ID}; {error, already_updating} -> - {error, -11, already_updating, undefined, ID}; + {error, already_updating, undefined, undefined, ID}; {error, boot_system_not_validated} -> - {error, -12, boot_system_not_validated, undefined, ID}; + {error, boot_system_not_validated, undefined, undefined, ID}; {error, Reason} -> ReasonBinary = iolist_to_binary(io_lib:format("~p", [Reason])), - format_error({internal_error, ReasonBinary, ID}); + {error, internal_error, ReasonBinary, undefined, ID}; ok -> - {result, ok, ID} - end, - {send_response, grisp_connect_jsonrpc:encode(Reply)} + {reply, ok, ID} + end catch throw:bad_key -> - {send_response, - format_error({internal_error, invalid_params, ID})} - end; -handle_request(?method_post, #{type := <<"validate">>}, ID) -> - Reply = case grisp_connect_updater:validate() of + {error, internal_error, <<"Invalid params">>, ID} + end; +handle_request([?method_post], #{type := <<"validate">>}, ID) -> + case grisp_connect_updater:validate() of {error, grisp_updater_unavailable} -> - {error, -10, grisp_updater_unavailable, undefined, ID}; + {error, grisp_updater_unavailable, undefined, undefined, ID}; {error, {validate_from_unbooted, PartitionIndex}} -> - {error, -13, validate_from_unbooted, PartitionIndex, ID}; + {error, validate_from_unbooted, undefined, PartitionIndex, ID}; {error, Reason} -> ReasonBinary = iolist_to_binary(io_lib:format("~p", [Reason])), - format_error({internal_error, ReasonBinary, ID}); + {error, internal_error, ReasonBinary, undefined, ID}; ok -> - {result, ok, ID} - end, - {send_response, grisp_connect_jsonrpc:encode(Reply)}; -handle_request(?method_post, #{type := <<"reboot">>}, ID) -> + {reply, ok, ID} + end; +handle_request([?method_post], #{type := <<"reboot">>}, ID) -> grisp_connect_client:reboot(), - {send_response, grisp_connect_jsonrpc:encode({result, ok, ID})}; -handle_request(?method_post, #{type := <<"cancel">>}, ID) -> - Reply = case grisp_connect_updater:cancel() of + {reply, ok, ID}; +handle_request([?method_post], #{type := <<"cancel">>}, ID) -> + case grisp_connect_updater:cancel() of {error, grisp_updater_unavailable} -> - {error, -10, grisp_updater_unavailable, undefined, ID}; + {error, grisp_updater_unavailable, undefined, undefined, ID}; ok -> - {result, ok, ID} - end, - {send_response, grisp_connect_jsonrpc:encode(Reply)}; + {reply, ok, ID} + end; handle_request(_T, _P, ID) -> - Error = {internal_error, method_not_found, ID}, - FormattedError = format_error(Error), - {send_response, grisp_connect_jsonrpc:encode(FormattedError)}. - -handle_response(Response) -> - {ID, Reply} = case Response of - {result, Result, ID0} -> - {ID0, {ok, Result}}; - {error, Code, _Message, _Data, ID0} -> - {ID0, {error, error_atom(Code)}} - end, - {handle_response, ID, Reply}. - -error_atom(-1) -> device_not_linked; -error_atom(-2) -> token_expired; -error_atom(-3) -> device_already_linked; -error_atom(-4) -> invalid_token; -error_atom(_) -> jsonrpc_error. - -id() -> - list_to_binary(integer_to_list(erlang:unique_integer())). + {error, method_not_found, undefined, undefined, ID}. diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index 7e53457..f9f9181 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -5,6 +5,12 @@ %% @end -module(grisp_connect_client). +-behaviour(gen_statem). + +-include("grisp_connect_internal.hrl"). + +-import(grisp_connect_utils, [as_bin/1]). + % External API -export([start_link/0]). -export([connect/0]). @@ -13,12 +19,9 @@ -export([notify/3]). % Internal API --export([connected/0]). --export([disconnected/0]). --export([handle_message/1]). -export([reboot/0]). --behaviour(gen_statem). +% Behaviour gen_statem callback functions -export([init/1, terminate/3, code_change/4, callback_mode/0]). % State Functions @@ -27,18 +30,50 @@ -export([connecting/3]). -export([connected/3]). --include_lib("kernel/include/logger.hrl"). + +%--- Types --------------------------------------------------------------------- + +-record(data, { + domain :: binary(), + port :: inet:port_number(), + ws_path :: binary(), + ws_transport :: tcp | tls, + conn :: undefined | pid(), + retry_count = 0 :: non_neg_integer() +}). + +-type data() :: #data{}. +-type on_result_fun() :: fun((data(), Result :: term()) -> data()). +-type on_error_fun() :: fun((data(), local | remote, + Code :: atom() | integer(), + Message :: undefined | binary(), + Data :: term()) -> data()). + + +%--- Macros -------------------------------------------------------------------- -define(STD_TIMEOUT, 1000). +-define(CONNECT_TIMEOUT, 2000). +-define(ENV(KEY, GUARDS), fun() -> + case application:get_env(grisp_connect, KEY) of + {ok, V} when GUARDS -> V; + {ok, V} -> erlang:exit({invalid_env, KEY, V}); + undefined -> erlang:exit({missing_env, KEY}) + end +end()). +-define(ENV(KEY, GUARDS, CONV), fun() -> + case application:get_env(grisp_connect, KEY) of + {ok, V} when GUARDS -> CONV; + {ok, V} -> erlang:exit({invalid_env, KEY, V}); + undefined -> erlang:exit({missing_env, KEY}) + end +end()). -define(HANDLE_COMMON, ?FUNCTION_NAME(EventType, EventContent, Data) -> handle_common(EventType, EventContent, ?FUNCTION_NAME, Data)). --record(data, { - requests = #{} -}). -% API +%--- External API Functions ---------------------------------------------------- start_link() -> gen_statem:start_link({local, ?MODULE}, ?MODULE, [], []). @@ -47,7 +82,9 @@ connect() -> gen_statem:cast(?MODULE, ?FUNCTION_NAME). is_connected() -> - gen_statem:call(?MODULE, ?FUNCTION_NAME). + try gen_statem:call(?MODULE, ?FUNCTION_NAME) + catch exit:noproc -> false + end. request(Method, Type, Params) -> gen_statem:call(?MODULE, {?FUNCTION_NAME, Method, Type, Params}). @@ -55,35 +92,47 @@ request(Method, Type, Params) -> notify(Method, Type, Params) -> gen_statem:cast(?MODULE, {?FUNCTION_NAME, Method, Type, Params}). -connected() -> - gen_statem:cast(?MODULE, ?FUNCTION_NAME). -disconnected() -> - gen_statem:cast(?MODULE, ?FUNCTION_NAME). - -handle_message(Payload) -> - gen_statem:cast(?MODULE, {?FUNCTION_NAME, Payload}). +%--- Internal API Functions ---------------------------------------------------- reboot() -> erlang:send_after(1000, ?MODULE, reboot). -% gen_statem CALLBACKS --------------------------------------------------------- + +%--- Behaviour gen_statem Callback Functions ----------------------------------- init([]) -> - {ok, Connect} = application:get_env(grisp_connect, connect), - NextState = case Connect of + process_flag(trap_exit, true), + AutoConnect = ?ENV(connect, is_boolean(V)), + Domain = ?ENV(domain, is_binary(V) orelse is_list(V) orelse is_atom(V), as_bin(V)), + Port = ?ENV(port, is_integer(V) andalso V >= 0 andalso V < 65536), + WsTransport = ?ENV(ws_transport, V =:= tls orelse V =:= tcp), + WsPath = ?ENV(ws_path, is_binary(V) orelse is_list(V), as_bin(V)), + Data = #data{ + domain = Domain, + port = Port, + ws_transport = WsTransport, + ws_path = WsPath + }, + % The error list is put in a persistent term to not add noise to the state. + persistent_term:put({?MODULE, self()}, generic_errors()), + NextState = case AutoConnect of true -> waiting_ip; false -> idle end, - {ok, NextState, #data{}}. + {ok, NextState, Data}. -terminate(_Reason, _State, _Data) -> ok. +terminate(Reason, _State, Data) -> + conn_close(Data, Reason), + persistent_term:erase({?MODULE, self()}), + ok. code_change(_Vsn, State, Data, _Extra) -> {ok, State, Data}. callback_mode() -> [state_functions, state_enter]. -%%% STATE CALLBACKS ------------------------------------------------------------ + +%--- Behaviour gen_statem State Callback Functions ----------------------------- idle(enter, _OldState, _Data) -> keep_state_and_data; @@ -91,30 +140,47 @@ idle(cast, connect, Data) -> {next_state, waiting_ip, Data}; ?HANDLE_COMMON. -waiting_ip(enter, _OldState, _Data) -> - {keep_state_and_data, [{state_timeout, 0, retry}]}; -waiting_ip(state_timeout, retry, Data) -> +waiting_ip(enter, _OldState, Data) -> + Delay = case Data#data.retry_count > 0 of + true -> ?STD_TIMEOUT; + false -> 0 + end, + {keep_state_and_data, [{state_timeout, Delay, retry}]}; +waiting_ip(state_timeout, retry, Data = #data{retry_count = RetryCount}) -> case check_inet_ipv4() of {ok, IP} -> ?LOG_INFO(#{event => checked_ip, ip => IP}), {next_state, connecting, Data}; invalid -> ?LOG_DEBUG(#{event => waiting_ip}), - {next_state, waiting_ip, Data, [{state_timeout, ?STD_TIMEOUT, retry}]} + {next_state, waiting_ip, Data#data{retry_count = RetryCount + 1}, + [{state_timeout, ?STD_TIMEOUT, retry}]} end; ?HANDLE_COMMON. -connecting(enter, _OldState, _Data) -> - {ok, Domain} = application:get_env(grisp_connect, domain), - {ok, Port} = application:get_env(grisp_connect, port), - ?LOG_NOTICE(#{event => connecting, domain => Domain, port => Port}), - grisp_connect_ws:connect(Domain, Port), - keep_state_and_data; -connecting(cast, connected, Data) -> - ?LOG_NOTICE(#{event => connected}), - {next_state, connected, Data}; -connecting(cast, disconnected, _Data) -> - repeat_state_and_data; +connecting(enter, _OldState, Data) -> + {keep_state, Data, [{state_timeout, 0, connect}]}; +connecting(state_timeout, connect, + Data = #data{conn = undefined, retry_count = RetryCount}) -> + ?GRISP_INFO("Connecting to grisp.io", [], #{event => connecting}), + case conn_start(Data) of + {ok, Data2} -> + {keep_state, Data2, [{state_timeout, ?CONNECT_TIMEOUT, timeout}]}; + {error, Reason} -> + ?LOG_WARNING("Failed to connect to grisp.io: ~p", [Reason], + #{event => connection_failed, reason => Reason}), + {next_state, waiting_ip, Data#data{retry_count = RetryCount + 1}} + end; +connecting(state_timeout, timeout, Data = #data{retry_count = RetryCount}) -> + Reason = connect_timeout, + ?GRISP_WARN("Timeout while connecting to grisp.io", [], + #{event => connection_failed, reason => Reason}), + Data2 = conn_close(Data, Reason), + {next_state, waiting_ip, Data2#data{retry_count = RetryCount + 1}}; +connecting(info, {conn, Conn, connected}, Data = #data{conn = Conn}) -> + % Received from the connection process + ?GRISP_INFO("Connected to grisp.io", [], #{event => connected}), + {next_state, connected, Data#data{retry_count = 0}}; ?HANDLE_COMMON. connected(enter, _OldState, _Data) -> @@ -122,34 +188,15 @@ connected(enter, _OldState, _Data) -> keep_state_and_data; connected({call, From}, is_connected, _) -> {keep_state_and_data, [{reply, From, true}]}; -connected(cast, disconnected, Data) -> - ?LOG_WARNING(#{event => disconnected}), - grisp_connect_log_server:stop(), - {next_state, waiting_ip, Data}; -connected(cast, {handle_message, Payload}, #data{requests = Requests} = Data) -> - Responses = grisp_connect_api:handle_msg(Payload), - % A reduce operation is needed to support jsonrpc batch comunications - case Responses of - [] -> - keep_state_and_data; - [{send_response, Response}] -> % Response for a GRiSP.io request - grisp_connect_ws:send(Response), - keep_state_and_data; - [{handle_response, ID, Response}] -> % handle a GRiSP.io response - {OtherRequests, Actions} = dispatch_response(ID, Response, Requests), - {keep_state, Data#data{requests = OtherRequests}, Actions} - end; -connected({call, From}, {request, Method, Type, Params}, - #data{requests = Requests} = Data) -> - {ID, Payload} = grisp_connect_api:request(Method, Type, Params), - grisp_connect_ws:send(Payload), - NewRequests = Requests#{ID => From}, - {keep_state, - Data#data{requests = NewRequests}, - [{{timeout, ID}, request_timeout(), request}]}; -connected(cast, {notify, Method, Type, Params}, _Data) -> - Payload = grisp_connect_api:notify(Method, Type, Params), - grisp_connect_ws:send(Payload), +connected(info, {conn, Conn, Msg}, Data = #data{conn = Conn}) -> + handle_connection_message(Data, Msg); +connected({call, From}, {request, Method, Type, Params}, Data) -> + Data2 = conn_post(Data, Method, Type, Params, + fun(D, R) -> gen_statem:reply(From, {ok, R}), D end, + fun(D, _, C, _, _) -> gen_statem:reply(From, {error, C}), D end), + {keep_state, Data2}; +connected(cast, {notify, Method, Type, Params}, Data) -> + conn_notify(Data, Method, Type, Params), keep_state_and_data; ?HANDLE_COMMON. @@ -164,14 +211,19 @@ when State =/= connected -> handle_common(cast, {notify, _Method, _Type, _Params}, _State, _Data) -> % We ignore notifications sent while disconnected keep_state_and_data; -handle_common({timeout, ID}, request, _, #data{requests = Requests} = Data) -> - Caller = maps:get(ID, Requests), - {keep_state, - Data#data{requests = maps:remove(ID, Requests)}, - [{reply, Caller, {error, timeout}}]}; handle_common(info, reboot, _, _) -> init:stop(), keep_state_and_data; +handle_common(info, {'EXIT', Conn, Reason}, _State, + Data = #data{conn = Conn, retry_count = RetryCount}) -> + % The connection process died + ?GRISP_WARN("The connection to grisp.io died: ~p", [Reason], + #{event => connection_failed, reason => Reason}), + {next_state, waiting_ip, conn_died(Data#data{retry_count = RetryCount + 1})}; +handle_common(info, {conn, Conn, Msg}, State, _Data) -> + ?LOG_DEBUG("Received message from unknown connection ~p in state ~w: ~p", + [Conn, State, Msg]), + keep_state_and_data; handle_common(cast, Cast, _, _) -> error({unexpected_cast, Cast}); handle_common({call, _}, Call, _, _) -> @@ -183,22 +235,113 @@ handle_common(info, Info, State, Data) -> data => Data}), keep_state_and_data. -% INTERNALS -------------------------------------------------------------------- - -dispatch_response(ID, Response, Requests) -> - case maps:take(ID, Requests) of - {Caller, OtherRequests} -> - Actions = [{{timeout, ID}, cancel}, {reply, Caller, Response}], - {OtherRequests, Actions}; - error -> - ?LOG_DEBUG(#{event => ?FUNCTION_NAME, reason => {missing_id, ID}, - data => Response}), - {Requests, []} + +%--- Internal Functions -------------------------------------------------------- + +generic_errors() -> [ + {device_not_linked, -1, <<"Device no linked">>}, + {token_expired, -2, <<"Token expired">>}, + {device_already_linked, -3, <<"Device already linked">>}, + {invalid_token, -4, <<"Invalid token">>}, + {grisp_updater_unavailable, -10, <<"Software update unavailable">>}, + {boot_system_not_validated, -12, <<"Boot system not validated">>}, + {validate_from_unbooted, -13, <<"Validate from unbooted">>} +]. + +handle_connection_message(_Data, {response, _Result, #{on_result := undefined}}) -> + keep_state_and_data; +handle_connection_message(Data, {response, Result, #{on_result := OnResult}}) -> + {keep_state, OnResult(Data, Result)}; +handle_connection_message(_Data, {remote_error, Code, Msg, _ErrorData, + #{on_error := undefined}}) -> + ?LOG_WARNING("Unhandled remote request error ~w: ~s", [Code, Msg]), + keep_state_and_data; +handle_connection_message(Data, {remote_error, Code, Msg, ErrorData, + #{on_error := OnError}}) -> + {keep_state, OnError(Data, remote, Code, Msg, ErrorData)}; + +handle_connection_message(_Data, {local_error, Reason, + #{on_error := undefined}}) -> + ?LOG_WARNING("Unhandled local request error ~w", [Reason]), + keep_state_and_data; +handle_connection_message(Data, {local_error, Reason, + #{on_error := OnError}}) -> + {keep_state, OnError(Data, local, Reason, undefined, undefined)}; +handle_connection_message(Data, Msg) -> + case grisp_connect_api:handle_msg(Msg) of + ok -> keep_state_and_data; + {reply, Result, ReqRef} -> + conn_reply(Data, Result, ReqRef), + keep_state_and_data + end. + +% Connection Functions + +conn_start(Data = #data{conn = undefined, + domain = Domain, + port = Port, + ws_path = WsPath, + ws_transport = WsTransport}) -> + WsPingTimeout = ?ENV(ws_ping_timeout, V =:= infinity orelse is_integer(V)), + WsReqTimeout = ?ENV(ws_request_timeout, V =:= infinity orelse is_integer(V)), + ConnTransport = case WsTransport of + tcp -> tcp; + tls -> {tls, grisp_cryptoauth_tls:options(Domain)} + end, + ErrorList = persistent_term:get({?MODULE, self()}), + ConnOpts = #{ + domain => Domain, + port => Port, + transport => ConnTransport, + path => WsPath, + errors => ErrorList, + ping_timeout => WsPingTimeout, + request_timeout => WsReqTimeout + }, + case grisp_connect_connection:start_link(self(), ConnOpts) of + {error, _Reason} = Error -> Error; + {ok, Conn} -> {ok, Data#data{conn = Conn}} + end. + +% Safe to call in any state +conn_close(Data = #data{conn = undefined}, _Reason) -> + Data; +conn_close(Data = #data{conn = Conn}, _Reason) -> + grisp_connect_log_server:stop(), + catch grisp_connect_connection:disconnect(Conn), + Data#data{conn = undefined}. + +% Safe to call in any state +conn_died(Data) -> + grisp_connect_log_server:stop(), + Data#data{conn = undefined}. + +-spec conn_post(data(), grisp_connect_connection:method(), atom(), map(), + undefined | on_result_fun(), undefined | on_error_fun()) + -> data(). +conn_post(Data = #data{conn = Conn}, Method, Type, Params, OnResult, OnError) + when Conn =/= undefined -> + ReqCtx = #{on_result => OnResult, on_error => OnError}, + Params2 = maps:put(type, Type, Params), + try grisp_connect_connection:post(Conn, Method, Params2, ReqCtx) of + _ -> Data + catch + _:Reason when OnError =/= undefined -> + OnError(Data, local, Reason, undefined, undefined); + _:_ -> + Data end. -request_timeout() -> - {ok, V} = application:get_env(grisp_connect, ws_requests_timeout), - V. +conn_notify(#data{conn = Conn}, Method, Type, Params) + when Conn =/= undefined -> + Params2 = maps:put(type, Type, Params), + catch grisp_connect_connection:notify(Conn, Method, Params2), + ok. + +conn_reply(#data{conn = Conn}, Result, ReqRef) + when Conn =/= undefined -> + catch grisp_connect_connection:reply(Conn, Result, ReqRef), + ok. % IP check functions diff --git a/src/grisp_connect_connection.erl b/src/grisp_connect_connection.erl new file mode 100644 index 0000000..14dec02 --- /dev/null +++ b/src/grisp_connect_connection.erl @@ -0,0 +1,741 @@ +%% @doc JSONRpc 2.0 Websocket connection +-module(grisp_connect_connection). + +-behaviour(gen_server). + +-include("grisp_connect_internal.hrl"). + +-import(grisp_connect_utils, [as_bin/1]). +-import(grisp_connect_utils, [parse_method/1]). +-import(grisp_connect_utils, [format_method/1]). + +% API Functions +-export([start_link/2]). +-export([request/3]). +-export([post/4]). +-export([notify/3]). +-export([reply/3]). +-export([error/5]). +-export([disconnect/1]). + +% Behaviour gen_server Callbacks +-export([init/1]). +-export([handle_call/3]). +-export([handle_cast/2]). +-export([handle_info/2]). +-export([terminate/2]). + + +%--- Types --------------------------------------------------------------------- + +-record(batch, { + bref :: reference(), + refcount :: pos_integer(), + responses :: list() +}). + +-record(inbound_req, { + method :: method(), + id :: binary() | integer(), + bref :: undefined | reference() % Set if part of a batch +}). + +-record(outbound_req, { + method :: method(), + id :: binary() | integer(), + tref :: undefined | reference(), + from :: undefined | gen_server:from(), + ctx :: undefined | term() +}). + +-record(state, { + handler :: pid(), + uri :: iodata(), + domain :: binary(), + port :: inet:port_number(), + path :: binary(), + ping_timeout :: infinity | pos_integer(), + request_timeout :: infinity | pos_integer(), + batches = #{} :: #{reference() => #batch{}}, + inbound = #{} :: #{binary() | integer() => #inbound_req{}}, + outbound = #{} :: #{binary() | integer() => #outbound_req{}}, + gun_pid :: undefined | pid(), + gun_ref :: undefined | reference(), + ws_stream :: undefined | gun:stream_ref(), + connected = false :: boolean(), + ping_tref :: undefined | reference() +}). + +-type error_mapping() :: {atom(), integer(), binary()}. +-type method() :: atom() | binary() | [atom() | binary()]. +-type start_options() :: #{ + domain := atom() | string() | binary(), + port := inet:port_number(), + %FIXME: dialyzer do no like ssl:tls_client_option(), maybe some erlang version issue + transport := tcp | tls | {tls, TlsOpts :: list()}, + path := atom() | string() | binary(), + errors => [error_mapping()], + ping_timeout => infinity | pos_integer(), + request_timeout => infinity | pos_integer() +}. + +% Type specfication of the messages that are sent to the handler: +-type handler_messages() :: + {conn, pid(), connected} + | {conn, pid(), {request, method(), Params :: map() | list(), ReqRef :: binary() | integer()}} + | {conn, pid(), {notification, method(), Params :: map() | list()}} + | {conn, pid(), {response, Result :: term(), Ctx :: term()}} + | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term()}} + | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term(), Ctx :: term()}} + | {conn, pid(), {local_error, Reason:: atom(), Ctx :: term()}}. + +-export_type([error_mapping/0, method/0, handler_messages/0]). + + +%--- Macros -------------------------------------------------------------------- + +-define(ENABLE_TRACE, false). +-if(?ENABLE_TRACE =:= true). +-define(TRACE_OUTPUT(ARG), ?GRISP_DEBUG("<<<<<<<<<< ~s", [ARG])). +-define(TRACE_INPUT(ARG), ?GRISP_DEBUG(">>>>>>>>>> ~s", [ARG])). +-else. +-define(TRACE_OUTPUT(ARG), ok). +-define(TRACE_INPUT(ARG), ok). +-endif. + +-define(DEFAULT_PING_TIMEOUT, 60_000). +-define(DEFAULT_REQUEST_TIMEOUT, 5_000). +-define(DEFAULT_TRANSPORT, tcp). + +-define(DEFAULT_JSONRPC_ERRORS, [ + {invalid_json, -32700, <<"Parse error">>}, + {invalid_request, -32600, <<"Invalid Request">>}, + {method_not_found, -32601, <<"Method not found">>}, + {invalid_params, -32602, <<"Invalid parameters">>}, + {internal_error, -32603, <<"Internal error">>} +]). + + +%--- API Functions ------------------------------------------------------------- + +-spec start_link(Handler :: pid(), Options :: start_options()) -> + {ok, Conn :: pid()} | {error, Reason :: term()}. +start_link(Handler, Opts = #{domain := _, port := _, path := _}) -> + gen_server:start_link(?MODULE, [Handler, Opts], []). + +-spec request(Conn :: pid(), Method :: method(), Params :: map()) -> + {ok, Result :: term()} | {error, timeout} | {error, not_connected} + | {error, Code :: integer(), + Message :: undefined | binary(), Data :: term()}. +request(Conn, Method, Params) -> + case gen_server:call(Conn, {request, parse_method(Method), Params}) of + {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack); + Other -> Other + end. + +-spec post(Conn :: pid(), Method :: method(), Params :: map(), + ReqCtx :: term()) -> ok | {error, not_connected}. +post(Conn, Method, Params, ReqCtx) -> + case gen_server:call(Conn, {post, parse_method(Method), Params, ReqCtx}) of + {ok, CallResult} -> CallResult; + {error, _Reason} = Error -> Error; + {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + end. + +-spec notify(Conn :: pid(), Method :: method(), Params :: map()) -> + ok | {error, not_connected}. +notify(Conn, Method, Params) -> + case gen_server:call(Conn, {notify, parse_method(Method), Params}) of + {ok, CallResult} -> CallResult; + {error, _Reason} = Error -> Error; + {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + end. + +-spec reply(Conn :: pid(), Result :: any(), ReqRef :: binary()) -> + ok | {error, not_connected}. +reply(Conn, Result, ReqRef) -> + case gen_server:call(Conn, {reply, Result, ReqRef}) of + {ok, CallResult} -> CallResult; + {error, _Reason} = Error -> Error; + {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + end. + +-spec error(Conn :: pid(), Code :: atom() | integer(), + Message :: undefined | binary(), Data :: term(), + ReqRef :: undefined | binary()) -> + ok | {error, not_connected}. +error(Conn, Code, Message, Data, ReqRef) -> + case gen_server:call(Conn, {error, Code, Message, Data, ReqRef}) of + {ok, CallResult} -> CallResult; + {error, _Reason} = Error -> Error; + {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + end. + +-spec disconnect(Conn :: pid()) -> ok. +disconnect(Conn) -> + gen_server:call(Conn, disconnect). + + +%--- Behaviour gen_server Callbacks -------------------------------------------- + +init([Handler, Opts]) -> + process_flag(trap_exit, true), % To ensure terminate/2 is called + #{domain := Domain, port := Port, path := Path} = Opts, + PingTimeout = maps:get(ping_timeout, Opts, ?DEFAULT_PING_TIMEOUT), + ReqTimeout = maps:get(request_timeout, Opts, ?DEFAULT_REQUEST_TIMEOUT), + Transport = maps:get(transport, Opts, ?DEFAULT_TRANSPORT), + State = #state{ + handler = Handler, + uri = format_ws_uri(Transport, Domain, Port, Path), + domain = as_bin(Domain), + port = Port, + path = as_bin(Path), + ping_timeout = PingTimeout, + request_timeout = ReqTimeout + }, + index_errors(?DEFAULT_JSONRPC_ERRORS), + index_errors(maps:get(errors, Opts, [])), + case connection_start(State, Transport) of + {ok, _State2} = Result -> Result; + {error, Reason} -> {stop, Reason} + end. + +handle_call({request, Method, _Params}, _From, + State = #state{connected = false}) -> + ?GRISP_INFO("Request ~s performed while disconnected", + [format_method(Method)], + #{event => rpc_request_error, method => Method, + reason => not_connected}), + {reply, {error, not_connected}, State}; +handle_call({request, Method, Params}, From, State) -> + try send_request(State, Method, Params, From, undefined) of + State2 -> {noreply, State2} + catch + C:badarg:S -> {reply, {exception, C, badarg, S}, State}; + C:R:S -> {stop, R, {exception, C, R, S}, State} + end; +handle_call({post, Method, _Params, ReqCtx}, _From, + State = #state{handler = Handler, connected = false}) -> + ?GRISP_INFO("Request ~s posted while disconnected", + [format_method(Method)], + #{event => rpc_post_error, method => Method, + reason => not_connected}), + % Notify the handler anyway so it doesn't have to make a special case + Handler ! {conn, self(), {local_error, not_connected, ReqCtx}}, + {reply, {error, not_connected}, State}; +handle_call({post, Method, Params, ReqCtx}, _From, State) -> + try send_request(State, Method, Params, undefined, ReqCtx) of + State2 -> {reply, {ok, ok}, State2} + catch + C:badarg:S -> {reply, {exception, C, badarg, S}, State}; + C:R:S -> {stop, R, {exception, C, R, S}, State} + end; +handle_call({notify, Method, _Params}, _From, + State = #state{connected = false}) -> + ?GRISP_INFO("Notification ~s posted while disconnected", + [format_method(Method)], + #{event => rpc_notify_error, method => Method, + reason => not_connected}), + {noreply, State}; +handle_call({notify, Method, Params}, _From, State) -> + try send_notification(State, Method, Params) of + State2 -> {reply, {ok, ok}, State2} + catch + C:badarg:S -> {reply, {exception, C, badarg, S}, State}; + C:R:S -> {stop, R, {exception, C, R, S}, State} + end; +handle_call({reply, _Result, ReqRef}, _From, + State = #state{connected = false}) -> + ?GRISP_INFO("Reply to ~s posted while disconnected", + [inbound_req_tag(State, ReqRef)], + #{event => rpc_reply_error, ref => ReqRef, + method => inbound_method(State, ReqRef), + reason => not_connected}), + {reply, {error, not_connected}, State}; +handle_call({reply, Result, ReqRef}, _From, State) -> + try send_response(State, Result, ReqRef) of + State2 -> {reply, {ok, ok}, State2} + catch + C:badarg:S -> {reply, {exception, C, badarg, S}, State}; + C:R:S -> {stop, R, {exception, C, R, S}, State} + end; +handle_call({error, Code, _Message, _Data, undefined}, _From, + State = #state{connected = false}) -> + ?GRISP_INFO("Error ~w posted while disconnected", [Code], + #{event => rpc_error_error, code => Code, + reason => not_connected}), + {reply, {error, not_connected}, State}; +handle_call({error, Code, _Message, _Data, ReqRef}, _From, + State = #state{connected = false}) -> + ?GRISP_INFO("Error ~w to ~s posted while disconnected", + [Code, inbound_req_tag(State, ReqRef)], + #{event => rpc_error_error, code => Code, ref => ReqRef, + reason => not_connected}), + {reply, {error, not_connected}, State}; +handle_call({error, Code, Message, Data, ReqRef}, _From, State) -> + try send_error(State, Code, Message, Data, ReqRef) of + State2 -> {reply, {ok, ok}, State2} + catch + C:badarg:S -> {reply, {exception, C, badarg, S}, State}; + C:R:S -> {stop, R, {exception, C, R, S}, State} + end; +handle_call(disconnect, _From, State) -> + try connection_close(State) of + State2 -> {reply, {ok, ok}, State2} + catch + C:R:S -> {stop, R, {exception, C, R, S}, State} + end; +handle_call(Call, From, State) -> + ?GRISP_ERROR("Unexpected call from ~p to ~s: ~p", [From, ?MODULE, Call], + #{event => unexpected_call, from => From, message => Call}), + {stop, {unexpected_call, Call}, {error, unexpected_call}, State}. + +handle_cast(Cast, State) -> + Reason = {unexpected_cast, Cast}, + ?GRISP_ERROR("Unexpected cast to ~s: ~p", [?MODULE, Cast], + #{event => unexpected_cast, message => Cast}), + {stop, Reason, State}. + +handle_info({gun_up, GunPid, _}, State = #state{gun_pid = GunPid}) -> + ?GRISP_DEBUG("Connection to ~s established", + [State#state.uri], + #{event => ws_connection_enstablished, uri => State#state.uri}), + {noreply, connection_upgrade(State)}; +handle_info({gun_up, Pid, http} = _Msg, State = #state{gun_pid = GunPid}) -> + ?GRISP_DEBUG("Ignoring unexpected gun_up message" + " from pid ~p, current pid is ~p", [Pid, GunPid], + #{event => unexpected_gun_message, message => _Msg}), + {noreply, State}; +handle_info({gun_upgrade, Pid, Stream, [<<"websocket">>], _}, + State = #state{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_DEBUG("Connection to ~s upgraded to websocket", [State#state.uri], + #{event => ws_upgraded, uri => State#state.uri}), + {noreply, connection_established(State)}; +handle_info({gun_response, Pid, Stream, _, Status, _Headers}, + State = #state{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_INFO("Connection to ~s failed to upgrade to websocket: ~p", + [State#state.uri, Status], + #{event => ws_upgrade_failed, uri => State#state.uri, + status => Status}), + {stop, ws_upgrade_failed, connection_close(State)}; +handle_info({gun_ws, Pid, Stream, ping}, + State = #state{gun_pid = Pid, ws_stream = Stream}) -> + {noreply, schedule_ping_timeout(State)}; +handle_info({gun_ws, Pid, Stream, {text, Text}}, + State = #state{gun_pid = Pid, ws_stream = Stream}) -> + {noreply, process_data(State, Text)}; +handle_info({gun_ws, Pid, Stream, close}, + State = #state{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_INFO("Connection to ~s closed without code", [State#state.uri], + #{event => ws_stream_closed, uri => State#state.uri}), + {stop, normal, connection_closed(State, closed)}; +handle_info({gun_ws, Pid, Stream, {close, Code, Message}}, + State = #state{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_INFO("Connection to ~s closed: ~s (~w)", + [State#state.uri, Message, Code], + #{event => ws_stream_closed, uri => State#state.uri, + code => Code, reason => Message}), + {stop, normal, connection_closed(State, closed)}; +handle_info({gun_error, Pid, _Stream, Reason}, + State = #state{gun_pid = Pid}) -> + ?GRISP_INFO("Connection to ~s got an error: ~p", + [State#state.uri, Reason], + #{event => ws_error, uri => State#state.uri, + reason => Reason}), + {stop, Reason, connection_close(State)}; +handle_info({gun_down, Pid, ws, Reason, [Stream]}, + State = #state{gun_pid = Pid, ws_stream = Stream}) + when Reason =:= closed; Reason =:= {error, closed}; Reason =:= normal -> + ?GRISP_INFO("Connection to ~s was closed by the server", [State#state.uri], + #{event => ws_closed_by_peer, uri => State#state.uri, + reason => closed}), + {stop, normal, connection_close(State)}; +handle_info({'DOWN', _, process, Pid, Reason}, + State = #state{gun_pid = Pid}) -> + ?GRISP_INFO("gun process of the connection to ~s crashed: ~p", + [State#state.uri, Reason], + #{event => ws_gun_crash, uri => State#state.uri, + reason => Reason}), + {stop, Reason, connection_closed(State, gun_crashed)}; +handle_info(ping_timeout, State) -> + ?GRISP_INFO("Connection to ~s timed out", [State#state.uri], + #{event => ws_ping_timeout, uri => State#state.uri}), + {stop, normal, connection_close(State)}; +handle_info({outbound_timeout, ReqRef}, State) -> + ?GRISP_INFO("Request ~s time to ~s timed out", + [outbound_req_tag(State, ReqRef), State#state.uri], + #{event => rpc_request_timeout_error, uri => State#state.uri, + ref => ReqRef, method => outbound_method(State, ReqRef)}), + {noreply, outbound_timeout(State, ReqRef)}; +handle_info(Msg, State) -> + ?GRISP_WARN("Unexpected info message to ~s: ~p", + [?MODULE, Msg], + #{event => unexpected_info, message => Msg}), + {noreply, State}. + +terminate(_Reason, State) -> + connection_close(State), + persistent_term:erase({?MODULE, self(), tags}), + persistent_term:erase({?MODULE, self(), codes}), + ok. + + +%--- INTERNAL FUNCTION --------------------------------------------------------- + +format_ws_uri(Transport, Domain, Port, Path) -> + Proto = case Transport of + tcp -> <<"ws">>; + tls -> <<"wss">>; + {tls, _} -> <<"wss">> + end, + ?FORMAT("~s://~s:~w~s", [Proto, Domain, Port, Path]). + +make_reqref() -> + list_to_binary(integer_to_list(erlang:unique_integer())). + +send_after(infinity, _Message) -> undefined; +send_after(Timeout, Message) -> + erlang:send_after(Timeout, self(), Message). + +cancel_timer(undefined) -> ok; +cancel_timer(TRef) -> + erlang:cancel_timer(TRef). + +schedule_ping_timeout(State = #state{ping_timeout = Timeout}) -> + State2 = cancel_ping_timeout(State), + TRef = send_after(Timeout, ping_timeout), + State2#state{ping_tref = TRef}. + +cancel_ping_timeout(State = #state{ping_tref = undefined}) -> + State; +cancel_ping_timeout(State = #state{ping_tref = TRef}) -> + cancel_timer(TRef), + State#state{ping_tref = undefined}. + +% Returns either the method of the outbound request or its reference +% as a binary if not found. Only mean for logging. +outbound_req_tag(#state{outbound = ReqMap}, ReqRef) -> + case maps:find(ReqRef, ReqMap) of + error -> as_bin(ReqRef); + {ok, #outbound_req{method = Method}} -> format_method(Method) + end. + +outbound_method(#state{outbound = ReqMap}, ReqRef) -> + case maps:find(ReqRef, ReqMap) of + error -> undefined; + {ok, #outbound_req{method = Method}} -> Method + end. + +% Returns either the method of the inbound request or its reference +% as a binary if not found. Only mean for logging. +inbound_req_tag(#state{inbound = ReqMap}, ReqRef) -> + case maps:find(ReqRef, ReqMap) of + error -> as_bin(ReqRef); + {ok, #inbound_req{method = Method}} -> format_method(Method) + end. + +inbound_method(#state{inbound = ReqMap}, ReqRef) -> + case maps:find(ReqRef, ReqMap) of + error -> undefined; + {ok, #inbound_req{method = Method}} -> Method + end. + +index_errors(ErrorSpecs) -> + ErrorTags = persistent_term:get({?MODULE, self(), tags}, #{}), + ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), + {ErrorTags2, ErrorCodes2} = + lists:foldl(fun({Tag, Code, Msg}, {Tags, Codes}) -> + {Tags#{Tag => {Code, Msg}}, Codes#{Code => {Tag, Msg}}} + end, {ErrorTags, ErrorCodes}, ErrorSpecs), + % The error list is put in a persistent term to not add noise to the state. + persistent_term:put({?MODULE, self(), tags}, ErrorTags2), + persistent_term:put({?MODULE, self(), codes}, ErrorCodes2), + ok. + +decode_error(Code, Message) + when is_integer(Code), Message =:= undefined -> + ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), + case maps:find(Code, ErrorCodes) of + error -> {Code, undefined}; + {ok, {Tag, DefaultMessage}} -> {Tag, DefaultMessage} + end; +decode_error(Code, Message) + when is_integer(Code) -> + ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), + case maps:find(Code, ErrorCodes) of + error -> {Code, Message}; + {ok, {Tag, _DefaultMessage}} -> {Tag, Message} + end. + +encode_error(Code, Message) + when is_integer(Code), Message =:= undefined -> + ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), + case maps:find(Code, ErrorCodes) of + error -> {Code, null}; + {ok, {_Tag, DefaultMessage}} -> {Code, DefaultMessage} + end; +encode_error(Code, Message) + when is_integer(Code) -> + {Code, Message}; +encode_error(Tag, Message) + when is_atom(Tag), Message =:= undefined -> + ErrorTags = persistent_term:get({?MODULE, self(), tags}, #{}), + case maps:find(Tag, ErrorTags) of + error -> erlang:error(badarg); + {ok, {Code, DefaultMessage}} -> {Code, DefaultMessage} + end; +encode_error(Tag, Message) + when is_atom(Tag) -> + ErrorTags = persistent_term:get({?MODULE, self(), tags}, #{}), + case maps:find(Tag, ErrorTags) of + error -> erlang:error(badarg); + {ok, {Code, _DefaultMessage}} -> {Code, Message} + end. + +connection_start(State = #state{uri = Uri, domain = Domain, port = Port}, + TransportSpec) -> + BaseGunOpts = #{protocols => [http], retry => 0}, + GunOpts = case TransportSpec of + tcp -> BaseGunOpts#{transport => tcp}; + tls -> BaseGunOpts#{transport => tls}; + {tls, Opts} -> BaseGunOpts#{transport => tls, tls_opts => Opts} + end, + ?GRISP_DEBUG("Connecting to ~s", [Uri], + #{event => connecting, uri => Uri, options => GunOpts}), + case gun:open(binary_to_list(Domain), Port, GunOpts) of + {ok, GunPid} -> + GunRef = monitor(process, GunPid), + {ok, State#state{gun_pid = GunPid, gun_ref = GunRef}}; + {error, Reason} -> + ?GRISP_ERROR("Failed to open connection to ~s: ~p", [Uri, Reason], + #{event => connection_failure, uri => Uri, + reason => Reason}), + {error, Reason} + end. + +connection_upgrade(State = #state{path = Path, gun_pid = GunPid}) -> + WsStream = gun:ws_upgrade(GunPid, Path,[], #{silence_pings => false}), + State#state{ws_stream = WsStream}. + +connection_established(State = #state{handler = Handler}) -> + State2 = schedule_ping_timeout(State#state{connected = true}), + Handler ! {conn, self(), connected}, + State2. + +connection_close(State = #state{gun_pid = GunPid, gun_ref = GunRef}) + when GunPid =/= undefined, GunRef =/= undefined -> + demonitor(GunRef), + gun:shutdown(GunPid), + State2 = cancel_ping_timeout(State), + State3 = requests_error(State2, not_connected), + State3#state{gun_pid = undefined, gun_ref = undefined, + ws_stream = undefined, connected = false}; +connection_close(State) -> + State2 = requests_error(State, not_connected), + State2#state{connected = false}. + +connection_closed(State, Reason) -> + State2 = cancel_ping_timeout(State), + State3 = requests_error(State2, Reason), + State3#state{gun_pid = undefined, gun_ref = undefined, + ws_stream = undefined, connected = false}. + +send_request(State, Method, Params, From, Ctx) -> + {ReqRef, State2} = outbound_add(State, Method, From, Ctx), + Msg = {request, format_method(Method), Params, ReqRef}, + send_packet(State2, Msg). + +send_notification(State, Method, Params) -> + Msg = {notification, format_method(Method), Params}, + send_packet(State, Msg). + +send_response(State, Result, ReqRef) -> + Msg = {result, Result, ReqRef}, + inbound_response(State, Msg, ReqRef). + +send_error(State, Code, Message, Data, ReqRef) -> + {Code2, Message2} = encode_error(Code, Message), + Msg = {error, Code2, Message2, Data, ReqRef}, + case ReqRef of + undefined -> send_packet(State, Msg); + _ -> inbound_response(State, Msg, ReqRef) + end. + +send_packet(State = #state{gun_pid = GunPid, ws_stream = Stream}, Packet) -> + Payload = grisp_connect_jsonrpc:encode(Packet), + ?TRACE_OUTPUT(Payload), + gun:ws_send(GunPid, Stream, {text, Payload}), + State. + +process_data(State = #state{batches = BatchMap}, Data) -> + ?TRACE_INPUT(Data), + DecodedData = grisp_connect_jsonrpc:decode(Data), + case DecodedData of + Messages when is_list(Messages) -> + BatchRef = make_ref(), + case process_messages(State, BatchRef, 0, [], Messages) of + {ReqCount, Replies, State2} when ReqCount > 0 -> + Batch = #batch{bref = BatchRef, refcount = ReqCount, + responses = Replies}, + State2#state{batches = BatchMap#{BatchRef => Batch}}; + {_ReqCount, [], State2} -> + State2; + {_ReqCount, [_|_] = Replies, State2} -> + % All the requests got a reply right away + send_packet(State2, Replies) + end; + Message when is_tuple(Message) -> + case process_messages(State, undefined, 0, [], [Message]) of + {_, [Reply], State2} -> + send_packet(State2, Reply); + {_, [], State2} -> + State2 + end + end. + +process_messages(State, _BatchRef, ReqCount, Replies, []) -> + {ReqCount, lists:reverse(Replies), State}; +process_messages(State, BatchRef, ReqCount, Replies, + [{decoding_error, _, _, _, _} = Error | Rest]) -> + process_messages(State, BatchRef, ReqCount, [Error | Replies], Rest); +process_messages(State, BatchRef, ReqCount, Replies, + [{request, RawMethod, Params, ReqRef} | Rest]) -> + Method = parse_method(RawMethod), + State2 = process_request(State, BatchRef, Method, Params, ReqRef), + process_messages(State2, BatchRef, ReqCount + 1, Replies, Rest); +process_messages(State, BatchRef, ReqCount, Replies, + [{notification, RawMethod, Params} | Rest]) -> + Method = parse_method(RawMethod), + State2 = process_notification(State, Method, Params), + process_messages(State2, BatchRef, ReqCount, Replies, Rest); +process_messages(State, BatchRef, ReqCount, Replies, + [{result, Result, ReqRef} | Rest]) -> + {State2, Replies2} = process_response(State, Replies, Result, ReqRef), + process_messages(State2, BatchRef, ReqCount, Replies2, Rest); +process_messages(State, BatchRef, ReqCount, Replies, + [{error, Code, Message, Data, ReqRef} | Rest]) -> + {State2, Replies2} = process_error(State, Replies, Code, + Message, Data, ReqRef), + process_messages(State2, BatchRef, ReqCount, Replies2, Rest). + +process_request(State = #state{handler = Handler}, + BatchRef, Method, Params, ReqRef) -> + Handler ! {conn, self(), {request, Method, Params, ReqRef}}, + inbound_add(State, BatchRef, Method, ReqRef). + +process_notification(State = #state{handler = Handler}, Method, Params) -> + Handler ! {conn, self(), {notification, Method, Params}}, + State. + +process_response(State = #state{handler = Handler}, Replies, Result, ReqRef) -> + case outbound_del(State, ReqRef) of + {error, not_found} -> + %FIXME: Not sure what error should be returned... + Error = {invalid_request, -32600, <<"Result for unknown request">>}, + {State, [Error | Replies]}; + {ok, _Method, undefined, Ctx, State2} -> + Handler ! {conn, self(), {response, Result, Ctx}}, + {State2, Replies}; + {ok, _, From, _, State2} -> + gen_server:reply(From, {ok, Result}), + {State2, Replies} + end. + +process_error(State = #state{handler = Handler}, + Replies, Code, Message, Data, undefined) -> + {Code2, Message2} = decode_error(Code, Message), + Handler ! {conn, self(), {remote_error, Code2, Message2, Data}}, + {State, Replies}; +process_error(State = #state{handler = Handler}, + Replies, Code, Message, Data, ReqRef) -> + case outbound_del(State, ReqRef) of + {error, not_found} -> + %FIXME: Not sure what error should be returned... + Error = {invalid_request, -32600, <<"Error for unknown request">>}, + {State, [Error | Replies]}; + {ok, _Method, undefined, Ctx, State2} -> + {Code2, Message2} = decode_error(Code, Message), + Handler ! {conn, self(), {remote_error, Code2, Message2, Data, Ctx}}, + {State2, Replies}; + {ok, _, From, _, State2} -> + {Code2, Message2} = decode_error(Code, Message), + gen_server:reply(From, {remote_error, Code2, Message2, Data}), + {State2, Replies} + end. + +inbound_response(State = #state{batches = BatchMap, inbound = ReqMap}, + Message, ReqRef) -> + case maps:take(ReqRef, ReqMap) of + error -> + ?GRISP_ERROR("Ask to send a response to the unknown request ~p", + [ReqRef], + #{event => internal_error, ref => ReqRef, + reason => unknown_request}), + State; + {#inbound_req{bref = undefined}, ReqMap2} -> + % Not part of a batch response + send_packet(State#state{inbound = ReqMap2}, Message); + {#inbound_req{bref = BatchRef}, ReqMap2} -> + % The batch must exists + case maps:find(BatchRef, BatchMap) of + {ok, #batch{refcount = 1, responses = Responses}} -> + % This is the last message of the batch + BatchMap2 = maps:remove(BatchRef, BatchMap), + send_packet(State#state{batches = BatchMap2, + inbound = ReqMap2}, + [Message | Responses]); + {ok, Batch = #batch{refcount = RefCount, + responses = Responses}} -> + Batch2 = Batch#batch{refcount = RefCount - 1, + responses = [Message | Responses]}, + BatchMap2 = BatchMap#{BatchRef => Batch2}, + State#state{batches = BatchMap2, inbound = ReqMap2} + end + end. + +inbound_add(State = #state{inbound = ReqMap}, BatchRef, Method, ReqRef) -> + %TODO: Should we add a timeout for inbound requests ? + Req = #inbound_req{method = Method, id = ReqRef, bref = BatchRef}, + State#state{inbound = ReqMap#{ReqRef => Req}}. + +outbound_add(State = #state{request_timeout = Timeout, outbound = ReqMap}, + Method, From, Ctx) -> + ReqRef = make_reqref(), + TRef = send_after(Timeout, {outbound_timeout, ReqRef}), + Req = #outbound_req{id = ReqRef, method = Method, tref = TRef, + from = From, ctx = Ctx}, + {ReqRef, State#state{outbound = ReqMap#{ReqRef => Req}}}. + +outbound_del(State = #state{outbound = ReqMap}, ReqRef) -> + case maps:find(ReqRef, ReqMap) of + error -> {error, not_found}; + {ok, #outbound_req{method = Method, tref = TRef, + from = From, ctx = Ctx}} -> + cancel_timer(TRef), + State2 = State#state{outbound = maps:remove(ReqRef, ReqMap)}, + {ok, Method, From, Ctx, State2} + end. + +outbound_timeout(State = #state{handler = Handler}, ReqRef) -> + case outbound_del(State, ReqRef) of + {error, not_found} -> + ?GRISP_WARN("Timeout for unknown request ~s", [ReqRef], + #{event => internal_error, ref => ReqRef, + reason => unknown_request}), + State; + {ok, _Method, undefined, Ctx, State2} -> + Handler ! {conn, self(), {local_error, timeout, Ctx}}, + State2; + {ok, _, From, _, State2} -> + gen_server:reply(From, {error, timeout}), + State2 + end. + +requests_error(State = #state{handler = Handler, outbound = ReqMap}, Reason) -> + maps:foreach(fun + (_, #outbound_req{from = undefined, ctx = Ctx}) -> + Handler ! {conn, self(), {local_error, Reason, Ctx}}; + (_, #outbound_req{from = From, ctx = undefined}) -> + gen_server:reply(From, {error, Reason}) + end, ReqMap), + State#state{outbound = #{}}. diff --git a/src/grisp_connect_internal.hrl b/src/grisp_connect_internal.hrl new file mode 100644 index 0000000..489c9a7 --- /dev/null +++ b/src/grisp_connect_internal.hrl @@ -0,0 +1,25 @@ +-ifndef(GRISP_CONNECT_INTERNAL_HRL). +-define(GRISP_CONNECT_INTERNAL_HRL, true). + +-include_lib("kernel/include/logger.hrl"). + +-define(FORMAT(FMT, ARGS), iolist_to_binary(io_lib:format(FMT, ARGS))). + +-define(GRISP_DEBUG(FMT, ARGS), + ?LOG_DEBUG(FMT, ARGS)). +-define(GRISP_DEBUG(FMT, ARGS, REPORT), + ?LOG_DEBUG(REPORT#{description => ?FORMAT(FMT, ARGS)})). +-define(GRISP_INFO(FMT, ARGS), + ?LOG_INFO(FMT, ARGS)). +-define(GRISP_INFO(FMT, ARGS, REPORT), + ?LOG_INFO(REPORT#{description => ?FORMAT(FMT, ARGS)})). +-define(GRISP_WARN(FMT, ARGS), + ?LOG_WARNING(FMT, ARGS)). +-define(GRISP_WARN(FMT, ARGS, REPORT), + ?LOG_WARNING(REPORT#{description => ?FORMAT(FMT, ARGS)})). +-define(GRISP_ERROR(FMT, ARGS), + ?LOG_ERROR(FMT, ARGS)). +-define(GRISP_ERROR(FMT, ARGS, REPORT), + ?LOG_ERROR(REPORT#{description => ?FORMAT(FMT, ARGS)})). + +-endif. % GRISP_CONNECT_INTERNAL_HRL diff --git a/src/grisp_connect_jsonrpc.erl b/src/grisp_connect_jsonrpc.erl index f37d1f8..5029387 100644 --- a/src/grisp_connect_jsonrpc.erl +++ b/src/grisp_connect_jsonrpc.erl @@ -66,7 +66,7 @@ decode(Data) -> case json_to_term(iolist_to_binary(Data)) of [] -> - [{decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}]; + {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}; Messages when is_list(Messages) -> [unpack(M) || M <- Messages]; Message when is_map(Message) -> @@ -120,9 +120,9 @@ unpack(#{error := #{code := Code, message := Message}, id := ID} = M) when ?is_valid(M), is_integer(Code) -> {error, Code, as_bin(Message), undefined, as_id(ID)}; unpack(#{id := ID}) -> - {decoding_error, -32600, <<"Invalid request">>, undefined, as_id(ID)}; + {decoding_error, -32600, <<"Invalid Request">>, undefined, as_id(ID)}; unpack(_M) -> - {decoding_error, -32600, <<"Invalid request">>, undefined, undefined}. + {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}. pack({request, Method, undefined, ID}) when is_binary(Method) orelse is_atom(Method), ?is_id(ID) -> diff --git a/src/grisp_connect_logger_bin.erl b/src/grisp_connect_logger_bin.erl index a523588..95316b7 100644 --- a/src/grisp_connect_logger_bin.erl +++ b/src/grisp_connect_logger_bin.erl @@ -34,9 +34,6 @@ -export([handle_info/3]). -export([terminate/3]). -% % Log filtering functions --export([filter_out/2]). - % Internal Callbacks -export([queue_ctrl_init/1]). -export([queue_ctrl_loop/1]). @@ -150,26 +147,6 @@ handle_info(_, _, State) -> terminate(_Name, _Reason, _State) -> ok. -% %--- Log Filtering Functions --------------------------------------------------- - -filter_out(LogEvent = #{meta := Meta}, Mfa = {M, F, A}) - when is_atom(M), is_atom(F), is_integer(A) -> - case Meta of - #{mfa := Mfa} -> stop; - _ -> LogEvent - end; -filter_out(LogEvent = #{meta := Meta}, {M, F}) - when is_atom(M), is_atom(F) -> - case Meta of - #{mfa := {M, F, _}} -> stop; - _ -> LogEvent - end; -filter_out(LogEvent = #{meta := Meta}, M) - when is_atom(M) -> - case Meta of - #{mfa := {M, _, _}} -> stop; - _ -> LogEvent - end. %--- Internal ------------------------------------------------------------------ diff --git a/src/grisp_connect_sup.erl b/src/grisp_connect_sup.erl index ff8887c..f583f29 100644 --- a/src/grisp_connect_sup.erl +++ b/src/grisp_connect_sup.erl @@ -44,7 +44,6 @@ init([]) -> %% Hence grisp_connect_log_server should be started before grisp_connect_client %% and a crash in grisp_connect_log_server should crash grisp_connect_client as well. ChildSpecs = NTP ++ [ - worker(grisp_connect_ws, []), worker(grisp_connect_log_server, []), worker(grisp_connect_client, []) ], diff --git a/src/grisp_connect_updater.erl b/src/grisp_connect_updater.erl index d8b34fa..123f7da 100644 --- a/src/grisp_connect_updater.erl +++ b/src/grisp_connect_updater.erl @@ -15,6 +15,13 @@ -export([validate/0]). -export([cancel/0]). +% Disable dialyzer warnings because of unknown dependencies +-dialyzer({nowarn_function, system_info/0}). +-dialyzer({nowarn_function, start_update/1}). +-dialyzer({nowarn_function, validate/0}). +-dialyzer({nowarn_function, cancel/0}). +-dialyzer({nowarn_function, update_info/0}). + %--- API ----------------------------------------------------------------------- diff --git a/src/grisp_connect_utils.erl b/src/grisp_connect_utils.erl new file mode 100644 index 0000000..9a73457 --- /dev/null +++ b/src/grisp_connect_utils.erl @@ -0,0 +1,45 @@ +-module(grisp_connect_utils). + +% API Functions +-export([as_bin/1]). +-export([maybe_atom/1]). +-export([parse_method/1]). +-export([format_method/1]). + + +%--- API Functions ------------------------------------------------------------- + +as_bin(Binary) when is_binary(Binary) -> Binary; +as_bin(List) when is_list(List) -> list_to_binary(List); +as_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom). + +maybe_atom(Bin) when is_binary(Bin) -> + try binary_to_existing_atom(Bin) + catch error:badarg -> Bin + end. + +parse_method(List) when is_list(List) -> check_method(List); +parse_method(Atom) when is_atom(Atom) -> [Atom]; +parse_method(Binary) when is_binary(Binary) -> + [maybe_atom(B) || B <- binary:split(Binary, <<".">>, [global])]. + +format_method(Binary) when is_binary(Binary) -> Binary; +format_method(Atom) when is_atom(Atom) -> atom_to_binary(Atom); +format_method(List) when is_list(List) -> + iolist_to_binary(lists:join(<<".">>, [as_bin(E) || E <- List])). + + +%--- Internal Functions -------------------------------------------------------- + +check_method(Method) when is_list(Method) -> + check_method(Method, Method); +check_method(_Method) -> + erlang:exit(badarg). + +check_method(Method, []) -> Method; +check_method(Method, [Atom | Rest]) when is_atom(Atom) -> + check_method(Method, Rest); +check_method(Method, [Bin | Rest]) when is_binary(Bin) -> + check_method(Method, Rest); +check_method(_Method, _Rest) -> + erlang:error(badarg). diff --git a/src/grisp_connect_ws.erl b/src/grisp_connect_ws.erl deleted file mode 100644 index 074ed46..0000000 --- a/src/grisp_connect_ws.erl +++ /dev/null @@ -1,147 +0,0 @@ -%% @doc Websocket client to connect to grisp.io --module(grisp_connect_ws). - --export([start_link/0]). --export([connect/0]). --export([connect/2]). --export([send/1]). - --behaviour(gen_server). - --export([init/1]). --export([handle_call/3]). --export([handle_cast/2]). --export([handle_info/2]). - --record(state, { - gun_pid, - gun_ref, - ws_stream, - ws_up = false, - ping_timer -}). - --include_lib("kernel/include/logger.hrl"). - -%--- API Functions ------------------------------------------------------------- - -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). - -connect() -> - {ok, Domain} = application:get_env(grisp_connect, domain), - {ok, Port} = application:get_env(grisp_connect, port), - connect(Domain, Port). - -connect(Server, Port) -> - gen_server:cast(?MODULE, {?FUNCTION_NAME, Server, Port}). - -send(Payload) -> - gen_server:cast(?MODULE, {?FUNCTION_NAME, Payload}). - -% gen_server callbacks --------------------------------------------------------- - -init([]) -> {ok, #state{}}. - -handle_call(Call, _, _) -> - error({unexpected_call, Call}). - -handle_cast({connect, Server, Port}, #state{gun_pid = undefined} = S) -> - GunOpts = #{ - protocols => [http], - transport => tls, - retry => 0, - tls_opts => grisp_cryptoauth_tls:options(Server) - }, - case gun:open(Server, Port, GunOpts) of - {ok, GunPid} -> - GunRef = monitor(process, GunPid), - {noreply, #state{gun_pid = GunPid, gun_ref = GunRef}}; - Error -> - ?LOG_ERROR(#{event => connection_failure, reason => Error}), - {noreply, S} - end; -handle_cast({connect, _Server, _Port}, S) -> - {noreply, S}; -handle_cast({send, _}, #state{ws_up = false} = S) -> - ?LOG_ERROR(#{event => ws_send, reason => ws_disconnected}), - {noreply, S}; -handle_cast({send, Payload}, #state{gun_pid = Pid, ws_stream = Stream} = S) -> - gun:ws_send(Pid, Stream, {text, Payload}), - {noreply, S}. - -handle_info({gun_up, GunPid, _}, #state{gun_pid = GunPid} = S) -> - ?LOG_INFO(#{event => connection_enstablished}), - WsStream = gun:ws_upgrade(GunPid, "/grisp-connect/ws",[], - #{silence_pings => false}), - NewS = S#state{gun_pid = GunPid, ws_stream = WsStream}, - {noreply, NewS}; -handle_info({gun_up, Pid, http}, #state{gun_pid = GunPid} = S) -> - ?LOG_WARNING("Ignoring unexpected gun_up http message" - " from pid ~p, current pid is ~p", [Pid, GunPid]), - {noreply, S}; -handle_info({gun_upgrade, Pid, Stream, [<<"websocket">>], _}, - #state{gun_pid = Pid, ws_stream = Stream} = S) -> - ?LOG_INFO(#{event => ws_upgrade}), - grisp_connect_client:connected(), - {noreply, S#state{ws_up = true, ping_timer = start_ping_timer()}}; -handle_info({gun_response, Pid, Stream, _, Status, _Headers}, - #state{gun_pid = Pid, ws_stream = Stream} = S) -> - ?LOG_ERROR(#{event => ws_upgrade_failure, status => Status}), - {noreply, shutdown_gun(S)}; -handle_info({gun_ws, Pid, Stream, ping}, - #state{gun_pid = Pid, ws_stream = Stream, - ping_timer = PingTimer} = S) -> - timer:cancel(PingTimer), - {noreply, S#state{ping_timer = start_ping_timer()}}; -handle_info({gun_ws, Pid, Stream, {text, Text}}, - #state{gun_pid = Pid, ws_stream = Stream} = S) -> - grisp_connect_client:handle_message(Text), - {noreply, S}; -handle_info({gun_ws, Pid, Stream, {close, Code, Message}}, - #state{gun_pid = Pid, ws_stream = Stream} = S) -> - ?LOG_WARNING(#{event => stream_closed, code => Code, reason => Message}), - {noreply, S}; -handle_info({gun_error, Pid, _Stream, Reason}, #state{gun_pid = Pid} = S) -> - ?LOG_ERROR(#{event => ws_closed, reason => Reason}), - grisp_connect_client:disconnected(), - {noreply, shutdown_gun(S)}; -handle_info({gun_down, Pid, ws, closed, [Stream]}, #state{gun_pid = Pid, ws_stream = Stream} = S) -> - ?LOG_WARNING(#{event => ws_closed}), - grisp_connect_client:disconnected(), - {noreply, shutdown_gun(S)}; -handle_info({gun_down, Pid, ws, normal, _}, #state{gun_pid = Pid} = S) -> - ?LOG_INFO(#{event => ws_closed, reason => normal}), - grisp_connect_client:disconnected(), - {noreply, shutdown_gun(S)}; -handle_info({'DOWN', _, process, Pid, Reason}, #state{gun_pid = Pid, - ping_timer = Tref} = S) -> - ?LOG_WARNING(#{event => gun_crash, reason => Reason}), - timer:cancel(Tref), - grisp_connect_client:disconnected(), - {noreply, disconnected_state(S)}; -handle_info(ping_timeout, S) -> - ?LOG_WARNING(#{event => ping_timeout}), - grisp_connect_client:disconnected(), - {noreply, shutdown_gun(S)}; -handle_info(M, S) -> - ?LOG_WARNING(#{event => unhandled_info, info => M, state => S}), - {noreply, S}. - -% internal functions ----------------------------------------------------------- - -shutdown_gun(#state{gun_pid = Pid, gun_ref = GunRef, - ping_timer = PingTimer} = S) -> - timer:cancel(PingTimer), - demonitor(GunRef), - gun:shutdown(Pid), - disconnected_state(S). - -start_ping_timer() -> - {ok, Timeout} = application:get_env(grisp_connect, ws_ping_timeout), - {ok, Tref} = timer:send_after(Timeout, ping_timeout), - Tref. - -disconnected_state(S) -> - S#state{gun_pid = undefined, gun_ref = undefined, - ws_up = false, ping_timer = undefined}. diff --git a/test/grisp_connect_connection_SUITE.erl b/test/grisp_connect_connection_SUITE.erl new file mode 100644 index 0000000..ee3a288 --- /dev/null +++ b/test/grisp_connect_connection_SUITE.erl @@ -0,0 +1,326 @@ +-module(grisp_connect_connection_SUITE). + +-behaviour(ct_suite). +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). +-include("grisp_connect_test.hrl"). + +-compile([export_all, nowarn_export_all]). + +-import(grisp_connect_test_async, [async_eval/1]). +-import(grisp_connect_test_async, [async_get_result/1]). + +-import(grisp_connect_test_server, [flush/0]). +-import(grisp_connect_test_server, [send_text/1]). +-import(grisp_connect_test_server, [send_jsonrpc_request/3]). +-import(grisp_connect_test_server, [send_jsonrpc_notification/2]). +-import(grisp_connect_test_server, [send_jsonrpc_result/2]). +-import(grisp_connect_test_server, [send_jsonrpc_error/3]). + + +%--- MACROS -------------------------------------------------------------------- + +-define(fmt(Fmt, Args), lists:flatten(io_lib:format(Fmt, Args))). +-define(assertConnRequest(Conn, M, P, R), fun() -> + receive {conn, Conn, {request, M, P = Result, R}} -> Result + after 1000 -> + ?assert(false, ?fmt("The client connection did not receive request ~s ~s ~s", + [??M, ??P, ??P])) + end +end()). +-define(assertConnNotification(Conn, M, P), fun() -> + receive {conn, Conn, {notification, M, P = Result}} -> Result + after 1000 -> + ?assert(false, ?fmt("The client did not receive notification ~s ~s", + [??M, ??P])) + end +end()). +-define(assertConnResponse(Conn, V, X), fun() -> + receive {conn, Conn, {response, V = Result, X}} -> Result + after 1000 -> + ?assert(false, ?fmt("The client connection did not receive response ~s ~s", + [??V, ??X])) + end +end()). +-define(assertConnRemoteError(Conn, C, M, D, X), fun() -> + receive {conn, Conn, {remote_error, C, M, D, X}} -> ok + after 1000 -> + ?assert(false, ?fmt("The client connection did not receive remote error ~s ~s ~s ~s", + [??C, ??M, ??D, ??X])) + end +end()). +-define(assertConnRemoteError(Conn, C, M, D), fun() -> + receive {conn, Conn, {remote_error, C, M, D}} -> ok + after 1000 -> + ?assert(false, ?fmt("The client connection did not receive remote error ~s ~s ~s", + [??C, ??M, ??D])) + end +end()). +-define(assertConnLocalError(Conn, R, X), fun() -> + receive {conn, Conn, {local_error, R, X}} -> ok + after 1000 -> + ?assert(false, ?fmt("The client connection did not receive local error ~s ~s", + [??R, ??X])) + end +end()). + + +%--- API ----------------------------------------------------------------------- + +all() -> + [ + F + || + {F, 1} <- ?MODULE:module_info(exports), + lists:suffix("_test", atom_to_list(F)) + ]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(gun), + Apps = grisp_connect_test_server:start(), + [{apps, Apps} | Config]. + +end_per_suite(Config) -> + [?assertEqual(ok, application:stop(App)) || App <- ?config(apps, Config)]. + +init_per_testcase(_TestCase, Config) -> + {ok, _} = application:ensure_all_started(grisp_emulation), + Config. + +end_per_testcase(_, Config) -> + ?assertEqual([], flush()), + Config. + + +%--- Tests --------------------------------------------------------------------- + +basic_server_notifications_test(_) -> + Conn = connect(), + send_jsonrpc_notification(<<"ping">>, #{foo => null}), + ?assertConnNotification(Conn, [ping], #{foo := undefined}), + send_jsonrpc_notification(<<"foo.bar.ping">>, #{}), + ?assertConnNotification(Conn, [foo, bar, ping], _), + send_jsonrpc_notification(<<"foo.bar.NotAnAtom">>, #{}), + ?assertConnNotification(Conn, [foo, bar, <<"NotAnAtom">>], _), + disconnect(Conn). + +basic_client_notifications_test(_) -> + Conn = connect(), + grisp_connect_connection:notify(Conn, ping, #{foo => undefined}), + ?receiveNotification(<<"ping">>, #{foo := null}), + grisp_connect_connection:notify(Conn, [foo, bar, ping], #{}), + ?receiveNotification(<<"foo.bar.ping">>, _), + grisp_connect_connection:notify(Conn, [foo, bar, <<"NotAnAtom">>], #{}), + ?receiveNotification(<<"foo.bar.NotAnAtom">>, _), + disconnect(Conn). + +basic_server_request_test(_) -> + Conn = connect(), + send_jsonrpc_request(<<"toto">>, #{}, 1), + ?assertConnRequest(Conn, [toto], _, 1), + grisp_connect_connection:reply(Conn, <<"spam">>, 1), + ?receiveResult(<<"spam">>, 1), + send_jsonrpc_request(<<"foo.bar.tata">>, #{}, 2), + ?assertConnRequest(Conn, [foo, bar, tata], _, 2), + grisp_connect_connection:error(Conn, error1, undefined, undefined, 2), + ?receiveError(-1, <<"Error Number 1">>, 2), + send_jsonrpc_request(<<"foo.bar.toto">>, #{}, 3), + ?assertConnRequest(Conn, [foo, bar, toto], _, 3), + grisp_connect_connection:error(Conn, error2, <<"Custom">>, undefined, 3), + ?receiveError(-2, <<"Custom">>, 3), + send_jsonrpc_request(<<"foo.bar.titi">>, #{}, 4), + ?assertConnRequest(Conn, [foo, bar, titi], _, 4), + grisp_connect_connection:error(Conn, -42, <<"Message">>, undefined, 4), + ?receiveError(-42, <<"Message">>, 4), + disconnect(Conn). + +basic_client_synchronous_request_test(_) -> + Conn = connect(), + Async1 = async_eval(fun() -> grisp_connect_connection:request(Conn, [toto], #{}) end), + Id1 = ?receiveRequest(<<"toto">>, _), + send_jsonrpc_result(<<"spam">>, Id1), + ?assertEqual({ok, <<"spam">>}, async_get_result(Async1)), + Async2 = async_eval(fun() -> grisp_connect_connection:request(Conn, tata, #{}) end), + Id2 = ?receiveRequest(<<"tata">>, _), + send_jsonrpc_error(-1, null, Id2), + ?assertEqual({remote_error, error1, <<"Error Number 1">>, undefined}, async_get_result(Async2)), + Async3 = async_eval(fun() -> grisp_connect_connection:request(Conn, titi, #{}) end), + Id3 = ?receiveRequest(<<"titi">>, _), + send_jsonrpc_error(-2, <<"Custom">>, Id3), + ?assertEqual({remote_error, error2, <<"Custom">>, undefined}, async_get_result(Async3)), + disconnect(Conn). + +basic_client_asynchronous_request_test(_) -> + Conn = connect(), + grisp_connect_connection:post(Conn, toto, #{}, ctx1), + Id1 = ?receiveRequest(<<"toto">>, _), + send_jsonrpc_result(<<"spam">>, Id1), + ?assertConnResponse(Conn, <<"spam">>, ctx1), + grisp_connect_connection:post(Conn, tata, #{}, ctx2), + Id2 = ?receiveRequest(<<"tata">>, _), + send_jsonrpc_error(-1, null, Id2), + ?assertConnRemoteError(Conn, error1, <<"Error Number 1">>, undefined, ctx2), + grisp_connect_connection:post(Conn, titi, #{}, ctx3), + Id3 = ?receiveRequest(<<"titi">>, _), + send_jsonrpc_error(-2, <<"Custom">>, Id3), + ?assertConnRemoteError(Conn, error2, <<"Custom">>, undefined, ctx3), + disconnect(Conn). + +basic_error_test(_) -> + Conn = connect(), + grisp_connect_connection:error(Conn, -1, undefined, undefined, undefined), + ?receiveError(-1, <<"Error Number 1">>, null), + send_jsonrpc_error(-2, null, null), + ?assertConnRemoteError(Conn, error2, <<"Error Number 2">>, undefined), + disconnect(Conn). + +request_timeout_test(_) -> + Conn = connect(), + grisp_connect_connection:post(Conn, toto, #{}, ctx1), + _Id1 = ?receiveRequest(<<"toto">>, _), + timer:sleep(500), + ?assertConnLocalError(Conn, timeout, ctx1), + disconnect(Conn). + +spec_example_test(Config) -> + DataDir = proplists:get_value(data_dir, Config), + ExamplesFile = filename:join(DataDir, "jsonrpc_examples.txt"), + + Conn = connect(), + + {ok, ExData} = file:read_file(ExamplesFile), + Examples = parse_examples(ExData), + maps:foreach(fun(Desc, Actions) -> + try + lists:foreach(fun + ({send, Text}) -> + send_text(Text); + ({recv, Expected}) when is_list(Expected) -> + example_handler(Conn), + SortedExpected = lists:sort(Expected), + Received = grisp_connect_test_server:receive_jsonrpc(), + ?assert(is_list(Received), + ?fmt("Invalid response to a batch request during ~s: ~p", + [Desc, Received])), + SortedReceived = lists:sort(Received), + ?assertEqual(SortedExpected, SortedReceived, + ?fmt("Invalid response during ~s", [Desc])); + ({recv, Expected}) -> + example_handler(Conn), + Received = grisp_connect_test_server:receive_jsonrpc(), + ?assertEqual(Expected, Received, + ?fmt("Invalid response during ~s", [Desc])) + end, Actions), + example_handler(Conn), + RemMsgs = flush(), + ?assertEqual([], RemMsgs, + ?fmt("Unexpected message during example ~s: ~p", + [Desc, RemMsgs])) + catch + error:timeout -> + ?assert(false, ?fmt("Timeout while testing example ~s", [Desc])) + end + end, Examples), + + disconnect(Conn). + + +%--- Internal Functions -------------------------------------------------------- + +connect() -> + connect(#{}). + +connect(Opts) -> + DefaultOpts = #{domain => localhost, port => 3030, transport => tcp, + path => <<"/grisp-connect/ws">>, + request_timeout => 300, + errors => [ + {error1, -1, <<"Error Number 1">>}, + {error2, -2, <<"Error Number 2">>} + ]}, + ConnOpts = maps:merge(DefaultOpts, Opts), + {ok, Conn} = grisp_connect_connection:start_link(self(), ConnOpts), + receive + {conn, Conn, connected} -> + grisp_connect_test_server:listen(), + Conn + after + 1000 -> + ?assert(false, "Connection to test server failed") + end. + +disconnect(Conn) -> + unlink(Conn), + grisp_connect_connection:disconnect(Conn), + ok. + +example_handler(Conn) -> + receive + {conn, Conn, {request, [subtract], [A, B], ReqRef}} -> + grisp_connect_connection:reply(Conn, A - B, ReqRef), + example_handler(Conn); + {conn, Conn, {request, [subtract], #{minuend := A, subtrahend := B}, ReqRef}} -> + grisp_connect_connection:reply(Conn, A - B, ReqRef), + example_handler(Conn); + {conn, Conn, {request, [sum], Values, ReqRef}} -> + Result = lists:foldl(fun(V, Acc) -> V + Acc end, 0, Values), + grisp_connect_connection:reply(Conn, Result, ReqRef), + example_handler(Conn); + {conn, Conn, {request, [get_data], _, ReqRef}} -> + grisp_connect_connection:reply(Conn, [<<"hello">>, 5], ReqRef), + example_handler(Conn); + {conn, Conn, {request, _M, _P, ReqRef}} -> + grisp_connect_connection:error(Conn, method_not_found, undefined, undefined, ReqRef), + example_handler(Conn); + {conn, Conn, {notification, _M, _P}} -> + example_handler(Conn); + % {conn, Conn, {response, R, Ctx :: term()}} + {conn, Conn, {remote_error, _C, _M, _D}} -> + example_handler(Conn) + after + 100 -> ok + end. + +parse_examples(Data) when is_binary(Data) -> + Lines = binary:split(Data, <<"\n">>, [global, trim]), + parse_examples_lines(Lines, #{}, undefined, undefined). + +parse_examples_lines([], Acc, undefined, _Actions) -> + Acc; +parse_examples_lines([], Acc, Desc, Actions) -> + Acc#{Desc => lists:reverse(Actions)}; +parse_examples_lines([<<"-->", RestBin/binary>> | RestLines], Acc, Desc, Actions) + when Desc =/= undefined -> + {Raw, RestLines2} = parse_examples_collect([RestBin | RestLines], <<>>), + parse_examples_lines(RestLines2, Acc, Desc, [{send, Raw} | Actions]); +parse_examples_lines([<<"<--", RestBin/binary>> | RestLines], Acc, Desc, Actions) + when Desc =/= undefined -> + case parse_examples_collect([RestBin | RestLines], <<>>) of + {<<"">>, RestLines2} -> + parse_examples_lines(RestLines2, Acc, Desc, Actions); + {Raw, RestLines2} -> + Decoded = jsx:decode(Raw, [{labels, attempt_atom}, return_maps]), + parse_examples_lines(RestLines2, Acc, Desc, [{recv, Decoded} | Actions]) + end; +parse_examples_lines([Line | RestLines], Acc, Desc, Actions) -> + case re:replace(Line, "^\\s+|\\s+$", "", [{return, binary}, global]) of + <<"">> -> parse_examples_lines(RestLines, Acc, Desc, Actions); + <<"//", _/binary>> -> parse_examples_lines(RestLines, Acc, Desc, Actions); + NewDesc -> + NewAcc = case Desc =/= undefined of + true -> Acc#{Desc => lists:reverse(Actions)}; + false -> Acc + end, + NewDesc2 = re:replace(NewDesc, ":+$", "", [{return, binary}, global]), + parse_examples_lines(RestLines, NewAcc, NewDesc2, []) + end. + +parse_examples_collect([], Acc) -> {Acc, []}; +parse_examples_collect([Line | RestLines], Acc) -> + case re:replace(Line, "^\\s+|\\s+$", "", [{return, binary}, global]) of + <<"">> -> {Acc, RestLines}; + <<"-->", _/binary>> -> {Acc, [Line | RestLines]}; + <<"<--", _/binary>> -> {Acc, [Line | RestLines]}; + <<"//", _/binary>> -> parse_examples_collect(RestLines, Acc); + Line2 -> parse_examples_collect(RestLines, <>) + end. diff --git a/test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt b/test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt new file mode 100644 index 0000000..f6c0fad --- /dev/null +++ b/test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt @@ -0,0 +1,96 @@ +// JSON-RPC Examples from specification +// See: https://www.jsonrpc.org/specification#examples + +rpc call with positional parameters: + +--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1} +<-- {"jsonrpc": "2.0", "result": 19, "id": 1} + +--> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2} +<-- {"jsonrpc": "2.0", "result": -19, "id": 2} + +--> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2} +<-- {"jsonrpc": "2.0", "result": -19, "id": 2} + +rpc call with named parameters: + +--> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3} +<-- {"jsonrpc": "2.0", "result": 19, "id": 3} + +--> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4} +<-- {"jsonrpc": "2.0", "result": 19, "id": 4} + +a Notification: + +--> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]} +--> {"jsonrpc": "2.0", "method": "foobar"} + +rpc call of non-existent method: + +--> {"jsonrpc": "2.0", "method": "foobar", "id": "1"} +<-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"} + +rpc call with invalid JSON: + +--> {"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz] +<-- {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null} + +rpc call with invalid Request object: + +--> {"jsonrpc": "2.0", "method": 1, "params": "bar"} +<-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} + +rpc call Batch, invalid JSON: + +--> [ + {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, + {"jsonrpc": "2.0", "method" +] +<-- {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null} + +rpc call with an empty Array: + +--> [] +<-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} + +rpc call with an invalid Batch (but not empty): + +--> [1] +<-- [ + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} +] + +rpc call with invalid Batch: + +--> [1,2,3] +<-- [ + {"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} +] + +rpc call Batch: + +--> [ + {"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"} + ] +<-- [ + {"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"} + ] + +rpc call Batch (all notifications): + +--> [ + {"jsonrpc": "2.0", "method": "notify_sum", "params": [1,2,4]}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]} + ] +<-- //Nothing is returned for all notification batches diff --git a/test/grisp_connect_jsonrpc_SUITE.erl b/test/grisp_connect_jsonrpc_SUITE.erl index 16853a1..abc1e14 100644 --- a/test/grisp_connect_jsonrpc_SUITE.erl +++ b/test/grisp_connect_jsonrpc_SUITE.erl @@ -72,18 +72,18 @@ invalid_json(_) -> <<"\"id\":null">>], JsonError)). invalid_request(_) -> - Term = {decoding_error, -32600, <<"Invalid request">>, undefined, undefined}, + Term = {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}, Json = <<"{\"jsonrpc\":\"2.0\",\"method\":1,\"params\":\"bar\"}">>, ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), JsonError = grisp_connect_jsonrpc:encode(Term), ?assert(jsonrpc_check([<<"\"error\":{">>, <<"\"code\":-32600">>, - <<"\"message\":\"Invalid request\"">>, + <<"\"message\":\"Invalid Request\"">>, <<"\"id\":null">>], JsonError)). batch(_) -> Term1 = {request, <<"sum">>, [1,2,4], <<"1">>}, - Term2 = {decoding_error, -32600, <<"Invalid request">>, undefined, undefined}, + Term2 = {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}, Json = <<"[{\"jsonrpc\":\"2.0\",\"method\":\"sum\",\"params\":[1,2,4],\"id\":\"1\"},{\"foo\":\"boo\"}]">>, ?assertMatch([Term1, Term2], grisp_connect_jsonrpc:decode(Json)), JsonError = grisp_connect_jsonrpc:encode([Term1, Term2]), @@ -92,7 +92,7 @@ batch(_) -> <<"\"params\":[1,2,4]">>, <<"\"error\":{">>, <<"\"code\":-32600">>, - <<"\"message\":\"Invalid request\"">>, + <<"\"message\":\"Invalid Request\"">>, <<"\"id\":null">>], JsonError)). result(_) -> diff --git a/test/grisp_connect_log_SUITE.erl b/test/grisp_connect_log_SUITE.erl index 39cb204..32b745e 100644 --- a/test/grisp_connect_log_SUITE.erl +++ b/test/grisp_connect_log_SUITE.erl @@ -13,6 +13,7 @@ -import(grisp_connect_test_server, [flush/0]). -import(grisp_connect_test_server, [receive_jsonrpc_request/0]). +-import(grisp_connect_test_server, [receive_jsonrpc_request/1]). -import(grisp_connect_test_server, [send_jsonrpc_result/2]). %--- API ----------------------------------------------------------------------- @@ -55,11 +56,11 @@ end_per_testcase(_, Config) -> %--- Tests --------------------------------------------------------------------- string_logs_test(_) -> - LastSeq = last_seq(), S1 = "@#$%^&*()_ +{}|:\"<>?-[];'./,\\`~!\néäüßóçøáîùêñÄÖÜÉÁÍÓÚàèìòùÂÊÎÔÛ€", S2 = <<"@#$%^&*()_ +{}|:\"<>?-[];'./,\\`~!\néäüßóçøáîùêñÄÖÜÉÁÍÓÚàèìòùÂÊÎÔÛ€"/utf8>>, Strings = [S1, S2], Texts = [<>, <>], + LastSeq = reset_log(last_seq()), Seqs = [LastSeq + 1, LastSeq + 2], Fun = fun({Seq, String, Text}) -> grisp_connect:log(error, [String]), @@ -73,12 +74,12 @@ formatted_logs_test(_) -> ["~p, ~tp", [<<"tést">>, <<"tést"/utf8>>]], ["~s, ~ts", ["tést", "tést"]], ["~s, ~ts", [<<"tést">>, <<"tést"/utf8>>]]], - LastSeq = last_seq(), Texts = [<<"ä, €"/utf8>>, <<"tést, tést"/utf8>>, <<"<<\"tést\">>, <<\"tést\"/utf8>>"/utf8>>, <<"tést, tést"/utf8>>, <<"tést, tést"/utf8>>], + LastSeq = reset_log(last_seq()), Seqs = lists:seq(LastSeq + 1, LastSeq + length(ArgsList)), Fun = fun({Seq, Args, Text}) -> grisp_connect:log(error, Args), @@ -96,7 +97,6 @@ structured_logs_test(_) -> #{event => 1234}, #{event => 0.1}, #{event => {'äh', 'bäh'}}], - LastSeq = last_seq(), Texts = [#{event => <<"tést"/utf8>>}, #{event => "tést"}, #{event => <<"tést"/utf8>>}, @@ -106,6 +106,7 @@ structured_logs_test(_) -> #{event => 1234}, #{event => 0.1}, <<"[JSON incompatible term]\n#{event => {äh,bäh}}"/utf8>>], + LastSeq = reset_log(last_seq()), Seqs = lists:seq(LastSeq + 1, LastSeq + length(Events)), Fun = fun({Seq, Event, Text}) -> grisp_connect:log(error, [Event]), @@ -122,7 +123,7 @@ log_level_test(_) -> notice, info, debug], - LastSeq = last_seq(), + LastSeq = reset_log(last_seq()), Seqs = lists:seq(LastSeq + 1, LastSeq + length(Levels)), Fun = fun({Seq, Level}) -> grisp_connect:log(Level, ["level test"]), @@ -146,7 +147,7 @@ meta_data_test(_) -> custom5 => #{boolean => true}, custom6 => 6, custom7 => 7.0}, - LastSeq = last_seq(), + LastSeq = reset_log(last_seq()), Seq = LastSeq + 1, grisp_connect:log(error, ["Test meta", Meta]), send_logs(), @@ -187,6 +188,23 @@ last_seq() -> send_jsonrpc_result(#{seq => Seq, dropped => 0}, Id), Seq. +reset_log() -> + reset_log(undefined). + +reset_log(LasSeq) -> + send_logs(), + try receive_jsonrpc_request(200) of + #{id := Id, params := #{type := <<"logs">>, events := Events}} -> + AllSeq = [S || [S, _] <- Events], + MaxSeq = lists:max(AllSeq), + send_jsonrpc_result(#{seq => MaxSeq, dropped => 0}, Id), + reset_log(MaxSeq); + _Other -> + reset_log(LasSeq) + catch + error:timeout -> LasSeq + end. + check_log(Seq, Level, Text) -> send_logs(), ct:pal("Expected seq:~n~p~nExpected level:~n~p~nExpected text:~n~p", diff --git a/test/grisp_connect_reconnect_SUITE.erl b/test/grisp_connect_reconnect_SUITE.erl index d339e0f..144c216 100644 --- a/test/grisp_connect_reconnect_SUITE.erl +++ b/test/grisp_connect_reconnect_SUITE.erl @@ -48,11 +48,12 @@ end_per_testcase(_, Config) -> flush(), Config. + %--- Tests --------------------------------------------------------------------- reconnect_on_gun_crash_test(_) -> ?assertMatch(ok, wait_connection(100)), - {state, GunPid, _, _, _, _} = sys:get_state(grisp_connect_ws), + GunPid = connection_gun_pid(), proc_lib:stop(GunPid), ?assertMatch(ok, wait_disconnection()), ?assertMatch(ok, wait_connection()). @@ -62,23 +63,31 @@ reconnect_on_disconnection_test(Config) -> stop_cowboy(), ?assertMatch(ok, wait_disconnection()), start_cowboy(cert_dir()), - ?assertMatch(ok, wait_connection(100)), + ?assertMatch(ok, wait_connection(1200)), Config. reconnect_on_ping_timeout_test(_) -> ?assertMatch(ok, wait_connection()), - {state, GunPid, _, _, _, _} = sys:get_state(grisp_connect_ws), + GunPid = connection_gun_pid(), proc_lib:stop(GunPid), % Now decrease ping timeout so that the WS closes after just 1 second application:set_env(grisp_connect, ws_ping_timeout, 1500), ?assertMatch(ok, wait_disconnection()), - ?assertMatch(ok, wait_connection(150)), + ?assertMatch(ok, wait_connection(1200)), ?assertMatch(ok, wait_disconnection()), - ?assertMatch(ok, wait_connection(150)), + ?assertMatch(ok, wait_connection(1200)), ?assertMatch(ok, wait_disconnection()). reconnect_on_closed_frame_test(_) -> ?assertMatch(ok, wait_connection()), close_websocket(), ?assertMatch(ok, wait_disconnection()), - ?assertMatch(ok, wait_connection(100)). + ?assertMatch(ok, wait_connection(1200)). + + +%--- Internal Functions -------------------------------------------------------- + +connection_gun_pid() -> + {_, {data, _, _, _, _, ConnPid, _}} = sys:get_state(grisp_connect_client), + {state, _, _, _, _, _, _, _, _, _, _, GunPid, _, _, _, _} = sys:get_state(ConnPid), + GunPid. diff --git a/test/grisp_connect_test.hrl b/test/grisp_connect_test.hrl index 0e542f7..2c1f54a 100644 --- a/test/grisp_connect_test.hrl +++ b/test/grisp_connect_test.hrl @@ -7,7 +7,45 @@ ?assertMatch(#{method := Method, params := Params}, Send), maps:get(id, Send) end)()). -% TODO when needed: -%-define(receiveNotification(Method, Params) -%-define(receiveResult(Pattern, Id) -%-define(receiveError(Code, Message, Id) + +% Receive a JSON-RPC request with explicit timeout, +% pattern match method and params, return the id +-define(receiveRequest(Timeout, Method, Params), + (fun() -> + Send = grisp_connect_test_server:receive_jsonrpc_request(Timeout), + ?assertMatch(#{method := Method, params := Params}, Send), + maps:get(id, Send) + end)()). + + +% Receive a JSON-RPC notification, pattern match method and params +-define(receiveNotification(Method, Params), + (fun() -> + Send = grisp_connect_test_server:receive_jsonrpc_notification(), + ?assertMatch(#{method := Method, params := Params}, Send), + ok + end)()). + +% Receive a JSON-RPC result, pattern match value and id +-define(receiveResult(Value, Id), + (fun() -> + Send = grisp_connect_test_server:receive_jsonrpc_result(), + ?assertMatch(#{result := Value, id := Id}, Send), + ok + end)()). + +% Receive a JSON-RPC request error, pattern match code, message and id +-define(receiveError(Code, Message, Id), + (fun() -> + Send = grisp_connect_test_server:receive_jsonrpc_error(), + ?assertMatch(#{error := #{code := Code, message := Message}, id := Id}, Send), + ok + end)()). + +% Receive a JSON-RPC standalone error, pattern match code and message +-define(receiveError(Code, Message), + (fun() -> + Send = grisp_connect_test_server:receive_jsonrpc_error(), + ?assertMatch(#{code := Code, message := Message}, Send), + ok + end)()). diff --git a/test/grisp_connect_test_client.erl b/test/grisp_connect_test_client.erl index 63b131f..7a01d47 100644 --- a/test/grisp_connect_test_client.erl +++ b/test/grisp_connect_test_client.erl @@ -20,7 +20,6 @@ wait_connection() -> wait_connection(30_000). wait_connection(0) -> - ct:pal("grisp_connect_ws state:~n~p~n", [sys:get_state(grisp_connect_ws)]), {error, timeout}; wait_connection(N) -> case grisp_connect:is_connected() of @@ -34,7 +33,6 @@ wait_disconnection() -> wait_disconnection(30_000). wait_disconnection(0) -> - ct:pal("grisp_connect_ws state:~n~p~n", [sys:get_state(grisp_connect_ws)]), {error, timeout}; wait_disconnection(N) -> case grisp_connect:is_connected() of diff --git a/test/grisp_connect_test_server.erl b/test/grisp_connect_test_server.erl index c41aec7..6f7e56e 100644 --- a/test/grisp_connect_test_server.erl +++ b/test/grisp_connect_test_server.erl @@ -1,16 +1,21 @@ -module(grisp_connect_test_server). % API --export([start/1]). --export([start_cowboy/1]). +-export([start/0, start/1]). +-export([start_cowboy/0, start_cowboy/1]). -export([stop_cowboy/0]). -export([close_websocket/0]). -export([listen/0]). -export([flush/0]). --export([receive_text/0]). --export([receive_jsonrpc/0]). --export([receive_jsonrpc_request/0]). +-export([receive_text/0, receive_text/1]). +-export([receive_jsonrpc/0, receive_jsonrpc/1]). +-export([receive_jsonrpc_request/0, receive_jsonrpc_request/1]). +-export([receive_jsonrpc_notification/0]). +-export([receive_jsonrpc_result/0]). +-export([receive_jsonrpc_error/0]). -export([send_text/1]). +-export([send_jsonrpc_notification/2]). +-export([send_jsonrpc_request/3]). -export([send_jsonrpc_result/2]). -export([send_jsonrpc_error/3]). @@ -27,6 +32,11 @@ % Start the cowboy application and server, % call this in `init_per_suite'. % Returns the started apps +start() -> + {ok, Apps} = application:ensure_all_started(cowboy), + start_cowboy(), + Apps. + start(CertDir) -> {ok, Apps} = application:ensure_all_started(cowboy), % TODO: Disable ssl for testing @@ -35,6 +45,12 @@ start(CertDir) -> % Start the cowboy listener. % You have to make sure cowboy is running before. +start_cowboy() -> + Dispatch = cowboy_router:compile( + [{'_', [{"/grisp-connect/ws", grisp_connect_test_server, []}]}]), + {ok, _} = cowboy:start_clear(server_listener, [{port, 3030}], + #{env => #{dispatch => Dispatch}}). + start_cowboy(CertDir) -> SslOpts = [ {verify, verify_peer}, @@ -77,33 +93,77 @@ flush(Acc) -> end. receive_text() -> + receive_text(5000). + +receive_text(Timeout) -> receive {received_text, Msg} -> Msg - after 5000 -> {error, timeout} + after Timeout -> error(timeout) end. receive_jsonrpc() -> - case receive_text() of - {error, _} = Error -> Error; - Msg -> check_jsonrpc(Msg) - end. + check_jsonrpc(receive_text()). + +receive_jsonrpc(Timeout) -> + check_jsonrpc(receive_text(Timeout)). receive_jsonrpc_request() -> case receive_jsonrpc() of - {error, _} = Error -> Error; - {error, _, _} = Error -> Error; #{id := _} = Decoded -> Decoded; - Decoded -> {error, invalid_jsonrpc_request, Decoded} + Decoded -> error({invalid_jsonrpc_request, Decoded}) + end. + +receive_jsonrpc_request(Timeout) -> + case receive_jsonrpc(Timeout) of + #{id := _} = Decoded -> Decoded; + Decoded -> error({invalid_jsonrpc_request, Decoded}) + end. + +receive_jsonrpc_notification() -> + case receive_jsonrpc() of + #{method := _} = Decoded -> Decoded; + Decoded -> error({invalid_jsonrpc_notification, Decoded}) + end. + +receive_jsonrpc_result() -> + case receive_jsonrpc() of + #{result := _} = Decoded -> Decoded; + Decoded -> error({invalid_jsonrpc_result, Decoded}) + end. + +receive_jsonrpc_error() -> + case receive_jsonrpc() of + #{error := _} = Decoded -> Decoded; + Decoded -> error({invalid_jsonrpc_error, Decoded}) end. check_jsonrpc(Msg) -> case jsx:decode(Msg, [{labels, attempt_atom}, return_maps]) of #{jsonrpc := <<"2.0">>} = Decoded -> Decoded; - _ -> {error, invalid_jsonrpc, Msg} + Batch when is_list(Batch) -> + lists:foreach(fun + (#{jsonrpc := <<"2.0">>}) -> ok; + (_) -> error({invalid_jsonrpc, Msg}) + end, Batch), + Batch; + _ -> error({invalid_jsonrpc, Msg}) end. send_text(Msg) -> ?MODULE ! {?FUNCTION_NAME, Msg}. +send_jsonrpc_notification(Method, Params) -> + Map = #{jsonrpc => <<"2.0">>, + method => Method, + params => Params}, + send_text(jsx:encode(Map)). + +send_jsonrpc_request(Method, Params, Id) -> + Map = #{jsonrpc => <<"2.0">>, + method => Method, + params => Params, + id => Id}, + send_text(jsx:encode(Map)). + send_jsonrpc_result(Result, Id) -> Map = #{jsonrpc => <<"2.0">>, result => Result, From 2658e9f2dbde47adc0aa99908f23162acb899ed3 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Tue, 26 Nov 2024 17:27:11 +0100 Subject: [PATCH 02/12] Fix Erlang 27 warnings --- src/grisp_connect_internal.hrl | 8 ++++---- test/grisp_connect_test_client.erl | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/grisp_connect_internal.hrl b/src/grisp_connect_internal.hrl index 489c9a7..a037dfb 100644 --- a/src/grisp_connect_internal.hrl +++ b/src/grisp_connect_internal.hrl @@ -8,18 +8,18 @@ -define(GRISP_DEBUG(FMT, ARGS), ?LOG_DEBUG(FMT, ARGS)). -define(GRISP_DEBUG(FMT, ARGS, REPORT), - ?LOG_DEBUG(REPORT#{description => ?FORMAT(FMT, ARGS)})). + ?LOG_DEBUG(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). -define(GRISP_INFO(FMT, ARGS), ?LOG_INFO(FMT, ARGS)). -define(GRISP_INFO(FMT, ARGS, REPORT), - ?LOG_INFO(REPORT#{description => ?FORMAT(FMT, ARGS)})). + ?LOG_INFO(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). -define(GRISP_WARN(FMT, ARGS), ?LOG_WARNING(FMT, ARGS)). -define(GRISP_WARN(FMT, ARGS, REPORT), - ?LOG_WARNING(REPORT#{description => ?FORMAT(FMT, ARGS)})). + ?LOG_WARNING(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). -define(GRISP_ERROR(FMT, ARGS), ?LOG_ERROR(FMT, ARGS)). -define(GRISP_ERROR(FMT, ARGS, REPORT), - ?LOG_ERROR(REPORT#{description => ?FORMAT(FMT, ARGS)})). + ?LOG_ERROR(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). -endif. % GRISP_CONNECT_INTERNAL_HRL diff --git a/test/grisp_connect_test_client.erl b/test/grisp_connect_test_client.erl index 7a01d47..93b4fc5 100644 --- a/test/grisp_connect_test_client.erl +++ b/test/grisp_connect_test_client.erl @@ -12,7 +12,7 @@ %--- API ----------------------------------------------------------------------- -cert_dir() -> filename:join(code:lib_dir(grisp_connect, test), "certs"). +cert_dir() -> filename:join([code:lib_dir(grisp_connect), "test", "certs"]). serial_number() -> <<"0000">>. From 48a204397b210261e4349e44c7b60823e293a028 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Tue, 26 Nov 2024 17:59:48 +0100 Subject: [PATCH 03/12] Try fixing CT error on CI --- test/grisp_connect_api_SUITE.erl | 2 +- test/grisp_connect_connection_SUITE.erl | 2 +- test/grisp_connect_log_SUITE.erl | 2 +- test/grisp_connect_test_server.erl | 21 +++++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/test/grisp_connect_api_SUITE.erl b/test/grisp_connect_api_SUITE.erl index b63c1ce..e4db4c4 100644 --- a/test/grisp_connect_api_SUITE.erl +++ b/test/grisp_connect_api_SUITE.erl @@ -37,7 +37,7 @@ init_per_suite(Config) -> [{apps, Apps} | Config]. end_per_suite(Config) -> - [?assertEqual(ok, application:stop(App)) || App <- ?config(apps, Config)]. + grisp_connect_test_server:stop(?config(apps, Config)). init_per_testcase(TestCase, Config) -> {ok, _} = application:ensure_all_started(grisp_emulation), diff --git a/test/grisp_connect_connection_SUITE.erl b/test/grisp_connect_connection_SUITE.erl index ee3a288..5d7d336 100644 --- a/test/grisp_connect_connection_SUITE.erl +++ b/test/grisp_connect_connection_SUITE.erl @@ -81,7 +81,7 @@ init_per_suite(Config) -> [{apps, Apps} | Config]. end_per_suite(Config) -> - [?assertEqual(ok, application:stop(App)) || App <- ?config(apps, Config)]. + grisp_connect_test_server:stop(?config(apps, Config)). init_per_testcase(_TestCase, Config) -> {ok, _} = application:ensure_all_started(grisp_emulation), diff --git a/test/grisp_connect_log_SUITE.erl b/test/grisp_connect_log_SUITE.erl index 32b745e..56a843c 100644 --- a/test/grisp_connect_log_SUITE.erl +++ b/test/grisp_connect_log_SUITE.erl @@ -32,7 +32,7 @@ init_per_suite(Config) -> [{apps, Apps} | Config]. end_per_suite(Config) -> - [?assertEqual(ok, application:stop(App)) || App <- ?config(apps, Config)]. + grisp_connect_test_server:stop(?config(apps, Config)). init_per_testcase(log_level_test, Config) -> #{level := Level} = logger:get_primary_config(), diff --git a/test/grisp_connect_test_server.erl b/test/grisp_connect_test_server.erl index 6f7e56e..db46f64 100644 --- a/test/grisp_connect_test_server.erl +++ b/test/grisp_connect_test_server.erl @@ -1,7 +1,10 @@ -module(grisp_connect_test_server). +-include_lib("stdlib/include/assert.hrl"). + % API -export([start/0, start/1]). +-export([stop/1]). -export([start_cowboy/0, start_cowboy/1]). -export([stop_cowboy/0]). -export([close_websocket/0]). @@ -43,6 +46,13 @@ start(CertDir) -> start_cowboy(CertDir), Apps. +stop(Apps) -> + ?assertEqual(ok, stop_cowboy()), + [?assertEqual(ok, application:stop(App)) || App <- Apps], + % Ensure the process is unregistered... + wait_no_websocket(). + + % Start the cowboy listener. % You have to make sure cowboy is running before. start_cowboy() -> @@ -203,3 +213,14 @@ websocket_info(close_websocket, State) -> websocket_info(Info, State) -> ct:pal("Ignore websocket info:~n~p", [Info]), {[], State}. + + +%--- Internal Functions -------------------------------------------------------- + +wait_no_websocket() -> + case whereis(?MODULE) of + undefined -> ok; + _Pid -> + timer:sleep(200), + wait_no_websocket() + end. From 60b21af7b93e27acd2ad084f92c279846c2b4e63 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Wed, 27 Nov 2024 12:28:20 +0100 Subject: [PATCH 04/12] Try fixing CT error on CI 2 --- test/grisp_connect_api_SUITE.erl | 1 + test/grisp_connect_connection_SUITE.erl | 1 + test/grisp_connect_log_SUITE.erl | 1 + test/grisp_connect_test_server.erl | 23 +++++++++++------------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/test/grisp_connect_api_SUITE.erl b/test/grisp_connect_api_SUITE.erl index e4db4c4..3c09b25 100644 --- a/test/grisp_connect_api_SUITE.erl +++ b/test/grisp_connect_api_SUITE.erl @@ -52,6 +52,7 @@ init_per_testcase(TestCase, Config) -> end_per_testcase(_, Config) -> ok = application:stop(grisp_connect), + grisp_connect_test_server:wait_disconnection(), ?assertEqual([], flush()), Config. diff --git a/test/grisp_connect_connection_SUITE.erl b/test/grisp_connect_connection_SUITE.erl index 5d7d336..295ca02 100644 --- a/test/grisp_connect_connection_SUITE.erl +++ b/test/grisp_connect_connection_SUITE.erl @@ -88,6 +88,7 @@ init_per_testcase(_TestCase, Config) -> Config. end_per_testcase(_, Config) -> + grisp_connect_test_server:wait_disconnection(), ?assertEqual([], flush()), Config. diff --git a/test/grisp_connect_log_SUITE.erl b/test/grisp_connect_log_SUITE.erl index 56a843c..583736d 100644 --- a/test/grisp_connect_log_SUITE.erl +++ b/test/grisp_connect_log_SUITE.erl @@ -50,6 +50,7 @@ end_per_testcase(log_level_test, Config) -> end_per_testcase(other, Config); end_per_testcase(_, Config) -> ok = application:stop(grisp_connect), + grisp_connect_test_server:wait_disconnection(), ?assertEqual([], flush()), Config. diff --git a/test/grisp_connect_test_server.erl b/test/grisp_connect_test_server.erl index db46f64..97cb832 100644 --- a/test/grisp_connect_test_server.erl +++ b/test/grisp_connect_test_server.erl @@ -21,6 +21,7 @@ -export([send_jsonrpc_request/3]). -export([send_jsonrpc_result/2]). -export([send_jsonrpc_error/3]). +-export([wait_disconnection/0]). % Websocket Callbacks -export([init/2]). @@ -50,7 +51,7 @@ stop(Apps) -> ?assertEqual(ok, stop_cowboy()), [?assertEqual(ok, application:stop(App)) || App <- Apps], % Ensure the process is unregistered... - wait_no_websocket(). + wait_disconnection(). % Start the cowboy listener. @@ -186,6 +187,15 @@ send_jsonrpc_error(Code, Msg, Id) -> id => Id}, send_text(jsx:encode(Map)). +wait_disconnection() -> + case whereis(?MODULE) of + undefined -> ok; + _Pid -> + timer:sleep(200), + wait_disconnection() + end. + + %--- Websocket Callbacks ------------------------------------------------------- init(Req, State) -> @@ -213,14 +223,3 @@ websocket_info(close_websocket, State) -> websocket_info(Info, State) -> ct:pal("Ignore websocket info:~n~p", [Info]), {[], State}. - - -%--- Internal Functions -------------------------------------------------------- - -wait_no_websocket() -> - case whereis(?MODULE) of - undefined -> ok; - _Pid -> - timer:sleep(200), - wait_no_websocket() - end. From b847cea9847c55df49e82e59cb06a47301b64828 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Wed, 27 Nov 2024 12:44:02 +0100 Subject: [PATCH 05/12] Add some archtecture documentation --- docs/grisp_connect_architecture.md | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/grisp_connect_architecture.md diff --git a/docs/grisp_connect_architecture.md b/docs/grisp_connect_architecture.md new file mode 100644 index 0000000..850aec2 --- /dev/null +++ b/docs/grisp_connect_architecture.md @@ -0,0 +1,65 @@ +# Architecture + +```mermaid +graph TD + RootSup[VM Root Supervisor] + + subgraph GrispConnectApp[Grisp Connect Application] + GrispConnectRootSup[Root Supervisor
grisp_connect_sup] + GrispConnectLogServer[Log Server
grisp_connect_log_server] + GrispConnectClient[Client
grisp_connect_client] + GrispConnectConnection[JSON-RPC Connection
grisp_connect_connection] + GrispConnectJsonRPC[JSON-RPC Codec
grisp_connect_jsonrpc] + + GrispConnectRootSup --Supervise--> GrispConnectLogServer + GrispConnectRootSup --Supervise--> GrispConnectClient + GrispConnectClient --Spawn and Monitor--> GrispConnectConnection + GrispConnectConnection --Use--> GrispConnectJsonRPC + end + + subgraph GunApp[Gun Application] + GunRootSup[Gun Root Supervisor
gun_sup] + GunConnsSup[Gun Connection Supervisor
gun_conns_sup] + Gun[Gun Connection
gun] + Gun[Gun HTTP Handler
gun_http] + + GunRootSup --Supervise--> GunConnsSup + GunConnsSup --Supervise--> Gun + end + + RootSup --Supervise--> GrispConnectRootSup + RootSup --Supervise--> GunRootSup + GrispConnectConnection -.Interact.-> Gun +``` + + +## Client + +The client process is the main state machine. Its responsabilities are: + + - Trigger connection/reconnection to the backend. + - Expose high-level protocol API to the application. + - Implement generic API endpoints. + +See the [client documentation](grisp_connect_client.md). + + +## Connection + +`grisp_connect_connection` module encpasulate a JSON-RPC connection. + +It is not supervised, the process starting it must monitor it. + +It provides a high-level API to a JSON-RPC connection: + + - Perform synchronous requests + - Start asynchronous requests + - Reply to a request + - Send and error result for a request + - Send asynchronous notifications + - Send generic errors + +When performing an asynchronous request, the caller can give an opaque context +term, that will given back when receiving a response or an error for this +request, allowing the caller to handle the asynchronous operation without +having to store information locally. From d1fbbe578f17a0c8d57438962ea6274c68250043 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Wed, 27 Nov 2024 16:02:29 +0100 Subject: [PATCH 06/12] Move noproc error handling to grisp_connect_connection --- src/grisp_connect_client.erl | 19 +++++++------------ src/grisp_connect_connection.erl | 25 +++++++++++++++++++------ 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index f9f9181..20fb86d 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -308,7 +308,7 @@ conn_close(Data = #data{conn = undefined}, _Reason) -> Data; conn_close(Data = #data{conn = Conn}, _Reason) -> grisp_connect_log_server:stop(), - catch grisp_connect_connection:disconnect(Conn), + grisp_connect_connection:disconnect(Conn), Data#data{conn = undefined}. % Safe to call in any state @@ -323,25 +323,20 @@ conn_post(Data = #data{conn = Conn}, Method, Type, Params, OnResult, OnError) when Conn =/= undefined -> ReqCtx = #{on_result => OnResult, on_error => OnError}, Params2 = maps:put(type, Type, Params), - try grisp_connect_connection:post(Conn, Method, Params2, ReqCtx) of - _ -> Data - catch - _:Reason when OnError =/= undefined -> - OnError(Data, local, Reason, undefined, undefined); - _:_ -> - Data + case grisp_connect_connection:post(Conn, Method, Params2, ReqCtx) of + ok -> Data; + {error, Reason} -> + OnError(Data, local, Reason, undefined, undefined) end. conn_notify(#data{conn = Conn}, Method, Type, Params) when Conn =/= undefined -> Params2 = maps:put(type, Type, Params), - catch grisp_connect_connection:notify(Conn, Method, Params2), - ok. + grisp_connect_connection:notify(Conn, Method, Params2). conn_reply(#data{conn = Conn}, Result, ReqRef) when Conn =/= undefined -> - catch grisp_connect_connection:reply(Conn, Result, ReqRef), - ok. + grisp_connect_connection:reply(Conn, Result, ReqRef). % IP check functions diff --git a/src/grisp_connect_connection.erl b/src/grisp_connect_connection.erl index 14dec02..604b6e0 100644 --- a/src/grisp_connect_connection.erl +++ b/src/grisp_connect_connection.erl @@ -128,36 +128,44 @@ start_link(Handler, Opts = #{domain := _, port := _, path := _}) -> | {error, Code :: integer(), Message :: undefined | binary(), Data :: term()}. request(Conn, Method, Params) -> - case gen_server:call(Conn, {request, parse_method(Method), Params}) of + try gen_server:call(Conn, {request, parse_method(Method), Params}) of {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack); Other -> Other + catch + exit:{noproc, _} -> {error, not_connected} end. -spec post(Conn :: pid(), Method :: method(), Params :: map(), ReqCtx :: term()) -> ok | {error, not_connected}. post(Conn, Method, Params, ReqCtx) -> - case gen_server:call(Conn, {post, parse_method(Method), Params, ReqCtx}) of + try gen_server:call(Conn, {post, parse_method(Method), Params, ReqCtx}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + catch + exit:{noproc, _} -> {error, not_connected} end. -spec notify(Conn :: pid(), Method :: method(), Params :: map()) -> ok | {error, not_connected}. notify(Conn, Method, Params) -> - case gen_server:call(Conn, {notify, parse_method(Method), Params}) of + try gen_server:call(Conn, {notify, parse_method(Method), Params}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + catch + exit:{noproc, _} -> {error, not_connected} end. -spec reply(Conn :: pid(), Result :: any(), ReqRef :: binary()) -> ok | {error, not_connected}. reply(Conn, Result, ReqRef) -> - case gen_server:call(Conn, {reply, Result, ReqRef}) of + try gen_server:call(Conn, {reply, Result, ReqRef}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + catch + exit:{noproc, _} -> {error, not_connected} end. -spec error(Conn :: pid(), Code :: atom() | integer(), @@ -165,15 +173,20 @@ reply(Conn, Result, ReqRef) -> ReqRef :: undefined | binary()) -> ok | {error, not_connected}. error(Conn, Code, Message, Data, ReqRef) -> - case gen_server:call(Conn, {error, Code, Message, Data, ReqRef}) of + try gen_server:call(Conn, {error, Code, Message, Data, ReqRef}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) + catch + exit:{noproc, _} -> {error, not_connected} end. -spec disconnect(Conn :: pid()) -> ok. disconnect(Conn) -> - gen_server:call(Conn, disconnect). + try gen_server:call(Conn, disconnect) + catch + exit:{noproc, _} -> ok + end. %--- Behaviour gen_server Callbacks -------------------------------------------- From 0a0d7a6960385d79df5cb012217bbff52716994e Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Tue, 3 Dec 2024 20:32:39 +0100 Subject: [PATCH 07/12] Change grisp_connect_connection to gen_statem - Small changes in tests from review feedback --- src/grisp_connect_connection.erl | 637 ++++++++++++------------ test/grisp_connect_connection_SUITE.erl | 53 +- test/grisp_connect_log_SUITE.erl | 15 +- test/grisp_connect_reconnect_SUITE.erl | 2 +- 4 files changed, 367 insertions(+), 340 deletions(-) diff --git a/src/grisp_connect_connection.erl b/src/grisp_connect_connection.erl index 604b6e0..af81fb9 100644 --- a/src/grisp_connect_connection.erl +++ b/src/grisp_connect_connection.erl @@ -1,7 +1,7 @@ %% @doc JSONRpc 2.0 Websocket connection -module(grisp_connect_connection). --behaviour(gen_server). +-behaviour(gen_statem). -include("grisp_connect_internal.hrl"). @@ -18,12 +18,15 @@ -export([error/5]). -export([disconnect/1]). -% Behaviour gen_server Callbacks +% Behaviour gen_statem callback functions +-export([callback_mode/0]). -export([init/1]). --export([handle_call/3]). --export([handle_cast/2]). --export([handle_info/2]). --export([terminate/2]). +-export([code_change/4]). +-export([terminate/3]). + +% Data Functions +-export([connecting/3]). +-export([connected/3]). %--- Types --------------------------------------------------------------------- @@ -44,11 +47,11 @@ method :: method(), id :: binary() | integer(), tref :: undefined | reference(), - from :: undefined | gen_server:from(), + from :: undefined | gen_statem:from(), ctx :: undefined | term() }). --record(state, { +-record(data, { handler :: pid(), uri :: iodata(), domain :: binary(), @@ -62,7 +65,6 @@ gun_pid :: undefined | pid(), gun_ref :: undefined | reference(), ws_stream :: undefined | gun:stream_ref(), - connected = false :: boolean(), ping_tref :: undefined | reference() }). @@ -85,8 +87,8 @@ | {conn, pid(), {request, method(), Params :: map() | list(), ReqRef :: binary() | integer()}} | {conn, pid(), {notification, method(), Params :: map() | list()}} | {conn, pid(), {response, Result :: term(), Ctx :: term()}} - | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term()}} - | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term(), Ctx :: term()}} + | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), ErData :: term()}} + | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), ErData :: term(), Ctx :: term()}} | {conn, pid(), {local_error, Reason:: atom(), Ctx :: term()}}. -export_type([error_mapping/0, method/0, handler_messages/0]). @@ -115,20 +117,24 @@ {internal_error, -32603, <<"Internal error">>} ]). +-define(HANDLE_COMMON, + ?FUNCTION_NAME(EventType, EventContent, Data) -> + handle_common(EventType, EventContent, ?FUNCTION_NAME, Data)). + %--- API Functions ------------------------------------------------------------- -spec start_link(Handler :: pid(), Options :: start_options()) -> {ok, Conn :: pid()} | {error, Reason :: term()}. start_link(Handler, Opts = #{domain := _, port := _, path := _}) -> - gen_server:start_link(?MODULE, [Handler, Opts], []). + gen_statem:start_link(?MODULE, [Handler, Opts], []). -spec request(Conn :: pid(), Method :: method(), Params :: map()) -> {ok, Result :: term()} | {error, timeout} | {error, not_connected} | {error, Code :: integer(), - Message :: undefined | binary(), Data :: term()}. + Message :: undefined | binary(), ErData :: term()}. request(Conn, Method, Params) -> - try gen_server:call(Conn, {request, parse_method(Method), Params}) of + try gen_statem:call(Conn, {request, parse_method(Method), Params}) of {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack); Other -> Other catch @@ -138,7 +144,7 @@ request(Conn, Method, Params) -> -spec post(Conn :: pid(), Method :: method(), Params :: map(), ReqCtx :: term()) -> ok | {error, not_connected}. post(Conn, Method, Params, ReqCtx) -> - try gen_server:call(Conn, {post, parse_method(Method), Params, ReqCtx}) of + try gen_statem:call(Conn, {post, parse_method(Method), Params, ReqCtx}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) @@ -149,7 +155,7 @@ post(Conn, Method, Params, ReqCtx) -> -spec notify(Conn :: pid(), Method :: method(), Params :: map()) -> ok | {error, not_connected}. notify(Conn, Method, Params) -> - try gen_server:call(Conn, {notify, parse_method(Method), Params}) of + try gen_statem:call(Conn, {notify, parse_method(Method), Params}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) @@ -160,7 +166,7 @@ notify(Conn, Method, Params) -> -spec reply(Conn :: pid(), Result :: any(), ReqRef :: binary()) -> ok | {error, not_connected}. reply(Conn, Result, ReqRef) -> - try gen_server:call(Conn, {reply, Result, ReqRef}) of + try gen_statem:call(Conn, {reply, Result, ReqRef}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) @@ -169,11 +175,11 @@ reply(Conn, Result, ReqRef) -> end. -spec error(Conn :: pid(), Code :: atom() | integer(), - Message :: undefined | binary(), Data :: term(), + Message :: undefined | binary(), ErData :: term(), ReqRef :: undefined | binary()) -> ok | {error, not_connected}. -error(Conn, Code, Message, Data, ReqRef) -> - try gen_server:call(Conn, {error, Code, Message, Data, ReqRef}) of +error(Conn, Code, Message, ErData, ReqRef) -> + try gen_statem:call(Conn, {error, Code, Message, ErData, ReqRef}) of {ok, CallResult} -> CallResult; {error, _Reason} = Error -> Error; {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) @@ -183,13 +189,15 @@ error(Conn, Code, Message, Data, ReqRef) -> -spec disconnect(Conn :: pid()) -> ok. disconnect(Conn) -> - try gen_server:call(Conn, disconnect) + try gen_statem:call(Conn, disconnect) catch exit:{noproc, _} -> ok end. -%--- Behaviour gen_server Callbacks -------------------------------------------- +%--- Behaviour gen_statem Callbacks -------------------------------------------- + +callback_mode() -> [state_functions]. init([Handler, Opts]) -> process_flag(trap_exit, true), % To ensure terminate/2 is called @@ -197,7 +205,7 @@ init([Handler, Opts]) -> PingTimeout = maps:get(ping_timeout, Opts, ?DEFAULT_PING_TIMEOUT), ReqTimeout = maps:get(request_timeout, Opts, ?DEFAULT_REQUEST_TIMEOUT), Transport = maps:get(transport, Opts, ?DEFAULT_TRANSPORT), - State = #state{ + Data = #data{ handler = Handler, uri = format_ws_uri(Transport, Domain, Port, Path), domain = as_bin(Domain), @@ -208,189 +216,210 @@ init([Handler, Opts]) -> }, index_errors(?DEFAULT_JSONRPC_ERRORS), index_errors(maps:get(errors, Opts, [])), - case connection_start(State, Transport) of - {ok, _State2} = Result -> Result; + case connection_start(Data, Transport) of + {ok, Data2} -> {ok, connecting, Data2}; {error, Reason} -> {stop, Reason} end. -handle_call({request, Method, _Params}, _From, - State = #state{connected = false}) -> - ?GRISP_INFO("Request ~s performed while disconnected", +code_change(_Vsn, State, Data, _Extra) -> + {ok, State, Data}. + +terminate(_Reason, _State, Data) -> + connection_close(Data), + persistent_term:erase({?MODULE, self(), tags}), + persistent_term:erase({?MODULE, self(), codes}), + ok. + + +%--- Behaviour gen_statem State Callback Functions ----------------------------- + +connecting({call, From}, {request, Method, _Params}, _Data) -> + ?GRISP_INFO("Request ~s performed while connecting", [format_method(Method)], #{event => rpc_request_error, method => Method, reason => not_connected}), - {reply, {error, not_connected}, State}; -handle_call({request, Method, Params}, From, State) -> - try send_request(State, Method, Params, From, undefined) of - State2 -> {noreply, State2} - catch - C:badarg:S -> {reply, {exception, C, badarg, S}, State}; - C:R:S -> {stop, R, {exception, C, R, S}, State} - end; -handle_call({post, Method, _Params, ReqCtx}, _From, - State = #state{handler = Handler, connected = false}) -> - ?GRISP_INFO("Request ~s posted while disconnected", + {keep_state_and_data, [{reply, From, {error, not_connected}}]}; +connecting({call, From}, {post, Method, _Params, ReqCtx}, + #data{handler = Handler}) -> + ?GRISP_INFO("Request ~s posted while connecting", [format_method(Method)], #{event => rpc_post_error, method => Method, reason => not_connected}), % Notify the handler anyway so it doesn't have to make a special case Handler ! {conn, self(), {local_error, not_connected, ReqCtx}}, - {reply, {error, not_connected}, State}; -handle_call({post, Method, Params, ReqCtx}, _From, State) -> - try send_request(State, Method, Params, undefined, ReqCtx) of - State2 -> {reply, {ok, ok}, State2} - catch - C:badarg:S -> {reply, {exception, C, badarg, S}, State}; - C:R:S -> {stop, R, {exception, C, R, S}, State} - end; -handle_call({notify, Method, _Params}, _From, - State = #state{connected = false}) -> - ?GRISP_INFO("Notification ~s posted while disconnected", + {keep_state_and_data, [{reply, From, {error, not_connected}}]}; +connecting({call, From}, {notify, Method, _Params}, _Data) -> + ?GRISP_INFO("Notification ~s posted while connecting", [format_method(Method)], #{event => rpc_notify_error, method => Method, reason => not_connected}), - {noreply, State}; -handle_call({notify, Method, Params}, _From, State) -> - try send_notification(State, Method, Params) of - State2 -> {reply, {ok, ok}, State2} - catch - C:badarg:S -> {reply, {exception, C, badarg, S}, State}; - C:R:S -> {stop, R, {exception, C, R, S}, State} - end; -handle_call({reply, _Result, ReqRef}, _From, - State = #state{connected = false}) -> - ?GRISP_INFO("Reply to ~s posted while disconnected", - [inbound_req_tag(State, ReqRef)], + {keep_state_and_data, [{reply, From, {error, not_connected}}]}; +connecting({call, From}, {reply, _Result, ReqRef}, Data) -> + ?GRISP_INFO("Reply to ~s posted while connecting", + [inbound_req_tag(Data, ReqRef)], #{event => rpc_reply_error, ref => ReqRef, - method => inbound_method(State, ReqRef), + method => inbound_method(Data, ReqRef), reason => not_connected}), - {reply, {error, not_connected}, State}; -handle_call({reply, Result, ReqRef}, _From, State) -> - try send_response(State, Result, ReqRef) of - State2 -> {reply, {ok, ok}, State2} - catch - C:badarg:S -> {reply, {exception, C, badarg, S}, State}; - C:R:S -> {stop, R, {exception, C, R, S}, State} - end; -handle_call({error, Code, _Message, _Data, undefined}, _From, - State = #state{connected = false}) -> - ?GRISP_INFO("Error ~w posted while disconnected", [Code], + {keep_state_and_data, [{reply, From, {error, not_connected}}]}; +connecting({call, From}, {error, Code, _Message, _ErData, undefined}, _Data) -> + ?GRISP_INFO("Error ~w posted while connecting", [Code], #{event => rpc_error_error, code => Code, reason => not_connected}), - {reply, {error, not_connected}, State}; -handle_call({error, Code, _Message, _Data, ReqRef}, _From, - State = #state{connected = false}) -> - ?GRISP_INFO("Error ~w to ~s posted while disconnected", - [Code, inbound_req_tag(State, ReqRef)], + {keep_state_and_data, [{reply, From, {error, not_connected}}]}; +connecting({call, From}, {error, Code, _Message, _ErData, ReqRef}, Data) -> + ?GRISP_INFO("Error ~w to ~s posted while connecting", + [Code, inbound_req_tag(Data, ReqRef)], #{event => rpc_error_error, code => Code, ref => ReqRef, reason => not_connected}), - {reply, {error, not_connected}, State}; -handle_call({error, Code, Message, Data, ReqRef}, _From, State) -> - try send_error(State, Code, Message, Data, ReqRef) of - State2 -> {reply, {ok, ok}, State2} + {keep_state_and_data, [{reply, From, {error, not_connected}}]}; +connecting(info, {gun_up, GunPid, _}, Data = #data{gun_pid = GunPid}) -> + ?GRISP_DEBUG("Connection to ~s established", + [Data#data.uri], + #{event => ws_connection_enstablished, uri => Data#data.uri}), + {keep_state, connection_upgrade(Data)}; +connecting(info, {gun_upgrade, Pid, Stream, [<<"websocket">>], _}, + Data = #data{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_DEBUG("Connection to ~s upgraded to websocket", [Data#data.uri], + #{event => ws_upgraded, uri => Data#data.uri}), + {next_state, connected, connection_established(Data)}; + +connecting(info, {gun_response, Pid, Stream, _, Status, _Headers}, + Data = #data{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_INFO("Connection to ~s failed to upgrade to websocket: ~p", + [Data#data.uri, Status], + #{event => ws_upgrade_failed, uri => Data#data.uri, + status => Status}), + {stop, ws_upgrade_failed, connection_close(Data)}; +?HANDLE_COMMON. + +connected({call, From}, {request, Method, Params}, Data) -> + try send_request(Data, Method, Params, From, undefined) of + Data2 -> {keep_state, Data2} catch - C:badarg:S -> {reply, {exception, C, badarg, S}, State}; - C:R:S -> {stop, R, {exception, C, R, S}, State} + C:badarg:S -> + {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; + C:R:S -> + {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} end; -handle_call(disconnect, _From, State) -> - try connection_close(State) of - State2 -> {reply, {ok, ok}, State2} +connected({call, From}, {post, Method, Params, ReqCtx}, Data) -> + try send_request(Data, Method, Params, undefined, ReqCtx) of + Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} catch - C:R:S -> {stop, R, {exception, C, R, S}, State} + C:badarg:S -> + {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; + C:R:S -> + {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} end; -handle_call(Call, From, State) -> - ?GRISP_ERROR("Unexpected call from ~p to ~s: ~p", [From, ?MODULE, Call], - #{event => unexpected_call, from => From, message => Call}), - {stop, {unexpected_call, Call}, {error, unexpected_call}, State}. - -handle_cast(Cast, State) -> - Reason = {unexpected_cast, Cast}, - ?GRISP_ERROR("Unexpected cast to ~s: ~p", [?MODULE, Cast], - #{event => unexpected_cast, message => Cast}), - {stop, Reason, State}. - -handle_info({gun_up, GunPid, _}, State = #state{gun_pid = GunPid}) -> - ?GRISP_DEBUG("Connection to ~s established", - [State#state.uri], - #{event => ws_connection_enstablished, uri => State#state.uri}), - {noreply, connection_upgrade(State)}; -handle_info({gun_up, Pid, http} = _Msg, State = #state{gun_pid = GunPid}) -> - ?GRISP_DEBUG("Ignoring unexpected gun_up message" - " from pid ~p, current pid is ~p", [Pid, GunPid], - #{event => unexpected_gun_message, message => _Msg}), - {noreply, State}; -handle_info({gun_upgrade, Pid, Stream, [<<"websocket">>], _}, - State = #state{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_DEBUG("Connection to ~s upgraded to websocket", [State#state.uri], - #{event => ws_upgraded, uri => State#state.uri}), - {noreply, connection_established(State)}; -handle_info({gun_response, Pid, Stream, _, Status, _Headers}, - State = #state{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_INFO("Connection to ~s failed to upgrade to websocket: ~p", - [State#state.uri, Status], - #{event => ws_upgrade_failed, uri => State#state.uri, - status => Status}), - {stop, ws_upgrade_failed, connection_close(State)}; -handle_info({gun_ws, Pid, Stream, ping}, - State = #state{gun_pid = Pid, ws_stream = Stream}) -> - {noreply, schedule_ping_timeout(State)}; -handle_info({gun_ws, Pid, Stream, {text, Text}}, - State = #state{gun_pid = Pid, ws_stream = Stream}) -> - {noreply, process_data(State, Text)}; -handle_info({gun_ws, Pid, Stream, close}, - State = #state{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_INFO("Connection to ~s closed without code", [State#state.uri], - #{event => ws_stream_closed, uri => State#state.uri}), - {stop, normal, connection_closed(State, closed)}; -handle_info({gun_ws, Pid, Stream, {close, Code, Message}}, - State = #state{gun_pid = Pid, ws_stream = Stream}) -> +connected({call, From}, {notify, Method, Params}, Data) -> + try send_notification(Data, Method, Params) of + Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} + catch + C:badarg:S -> + {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; + C:R:S -> + {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} + end; +connected({call, From}, {reply, Result, ReqRef}, Data) -> + try send_response(Data, Result, ReqRef) of + Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} + catch + C:badarg:S -> + {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; + C:R:S -> + {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} + end; +connected({call, From}, {error, Code, Message, ErData, ReqRef}, Data) -> + try send_error(Data, Code, Message, ErData, ReqRef) of + Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} + catch + C:badarg:S -> + {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; + C:R:S -> + {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} + end; +connected(info, {gun_ws, Pid, Stream, {text, Text}}, + Data = #data{gun_pid = Pid, ws_stream = Stream}) -> + {keep_state, process_text(Data, Text)}; +connected(info, ping_timeout, Data) -> + ?GRISP_INFO("Connection to ~s timed out", [Data#data.uri], + #{event => ws_ping_timeout, uri => Data#data.uri}), + {stop, normal, connection_close(Data)}; +connected(info, {outbound_timeout, ReqRef}, Data) -> + ?GRISP_INFO("Request ~s time to ~s timed out", + [outbound_req_tag(Data, ReqRef), Data#data.uri], + #{event => rpc_request_timeout_error, uri => Data#data.uri, + ref => ReqRef, method => outbound_method(Data, ReqRef)}), + {keep_state, outbound_timeout(Data, ReqRef)}; +?HANDLE_COMMON. + +handle_common({call, From}, disconnect, _StateName, Data) -> + try connection_close(Data) of + Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} + catch + C:R:S -> {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} + end; +handle_common(info, {gun_ws, Pid, Stream, ping}, _StateName, + Data = #data{gun_pid = Pid, ws_stream = Stream}) -> + {keep_state, schedule_ping_timeout(Data)}; +handle_common(info, {gun_ws, Pid, Stream, close}, StateName, + Data = #data{gun_pid = Pid, ws_stream = Stream}) -> + ?GRISP_INFO("Connection to ~s closed without code", [Data#data.uri], + #{event => ws_stream_closed, uri => Data#data.uri, + state => StateName}), + {stop, normal, connection_closed(Data, closed)}; +handle_common(info, {gun_ws, Pid, Stream, {close, Code, Message}}, StateName, + Data = #data{gun_pid = Pid, ws_stream = Stream}) -> ?GRISP_INFO("Connection to ~s closed: ~s (~w)", - [State#state.uri, Message, Code], - #{event => ws_stream_closed, uri => State#state.uri, - code => Code, reason => Message}), - {stop, normal, connection_closed(State, closed)}; -handle_info({gun_error, Pid, _Stream, Reason}, - State = #state{gun_pid = Pid}) -> + [Data#data.uri, Message, Code], + #{event => ws_stream_closed, uri => Data#data.uri, + code => Code, reason => Message, state => StateName}), + {stop, normal, connection_closed(Data, closed)}; +handle_common(info, {gun_error, Pid, _Stream, Reason}, StateName, + Data = #data{gun_pid = Pid}) -> ?GRISP_INFO("Connection to ~s got an error: ~p", - [State#state.uri, Reason], - #{event => ws_error, uri => State#state.uri, - reason => Reason}), - {stop, Reason, connection_close(State)}; -handle_info({gun_down, Pid, ws, Reason, [Stream]}, - State = #state{gun_pid = Pid, ws_stream = Stream}) + [Data#data.uri, Reason], + #{event => ws_error, uri => Data#data.uri, + reason => Reason, state => StateName}), + {stop, Reason, connection_close(Data)}; +handle_common(info, {gun_down, Pid, ws, Reason, [Stream]}, StateName, + Data = #data{gun_pid = Pid, ws_stream = Stream}) when Reason =:= closed; Reason =:= {error, closed}; Reason =:= normal -> - ?GRISP_INFO("Connection to ~s was closed by the server", [State#state.uri], - #{event => ws_closed_by_peer, uri => State#state.uri, - reason => closed}), - {stop, normal, connection_close(State)}; -handle_info({'DOWN', _, process, Pid, Reason}, - State = #state{gun_pid = Pid}) -> + ?GRISP_INFO("Connection to ~s was closed by the server", [Data#data.uri], + #{event => ws_closed_by_peer, uri => Data#data.uri, + reason => closed, state => StateName}), + {stop, normal, connection_close(Data)}; +handle_common(info, {'DOWN', _, process, Pid, Reason}, StateName, + Data = #data{gun_pid = Pid}) -> ?GRISP_INFO("gun process of the connection to ~s crashed: ~p", - [State#state.uri, Reason], - #{event => ws_gun_crash, uri => State#state.uri, - reason => Reason}), - {stop, Reason, connection_closed(State, gun_crashed)}; -handle_info(ping_timeout, State) -> - ?GRISP_INFO("Connection to ~s timed out", [State#state.uri], - #{event => ws_ping_timeout, uri => State#state.uri}), - {stop, normal, connection_close(State)}; -handle_info({outbound_timeout, ReqRef}, State) -> - ?GRISP_INFO("Request ~s time to ~s timed out", - [outbound_req_tag(State, ReqRef), State#state.uri], - #{event => rpc_request_timeout_error, uri => State#state.uri, - ref => ReqRef, method => outbound_method(State, ReqRef)}), - {noreply, outbound_timeout(State, ReqRef)}; -handle_info(Msg, State) -> + [Data#data.uri, Reason], + #{event => ws_gun_crash, uri => Data#data.uri, + reason => Reason, state => StateName}), + {stop, Reason, connection_closed(Data, gun_crashed)}; +handle_common(info, {gun_up, Pid, http} = Info, StateName, + #data{gun_pid = GunPid}) -> + ?GRISP_DEBUG("Ignoring unexpected gun_up message" + " from pid ~p, current pid is ~p", [Pid, GunPid], + #{event => unexpected_gun_message, message => Info, + state => StateName}), + keep_state_and_data; +handle_common({call, From}, Call, StateName, _Data) -> + ?GRISP_ERROR("Unexpected call from ~p to ~s: ~p", [From, ?MODULE, Call], + #{event => unexpected_call, from => From, message => Call, + state => StateName}), + {stop_and_reply, {unexpected_call, Call}, + [{reply, From, {error, unexpected_call}}]}; +handle_common(cast, Cast, StateName, _Data) -> + Reason = {unexpected_cast, Cast}, + ?GRISP_ERROR("Unexpected cast to ~s: ~p", [?MODULE, Cast], + #{event => unexpected_cast, message => Cast, + state => StateName}), + {stop, Reason}; +handle_common(info, Info, StateName, _Data) -> ?GRISP_WARN("Unexpected info message to ~s: ~p", - [?MODULE, Msg], - #{event => unexpected_info, message => Msg}), - {noreply, State}. - -terminate(_Reason, State) -> - connection_close(State), - persistent_term:erase({?MODULE, self(), tags}), - persistent_term:erase({?MODULE, self(), codes}), - ok. + [?MODULE, Info], + #{event => unexpected_info, message => Info, + state => StateName}), + keep_state_and_data. %--- INTERNAL FUNCTION --------------------------------------------------------- @@ -414,26 +443,26 @@ cancel_timer(undefined) -> ok; cancel_timer(TRef) -> erlang:cancel_timer(TRef). -schedule_ping_timeout(State = #state{ping_timeout = Timeout}) -> - State2 = cancel_ping_timeout(State), +schedule_ping_timeout(Data = #data{ping_timeout = Timeout}) -> + Data2 = cancel_ping_timeout(Data), TRef = send_after(Timeout, ping_timeout), - State2#state{ping_tref = TRef}. + Data2#data{ping_tref = TRef}. -cancel_ping_timeout(State = #state{ping_tref = undefined}) -> - State; -cancel_ping_timeout(State = #state{ping_tref = TRef}) -> +cancel_ping_timeout(Data = #data{ping_tref = undefined}) -> + Data; +cancel_ping_timeout(Data = #data{ping_tref = TRef}) -> cancel_timer(TRef), - State#state{ping_tref = undefined}. + Data#data{ping_tref = undefined}. % Returns either the method of the outbound request or its reference % as a binary if not found. Only mean for logging. -outbound_req_tag(#state{outbound = ReqMap}, ReqRef) -> +outbound_req_tag(#data{outbound = ReqMap}, ReqRef) -> case maps:find(ReqRef, ReqMap) of error -> as_bin(ReqRef); {ok, #outbound_req{method = Method}} -> format_method(Method) end. -outbound_method(#state{outbound = ReqMap}, ReqRef) -> +outbound_method(#data{outbound = ReqMap}, ReqRef) -> case maps:find(ReqRef, ReqMap) of error -> undefined; {ok, #outbound_req{method = Method}} -> Method @@ -441,13 +470,13 @@ outbound_method(#state{outbound = ReqMap}, ReqRef) -> % Returns either the method of the inbound request or its reference % as a binary if not found. Only mean for logging. -inbound_req_tag(#state{inbound = ReqMap}, ReqRef) -> +inbound_req_tag(#data{inbound = ReqMap}, ReqRef) -> case maps:find(ReqRef, ReqMap) of error -> as_bin(ReqRef); {ok, #inbound_req{method = Method}} -> format_method(Method) end. -inbound_method(#state{inbound = ReqMap}, ReqRef) -> +inbound_method(#data{inbound = ReqMap}, ReqRef) -> case maps:find(ReqRef, ReqMap) of error -> undefined; {ok, #inbound_req{method = Method}} -> Method @@ -460,7 +489,7 @@ index_errors(ErrorSpecs) -> lists:foldl(fun({Tag, Code, Msg}, {Tags, Codes}) -> {Tags#{Tag => {Code, Msg}}, Codes#{Code => {Tag, Msg}}} end, {ErrorTags, ErrorCodes}, ErrorSpecs), - % The error list is put in a persistent term to not add noise to the state. + % The error list is put in a persistent term to not add noise to the Data. persistent_term:put({?MODULE, self(), tags}, ErrorTags2), persistent_term:put({?MODULE, self(), codes}, ErrorCodes2), ok. @@ -505,7 +534,7 @@ encode_error(Tag, Message) {ok, {Code, _DefaultMessage}} -> {Code, Message} end. -connection_start(State = #state{uri = Uri, domain = Domain, port = Port}, +connection_start(Data = #data{uri = Uri, domain = Domain, port = Port}, TransportSpec) -> BaseGunOpts = #{protocols => [http], retry => 0}, GunOpts = case TransportSpec of @@ -518,7 +547,7 @@ connection_start(State = #state{uri = Uri, domain = Domain, port = Port}, case gun:open(binary_to_list(Domain), Port, GunOpts) of {ok, GunPid} -> GunRef = monitor(process, GunPid), - {ok, State#state{gun_pid = GunPid, gun_ref = GunRef}}; + {ok, Data#data{gun_pid = GunPid, gun_ref = GunRef}}; {error, Reason} -> ?GRISP_ERROR("Failed to open connection to ~s: ~p", [Uri, Reason], #{event => connection_failure, uri => Uri, @@ -526,157 +555,153 @@ connection_start(State = #state{uri = Uri, domain = Domain, port = Port}, {error, Reason} end. -connection_upgrade(State = #state{path = Path, gun_pid = GunPid}) -> +connection_upgrade(Data = #data{path = Path, gun_pid = GunPid}) -> WsStream = gun:ws_upgrade(GunPid, Path,[], #{silence_pings => false}), - State#state{ws_stream = WsStream}. + Data#data{ws_stream = WsStream}. -connection_established(State = #state{handler = Handler}) -> - State2 = schedule_ping_timeout(State#state{connected = true}), +connection_established(Data = #data{handler = Handler}) -> Handler ! {conn, self(), connected}, - State2. + schedule_ping_timeout(Data). -connection_close(State = #state{gun_pid = GunPid, gun_ref = GunRef}) +connection_close(Data = #data{gun_pid = GunPid, gun_ref = GunRef}) when GunPid =/= undefined, GunRef =/= undefined -> demonitor(GunRef), gun:shutdown(GunPid), - State2 = cancel_ping_timeout(State), - State3 = requests_error(State2, not_connected), - State3#state{gun_pid = undefined, gun_ref = undefined, - ws_stream = undefined, connected = false}; -connection_close(State) -> - State2 = requests_error(State, not_connected), - State2#state{connected = false}. - -connection_closed(State, Reason) -> - State2 = cancel_ping_timeout(State), - State3 = requests_error(State2, Reason), - State3#state{gun_pid = undefined, gun_ref = undefined, - ws_stream = undefined, connected = false}. - -send_request(State, Method, Params, From, Ctx) -> - {ReqRef, State2} = outbound_add(State, Method, From, Ctx), + Data2 = cancel_ping_timeout(Data), + Data3 = requests_error(Data2, not_connected), + Data3#data{gun_pid = undefined, gun_ref = undefined, ws_stream = undefined}; +connection_close(Data) -> + requests_error(Data, not_connected). + +connection_closed(Data, Reason) -> + Data2 = cancel_ping_timeout(Data), + Data3 = requests_error(Data2, Reason), + Data3#data{gun_pid = undefined, gun_ref = undefined, ws_stream = undefined}. + +send_request(Data, Method, Params, From, Ctx) -> + {ReqRef, Data2} = outbound_add(Data, Method, From, Ctx), Msg = {request, format_method(Method), Params, ReqRef}, - send_packet(State2, Msg). + send_packet(Data2, Msg). -send_notification(State, Method, Params) -> +send_notification(Data, Method, Params) -> Msg = {notification, format_method(Method), Params}, - send_packet(State, Msg). + send_packet(Data, Msg). -send_response(State, Result, ReqRef) -> +send_response(Data, Result, ReqRef) -> Msg = {result, Result, ReqRef}, - inbound_response(State, Msg, ReqRef). + inbound_response(Data, Msg, ReqRef). -send_error(State, Code, Message, Data, ReqRef) -> +send_error(Data, Code, Message, ErData, ReqRef) -> {Code2, Message2} = encode_error(Code, Message), - Msg = {error, Code2, Message2, Data, ReqRef}, + Msg = {error, Code2, Message2, ErData, ReqRef}, case ReqRef of - undefined -> send_packet(State, Msg); - _ -> inbound_response(State, Msg, ReqRef) + undefined -> send_packet(Data, Msg); + _ -> inbound_response(Data, Msg, ReqRef) end. -send_packet(State = #state{gun_pid = GunPid, ws_stream = Stream}, Packet) -> +send_packet(Data = #data{gun_pid = GunPid, ws_stream = Stream}, Packet) -> Payload = grisp_connect_jsonrpc:encode(Packet), ?TRACE_OUTPUT(Payload), gun:ws_send(GunPid, Stream, {text, Payload}), - State. + Data. -process_data(State = #state{batches = BatchMap}, Data) -> +process_text(Data = #data{batches = BatchMap}, Text) -> ?TRACE_INPUT(Data), - DecodedData = grisp_connect_jsonrpc:decode(Data), + DecodedData = grisp_connect_jsonrpc:decode(Text), case DecodedData of Messages when is_list(Messages) -> BatchRef = make_ref(), - case process_messages(State, BatchRef, 0, [], Messages) of - {ReqCount, Replies, State2} when ReqCount > 0 -> + case process_messages(Data, BatchRef, 0, [], Messages) of + {ReqCount, Replies, Data2} when ReqCount > 0 -> Batch = #batch{bref = BatchRef, refcount = ReqCount, responses = Replies}, - State2#state{batches = BatchMap#{BatchRef => Batch}}; - {_ReqCount, [], State2} -> - State2; - {_ReqCount, [_|_] = Replies, State2} -> + Data2#data{batches = BatchMap#{BatchRef => Batch}}; + {_ReqCount, [], Data2} -> + Data2; + {_ReqCount, [_|_] = Replies, Data2} -> % All the requests got a reply right away - send_packet(State2, Replies) + send_packet(Data2, Replies) end; Message when is_tuple(Message) -> - case process_messages(State, undefined, 0, [], [Message]) of - {_, [Reply], State2} -> - send_packet(State2, Reply); - {_, [], State2} -> - State2 + case process_messages(Data, undefined, 0, [], [Message]) of + {_, [Reply], Data2} -> + send_packet(Data2, Reply); + {_, [], Data2} -> + Data2 end end. -process_messages(State, _BatchRef, ReqCount, Replies, []) -> - {ReqCount, lists:reverse(Replies), State}; -process_messages(State, BatchRef, ReqCount, Replies, +process_messages(Data, _BatchRef, ReqCount, Replies, []) -> + {ReqCount, lists:reverse(Replies), Data}; +process_messages(Data, BatchRef, ReqCount, Replies, [{decoding_error, _, _, _, _} = Error | Rest]) -> - process_messages(State, BatchRef, ReqCount, [Error | Replies], Rest); -process_messages(State, BatchRef, ReqCount, Replies, + process_messages(Data, BatchRef, ReqCount, [Error | Replies], Rest); +process_messages(Data, BatchRef, ReqCount, Replies, [{request, RawMethod, Params, ReqRef} | Rest]) -> Method = parse_method(RawMethod), - State2 = process_request(State, BatchRef, Method, Params, ReqRef), - process_messages(State2, BatchRef, ReqCount + 1, Replies, Rest); -process_messages(State, BatchRef, ReqCount, Replies, + Data2 = process_request(Data, BatchRef, Method, Params, ReqRef), + process_messages(Data2, BatchRef, ReqCount + 1, Replies, Rest); +process_messages(Data, BatchRef, ReqCount, Replies, [{notification, RawMethod, Params} | Rest]) -> Method = parse_method(RawMethod), - State2 = process_notification(State, Method, Params), - process_messages(State2, BatchRef, ReqCount, Replies, Rest); -process_messages(State, BatchRef, ReqCount, Replies, + Data2 = process_notification(Data, Method, Params), + process_messages(Data2, BatchRef, ReqCount, Replies, Rest); +process_messages(Data, BatchRef, ReqCount, Replies, [{result, Result, ReqRef} | Rest]) -> - {State2, Replies2} = process_response(State, Replies, Result, ReqRef), - process_messages(State2, BatchRef, ReqCount, Replies2, Rest); -process_messages(State, BatchRef, ReqCount, Replies, - [{error, Code, Message, Data, ReqRef} | Rest]) -> - {State2, Replies2} = process_error(State, Replies, Code, - Message, Data, ReqRef), - process_messages(State2, BatchRef, ReqCount, Replies2, Rest). - -process_request(State = #state{handler = Handler}, + {Data2, Replies2} = process_response(Data, Replies, Result, ReqRef), + process_messages(Data2, BatchRef, ReqCount, Replies2, Rest); +process_messages(Data, BatchRef, ReqCount, Replies, + [{error, Code, Message, ErData, ReqRef} | Rest]) -> + {Data2, Replies2} = process_error(Data, Replies, Code, + Message, ErData, ReqRef), + process_messages(Data2, BatchRef, ReqCount, Replies2, Rest). + +process_request(Data = #data{handler = Handler}, BatchRef, Method, Params, ReqRef) -> Handler ! {conn, self(), {request, Method, Params, ReqRef}}, - inbound_add(State, BatchRef, Method, ReqRef). + inbound_add(Data, BatchRef, Method, ReqRef). -process_notification(State = #state{handler = Handler}, Method, Params) -> +process_notification(Data = #data{handler = Handler}, Method, Params) -> Handler ! {conn, self(), {notification, Method, Params}}, - State. + Data. -process_response(State = #state{handler = Handler}, Replies, Result, ReqRef) -> - case outbound_del(State, ReqRef) of +process_response(Data = #data{handler = Handler}, Replies, Result, ReqRef) -> + case outbound_del(Data, ReqRef) of {error, not_found} -> %FIXME: Not sure what error should be returned... Error = {invalid_request, -32600, <<"Result for unknown request">>}, - {State, [Error | Replies]}; - {ok, _Method, undefined, Ctx, State2} -> + {Data, [Error | Replies]}; + {ok, _Method, undefined, Ctx, Data2} -> Handler ! {conn, self(), {response, Result, Ctx}}, - {State2, Replies}; - {ok, _, From, _, State2} -> - gen_server:reply(From, {ok, Result}), - {State2, Replies} + {Data2, Replies}; + {ok, _, From, _, Data2} -> + gen_statem:reply(From, {ok, Result}), + {Data2, Replies} end. -process_error(State = #state{handler = Handler}, - Replies, Code, Message, Data, undefined) -> +process_error(Data = #data{handler = Handler}, + Replies, Code, Message, ErData, undefined) -> {Code2, Message2} = decode_error(Code, Message), - Handler ! {conn, self(), {remote_error, Code2, Message2, Data}}, - {State, Replies}; -process_error(State = #state{handler = Handler}, - Replies, Code, Message, Data, ReqRef) -> - case outbound_del(State, ReqRef) of + Handler ! {conn, self(), {remote_error, Code2, Message2, ErData}}, + {Data, Replies}; +process_error(Data = #data{handler = Handler}, + Replies, Code, Message, ErData, ReqRef) -> + case outbound_del(Data, ReqRef) of {error, not_found} -> %FIXME: Not sure what error should be returned... Error = {invalid_request, -32600, <<"Error for unknown request">>}, - {State, [Error | Replies]}; - {ok, _Method, undefined, Ctx, State2} -> + {Data, [Error | Replies]}; + {ok, _Method, undefined, Ctx, Data2} -> {Code2, Message2} = decode_error(Code, Message), - Handler ! {conn, self(), {remote_error, Code2, Message2, Data, Ctx}}, - {State2, Replies}; - {ok, _, From, _, State2} -> + Handler ! {conn, self(), {remote_error, Code2, Message2, ErData, Ctx}}, + {Data2, Replies}; + {ok, _, From, _, Data2} -> {Code2, Message2} = decode_error(Code, Message), - gen_server:reply(From, {remote_error, Code2, Message2, Data}), - {State2, Replies} + gen_statem:reply(From, {remote_error, Code2, Message2, ErData}), + {Data2, Replies} end. -inbound_response(State = #state{batches = BatchMap, inbound = ReqMap}, +inbound_response(Data = #data{batches = BatchMap, inbound = ReqMap}, Message, ReqRef) -> case maps:take(ReqRef, ReqMap) of error -> @@ -684,17 +709,17 @@ inbound_response(State = #state{batches = BatchMap, inbound = ReqMap}, [ReqRef], #{event => internal_error, ref => ReqRef, reason => unknown_request}), - State; + Data; {#inbound_req{bref = undefined}, ReqMap2} -> % Not part of a batch response - send_packet(State#state{inbound = ReqMap2}, Message); + send_packet(Data#data{inbound = ReqMap2}, Message); {#inbound_req{bref = BatchRef}, ReqMap2} -> % The batch must exists case maps:find(BatchRef, BatchMap) of {ok, #batch{refcount = 1, responses = Responses}} -> % This is the last message of the batch BatchMap2 = maps:remove(BatchRef, BatchMap), - send_packet(State#state{batches = BatchMap2, + send_packet(Data#data{batches = BatchMap2, inbound = ReqMap2}, [Message | Responses]); {ok, Batch = #batch{refcount = RefCount, @@ -702,53 +727,53 @@ inbound_response(State = #state{batches = BatchMap, inbound = ReqMap}, Batch2 = Batch#batch{refcount = RefCount - 1, responses = [Message | Responses]}, BatchMap2 = BatchMap#{BatchRef => Batch2}, - State#state{batches = BatchMap2, inbound = ReqMap2} + Data#data{batches = BatchMap2, inbound = ReqMap2} end end. -inbound_add(State = #state{inbound = ReqMap}, BatchRef, Method, ReqRef) -> +inbound_add(Data = #data{inbound = ReqMap}, BatchRef, Method, ReqRef) -> %TODO: Should we add a timeout for inbound requests ? Req = #inbound_req{method = Method, id = ReqRef, bref = BatchRef}, - State#state{inbound = ReqMap#{ReqRef => Req}}. + Data#data{inbound = ReqMap#{ReqRef => Req}}. -outbound_add(State = #state{request_timeout = Timeout, outbound = ReqMap}, +outbound_add(Data = #data{request_timeout = Timeout, outbound = ReqMap}, Method, From, Ctx) -> ReqRef = make_reqref(), TRef = send_after(Timeout, {outbound_timeout, ReqRef}), Req = #outbound_req{id = ReqRef, method = Method, tref = TRef, from = From, ctx = Ctx}, - {ReqRef, State#state{outbound = ReqMap#{ReqRef => Req}}}. + {ReqRef, Data#data{outbound = ReqMap#{ReqRef => Req}}}. -outbound_del(State = #state{outbound = ReqMap}, ReqRef) -> +outbound_del(Data = #data{outbound = ReqMap}, ReqRef) -> case maps:find(ReqRef, ReqMap) of error -> {error, not_found}; {ok, #outbound_req{method = Method, tref = TRef, from = From, ctx = Ctx}} -> cancel_timer(TRef), - State2 = State#state{outbound = maps:remove(ReqRef, ReqMap)}, - {ok, Method, From, Ctx, State2} + Data2 = Data#data{outbound = maps:remove(ReqRef, ReqMap)}, + {ok, Method, From, Ctx, Data2} end. -outbound_timeout(State = #state{handler = Handler}, ReqRef) -> - case outbound_del(State, ReqRef) of +outbound_timeout(Data = #data{handler = Handler}, ReqRef) -> + case outbound_del(Data, ReqRef) of {error, not_found} -> ?GRISP_WARN("Timeout for unknown request ~s", [ReqRef], #{event => internal_error, ref => ReqRef, reason => unknown_request}), - State; - {ok, _Method, undefined, Ctx, State2} -> + Data; + {ok, _Method, undefined, Ctx, Data2} -> Handler ! {conn, self(), {local_error, timeout, Ctx}}, - State2; - {ok, _, From, _, State2} -> - gen_server:reply(From, {error, timeout}), - State2 + Data2; + {ok, _, From, _, Data2} -> + gen_statem:reply(From, {error, timeout}), + Data2 end. -requests_error(State = #state{handler = Handler, outbound = ReqMap}, Reason) -> +requests_error(Data = #data{handler = Handler, outbound = ReqMap}, Reason) -> maps:foreach(fun (_, #outbound_req{from = undefined, ctx = Ctx}) -> Handler ! {conn, self(), {local_error, Reason, Ctx}}; (_, #outbound_req{from = From, ctx = undefined}) -> - gen_server:reply(From, {error, Reason}) + gen_statem:reply(From, {error, Reason}) end, ReqMap), - State#state{outbound = #{}}. + Data#data{outbound = #{}}. diff --git a/test/grisp_connect_connection_SUITE.erl b/test/grisp_connect_connection_SUITE.erl index 295ca02..81bb8ed 100644 --- a/test/grisp_connect_connection_SUITE.erl +++ b/test/grisp_connect_connection_SUITE.erl @@ -85,9 +85,12 @@ end_per_suite(Config) -> init_per_testcase(_TestCase, Config) -> {ok, _} = application:ensure_all_started(grisp_emulation), - Config. + Conn = connect(), + [{conn, Conn} | Config]. -end_per_testcase(_, Config) -> +end_per_testcase(_TestCase, Config) -> + Conn = proplists:get_value(conn, Config), + disconnect(Conn), grisp_connect_test_server:wait_disconnection(), ?assertEqual([], flush()), Config. @@ -95,28 +98,28 @@ end_per_testcase(_, Config) -> %--- Tests --------------------------------------------------------------------- -basic_server_notifications_test(_) -> - Conn = connect(), +basic_server_notifications_test(Config) -> + Conn = proplists:get_value(conn, Config), send_jsonrpc_notification(<<"ping">>, #{foo => null}), ?assertConnNotification(Conn, [ping], #{foo := undefined}), send_jsonrpc_notification(<<"foo.bar.ping">>, #{}), ?assertConnNotification(Conn, [foo, bar, ping], _), send_jsonrpc_notification(<<"foo.bar.NotAnAtom">>, #{}), ?assertConnNotification(Conn, [foo, bar, <<"NotAnAtom">>], _), - disconnect(Conn). + ok. -basic_client_notifications_test(_) -> - Conn = connect(), +basic_client_notifications_test(Config) -> + Conn = proplists:get_value(conn, Config), grisp_connect_connection:notify(Conn, ping, #{foo => undefined}), ?receiveNotification(<<"ping">>, #{foo := null}), grisp_connect_connection:notify(Conn, [foo, bar, ping], #{}), ?receiveNotification(<<"foo.bar.ping">>, _), grisp_connect_connection:notify(Conn, [foo, bar, <<"NotAnAtom">>], #{}), ?receiveNotification(<<"foo.bar.NotAnAtom">>, _), - disconnect(Conn). + ok. -basic_server_request_test(_) -> - Conn = connect(), +basic_server_request_test(Config) -> + Conn = proplists:get_value(conn, Config), send_jsonrpc_request(<<"toto">>, #{}, 1), ?assertConnRequest(Conn, [toto], _, 1), grisp_connect_connection:reply(Conn, <<"spam">>, 1), @@ -133,10 +136,10 @@ basic_server_request_test(_) -> ?assertConnRequest(Conn, [foo, bar, titi], _, 4), grisp_connect_connection:error(Conn, -42, <<"Message">>, undefined, 4), ?receiveError(-42, <<"Message">>, 4), - disconnect(Conn). + ok. -basic_client_synchronous_request_test(_) -> - Conn = connect(), +basic_client_synchronous_request_test(Config) -> + Conn = proplists:get_value(conn, Config), Async1 = async_eval(fun() -> grisp_connect_connection:request(Conn, [toto], #{}) end), Id1 = ?receiveRequest(<<"toto">>, _), send_jsonrpc_result(<<"spam">>, Id1), @@ -149,10 +152,10 @@ basic_client_synchronous_request_test(_) -> Id3 = ?receiveRequest(<<"titi">>, _), send_jsonrpc_error(-2, <<"Custom">>, Id3), ?assertEqual({remote_error, error2, <<"Custom">>, undefined}, async_get_result(Async3)), - disconnect(Conn). + ok. -basic_client_asynchronous_request_test(_) -> - Conn = connect(), +basic_client_asynchronous_request_test(Config) -> + Conn = proplists:get_value(conn, Config), grisp_connect_connection:post(Conn, toto, #{}, ctx1), Id1 = ?receiveRequest(<<"toto">>, _), send_jsonrpc_result(<<"spam">>, Id1), @@ -165,29 +168,29 @@ basic_client_asynchronous_request_test(_) -> Id3 = ?receiveRequest(<<"titi">>, _), send_jsonrpc_error(-2, <<"Custom">>, Id3), ?assertConnRemoteError(Conn, error2, <<"Custom">>, undefined, ctx3), - disconnect(Conn). + ok. -basic_error_test(_) -> - Conn = connect(), +basic_error_test(Config) -> + Conn = proplists:get_value(conn, Config), grisp_connect_connection:error(Conn, -1, undefined, undefined, undefined), ?receiveError(-1, <<"Error Number 1">>, null), send_jsonrpc_error(-2, null, null), ?assertConnRemoteError(Conn, error2, <<"Error Number 2">>, undefined), - disconnect(Conn). + ok. -request_timeout_test(_) -> - Conn = connect(), +request_timeout_test(Config) -> + Conn = proplists:get_value(conn, Config), grisp_connect_connection:post(Conn, toto, #{}, ctx1), _Id1 = ?receiveRequest(<<"toto">>, _), timer:sleep(500), ?assertConnLocalError(Conn, timeout, ctx1), - disconnect(Conn). + ok. spec_example_test(Config) -> DataDir = proplists:get_value(data_dir, Config), ExamplesFile = filename:join(DataDir, "jsonrpc_examples.txt"), - Conn = connect(), + Conn = proplists:get_value(conn, Config), {ok, ExData} = file:read_file(ExamplesFile), Examples = parse_examples(ExData), @@ -223,7 +226,7 @@ spec_example_test(Config) -> end end, Examples), - disconnect(Conn). + ok. %--- Internal Functions -------------------------------------------------------- diff --git a/test/grisp_connect_log_SUITE.erl b/test/grisp_connect_log_SUITE.erl index 583736d..8e6c249 100644 --- a/test/grisp_connect_log_SUITE.erl +++ b/test/grisp_connect_log_SUITE.erl @@ -61,7 +61,7 @@ string_logs_test(_) -> S2 = <<"@#$%^&*()_ +{}|:\"<>?-[];'./,\\`~!\néäüßóçøáîùêñÄÖÜÉÁÍÓÚàèìòùÂÊÎÔÛ€"/utf8>>, Strings = [S1, S2], Texts = [<>, <>], - LastSeq = reset_log(last_seq()), + LastSeq = last_seq(), Seqs = [LastSeq + 1, LastSeq + 2], Fun = fun({Seq, String, Text}) -> grisp_connect:log(error, [String]), @@ -80,7 +80,7 @@ formatted_logs_test(_) -> <<"<<\"tést\">>, <<\"tést\"/utf8>>"/utf8>>, <<"tést, tést"/utf8>>, <<"tést, tést"/utf8>>], - LastSeq = reset_log(last_seq()), + LastSeq = last_seq(), Seqs = lists:seq(LastSeq + 1, LastSeq + length(ArgsList)), Fun = fun({Seq, Args, Text}) -> grisp_connect:log(error, Args), @@ -107,7 +107,7 @@ structured_logs_test(_) -> #{event => 1234}, #{event => 0.1}, <<"[JSON incompatible term]\n#{event => {äh,bäh}}"/utf8>>], - LastSeq = reset_log(last_seq()), + LastSeq = last_seq(), Seqs = lists:seq(LastSeq + 1, LastSeq + length(Events)), Fun = fun({Seq, Event, Text}) -> grisp_connect:log(error, [Event]), @@ -124,7 +124,7 @@ log_level_test(_) -> notice, info, debug], - LastSeq = reset_log(last_seq()), + LastSeq = last_seq(), Seqs = lists:seq(LastSeq + 1, LastSeq + length(Levels)), Fun = fun({Seq, Level}) -> grisp_connect:log(Level, ["level test"]), @@ -148,7 +148,7 @@ meta_data_test(_) -> custom5 => #{boolean => true}, custom6 => 6, custom7 => 7.0}, - LastSeq = reset_log(last_seq()), + LastSeq = last_seq(), Seq = LastSeq + 1, grisp_connect:log(error, ["Test meta", Meta]), send_logs(), @@ -187,10 +187,9 @@ last_seq() -> Events = maps:get(events, maps:get(params, Send)), [Seq, _] = lists:last(Events), send_jsonrpc_result(#{seq => Seq, dropped => 0}, Id), - Seq. + % Consume all the log events, updating the sequence number + reset_log(Seq). -reset_log() -> - reset_log(undefined). reset_log(LasSeq) -> send_logs(), diff --git a/test/grisp_connect_reconnect_SUITE.erl b/test/grisp_connect_reconnect_SUITE.erl index 144c216..343c882 100644 --- a/test/grisp_connect_reconnect_SUITE.erl +++ b/test/grisp_connect_reconnect_SUITE.erl @@ -89,5 +89,5 @@ reconnect_on_closed_frame_test(_) -> connection_gun_pid() -> {_, {data, _, _, _, _, ConnPid, _}} = sys:get_state(grisp_connect_client), - {state, _, _, _, _, _, _, _, _, _, _, GunPid, _, _, _, _} = sys:get_state(ConnPid), + {_, {data, _, _, _, _, _, _, _, _, _, _, GunPid, _, _, _}} = sys:get_state(ConnPid), GunPid. From 2e1df561892666a18b8aa3aa5194ab8a6100ce7f Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Fri, 10 Jan 2025 19:34:25 +0100 Subject: [PATCH 08/12] Move JSON-RPC connection out to its own library --- CHANGELOG.md | 5 +- docs/grisp_connect_architecture.md | 30 +- rebar.config | 4 +- src/grisp_connect.app.src | 4 +- src/grisp_connect_api.erl | 4 +- src/grisp_connect_client.erl | 36 +- src/grisp_connect_connection.erl | 779 ------------------ src/grisp_connect_internal.hrl | 25 - src/grisp_connect_jsonrpc.erl | 190 ----- src/grisp_connect_utils.erl | 45 - test/grisp_connect_connection_SUITE.erl | 330 -------- .../jsonrpc_examples.txt | 96 --- test/grisp_connect_jsonrpc_SUITE.erl | 118 --- 13 files changed, 46 insertions(+), 1620 deletions(-) delete mode 100644 src/grisp_connect_connection.erl delete mode 100644 src/grisp_connect_internal.hrl delete mode 100644 src/grisp_connect_jsonrpc.erl delete mode 100644 src/grisp_connect_utils.erl delete mode 100644 test/grisp_connect_connection_SUITE.erl delete mode 100644 test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt delete mode 100644 test/grisp_connect_jsonrpc_SUITE.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index b36a110..7ab1898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,8 @@ individual JSON-RPC requests changed from ws_requests_timeout ot ws_request_timeout. - Le default log filter changed to trying to filter out only some messages to filtering out all progress messages, as it wasn't working reliably. -- The connection is not a persistent process anymore, it is now a transiant -process handling a connection and dying when the connection is closed. -- Internally, the JSON-RPC is parsed into a list of atom or binaries to pave the +- JSON-RPC logic was extracted into the jarl library. +- Jarl parses the methods into a list of atom or binaries to pave the road for namespaces. foo.bar.Buz is parsed into [foo, bar, <<"Buz">>] (if foo and bar are already existing atoms, but 'Buz' is not). diff --git a/docs/grisp_connect_architecture.md b/docs/grisp_connect_architecture.md index 850aec2..05857c7 100644 --- a/docs/grisp_connect_architecture.md +++ b/docs/grisp_connect_architecture.md @@ -3,20 +3,24 @@ ```mermaid graph TD RootSup[VM Root Supervisor] - + subgraph GrispConnectApp[Grisp Connect Application] GrispConnectRootSup[Root Supervisor
grisp_connect_sup] GrispConnectLogServer[Log Server
grisp_connect_log_server] GrispConnectClient[Client
grisp_connect_client] - GrispConnectConnection[JSON-RPC Connection
grisp_connect_connection] - GrispConnectJsonRPC[JSON-RPC Codec
grisp_connect_jsonrpc] - + GrispConnectRootSup --Supervise--> GrispConnectLogServer GrispConnectRootSup --Supervise--> GrispConnectClient - GrispConnectClient --Spawn and Monitor--> GrispConnectConnection - GrispConnectConnection --Use--> GrispConnectJsonRPC + GrispConnectClient --Spawn and Monitor--> JarlConnection + end + + subgraph JarlApp[Jarl Application] + JarlConnection[JSON-RPC Connection
jarl_connection] + JarlJsonRpc[JSON-RPC Codec
jarl_jsonrpc] + + JarlConnection --Use--> JarlJsonRpc end - + subgraph GunApp[Gun Application] GunRootSup[Gun Root Supervisor
gun_sup] GunConnsSup[Gun Connection Supervisor
gun_conns_sup] @@ -26,10 +30,10 @@ graph TD GunRootSup --Supervise--> GunConnsSup GunConnsSup --Supervise--> Gun end - + RootSup --Supervise--> GrispConnectRootSup RootSup --Supervise--> GunRootSup - GrispConnectConnection -.Interact.-> Gun + JarlConnection -.Interact.-> Gun ``` @@ -40,13 +44,13 @@ The client process is the main state machine. Its responsabilities are: - Trigger connection/reconnection to the backend. - Expose high-level protocol API to the application. - Implement generic API endpoints. - + See the [client documentation](grisp_connect_client.md). ## Connection -`grisp_connect_connection` module encpasulate a JSON-RPC connection. +grisp_connect use the jarl library to handle the JSON-RPC connection. It is not supervised, the process starting it must monitor it. @@ -61,5 +65,5 @@ It provides a high-level API to a JSON-RPC connection: When performing an asynchronous request, the caller can give an opaque context term, that will given back when receiving a response or an error for this -request, allowing the caller to handle the asynchronous operation without -having to store information locally. +request, allowing the caller to handle the asynchronous operation without having +to store information locally. diff --git a/rebar.config b/rebar.config index 44cc1fd..407aba5 100644 --- a/rebar.config +++ b/rebar.config @@ -1,8 +1,8 @@ {erl_opts, [debug_info]}. {deps, [ - {grisp, "~> 2.7"}, - {gun, "2.1.0"}, jsx, + {jarl, {git, "https://github.com/grisp/jarl.git"}}, + {grisp, "~> 2.7"}, {grisp_cryptoauth, "~> 2.4"}, {certifi, "2.13.0"} ]}. diff --git a/src/grisp_connect.app.src b/src/grisp_connect.app.src index ce7076c..9c90847 100644 --- a/src/grisp_connect.app.src +++ b/src/grisp_connect.app.src @@ -7,10 +7,10 @@ kernel, inets, stdlib, + jsx, + jarl, grisp, grisp_cryptoauth, - gun, - jsx, certifi ]}, {optional_applications, [ diff --git a/src/grisp_connect_api.erl b/src/grisp_connect_api.erl index 46bcb7d..eca79a2 100644 --- a/src/grisp_connect_api.erl +++ b/src/grisp_connect_api.erl @@ -15,8 +15,8 @@ % @doc Handles requests, notifications and errors from grisp.io. -spec handle_msg(Msg) -> ok | {reply, Result :: term(), ReqRef :: binary() | integer()} - when Msg :: {request, Method :: grisp_connect_connection:method(), Params :: map() | list(), ReqRef :: binary() | integer()} - | {notification, grisp_connect_connection:method(), Params :: map() | list()} + when Msg :: {request, Method :: jarl:method(), Params :: map() | list(), ReqRef :: binary() | integer()} + | {notification, jarl:method(), Params :: map() | list()} | {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term()}. handle_msg({notification, M, Params}) -> ?LOG_ERROR("Received unexpected notification ~p: ~p", [M, Params]), diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index 20fb86d..6adec2e 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -7,9 +7,7 @@ -behaviour(gen_statem). --include("grisp_connect_internal.hrl"). - --import(grisp_connect_utils, [as_bin/1]). +-include_lib("kernel/include/logger.hrl"). % External API -export([start_link/0]). @@ -52,6 +50,7 @@ %--- Macros -------------------------------------------------------------------- +-define(FORMAT(FMT, ARGS), iolist_to_binary(io_lib:format(FMT, ARGS))). -define(STD_TIMEOUT, 1000). -define(CONNECT_TIMEOUT, 2000). -define(ENV(KEY, GUARDS), fun() -> @@ -162,7 +161,8 @@ connecting(enter, _OldState, Data) -> {keep_state, Data, [{state_timeout, 0, connect}]}; connecting(state_timeout, connect, Data = #data{conn = undefined, retry_count = RetryCount}) -> - ?GRISP_INFO("Connecting to grisp.io", [], #{event => connecting}), + ?LOG_INFO(#{description => <<"Connecting to grisp.io">>, + event => connecting}), case conn_start(Data) of {ok, Data2} -> {keep_state, Data2, [{state_timeout, ?CONNECT_TIMEOUT, timeout}]}; @@ -173,13 +173,14 @@ connecting(state_timeout, connect, end; connecting(state_timeout, timeout, Data = #data{retry_count = RetryCount}) -> Reason = connect_timeout, - ?GRISP_WARN("Timeout while connecting to grisp.io", [], - #{event => connection_failed, reason => Reason}), + ?LOG_WARNING(#{description => <<"Timeout while connecting to grisp.io">>, + event => connection_failed, reason => Reason}), Data2 = conn_close(Data, Reason), {next_state, waiting_ip, Data2#data{retry_count = RetryCount + 1}}; connecting(info, {conn, Conn, connected}, Data = #data{conn = Conn}) -> % Received from the connection process - ?GRISP_INFO("Connected to grisp.io", [], #{event => connected}), + ?LOG_INFO(#{description => <<"Connected to grisp.io">>, + event => connected}), {next_state, connected, Data#data{retry_count = 0}}; ?HANDLE_COMMON. @@ -217,8 +218,9 @@ handle_common(info, reboot, _, _) -> handle_common(info, {'EXIT', Conn, Reason}, _State, Data = #data{conn = Conn, retry_count = RetryCount}) -> % The connection process died - ?GRISP_WARN("The connection to grisp.io died: ~p", [Reason], - #{event => connection_failed, reason => Reason}), + ?LOG_WARNING(#{description => + ?FORMAT("The connection to grisp.io died: ~p", [Reason]), + event => connection_failed, reason => Reason}), {next_state, waiting_ip, conn_died(Data#data{retry_count = RetryCount + 1})}; handle_common(info, {conn, Conn, Msg}, State, _Data) -> ?LOG_DEBUG("Received message from unknown connection ~p in state ~w: ~p", @@ -248,6 +250,10 @@ generic_errors() -> [ {validate_from_unbooted, -13, <<"Validate from unbooted">>} ]. +as_bin(Binary) when is_binary(Binary) -> Binary; +as_bin(List) when is_list(List) -> list_to_binary(List); +as_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom). + handle_connection_message(_Data, {response, _Result, #{on_result := undefined}}) -> keep_state_and_data; handle_connection_message(Data, {response, Result, #{on_result := OnResult}}) -> @@ -298,7 +304,7 @@ conn_start(Data = #data{conn = undefined, ping_timeout => WsPingTimeout, request_timeout => WsReqTimeout }, - case grisp_connect_connection:start_link(self(), ConnOpts) of + case jarl:start_link(self(), ConnOpts) of {error, _Reason} = Error -> Error; {ok, Conn} -> {ok, Data#data{conn = Conn}} end. @@ -308,7 +314,7 @@ conn_close(Data = #data{conn = undefined}, _Reason) -> Data; conn_close(Data = #data{conn = Conn}, _Reason) -> grisp_connect_log_server:stop(), - grisp_connect_connection:disconnect(Conn), + jarl:disconnect(Conn), Data#data{conn = undefined}. % Safe to call in any state @@ -316,14 +322,14 @@ conn_died(Data) -> grisp_connect_log_server:stop(), Data#data{conn = undefined}. --spec conn_post(data(), grisp_connect_connection:method(), atom(), map(), +-spec conn_post(data(), jarl:method(), atom(), map(), undefined | on_result_fun(), undefined | on_error_fun()) -> data(). conn_post(Data = #data{conn = Conn}, Method, Type, Params, OnResult, OnError) when Conn =/= undefined -> ReqCtx = #{on_result => OnResult, on_error => OnError}, Params2 = maps:put(type, Type, Params), - case grisp_connect_connection:post(Conn, Method, Params2, ReqCtx) of + case jarl:post(Conn, Method, Params2, ReqCtx) of ok -> Data; {error, Reason} -> OnError(Data, local, Reason, undefined, undefined) @@ -332,11 +338,11 @@ conn_post(Data = #data{conn = Conn}, Method, Type, Params, OnResult, OnError) conn_notify(#data{conn = Conn}, Method, Type, Params) when Conn =/= undefined -> Params2 = maps:put(type, Type, Params), - grisp_connect_connection:notify(Conn, Method, Params2). + jarl:notify(Conn, Method, Params2). conn_reply(#data{conn = Conn}, Result, ReqRef) when Conn =/= undefined -> - grisp_connect_connection:reply(Conn, Result, ReqRef). + jarl:reply(Conn, Result, ReqRef). % IP check functions diff --git a/src/grisp_connect_connection.erl b/src/grisp_connect_connection.erl deleted file mode 100644 index af81fb9..0000000 --- a/src/grisp_connect_connection.erl +++ /dev/null @@ -1,779 +0,0 @@ -%% @doc JSONRpc 2.0 Websocket connection --module(grisp_connect_connection). - --behaviour(gen_statem). - --include("grisp_connect_internal.hrl"). - --import(grisp_connect_utils, [as_bin/1]). --import(grisp_connect_utils, [parse_method/1]). --import(grisp_connect_utils, [format_method/1]). - -% API Functions --export([start_link/2]). --export([request/3]). --export([post/4]). --export([notify/3]). --export([reply/3]). --export([error/5]). --export([disconnect/1]). - -% Behaviour gen_statem callback functions --export([callback_mode/0]). --export([init/1]). --export([code_change/4]). --export([terminate/3]). - -% Data Functions --export([connecting/3]). --export([connected/3]). - - -%--- Types --------------------------------------------------------------------- - --record(batch, { - bref :: reference(), - refcount :: pos_integer(), - responses :: list() -}). - --record(inbound_req, { - method :: method(), - id :: binary() | integer(), - bref :: undefined | reference() % Set if part of a batch -}). - --record(outbound_req, { - method :: method(), - id :: binary() | integer(), - tref :: undefined | reference(), - from :: undefined | gen_statem:from(), - ctx :: undefined | term() -}). - --record(data, { - handler :: pid(), - uri :: iodata(), - domain :: binary(), - port :: inet:port_number(), - path :: binary(), - ping_timeout :: infinity | pos_integer(), - request_timeout :: infinity | pos_integer(), - batches = #{} :: #{reference() => #batch{}}, - inbound = #{} :: #{binary() | integer() => #inbound_req{}}, - outbound = #{} :: #{binary() | integer() => #outbound_req{}}, - gun_pid :: undefined | pid(), - gun_ref :: undefined | reference(), - ws_stream :: undefined | gun:stream_ref(), - ping_tref :: undefined | reference() -}). - --type error_mapping() :: {atom(), integer(), binary()}. --type method() :: atom() | binary() | [atom() | binary()]. --type start_options() :: #{ - domain := atom() | string() | binary(), - port := inet:port_number(), - %FIXME: dialyzer do no like ssl:tls_client_option(), maybe some erlang version issue - transport := tcp | tls | {tls, TlsOpts :: list()}, - path := atom() | string() | binary(), - errors => [error_mapping()], - ping_timeout => infinity | pos_integer(), - request_timeout => infinity | pos_integer() -}. - -% Type specfication of the messages that are sent to the handler: --type handler_messages() :: - {conn, pid(), connected} - | {conn, pid(), {request, method(), Params :: map() | list(), ReqRef :: binary() | integer()}} - | {conn, pid(), {notification, method(), Params :: map() | list()}} - | {conn, pid(), {response, Result :: term(), Ctx :: term()}} - | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), ErData :: term()}} - | {conn, pid(), {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), ErData :: term(), Ctx :: term()}} - | {conn, pid(), {local_error, Reason:: atom(), Ctx :: term()}}. - --export_type([error_mapping/0, method/0, handler_messages/0]). - - -%--- Macros -------------------------------------------------------------------- - --define(ENABLE_TRACE, false). --if(?ENABLE_TRACE =:= true). --define(TRACE_OUTPUT(ARG), ?GRISP_DEBUG("<<<<<<<<<< ~s", [ARG])). --define(TRACE_INPUT(ARG), ?GRISP_DEBUG(">>>>>>>>>> ~s", [ARG])). --else. --define(TRACE_OUTPUT(ARG), ok). --define(TRACE_INPUT(ARG), ok). --endif. - --define(DEFAULT_PING_TIMEOUT, 60_000). --define(DEFAULT_REQUEST_TIMEOUT, 5_000). --define(DEFAULT_TRANSPORT, tcp). - --define(DEFAULT_JSONRPC_ERRORS, [ - {invalid_json, -32700, <<"Parse error">>}, - {invalid_request, -32600, <<"Invalid Request">>}, - {method_not_found, -32601, <<"Method not found">>}, - {invalid_params, -32602, <<"Invalid parameters">>}, - {internal_error, -32603, <<"Internal error">>} -]). - --define(HANDLE_COMMON, - ?FUNCTION_NAME(EventType, EventContent, Data) -> - handle_common(EventType, EventContent, ?FUNCTION_NAME, Data)). - - -%--- API Functions ------------------------------------------------------------- - --spec start_link(Handler :: pid(), Options :: start_options()) -> - {ok, Conn :: pid()} | {error, Reason :: term()}. -start_link(Handler, Opts = #{domain := _, port := _, path := _}) -> - gen_statem:start_link(?MODULE, [Handler, Opts], []). - --spec request(Conn :: pid(), Method :: method(), Params :: map()) -> - {ok, Result :: term()} | {error, timeout} | {error, not_connected} - | {error, Code :: integer(), - Message :: undefined | binary(), ErData :: term()}. -request(Conn, Method, Params) -> - try gen_statem:call(Conn, {request, parse_method(Method), Params}) of - {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack); - Other -> Other - catch - exit:{noproc, _} -> {error, not_connected} - end. - --spec post(Conn :: pid(), Method :: method(), Params :: map(), - ReqCtx :: term()) -> ok | {error, not_connected}. -post(Conn, Method, Params, ReqCtx) -> - try gen_statem:call(Conn, {post, parse_method(Method), Params, ReqCtx}) of - {ok, CallResult} -> CallResult; - {error, _Reason} = Error -> Error; - {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) - catch - exit:{noproc, _} -> {error, not_connected} - end. - --spec notify(Conn :: pid(), Method :: method(), Params :: map()) -> - ok | {error, not_connected}. -notify(Conn, Method, Params) -> - try gen_statem:call(Conn, {notify, parse_method(Method), Params}) of - {ok, CallResult} -> CallResult; - {error, _Reason} = Error -> Error; - {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) - catch - exit:{noproc, _} -> {error, not_connected} - end. - --spec reply(Conn :: pid(), Result :: any(), ReqRef :: binary()) -> - ok | {error, not_connected}. -reply(Conn, Result, ReqRef) -> - try gen_statem:call(Conn, {reply, Result, ReqRef}) of - {ok, CallResult} -> CallResult; - {error, _Reason} = Error -> Error; - {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) - catch - exit:{noproc, _} -> {error, not_connected} - end. - --spec error(Conn :: pid(), Code :: atom() | integer(), - Message :: undefined | binary(), ErData :: term(), - ReqRef :: undefined | binary()) -> - ok | {error, not_connected}. -error(Conn, Code, Message, ErData, ReqRef) -> - try gen_statem:call(Conn, {error, Code, Message, ErData, ReqRef}) of - {ok, CallResult} -> CallResult; - {error, _Reason} = Error -> Error; - {exception, Class, Reason, Stack} -> erlang:raise(Class, Reason, Stack) - catch - exit:{noproc, _} -> {error, not_connected} - end. - --spec disconnect(Conn :: pid()) -> ok. -disconnect(Conn) -> - try gen_statem:call(Conn, disconnect) - catch - exit:{noproc, _} -> ok - end. - - -%--- Behaviour gen_statem Callbacks -------------------------------------------- - -callback_mode() -> [state_functions]. - -init([Handler, Opts]) -> - process_flag(trap_exit, true), % To ensure terminate/2 is called - #{domain := Domain, port := Port, path := Path} = Opts, - PingTimeout = maps:get(ping_timeout, Opts, ?DEFAULT_PING_TIMEOUT), - ReqTimeout = maps:get(request_timeout, Opts, ?DEFAULT_REQUEST_TIMEOUT), - Transport = maps:get(transport, Opts, ?DEFAULT_TRANSPORT), - Data = #data{ - handler = Handler, - uri = format_ws_uri(Transport, Domain, Port, Path), - domain = as_bin(Domain), - port = Port, - path = as_bin(Path), - ping_timeout = PingTimeout, - request_timeout = ReqTimeout - }, - index_errors(?DEFAULT_JSONRPC_ERRORS), - index_errors(maps:get(errors, Opts, [])), - case connection_start(Data, Transport) of - {ok, Data2} -> {ok, connecting, Data2}; - {error, Reason} -> {stop, Reason} - end. - -code_change(_Vsn, State, Data, _Extra) -> - {ok, State, Data}. - -terminate(_Reason, _State, Data) -> - connection_close(Data), - persistent_term:erase({?MODULE, self(), tags}), - persistent_term:erase({?MODULE, self(), codes}), - ok. - - -%--- Behaviour gen_statem State Callback Functions ----------------------------- - -connecting({call, From}, {request, Method, _Params}, _Data) -> - ?GRISP_INFO("Request ~s performed while connecting", - [format_method(Method)], - #{event => rpc_request_error, method => Method, - reason => not_connected}), - {keep_state_and_data, [{reply, From, {error, not_connected}}]}; -connecting({call, From}, {post, Method, _Params, ReqCtx}, - #data{handler = Handler}) -> - ?GRISP_INFO("Request ~s posted while connecting", - [format_method(Method)], - #{event => rpc_post_error, method => Method, - reason => not_connected}), - % Notify the handler anyway so it doesn't have to make a special case - Handler ! {conn, self(), {local_error, not_connected, ReqCtx}}, - {keep_state_and_data, [{reply, From, {error, not_connected}}]}; -connecting({call, From}, {notify, Method, _Params}, _Data) -> - ?GRISP_INFO("Notification ~s posted while connecting", - [format_method(Method)], - #{event => rpc_notify_error, method => Method, - reason => not_connected}), - {keep_state_and_data, [{reply, From, {error, not_connected}}]}; -connecting({call, From}, {reply, _Result, ReqRef}, Data) -> - ?GRISP_INFO("Reply to ~s posted while connecting", - [inbound_req_tag(Data, ReqRef)], - #{event => rpc_reply_error, ref => ReqRef, - method => inbound_method(Data, ReqRef), - reason => not_connected}), - {keep_state_and_data, [{reply, From, {error, not_connected}}]}; -connecting({call, From}, {error, Code, _Message, _ErData, undefined}, _Data) -> - ?GRISP_INFO("Error ~w posted while connecting", [Code], - #{event => rpc_error_error, code => Code, - reason => not_connected}), - {keep_state_and_data, [{reply, From, {error, not_connected}}]}; -connecting({call, From}, {error, Code, _Message, _ErData, ReqRef}, Data) -> - ?GRISP_INFO("Error ~w to ~s posted while connecting", - [Code, inbound_req_tag(Data, ReqRef)], - #{event => rpc_error_error, code => Code, ref => ReqRef, - reason => not_connected}), - {keep_state_and_data, [{reply, From, {error, not_connected}}]}; -connecting(info, {gun_up, GunPid, _}, Data = #data{gun_pid = GunPid}) -> - ?GRISP_DEBUG("Connection to ~s established", - [Data#data.uri], - #{event => ws_connection_enstablished, uri => Data#data.uri}), - {keep_state, connection_upgrade(Data)}; -connecting(info, {gun_upgrade, Pid, Stream, [<<"websocket">>], _}, - Data = #data{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_DEBUG("Connection to ~s upgraded to websocket", [Data#data.uri], - #{event => ws_upgraded, uri => Data#data.uri}), - {next_state, connected, connection_established(Data)}; - -connecting(info, {gun_response, Pid, Stream, _, Status, _Headers}, - Data = #data{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_INFO("Connection to ~s failed to upgrade to websocket: ~p", - [Data#data.uri, Status], - #{event => ws_upgrade_failed, uri => Data#data.uri, - status => Status}), - {stop, ws_upgrade_failed, connection_close(Data)}; -?HANDLE_COMMON. - -connected({call, From}, {request, Method, Params}, Data) -> - try send_request(Data, Method, Params, From, undefined) of - Data2 -> {keep_state, Data2} - catch - C:badarg:S -> - {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; - C:R:S -> - {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} - end; -connected({call, From}, {post, Method, Params, ReqCtx}, Data) -> - try send_request(Data, Method, Params, undefined, ReqCtx) of - Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} - catch - C:badarg:S -> - {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; - C:R:S -> - {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} - end; -connected({call, From}, {notify, Method, Params}, Data) -> - try send_notification(Data, Method, Params) of - Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} - catch - C:badarg:S -> - {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; - C:R:S -> - {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} - end; -connected({call, From}, {reply, Result, ReqRef}, Data) -> - try send_response(Data, Result, ReqRef) of - Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} - catch - C:badarg:S -> - {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; - C:R:S -> - {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} - end; -connected({call, From}, {error, Code, Message, ErData, ReqRef}, Data) -> - try send_error(Data, Code, Message, ErData, ReqRef) of - Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} - catch - C:badarg:S -> - {keep_state_and_data, [{reply, From, {exception, C, badarg, S}}]}; - C:R:S -> - {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} - end; -connected(info, {gun_ws, Pid, Stream, {text, Text}}, - Data = #data{gun_pid = Pid, ws_stream = Stream}) -> - {keep_state, process_text(Data, Text)}; -connected(info, ping_timeout, Data) -> - ?GRISP_INFO("Connection to ~s timed out", [Data#data.uri], - #{event => ws_ping_timeout, uri => Data#data.uri}), - {stop, normal, connection_close(Data)}; -connected(info, {outbound_timeout, ReqRef}, Data) -> - ?GRISP_INFO("Request ~s time to ~s timed out", - [outbound_req_tag(Data, ReqRef), Data#data.uri], - #{event => rpc_request_timeout_error, uri => Data#data.uri, - ref => ReqRef, method => outbound_method(Data, ReqRef)}), - {keep_state, outbound_timeout(Data, ReqRef)}; -?HANDLE_COMMON. - -handle_common({call, From}, disconnect, _StateName, Data) -> - try connection_close(Data) of - Data2 -> {keep_state, Data2, [{reply, From, {ok, ok}}]} - catch - C:R:S -> {stop_and_reply, R, [{reply, From, {exception, C, R, S}}]} - end; -handle_common(info, {gun_ws, Pid, Stream, ping}, _StateName, - Data = #data{gun_pid = Pid, ws_stream = Stream}) -> - {keep_state, schedule_ping_timeout(Data)}; -handle_common(info, {gun_ws, Pid, Stream, close}, StateName, - Data = #data{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_INFO("Connection to ~s closed without code", [Data#data.uri], - #{event => ws_stream_closed, uri => Data#data.uri, - state => StateName}), - {stop, normal, connection_closed(Data, closed)}; -handle_common(info, {gun_ws, Pid, Stream, {close, Code, Message}}, StateName, - Data = #data{gun_pid = Pid, ws_stream = Stream}) -> - ?GRISP_INFO("Connection to ~s closed: ~s (~w)", - [Data#data.uri, Message, Code], - #{event => ws_stream_closed, uri => Data#data.uri, - code => Code, reason => Message, state => StateName}), - {stop, normal, connection_closed(Data, closed)}; -handle_common(info, {gun_error, Pid, _Stream, Reason}, StateName, - Data = #data{gun_pid = Pid}) -> - ?GRISP_INFO("Connection to ~s got an error: ~p", - [Data#data.uri, Reason], - #{event => ws_error, uri => Data#data.uri, - reason => Reason, state => StateName}), - {stop, Reason, connection_close(Data)}; -handle_common(info, {gun_down, Pid, ws, Reason, [Stream]}, StateName, - Data = #data{gun_pid = Pid, ws_stream = Stream}) - when Reason =:= closed; Reason =:= {error, closed}; Reason =:= normal -> - ?GRISP_INFO("Connection to ~s was closed by the server", [Data#data.uri], - #{event => ws_closed_by_peer, uri => Data#data.uri, - reason => closed, state => StateName}), - {stop, normal, connection_close(Data)}; -handle_common(info, {'DOWN', _, process, Pid, Reason}, StateName, - Data = #data{gun_pid = Pid}) -> - ?GRISP_INFO("gun process of the connection to ~s crashed: ~p", - [Data#data.uri, Reason], - #{event => ws_gun_crash, uri => Data#data.uri, - reason => Reason, state => StateName}), - {stop, Reason, connection_closed(Data, gun_crashed)}; -handle_common(info, {gun_up, Pid, http} = Info, StateName, - #data{gun_pid = GunPid}) -> - ?GRISP_DEBUG("Ignoring unexpected gun_up message" - " from pid ~p, current pid is ~p", [Pid, GunPid], - #{event => unexpected_gun_message, message => Info, - state => StateName}), - keep_state_and_data; -handle_common({call, From}, Call, StateName, _Data) -> - ?GRISP_ERROR("Unexpected call from ~p to ~s: ~p", [From, ?MODULE, Call], - #{event => unexpected_call, from => From, message => Call, - state => StateName}), - {stop_and_reply, {unexpected_call, Call}, - [{reply, From, {error, unexpected_call}}]}; -handle_common(cast, Cast, StateName, _Data) -> - Reason = {unexpected_cast, Cast}, - ?GRISP_ERROR("Unexpected cast to ~s: ~p", [?MODULE, Cast], - #{event => unexpected_cast, message => Cast, - state => StateName}), - {stop, Reason}; -handle_common(info, Info, StateName, _Data) -> - ?GRISP_WARN("Unexpected info message to ~s: ~p", - [?MODULE, Info], - #{event => unexpected_info, message => Info, - state => StateName}), - keep_state_and_data. - - -%--- INTERNAL FUNCTION --------------------------------------------------------- - -format_ws_uri(Transport, Domain, Port, Path) -> - Proto = case Transport of - tcp -> <<"ws">>; - tls -> <<"wss">>; - {tls, _} -> <<"wss">> - end, - ?FORMAT("~s://~s:~w~s", [Proto, Domain, Port, Path]). - -make_reqref() -> - list_to_binary(integer_to_list(erlang:unique_integer())). - -send_after(infinity, _Message) -> undefined; -send_after(Timeout, Message) -> - erlang:send_after(Timeout, self(), Message). - -cancel_timer(undefined) -> ok; -cancel_timer(TRef) -> - erlang:cancel_timer(TRef). - -schedule_ping_timeout(Data = #data{ping_timeout = Timeout}) -> - Data2 = cancel_ping_timeout(Data), - TRef = send_after(Timeout, ping_timeout), - Data2#data{ping_tref = TRef}. - -cancel_ping_timeout(Data = #data{ping_tref = undefined}) -> - Data; -cancel_ping_timeout(Data = #data{ping_tref = TRef}) -> - cancel_timer(TRef), - Data#data{ping_tref = undefined}. - -% Returns either the method of the outbound request or its reference -% as a binary if not found. Only mean for logging. -outbound_req_tag(#data{outbound = ReqMap}, ReqRef) -> - case maps:find(ReqRef, ReqMap) of - error -> as_bin(ReqRef); - {ok, #outbound_req{method = Method}} -> format_method(Method) - end. - -outbound_method(#data{outbound = ReqMap}, ReqRef) -> - case maps:find(ReqRef, ReqMap) of - error -> undefined; - {ok, #outbound_req{method = Method}} -> Method - end. - -% Returns either the method of the inbound request or its reference -% as a binary if not found. Only mean for logging. -inbound_req_tag(#data{inbound = ReqMap}, ReqRef) -> - case maps:find(ReqRef, ReqMap) of - error -> as_bin(ReqRef); - {ok, #inbound_req{method = Method}} -> format_method(Method) - end. - -inbound_method(#data{inbound = ReqMap}, ReqRef) -> - case maps:find(ReqRef, ReqMap) of - error -> undefined; - {ok, #inbound_req{method = Method}} -> Method - end. - -index_errors(ErrorSpecs) -> - ErrorTags = persistent_term:get({?MODULE, self(), tags}, #{}), - ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), - {ErrorTags2, ErrorCodes2} = - lists:foldl(fun({Tag, Code, Msg}, {Tags, Codes}) -> - {Tags#{Tag => {Code, Msg}}, Codes#{Code => {Tag, Msg}}} - end, {ErrorTags, ErrorCodes}, ErrorSpecs), - % The error list is put in a persistent term to not add noise to the Data. - persistent_term:put({?MODULE, self(), tags}, ErrorTags2), - persistent_term:put({?MODULE, self(), codes}, ErrorCodes2), - ok. - -decode_error(Code, Message) - when is_integer(Code), Message =:= undefined -> - ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), - case maps:find(Code, ErrorCodes) of - error -> {Code, undefined}; - {ok, {Tag, DefaultMessage}} -> {Tag, DefaultMessage} - end; -decode_error(Code, Message) - when is_integer(Code) -> - ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), - case maps:find(Code, ErrorCodes) of - error -> {Code, Message}; - {ok, {Tag, _DefaultMessage}} -> {Tag, Message} - end. - -encode_error(Code, Message) - when is_integer(Code), Message =:= undefined -> - ErrorCodes = persistent_term:get({?MODULE, self(), codes}, #{}), - case maps:find(Code, ErrorCodes) of - error -> {Code, null}; - {ok, {_Tag, DefaultMessage}} -> {Code, DefaultMessage} - end; -encode_error(Code, Message) - when is_integer(Code) -> - {Code, Message}; -encode_error(Tag, Message) - when is_atom(Tag), Message =:= undefined -> - ErrorTags = persistent_term:get({?MODULE, self(), tags}, #{}), - case maps:find(Tag, ErrorTags) of - error -> erlang:error(badarg); - {ok, {Code, DefaultMessage}} -> {Code, DefaultMessage} - end; -encode_error(Tag, Message) - when is_atom(Tag) -> - ErrorTags = persistent_term:get({?MODULE, self(), tags}, #{}), - case maps:find(Tag, ErrorTags) of - error -> erlang:error(badarg); - {ok, {Code, _DefaultMessage}} -> {Code, Message} - end. - -connection_start(Data = #data{uri = Uri, domain = Domain, port = Port}, - TransportSpec) -> - BaseGunOpts = #{protocols => [http], retry => 0}, - GunOpts = case TransportSpec of - tcp -> BaseGunOpts#{transport => tcp}; - tls -> BaseGunOpts#{transport => tls}; - {tls, Opts} -> BaseGunOpts#{transport => tls, tls_opts => Opts} - end, - ?GRISP_DEBUG("Connecting to ~s", [Uri], - #{event => connecting, uri => Uri, options => GunOpts}), - case gun:open(binary_to_list(Domain), Port, GunOpts) of - {ok, GunPid} -> - GunRef = monitor(process, GunPid), - {ok, Data#data{gun_pid = GunPid, gun_ref = GunRef}}; - {error, Reason} -> - ?GRISP_ERROR("Failed to open connection to ~s: ~p", [Uri, Reason], - #{event => connection_failure, uri => Uri, - reason => Reason}), - {error, Reason} - end. - -connection_upgrade(Data = #data{path = Path, gun_pid = GunPid}) -> - WsStream = gun:ws_upgrade(GunPid, Path,[], #{silence_pings => false}), - Data#data{ws_stream = WsStream}. - -connection_established(Data = #data{handler = Handler}) -> - Handler ! {conn, self(), connected}, - schedule_ping_timeout(Data). - -connection_close(Data = #data{gun_pid = GunPid, gun_ref = GunRef}) - when GunPid =/= undefined, GunRef =/= undefined -> - demonitor(GunRef), - gun:shutdown(GunPid), - Data2 = cancel_ping_timeout(Data), - Data3 = requests_error(Data2, not_connected), - Data3#data{gun_pid = undefined, gun_ref = undefined, ws_stream = undefined}; -connection_close(Data) -> - requests_error(Data, not_connected). - -connection_closed(Data, Reason) -> - Data2 = cancel_ping_timeout(Data), - Data3 = requests_error(Data2, Reason), - Data3#data{gun_pid = undefined, gun_ref = undefined, ws_stream = undefined}. - -send_request(Data, Method, Params, From, Ctx) -> - {ReqRef, Data2} = outbound_add(Data, Method, From, Ctx), - Msg = {request, format_method(Method), Params, ReqRef}, - send_packet(Data2, Msg). - -send_notification(Data, Method, Params) -> - Msg = {notification, format_method(Method), Params}, - send_packet(Data, Msg). - -send_response(Data, Result, ReqRef) -> - Msg = {result, Result, ReqRef}, - inbound_response(Data, Msg, ReqRef). - -send_error(Data, Code, Message, ErData, ReqRef) -> - {Code2, Message2} = encode_error(Code, Message), - Msg = {error, Code2, Message2, ErData, ReqRef}, - case ReqRef of - undefined -> send_packet(Data, Msg); - _ -> inbound_response(Data, Msg, ReqRef) - end. - -send_packet(Data = #data{gun_pid = GunPid, ws_stream = Stream}, Packet) -> - Payload = grisp_connect_jsonrpc:encode(Packet), - ?TRACE_OUTPUT(Payload), - gun:ws_send(GunPid, Stream, {text, Payload}), - Data. - -process_text(Data = #data{batches = BatchMap}, Text) -> - ?TRACE_INPUT(Data), - DecodedData = grisp_connect_jsonrpc:decode(Text), - case DecodedData of - Messages when is_list(Messages) -> - BatchRef = make_ref(), - case process_messages(Data, BatchRef, 0, [], Messages) of - {ReqCount, Replies, Data2} when ReqCount > 0 -> - Batch = #batch{bref = BatchRef, refcount = ReqCount, - responses = Replies}, - Data2#data{batches = BatchMap#{BatchRef => Batch}}; - {_ReqCount, [], Data2} -> - Data2; - {_ReqCount, [_|_] = Replies, Data2} -> - % All the requests got a reply right away - send_packet(Data2, Replies) - end; - Message when is_tuple(Message) -> - case process_messages(Data, undefined, 0, [], [Message]) of - {_, [Reply], Data2} -> - send_packet(Data2, Reply); - {_, [], Data2} -> - Data2 - end - end. - -process_messages(Data, _BatchRef, ReqCount, Replies, []) -> - {ReqCount, lists:reverse(Replies), Data}; -process_messages(Data, BatchRef, ReqCount, Replies, - [{decoding_error, _, _, _, _} = Error | Rest]) -> - process_messages(Data, BatchRef, ReqCount, [Error | Replies], Rest); -process_messages(Data, BatchRef, ReqCount, Replies, - [{request, RawMethod, Params, ReqRef} | Rest]) -> - Method = parse_method(RawMethod), - Data2 = process_request(Data, BatchRef, Method, Params, ReqRef), - process_messages(Data2, BatchRef, ReqCount + 1, Replies, Rest); -process_messages(Data, BatchRef, ReqCount, Replies, - [{notification, RawMethod, Params} | Rest]) -> - Method = parse_method(RawMethod), - Data2 = process_notification(Data, Method, Params), - process_messages(Data2, BatchRef, ReqCount, Replies, Rest); -process_messages(Data, BatchRef, ReqCount, Replies, - [{result, Result, ReqRef} | Rest]) -> - {Data2, Replies2} = process_response(Data, Replies, Result, ReqRef), - process_messages(Data2, BatchRef, ReqCount, Replies2, Rest); -process_messages(Data, BatchRef, ReqCount, Replies, - [{error, Code, Message, ErData, ReqRef} | Rest]) -> - {Data2, Replies2} = process_error(Data, Replies, Code, - Message, ErData, ReqRef), - process_messages(Data2, BatchRef, ReqCount, Replies2, Rest). - -process_request(Data = #data{handler = Handler}, - BatchRef, Method, Params, ReqRef) -> - Handler ! {conn, self(), {request, Method, Params, ReqRef}}, - inbound_add(Data, BatchRef, Method, ReqRef). - -process_notification(Data = #data{handler = Handler}, Method, Params) -> - Handler ! {conn, self(), {notification, Method, Params}}, - Data. - -process_response(Data = #data{handler = Handler}, Replies, Result, ReqRef) -> - case outbound_del(Data, ReqRef) of - {error, not_found} -> - %FIXME: Not sure what error should be returned... - Error = {invalid_request, -32600, <<"Result for unknown request">>}, - {Data, [Error | Replies]}; - {ok, _Method, undefined, Ctx, Data2} -> - Handler ! {conn, self(), {response, Result, Ctx}}, - {Data2, Replies}; - {ok, _, From, _, Data2} -> - gen_statem:reply(From, {ok, Result}), - {Data2, Replies} - end. - -process_error(Data = #data{handler = Handler}, - Replies, Code, Message, ErData, undefined) -> - {Code2, Message2} = decode_error(Code, Message), - Handler ! {conn, self(), {remote_error, Code2, Message2, ErData}}, - {Data, Replies}; -process_error(Data = #data{handler = Handler}, - Replies, Code, Message, ErData, ReqRef) -> - case outbound_del(Data, ReqRef) of - {error, not_found} -> - %FIXME: Not sure what error should be returned... - Error = {invalid_request, -32600, <<"Error for unknown request">>}, - {Data, [Error | Replies]}; - {ok, _Method, undefined, Ctx, Data2} -> - {Code2, Message2} = decode_error(Code, Message), - Handler ! {conn, self(), {remote_error, Code2, Message2, ErData, Ctx}}, - {Data2, Replies}; - {ok, _, From, _, Data2} -> - {Code2, Message2} = decode_error(Code, Message), - gen_statem:reply(From, {remote_error, Code2, Message2, ErData}), - {Data2, Replies} - end. - -inbound_response(Data = #data{batches = BatchMap, inbound = ReqMap}, - Message, ReqRef) -> - case maps:take(ReqRef, ReqMap) of - error -> - ?GRISP_ERROR("Ask to send a response to the unknown request ~p", - [ReqRef], - #{event => internal_error, ref => ReqRef, - reason => unknown_request}), - Data; - {#inbound_req{bref = undefined}, ReqMap2} -> - % Not part of a batch response - send_packet(Data#data{inbound = ReqMap2}, Message); - {#inbound_req{bref = BatchRef}, ReqMap2} -> - % The batch must exists - case maps:find(BatchRef, BatchMap) of - {ok, #batch{refcount = 1, responses = Responses}} -> - % This is the last message of the batch - BatchMap2 = maps:remove(BatchRef, BatchMap), - send_packet(Data#data{batches = BatchMap2, - inbound = ReqMap2}, - [Message | Responses]); - {ok, Batch = #batch{refcount = RefCount, - responses = Responses}} -> - Batch2 = Batch#batch{refcount = RefCount - 1, - responses = [Message | Responses]}, - BatchMap2 = BatchMap#{BatchRef => Batch2}, - Data#data{batches = BatchMap2, inbound = ReqMap2} - end - end. - -inbound_add(Data = #data{inbound = ReqMap}, BatchRef, Method, ReqRef) -> - %TODO: Should we add a timeout for inbound requests ? - Req = #inbound_req{method = Method, id = ReqRef, bref = BatchRef}, - Data#data{inbound = ReqMap#{ReqRef => Req}}. - -outbound_add(Data = #data{request_timeout = Timeout, outbound = ReqMap}, - Method, From, Ctx) -> - ReqRef = make_reqref(), - TRef = send_after(Timeout, {outbound_timeout, ReqRef}), - Req = #outbound_req{id = ReqRef, method = Method, tref = TRef, - from = From, ctx = Ctx}, - {ReqRef, Data#data{outbound = ReqMap#{ReqRef => Req}}}. - -outbound_del(Data = #data{outbound = ReqMap}, ReqRef) -> - case maps:find(ReqRef, ReqMap) of - error -> {error, not_found}; - {ok, #outbound_req{method = Method, tref = TRef, - from = From, ctx = Ctx}} -> - cancel_timer(TRef), - Data2 = Data#data{outbound = maps:remove(ReqRef, ReqMap)}, - {ok, Method, From, Ctx, Data2} - end. - -outbound_timeout(Data = #data{handler = Handler}, ReqRef) -> - case outbound_del(Data, ReqRef) of - {error, not_found} -> - ?GRISP_WARN("Timeout for unknown request ~s", [ReqRef], - #{event => internal_error, ref => ReqRef, - reason => unknown_request}), - Data; - {ok, _Method, undefined, Ctx, Data2} -> - Handler ! {conn, self(), {local_error, timeout, Ctx}}, - Data2; - {ok, _, From, _, Data2} -> - gen_statem:reply(From, {error, timeout}), - Data2 - end. - -requests_error(Data = #data{handler = Handler, outbound = ReqMap}, Reason) -> - maps:foreach(fun - (_, #outbound_req{from = undefined, ctx = Ctx}) -> - Handler ! {conn, self(), {local_error, Reason, Ctx}}; - (_, #outbound_req{from = From, ctx = undefined}) -> - gen_statem:reply(From, {error, Reason}) - end, ReqMap), - Data#data{outbound = #{}}. diff --git a/src/grisp_connect_internal.hrl b/src/grisp_connect_internal.hrl deleted file mode 100644 index a037dfb..0000000 --- a/src/grisp_connect_internal.hrl +++ /dev/null @@ -1,25 +0,0 @@ --ifndef(GRISP_CONNECT_INTERNAL_HRL). --define(GRISP_CONNECT_INTERNAL_HRL, true). - --include_lib("kernel/include/logger.hrl"). - --define(FORMAT(FMT, ARGS), iolist_to_binary(io_lib:format(FMT, ARGS))). - --define(GRISP_DEBUG(FMT, ARGS), - ?LOG_DEBUG(FMT, ARGS)). --define(GRISP_DEBUG(FMT, ARGS, REPORT), - ?LOG_DEBUG(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). --define(GRISP_INFO(FMT, ARGS), - ?LOG_INFO(FMT, ARGS)). --define(GRISP_INFO(FMT, ARGS, REPORT), - ?LOG_INFO(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). --define(GRISP_WARN(FMT, ARGS), - ?LOG_WARNING(FMT, ARGS)). --define(GRISP_WARN(FMT, ARGS, REPORT), - ?LOG_WARNING(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). --define(GRISP_ERROR(FMT, ARGS), - ?LOG_ERROR(FMT, ARGS)). --define(GRISP_ERROR(FMT, ARGS, REPORT), - ?LOG_ERROR(maps:put(description, ?FORMAT(FMT, ARGS), REPORT))). - --endif. % GRISP_CONNECT_INTERNAL_HRL diff --git a/src/grisp_connect_jsonrpc.erl b/src/grisp_connect_jsonrpc.erl deleted file mode 100644 index 5029387..0000000 --- a/src/grisp_connect_jsonrpc.erl +++ /dev/null @@ -1,190 +0,0 @@ --module(grisp_connect_jsonrpc). - -% API --export([decode/1]). --export([encode/1]). - - -%--- Types --------------------------------------------------------------------- - --type json_rpc_message() :: - {request, Method :: binary(), Params :: map() | list(), - ReqRef :: binary() | integer()} - | {result, Result :: term(), ReqRef :: binary()} - | {notification, Method :: binary(), Params :: map() | list()} - | {error, Code :: integer(), Message :: undefined | binary(), - Data :: undefined | term(), ReqRef :: undefined | binary() | integer()} - | {decoding_error, Code :: integer(), Message :: undefined | binary(), - Data :: undefined | term(), ReqRef :: undefined | binary() | integer()}. - - - -%--- Macros -------------------------------------------------------------------- - --define(V, jsonrpc => <<"2.0">>). --define(is_valid(Message), - (map_get(jsonrpc, Message) == <<"2.0">>) -). --define(is_method(Method), - (is_atom(Method) orelse is_binary(Method)) -). --define(is_params(Params), - (is_map(Params) orelse is_list(Params)) -). --define(is_id(ID), - (is_binary(ID) orelse is_integer(ID)) -). - - -%--- API ----------------------------------------------------------------------- - -%% @doc Decode a JSONRpc text packet and decoded message or a list of decoded -%% messages in the case of a batch. -%% -%% If it returns a list, the all the responses are supposed to be sent back in -%% a batch too, as per the JSONRpc 2.0 specifications. -%% -%% If some decoding errors occure, a special error message with the tag -%% `decoding_error' will be returned, this message can be encoded and sent back -%% directly to the JSON-RPC peer. -%% -%% During JSON decoding, the `null' values are changed to `undefined' and when -%% encoding, `undefined' values are changed back to `null'. -%% -%% The `method' will always be a binary, and `id' will always be either -%% a binary or an integer. -%% -%%

The possible decoded messages are: -%%

    -%%
  • `{request, Method :: binary(), Params :: map() | list(), ReqRef :: binary() | integer()}'
  • -%%
  • `{result, Result :: term(), ReqRef :: binary()}'
  • -%%
  • `{notification, Method :: binary(), Params :: map() | list()}'
  • -%%
  • `{error, Code :: integer(), Message :: undefined | binary(), Data :: undefined | term(), ReqRef :: undefined | binary() | integer()}'
  • -%%
  • `{decoding_error, Code :: integer(), Message :: undefined | binary(), Data :: undefined | term(), ReqRef :: undefined | binary() | integer()}'
  • -%%

--spec decode(Data :: iodata()) -> json_rpc_message() | [json_rpc_message()]. -decode(Data) -> - case json_to_term(iolist_to_binary(Data)) of - [] -> - {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}; - Messages when is_list(Messages) -> - [unpack(M) || M <- Messages]; - Message when is_map(Message) -> - unpack(Message); - {error, _Reason} -> - % Even though the data could have been a batch, we return a single - % error, as per JSON-RPC specifications - {decoding_error, -32700, <<"Parse error">>, undefined, undefined} - end. - -%% @doc Encode a JSONRpc message or a list of JSONRpc messages to JSON text. -%% For backward compatibility, the `method' can be an atom. --spec encode(Messages :: json_rpc_message() | [json_rpc_message()]) -> iodata(). -encode(Messages) when is_list(Messages) -> - term_to_json([pack(M) || M <- Messages]); -encode(Message) -> - term_to_json(pack(Message)). - - -%--- Internal ------------------------------------------------------------------ - -as_bin(undefined) -> undefined; -as_bin(Binary) when is_binary(Binary) -> Binary; -as_bin(List) when is_list(List) -> list_to_binary(List). - -as_id(undefined) -> undefined; -as_id(Integer) when is_integer(Integer) -> Integer; -as_id(Binary) when is_binary(Binary) -> Binary; -as_id(List) when is_list(List) -> list_to_binary(List). - -unpack(#{method := Method, params := Params, id := ID} = M) - when ?is_valid(M), ?is_method(Method), ?is_params(Params), ID =/= undefined -> - {request, as_bin(Method), Params, as_id(ID)}; -unpack(#{method := Method, id := ID} = M) - when ?is_valid(M), ?is_method(Method), ID =/= undefined -> - {request, as_bin(Method), undefined, as_id(ID)}; -unpack(#{method := Method, params := Params} = M) - when ?is_valid(M), ?is_method(Method), ?is_params(Params) -> - {notification, as_bin(Method), Params}; -unpack(#{method := Method} = M) - when ?is_valid(M), ?is_method(Method) -> - {notification, as_bin(Method), undefined}; -unpack(#{result := Result, id := ID} = M) - when ?is_valid(M) -> - {result, Result, as_id(ID)}; -unpack(#{error := #{code := Code, message := Message, data := Data}, - id := ID} = M) - when ?is_valid(M), is_integer(Code) -> - {error, Code, as_bin(Message), Data, as_id(ID)}; -unpack(#{error := #{code := Code, message := Message}, id := ID} = M) - when ?is_valid(M), is_integer(Code) -> - {error, Code, as_bin(Message), undefined, as_id(ID)}; -unpack(#{id := ID}) -> - {decoding_error, -32600, <<"Invalid Request">>, undefined, as_id(ID)}; -unpack(_M) -> - {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}. - -pack({request, Method, undefined, ID}) - when is_binary(Method) orelse is_atom(Method), ?is_id(ID) -> - #{?V, method => Method, id => ID}; -pack({request, Method, Params, ID}) - when is_binary(Method) orelse is_atom(Method), - Params =:= undefined orelse ?is_params(Params), - ?is_id(ID) -> - #{?V, method => Method, params => Params, id => ID}; -pack({notification, Method, undefined}) - when is_binary(Method) orelse is_atom(Method) -> - #{?V, method => Method}; -pack({notification, Method, Params}) - when is_binary(Method), Params =:= undefined orelse ?is_params(Params) -> - #{?V, method => Method, params => Params}; -pack({result, Result, ID}) - when ?is_id(ID) -> - #{?V, result => Result, id => ID}; -pack({ErrorTag, Code, Message, undefined, undefined}) - when ErrorTag =:= error orelse ErrorTag =:= decoding_error, is_integer(Code), - Message =:= undefined orelse is_binary(Message) -> - #{?V, error => #{code => Code, message => Message}, id => null}; -pack({ErrorTag, Code, Message, undefined, ID}) - when ErrorTag =:= error orelse ErrorTag =:= decoding_error, is_integer(Code), - Message =:= undefined orelse is_binary(Message), ?is_id(ID) -> - #{?V, error => #{code => Code, message => Message}, id => ID}; -pack({ErrorTag, Code, Message, Data, undefined}) - when ErrorTag =:= error orelse ErrorTag =:= decoding_error, is_integer(Code), - Message =:= undefined orelse is_binary(Message) -> - #{?V, error => #{code => Code, message => Message, data => Data, id => null}}; -pack({ErrorTag, Code, Message, Data, ID}) - when ErrorTag =:= error orelse ErrorTag =:= decoding_error, is_integer(Code), - Message =:= undefined orelse is_binary(Message), ?is_id(ID) -> - #{?V, error => #{code => Code, message => Message, data => Data}, id => ID}; -pack(Message) -> - erlang:error({badarg, Message}). - -json_to_term(Bin) -> - try jsx:decode(Bin, [{labels, attempt_atom}, return_maps]) of - Json -> postprocess(Json) - catch - error:E -> {error, E} - end. - -term_to_json(Term) -> - jsx:encode(preprocess(Term)). - -postprocess(null) -> undefined; -postprocess(Integer) when is_integer(Integer) -> Integer; -postprocess(Float) when is_float(Float) -> Float; -postprocess(Binary) when is_binary(Binary) -> Binary; -postprocess(List) when is_list(List) -> - [postprocess(E) || E <- List]; -postprocess(Map) when is_map(Map) -> - maps:map(fun(_K, V) -> postprocess(V) end, Map). - -preprocess(undefined) -> null; -preprocess(Atom) when is_atom(Atom) -> Atom; -preprocess(Integer) when is_integer(Integer) -> Integer; -preprocess(Float) when is_float(Float) -> Float; -preprocess(Binary) when is_binary(Binary) -> Binary; -preprocess(List) when is_list(List) -> - [preprocess(E) || E <- List]; -preprocess(Map) when is_map(Map) -> - maps:map(fun(_K, V) -> preprocess(V) end, Map). diff --git a/src/grisp_connect_utils.erl b/src/grisp_connect_utils.erl deleted file mode 100644 index 9a73457..0000000 --- a/src/grisp_connect_utils.erl +++ /dev/null @@ -1,45 +0,0 @@ --module(grisp_connect_utils). - -% API Functions --export([as_bin/1]). --export([maybe_atom/1]). --export([parse_method/1]). --export([format_method/1]). - - -%--- API Functions ------------------------------------------------------------- - -as_bin(Binary) when is_binary(Binary) -> Binary; -as_bin(List) when is_list(List) -> list_to_binary(List); -as_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom). - -maybe_atom(Bin) when is_binary(Bin) -> - try binary_to_existing_atom(Bin) - catch error:badarg -> Bin - end. - -parse_method(List) when is_list(List) -> check_method(List); -parse_method(Atom) when is_atom(Atom) -> [Atom]; -parse_method(Binary) when is_binary(Binary) -> - [maybe_atom(B) || B <- binary:split(Binary, <<".">>, [global])]. - -format_method(Binary) when is_binary(Binary) -> Binary; -format_method(Atom) when is_atom(Atom) -> atom_to_binary(Atom); -format_method(List) when is_list(List) -> - iolist_to_binary(lists:join(<<".">>, [as_bin(E) || E <- List])). - - -%--- Internal Functions -------------------------------------------------------- - -check_method(Method) when is_list(Method) -> - check_method(Method, Method); -check_method(_Method) -> - erlang:exit(badarg). - -check_method(Method, []) -> Method; -check_method(Method, [Atom | Rest]) when is_atom(Atom) -> - check_method(Method, Rest); -check_method(Method, [Bin | Rest]) when is_binary(Bin) -> - check_method(Method, Rest); -check_method(_Method, _Rest) -> - erlang:error(badarg). diff --git a/test/grisp_connect_connection_SUITE.erl b/test/grisp_connect_connection_SUITE.erl deleted file mode 100644 index 81bb8ed..0000000 --- a/test/grisp_connect_connection_SUITE.erl +++ /dev/null @@ -1,330 +0,0 @@ --module(grisp_connect_connection_SUITE). - --behaviour(ct_suite). --include_lib("common_test/include/ct.hrl"). --include_lib("stdlib/include/assert.hrl"). --include("grisp_connect_test.hrl"). - --compile([export_all, nowarn_export_all]). - --import(grisp_connect_test_async, [async_eval/1]). --import(grisp_connect_test_async, [async_get_result/1]). - --import(grisp_connect_test_server, [flush/0]). --import(grisp_connect_test_server, [send_text/1]). --import(grisp_connect_test_server, [send_jsonrpc_request/3]). --import(grisp_connect_test_server, [send_jsonrpc_notification/2]). --import(grisp_connect_test_server, [send_jsonrpc_result/2]). --import(grisp_connect_test_server, [send_jsonrpc_error/3]). - - -%--- MACROS -------------------------------------------------------------------- - --define(fmt(Fmt, Args), lists:flatten(io_lib:format(Fmt, Args))). --define(assertConnRequest(Conn, M, P, R), fun() -> - receive {conn, Conn, {request, M, P = Result, R}} -> Result - after 1000 -> - ?assert(false, ?fmt("The client connection did not receive request ~s ~s ~s", - [??M, ??P, ??P])) - end -end()). --define(assertConnNotification(Conn, M, P), fun() -> - receive {conn, Conn, {notification, M, P = Result}} -> Result - after 1000 -> - ?assert(false, ?fmt("The client did not receive notification ~s ~s", - [??M, ??P])) - end -end()). --define(assertConnResponse(Conn, V, X), fun() -> - receive {conn, Conn, {response, V = Result, X}} -> Result - after 1000 -> - ?assert(false, ?fmt("The client connection did not receive response ~s ~s", - [??V, ??X])) - end -end()). --define(assertConnRemoteError(Conn, C, M, D, X), fun() -> - receive {conn, Conn, {remote_error, C, M, D, X}} -> ok - after 1000 -> - ?assert(false, ?fmt("The client connection did not receive remote error ~s ~s ~s ~s", - [??C, ??M, ??D, ??X])) - end -end()). --define(assertConnRemoteError(Conn, C, M, D), fun() -> - receive {conn, Conn, {remote_error, C, M, D}} -> ok - after 1000 -> - ?assert(false, ?fmt("The client connection did not receive remote error ~s ~s ~s", - [??C, ??M, ??D])) - end -end()). --define(assertConnLocalError(Conn, R, X), fun() -> - receive {conn, Conn, {local_error, R, X}} -> ok - after 1000 -> - ?assert(false, ?fmt("The client connection did not receive local error ~s ~s", - [??R, ??X])) - end -end()). - - -%--- API ----------------------------------------------------------------------- - -all() -> - [ - F - || - {F, 1} <- ?MODULE:module_info(exports), - lists:suffix("_test", atom_to_list(F)) - ]. - -init_per_suite(Config) -> - {ok, _} = application:ensure_all_started(gun), - Apps = grisp_connect_test_server:start(), - [{apps, Apps} | Config]. - -end_per_suite(Config) -> - grisp_connect_test_server:stop(?config(apps, Config)). - -init_per_testcase(_TestCase, Config) -> - {ok, _} = application:ensure_all_started(grisp_emulation), - Conn = connect(), - [{conn, Conn} | Config]. - -end_per_testcase(_TestCase, Config) -> - Conn = proplists:get_value(conn, Config), - disconnect(Conn), - grisp_connect_test_server:wait_disconnection(), - ?assertEqual([], flush()), - Config. - - -%--- Tests --------------------------------------------------------------------- - -basic_server_notifications_test(Config) -> - Conn = proplists:get_value(conn, Config), - send_jsonrpc_notification(<<"ping">>, #{foo => null}), - ?assertConnNotification(Conn, [ping], #{foo := undefined}), - send_jsonrpc_notification(<<"foo.bar.ping">>, #{}), - ?assertConnNotification(Conn, [foo, bar, ping], _), - send_jsonrpc_notification(<<"foo.bar.NotAnAtom">>, #{}), - ?assertConnNotification(Conn, [foo, bar, <<"NotAnAtom">>], _), - ok. - -basic_client_notifications_test(Config) -> - Conn = proplists:get_value(conn, Config), - grisp_connect_connection:notify(Conn, ping, #{foo => undefined}), - ?receiveNotification(<<"ping">>, #{foo := null}), - grisp_connect_connection:notify(Conn, [foo, bar, ping], #{}), - ?receiveNotification(<<"foo.bar.ping">>, _), - grisp_connect_connection:notify(Conn, [foo, bar, <<"NotAnAtom">>], #{}), - ?receiveNotification(<<"foo.bar.NotAnAtom">>, _), - ok. - -basic_server_request_test(Config) -> - Conn = proplists:get_value(conn, Config), - send_jsonrpc_request(<<"toto">>, #{}, 1), - ?assertConnRequest(Conn, [toto], _, 1), - grisp_connect_connection:reply(Conn, <<"spam">>, 1), - ?receiveResult(<<"spam">>, 1), - send_jsonrpc_request(<<"foo.bar.tata">>, #{}, 2), - ?assertConnRequest(Conn, [foo, bar, tata], _, 2), - grisp_connect_connection:error(Conn, error1, undefined, undefined, 2), - ?receiveError(-1, <<"Error Number 1">>, 2), - send_jsonrpc_request(<<"foo.bar.toto">>, #{}, 3), - ?assertConnRequest(Conn, [foo, bar, toto], _, 3), - grisp_connect_connection:error(Conn, error2, <<"Custom">>, undefined, 3), - ?receiveError(-2, <<"Custom">>, 3), - send_jsonrpc_request(<<"foo.bar.titi">>, #{}, 4), - ?assertConnRequest(Conn, [foo, bar, titi], _, 4), - grisp_connect_connection:error(Conn, -42, <<"Message">>, undefined, 4), - ?receiveError(-42, <<"Message">>, 4), - ok. - -basic_client_synchronous_request_test(Config) -> - Conn = proplists:get_value(conn, Config), - Async1 = async_eval(fun() -> grisp_connect_connection:request(Conn, [toto], #{}) end), - Id1 = ?receiveRequest(<<"toto">>, _), - send_jsonrpc_result(<<"spam">>, Id1), - ?assertEqual({ok, <<"spam">>}, async_get_result(Async1)), - Async2 = async_eval(fun() -> grisp_connect_connection:request(Conn, tata, #{}) end), - Id2 = ?receiveRequest(<<"tata">>, _), - send_jsonrpc_error(-1, null, Id2), - ?assertEqual({remote_error, error1, <<"Error Number 1">>, undefined}, async_get_result(Async2)), - Async3 = async_eval(fun() -> grisp_connect_connection:request(Conn, titi, #{}) end), - Id3 = ?receiveRequest(<<"titi">>, _), - send_jsonrpc_error(-2, <<"Custom">>, Id3), - ?assertEqual({remote_error, error2, <<"Custom">>, undefined}, async_get_result(Async3)), - ok. - -basic_client_asynchronous_request_test(Config) -> - Conn = proplists:get_value(conn, Config), - grisp_connect_connection:post(Conn, toto, #{}, ctx1), - Id1 = ?receiveRequest(<<"toto">>, _), - send_jsonrpc_result(<<"spam">>, Id1), - ?assertConnResponse(Conn, <<"spam">>, ctx1), - grisp_connect_connection:post(Conn, tata, #{}, ctx2), - Id2 = ?receiveRequest(<<"tata">>, _), - send_jsonrpc_error(-1, null, Id2), - ?assertConnRemoteError(Conn, error1, <<"Error Number 1">>, undefined, ctx2), - grisp_connect_connection:post(Conn, titi, #{}, ctx3), - Id3 = ?receiveRequest(<<"titi">>, _), - send_jsonrpc_error(-2, <<"Custom">>, Id3), - ?assertConnRemoteError(Conn, error2, <<"Custom">>, undefined, ctx3), - ok. - -basic_error_test(Config) -> - Conn = proplists:get_value(conn, Config), - grisp_connect_connection:error(Conn, -1, undefined, undefined, undefined), - ?receiveError(-1, <<"Error Number 1">>, null), - send_jsonrpc_error(-2, null, null), - ?assertConnRemoteError(Conn, error2, <<"Error Number 2">>, undefined), - ok. - -request_timeout_test(Config) -> - Conn = proplists:get_value(conn, Config), - grisp_connect_connection:post(Conn, toto, #{}, ctx1), - _Id1 = ?receiveRequest(<<"toto">>, _), - timer:sleep(500), - ?assertConnLocalError(Conn, timeout, ctx1), - ok. - -spec_example_test(Config) -> - DataDir = proplists:get_value(data_dir, Config), - ExamplesFile = filename:join(DataDir, "jsonrpc_examples.txt"), - - Conn = proplists:get_value(conn, Config), - - {ok, ExData} = file:read_file(ExamplesFile), - Examples = parse_examples(ExData), - maps:foreach(fun(Desc, Actions) -> - try - lists:foreach(fun - ({send, Text}) -> - send_text(Text); - ({recv, Expected}) when is_list(Expected) -> - example_handler(Conn), - SortedExpected = lists:sort(Expected), - Received = grisp_connect_test_server:receive_jsonrpc(), - ?assert(is_list(Received), - ?fmt("Invalid response to a batch request during ~s: ~p", - [Desc, Received])), - SortedReceived = lists:sort(Received), - ?assertEqual(SortedExpected, SortedReceived, - ?fmt("Invalid response during ~s", [Desc])); - ({recv, Expected}) -> - example_handler(Conn), - Received = grisp_connect_test_server:receive_jsonrpc(), - ?assertEqual(Expected, Received, - ?fmt("Invalid response during ~s", [Desc])) - end, Actions), - example_handler(Conn), - RemMsgs = flush(), - ?assertEqual([], RemMsgs, - ?fmt("Unexpected message during example ~s: ~p", - [Desc, RemMsgs])) - catch - error:timeout -> - ?assert(false, ?fmt("Timeout while testing example ~s", [Desc])) - end - end, Examples), - - ok. - - -%--- Internal Functions -------------------------------------------------------- - -connect() -> - connect(#{}). - -connect(Opts) -> - DefaultOpts = #{domain => localhost, port => 3030, transport => tcp, - path => <<"/grisp-connect/ws">>, - request_timeout => 300, - errors => [ - {error1, -1, <<"Error Number 1">>}, - {error2, -2, <<"Error Number 2">>} - ]}, - ConnOpts = maps:merge(DefaultOpts, Opts), - {ok, Conn} = grisp_connect_connection:start_link(self(), ConnOpts), - receive - {conn, Conn, connected} -> - grisp_connect_test_server:listen(), - Conn - after - 1000 -> - ?assert(false, "Connection to test server failed") - end. - -disconnect(Conn) -> - unlink(Conn), - grisp_connect_connection:disconnect(Conn), - ok. - -example_handler(Conn) -> - receive - {conn, Conn, {request, [subtract], [A, B], ReqRef}} -> - grisp_connect_connection:reply(Conn, A - B, ReqRef), - example_handler(Conn); - {conn, Conn, {request, [subtract], #{minuend := A, subtrahend := B}, ReqRef}} -> - grisp_connect_connection:reply(Conn, A - B, ReqRef), - example_handler(Conn); - {conn, Conn, {request, [sum], Values, ReqRef}} -> - Result = lists:foldl(fun(V, Acc) -> V + Acc end, 0, Values), - grisp_connect_connection:reply(Conn, Result, ReqRef), - example_handler(Conn); - {conn, Conn, {request, [get_data], _, ReqRef}} -> - grisp_connect_connection:reply(Conn, [<<"hello">>, 5], ReqRef), - example_handler(Conn); - {conn, Conn, {request, _M, _P, ReqRef}} -> - grisp_connect_connection:error(Conn, method_not_found, undefined, undefined, ReqRef), - example_handler(Conn); - {conn, Conn, {notification, _M, _P}} -> - example_handler(Conn); - % {conn, Conn, {response, R, Ctx :: term()}} - {conn, Conn, {remote_error, _C, _M, _D}} -> - example_handler(Conn) - after - 100 -> ok - end. - -parse_examples(Data) when is_binary(Data) -> - Lines = binary:split(Data, <<"\n">>, [global, trim]), - parse_examples_lines(Lines, #{}, undefined, undefined). - -parse_examples_lines([], Acc, undefined, _Actions) -> - Acc; -parse_examples_lines([], Acc, Desc, Actions) -> - Acc#{Desc => lists:reverse(Actions)}; -parse_examples_lines([<<"-->", RestBin/binary>> | RestLines], Acc, Desc, Actions) - when Desc =/= undefined -> - {Raw, RestLines2} = parse_examples_collect([RestBin | RestLines], <<>>), - parse_examples_lines(RestLines2, Acc, Desc, [{send, Raw} | Actions]); -parse_examples_lines([<<"<--", RestBin/binary>> | RestLines], Acc, Desc, Actions) - when Desc =/= undefined -> - case parse_examples_collect([RestBin | RestLines], <<>>) of - {<<"">>, RestLines2} -> - parse_examples_lines(RestLines2, Acc, Desc, Actions); - {Raw, RestLines2} -> - Decoded = jsx:decode(Raw, [{labels, attempt_atom}, return_maps]), - parse_examples_lines(RestLines2, Acc, Desc, [{recv, Decoded} | Actions]) - end; -parse_examples_lines([Line | RestLines], Acc, Desc, Actions) -> - case re:replace(Line, "^\\s+|\\s+$", "", [{return, binary}, global]) of - <<"">> -> parse_examples_lines(RestLines, Acc, Desc, Actions); - <<"//", _/binary>> -> parse_examples_lines(RestLines, Acc, Desc, Actions); - NewDesc -> - NewAcc = case Desc =/= undefined of - true -> Acc#{Desc => lists:reverse(Actions)}; - false -> Acc - end, - NewDesc2 = re:replace(NewDesc, ":+$", "", [{return, binary}, global]), - parse_examples_lines(RestLines, NewAcc, NewDesc2, []) - end. - -parse_examples_collect([], Acc) -> {Acc, []}; -parse_examples_collect([Line | RestLines], Acc) -> - case re:replace(Line, "^\\s+|\\s+$", "", [{return, binary}, global]) of - <<"">> -> {Acc, RestLines}; - <<"-->", _/binary>> -> {Acc, [Line | RestLines]}; - <<"<--", _/binary>> -> {Acc, [Line | RestLines]}; - <<"//", _/binary>> -> parse_examples_collect(RestLines, Acc); - Line2 -> parse_examples_collect(RestLines, <>) - end. diff --git a/test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt b/test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt deleted file mode 100644 index f6c0fad..0000000 --- a/test/grisp_connect_connection_SUITE_data/jsonrpc_examples.txt +++ /dev/null @@ -1,96 +0,0 @@ -// JSON-RPC Examples from specification -// See: https://www.jsonrpc.org/specification#examples - -rpc call with positional parameters: - ---> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1} -<-- {"jsonrpc": "2.0", "result": 19, "id": 1} - ---> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2} -<-- {"jsonrpc": "2.0", "result": -19, "id": 2} - ---> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2} -<-- {"jsonrpc": "2.0", "result": -19, "id": 2} - -rpc call with named parameters: - ---> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3} -<-- {"jsonrpc": "2.0", "result": 19, "id": 3} - ---> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4} -<-- {"jsonrpc": "2.0", "result": 19, "id": 4} - -a Notification: - ---> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]} ---> {"jsonrpc": "2.0", "method": "foobar"} - -rpc call of non-existent method: - ---> {"jsonrpc": "2.0", "method": "foobar", "id": "1"} -<-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"} - -rpc call with invalid JSON: - ---> {"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz] -<-- {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null} - -rpc call with invalid Request object: - ---> {"jsonrpc": "2.0", "method": 1, "params": "bar"} -<-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} - -rpc call Batch, invalid JSON: - ---> [ - {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, - {"jsonrpc": "2.0", "method" -] -<-- {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null} - -rpc call with an empty Array: - ---> [] -<-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} - -rpc call with an invalid Batch (but not empty): - ---> [1] -<-- [ - {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} -] - -rpc call with invalid Batch: - ---> [1,2,3] -<-- [ - {"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} -] - -rpc call Batch: - ---> [ - {"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"} - ] -<-- [ - {"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"} - ] - -rpc call Batch (all notifications): - ---> [ - {"jsonrpc": "2.0", "method": "notify_sum", "params": [1,2,4]}, - {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]} - ] -<-- //Nothing is returned for all notification batches diff --git a/test/grisp_connect_jsonrpc_SUITE.erl b/test/grisp_connect_jsonrpc_SUITE.erl deleted file mode 100644 index abc1e14..0000000 --- a/test/grisp_connect_jsonrpc_SUITE.erl +++ /dev/null @@ -1,118 +0,0 @@ --module(grisp_connect_jsonrpc_SUITE). - --include_lib("stdlib/include/assert.hrl"). - --export([all/0]). - --export([positional_parameters/1, - named_parameters/1, - using_existing_atoms/1, - notification/1, - invalid_json/1, - invalid_request/1, - batch/1, - result/1, - null_values/1]). - -all() -> [ - positional_parameters, - named_parameters, - using_existing_atoms, - notification, - invalid_json, - invalid_request, - batch, - result, - null_values -]. - -positional_parameters(_) -> - Term = {request, <<"subtract">>, [42,23], 1}, - Json = <<"{\"id\":1,\"jsonrpc\":\"2.0\",\"method\":\"subtract\",\"params\":[42,23]}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - Json2 = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"id\":1">>, - <<"\"method\":\"subtract\"">>, - <<"\"params\":[42,23]">>], Json2)). - -named_parameters(_) -> - Term = {request, <<"divide">>, #{<<"dividend">> => 42, <<"divisor">> => 2}, 2}, - Json = <<"{\"id\":2,\"jsonrpc\":\"2.0\",\"method\":\"divide\",\"params\":{\"dividend\":42,\"divisor\":2}}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - Json2 = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"id\":2">>, - <<"\"method\":\"divide\"">>, - <<"\"dividend\":42">>, - <<"\"divisor\":2">>], Json2)). - -using_existing_atoms(_) -> - % The ID and method are matching existing atoms, checks they are not atoms - Term = {request, <<"notification">>, #{}, <<"request">>}, - Json = <<"{\"id\":\"request\",\"jsonrpc\":\"2.0\",\"method\":\"notification\",\"params\":{}}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - Json2 = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"id\":\"request\"">>, <<"\"method\":\"notification\"">>], Json2)). - -notification(_) -> - Term = {notification, <<"update">>, [1,2,3,4,5]}, - Json = <<"{\"jsonrpc\":\"2.0\",\"method\":\"update\",\"params\":[1,2,3,4,5]}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - Json2 = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"method\":\"update\"">>, - <<"\"params\":[1,2,3,4,5]">>], Json2)). - -invalid_json(_) -> - Term = {decoding_error, -32700, <<"Parse error">>, undefined, undefined}, - Json = <<"{\"jsonrpc\":\"2.0\",\"method\":\"foobar,\"params\":\"bar\",\"baz]">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - JsonError = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"error\":{">>, - <<"\"code\":-32700">>, - <<"\"message\":\"Parse error\"">>, - <<"\"id\":null">>], JsonError)). - -invalid_request(_) -> - Term = {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}, - Json = <<"{\"jsonrpc\":\"2.0\",\"method\":1,\"params\":\"bar\"}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - JsonError = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"error\":{">>, - <<"\"code\":-32600">>, - <<"\"message\":\"Invalid Request\"">>, - <<"\"id\":null">>], JsonError)). - -batch(_) -> - Term1 = {request, <<"sum">>, [1,2,4], <<"1">>}, - Term2 = {decoding_error, -32600, <<"Invalid Request">>, undefined, undefined}, - Json = <<"[{\"jsonrpc\":\"2.0\",\"method\":\"sum\",\"params\":[1,2,4],\"id\":\"1\"},{\"foo\":\"boo\"}]">>, - ?assertMatch([Term1, Term2], grisp_connect_jsonrpc:decode(Json)), - JsonError = grisp_connect_jsonrpc:encode([Term1, Term2]), - ?assert(jsonrpc_check([<<"\"id\":\"1\"">>, - <<"\"method\":\"sum\"">>, - <<"\"params\":[1,2,4]">>, - <<"\"error\":{">>, - <<"\"code\":-32600">>, - <<"\"message\":\"Invalid Request\"">>, - <<"\"id\":null">>], JsonError)). - -result(_) -> - Term = {result, 7, 45}, - Json = <<"{\"id\":45,\"jsonrpc\":\"2.0\",\"result\":7}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - Json2 = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"id\":45">>, - <<"\"result\":7">>], Json2)). - -null_values(_) -> - Term = {notification, <<"test_null">>, #{array => [undefined], object => #{foo => undefined}, value => undefined}}, - Json = <<"{\"jsonrpc\":\"2.0\",\"method\":\"test_null\",\"params\":{\"array\":[null],\"object\":{\"foo\":null},\"value\":null}}">>, - ?assertMatch(Term, grisp_connect_jsonrpc:decode(Json)), - Json2 = grisp_connect_jsonrpc:encode(Term), - ?assert(jsonrpc_check([<<"\"array\":[null]">>, - <<"\"foo\":null">>, - <<"\"value\":null">>], - Json2)). - -jsonrpc_check(Elements, JsonString) -> - Elements2 = [<<"\"jsonrpc\":\"2.0\"">>| Elements], - lists:all(fun(E) -> binary:match(JsonString, E) =/= nomatch end, Elements2). From b90ad1baf9074de3bf2aa16c22f2225d41686a77 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Tue, 21 Jan 2025 16:30:08 +0100 Subject: [PATCH 09/12] Fix typos --- CHANGELOG.md | 6 +++--- src/grisp_connect_client.erl | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab1898..cbc4bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to ### Changed -- The name of the grisp_connect configuration key to control the timout of -individual JSON-RPC requests changed from ws_requests_timeout ot +- The name of the grisp_connect configuration key to control the timeout of +individual JSON-RPC requests changed from ws_requests_timeout to ws_request_timeout. - Le default log filter changed to trying to filter out only some messages to filtering out all progress messages, as it wasn't working reliably. @@ -23,7 +23,7 @@ and bar are already existing atoms, but 'Buz' is not). ## Fixed - The client is now waiting 1 second before trying to reconnect when it gets -disconnected fomr the server. +disconnected from the server. ## [1.1.0] - 2024-10-12 diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index 6adec2e..8fb397e 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -241,7 +241,7 @@ handle_common(info, Info, State, Data) -> %--- Internal Functions -------------------------------------------------------- generic_errors() -> [ - {device_not_linked, -1, <<"Device no linked">>}, + {device_not_linked, -1, <<"Device not linked">>}, {token_expired, -2, <<"Token expired">>}, {device_already_linked, -3, <<"Device already linked">>}, {invalid_token, -4, <<"Invalid token">>}, From c8495b40aa379e0dcf88132311aeb9d31dd0e371 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Thu, 30 Jan 2025 14:30:51 +0100 Subject: [PATCH 10/12] Update to follow the changes in jarl API --- src/grisp_connect_api.erl | 6 +----- src/grisp_connect_client.erl | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/grisp_connect_api.erl b/src/grisp_connect_api.erl index eca79a2..89541a8 100644 --- a/src/grisp_connect_api.erl +++ b/src/grisp_connect_api.erl @@ -16,14 +16,10 @@ -spec handle_msg(Msg) -> ok | {reply, Result :: term(), ReqRef :: binary() | integer()} when Msg :: {request, Method :: jarl:method(), Params :: map() | list(), ReqRef :: binary() | integer()} - | {notification, jarl:method(), Params :: map() | list()} - | {remote_error, Code :: integer() | atom(), Message :: undefined | binary(), Data :: term()}. + | {notification, jarl:method(), Params :: map() | list()}. handle_msg({notification, M, Params}) -> ?LOG_ERROR("Received unexpected notification ~p: ~p", [M, Params]), ok; -handle_msg({remote_error, Code, Message, _Data}) -> - ?LOG_ERROR("Received JSON-RPC error ~p: ~s", [Code, Message]), - ok; handle_msg({request, M, Params, ID}) when M == [?method_post]; M == [?method_get] -> handle_request(M, Params, ID). diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index 8fb397e..840d998 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -177,7 +177,7 @@ connecting(state_timeout, timeout, Data = #data{retry_count = RetryCount}) -> event => connection_failed, reason => Reason}), Data2 = conn_close(Data, Reason), {next_state, waiting_ip, Data2#data{retry_count = RetryCount + 1}}; -connecting(info, {conn, Conn, connected}, Data = #data{conn = Conn}) -> +connecting(info, {jarl, Conn, connected}, Data = #data{conn = Conn}) -> % Received from the connection process ?LOG_INFO(#{description => <<"Connected to grisp.io">>, event => connected}), @@ -189,10 +189,10 @@ connected(enter, _OldState, _Data) -> keep_state_and_data; connected({call, From}, is_connected, _) -> {keep_state_and_data, [{reply, From, true}]}; -connected(info, {conn, Conn, Msg}, Data = #data{conn = Conn}) -> +connected(info, {jarl, Conn, Msg}, Data = #data{conn = Conn}) -> handle_connection_message(Data, Msg); connected({call, From}, {request, Method, Type, Params}, Data) -> - Data2 = conn_post(Data, Method, Type, Params, + Data2 = conn_request(Data, Method, Type, Params, fun(D, R) -> gen_statem:reply(From, {ok, R}), D end, fun(D, _, C, _, _) -> gen_statem:reply(From, {error, C}), D end), {keep_state, Data2}; @@ -222,7 +222,7 @@ handle_common(info, {'EXIT', Conn, Reason}, _State, ?FORMAT("The connection to grisp.io died: ~p", [Reason]), event => connection_failed, reason => Reason}), {next_state, waiting_ip, conn_died(Data#data{retry_count = RetryCount + 1})}; -handle_common(info, {conn, Conn, Msg}, State, _Data) -> +handle_common(info, {jarl, Conn, Msg}, State, _Data) -> ?LOG_DEBUG("Received message from unknown connection ~p in state ~w: ~p", [Conn, State, Msg]), keep_state_and_data; @@ -258,19 +258,19 @@ handle_connection_message(_Data, {response, _Result, #{on_result := undefined}}) keep_state_and_data; handle_connection_message(Data, {response, Result, #{on_result := OnResult}}) -> {keep_state, OnResult(Data, Result)}; -handle_connection_message(_Data, {remote_error, Code, Msg, _ErrorData, +handle_connection_message(_Data, {error, Code, Msg, _ErrorData, #{on_error := undefined}}) -> ?LOG_WARNING("Unhandled remote request error ~w: ~s", [Code, Msg]), keep_state_and_data; -handle_connection_message(Data, {remote_error, Code, Msg, ErrorData, +handle_connection_message(Data, {error, Code, Msg, ErrorData, #{on_error := OnError}}) -> {keep_state, OnError(Data, remote, Code, Msg, ErrorData)}; -handle_connection_message(_Data, {local_error, Reason, +handle_connection_message(_Data, {jarl_error, Reason, #{on_error := undefined}}) -> ?LOG_WARNING("Unhandled local request error ~w", [Reason]), keep_state_and_data; -handle_connection_message(Data, {local_error, Reason, +handle_connection_message(Data, {jarl_error, Reason, #{on_error := OnError}}) -> {keep_state, OnError(Data, local, Reason, undefined, undefined)}; handle_connection_message(Data, Msg) -> @@ -322,16 +322,16 @@ conn_died(Data) -> grisp_connect_log_server:stop(), Data#data{conn = undefined}. --spec conn_post(data(), jarl:method(), atom(), map(), +-spec conn_request(data(), jarl:method(), atom(), map(), undefined | on_result_fun(), undefined | on_error_fun()) -> data(). -conn_post(Data = #data{conn = Conn}, Method, Type, Params, OnResult, OnError) +conn_request(Data = #data{conn = Conn}, Method, Type, Params, OnResult, OnError) when Conn =/= undefined -> ReqCtx = #{on_result => OnResult, on_error => OnError}, Params2 = maps:put(type, Type, Params), - case jarl:post(Conn, Method, Params2, ReqCtx) of + case jarl:request(Conn, Method, Params2, ReqCtx) of ok -> Data; - {error, Reason} -> + {jarl_error, Reason} -> OnError(Data, local, Reason, undefined, undefined) end. From 984e720b6140dd80933f3da90d24fcdb064eb328 Mon Sep 17 00:00:00 2001 From: GwendalLaurent Date: Wed, 5 Feb 2025 13:48:38 +0100 Subject: [PATCH 11/12] increasing connection timeout --- src/grisp_connect_client.erl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index 840d998..099ba24 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -52,7 +52,7 @@ -define(FORMAT(FMT, ARGS), iolist_to_binary(io_lib:format(FMT, ARGS))). -define(STD_TIMEOUT, 1000). --define(CONNECT_TIMEOUT, 2000). +-define(CONNECT_TIMEOUT, 5000). -define(ENV(KEY, GUARDS), fun() -> case application:get_env(grisp_connect, KEY) of {ok, V} when GUARDS -> V; @@ -179,8 +179,8 @@ connecting(state_timeout, timeout, Data = #data{retry_count = RetryCount}) -> {next_state, waiting_ip, Data2#data{retry_count = RetryCount + 1}}; connecting(info, {jarl, Conn, connected}, Data = #data{conn = Conn}) -> % Received from the connection process - ?LOG_INFO(#{description => <<"Connected to grisp.io">>, - event => connected}), + ?LOG_NOTICE(#{description => <<"Connected to grisp.io">>, + event => connected}), {next_state, connected, Data#data{retry_count = 0}}; ?HANDLE_COMMON. @@ -222,6 +222,9 @@ handle_common(info, {'EXIT', Conn, Reason}, _State, ?FORMAT("The connection to grisp.io died: ~p", [Reason]), event => connection_failed, reason => Reason}), {next_state, waiting_ip, conn_died(Data#data{retry_count = RetryCount + 1})}; +handle_common(info, {'EXIT', _Conn, _Reason}, _State, _Data) -> + % Ignore any EXIT from past jarl connections + keep_state_and_data; handle_common(info, {jarl, Conn, Msg}, State, _Data) -> ?LOG_DEBUG("Received message from unknown connection ~p in state ~w: ~p", [Conn, State, Msg]), From da879a9350c55af7be59fbf58dc2ed2621931da0 Mon Sep 17 00:00:00 2001 From: Sebastien Merle Date: Fri, 7 Feb 2025 17:34:59 +0100 Subject: [PATCH 12/12] Fixes from review feedback --- CHANGELOG.md | 2 +- config/dev.config | 3 ++- config/local.config | 2 ++ config/test.config | 2 ++ docs/grisp_connect_architecture.md | 5 ++--- rebar.lock | 8 ++++++-- src/grisp_connect.app.src | 8 ++++---- src/grisp_connect_client.erl | 12 ++++++++++-- test/grisp_connect_reconnect_SUITE.erl | 3 ++- 9 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc4bd4..b1082ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to - The name of the grisp_connect configuration key to control the timeout of individual JSON-RPC requests changed from ws_requests_timeout to ws_request_timeout. -- Le default log filter changed to trying to filter out only some messages to +- The default log filter changed to trying to filter out only some messages to filtering out all progress messages, as it wasn't working reliably. - JSON-RPC logic was extracted into the jarl library. - Jarl parses the methods into a list of atom or binaries to pave the diff --git a/config/dev.config b/config/dev.config index a626e7f..1240684 100644 --- a/config/dev.config +++ b/config/dev.config @@ -16,10 +16,11 @@ config => #{type => standard_io}, filter_default => log, filters => [ + % Filter out supervisor progress reports so + % TLS certificates are not swamping the console... {disable_progress, {fun logger_filters:progress/2, stop}} ] }} ]} ]} - ]. diff --git a/config/local.config b/config/local.config index f663e70..0db4f98 100644 --- a/config/local.config +++ b/config/local.config @@ -33,6 +33,8 @@ config => #{type => standard_io}, filter_default => log, filters => [ + % Filter out supervisor progress reports so + % TLS certificates are not swamping the console... {disable_progress, {fun logger_filters:progress/2, stop}} ] }} diff --git a/config/test.config b/config/test.config index d84962f..bd9493d 100644 --- a/config/test.config +++ b/config/test.config @@ -23,6 +23,8 @@ config => #{type => standard_io}, filter_default => log, filters => [ + % Filter out supervisor progress reports so + % TLS certificates are not swamping the console... {disable_progress, {fun logger_filters:progress/2, stop}} ] }} diff --git a/docs/grisp_connect_architecture.md b/docs/grisp_connect_architecture.md index 05857c7..c417e48 100644 --- a/docs/grisp_connect_architecture.md +++ b/docs/grisp_connect_architecture.md @@ -59,11 +59,10 @@ It provides a high-level API to a JSON-RPC connection: - Perform synchronous requests - Start asynchronous requests - Reply to a request - - Send and error result for a request + - Send an error result for a request - Send asynchronous notifications - - Send generic errors When performing an asynchronous request, the caller can give an opaque context -term, that will given back when receiving a response or an error for this +term, that will be given back when receiving a response or an error for this request, allowing the caller to handle the asynchronous operation without having to store information locally. diff --git a/rebar.lock b/rebar.lock index de05d82..df65b7f 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,9 +1,13 @@ {"1.2.0", [{<<"certifi">>,{pkg,<<"certifi">>,<<"2.13.0">>},0}, - {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.13.0">>},1}, + {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.13.0">>},2}, {<<"grisp">>,{pkg,<<"grisp">>,<<"2.7.0">>},0}, {<<"grisp_cryptoauth">>,{pkg,<<"grisp_cryptoauth">>,<<"2.4.0">>},0}, - {<<"gun">>,{pkg,<<"gun">>,<<"2.1.0">>},0}, + {<<"gun">>,{pkg,<<"gun">>,<<"2.1.0">>},1}, + {<<"jarl">>, + {git,"https://github.com/grisp/jarl.git", + {ref,"10085d38df19c67664d33ef61f515c92a8b0de56"}}, + 0}, {<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},0}, {<<"mapz">>,{pkg,<<"mapz">>,<<"2.4.0">>},1}]}. [ diff --git a/src/grisp_connect.app.src b/src/grisp_connect.app.src index 9c90847..76065bd 100644 --- a/src/grisp_connect.app.src +++ b/src/grisp_connect.app.src @@ -30,12 +30,12 @@ {logger, [ % Enable our own default handler, % which will receive all events from boot - {handler, - grisp_connect_log_handler, - grisp_connect_logger_bin, - #{formatter => {grisp_connect_logger_bin, #{}}, + {handler, grisp_connect_log_handler, grisp_connect_logger_bin, #{ + formatter => {grisp_connect_logger_bin, #{}}, filter_default => log, filters => [ + % Filter out supervisor progress reports so + % TLS certificates are not swamping the console... {disable_progress, {fun logger_filters:progress/2, stop}} ] }} diff --git a/src/grisp_connect_client.erl b/src/grisp_connect_client.erl index 099ba24..e5af495 100644 --- a/src/grisp_connect_client.erl +++ b/src/grisp_connect_client.erl @@ -249,6 +249,7 @@ generic_errors() -> [ {device_already_linked, -3, <<"Device already linked">>}, {invalid_token, -4, <<"Invalid token">>}, {grisp_updater_unavailable, -10, <<"Software update unavailable">>}, + {already_updating, -11, <<"Already updating">>}, {boot_system_not_validated, -12, <<"Boot system not validated">>}, {validate_from_unbooted, -13, <<"Validate from unbooted">>} ]. @@ -279,8 +280,11 @@ handle_connection_message(Data, {jarl_error, Reason, handle_connection_message(Data, Msg) -> case grisp_connect_api:handle_msg(Msg) of ok -> keep_state_and_data; + {error, Code, Message, ErData, ReqRef} -> + conn_error(Data, Code, Message, ErData, ReqRef), + keep_state_and_data; {reply, Result, ReqRef} -> - conn_reply(Data, Result, ReqRef), + conn_result(Data, Result, ReqRef), keep_state_and_data end. @@ -343,10 +347,14 @@ conn_notify(#data{conn = Conn}, Method, Type, Params) Params2 = maps:put(type, Type, Params), jarl:notify(Conn, Method, Params2). -conn_reply(#data{conn = Conn}, Result, ReqRef) +conn_result(#data{conn = Conn}, Result, ReqRef) when Conn =/= undefined -> jarl:reply(Conn, Result, ReqRef). +conn_error(#data{conn = Conn}, Code, Message, ErData, ReqRef) + when Conn =/= undefined -> + jarl:reply(Conn, Code, Message, ErData, ReqRef). + % IP check functions check_inet_ipv4() -> diff --git a/test/grisp_connect_reconnect_SUITE.erl b/test/grisp_connect_reconnect_SUITE.erl index 343c882..0f85155 100644 --- a/test/grisp_connect_reconnect_SUITE.erl +++ b/test/grisp_connect_reconnect_SUITE.erl @@ -89,5 +89,6 @@ reconnect_on_closed_frame_test(_) -> connection_gun_pid() -> {_, {data, _, _, _, _, ConnPid, _}} = sys:get_state(grisp_connect_client), - {_, {data, _, _, _, _, _, _, _, _, _, _, GunPid, _, _, _}} = sys:get_state(ConnPid), + % Depends on the internal state of jarl_connection + {_, {data, _, _, _, _, _, _, _, _, _, _, _, GunPid, _, _, _}} = sys:get_state(ConnPid), GunPid.