├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── pushex.ex └── pushex │ ├── apns.ex │ ├── apns │ ├── app.ex │ ├── callback.ex │ ├── client.ex │ ├── client │ │ ├── sandbox.ex │ │ └── ssl.ex │ ├── request.ex │ ├── response.ex │ └── ssl_pool_manager.ex │ ├── app.ex │ ├── app_manager.ex │ ├── app_manager │ └── memory.ex │ ├── config.ex │ ├── event_handler.ex │ ├── event_manager.ex │ ├── exceptions.ex │ ├── gcm.ex │ ├── gcm │ ├── app.ex │ ├── client.ex │ ├── client │ │ ├── http.ex │ │ └── sandbox.ex │ ├── exceptions.ex │ ├── request.ex │ └── response.ex │ ├── helpers.ex │ ├── response_handler │ └── sandbox.ex │ ├── sandbox.ex │ ├── util.ex │ ├── validators │ └── type.ex │ ├── watcher.ex │ └── worker.ex ├── mix.exs ├── mix.lock └── test ├── pushex ├── apns │ ├── client │ │ └── ssl_test.exs │ ├── request_test.exs │ └── ssl_pool_manager_test.exs ├── app_manager │ └── memory_test.exs ├── app_manager_test.exs ├── config_test.exs ├── event_handler_test.exs ├── exceptions_test.exs ├── gcm │ ├── app_test.exs │ ├── client_test.exs │ ├── exceptions_test.exs │ └── request_test.exs ├── helpers_test.exs ├── sandbox_test.exs ├── util_test.exs ├── validators │ └── type_test.exs ├── watcher_test.exs └── worker_test.exs ├── pushex_test.exs ├── support └── pushex_case.ex └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | /doc 7 | /certs 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.8 4 | - 1.9 5 | otp_release: 6 | - 22.0 7 | env: 8 | - MIX_ENV=test 9 | 10 | script: mix coveralls.travis 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Daniel Perez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pushex 2 | [![Build Status](https://travis-ci.org/danhper/pushex.svg?branch=master)](https://travis-ci.org/danhper/pushex) 3 | [![Coverage Status](https://coveralls.io/repos/github/danhper/pushex/badge.svg?branch=master)](https://coveralls.io/github/danhper/pushex?branch=master) 4 | 5 | 6 | Pushex is a library to easily send push notifications with Elixir. 7 | 8 | ## About 9 | 10 | ### Goals 11 | 12 | The main goals are the following: 13 | 14 | * Easy to use async API 15 | * Common API for iOS and Android 16 | * Multiple applications handling 17 | * Proper error and response handling 18 | * Testable using a sanbox mode 19 | 20 | ### Status 21 | 22 | Both GCM and APNS are working. APNS delegates to [apns4ex](https://github.com/chvanikoff/apns4ex) 23 | for now, I will probably use the HTTP2 API in a later version. 24 | 25 | The API is still subject to change, with a minor version bump for each change. 26 | 27 | ## Installation 28 | 29 | Add the following to your dependencies mix.ex. 30 | 31 | ```elixir 32 | [{:pushex, "~> 0.2"}] 33 | ``` 34 | 35 | Then, add `:pushex` to your applications. 36 | 37 | ## Usage 38 | 39 | The most basic usage, with no configuration looks like this: 40 | 41 | ```elixir 42 | app = %Pushex.GCM.App{name: "a_unique_name_you_like", auth_key: "a GCM API auth key"} 43 | Pushex.push(%{title: "my_title", body: "my_body"}, to: "registration_id", with_app: app) 44 | ``` 45 | 46 | To avoid having to create or retreive your app each time, you can configure as many apps 47 | as you want in your `config.exs`: 48 | 49 | ```elixir 50 | config :pushex, 51 | gcm: [ 52 | default_app: "first_app", 53 | apps: [ 54 | [name: "first_app", auth_key: "a key"], 55 | [name: "other_app", auth_key: "another key"] 56 | ] 57 | ], 58 | apns: [ 59 | default_app: "first_app", 60 | apps: [ 61 | [name: "first_app", env: :dev, certfile: "/path/to/certfile", pool_size: 5] 62 | ] 63 | ] 64 | ``` 65 | 66 | You can then do the following: 67 | 68 | 69 | ```elixir 70 | # this will use the default app, "first_app" with the above configuration 71 | Pushex.push(%{title: "my_title", body: "my_body"}, to: "registration_id", using: :gcm) 72 | 73 | # this will use the other_app 74 | Pushex.push(%{title: "my_title", body: "my_body"}, to: "registration_id", using: :gcm, with_app: "other_app") 75 | ``` 76 | 77 | Note that the function is async and only returns a reference, see the response and error 78 | handling documentation for more information. 79 | 80 | ### Sending to multiple platforms 81 | 82 | If you want to use the same message for both platforms, you can define messages as follow: 83 | 84 | ```elixir 85 | message = %{ 86 | common: "this will be in both payloads", 87 | other: "this will also be in both payloads", 88 | apns: %{ 89 | alert: "My alert", 90 | badge: 1 91 | }, 92 | gcm: %{ 93 | title: "GCM title", 94 | body: "My body" 95 | } 96 | } 97 | 98 | Pushex.push(message, to: ["apns_token1", "apns_token2"], using: :apns) 99 | Pushex.push(message, to: ["gcm_registration_id1", "gcm_registration_id2"], using: :gcm) 100 | ``` 101 | 102 | Only `:gcm` and `:apns` are currently available. 103 | 104 | ### Passing more options 105 | 106 | If you need to pass options, `priority` for example, you can just pass 107 | it in the keyword list and it will be sent. 108 | 109 | See 110 | 111 | https://developers.google.com/cloud-messaging/http-server-ref#downstream-http-messages-json 112 | 113 | for more information. 114 | 115 | The parameters from `Table 1` should be passed in the keyword list, while 116 | the parameters from `Table 2` should be passed in the first argument. 117 | 118 | For more information about `APNS` options, see [apns4ex](https://github.com/chvanikoff/apns4ex) docs. 119 | 120 | NOTE: if you pass an array to the `to` parameter, if will automatically 121 | be converted to `registration_ids` when sending the request, to keep a consistent API. 122 | 123 | ### Loading app from somewhere else 124 | 125 | If you are saving your auth_keys in your database, you can override the default way to retreive the apps: 126 | 127 | ```elixir 128 | # config.exs 129 | config :pushex, 130 | app_manager_impl: MyAppManager 131 | 132 | # my_app_manager.ex 133 | defmodule MyAppManager do 134 | @behaviour Pushex.AppManager 135 | 136 | def find_app(platform, name) do 137 | if app = Repo.get_by(App, platform: platform, name: name) do 138 | make_app(platform, app) 139 | end 140 | end 141 | 142 | # transform to a `Pushex._.App` 143 | defp make_app(:gcm, app) do 144 | struct(Pushex.GCM.App, Map.from_struct(app)) 145 | end 146 | defp make_app(:apns, app) do 147 | struct(Pushex.APNS.App, Map.from_struct(app)) 148 | end 149 | end 150 | ``` 151 | 152 | ### Handling responses 153 | 154 | To handle responses, you can define a module using `Pushex.EventHandler` 155 | which uses `:gen_event` to process events. 156 | 157 | ```elixir 158 | # config.exs 159 | config :pushex, 160 | event_handlers: [MyEventHandler] 161 | 162 | # my_event_handler.ex 163 | defmodule MyEventHandler do 164 | use Pushex.EventHandler 165 | 166 | def handle_event({:request, request, {pid, ref}}, state) do 167 | # do whatever you want with the request 168 | # for example, logging or saving in a DB 169 | {:ok, state} 170 | end 171 | 172 | def handle_event({:response, response, request, {pid, ref}}, state) do 173 | # do whatever you want with the response and request 174 | {:ok, state} 175 | end 176 | end 177 | ``` 178 | 179 | The `ref` passed here is the one returned when calling `push`. 180 | 181 | ## Testing 182 | 183 | Pushex offers a sandbox mode to make testing easier. 184 | 185 | To enable it, you should add the following to your configuration: 186 | 187 | ``` 188 | config :pushex, 189 | sandbox: true 190 | ``` 191 | 192 | Once you are using the sandbox, the messages will not be sent to GCM or APNS anymore, 193 | but stored in `Pushex.Sandbox`. Furthermore, all the messages will be returned 194 | to the process that sent them. 195 | Here is a sample test. 196 | 197 | ```elixir 198 | test "send notification to users" do 199 | ref = Pushex.push(%{body: "my message"}, to: "my-user", using: :gcm) 200 | pid = self() 201 | assert_receive {{:ok, response}, request, ^ref} 202 | assert [{{:ok, ^response}, ^request, {^pid, ^ref}}] = Pushex.Sandbox.list_notifications 203 | end 204 | ``` 205 | 206 | Note that `list_notifications` depends on the running process, so 207 | if you call it from another process, you need to explicitly pass the pid with the `:pid` option. 208 | 209 | Also note that `Pushex.push` is asynchronous, so if you 210 | remove the `assert_receive`, you will have a race condition. 211 | To avoid this, you can use `Pushex.Sandbox.wait_notifications/1` instead of `Pushex.Sandbox.list_notifications`. 212 | It will wait (by default for `100ms`) until at least `:count` notifications arrive 213 | 214 | ```elixir 215 | test "send notification to users and wait" do 216 | Enum.each (1..10), fn _ -> 217 | Pushex.push(%{body: "foo"}, to: "whoever", using: :gcm) 218 | end 219 | notifications = Pushex.Sandbox.wait_notifications(count: 10, timeout: 50) 220 | assert length(notifications) == 10 221 | end 222 | ``` 223 | 224 | However, the requests are asynchronous, so there is no guaranty that the notifications 225 | in the sandbox will in the same order they have been sent. 226 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :apns, 4 | pools: [], 5 | callback_module: Pushex.APNS.Callback 6 | 7 | import_config "#{Mix.env}.exs" 8 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :pushex, 4 | sandbox: true, 5 | gcm: [ 6 | default_app: "default_app", 7 | apps: [ 8 | [name: "default_app", auth_key: "whatever"] 9 | ] 10 | ], 11 | apns: [ 12 | apps: [ 13 | [name: "default_app", 14 | env: :dev, 15 | certfile: Path.expand("../certs/debug.pem", __DIR__), 16 | pool_size: 5] 17 | ] 18 | ] 19 | -------------------------------------------------------------------------------- /lib/pushex.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex do 2 | @moduledoc """ 3 | Facade module to access Pushex functionalities. 4 | 5 | See Pushex.Helpers documentation for more information. 6 | """ 7 | 8 | defdelegate push(notification), to: Pushex.Helpers, as: :send_notification 9 | defdelegate push(notification, opts), to: Pushex.Helpers, as: :send_notification 10 | 11 | def add_event_handler(handler) do 12 | case Pushex.Watcher.watch(Pushex.EventManager, handler, []) do 13 | {:ok, _} = ok -> 14 | Pushex.Config.add_event_handler(handler) 15 | ok 16 | {:error, _reason} = err -> 17 | err 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/pushex/apns.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS do 2 | @moduledoc "This module defines types to work with APNS" 3 | 4 | @type response :: {:ok, Pushex.APNS.Response} | {:error, atom} 5 | @type request :: Pushex.APNS.Request.t 6 | end 7 | -------------------------------------------------------------------------------- /lib/pushex/apns/app.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.App do 2 | @moduledoc """ 3 | `Pushex.APNS.App` represents an APNS application. 4 | 5 | `:name` is a unique identifier used to find the application, 6 | `:certfile` is the certificate for the application, and `:env` is `:dev` or `:prod`. 7 | """ 8 | 9 | use Vex.Struct 10 | 11 | @type t :: %__MODULE__{name: String.t} 12 | 13 | defstruct [ 14 | :name, 15 | :certfile, 16 | :env, 17 | :cert, 18 | :feedback_interval, 19 | :cert_password, 20 | :key, 21 | :keyfile, 22 | :support_old_ios, 23 | :expiry 24 | ] 25 | 26 | validates :name, 27 | presence: true, 28 | type: [is: :string] 29 | 30 | def create(app) do 31 | app = struct(Pushex.APNS.App, app) 32 | Pushex.Util.validate(app) 33 | end 34 | def create!(app) do 35 | case create(app) do 36 | {:ok, app} -> app 37 | {:error, errors} -> raise Pushex.ValidationError, errors: errors 38 | end 39 | end 40 | 41 | def to_config(app) do 42 | app 43 | |> Map.from_struct 44 | |> Enum.reject(fn {_k, v} -> is_nil(v) end) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/pushex/apns/callback.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Callback do 2 | def error(error, token \\ "unknown token") 3 | def error({:error, reason}, _token) when reason in ~w(invalid_token_size)a do 4 | # already handled 5 | end 6 | def error(error, token) do 7 | Pushex.EventManager.handle_error(error, token) 8 | end 9 | 10 | def feedback(feedback) do 11 | Pushex.EventManager.handle_feedback(feedback) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/pushex/apns/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Client do 2 | @moduledoc """ 3 | Module defining the behaviour to send requests to APNS.. 4 | 5 | The behaviour can be changed by setting the `:client_impl` module 6 | under the configuration `pushex: :apns` key 7 | """ 8 | 9 | @callback send_notification(request :: Pushex.APNS.Request.t) :: {:ok, Pushex.APNS.Response.t} | {:error, Pushex.APNS.Reponse} 10 | 11 | def send_notification(request) do 12 | impl().send_notification(request) 13 | end 14 | 15 | defp impl(), do: Application.get_env(:pushex, :apns)[:client_impl] 16 | end 17 | -------------------------------------------------------------------------------- /lib/pushex/apns/client/sandbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Client.Sandbox do 2 | @moduledoc false 3 | 4 | @behaviour Pushex.APNS.Client 5 | 6 | def send_notification(request) do 7 | if request.to == :bad_id do 8 | {:error, %Pushex.APNS.Response{failure: 1, results: [{:error, :invalid_token_size}]}} 9 | else 10 | count = request.to |> List.wrap |> Enum.count 11 | results = List.duplicate(:ok, count) 12 | response = %Pushex.APNS.Response{success: count, results: results} 13 | {:ok, response} 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/pushex/apns/client/ssl.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Client.SSL do 2 | @moduledoc false 3 | 4 | require Logger 5 | 6 | @behaviour Pushex.APNS.Client 7 | 8 | alias Pushex.APNS.SSLPoolManager 9 | 10 | def send_notification(request) do 11 | SSLPoolManager.ensure_pool_started(request.app) 12 | 13 | make_messages(request) 14 | |> Enum.map(&log_and_send(request.app.name, &1)) 15 | |> generate_response() 16 | end 17 | 18 | defp log_and_send(app_name, message) do 19 | to_log = Map.put(message, :token, String.slice(message.token, 0..10) <> "...") 20 | Logger.debug("sending message to apns using #{app_name}: #{inspect(to_log)}") 21 | APNS.push_sync(app_name, message) 22 | end 23 | 24 | @doc false 25 | def make_messages(request) do 26 | base_message = Pushex.APNS.Request.to_message(request) 27 | Enum.map(List.wrap(request.to), &Map.put(base_message, :token, &1)) 28 | end 29 | 30 | @doc false 31 | def generate_response(results) do 32 | List.foldr results, %Pushex.APNS.Response{}, fn 33 | :ok, response -> 34 | %{response | success: response.success + 1, results: [:ok | response.results]} 35 | {:error, _reason} = err, response -> 36 | %{response | failure: response.failure + 1, results: [err | response.results]} 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/pushex/apns/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Request do 2 | @moduledoc """ 3 | `Pushex.APNS.Request` represents a request that will be sent to APNS. 4 | It contains the notification, and all the metadata that can be sent with it. 5 | 6 | Only the key with a value will be sent to APNS. 7 | """ 8 | 9 | use Vex.Struct 10 | 11 | defstruct [:app, :to, :notification] 12 | 13 | @valid_keys ~w(alert badge category content_available expiry extra generated_at id priority 14 | retry_count sound support_old_ios token) 15 | 16 | @type t :: %__MODULE__{ 17 | app: Pushex.APNS.App.t, 18 | to: String.t | [String.t], 19 | notification: map 20 | } 21 | 22 | validates :app, type: [is: Pushex.APNS.App] 23 | validates :notification, type: [is: :map] 24 | validates :to, type: [is: [:binary, [list: :binary]]] 25 | 26 | def create!(notification, app, opts) do 27 | params = %{notification: notification, app: app, to: opts[:to]} 28 | Pushex.Util.create_struct!(__MODULE__, params) 29 | end 30 | 31 | def to_message(request) do 32 | message = APNS.Message.new 33 | Enum.reduce(request.notification, message, fn 34 | {_k, v}, msg when is_nil(v) or v == "" -> msg 35 | {k, v}, msg -> Map.put(msg, to_atom(k), v) 36 | end) 37 | end 38 | 39 | defp to_atom(value) when value in unquote(@valid_keys), do: String.to_atom(value) 40 | defp to_atom(value), do: value 41 | end 42 | -------------------------------------------------------------------------------- /lib/pushex/apns/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Response do 2 | @moduledoc """ 3 | `Pushex.APNS.Response` represents a result to an APNS request 4 | """ 5 | 6 | defstruct [success: 0, failure: 0, results: []] 7 | 8 | @type t :: %__MODULE__{ 9 | success: non_neg_integer, 10 | failure: non_neg_integer, 11 | results: [:ok | {:error, atom}] 12 | } 13 | end 14 | -------------------------------------------------------------------------------- /lib/pushex/apns/ssl_pool_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.SSLPoolManager do 2 | use GenServer 3 | 4 | alias Pushex.APNS.App 5 | 6 | @doc false 7 | def init(init_args) do 8 | {:ok, init_args} 9 | end 10 | 11 | def start_link do 12 | GenServer.start_link(__MODULE__, %{pools: []}, name: __MODULE__) 13 | end 14 | 15 | def ensure_pool_started(app) do 16 | unless pool_started?(app.name) do 17 | start_pool(app) 18 | end 19 | end 20 | 21 | def pool_started?(name) do 22 | pools = GenServer.call(__MODULE__, :get_pools) 23 | not is_nil(List.keyfind(pools, name, 0)) 24 | end 25 | 26 | def start_pool(app) do 27 | GenServer.call(__MODULE__, {:start_pool, app}) 28 | end 29 | 30 | def handle_call(:get_pools, _from, state) do 31 | {:reply, state.pools, state} 32 | end 33 | 34 | def handle_call({:start_pool, app}, _from, state) do 35 | {res, new_state} = do_start_pool(app, state) 36 | {:reply, res, new_state} 37 | end 38 | 39 | defp do_start_pool(app, state) do 40 | case APNS.connect_pool(app.name, App.to_config(app)) do 41 | {:ok, pid} = res -> 42 | {res, %{state | pools: [{app.name, pid} | state.pools]}} 43 | error -> 44 | {error, state} 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/pushex/app.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.App do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @doc false 7 | def start(_type, _args) do 8 | import Supervisor.Spec, warn: false 9 | 10 | Application.put_env(:vex, :sources, [Pushex.Validators, Vex.Validators]) 11 | 12 | config = Pushex.Config.make_defaults(Application.get_all_env(:pushex)) 13 | 14 | gcm_pool_options = Keyword.merge([ 15 | name: {:local, Pushex.GCM}, 16 | worker_module: Pushex.Worker, 17 | ], config[:gcm][:pool_options]) 18 | 19 | apns_pool_options = Keyword.merge([ 20 | name: {:local, Pushex.APNS}, 21 | worker_module: Pushex.Worker, 22 | ], config[:apns][:pool_options]) 23 | 24 | children = [ 25 | worker(Pushex.Config, [config]), 26 | worker(:gen_event, [{:local, Pushex.EventManager}]), 27 | supervisor(Pushex.Watcher, [Pushex.Config, :event_handlers, []]), 28 | :poolboy.child_spec(Pushex.GCM, gcm_pool_options, [client: Pushex.GCM.Client]), 29 | :poolboy.child_spec(Pushex.APNS, apns_pool_options, [client: Pushex.APNS.Client]), 30 | worker(Pushex.AppManager.Memory, []), 31 | worker(Pushex.APNS.SSLPoolManager, []) 32 | ] 33 | 34 | children = children ++ (if config[:sandbox] do 35 | [worker(Pushex.Sandbox, [])] 36 | else 37 | [] 38 | end) 39 | 40 | opts = [strategy: :one_for_one, name: Pushex.Supervisor] 41 | Supervisor.start_link(children, opts) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/pushex/app_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.AppManager do 2 | @moduledoc """ 3 | `Pushex.AppManager` is used to retreive applications from their name. 4 | 5 | By default, applications will be loaded from the configuration, 6 | but this behaviour can be implemented to get an app from a database for example. 7 | 8 | ## Example 9 | 10 | defmodule MyAppManager do 11 | @behaviour Pushex.AppManager 12 | 13 | def find_app(:gcm, name) do 14 | app = Repo.find_by(platform: gcm, name: name) 15 | %Pushex.GCM.App{name: name, auth_key: app.auth_key} 16 | end 17 | end 18 | """ 19 | 20 | @callback find_app(platform :: atom, name :: String.t) :: Pushex.GCM.App 21 | 22 | def find_app(platform, name) do 23 | impl().find_app(platform, name) 24 | end 25 | 26 | defp impl(), do: Application.get_env(:pushex, :app_manager_impl) 27 | end 28 | -------------------------------------------------------------------------------- /lib/pushex/app_manager/memory.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.AppManager.Memory do 2 | @moduledoc """ 3 | An in memory implementation using a `GenServer` for `Pushex.AppManager` 4 | """ 5 | use GenServer 6 | 7 | @behaviour Pushex.AppManager 8 | 9 | @valid_platforms ~w(gcm apns)a 10 | 11 | def start(apps \\ []) do 12 | GenServer.start(__MODULE__, apps, name: __MODULE__) 13 | end 14 | 15 | def start_link(apps \\ []) do 16 | GenServer.start_link(__MODULE__, apps, name: __MODULE__) 17 | end 18 | 19 | def init([]) do 20 | {:ok, Application.get_env(:pushex, :apps, [])} 21 | end 22 | def init(apps) do 23 | {:ok, apps} 24 | end 25 | 26 | def find_app(platform, name) when platform in unquote(@valid_platforms) do 27 | GenServer.call(__MODULE__, {:find, platform, name}) 28 | end 29 | 30 | def handle_call({:find, platform, name}, _from, apps) do 31 | app = Map.get(apps[platform], name) 32 | {:reply, app, apps} 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/pushex/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Config do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | @default_gcm_endpoint "https://fcm.googleapis.com/fcm" 7 | 8 | def start_link(opts) do 9 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 10 | end 11 | 12 | def init(opts) do 13 | opts |> make_defaults() |> do_configure() 14 | {:ok, []} 15 | end 16 | 17 | def event_handlers do 18 | for handler <- Application.get_env(:pushex, :event_handlers, []) do 19 | {Pushex.EventManager, handler, []} 20 | end 21 | end 22 | 23 | def configure(options) do 24 | GenServer.call(__MODULE__, {:configure, options}) 25 | end 26 | 27 | def add_event_handler(handler) do 28 | GenServer.call(__MODULE__, {:add_event_handler, handler}) 29 | end 30 | 31 | def handle_call({:configure, options}, _from, state) do 32 | do_configure(options) 33 | {:reply, :ok, state} 34 | end 35 | 36 | def handle_call({:add_event_handler, handler}, _from, state) do 37 | update_event_handlers(&[handler|List.delete(&1, handler)]) 38 | {:reply, :ok, state} 39 | end 40 | 41 | def make_defaults(base_config) do 42 | config = base_config |> make_common_config() 43 | if config[:sandbox] do 44 | make_sandbox_defaults(config) 45 | else 46 | make_normal_settings(config) 47 | end 48 | end 49 | 50 | defp make_common_config(config) do 51 | gcm_config = 52 | Keyword.get(config, :gcm, []) 53 | |> Keyword.put_new(:endpoint, @default_gcm_endpoint) 54 | |> Keyword.put_new(:pool_options, [size: 100, max_overflow: 20]) 55 | 56 | apns_config = 57 | Keyword.get(config, :apns, []) 58 | |> Keyword.put_new(:pool_options, [size: 100, max_overflow: 20]) 59 | 60 | config 61 | |> Keyword.put(:gcm, gcm_config) 62 | |> Keyword.put(:apns, apns_config) 63 | |> load_apps(:gcm, Pushex.GCM.App) 64 | |> load_apps(:apns, Pushex.APNS.App) 65 | |> Keyword.put_new(:app_manager_impl, Pushex.AppManager.Memory) 66 | |> Keyword.put_new(:event_handlers, []) 67 | end 68 | 69 | defp make_normal_settings(config) do 70 | gcm_config = 71 | Keyword.get(config, :gcm, []) 72 | |> Keyword.put_new(:client_impl, Pushex.GCM.Client.HTTP) 73 | apns_config = config 74 | |> Keyword.get(:apns, []) 75 | |> Keyword.put_new(:client_impl, Pushex.APNS.Client.SSL) 76 | config 77 | |> Keyword.put(:gcm, gcm_config) 78 | |> Keyword.put(:apns, apns_config) 79 | end 80 | 81 | defp make_sandbox_defaults(config) do 82 | base_handlers = Keyword.get(config, :event_handlers, []) 83 | event_handlers = if Enum.find(base_handlers, &(&1 == Pushex.EventHandler.Sandbox)) do 84 | base_handlers 85 | else 86 | base_handlers ++ [Pushex.EventHandler.Sandbox] 87 | end 88 | gcm_config = 89 | Keyword.get(config, :gcm, []) 90 | |> Keyword.put_new(:client_impl, Pushex.GCM.Client.Sandbox) 91 | apns_config = 92 | Keyword.get(config, :apns, []) 93 | |> Keyword.put_new(:client_impl, Pushex.APNS.Client.Sandbox) 94 | 95 | config 96 | |> Keyword.put(:gcm, gcm_config) 97 | |> Keyword.put(:apns, apns_config) 98 | |> Keyword.put(:event_handlers, event_handlers) 99 | end 100 | 101 | defp load_apps(config, platform, mod) do 102 | apps = 103 | Keyword.get(config[platform], :apps, []) 104 | |> Enum.map(&mod.create!/1) 105 | |> Enum.group_by(&(&1.name)) 106 | |> Enum.map(fn {k, v} -> {k, List.first(v)} end) 107 | |> Enum.into(%{}) 108 | Keyword.put(config, :apps, [{platform, apps} | Keyword.get(config, :apps, [])]) 109 | end 110 | 111 | defp update_event_handlers(fun) do 112 | event_handlers = fun.(Application.get_env(:pushex, :event_handlers, [])) 113 | Application.put_env(:pushex, :event_handlers, event_handlers) 114 | end 115 | 116 | defp do_configure(options) do 117 | Enum.each options, fn {key, value} -> 118 | Application.put_env(:pushex, key, value) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/pushex/event_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.EventHandler do 2 | @moduledoc """ 3 | This module defines utilities to handle events when sending notification. 4 | 5 | Events can be implemented to log requests, responses or save them in a database for example. 6 | 7 | Under the hood, it uses a `:gen_event`, so you will need to implement 8 | `handle_event` for the events you care about. 9 | 10 | The following events are emitted: 11 | 12 | * `{:request, request, {pid, ref}}` 13 | * `{:response, response, request, {pid, ref}}` 14 | 15 | ## Examples 16 | 17 | defmodule MyPushEventHandler do 18 | use Pushex.EventHandler 19 | 20 | def handle_event({:request, request, {pid, ref}}, state) do 21 | # do whatever you want with the request 22 | # for example, logging or saving in a DB 23 | {:ok, state} 24 | end 25 | 26 | def handle_event({:response, response, request, {pid, ref}}, state) do 27 | # do whatever you want with the response and request 28 | {:ok, state} 29 | end 30 | end 31 | """ 32 | 33 | @doc false 34 | defmacro __using__(_opts) do 35 | quote do 36 | @behaviour :gen_event 37 | @before_compile {unquote(__MODULE__), :add_default_handlers} 38 | end 39 | end 40 | 41 | @doc false 42 | defmacro add_default_handlers(_env) do 43 | quote do 44 | def init(args), do: {:ok, args} 45 | def handle_call(_request, state), do: {:ok, :ok, state} 46 | def handle_info(_info, state), do: {:ok, state} 47 | def code_change(_old_vsn, state, _extra), do: state 48 | def terminate(args, _state), do: :ok 49 | 50 | def handle_event({:request, _request, _info}, state), do: {:ok, state} 51 | def handle_event({:response, _response, _request, _info}, state), do: {:ok, state} 52 | def handle_event({:error, _error, _token}, state), do: {:ok, state} 53 | def handle_event({:feedback, _feedback}, state), do: {:ok, state} 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/pushex/event_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.EventManager do 2 | @moduledoc false 3 | 4 | def handle_request(request, info) do 5 | :gen_event.notify(__MODULE__, {:request, request, info}) 6 | end 7 | 8 | def handle_response(response, request, info) do 9 | :gen_event.notify(__MODULE__, {:response, response, request, info}) 10 | end 11 | 12 | def handle_error(error, token) do 13 | :gen_event.notify(__MODULE__, {:error, error, token}) 14 | end 15 | 16 | def handle_feedback(feedback) do 17 | :gen_event.notify(__MODULE__, {:feedback, feedback}) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/pushex/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.ValidationError do 2 | @moduledoc """ 3 | `Pushex.ValidationError` is raised when a request contains invalid or incomplete data 4 | """ 5 | defexception [:errors] 6 | 7 | def message(err) do 8 | List.wrap(err.errors) 9 | |> Enum.map(&format_error/1) 10 | |> Enum.join(". ") 11 | end 12 | 13 | defp format_error({:error, field, validator, error}) do 14 | "error on #{inspect(field)} with #{inspect(validator)} validator: #{error}" 15 | end 16 | end 17 | 18 | defmodule Pushex.AppNotFoundError do 19 | @moduledoc """ 20 | `Pushex.AppNotFoundError` is raised when the app to send the request do not exist. 21 | """ 22 | 23 | defexception [:platform, :name] 24 | 25 | def message(err) do 26 | "could not find an app named #{inspect(err.name)} for platform #{inspect(err.platform)}" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/pushex/gcm.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM do 2 | @moduledoc "This module defines types to work with GCM" 3 | 4 | @type response :: {:ok, Pushex.GCM.Response} | {:error, Pushex.GCM.HTTPError} 5 | @type request :: Pushex.GCM.Request.t 6 | end 7 | -------------------------------------------------------------------------------- /lib/pushex/gcm/app.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.App do 2 | @moduledoc """ 3 | `Pushex.GCM.App` represents a GCM application. 4 | 5 | The `name` is a unique identifier used to find the application, 6 | and the `auth_key` is the API key provided by Google. 7 | """ 8 | use Vex.Struct 9 | 10 | defstruct [:name, :auth_key] 11 | 12 | @type t :: %__MODULE__{name: String.t, auth_key: String.t} 13 | 14 | validates :name, 15 | presence: true, 16 | type: [is: :string] 17 | validates :auth_key, 18 | presence: true, 19 | type: [is: :string] 20 | 21 | def create(app) do 22 | app = struct(Pushex.GCM.App, app) 23 | Pushex.Util.validate(app) 24 | end 25 | def create!(app) do 26 | case create(app) do 27 | {:ok, app} -> app 28 | {:error, errors} -> raise Pushex.ValidationError, errors: errors 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pushex/gcm/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.Client do 2 | @moduledoc """ 3 | Module defining the behaviour to send requests to GCM. 4 | 5 | The behaviour can be changed by setting the `:client_impl` module 6 | under the configuration `pushex: :gcm` key 7 | """ 8 | 9 | @callback send_notification(notification :: Pushex.GCM.Request.t) :: {:ok, Pushex.GCM.Response.t} | {:error, Pushex.GCM.HTTPError.t} 10 | 11 | def send_notification(notification) do 12 | impl().send_notification(notification) 13 | end 14 | 15 | defp impl(), do: Application.get_env(:pushex, :gcm)[:client_impl] 16 | end 17 | -------------------------------------------------------------------------------- /lib/pushex/gcm/client/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.Client.HTTP do 2 | @moduledoc """ 3 | Implementation of `Pushex.GCM.Client` sending HTTP requests to the GCM API 4 | """ 5 | 6 | use HTTPoison.Base 7 | 8 | @behaviour Pushex.GCM.Client 9 | 10 | @expected_fields ~w(multicast_id success failure canonical_ids results) 11 | 12 | def process_url(url) do 13 | Path.join(endpoint(), url) 14 | end 15 | 16 | defp process_request_body(body) when is_binary(body), do: body 17 | defp process_request_body(body) do 18 | Poison.encode!(body) 19 | end 20 | 21 | defp process_request_headers(headers) when is_map(headers) do 22 | Enum.into(headers, []) 23 | end 24 | defp process_request_headers(headers) do 25 | [{"Content-Type", "application/json"} | headers] 26 | end 27 | 28 | def send_notification(request) do 29 | headers = [{"Authorization", "key=#{request.app.auth_key}"}] 30 | post("send", request, headers) |> process_notification_response 31 | end 32 | 33 | defp process_notification_response({:ok, %HTTPoison.Response{status_code: 200, body: body}}) do 34 | response = body 35 | |> Poison.decode! 36 | |> Map.take(@expected_fields) 37 | |> Enum.map(fn({k, v}) -> {String.to_atom(k), v} end) 38 | {:ok, struct(Pushex.GCM.Response, response)} 39 | end 40 | defp process_notification_response({:ok, %HTTPoison.Response{status_code: code, body: body}}) do 41 | {:error, %Pushex.GCM.HTTPError{status_code: code, reason: body}} 42 | end 43 | defp process_notification_response({:error, %HTTPoison.Error{reason: reason}}) do 44 | {:error, %Pushex.GCM.HTTPError{status_code: 0, reason: reason}} 45 | end 46 | 47 | defp endpoint() do 48 | Application.get_env(:pushex, :gcm)[:endpoint] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/pushex/gcm/client/sandbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.Client.Sandbox do 2 | @moduledoc false 3 | 4 | @behaviour Pushex.GCM.Client 5 | 6 | def send_notification(request) do 7 | if request.to == :bad_id do 8 | {:error, %Pushex.GCM.HTTPError{status_code: 401, reason: "not authorized"}} 9 | else 10 | count = if request.registration_ids, do: Enum.count(request.registration_ids), else: 1 11 | results = Enum.map(0..count, &(%{message_id: "#{&1}:123456#{&1}"})) 12 | response = %Pushex.GCM.Response{canonical_ids: 0, 13 | success: count, 14 | multicast_id: 123456, 15 | results: results} 16 | {:ok, response} 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/pushex/gcm/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.HTTPError do 2 | @moduledoc """ 3 | `Pushex.GCM.HTTPError` represents a failed request to GCM API. 4 | """ 5 | 6 | defexception [:status_code, :reason] 7 | 8 | @type t :: %__MODULE__{ 9 | status_code: non_neg_integer, 10 | reason: String.t 11 | } 12 | 13 | def message(err) do 14 | "HTTP request failed with status #{err.status_code}: #{inspect(err.reason)}" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/pushex/gcm/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.Request do 2 | @moduledoc """ 3 | `Pushex.GCM.Request` represents a request that will be sent to GCM. 4 | It contains the notification, and all the metadata that can be sent with it. 5 | 6 | Only the key with a value will be sent to GCM, so that the proper default 7 | are used. 8 | """ 9 | 10 | use Vex.Struct 11 | 12 | defstruct [ 13 | :app, 14 | :registration_ids, 15 | :to, 16 | :collapse_key, 17 | :priority, 18 | :content_available, 19 | :delay_while_idle, 20 | :time_to_live, 21 | :restricted_package_name, 22 | :data, 23 | :notification 24 | ] 25 | 26 | @type t :: %__MODULE__{ 27 | app: Pushex.GCM.App.t, 28 | registration_ids: [String.t], 29 | to: String.t, 30 | collapse_key: String.t, 31 | priority: String.t, 32 | content_available: boolean, 33 | delay_while_idle: boolean, 34 | time_to_live: non_neg_integer, 35 | restricted_package_name: String.t, 36 | data: map, 37 | notification: map 38 | } 39 | 40 | validates :app, 41 | presence: true, 42 | type: [is: Pushex.GCM.App] 43 | validates :registration_ids, 44 | type: [is: [[list: :binary], :nil]], 45 | presence: [if: [to: nil]] 46 | validates :to, 47 | type: [is: [:binary, :nil]], 48 | presence: [if: [registration_ids: nil]] 49 | validates :collapse_key, 50 | type: [is: [:binary, :nil]] 51 | validates :priority, 52 | type: [is: [:string, :nil]], 53 | inclusion: [in: ~w(high normal), allow_nil: true] 54 | validates :content_available, 55 | type: [is: [:boolean, :nil]] 56 | validates :delay_while_idle, 57 | type: [is: [:boolean, :nil]] 58 | validates :time_to_live, 59 | type: [is: [:integer, :nil]] 60 | validates :restricted_package_name, 61 | type: [is: [:binary, :nil]] 62 | validates :data, 63 | type: [is: [:map, :nil]] 64 | validates :notification, 65 | type: [is: [:map, :nil]] 66 | 67 | 68 | def create(params) do 69 | Pushex.Util.create_struct(__MODULE__, params) 70 | end 71 | def create!(params) do 72 | Pushex.Util.create_struct!(__MODULE__, params) 73 | end 74 | 75 | def create!(notification, app, opts) do 76 | params = opts 77 | |> Keyword.merge(create_base_request(notification)) 78 | |> Keyword.put(:app, app) 79 | |> normalize_opts 80 | 81 | Pushex.Util.create_struct!(__MODULE__, params) 82 | end 83 | 84 | defp create_base_request(notification) do 85 | keys = ["notification", "data", :notification, :data] 86 | req = Enum.reduce(keys, %{}, fn(elem, acc) -> add_field(acc, notification, elem) end) 87 | if Enum.empty?(req) do 88 | [notification: notification] 89 | else 90 | Keyword.new(req) 91 | end 92 | end 93 | 94 | defp add_field(request, notification, field) do 95 | case Map.fetch(notification, field) do 96 | {:ok, value} -> Map.put(request, field, value) 97 | :error -> request 98 | end 99 | end 100 | 101 | defp normalize_opts(opts) do 102 | if is_list(opts[:to]) do 103 | {to, opts} = Keyword.pop(opts, :to) 104 | Keyword.put(opts, :registration_ids, to) 105 | else 106 | opts 107 | end 108 | end 109 | end 110 | 111 | defimpl Poison.Encoder, for: Pushex.GCM.Request do 112 | def encode(request, options) do 113 | request 114 | |> Map.from_struct() 115 | |> Enum.filter(fn {_key, value} -> not is_nil(value) end) 116 | |> Enum.into(%{}) 117 | |> Map.delete(:app) 118 | |> Poison.encode!(options) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/pushex/gcm/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.Response do 2 | @moduledoc """ 3 | `Pushex.GCM.Response` represents a GCM response. 4 | 5 | When `canonical_ids` is greater than `0`, `results` should be checked 6 | and the registration ids should be updated consequently. 7 | This should be done in a custom `ResponseHandler`. 8 | 9 | See https://developers.google.com/cloud-messaging/http#response for more info 10 | """ 11 | 12 | defstruct [:multicast_id, :success, :failure, :canonical_ids, :results] 13 | 14 | @type t :: %__MODULE__{ 15 | multicast_id: integer, 16 | success: non_neg_integer, 17 | failure: non_neg_integer, 18 | canonical_ids: non_neg_integer, 19 | results: [%{String.t => String.t}] 20 | } 21 | end 22 | -------------------------------------------------------------------------------- /lib/pushex/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Helpers do 2 | @moduledoc """ 3 | Module containing helpers functions to use Pushex functionalities easily. 4 | """ 5 | 6 | @doc """ 7 | Sends a notification asynchrnously. 8 | The first argument can be a notification or a full request (i.e. a `Pushex.GCM.Request`) 9 | 10 | When using the first form, all the options passed will be passed to 11 | the request (e.g. `:priority`) 12 | 13 | The function raises an exception if the request cannot be executed, 14 | for example if a parameter is missing, or the requested application is not found. 15 | If the request is executed but fails, it should be handled in the response handler. 16 | 17 | ## Examples 18 | 19 | notification = %{title: "my title", body: "my body"} 20 | 21 | app = %Pushex.GCM.App{name: "some name", auth_key: "my_auth_key"} 22 | reg_id = get_my_registration_id 23 | Pushex.push(notification, to: reg_id, with_app: app) 24 | 25 | # with default_app setup 26 | Pushex.push(notification, to: reg_id, using: :gcm) 27 | 28 | 29 | request = %Pushex.GCM.Request{app: app, notification: notification, to: reg_id} 30 | Pushex.push(request) 31 | """ 32 | @spec send_notification(Pushex.GCM.request | Pushex.APNS.request | map, Keyword.t) :: reference 33 | def send_notification(request, opts \\ []) 34 | def send_notification(%Pushex.GCM.Request{} = request, _opts) do 35 | Pushex.Worker.send_notification(request) 36 | end 37 | def send_notification(%Pushex.APNS.Request{} = request, _opts) do 38 | Pushex.Worker.send_notification(request) 39 | end 40 | def send_notification(notification, opts) do 41 | if opts[:using] do 42 | do_send_notification(notification, opts[:using], opts) 43 | else 44 | case Keyword.get(opts, :with_app) do 45 | %Pushex.GCM.App{} -> do_send_notification(notification, :gcm, opts) 46 | %Pushex.APNS.App{} -> do_send_notification(notification, :apns, opts) 47 | _ -> raise ArgumentError, ":with_app must be a `Pushex.GCM.App` or `Pushex.APNS.App` when :using is not passed" 48 | end 49 | end 50 | end 51 | 52 | defp do_send_notification(notification, platform, opts) when platform in ["gcm", "apns"] do 53 | do_send_notification(notification, String.to_atom(platform), opts) 54 | end 55 | defp do_send_notification(notification, platform, opts) when platform in [:gcm, :apns] do 56 | {app, opts} = Keyword.pop(opts, :with_app) 57 | app = fetch_app(platform, app || default_app(platform)) 58 | 59 | notification 60 | |> Pushex.Util.normalize_notification(platform) 61 | |> make_request(app, opts) 62 | |> Pushex.Worker.send_notification() 63 | end 64 | defp do_send_notification(_notification, platform, _opts) do 65 | raise ArgumentError, "#{inspect(platform)} is not a valid platform" 66 | end 67 | 68 | defp fetch_app(platform, nil) do 69 | raise ArgumentError, """ 70 | you need to define a default app for the #{platform} in your config 71 | or to pass one explicitly with the :with_app parameter 72 | """ 73 | end 74 | defp fetch_app(_platform, %Pushex.GCM.App{} = app), do: app 75 | defp fetch_app(_platform, %Pushex.APNS.App{} = app), do: app 76 | defp fetch_app(platform, app_name) when is_binary(app_name) or is_atom(app_name) do 77 | case Pushex.AppManager.find_app(platform, app_name) do 78 | nil -> raise Pushex.AppNotFoundError, platform: platform, name: app_name 79 | app -> app 80 | end 81 | end 82 | 83 | defp make_request(notification, %Pushex.GCM.App{} = app, opts) do 84 | Pushex.GCM.Request.create!(notification, app, opts) 85 | end 86 | defp make_request(notification, %Pushex.APNS.App{} = app, opts) do 87 | Pushex.APNS.Request.create!(notification, app, opts) 88 | end 89 | defp make_request(_notification, app, _opts) do 90 | raise ArgumentError, "application must be Pushex.GCM.App or Pushex.APNS.app, got #{inspect(app)}" 91 | end 92 | 93 | defp default_app(platform) do 94 | Application.get_env(:pushex, platform)[:default_app] 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/pushex/response_handler/sandbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.EventHandler.Sandbox do 2 | @moduledoc """ 3 | The event handler used when sandbox mode is activated. 4 | 5 | It will send a message containing the response, request and pid/ref information 6 | back to the caller of `Pushex.push/2` and record the notification 7 | to `Pushex.Sandbox`. 8 | """ 9 | 10 | use Pushex.EventHandler 11 | 12 | def handle_event({:response, response, request, {pid, ref} = info}, state) do 13 | send(pid, {response, request, ref}) 14 | Pushex.Sandbox.record_notification(response, request, info) 15 | {:ok, state} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pushex/sandbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Sandbox do 2 | @moduledoc """ 3 | Sandbox where notifications get saved when the application is running in sandbox mode. 4 | 5 | This is meant to be used in tests, and should not be used in production. 6 | Note that all operations are dependent on the `pid`, so the process 7 | calling and `Pushex.push/2` and the process calling `Pushex.Sandbox.list_notifications/1` 8 | must be the same, or the `pid` should be passed explicitly otherwise. 9 | """ 10 | 11 | use GenServer 12 | 13 | @doc """ 14 | Records the notification. This is used by `Pushex.ResponseHandler.Sandbox` to record 15 | requests and responses. 16 | """ 17 | @spec record_notification(Pushex.GCM.response, Pushex.GCM.request, {pid, reference}) :: :ok 18 | def record_notification(response, request, info) do 19 | GenServer.call(__MODULE__, {:record_notification, response, request, info}) 20 | end 21 | 22 | @doc """ 23 | Wait until a notification arrives. 24 | """ 25 | @spec wait_notifications([pid: pid, timeout: non_neg_integer, count: non_neg_integer]) :: [{Pushex.GCM.response, Pushex.GCM.request, {pid, reference}}] 26 | def wait_notifications(opts \\ []) do 27 | pid = opts[:pid] || self() 28 | timeout = opts[:timeout] || 100 29 | count = opts[:count] || 1 30 | case list_notifications(pid: pid) do 31 | notifications when length(notifications) < count and timeout > 0 -> 32 | receive do 33 | after 10 -> 34 | wait_notifications(pid: pid, timeout: timeout - 10, count: count) 35 | end 36 | notifications -> notifications 37 | end 38 | end 39 | 40 | @doc """ 41 | List recorded notifications keeping their order of arrival. 42 | """ 43 | @spec list_notifications([pid: pid]) :: [{Pushex.GCM.response, Pushex.GCM.request, {pid, reference}}] 44 | def list_notifications(opts \\ []) do 45 | pid = opts[:pid] || self() 46 | GenServer.call(__MODULE__, {:list_notifications, pid}) 47 | end 48 | 49 | @doc """ 50 | Clear all the recorded notifications. 51 | """ 52 | @spec clear_notifications([pid: pid]) :: :ok 53 | def clear_notifications(opts \\ []) do 54 | pid = opts[:pid] || self() 55 | GenServer.call(__MODULE__, {:clear_notifications, pid}) 56 | end 57 | 58 | @doc false 59 | def init(init_args) do 60 | {:ok, init_args} 61 | end 62 | 63 | @doc false 64 | def start_link do 65 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 66 | end 67 | 68 | @doc false 69 | def handle_call({:record_notification, response, request, {pid, _ref} = info}, _from, state) do 70 | notifications = [{response, request, info} | Map.get(state, pid, [])] 71 | {:reply, :ok, Map.put(state, pid, notifications)} 72 | end 73 | 74 | @doc false 75 | def handle_call({:list_notifications, pid}, _from, state) do 76 | notifications = Map.get(state, pid, []) |> Enum.reverse 77 | {:reply, notifications, state} 78 | end 79 | 80 | @doc false 81 | def handle_call({:clear_notifications, pid}, _from, state) do 82 | {:reply, :ok, Map.put(state, pid, [])} 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/pushex/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Util do 2 | def validate(data) do 3 | validate(data, Vex.Extract.settings(data)) 4 | end 5 | defp validate(data, settings) do 6 | case Vex.errors(data, settings) do 7 | errors when errors != [] -> {:error, errors} 8 | _ -> {:ok, data} 9 | end 10 | end 11 | 12 | def normalize_notification(notification, platform) do 13 | {platform_data, notification} = cond do 14 | Map.has_key?(notification, platform) -> 15 | Map.pop(notification, platform) 16 | Map.has_key?(notification, to_string(platform)) -> 17 | Map.pop(notification, to_string(platform)) 18 | true -> 19 | {%{}, notification} 20 | end 21 | Map.merge(notification, platform_data) 22 | end 23 | 24 | def create_struct!(mod, params) do 25 | case create_struct(mod, params) do 26 | {:ok, value} -> value 27 | {:error, err} -> raise Pushex.ValidationError, errors: err 28 | end 29 | end 30 | def create_struct(mod, %{__struct__: struct_name} = value) when mod == struct_name do 31 | validate(value) 32 | end 33 | def create_struct(mod, params) when is_map(params) do 34 | create_struct(mod, struct(mod, params)) 35 | end 36 | def create_struct(mod, params) when is_list(params) do 37 | create_struct(mod, Enum.into(params, %{})) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/pushex/validators/type.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Validators.Type do 2 | @moduledoc """ 3 | Ensure the value has the correct type. 4 | 5 | The type can be provided in the following form: 6 | 7 | * `type`: An atom representing the type. 8 | It can be any of the `TYPE` in Elixir `is_TYPE` functions. 9 | `:any` is treated as a special case and accepts any type. 10 | * `[type]`: A list of types as described above. When a list is passed, 11 | the value will be valid if it any of the types in the list. 12 | * `type: inner_type`: Type should be either `map`, `list`, `tuple`, or `function`. 13 | The usage are as follow 14 | 15 | * `function: arity`: checks if the function has the correct arity. 16 | * `map: {key_type, value_type}`: checks keys and value in the map with the provided types. 17 | * `list: type`: checks every element in the list for the given types. 18 | * `tuple: {type_a, type_b}`: check each element of the tuple with the provided types, 19 | the types tuple should be the same size as the tuple itself. 20 | 21 | ## Options 22 | 23 | * `:is`: Required. The type of the value, in the format described above. 24 | * `:message`: Optional. A custom error message. May be in EEx format 25 | and use the fields described in "Custom Error Messages," below. 26 | 27 | ## Examples 28 | 29 | iex> Vex.Validators.Type.validate(1, is: :binary) 30 | {:error, "must be of type :binary"} 31 | iex> Vex.Validators.Type.validate(1, is: :number) 32 | :ok 33 | iex> Vex.Validators.Type.validate(nil, is: nil) 34 | :ok 35 | iex> Vex.Validators.Type.validate(1, is: :integer) 36 | :ok 37 | iex> Vex.Validators.Type.validate("foo"", is: :binary) 38 | :ok 39 | iex> Vex.Validators.Type.validate([1, 2, 3], is: [list: :integer]) 40 | :ok 41 | iex> Vex.Validators.Type.validate(%{:a => 1, "b" => 2, 3 => 4}, is: :map) 42 | :ok 43 | iex> Vex.Validators.Type.validate(%{:a => 1, "b" => 2}, is: [map: {[:binary, :atom], :any}]) 44 | :ok 45 | iex> Vex.Validators.Type.validate(%{"b" => 2, 3 => 4}, is: [map: {[:binary, :atom], :any}]) 46 | {:error, "must be of type {:map, {[:binary, :atom], :any}}"} 47 | 48 | ## Custom Error Messages 49 | 50 | Custom error messages (in EEx format), provided as :message, can use the following values: 51 | 52 | iex> Vex.Validators.Type.__validator__(:message_fields) 53 | [value: "The bad value"] 54 | 55 | An example: 56 | 57 | iex> Vex.Validators.Type.validate([1], is: :binary, message: "<%= inspect value %> is not a string") 58 | {:error, "[1] is not a string"} 59 | """ 60 | use Vex.Validator 61 | 62 | @message_fields [value: "The bad value"] 63 | 64 | @doc """ 65 | Validates the value against the given type. 66 | See the module documentation for more info. 67 | """ 68 | @spec validate(any, Keyword.t) :: :ok | {:error, String.t} 69 | def validate(value, options) when is_list(options) do 70 | acceptable_types = Keyword.get(options, :is, []) 71 | if do_validate(value, acceptable_types) do 72 | :ok 73 | else 74 | message = "must be of type #{acceptable_type_str(acceptable_types)}" 75 | {:error, message(options, message, value: value)} 76 | end 77 | end 78 | 79 | # Allow any type, useful for composed types 80 | defp do_validate(_value, :any), do: true 81 | 82 | # Handle nil 83 | defp do_validate(nil, nil), do: true 84 | defp do_validate(nil, :atom), do: false 85 | 86 | # Simple types 87 | defp do_validate(value, :atom) when is_atom(value), do: true 88 | defp do_validate(value, :number) when is_number(value), do: true 89 | defp do_validate(value, :integer) when is_integer(value), do: true 90 | defp do_validate(value, :float) when is_float(value), do: true 91 | defp do_validate(value, :boolean) when is_boolean(value), do: true 92 | defp do_validate(value, :binary) when is_binary(value), do: true 93 | defp do_validate(value, :bitstring) when is_bitstring(value), do: true 94 | defp do_validate(value, :tuple) when is_tuple(value), do: true 95 | defp do_validate(value, :list) when is_list(value), do: true 96 | defp do_validate(value, :map) when is_map(value), do: true 97 | defp do_validate(value, :function) when is_function(value), do: true 98 | defp do_validate(value, :reference) when is_reference(value), do: true 99 | defp do_validate(value, :port) when is_port(value), do: true 100 | defp do_validate(value, :pid) when is_pid(value), do: true 101 | defp do_validate(%{__struct__: module}, module), do: true 102 | 103 | # Complex types 104 | defp do_validate(value, :string) when is_binary(value) do 105 | String.valid?(value) 106 | end 107 | 108 | defp do_validate(value, function: arity) when is_function(value, arity), do: true 109 | 110 | defp do_validate(list, list: type) when is_list(list) do 111 | Enum.all?(list, &(do_validate(&1, type))) 112 | end 113 | defp do_validate(value, map: {key_type, value_type}) when is_map(value) do 114 | Enum.all? value, fn {k, v} -> 115 | do_validate(k, key_type) && do_validate(v, value_type) 116 | end 117 | end 118 | defp do_validate(tuple, tuple: types) 119 | when is_tuple(tuple) and is_tuple(types) and tuple_size(tuple) == tuple_size(types) do 120 | Enum.all? Enum.zip(Tuple.to_list(tuple), Tuple.to_list(types)), fn {value, type} -> 121 | do_validate(value, type) 122 | end 123 | end 124 | 125 | # Accept multiple types 126 | defp do_validate(value, acceptable_types) when is_list(acceptable_types) do 127 | Enum.any?(acceptable_types, &(do_validate(value, &1))) 128 | end 129 | 130 | # Fail if nothing above matched 131 | defp do_validate(_value, _type), do: false 132 | 133 | 134 | defp acceptable_type_str([acceptable_type]), do: inspect(acceptable_type) 135 | defp acceptable_type_str(acceptable_types) when is_list(acceptable_types) do 136 | last_type = acceptable_types |> List.last |> inspect 137 | but_last = 138 | acceptable_types 139 | |> Enum.take(Enum.count(acceptable_types) - 1) 140 | |> Enum.map(&inspect/1) 141 | |> Enum.join(", ") 142 | "#{but_last} or #{last_type}" 143 | end 144 | defp acceptable_type_str(acceptable_type), do: inspect(acceptable_type) 145 | end 146 | -------------------------------------------------------------------------------- /lib/pushex/watcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Watcher do 2 | @moduledoc false 3 | 4 | use GenServer 5 | require Logger 6 | 7 | @name __MODULE__ 8 | 9 | def start_link(m, f, a) do 10 | import Supervisor.Spec 11 | child = worker(__MODULE__, [], function: :watcher, restart: :transient) 12 | options = [strategy: :simple_one_for_one, name: @name] 13 | case Supervisor.start_link([child], options) do 14 | {:ok, _res} = ok -> 15 | _ = for {mod, handler, args} <- apply(m, f, a) do 16 | watch(mod, handler, args) 17 | end 18 | ok 19 | end 20 | end 21 | 22 | def watch(mod, handler, args) do 23 | case Supervisor.start_child(@name, [mod, handler, args]) do 24 | {:ok, _pid} = res -> 25 | res 26 | {:error, _reason} = err -> 27 | err 28 | end 29 | end 30 | 31 | def unwatch(mod, handler) do 32 | :gen_event.delete_handler(mod, handler, []) 33 | end 34 | 35 | def watcher(mod, handler, args) do 36 | GenServer.start_link(__MODULE__, {mod, handler, args}) 37 | end 38 | 39 | def init({mod, handler, args}) do 40 | ref = Process.monitor(mod) 41 | case :gen_event.add_sup_handler(mod, handler, args) do 42 | :ok -> 43 | {:ok, {mod, handler, ref}} 44 | {:error, :ignore} -> 45 | send(self(), {:gen_event_EXIT, handler, :normal}) 46 | {:ok, {mod, handler, ref}} 47 | {:error, reason} -> 48 | {:stop, reason} 49 | end 50 | end 51 | 52 | @doc false 53 | def handle_info({:gen_event_EXIT, handler, reason}, {_, handler, _} = state) 54 | when reason in [:normal, :shutdown] do 55 | {:stop, reason, state} 56 | end 57 | 58 | def handle_info({:gen_event_EXIT, handler, reason}, {mod, handler, _} = state) do 59 | _ = Logger.error ":gen_event handler #{inspect handler} installed at #{inspect mod}\n" <> 60 | "** (exit) #{format_exit(reason)}" 61 | {:stop, reason, state} 62 | end 63 | 64 | def handle_info({:DOWN, ref, _, _, reason}, {mod, handler, ref} = state) do 65 | _ = Logger.error ":gen_event handler #{inspect handler} installed at #{inspect mod}\n" <> 66 | "** (exit) #{format_exit(reason)}" 67 | {:stop, reason, state} 68 | end 69 | 70 | def handle_info(_msg, state) do 71 | {:noreply, state} 72 | end 73 | 74 | defp format_exit({:EXIT, reason}), do: Exception.format_exit(reason) 75 | defp format_exit(reason), do: Exception.format_exit(reason) 76 | end 77 | -------------------------------------------------------------------------------- /lib/pushex/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Worker do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | 7 | @doc false 8 | def init(init_arg) do 9 | {:ok, init_arg} 10 | end 11 | 12 | @doc false 13 | def start_link(options \\ []) do 14 | GenServer.start_link(__MODULE__, options) 15 | end 16 | 17 | @doc """ 18 | Sends a notification asynchronously using the implementation defined in the configuration 19 | """ 20 | @spec send_notification(Pushex.GCM.request | Pushex.APNS.request, reference) :: reference 21 | def send_notification(request, ref \\ make_ref()) 22 | def send_notification(%Pushex.GCM.Request{} = request, ref) do 23 | do_send_notification_async(Pushex.GCM, request, ref) 24 | end 25 | def send_notification(%Pushex.APNS.Request{} = request, ref) do 26 | do_send_notification_async(Pushex.APNS, request, ref) 27 | end 28 | 29 | defp do_send_notification_async(module, request, ref) do 30 | message = {:send, {self(), ref}, request} 31 | :poolboy.transaction(module, &GenServer.cast(&1, message)) 32 | ref 33 | end 34 | 35 | @doc false 36 | def handle_cast({:send, info, request}, state) do 37 | do_send_notification(state[:client], info, request) 38 | {:noreply, state} 39 | end 40 | 41 | defp do_send_notification(client, info, request) do 42 | Pushex.EventManager.handle_request(request, info) 43 | client.send_notification(request) 44 | |> Pushex.EventManager.handle_response(request, info) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.2.3" 5 | 6 | def project do 7 | [app: :pushex, 8 | version: @version, 9 | elixir: "~> 1.2", 10 | elixirc_paths: elixirc_paths(Mix.env), 11 | description: "Mobile push notification library", 12 | source_url: "https://github.com/tuvistavie/pushex", 13 | build_embedded: Mix.env == :prod, 14 | start_permanent: Mix.env == :prod, 15 | package: package(), 16 | deps: deps(), 17 | test_coverage: [tool: ExCoveralls], 18 | dialyzer: [plt_add_apps: [:poison, :httpoison, :vex]], 19 | docs: [source_ref: "#{@version}", extras: ["README.md"], main: "readme"]] 20 | end 21 | 22 | def application do 23 | [applications: [:logger, :httpoison, :vex, :poolboy, :apns], 24 | mod: {Pushex.App, []}, 25 | description: 'Mobile push notification library'] 26 | end 27 | 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | defp deps do 32 | [{:httpoison, "~> 0.8"}, 33 | {:poison, "~> 1.5 or ~> 2.1 or ~> 3.0"}, 34 | {:poolboy, "~> 1.5"}, 35 | {:vex, "~> 0.5"}, 36 | {:apns, "~> 0.9.4"}, 37 | {:excoveralls, "~> 0.5", only: :test}, 38 | {:dialyxir, "~> 0.3", only: :dev}, 39 | {:earmark, "~> 1.0", only: :dev}, 40 | {:ex_doc, "~> 0.11", only: :dev}] 41 | end 42 | 43 | defp package do 44 | [ 45 | maintainers: ["Daniel Perez"], 46 | files: ["lib", "mix.exs", "README.md"], 47 | licenses: ["MIT"], 48 | links: %{ 49 | "GitHub" => "https://github.com/tuvistavie/pushex", 50 | "Docs" => "http://hexdocs.pm/pushex/" 51 | } 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "apns": {:hex, :apns, "0.9.4", "ae5207798f7962f4fe863fdea0395bc141d2e25c1d273d3e47153c597b338a53", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.1", [hex: :poison, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "bbmustache": {:hex, :bbmustache, "1.0.4", "7ba94f971c5afd7b6617918a4bb74705e36cab36eb84b19b6a1b7ee06427aa38", [:rebar], []}, 4 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "cf": {:hex, :cf, "0.2.1", "69d0b1349fd4d7d4dc55b7f407d29d7a840bf9a1ef5af529f1ebe0ce153fc2ab", [:rebar3], []}, 6 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 7 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 8 | "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm"}, 9 | "erlware_commons": {:hex, :erlware_commons, "0.21.0", "a04433071ad7d112edefc75ac77719dd3e6753e697ac09428fc83d7564b80b15", [:rebar3], [{:cf, "0.2.1", [hex: :cf, optional: false]}]}, 10 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "excoveralls": {:hex, :excoveralls, "0.11.2", "0c6f2c8db7683b0caa9d490fb8125709c54580b4255ffa7ad35f3264b075a643", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "exrm": {:hex, :exrm, "1.0.8", "5aa8990cdfe300282828b02cefdc339e235f7916388ce99f9a1f926a9271a45d", [:mix], [{:relx, "~> 3.5", [hex: :relx, optional: false]}]}, 14 | "getopt": {:hex, :getopt, "0.8.2", "b17556db683000ba50370b16c0619df1337e7af7ecbf7d64fbf8d1d6bce3109b", [:rebar], []}, 15 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 19 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 20 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 23 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 24 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 26 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], [], "hexpm"}, 27 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 28 | "providers": {:hex, :providers, "1.6.0", "db0e2f9043ae60c0155205fcd238d68516331d0e5146155e33d1e79dc452964a", [:rebar3], [{:getopt, "0.8.2", [hex: :getopt, optional: false]}]}, 29 | "relx": {:hex, :relx, "3.20.0", "b515b8317d25b3a1508699294c3d1fa6dc0527851dffc87446661bce21a36710", [:rebar3], [{:bbmustache, "1.0.4", [hex: :bbmustache, optional: false]}, {:cf, "0.2.1", [hex: :cf, optional: false]}, {:erlware_commons, "0.21.0", [hex: :erlware_commons, optional: false]}, {:getopt, "0.8.2", [hex: :getopt, optional: false]}, {:providers, "1.6.0", [hex: :providers, optional: false]}]}, 30 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 32 | "vex": {:hex, :vex, "0.8.0", "0a04e3aebe5ec443525c88f3833b4481d97de272f891243b62b18efbda85b121", [:mix], [], "hexpm"}, 33 | } 34 | -------------------------------------------------------------------------------- /test/pushex/apns/client/ssl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.Client.SSLTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.APNS.Response 5 | alias Pushex.APNS.Client.SSL 6 | 7 | test "generate_response" do 8 | results = [:ok, :ok, {:error, :invalid}] 9 | assert %Response{} = response = SSL.generate_response(results) 10 | assert response.success == 2 11 | assert response.failure == 1 12 | assert response.results == results 13 | end 14 | 15 | test "make_messages" do 16 | req = %Pushex.APNS.Request{to: "foo", notification: %{alert: "ok"}} 17 | assert [message] = SSL.make_messages(req) 18 | assert message.token == req.to 19 | assert message.alert == req.notification.alert 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/pushex/apns/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.RequestTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.APNS.{App, Request} 5 | 6 | test "to_message" do 7 | notif = %{alert: "notif", category: ""} 8 | assert %APNS.Message{} = message = Request.to_message(%Request{to: "foo", notification: notif}) 9 | assert message.alert == "notif" 10 | refute message.category 11 | end 12 | 13 | test "to_message with string data" do 14 | assert %APNS.Message{} = message = Request.to_message(%Request{notification: %{"alert" => "notif"}}) 15 | assert message.alert == "notif" 16 | end 17 | 18 | test "create!" do 19 | assert_raise Pushex.ValidationError, fn -> 20 | Request.create!(nil, nil, []) 21 | end 22 | assert %Request{} = Request.create!(%{alert: "notif"}, %App{}, to: "hello") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/pushex/apns/ssl_pool_manager_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.APNS.SSLPoolManagerTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.APNS.SSLPoolManager 5 | 6 | setup do 7 | [{_, app}] = Application.get_env(:pushex, :apps)[:apns] |> Map.to_list 8 | Logger.configure(level: :info) 9 | on_exit fn -> 10 | Logger.configure(level: :debug) 11 | end 12 | {:ok, app: app} 13 | end 14 | 15 | test "ensure_pool_started", %{app: app} do 16 | refute SSLPoolManager.pool_started?(app.name) 17 | assert {:ok, _pid} = SSLPoolManager.ensure_pool_started(app) 18 | assert SSLPoolManager.pool_started?(app.name) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/pushex/app_manager/memory_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.AppManager.MemoryTest do 2 | use ExUnit.Case 3 | 4 | alias Pushex.AppManager 5 | 6 | test "find returns the requested app" do 7 | assert AppManager.Memory.find_app(:gcm, "default_app") 8 | refute AppManager.Memory.find_app(:gcm, "inexisting_app") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/pushex/app_manager_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.AppManagerTest do 2 | use Pushex.Case 3 | 4 | defmodule DummyAppManager do 5 | def find_app(_, _), do: "dummy" 6 | end 7 | 8 | test "app_manager delegates to impl" do 9 | Application.put_env(:pushex, :app_manager_impl, DummyAppManager) 10 | assert Pushex.AppManager.find_app(nil, nil) == "dummy" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/pushex/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.ConfigTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.Config 5 | 6 | setup do 7 | Application.put_env(:pushex, :event_handlers, []) 8 | Application.put_env(:pushex, :gcm, []) 9 | end 10 | 11 | test "defaults" do 12 | {:ok, []} = Config.init([]) 13 | config = Application.get_all_env(:pushex) 14 | assert config[:gcm][:endpoint] 15 | assert config[:event_handlers] 16 | end 17 | 18 | test "make_defaults with sandbox" do 19 | {:ok, []} = Config.init(sandbox: true) 20 | config = Application.get_all_env(:pushex) 21 | assert config[:event_handlers] == [Pushex.EventHandler.Sandbox] 22 | assert config[:gcm][:client_impl] == Pushex.GCM.Client.Sandbox 23 | end 24 | 25 | test "make_defaults without sandbox" do 26 | {:ok, []} = Config.init(sandbox: false) 27 | config = Application.get_all_env(:pushex) 28 | assert config[:event_handlers] == [] 29 | assert config[:gcm][:client_impl] == Pushex.GCM.Client.HTTP 30 | end 31 | 32 | test "load all apps" do 33 | {:ok, []} = Config.init(gcm: [apps: [[name: "default_app", auth_key: "whatever"]]]) 34 | config = Application.get_all_env(:pushex) 35 | assert %{"default_app" => gcm_app} = config[:apps][:gcm] 36 | assert gcm_app.name == "default_app" 37 | assert gcm_app.auth_key == "whatever" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/pushex/event_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.EventHandlerTest do 2 | use Pushex.Case 3 | 4 | test "use EventHandler adds default handle_event definitions" do 5 | defmodule DummyHandler do 6 | use Pushex.EventHandler 7 | end 8 | 9 | assert DummyHandler.handle_event({:request, nil, nil}, :state) == {:ok, :state} 10 | assert DummyHandler.handle_event({:response, nil, nil, nil}, :state) == {:ok, :state} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/pushex/exceptions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.ExceptionsTest do 2 | use Pushex.Case 3 | 4 | test "Pushex.ValidationError" do 5 | errors = Vex.errors(%Pushex.GCM.Request{app: %Pushex.GCM.App{}, to: 1}) 6 | exception = %Pushex.ValidationError{errors: errors} 7 | assert Exception.message(exception) == "error on :to with :type validator: must be of type :binary or nil" 8 | end 9 | 10 | test "Pushex.AppNotFoundError" do 11 | exception = %Pushex.AppNotFoundError{platform: :gcm, name: "dummy"} 12 | assert Exception.message(exception) == ~s(could not find an app named "dummy" for platform :gcm) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/pushex/gcm/app_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.AppTest do 2 | use ExUnit.Case 3 | 4 | alias Pushex.GCM.App 5 | 6 | test "validate name and auth_key" do 7 | assert App.valid?(%App{name: "foo", auth_key: "bar"}) 8 | refute App.valid?(%App{name: "foo"}) 9 | refute App.valid?(%App{auth_key: "bar"}) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/pushex/gcm/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.ClientTest do 2 | use ExUnit.Case 3 | 4 | alias Pushex.GCM.Request 5 | 6 | test "send_notification delegates to impl" do 7 | assert {:ok, _} = Pushex.GCM.Client.send_notification(%Request{to: "foo"}) 8 | assert {:error, _} = Pushex.GCM.Client.send_notification(%Request{to: :bad_id}) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/pushex/gcm/exceptions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.ExceptionsTest do 2 | use Pushex.Case 3 | 4 | test "Pushex.GCM.HTTPError" do 5 | exception = %Pushex.GCM.HTTPError{status_code: 401, reason: "not authorized"} 6 | assert Exception.message(exception) == ~s(HTTP request failed with status 401: "not authorized") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/pushex/gcm/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.GCM.RequestTest do 2 | use ExUnit.Case 3 | 4 | alias Pushex.GCM.Request 5 | 6 | @app %Pushex.GCM.App{name: "foo", auth_key: "bar"} 7 | 8 | test "create from keyword" do 9 | assert {:ok, %Request{app: @app, to: "foo"}} = Request.create(app: @app, to: "foo") 10 | end 11 | 12 | test "create from dictionary" do 13 | assert {:ok, %Request{app: @app, to: "foo"}} = Request.create(%{app: @app, to: "foo"}) 14 | end 15 | 16 | test "create from struct" do 17 | assert {:ok, %Request{app: @app, to: "foo"}} = Request.create(%Request{app: @app, to: "foo"}) 18 | end 19 | 20 | test "create returns error on failure" do 21 | assert {:error, [{:error, :to, :type, "must be of type :binary or nil"}]} = Request.create(app: @app, to: 1) 22 | assert {:error, _} = Request.create(app: @app, foo: "bar") 23 | end 24 | 25 | test "create! returns notification on success" do 26 | assert %Request{app: @app, to: "bar"} = Request.create!(app: @app, to: "bar") 27 | end 28 | 29 | test "create with neither notification or data" do 30 | notification = %{foo: "bar"} 31 | assert %Request{notification: ^notification, data: nil} = Request.create!(notification, @app, to: "bar") 32 | end 33 | 34 | test "create with notification" do 35 | notification = %{foo: "bar"} 36 | assert %Request{notification: ^notification, data: nil} = Request.create!(%{notification: notification}, @app, to: "bar") 37 | end 38 | 39 | test "create with data" do 40 | notification = %{foo: "bar"} 41 | assert %Request{data: ^notification, notification: nil} = Request.create!(%{data: notification}, @app, to: "bar") 42 | end 43 | 44 | test "create! raises on failure" do 45 | assert_raise Pushex.ValidationError, fn -> 46 | Request.create!(app: @app, to: 1) 47 | end 48 | end 49 | 50 | test "removes nil keys and app when encoding to JSON" do 51 | assert Poison.encode!(Request.create!(app: @app, to: "foo")) == Poison.encode!(%{to: "foo"}) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/pushex/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.HelpersTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.Helpers 5 | alias Pushex.GCM 6 | 7 | test "send_notification delegates to GCM when using: :gcm" do 8 | ref = Helpers.send_notification(%{body: "foo"}, to: "whoever", using: :gcm, with_app: "default_app") 9 | assert_receive {{:ok, res}, req, ^ref} 10 | assert match?(%GCM.Request{}, req) 11 | assert match?(%GCM.Response{}, res) 12 | assert [{_, req, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 13 | assert req.notification.body == "foo" 14 | end 15 | 16 | test "allows to pass platform as string" do 17 | ref = Helpers.send_notification(%{}, to: "whoever", using: "gcm", with_app: "default_app") 18 | assert_receive {{:ok, res}, _req, ^ref} 19 | assert match?(%GCM.Response{}, res) 20 | assert [{_, _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 21 | end 22 | 23 | test "allows to pass list to :to" do 24 | ref = Helpers.send_notification(%{}, to: ["whoever"], using: "gcm", with_app: "default_app") 25 | assert_receive {{:ok, res}, _req, ^ref} 26 | assert match?(%GCM.Response{}, res) 27 | assert [{_, _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 28 | end 29 | 30 | test "send_notification delegates to GCM when passing a GCM request" do 31 | ref = Helpers.send_notification(%GCM.Request{}) 32 | assert_receive {{:ok, res}, _, ^ref} 33 | assert match?(%GCM.Response{}, res) 34 | assert [{_, _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 35 | end 36 | 37 | test "send_notification delegates to APNS when passing an APNS request" do 38 | ref = Helpers.send_notification(%Pushex.APNS.Request{}) 39 | assert_receive {{:ok, res}, _, ^ref} 40 | assert match?(%Pushex.APNS.Response{}, res) 41 | assert [{_, _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 42 | end 43 | 44 | test "send_notification delegates to GCM when :with_app is a GCM app" do 45 | ref = Helpers.send_notification(%{}, to: "whoever", with_app: %GCM.App{}) 46 | assert [{{:ok, res} , _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 47 | assert match?(%GCM.Response{}, res) 48 | end 49 | 50 | test "send_notification delegates to APNS when :with_app is a APNS app" do 51 | ref = Helpers.send_notification(%{}, to: "whoever", with_app: %Pushex.APNS.App{}) 52 | assert [{{:ok, res} , _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 53 | assert match?(%Pushex.APNS.Response{}, res) 54 | end 55 | 56 | test "send_notification sends multiple notifications" do 57 | refs = Enum.map (1..10), fn _ -> 58 | Helpers.send_notification(%{}, to: "whoever", with_app: %GCM.App{}) 59 | end 60 | assert (list when length(list) == 10) = Pushex.Sandbox.wait_notifications(count: 10) 61 | Enum.each list, fn {_, _, {_, ref}} -> 62 | assert Enum.find(refs, &(&1 == ref)) 63 | end 64 | end 65 | 66 | test "send_notification raises when neither with_app nor :using is passed" do 67 | assert_raise ArgumentError, fn -> Helpers.send_notification(%{}) end 68 | end 69 | 70 | test "send_notification raises when :using is passed without a default app" do 71 | assert_raise ArgumentError, fn -> Helpers.send_notification(%{}, using: :apns) end 72 | end 73 | 74 | test "send_notification raises when app does not exist" do 75 | assert_raise Pushex.AppNotFoundError, fn -> 76 | Helpers.send_notification(%{}, with_app: "foo", using: :apns) 77 | end 78 | end 79 | 80 | test "send_notification raises when :with_app is binary and :using is not passed" do 81 | assert_raise ArgumentError, fn -> Helpers.send_notification(%{}, with_app: "foo") end 82 | end 83 | 84 | test "send_notification gives a proper error message when platform does not exist" do 85 | assert_raise ArgumentError, ~s("foo" is not a valid platform), fn -> 86 | Helpers.send_notification(%{}, with_app: "foo", using: "foo") 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/pushex/sandbox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.SandboxTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.Sandbox 5 | 6 | test "record_notification records a notification" do 7 | ref = make_ref() 8 | Sandbox.record_notification(%{}, %{}, {self(), ref}) 9 | assert [{_, _, {_, ^ref}}] = Sandbox.list_notifications 10 | end 11 | 12 | test "list_notifications accept pid" do 13 | ref = make_ref() 14 | parent = self() 15 | Sandbox.record_notification(%{}, %{}, {parent, ref}) 16 | task = Task.async fn -> 17 | assert Sandbox.list_notifications == [] 18 | assert [{_, _, {_, ^ref}}] = Sandbox.list_notifications(pid: parent) 19 | Sandbox.record_notification(%{}, %{}, {self(), make_ref()}) 20 | assert [_] = Sandbox.list_notifications 21 | end 22 | Task.await(task) 23 | assert [{_, _, {_, ^ref}}] = Sandbox.list_notifications 24 | end 25 | 26 | test "wait_notifications waits before failing" do 27 | parent = self() 28 | ref = make_ref() 29 | Task.async fn -> 30 | :timer.sleep(50) 31 | Sandbox.record_notification(%{}, %{}, {parent, ref}) 32 | end 33 | assert Sandbox.list_notifications == [] 34 | assert [{_, _, {_, ^ref}}] = Sandbox.wait_notifications 35 | end 36 | 37 | test "clear_notifications accepts pid" do 38 | ref = make_ref() 39 | parent = self() 40 | Sandbox.record_notification(%{}, %{}, {parent, ref}) 41 | task = Task.async fn -> 42 | Sandbox.record_notification(%{}, %{}, {self(), make_ref()}) 43 | assert [_] = Sandbox.list_notifications 44 | Sandbox.clear_notifications 45 | assert Sandbox.list_notifications == [] 46 | end 47 | Task.await(task) 48 | assert [{_, _, {_, ^ref}}] = Sandbox.list_notifications 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/pushex/util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.UtilTest do 2 | use ExUnit.Case 3 | 4 | alias Pushex.Util 5 | 6 | test "normalize_notification without platform key" do 7 | assert Util.normalize_notification(%{title: "foo"}, :apns) == %{title: "foo"} 8 | end 9 | 10 | test "normalize_notification with platform key" do 11 | map = %{title: "foo", apns: %{alert: "foobar", title: "other"}} 12 | assert Util.normalize_notification(map, :apns) == %{alert: "foobar", title: "other"} 13 | map = %{"title" => "foo", "apns" => %{"alert" => "foobar", "title" => "other"}} 14 | assert Util.normalize_notification(map, :apns) == %{"alert" => "foobar", "title" => "other"} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/pushex/validators/type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Validators.TypeTest do 2 | use ExUnit.Case 3 | 4 | defmodule Dummy do 5 | defstruct [:value] 6 | end 7 | 8 | test "simple types" do 9 | port = Port.list |> List.first 10 | valid_cases = [ 11 | {1, :any}, 12 | {"a", :any}, 13 | {1, :number}, 14 | {1, :integer}, 15 | {nil, nil}, 16 | {"a", :binary}, 17 | {"a", :bitstring}, 18 | {1.1, :float}, 19 | {1.1, :number}, 20 | {:foo, :atom}, 21 | {&self/0, :function}, 22 | {{1, 2}, :tuple}, 23 | {[1, 2], :list}, 24 | {%{a: 1}, :map}, 25 | {self(), :pid}, 26 | {make_ref(), :reference}, 27 | {port, :port}, 28 | {["foo"], [:binary, [list: :binary]]}, 29 | {1, [:binary, :integer]}, 30 | {nil, [nil, :integer]}, 31 | {"a", [:binary, :atom]}, 32 | {:a, [:binary, :atom]}, 33 | {"hello", :string}, 34 | {~r/foo/, Regex}, 35 | {%Dummy{}, Dummy} 36 | ] 37 | invalid_cases = [ 38 | {1, :binary}, 39 | {1, :float}, 40 | {1, nil}, 41 | {nil, :atom}, 42 | {1.1, :integer}, 43 | {self(), :reference}, 44 | {{1, 2}, :list}, 45 | {{1, 2}, :map}, 46 | {[1, 2], :tuple}, 47 | {%{a: 2}, :list}, 48 | {<<271, 191, 191>>, :string}, 49 | {~r/foo/, :string}, 50 | {:a, [:binary, :integer]} 51 | ] 52 | 53 | run_cases(valid_cases, invalid_cases) 54 | end 55 | 56 | test "complex types" do 57 | valid_cases = [ 58 | {&self/0, function: 0}, 59 | {[1, 2,], list: :integer}, 60 | {[1, 2, nil], list: [nil, :number]}, 61 | {[a: 1, b: 2], list: [tuple: {:atom, :number}]}, 62 | {%{:a => "a", "b" => nil}, map: {[:atom, :binary], [:binary, nil]}} 63 | ] 64 | invalid_cases = [ 65 | {[a: 1, b: "a"], list: [tuple: {:atom, :number}]}, 66 | {%{1 => "a", "b" => 1}, map: {[:atom, :binary], [:binary, :integer]}}, 67 | {%{:a => 1.1, "b" => 1}, map: {[:atom, :binary], [:binary, :integer]}} 68 | ] 69 | run_cases(valid_cases, invalid_cases) 70 | end 71 | 72 | test "deeply nested type" do 73 | valid_value = %{a: %{1 => [a: [%{"a" => [a: [1, 2.2, 3]], "b" => nil}]]}} 74 | invalid_value = %{a: %{1 => [a: [%{"a" => [a: [1, 2.2, "3"]]}]]}} 75 | other_invalid_value = %{a: %{1 => [a: [%{"a" => [a: [1, 2.2, nil]]}]]}} 76 | type = [map: {:atom, [map: {:integer, [list: [tuple: {:atom, [list: [map: {:binary, [nil, [list: [tuple: {:atom, [list: [:integer, :float]]}]]]}]]}]]}]}] 77 | run_cases([{valid_value, type}], [{invalid_value, type}, {other_invalid_value, type}]) 78 | end 79 | 80 | test "default message" do 81 | expected = [{:error, :foo, :type, "must be of type :integer"}] 82 | assert Vex.errors([foo: "bar"], foo: [type: [is: :integer]]) == expected 83 | expected = [{:error, :foo, :type, "must be of type :atom, :string or :list"}] 84 | assert Vex.errors([foo: 1], foo: [type: [is: [:atom, :string, :list]]]) == expected 85 | expected = [{:error, :foo, :type, "value 1 is not a string"}] 86 | message = "value <%= value %> is not a string" 87 | assert Vex.errors([foo: 1], foo: [type: [is: :string, message: message]]) == expected 88 | end 89 | 90 | defp run_cases(valid_cases, invalid_cases) do 91 | Enum.each valid_cases, fn {value, type} -> 92 | assert Vex.valid?([foo: value], foo: [type: [is: type]]), "expected #{inspect(value)} to have type #{inspect(type)}" 93 | end 94 | 95 | Enum.each invalid_cases, fn {value, type} -> 96 | refute Vex.valid?([foo: value], foo: [type: [is: type]]), "expected #{inspect(value)} not to have type #{inspect(type)}" 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/pushex/watcher_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.WatcherTest do 2 | use Pushex.Case 3 | 4 | alias Pushex.GCM.{Request, Response} 5 | 6 | defmodule BadHandler do 7 | use Pushex.EventHandler 8 | 9 | def handle_event({:request, _request, {_pid, _ref}}, _state) do 10 | raise "I hate requests" 11 | end 12 | 13 | def handle_event({:response, _response, _request, {pid, ref}}, state) do 14 | send pid, {:bad_handler, ref} 15 | {:ok, state} 16 | end 17 | end 18 | 19 | test "restarts children" do 20 | assert {:ok, _pid} = Pushex.Watcher.watch(Pushex.EventManager, BadHandler, []) 21 | assert BadHandler in :gen_event.which_handlers(Pushex.EventManager) 22 | ref = make_ref() 23 | :gen_event.notify(Pushex.EventManager, {:response, %Response{}, %Request{}, {self(), ref}}) 24 | assert_receive {:bad_handler, ^ref} 25 | # crash here 26 | Logger.remove_backend(Logger.Backends.Console) 27 | :gen_event.notify(Pushex.EventManager, {:request, %Request{}, {self(), ref}}) 28 | # wait for supervisor to restart handler 29 | :timer.sleep(100) 30 | Logger.add_backend(Logger.Backends.Console) 31 | 32 | :gen_event.notify(Pushex.EventManager, {:response, %Response{}, %Request{}, {self(), ref}}) 33 | assert_receive {:bad_handler, ^ref} 34 | 35 | assert :ok = Pushex.Watcher.unwatch(Pushex.EventManager, BadHandler) 36 | :gen_event.notify(Pushex.EventManager, {:response, %Response{}, %Request{}, {self(), ref}}) 37 | refute_receive {:bad_handler, ^ref} 38 | refute BadHandler in :gen_event.which_handlers(Pushex.EventManager) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/pushex/worker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pushex.WorkerTest do 2 | use Pushex.Case 3 | 4 | test "send_notification returns a reference" do 5 | assert is_reference(Pushex.Worker.send_notification(%Pushex.GCM.Request{})) 6 | assert is_reference(Pushex.Worker.send_notification(%Pushex.APNS.Request{})) 7 | end 8 | 9 | test "send_notification with gcm calls handle_response" do 10 | ref = Pushex.Worker.send_notification(%Pushex.GCM.Request{}) 11 | assert_receive {_, _, ^ref} 12 | end 13 | 14 | test "send_notification with apns calls handle_response" do 15 | ref = Pushex.Worker.send_notification(%Pushex.APNS.Request{}) 16 | assert_receive {_, _, ^ref} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/pushex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PushexTest do 2 | use Pushex.Case 3 | 4 | test "send_notification/1 delegates to Helpers" do 5 | ref = Pushex.push(%Pushex.GCM.Request{}) 6 | assert [{_, _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 7 | end 8 | 9 | test "send_notification/2 delegates to Helpers" do 10 | ref = Pushex.push(%{body: "foo"}, to: "whoever", using: :gcm) 11 | assert [{_, _, {_, ^ref}}] = Pushex.Sandbox.wait_notifications 12 | end 13 | 14 | test "add_event_handler adds a new handler" do 15 | defmodule NoopHandler do 16 | use Pushex.EventHandler 17 | end 18 | refute List.keyfind(Pushex.Config.event_handlers, NoopHandler, 1) 19 | Pushex.add_event_handler(NoopHandler) 20 | assert List.keyfind(Pushex.Config.event_handlers, NoopHandler, 1) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/support/pushex_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Pushex.Case do 2 | use ExUnit.CaseTemplate 3 | 4 | setup do 5 | config = Application.get_all_env(:pushex) 6 | on_exit fn -> 7 | Pushex.Config.configure(config) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule Pushex.DummyHandler do 4 | def handle_response(response, request, {pid, ref}) do 5 | send pid, {response, request, ref} 6 | end 7 | end 8 | --------------------------------------------------------------------------------