From 0bb96e1070a645a2ce3772a3fd2e83db6cf73f08 Mon Sep 17 00:00:00 2001 From: Felipe Ripoll Date: Tue, 31 Jan 2017 08:03:53 -0600 Subject: [PATCH 1/2] [#135] push notifications with provider certificate --- rebar.config | 3 +- rebar.lock | 8 ++- src/apns.erl | 73 +++++++++++++++++++++++ src/apns_connection.erl | 68 +++++++++++++++++++++ test/connection_SUITE.erl | 121 ++++++++++++++++++++++++++++++++++++++ test/test.config | 18 ++++-- 6 files changed, 284 insertions(+), 7 deletions(-) diff --git a/rebar.config b/rebar.config index d92aae1..273040d 100644 --- a/rebar.config +++ b/rebar.config @@ -21,7 +21,8 @@ %% == Dependencies == {deps, [ - {gun, {git, "https://github.com/ninenines/gun.git", {branch, "master"}}} + {gun, {git, "https://github.com/ninenines/gun.git", {branch, "master"}}}, + {jiffy, "0.14.11"} ]}. %% == Profiles == diff --git a/rebar.lock b/rebar.lock index 96232a5..486406d 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,3 +1,4 @@ +{"1.1.0", [{<<"cowlib">>, {git,"https://github.com/ninenines/cowlib", {ref,"0e7abe0b24593f131add272c275406d8ed231805"}}, @@ -6,7 +7,12 @@ {git,"https://github.com/ninenines/gun.git", {ref,"bc733a2ca5f7d07f997ad6edf184f775b23434aa"}}, 0}, + {<<"jiffy">>,{pkg,<<"jiffy">>,<<"0.14.11">>},0}, {<<"ranch">>, {git,"https://github.com/ninenines/ranch", {ref,"f4f297cc9c07bc057479f77437cf0cbcfd0e67eb"}}, - 1}]. + 1}]}. +[ +{pkg_hash,[ + {<<"jiffy">>, <<"919A87D491C5A6B5E3BBC27FAFEDC3A0761CA0B4C405394F121F582FD4E3F0E5">>}]} +]. diff --git a/src/apns.erl b/src/apns.erl index e8a6be2..f7f2b48 100644 --- a/src/apns.erl +++ b/src/apns.erl @@ -24,8 +24,28 @@ , stop/0 , connect/1 , close_connection/1 + , push_notification/3 + , push_notification/4 + , default_headers/0 ]). +-export_type([ json/0 + , device_id/0 + , response/0 + ]). + +-type json() :: #{binary() => binary() | json()}. +-type device_id() :: string(). +-type response() :: { integer() % HTTP2 Code + , [term()] % Data from APNs server + } | timeout. +-type headers() :: #{ apns_id => binary() + , apns_expiration => binary() + , apns_priority => binary() + , apns_topic => binary() + , apns_collapse_id => binary() + }. + %%%=================================================================== %%% API %%%=================================================================== @@ -57,6 +77,59 @@ connect(Connection) -> close_connection(ConnectionName) -> apns_connection:close_connection(ConnectionName). +%% @doc Push notification to APNs. It will use the headers provided on the +%% environment variables. +-spec push_notification( apns_connection:name() + , device_id() + , json()) -> response(). +push_notification(ConnectionName, DeviceId, JSONMap) -> + Headers = default_headers(), + push_notification(ConnectionName, DeviceId, JSONMap, Headers). + +%% @doc Push notification to APNs. +-spec push_notification( apns_connection:name() + , device_id() + , json() + , headers()) -> response(). +push_notification(ConnectionName, DeviceId, JSONMap, Headers) -> + Notification = jiffy:encode(JSONMap), + apns_connection:push_notification( ConnectionName + , DeviceId + , Notification + , Headers). + +%% @doc Get the default headers from environment variables. +-spec default_headers() -> apns:headers(). +default_headers() -> + Headers = [ apns_id + , apns_expiration + , apns_priority + , apns_topic + , apns_collapse_id], + + default_headers(Headers, #{}). + %%%=================================================================== %%% Internal Functions %%%=================================================================== + +%% Build a headers() structure from environment variables. +-spec default_headers(list(), headers()) -> headers(). +default_headers([], Headers) -> + Headers; +default_headers([Key | Keys], Headers) -> + case application:get_env(apns, Key) of + {ok, undefined} -> + default_headers(Keys, Headers); + {ok, Value} -> + NewHeaders = Headers#{Key => to_binary(Value)}, + default_headers(Keys, NewHeaders) + end. + +%% Convert to binary +to_binary(Value) when is_integer(Value) -> + list_to_binary(integer_to_list(Value)); +to_binary(Value) when is_list(Value) -> + list_to_binary(Value); +to_binary(Value) when is_binary(Value) -> + Value. diff --git a/src/apns_connection.erl b/src/apns_connection.erl index 1f98134..f6ae153 100644 --- a/src/apns_connection.erl +++ b/src/apns_connection.erl @@ -31,6 +31,7 @@ , keyfile/1 , gun_connection/1 , close_connection/1 + , push_notification/4 ]). %% gen_server callbacks @@ -47,11 +48,13 @@ , port/0 , path/0 , connection/0 + , notification/0 ]). -type name() :: atom(). -type host() :: string() | inet:ip_address(). -type path() :: string(). +-type notification() :: binary(). -opaque connection() :: #{ name := name() , apple_host := host() , apple_port := inet:port_number() @@ -100,6 +103,17 @@ close_connection(ConnectionName) -> gun_connection(ConnectionName) -> gen_server:call(ConnectionName, gun_connection). +%% @doc Pushes notification to APNs connection. +-spec push_notification( name() + , apns:device_id() + , notification() + , apns:headers()) -> apns:response(). +push_notification(ConnectionName, DeviceId, Notification, Headers) -> + gen_server:call(ConnectionName, { push_notification + , DeviceId + , Notification + , Headers}). + %%%=================================================================== %%% gen_server callbacks %%%=================================================================== @@ -119,6 +133,15 @@ init(Connection) -> ) -> {reply, ok, State}. handle_call(gun_connection, _From, #{gun_connection := GunConn} = State) -> {reply, GunConn, State}; +handle_call( {push_notification, DeviceId, Notification, HeadersMap} + , _From + , State) -> + #{gun_connection := GunConn} = State, + Headers = get_headers(HeadersMap), + Path = get_device_path(DeviceId), + StreamRef = gun:post(GunConn, Path, Headers, Notification), + Response = wait_for_response(GunConn, StreamRef), + {reply, Response, State}; handle_call(_Request, _From, State) -> {reply, ok, State}. @@ -198,3 +221,48 @@ open_gun_connection(Connection) -> }), GunMonitor = monitor(process, GunConnectionPid), {GunMonitor, GunConnectionPid}. + +-spec wait_for_response(pid(), reference()) -> apns:response(). +wait_for_response(GunConn, StreamRef) -> + {ok, Timeout} = application:get_env(apns, timeout), + receive + {gun_response, GunConn, StreamRef, nofin, Code, Response} -> + case wait_for_data(GunConn, StreamRef, Response, Timeout) of + timeout -> timeout; + Data -> {Code, Data} + end; + {gun_response, GunConn, StreamRef, fin, Code, Response} -> + {Code, Response} + after + Timeout -> timeout + end. + +-spec wait_for_data(pid(), reference(), list(), integer()) -> apns:response(). +wait_for_data(GunConn, StreamRef, Acc, Timeout) -> + receive + {gun_data, GunConn, StreamRef, fin, Response} -> + {[DecodedResponse]} = jiffy:decode(Response), + [DecodedResponse | Acc] + after + Timeout -> timeout + end. + +-spec get_headers(apns:headers()) -> list(). +get_headers(Headers) -> + List = [ {<<"apns-id">>, apns_id} + , {<<"apns-expiration">>, apns_expiration} + , {<<"apns-priority">>, apns_priority} + , {<<"apns-topic">>, apns_topic} + , {<<"apns-collapse_id">>, apns_collapse_id} + ], + F = fun({ActualHeader, Key}) -> + case (catch maps:get(Key, Headers)) of + {'EXIT', {{badkey, Key}, _}} -> []; + Value -> [{ActualHeader, Value}] + end + end, + lists:flatmap(F, List). + +-spec get_device_path(apns:device_id()) -> string(). +get_device_path(DeviceId) -> + "/3/device/" ++ DeviceId. diff --git a/test/connection_SUITE.erl b/test/connection_SUITE.erl index 25013d4..fbe4153 100644 --- a/test/connection_SUITE.erl +++ b/test/connection_SUITE.erl @@ -9,6 +9,9 @@ -export([ default_connection/1 , connect/1 , gun_connection_crashes/1 + , push_notification/1 + , push_notification_timeout/1 + , default_headers/1 ]). -type config() :: [{atom(), term()}]. @@ -21,6 +24,9 @@ all() -> [ default_connection , connect , gun_connection_crashes + , push_notification + , push_notification_timeout + , default_headers ]. -spec init_per_suite(config()) -> config(). @@ -81,6 +87,121 @@ gun_connection_crashes(_Config) -> [_] = meck:unload(), ok. +-spec push_notification(config()) -> ok. +push_notification(_Config) -> + ok = meck:expect(gun, post, fun(GunConn, _, _, _) -> + Ref = make_ref(), + {ok, _} = + timer:send_after(1000, {gun_response, GunConn, Ref, fin, 200, []}), + Ref + end), + ConnectionName = my_connection, + {ok, _ApnsPid} = apns:connect(ConnectionName), + Headers = #{ apns_id => <<"apnsid">> + , apns_expiration => <<"0">> + , apns_priority => <<"10">> + , apns_topic => <<"net.inaka.myapp">> + }, + Notification = #{<<"aps">> => #{<<"alert">> => <<"yo have a message">>}}, + DeviceId = "device_id", + {200, []} = + apns:push_notification(ConnectionName, DeviceId, Notification, Headers), + [_] = meck:unload(), + + %% Now mock an error from APNs + ok = meck:expect(gun, post, fun(GunConn, _, _, _) -> + Ref = make_ref(), + {ok, _} = timer:send_after(1000, { gun_response + , GunConn + , Ref + , nofin + , 400 + , [{<<"apns-id">>, <<"apnsid2">>}]}), + {ok, _} = timer:send_after(1500, { gun_data + , GunConn + , Ref + , fin + , [<<"{\"reason\":\"BadTopic\"}">>]}), + Ref + end), + + {400, _} = + apns:push_notification(ConnectionName, DeviceId, Notification, Headers), + ok = apns:close_connection(ConnectionName), + [_] = meck:unload(), + ok. + +-spec push_notification_timeout(config()) -> ok. +push_notification_timeout(_Config) -> + %% Change the timeout variable + {ok, OriginalTimeout} = application:get_env(apns, timeout), + ok = application:set_env(apns, timeout, 500), + + ok = meck:expect(gun, post, fun(_, _, _, _) -> + make_ref() + end), + ConnectionName = my_connection, + {ok, _ApnsPid} = apns:connect(ConnectionName), + Notification = #{<<"aps">> => #{<<"alert">> => <<"another message">>}}, + DeviceId = "device_id", + timeout = apns:push_notification(ConnectionName, DeviceId, Notification), + [_] = meck:unload(), + + % code coverage + ok = meck:expect(gun, post, fun(GunConn, _, _, _) -> + Ref = make_ref(), + {ok, _} = timer:send_after(1, { gun_response + , GunConn + , Ref + , nofin + , 400 + , [{<<"apns-id">>, <<"apnsid">>}]}), + % don't send the second message in order to throw the timeout + Ref + end), + timeout = apns:push_notification(ConnectionName, DeviceId, Notification), + [_] = meck:unload(), + + % turn back the original timeout + ok = application:set_env(apns, timeout, OriginalTimeout), + ok. + +-spec default_headers(config()) -> ok. +default_headers(_Config) -> + % Replace the environment header variables + {ok, OriginalApnsId} = application:get_env(apns, apns_id), + {ok, OriginalApnsExp} = application:get_env(apns, apns_expiration), + {ok, OriginalApnsPriority} = application:get_env(apns, apns_priority), + {ok, OriginalApnsTopic} = application:get_env(apns, apns_topic), + {ok, OriginalApnsCollapseId} = application:get_env(apns, apns_collapse_id), + + ApnsId = "this is the ID", + ApnsExp = 10, + ApnsPriority = undefined, + ApnsTopic = <<"com.example.mycoolapp">>, + ApnsCollapseId = undefined, + + ok = application:set_env(apns, apns_id, ApnsId), + ok = application:set_env(apns, apns_expiration, ApnsExp), + ok = application:set_env(apns, apns_priority, ApnsPriority), + ok = application:set_env(apns, apns_topic, ApnsTopic), + ok = application:set_env(apns, apns_collapse_id, ApnsCollapseId), + + Expected = #{ apns_id => list_to_binary(ApnsId) + , apns_expiration => list_to_binary(integer_to_list(ApnsExp)) + , apns_topic => ApnsTopic + }, + + Expected = apns:default_headers(), + + % turn back the original values + ok = application:set_env(apns, apns_id, OriginalApnsId), + ok = application:set_env(apns, apns_expiration, OriginalApnsExp), + ok = application:set_env(apns, apns_priority, OriginalApnsPriority), + ok = application:set_env(apns, apns_topic, OriginalApnsTopic), + ok = application:set_env(apns, apns_collapse_id, OriginalApnsCollapseId), + ok. + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% Internal Functions %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff --git a/test/test.config b/test/test.config index 377448c..4483aa5 100644 --- a/test/test.config +++ b/test/test.config @@ -1,11 +1,19 @@ [ { apns, - [ {apple_host, "api.development.push.apple.com"} - , {apple_port, 443} - , {certfile, "priv/apns-dev-cert.pem"} - , {keyfile, "priv/apns-dev-key-noenc.pem"} - , {timeout, 30000} + [ {apple_host, "api.development.push.apple.com"} + , {apple_port, 443} + , {certfile, "priv/apns-dev-cert.pem"} + , {keyfile, "priv/apns-dev-key-noenc.pem"} + , {timeout, 10000} + + %% APNs Headers + + , {apns_id, "apns_id"} + , {apns_expiration, 0} + , {apns_priority, 10} + , {apns_topic, "com.example.myapp"} + , {apns_collapse_id, undefined} ] }, {sasl, [{sasl_error_logger, false}]} From d3ee3a2985613befbe8215de5cf3de31af18f4fc Mon Sep 17 00:00:00 2001 From: Felipe Ripoll Date: Tue, 31 Jan 2017 08:48:56 -0600 Subject: [PATCH 2/2] [#135] replacing jiffy by jsx --- rebar.config | 2 +- rebar.lock | 6 +++--- src/apns.erl | 14 +++++++++----- src/apns_connection.erl | 7 ++++--- test/connection_SUITE.erl | 9 ++++++--- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/rebar.config b/rebar.config index 273040d..4793516 100644 --- a/rebar.config +++ b/rebar.config @@ -22,7 +22,7 @@ {deps, [ {gun, {git, "https://github.com/ninenines/gun.git", {branch, "master"}}}, - {jiffy, "0.14.11"} + {jsx, "2.8.1"} ]}. %% == Profiles == diff --git a/rebar.lock b/rebar.lock index 486406d..a448129 100644 --- a/rebar.lock +++ b/rebar.lock @@ -7,12 +7,12 @@ {git,"https://github.com/ninenines/gun.git", {ref,"bc733a2ca5f7d07f997ad6edf184f775b23434aa"}}, 0}, - {<<"jiffy">>,{pkg,<<"jiffy">>,<<"0.14.11">>},0}, + {<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.1">>},0}, {<<"ranch">>, {git,"https://github.com/ninenines/ranch", - {ref,"f4f297cc9c07bc057479f77437cf0cbcfd0e67eb"}}, + {ref,"8a07098b31ebdf37bf2e72fdd4b25a0f0052c849"}}, 1}]}. [ {pkg_hash,[ - {<<"jiffy">>, <<"919A87D491C5A6B5E3BBC27FAFEDC3A0761CA0B4C405394F121F582FD4E3F0E5">>}]} + {<<"jsx">>, <<"1453B4EB3615ACB3E2CD0A105D27E6761E2ED2E501AC0B390F5BBEC497669846">>}]} ]. diff --git a/src/apns.erl b/src/apns.erl index f7f2b48..55d7bb0 100644 --- a/src/apns.erl +++ b/src/apns.erl @@ -81,7 +81,8 @@ close_connection(ConnectionName) -> %% environment variables. -spec push_notification( apns_connection:name() , device_id() - , json()) -> response(). + , json() + ) -> response(). push_notification(ConnectionName, DeviceId, JSONMap) -> Headers = default_headers(), push_notification(ConnectionName, DeviceId, JSONMap, Headers). @@ -90,13 +91,15 @@ push_notification(ConnectionName, DeviceId, JSONMap) -> -spec push_notification( apns_connection:name() , device_id() , json() - , headers()) -> response(). + , headers() + ) -> response(). push_notification(ConnectionName, DeviceId, JSONMap, Headers) -> - Notification = jiffy:encode(JSONMap), + Notification = jsx:encode(JSONMap), apns_connection:push_notification( ConnectionName , DeviceId , Notification - , Headers). + , Headers + ). %% @doc Get the default headers from environment variables. -spec default_headers() -> apns:headers(). @@ -105,7 +108,8 @@ default_headers() -> , apns_expiration , apns_priority , apns_topic - , apns_collapse_id], + , apns_collapse_id + ], default_headers(Headers, #{}). diff --git a/src/apns_connection.erl b/src/apns_connection.erl index f6ae153..a00ad7f 100644 --- a/src/apns_connection.erl +++ b/src/apns_connection.erl @@ -112,7 +112,8 @@ push_notification(ConnectionName, DeviceId, Notification, Headers) -> gen_server:call(ConnectionName, { push_notification , DeviceId , Notification - , Headers}). + , Headers + }). %%%=================================================================== %%% gen_server callbacks @@ -240,8 +241,8 @@ wait_for_response(GunConn, StreamRef) -> -spec wait_for_data(pid(), reference(), list(), integer()) -> apns:response(). wait_for_data(GunConn, StreamRef, Acc, Timeout) -> receive - {gun_data, GunConn, StreamRef, fin, Response} -> - {[DecodedResponse]} = jiffy:decode(Response), + {gun_data, GunConn, StreamRef, fin, [Response]} -> + DecodedResponse = jsx:decode(Response), [DecodedResponse | Acc] after Timeout -> timeout diff --git a/test/connection_SUITE.erl b/test/connection_SUITE.erl index fbe4153..9e84c9b 100644 --- a/test/connection_SUITE.erl +++ b/test/connection_SUITE.erl @@ -116,12 +116,14 @@ push_notification(_Config) -> , Ref , nofin , 400 - , [{<<"apns-id">>, <<"apnsid2">>}]}), + , [{<<"apns-id">>, <<"apnsid2">>}] + }), {ok, _} = timer:send_after(1500, { gun_data , GunConn , Ref , fin - , [<<"{\"reason\":\"BadTopic\"}">>]}), + , [<<"{\"reason\":\"BadTopic\"}">>] + }), Ref end), @@ -155,7 +157,8 @@ push_notification_timeout(_Config) -> , Ref , nofin , 400 - , [{<<"apns-id">>, <<"apnsid">>}]}), + , [{<<"apns-id">>, <<"apnsid">>}] + }), % don't send the second message in order to throw the timeout Ref end),