Skip to content

Commit

Permalink
Merge pull request #137 from inaka/ferigis.135.push_with_certificate
Browse files Browse the repository at this point in the history
[#135] push notifications with provider certificate
  • Loading branch information
Brujo Benavides authored Jan 31, 2017
2 parents efded0b + d3ee3a2 commit 1ddd4fb
Show file tree
Hide file tree
Showing 6 changed files with 293 additions and 8 deletions.
3 changes: 2 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}},
{jsx, "2.8.1"}
]}.

%% == Profiles ==
Expand Down
10 changes: 8 additions & 2 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{"1.1.0",
[{<<"cowlib">>,
{git,"https://github.com/ninenines/cowlib",
{ref,"0e7abe0b24593f131add272c275406d8ed231805"}},
Expand All @@ -6,7 +7,12 @@
{git,"https://github.com/ninenines/gun.git",
{ref,"bc733a2ca5f7d07f997ad6edf184f775b23434aa"}},
0},
{<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.1">>},0},
{<<"ranch">>,
{git,"https://github.com/ninenines/ranch",
{ref,"f4f297cc9c07bc057479f77437cf0cbcfd0e67eb"}},
1}].
{ref,"8a07098b31ebdf37bf2e72fdd4b25a0f0052c849"}},
1}]}.
[
{pkg_hash,[
{<<"jsx">>, <<"1453B4EB3615ACB3E2CD0A105D27E6761E2ED2E501AC0B390F5BBEC497669846">>}]}
].
77 changes: 77 additions & 0 deletions src/apns.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
%%%===================================================================
Expand Down Expand Up @@ -57,6 +77,63 @@ 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 = jsx: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.
69 changes: 69 additions & 0 deletions src/apns_connection.erl
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
, keyfile/1
, gun_connection/1
, close_connection/1
, push_notification/4
]).

%% gen_server callbacks
Expand All @@ -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()
Expand Down Expand Up @@ -100,6 +103,18 @@ 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
%%%===================================================================
Expand All @@ -119,6 +134,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}.

Expand Down Expand Up @@ -198,3 +222,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 = jsx: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.
124 changes: 124 additions & 0 deletions test/connection_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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()}].
Expand All @@ -21,6 +24,9 @@
all() -> [ default_connection
, connect
, gun_connection_crashes
, push_notification
, push_notification_timeout
, default_headers
].

-spec init_per_suite(config()) -> config().
Expand Down Expand Up @@ -81,6 +87,124 @@ 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
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
Expand Down
18 changes: 13 additions & 5 deletions test/test.config
Original file line number Diff line number Diff line change
@@ -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}]}
Expand Down

0 comments on commit 1ddd4fb

Please sign in to comment.