├── .DS_Store ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── .DS_Store └── express │ ├── apns │ ├── apns.ex │ ├── connection.ex │ ├── delayed_pushes.ex │ ├── jwt_holder.ex │ ├── push_message.ex │ ├── push_message │ │ ├── alert.ex │ │ └── aps.ex │ ├── ssl_config.ex │ ├── supervisor.ex │ └── worker.ex │ ├── application.ex │ ├── configuration.ex │ ├── configuration │ └── test.ex │ ├── express.ex │ ├── fcm │ ├── delayed_pushes.ex │ ├── fcm.ex │ ├── push_message.ex │ ├── push_message │ │ └── notification.ex │ ├── supervisor.ex │ └── worker.ex │ ├── helpers │ └── map_helper.ex │ ├── network │ ├── http2.ex │ └── http2 │ │ ├── chatterbox_client.ex │ │ ├── client.ex │ │ ├── connection.ex │ │ └── ssl_config.ex │ ├── operations │ ├── apns │ │ └── push.ex │ ├── establish_http2_connection.ex │ ├── fcm │ │ └── push.ex │ ├── log_message.ex │ └── poolboy_configs.ex │ ├── push_requests │ ├── adder.ex │ ├── buffer.ex │ ├── consumer.ex │ ├── consumers_supervisor.ex │ ├── push_request.ex │ └── supervisor.ex │ └── supervisor.ex ├── mix.exs ├── mix.lock └── test ├── apns ├── delayed_pushes_test.exs ├── jwt_holder_test.exs └── ssl_config_test.exs ├── configuration_test.exs ├── fcm └── delayed_pushes_test.exs ├── fixtures ├── file ├── test_apns_cert.pem ├── test_apns_key.pem └── test_auth_key.p8 ├── helpers └── map_helper_test.exs ├── operations ├── apns │ └── push_test.exs ├── establish_http2_connection_test.exs ├── fcm │ └── push_test.exs └── poolboy_configs_test.exs ├── push_requests └── buffer_test.exs └── test_helper.exs /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/implicitly-awesome/express/f66fa74bc6c627e930cf7e32bbfbf632e1ef0f40/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /.fetch 6 | /log 7 | erl_crash.dump 8 | *.ez 9 | live_test.ex 10 | /lib/express/configuration/dev.ex 11 | /lib/express/configuration/prod.ex 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: elixir 3 | elixir: 4 | - 1.4.5 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.3.3] - 2017.12.12 2 | 3 | * a push message id in push result was unified (single `id` key): a pair of `message_id` and `multicast_id` for FCM, `apns_id` for APNS 4 | 5 | ## [1.3.2] - 2017.12.11 6 | 7 | * apns-id of APNS push message was added into response 8 | 9 | ## [1.3.1] - 2017.12.04 10 | 11 | * Bug with sync push and push_message == nil in callback_fun was fixed 12 | 13 | ## [1.3.0] - 2017.11.17 14 | 15 | * No link b/w consumer & task 16 | * Adjusted timeouts 17 | * Buffer ping 18 | 19 | ## [1.2.10, 1.2.11, 1.2.12] - 2017.11.14 20 | 21 | * Dynamic consumer 22 | * Automatic consumer 23 | * Buffer sync add 24 | 25 | ## [1.2.8, 1.2.9] - 2017.11.13 26 | 27 | * Added APNS sync/async option for a worker (async is faster, sync is more stable) 28 | * Tasks Supervisor: :temporary restart strategy 29 | * bug fixes 30 | 31 | ## [1.2.6, 1.2.7] - 2017.11.10 32 | 33 | * Tasks & their Supervisor improvements 34 | 35 | ## [1.2.5] - 2017.11.02 36 | 37 | * APNS worker's asunc push 38 | * APNS worker ttl 39 | 40 | ## [1.2.4] - 2017.11.01 41 | 42 | * refactored APNS worker check 43 | * APNS push operation async option 44 | * timeouts handling 45 | * some other improvements 46 | 47 | ## [1.2.3] - 2017.10.31 48 | 49 | * checks if APNS worker is alive before push 50 | * checks for opened frames count per connection before push 51 | 52 | ## [1.2.2] - 2017.10.31 53 | 54 | * APNS: worker checks if a connection alive before push 55 | * APNS: tries to redeliver push messages of crashed workers 56 | 57 | ## [1.2.1] - 2017.10.31 58 | 59 | * does not rely on Mix.env (uses Application.get_env(:express, :environment) instead) 60 | 61 | ## [1.2.0] - 2017.10.30 62 | 63 | * added APNS :auth_key as p8 file content in config 64 | * configuration via module 65 | 66 | ## [1.1.3] - 2017.10.27 67 | 68 | * fixed APNS :cert & :key config attributes resolving 69 | 70 | ## [1.1.2] - 2017.10.27 71 | 72 | * got rid enforced_keys from Express.FCM.PushMessage.Notification 73 | 74 | ## [1.1.1] - 2017.10.26 75 | 76 | * Fixed a bug with field names of APNS payload (they should be dasherized). 77 | * Added a validation on APNS payload fields. 78 | * Added `thread_id` field to APNS aps structure 79 | 80 | ## [1.1.0] - 2017.10.26 81 | 82 | * JWT for APNS 83 | * GenStage for load balancing 84 | * Refactored supervision tree 85 | 86 | ## [1.0.0] - 2017.07.23 87 | 88 | the first publication on hex.pm 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Andrey Chernykh 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 | [![Hex.pm](https://img.shields.io/hexpm/v/express.svg)](https://hex.pm/packages/express) [![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](http://hexdocs.pm/express/) [![Build Status](https://travis-ci.org/madeinussr/express.svg?branch=master)](https://travis-ci.org/madeinussr/express) 2 | 3 | # Express 4 | 5 | Library for sending push notifications. 6 | Supports Apple APNS and Google FCM services. 7 | 8 | At the moment sends pushes to FCM via HTTP and to APNS via HTTP/2 (with either ssl certificate or JWT). 9 | 10 | Uses GenServer in order to balance the load. Default buffer (producer) size is 5000. 11 | Default consumer max demand is number_of_available_schedulers * 5 (multiplier can be adjusted). 12 | 13 | ## Installation 14 | 15 | ```elixir 16 | # in your mix.exs file 17 | 18 | def deps do 19 | {:express, "~> 1.3.3"} 20 | end 21 | 22 | # in your config.exs file (more in configuration section below) 23 | 24 | config :express, 25 | apns: [ 26 | mode: :prod, 27 | cert_path: "path_to_your_cert.pem", 28 | key_path: "path_to_your_key.pem" 29 | ], 30 | fcm: [ 31 | api_key: "your_key" 32 | ] 33 | ``` 34 | 35 | ## Quick examples 36 | 37 | ### APNS 38 | 39 | ```elixir 40 | alias Express.APNS 41 | 42 | push_message = 43 | %APNS.PushMessage{ 44 | token: "your_device_token", 45 | topic: "your_app_topic", 46 | acme: %{}, 47 | aps: %APNS.PushMessage.Aps{ 48 | badge: 1, 49 | content_available: 1, 50 | alert: %APNS.PushMessage.Alert{ 51 | title: "Hello", 52 | body: "World" 53 | } 54 | } 55 | } 56 | 57 | opts = [delay: 5] # in seconds 58 | 59 | callback_fun = 60 | fn(push_message, response) -> 61 | IO.inspect("==Push message==") 62 | IO.inspect(push_message) 63 | IO.inspect("==APNS response==") 64 | IO.inspect(response) 65 | end 66 | 67 | APNS.push(push_message, opts, callback_fun) 68 | ``` 69 | 70 | ### FCM 71 | 72 | ```elixir 73 | alias Express.FCM 74 | 75 | push_message = 76 | %FCM.PushMessage{ 77 | to: "your_device_registration_id", 78 | priority: "high", 79 | content_available: true, 80 | data: %{}, 81 | notification: %FCM.PushMessage.Notification{ 82 | title: "Hello", 83 | body: "World" 84 | } 85 | } 86 | 87 | opts = [delay: 5] # in seconds 88 | 89 | callback_fun = 90 | fn(push_message, response) -> 91 | IO.inspect("==Push message==") 92 | IO.inspect(push_message) 93 | IO.inspect("==FCM response==") 94 | IO.inspect(response) 95 | end 96 | 97 | FCM.push(push_message, opts, callback_fun) 98 | ``` 99 | 100 | ## Configuration 101 | 102 | Express can be configured by either config file options or configuration module. 103 | 104 | Configuration module is preferable, because it allows you to change some config options dynamically. 105 | Every option from configuration module can be overriden by appropriate option in config file. 106 | 107 | ### Basic 108 | 109 | ```elixir 110 | config :express, 111 | apns: [ 112 | mode: :prod, 113 | cert_path: "your_cert_path.pem", 114 | key_path: "your_key_path.pem" 115 | ], 116 | fcm: [ 117 | api_key: "your_api_key" 118 | ] 119 | ``` 120 | 121 | There is an option: 122 | 123 | ### Buffer 124 | 125 | There are all possible options for the buffer: 126 | 127 | ```elixir 128 | config :express, 129 | buffer: [ 130 | max_size: 5000, 131 | consumers_count: 10, 132 | consumer_demand_multiplier: 5, 133 | adders_pool_config: [ 134 | {:name, {:local, :buffer_adders_pool}}, 135 | {:worker_module, Express.PushRequests.Adder}, 136 | {:size, 5}, 137 | {:max_overflow, 1} 138 | ] 139 | ] 140 | ``` 141 | 142 | ### APNS 143 | 144 | Possible options for APNS: 145 | 146 | _You should provide either (cert_path & key_path) or (cert & key) or (key_id & team_id & auth_key_path)._ 147 | _Every "*_path" option has the priority over corresponding option with a file content: `cert_path > cert`, `key_path > key` and `auth_key_path > auth_key`._ 148 | 149 | *_If you'd like to use cert/key file content, you should use the original content from a file (even with new-line symbols)_* 150 | 151 | ```elixir 152 | config :express, 153 | apns: [ 154 | mode: :prod, 155 | # for requests with jwt 156 | key_id: "your_key_id", 157 | team_id: "your_team_id", 158 | auth_key_path: "your_auth_key_path.p8", 159 | 160 | # for requests with a certificate 161 | cert_path: "your_cert_path.pem", 162 | key_path: "your_key_path.pem", 163 | 164 | # workers config (if default doesn't meet you requirements) 165 | workers_pool_config: [ 166 | {:name, {:local, :apns_workers_pool}}, 167 | {:worker_module, Express.APNS.Worker}, 168 | {:size, 8}, 169 | {:max_overflow, 1} 170 | ], 171 | 172 | workers_push_async: true 173 | ] 174 | ``` 175 | 176 | ### FCM 177 | 178 | Possible options for FCM: 179 | 180 | ```elixir 181 | config :express, 182 | fcm: [ 183 | api_key: "your_api_key" 184 | 185 | # workers config (if default doesn't meet you requirements) 186 | workers_pool_config: [ 187 | {:name, {:local, :fcm_workers_pool}}, 188 | {:worker_module, Express.FCM.Worker}, 189 | {:size, 8}, 190 | {:max_overflow, 1} 191 | ] 192 | ] 193 | ``` 194 | 195 | ### Configuration module 196 | 197 | In order to use configuration module, you need: 198 | 199 | * create a module that conforms `Express.Configuration` behaviour 200 | * define that module in config file 201 | 202 | Let a function return empty list `[]` if you want all default options for a section: 203 | 204 | ```elixir 205 | def buffer do 206 | [] #Express will use default options 207 | end 208 | ``` 209 | 210 | `Express.Configuration` behaviour is pretty simple, all you need is define functions: 211 | 212 | ```elixir 213 | @callback buffer() :: Keyword.t 214 | @callback apns() :: Keyword.t 215 | @callback fcm() :: Keyword.t 216 | ``` 217 | 218 | For example (_for more possible options see the sections above_): 219 | 220 | ```elixir 221 | defmodule YourApp.ExpressConfig.Dev do 222 | @behaviour Express.Configuration 223 | 224 | def buffer do 225 | [ 226 | max_size: 1000 227 | ] 228 | end 229 | 230 | def apns do 231 | [ 232 | mode: :dev, 233 | key_id: "your_key_id", 234 | team_id: "your_team_id", 235 | auth_key: "your_auth_key" 236 | ] 237 | end 238 | 239 | def fcm do 240 | [ 241 | api_key: "your_api_key" 242 | ] 243 | end 244 | end 245 | ``` 246 | 247 | Then in `config/dev.exs`: 248 | 249 | ```elixir 250 | config :express, module: YourApp.ExpressConfig.Dev 251 | ``` 252 | 253 | As said earlier, you can even override your configuration module options later in config file: 254 | 255 | ```elixir 256 | config :express, 257 | module: YourApp.ExpressConfig.Dev, 258 | buffer: [ 259 | max_size: 1000 260 | ] 261 | ``` 262 | 263 | _configuration in config files has the highest priority_ 264 | 265 | *Do not forget to add configuration module to .gitignore if it contains secret data* 266 | 267 | ## Push message structure 268 | 269 | You should construct `%Express.APNS.PushMessage{}` and `%Express.FCM.PushMessage{}` 270 | structures and pass them to `Express.APNS.push/3` and `Express.FCM.push/3` respectively 271 | in order to send a push message. 272 | 273 | Express's `Express.APNS.PushMessage` as well as `Express.FCM.PushMessage` conforms official 274 | Apple & Google push message structures, so there should not be any confusion with it. 275 | 276 | Here are their structures: 277 | 278 | ### APNS 279 | 280 | ```elixir 281 | %Express.APNS.PushMessage{ 282 | token: String.t, 283 | topic: String.t, 284 | aps: Express.APNS.PushMessage.Aps.t, 285 | apple_watch: map(), 286 | acme: map() 287 | } 288 | 289 | %Express.APNS.PushMessage.Aps{ 290 | content_available: pos_integer(), 291 | mutable_content: pos_integer(), 292 | badge: pos_integer(), 293 | sound: String.t, 294 | category: String.t, 295 | thread_id: String.t, 296 | alert: Express.APNS.PushMessage.Alert.t | String.t 297 | } 298 | 299 | %Express.APNS.PushMessage.Alert{ 300 | title: String.t, 301 | body: String.t 302 | } 303 | ``` 304 | 305 | ### FCM 306 | 307 | ```elixir 308 | %Express.FCM.PushMessage{ 309 | to: String.t, 310 | registration_ids: [String.t], 311 | priority: String.t, 312 | content_available: boolean(), 313 | collapse_key: String.t, 314 | data: map(), 315 | notification: PushMessage.Notification.t 316 | } 317 | 318 | %Express.FCM.PushMessage.Notification{ 319 | title: String.t, 320 | body: String.t, 321 | icon: String.t, 322 | sound: String.t, 323 | click_action: String.t, 324 | badge: pos_integer(), 325 | category: String.t 326 | } 327 | ``` 328 | 329 | ## Send a push message 330 | 331 | In order to send a push message you should to construct a valid message structure, 332 | define a callback function, which will be invoked on provider's response (APNS or FCM) 333 | and pass them along with options to either `Express.FCM.push/3` or `Express.APNS.push/3` 334 | function (see quick examples above). 335 | 336 | Nothing to add here, but: 337 | 338 | * a callback function has to take two arguments: 339 | * a push message (which push message structure you tried to send) 340 | * a push result (response received from a provider and handled by Express) 341 | 342 | ```elixir 343 | # push result type 344 | @type push_result :: {:ok, %{id: any(), status: pos_integer(), body: any()}} | 345 | {:error, %{id: any(), status: pos_integer(), body: any()}} 346 | ``` 347 | 348 | * at this moment the single option you can pass with `opts` argument - `delay` 349 | * it defines a delay in seconds for a push worker (a worker will push a message after that delay) 350 | 351 | ## Supervision tree 352 | 353 | ```elixir 354 | Application 355 | | 356 | Supervisor 357 | | 358 | ----------------------------------------------------------------------- 359 | | | | | 360 | APNS.Supervisor FCM.Supervisor PushRequests.Supervisor TasksSupervisor 361 | | | | | 362 | | ------------------------- | ------------------------ 363 | | | | | | | | 364 | | FCM.DelayedPushes :fcm_workers_pool | Task Task Task 365 | | | | 366 | | ------------------------ | 367 | | | | | | 368 | | FCM.Worker FCM.Worker FCM.Worker | 369 | | | 370 | | ---------------------------------------- 371 | | | | | 372 | | PushRequests.Buffer :buffer_adders_pool PushRequests.ConsumersSupervisor 373 | | | | 374 | | ------------- ---------------- 375 | | | | | | 376 | | | |PushRequests.Consumer PushRequests.Consumer 377 | | | | 378 | | PushRequests.Adder PushRequests.Adder 379 | | 380 | --------------------------------------------- 381 | | | | 382 | APNS.JWTHolder APNS.DelayedPushes :apns_workers_pool 383 | | 384 | -------------------------------------- 385 | | | | 386 | APNS.Worker APNS.Worker APNS.Worker 387 | | | | 388 | APNS.Connection APNS.Connection APNS.Connection 389 | ``` 390 | 391 | ## LICENSE 392 | 393 | Copyright © 2017 Andrey Chernykh ( andrei.chernykh@gmail.com ) 394 | 395 | This work is free. You can redistribute it and/or modify it under the 396 | terms of the MIT License. See the LICENSE file for more details. 397 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * dynamic SSL configuration settings (APNS) 2 | * dynamic api key (FCM) 3 | * rabbitmq "plugin" 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :express, 4 | module: Express.Configuration.Dev, 5 | environment: :dev 6 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :express, 4 | module: Express.Configuration.Prod, 5 | environment: :prod 6 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # do not write into logs during tests 4 | config :logger, 5 | backends: [] 6 | 7 | config :express, 8 | module: Express.Configuration.Test, 9 | buffer: [ 10 | max_size: 1000 11 | ], 12 | environment: :test 13 | -------------------------------------------------------------------------------- /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/implicitly-awesome/express/f66fa74bc6c627e930cf7e32bbfbf632e1ef0f40/lib/.DS_Store -------------------------------------------------------------------------------- /lib/express/apns/apns.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS do 2 | @behaviour Express 3 | 4 | alias Express.PushRequests.Adder 5 | alias Express.APNS.{PushMessage, DelayedPushes} 6 | alias Express.Operations.PoolboyConfigs 7 | 8 | @spec push(PushMessage.t, Keyword.t, Express.callback_fun | nil) :: {:noreply, map()} 9 | def push(push_message, opts \\ [], callback_fun \\ nil) do 10 | if is_integer(opts[:delay]) && opts[:delay] > 0 do 11 | delayed_push(push_message, opts, callback_fun) 12 | else 13 | instant_push(push_message, opts, callback_fun) 14 | end 15 | end 16 | 17 | @spec instant_push(PushMessage.t, Keyword.t, Express.callback_fun) :: {:noreply, map()} 18 | defp instant_push(push_message, opts, callback_fun) do 19 | :poolboy.transaction(PoolboyConfigs.buffer_adders().name, fn(adder) -> 20 | Adder.add(adder, push_message, opts, callback_fun) 21 | end) 22 | end 23 | 24 | @spec delayed_push(PushMessage.t, Keyword.t, Express.callback_fun) :: {:noreply, map()} 25 | defp delayed_push(push_message, opts, callback_fun) do 26 | DelayedPushes.add(push_message, opts, callback_fun) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/express/apns/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.Connection do 2 | @moduledoc """ 3 | Establishes a connection to APNS with proper configuration. 4 | """ 5 | 6 | alias Express.Configuration 7 | alias Express.Operations.EstablishHTTP2Connection 8 | alias Express.APNS.SSLConfig 9 | alias Express.Network.HTTP2.ChatterboxClient 10 | 11 | def new do 12 | params = 13 | if need_ssl_config?() do 14 | [http2_client: ChatterboxClient, ssl_config: SSLConfig.new()] 15 | else 16 | [http2_client: ChatterboxClient] 17 | end 18 | 19 | case EstablishHTTP2Connection.run(params) do 20 | {:ok, connection} -> connection 21 | _ -> nil 22 | end 23 | end 24 | 25 | def need_ssl_config? do 26 | (Configuration.APNS.cert_path() || Configuration.APNS.cert()) && 27 | (Configuration.APNS.key_path() || Configuration.APNS.key()) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/express/apns/delayed_pushes.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.DelayedPushes do 2 | use Supervisor 3 | 4 | alias Express.APNS.PushMessage 5 | alias Express.Operations.LogMessage 6 | alias Express.PushRequests.Adder 7 | 8 | def start_link, do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 9 | 10 | def init(:ok) do 11 | children = [worker(Adder, [], restart: :temporary)] 12 | 13 | supervise(children, strategy: :simple_one_for_one) 14 | end 15 | 16 | @spec add(PushMessage.t, Keyword.t | nil, 17 | Express.callback_fun | nil) :: {:noreply, map()} 18 | def add(push_message, opts, callback_fun) do 19 | case Supervisor.start_child(__MODULE__, []) do 20 | {:ok, adder} -> 21 | Adder.add_after(adder, push_message, opts, callback_fun) 22 | {:error, reason} -> 23 | error_message = """ 24 | [APNS DelayedPushes] Failed to start an adder. 25 | Reason: #{inspect(reason)} 26 | """ 27 | LogMessage.run!(message: error_message) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/express/apns/jwt_holder.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.JWTHolder do 2 | use GenServer 3 | 4 | import Joken 5 | 6 | alias JOSE.JWK 7 | alias Express.Configuration 8 | 9 | @algorithm "ES256" 10 | @ttl 50 * 60 11 | 12 | defmodule State do 13 | @type t :: %__MODULE__{jwt: String.t, iat: integer()} 14 | 15 | defstruct ~w(jwt iat)a 16 | end 17 | 18 | def start_link, do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 19 | 20 | def init(:ok) do 21 | now = Timex.now() 22 | jwt = new(now) 23 | 24 | {:ok, %State{jwt: jwt, iat: now}} 25 | end 26 | 27 | def get_jwt, do: GenServer.call(__MODULE__, :get_jwt) 28 | 29 | def handle_call(:get_jwt, _from, state) do 30 | state = 31 | if expired?(state.iat) do 32 | now = Timex.now() 33 | jwt = new(now) 34 | 35 | %State{jwt: jwt, iat: now} 36 | else 37 | state 38 | end 39 | 40 | {:reply, state.jwt, state} 41 | end 42 | 43 | defp new(iat) do 44 | %{ 45 | "iss" => Configuration.APNS.team_id(), 46 | "iat" => Timex.to_unix(iat) 47 | } 48 | |> token() 49 | |> with_header_arg("alg", @algorithm) 50 | |> with_header_arg("kid", Configuration.APNS.key_id()) 51 | |> with_signer(es256(apns_auth_key())) 52 | |> sign() 53 | |> get_compact() 54 | end 55 | 56 | def apns_auth_key do 57 | if path = auth_key_path() do 58 | JWK.from_pem_file(path) 59 | else 60 | if key = auth_key() do 61 | key 62 | |> String.replace("\\n", "\n") 63 | |> JWK.from_pem() 64 | end 65 | end 66 | end 67 | 68 | def expired?(iat), do: Timex.diff(Timex.now(), iat, :seconds) > @ttl 69 | 70 | defp auth_key_path, do: Configuration.APNS.auth_key_path() 71 | 72 | defp auth_key, do: Configuration.APNS.auth_key() 73 | end 74 | -------------------------------------------------------------------------------- /lib/express/apns/push_message.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.PushMessage do 2 | @moduledoc """ 3 | Defines APNS push message structure. 4 | """ 5 | 6 | alias Express.APNS.PushMessage.Aps 7 | 8 | @derive [Poison.Encoder] 9 | 10 | @type t :: %__MODULE__{token: String.t, 11 | topic: String.t, 12 | aps: Aps.t, 13 | apple_watch: map(), 14 | acme: map()} 15 | 16 | defstruct [token: nil, 17 | topic: nil, 18 | aps: nil, 19 | apple_watch: %{}, 20 | acme: %{}] 21 | 22 | @doc "Normalizes a push message `struct` to a map acceptable by APNS" 23 | @spec to_apns_map(__MODULE__.t) :: map() 24 | def to_apns_map(nil), do: %{} 25 | def to_apns_map(struct) do 26 | map = 27 | if struct |> Map.keys |> Enum.member?(:__struct__) do 28 | Map.from_struct(struct) 29 | else 30 | struct 31 | end 32 | 33 | %{ 34 | "token" => map.token, 35 | "topic" => map.topic, 36 | "aps" => Aps.to_apns_map(map.aps), 37 | "apple-watch" => map.apple_watch, 38 | "acme" => map.acme 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/express/apns/push_message/alert.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.PushMessage.Alert do 2 | @moduledoc """ 3 | Defines APNS push message alert structure. 4 | """ 5 | 6 | @derive [Poison.Encoder] 7 | 8 | @type t :: %__MODULE__{title: String.t, body: String.t} 9 | 10 | defstruct ~w(title body)a 11 | end 12 | -------------------------------------------------------------------------------- /lib/express/apns/push_message/aps.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.PushMessage.Aps do 2 | @moduledoc """ 3 | Defines APNS push message aps structure. 4 | """ 5 | 6 | alias Express.APNS.PushMessage.Alert 7 | alias Express.Helpers.MapHelper 8 | 9 | @derive [Poison.Encoder] 10 | 11 | @type t :: %__MODULE__{content_available: pos_integer(), 12 | mutable_content: pos_integer(), 13 | badge: pos_integer(), 14 | sound: String.t, 15 | category: String.t, 16 | thread_id: String.t, 17 | alert: Alert.t | String.t} 18 | 19 | defstruct ~w( 20 | content_available 21 | mutable_content 22 | badge 23 | sound 24 | category 25 | thread_id 26 | alert 27 | )a 28 | 29 | @doc "Normalizes an aps `struct` to a map acceptable by APNS" 30 | @spec to_apns_map(__MODULE__.t) :: map() 31 | def to_apns_map(nil), do: %{} 32 | def to_apns_map(struct) do 33 | struct 34 | |> Map.from_struct() 35 | |> Enum.reduce(%{}, fn({key, value}, result) -> 36 | if valid_key?(key, value) do 37 | Map.put(result, key, value) 38 | else 39 | result 40 | end 41 | end) 42 | |> MapHelper.dasherize_keys() 43 | end 44 | 45 | defp valid_key?(:content_available, value), do: is_integer(value) 46 | defp valid_key?(:mutable_content, value), do: is_integer(value) 47 | defp valid_key?(:badge, value), do: is_integer(value) 48 | defp valid_key?(:sound, value), do: is_binary(value) && String.length(value) > 0 49 | defp valid_key?(:category, value), do: is_binary(value) && String.length(value) > 0 50 | defp valid_key?(:thread_id, value), do: is_binary(value) && String.length(value) > 0 51 | defp valid_key?(_, _), do: true 52 | end 53 | -------------------------------------------------------------------------------- /lib/express/apns/ssl_config.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.SSLConfig do 2 | @moduledoc """ 3 | Provides APNS SSL configuration constructor. 4 | """ 5 | 6 | alias Express.Configuration 7 | alias Express.Network.HTTP2.SSLConfig 8 | 9 | @doc "Creates SSL configuration with provided `opts`" 10 | @spec new(Keyword.t) :: SSLConfig.t 11 | def new(opts \\ []) do 12 | mode = 13 | opts[:mode] || 14 | config_mode() 15 | cert = 16 | opts[:cert] || 17 | cert(config_cert_path()) || 18 | decode_content(config_cert(), :cert) 19 | key = 20 | opts[:key] || 21 | key(config_key_path()) || 22 | decode_content(config_key(), :key) 23 | 24 | %SSLConfig{ 25 | mode: mode, 26 | cert: cert, 27 | key: key 28 | } 29 | end 30 | 31 | @doc "Returns apns mode from configuration" 32 | @spec config_mode() :: atom() | String.t | nil 33 | def config_mode, do: Configuration.APNS.mode() 34 | 35 | @doc "Returns SSL certificate path from configuration" 36 | @spec config_cert_path() :: String.t | nil 37 | def config_cert_path, do: Configuration.APNS.cert_path() 38 | 39 | @doc "Returns SSL key path from configuration" 40 | @spec config_key_path() :: String.t | nil 41 | def config_key_path, do: Configuration.APNS.key_path() 42 | 43 | @doc "Returns SSL certificate from configuration" 44 | @spec config_cert() :: String.t | nil 45 | def config_cert, do: Configuration.APNS.cert() 46 | 47 | @doc "Returns SSL key from configuration" 48 | @spec config_key() :: String.t | nil 49 | def config_key, do: Configuration.APNS.key() 50 | 51 | @doc "Returns SSL certificate by file path" 52 | @spec cert(String.t) :: binary() 53 | def cert(file_path) when is_binary(file_path) do 54 | file_path |> read_file |> decode_content(:cert) 55 | end 56 | def cert(_), do: nil 57 | 58 | @doc "Returns SSL key by file path" 59 | @spec key(String.t) :: binary() 60 | def key(file_path) when is_binary(file_path) do 61 | file_path |> read_file |> decode_content(:key) 62 | end 63 | def key(_), do: nil 64 | 65 | @doc "Returns a file content by file path" 66 | @spec read_file(String.t) :: String.t | nil 67 | def read_file(file_path) when is_binary(file_path) do 68 | with true <- :filelib.is_file(file_path), 69 | full_file_path <- Path.expand(file_path), 70 | {:ok, content} <- File.read(full_file_path) do 71 | content 72 | else 73 | _ -> nil 74 | end 75 | end 76 | 77 | @doc "Returns either SSL certificate or SSL key from a file content" 78 | @spec decode_content(String.t, :cert | :key) :: binary() | nil 79 | def decode_content(file_content, type) when is_binary(file_content) and is_atom(type) do 80 | file_content = file_content |> String.replace("\\n", "\n") 81 | try do 82 | case type do 83 | :cert -> fetch_cert(:public_key.pem_decode(file_content)) 84 | :key -> fetch_key(:public_key.pem_decode(file_content)) 85 | _ -> nil 86 | end 87 | rescue 88 | _ -> nil 89 | end 90 | end 91 | def decode_content(_file_content, _type), do: nil 92 | 93 | @doc "Returns SSL certificate from pem" 94 | @spec fetch_cert(list()) :: binary() | nil 95 | def fetch_cert([]), do: nil 96 | def fetch_cert([{:Certificate, cert, _} | _tail]), do: cert 97 | def fetch_cert([_head | tail]), do: fetch_cert(tail) 98 | def fetch_cert(_), do: nil 99 | 100 | @doc "Returns SSL key from pem" 101 | @spec fetch_key(list()) :: binary() | nil 102 | def fetch_key([]), do: nil 103 | def fetch_key([{:RSAPrivateKey, key, _} | _tail]), do: {:RSAPrivateKey, key} 104 | def fetch_key([_head | tail]), do: fetch_key(tail) 105 | def fetch_key(_), do: nil 106 | end 107 | -------------------------------------------------------------------------------- /lib/express/apns/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.Supervisor do 2 | use Supervisor 3 | 4 | alias Express.Configuration 5 | alias Express.APNS.Connection 6 | alias Express.Operations.PoolboyConfigs 7 | 8 | def start_link do 9 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 10 | end 11 | 12 | def init(:ok), do: supervise(children(), opts()) 13 | 14 | defp children do 15 | children = 16 | [ 17 | supervisor( 18 | Express.APNS.DelayedPushes, 19 | [], 20 | restart: :permanent 21 | ), 22 | :poolboy.child_spec( 23 | PoolboyConfigs.apns_workers().name, 24 | PoolboyConfigs.apns_workers().config, 25 | [async: Configuration.APNS.workers_push_async()] 26 | ) 27 | ] 28 | 29 | if Connection.need_ssl_config?() do 30 | children 31 | else 32 | [worker(Express.APNS.JWTHolder, [], restart: :permanent)] ++ children 33 | end 34 | end 35 | 36 | defp opts do 37 | [ 38 | strategy: :one_for_one, 39 | name: __MODULE__ 40 | ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/express/apns/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.APNS.Worker do 2 | use GenServer 3 | 4 | alias Express.Operations.LogMessage 5 | alias Express.APNS.JWTHolder 6 | alias Express.Network.HTTP2 7 | alias Express.APNS.PushMessage 8 | alias Express.Operations.APNS.Push 9 | alias Express.APNS.Connection, as: APNSConnection 10 | 11 | require Logger 12 | 13 | defmodule State do 14 | @type t :: %__MODULE__{ 15 | connection: HTTP2.Connection.t, 16 | push_message: PushMessage.t, 17 | callback_fun: Express.callback_fun, 18 | async: boolean(), 19 | stop_at: pos_integer() 20 | } 21 | 22 | defstruct ~w(connection push_message callback_fun async stop_at)a 23 | end 24 | 25 | def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, {:ok, opts}) 26 | 27 | def init({:ok, opts}) do 28 | connection = APNSConnection.new() 29 | 30 | if connection do 31 | {:ok, %State{connection: connection, 32 | async: (opts[:async] in ["true", true]), 33 | stop_at: shift_timer()}} 34 | else 35 | {:stop, :no_connection} 36 | end 37 | end 38 | 39 | def push(worker, push_message, opts, callback_fun) do 40 | GenServer.call(worker, {:push, push_message, opts, callback_fun}, 3000) 41 | end 42 | 43 | def handle_call({:push, push_message, _opts, callback_fun}, _from, %{async: true} = state) do 44 | if state.stop_at && state.stop_at <= Timex.to_unix(Timex.now()) do 45 | {:stop, :normal, {:error, :connection_down}, state} 46 | else 47 | if Process.alive?(state.connection.socket) do 48 | push_message 49 | |> push_params(state) 50 | |> Push.run!() 51 | 52 | new_state = 53 | state 54 | |> Map.put(:push_message, push_message) 55 | |> Map.put(:callback_fun, callback_fun) 56 | 57 | {:reply, :pushed, new_state} 58 | else 59 | {:stop, :normal, {:error, :connection_down}, state} 60 | end 61 | end 62 | end 63 | def handle_call({:push, push_message, _opts, callback_fun}, _from, %{async: false} = state) do 64 | if state.stop_at && state.stop_at <= Timex.to_unix(Timex.now()) do 65 | {:stop, :normal, {:error, :connection_down}, state} 66 | else 67 | if Process.alive?(state.connection.socket) do 68 | {headers, body} = 69 | push_message 70 | |> push_params(state) 71 | |> Push.run!() 72 | 73 | new_state = 74 | state 75 | |> Map.put(:stop_at, shift_timer()) 76 | |> Map.put(:push_message, push_message) 77 | 78 | result = handle_response({headers, body}, new_state, callback_fun) 79 | 80 | {:reply, {:ok, result}, new_state} 81 | else 82 | {:stop, :normal, {:error, :connection_down}, state} 83 | end 84 | end 85 | end 86 | 87 | def handle_info({:END_STREAM, stream}, 88 | %{connection: connection, 89 | callback_fun: callback_fun} = state) 90 | do 91 | {:ok, {headers, body}} = HTTP2.get_response(connection, stream) 92 | handle_response({headers, body}, state, callback_fun) 93 | 94 | new_state = Map.put(state, :stop_at, shift_timer()) 95 | 96 | {:noreply, new_state} 97 | end 98 | def handle_info(:timeout, state), do: {:stop, :normal, {:error, :timeout}, state} 99 | def handle_info(_, state), do: {:noreply, state} 100 | 101 | @spec push_params(PushMessage.t, State.t) :: Keyword.t 102 | defp push_params(push_message, %{connection: connection, async: async}) when is_map(connection) do 103 | if is_map(connection.ssl_config) do 104 | [ 105 | push_message: push_message, 106 | connection: connection, 107 | async: async 108 | ] 109 | else 110 | [ 111 | push_message: push_message, 112 | connection: connection, 113 | jwt: JWTHolder.get_jwt(), 114 | async: async 115 | ] 116 | end 117 | end 118 | 119 | @spec handle_response({list(), String.t}, State.t, Express.callback_fun) :: any() 120 | defp handle_response({headers, body} = _response, 121 | %{push_message: push_message} = _state, 122 | callback_fun) 123 | do 124 | headers_map = Enum.reduce(headers, %{}, fn({k, v}, m) -> Map.put(m, k, v) end) 125 | status = fetch_status(headers_map) 126 | apns_id = fetch_apns_id(headers_map) 127 | 128 | result = 129 | case status do 130 | 200 -> 131 | {:ok, %{id: apns_id, status: status, body: body}} 132 | status -> 133 | error_reason = fetch_reason(body) 134 | log_error({status, error_reason}, push_message) 135 | 136 | {:error, %{id: apns_id, status: status, body: body}} 137 | end 138 | 139 | if is_function(callback_fun) do 140 | callback_fun.(push_message, result) 141 | end 142 | 143 | result 144 | end 145 | defp handle_response(_, _, _), do: :nothing 146 | 147 | @spec fetch_status(Map.t) :: pos_integer() | nil 148 | defp fetch_status(%{":status" => status}), do: String.to_integer(status) 149 | defp fetch_status(_), do: nil 150 | 151 | @spec fetch_apns_id(Map.t) :: String.t | nil 152 | defp fetch_apns_id(%{"apns-id" => apns_id}), do: apns_id 153 | defp fetch_apns_id(_), do: nil 154 | 155 | @spec fetch_reason(String.t) :: String.t 156 | defp fetch_reason(nil), do: nil 157 | defp fetch_reason(""), do: "" 158 | defp fetch_reason([]), do: "" 159 | defp fetch_reason([body]), do: fetch_reason(body) 160 | defp fetch_reason(body) do 161 | {:ok, body} = Poison.decode(body) 162 | Macro.underscore(body["reason"]) 163 | end 164 | 165 | @spec log_error({String.t, String.t}, PushMessage.t) :: :ok | {:error, any()} 166 | defp log_error({status, reason}, push_message) do 167 | error_message = """ 168 | [APNS worker] APNS: #{inspect(reason)}[#{status}]\n#{inspect(push_message)} 169 | """ 170 | 171 | LogMessage.run!(message: error_message, type: :warn) 172 | end 173 | 174 | @spec shift_timer() :: pos_integer() 175 | defp shift_timer do 176 | Timex.now() |> Timex.shift(seconds: 10) |> Timex.to_unix() 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/express/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Application do 2 | @moduledoc "The application. Starts the main supervisor." 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [supervisor(Express.Supervisor, [], restart: :permanent)] 10 | 11 | if Application.get_env(:express, :environment) == :test do 12 | Supervisor.start_link([], strategy: :one_for_one) 13 | else 14 | Supervisor.start_link(children, strategy: :one_for_one) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/express/configuration.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Configuration do 2 | @moduledoc "Defines a behaviour for configuration modules." 3 | 4 | @callback buffer() :: Keyword.t 5 | @callback apns() :: Keyword.t 6 | @callback fcm() :: Keyword.t 7 | 8 | defmodule APNS do 9 | @moduledoc "Defines an API for APNS configuration." 10 | 11 | @spec mode() :: atom() 12 | def mode do 13 | Application.get_env(:express, :apns)[:mode] || 14 | Application.get_env(:express, :module).apns()[:mode] 15 | end 16 | 17 | @spec cert_path() :: String.t 18 | def cert_path do 19 | Application.get_env(:express, :apns)[:cert_path] || 20 | Application.get_env(:express, :module).apns()[:cert_path] 21 | end 22 | 23 | @spec cert() :: binary() 24 | def cert do 25 | Application.get_env(:express, :apns)[:cert] || 26 | Application.get_env(:express, :module).apns()[:cert] 27 | end 28 | 29 | @spec key_path() :: String.t 30 | def key_path do 31 | Application.get_env(:express, :apns)[:key_path] || 32 | Application.get_env(:express, :module).apns()[:key_path] 33 | end 34 | 35 | @spec key() :: binary() 36 | def key do 37 | Application.get_env(:express, :apns)[:key] || 38 | Application.get_env(:express, :module).apns()[:key] 39 | end 40 | 41 | @spec team_id() :: String.t 42 | def team_id do 43 | Application.get_env(:express, :apns)[:team_id] || 44 | Application.get_env(:express, :module).apns()[:team_id] 45 | end 46 | 47 | @spec key_id() :: String.t 48 | def key_id do 49 | Application.get_env(:express, :apns)[:key_id] || 50 | Application.get_env(:express, :module).apns()[:key_id] 51 | end 52 | 53 | @spec auth_key_path() :: String.t 54 | def auth_key_path do 55 | Application.get_env(:express, :apns)[:auth_key_path] || 56 | Application.get_env(:express, :module).apns()[:auth_key_path] 57 | end 58 | 59 | @spec auth_key() :: binary() 60 | def auth_key do 61 | Application.get_env(:express, :apns)[:auth_key] || 62 | Application.get_env(:express, :module).apns()[:auth_key] 63 | end 64 | 65 | @spec workers_push_async() :: boolean() 66 | def workers_push_async do 67 | Application.get_env(:express, :apns)[:workers_push_async] || 68 | Application.get_env(:express, :module).apns()[:workers_push_async] 69 | end 70 | 71 | @spec workers_pool_config() :: Keyword.t 72 | def workers_pool_config do 73 | Application.get_env(:express, :apns)[:workers_pool_config] || 74 | Application.get_env(:express, :module).apns()[:workers_pool_config] 75 | end 76 | end 77 | 78 | defmodule FCM do 79 | @moduledoc "Defines an API for FCM configuration." 80 | 81 | @spec api_key() :: String.t 82 | def api_key do 83 | Application.get_env(:express, :fcm)[:api_key] || 84 | Application.get_env(:express, :module).fcm()[:api_key] 85 | end 86 | 87 | @spec workers_pool_config() :: Keyword.t 88 | def workers_pool_config do 89 | Application.get_env(:express, :fcm)[:workers_pool_config] || 90 | Application.get_env(:express, :module).fcm()[:workers_pool_config] 91 | end 92 | end 93 | 94 | defmodule Buffer do 95 | @moduledoc "Defines an API for Buffer configuration." 96 | 97 | @spec adders_pool_config() :: Keyword.t 98 | def adders_pool_config do 99 | Application.get_env(:express, :buffer)[:adders_pool_config] || 100 | Application.get_env(:express, :module).buffer()[:adders_pool_config] 101 | end 102 | 103 | @spec max_size() :: integer() 104 | def max_size do 105 | Application.get_env(:express, :buffer)[:max_size] || 106 | Application.get_env(:express, :module).buffer()[:max_size] 107 | end 108 | 109 | @spec consumers_count() :: integer() 110 | def consumers_count do 111 | Application.get_env(:express, :buffer)[:consumers_count] || 112 | Application.get_env(:express, :module).buffer()[:consumers_count] 113 | end 114 | 115 | @spec consumer_demand_multiplier() :: integer() 116 | def consumer_demand_multiplier do 117 | Application.get_env(:express, :buffer)[:consumer_demand_multiplier] || 118 | Application.get_env(:express, :module).buffer()[:consumer_demand_multiplier] 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/express/configuration/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Configuration.Test do 2 | @moduledoc """ 3 | Configuration for :test environment. 4 | Conforms Express.Configuration behaviour 5 | """ 6 | 7 | @behaviour Express.Configuration 8 | 9 | def buffer do 10 | [ 11 | adders_pool_config: [ 12 | {:name, {:local, :buffer_adders_pool}}, 13 | {:worker_module, Express.PushRequests.Adder}, 14 | {:size, 10}, 15 | {:max_overflow, 2} 16 | ], 17 | consumers_count: 10, 18 | max_size: 10_000 19 | ] 20 | end 21 | 22 | def apns do 23 | [ 24 | mode: :dev, 25 | cert_path: Path.expand("test/fixtures/test_apns_cert.pem"), 26 | key_path: Path.expand("test/fixtures/test_apns_key.pem"), 27 | key_id: "key_id", 28 | team_id: "team_id", 29 | auth_key_path: Path.expand("test/fixtures/test_auth_key.p8") 30 | ] 31 | end 32 | 33 | def fcm do 34 | [ 35 | api_key: "your_api_key", 36 | collapse_key: "your_collapse_key" 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/express/express.ex: -------------------------------------------------------------------------------- 1 | defmodule Express do 2 | @moduledoc """ 3 | Library for sending push notifications. 4 | Supports Apple APNS and Google FCM services. 5 | """ 6 | 7 | alias Express.APNS 8 | alias Express.FCM 9 | 10 | @type push_result :: {:ok, %{id: any(), status: pos_integer(), body: any()}} | 11 | {:error, %{id: any(), status: pos_integer(), body: any()}} 12 | 13 | @type callback_fun :: ((PushMessage.t, Express.push_result) -> any()) 14 | 15 | @doc """ 16 | Pushes a message with options and callback function (which are optional). 17 | Returns a response from a provider (via callback function). 18 | """ 19 | @callback push(APNS.PushMessage.t | FCM.PushMessage.t, 20 | Keyword.t, 21 | callback_fun | nil) :: {:noreply, map()} 22 | end 23 | -------------------------------------------------------------------------------- /lib/express/fcm/delayed_pushes.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.FCM.DelayedPushes do 2 | use Supervisor 3 | 4 | alias Express.FCM.PushMessage 5 | alias Express.Operations.LogMessage 6 | alias Express.PushRequests.Adder 7 | 8 | def start_link, do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 9 | 10 | def init(:ok) do 11 | children = [worker(Adder, [], restart: :temporary)] 12 | 13 | supervise(children, strategy: :simple_one_for_one) 14 | end 15 | 16 | @spec add(PushMessage.t, Keyword.t | nil, 17 | Express.callback_fun | nil) :: {:noreply, map()} 18 | def add(push_message, opts, callback_fun) do 19 | case Supervisor.start_child(__MODULE__, []) do 20 | {:ok, adder} -> 21 | Adder.add_after(adder, push_message, opts, callback_fun) 22 | {:error, reason} -> 23 | error_message = """ 24 | [FCM DelayedPushes] Failed to start an adder. 25 | Reason: #{inspect(reason)} 26 | """ 27 | LogMessage.run!(message: error_message) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/express/fcm/fcm.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.FCM do 2 | @moduledoc """ 3 | FCM pusher. Conforms Express behaviour. 4 | """ 5 | 6 | @behaviour Express 7 | 8 | alias Express.PushRequests.Adder 9 | alias Express.FCM.{PushMessage, DelayedPushes} 10 | alias Express.Operations.PoolboyConfigs 11 | 12 | @spec push(FCM.PushMessage.t, Keyword.t, Express.callback_fun | nil) :: {:noreply, map()} 13 | def push(push_message, opts \\ [], callback_fun \\ nil) do 14 | if is_integer(opts[:delay]) && opts[:delay] > 0 do 15 | delayed_push(push_message, opts, callback_fun) 16 | else 17 | instant_push(push_message, opts, callback_fun) 18 | end 19 | end 20 | 21 | @spec instant_push(PushMessage.t, Keyword.t, Express.callback_fun) :: {:noreply, map()} 22 | defp instant_push(push_message, opts, callback_fun) do 23 | :poolboy.transaction(PoolboyConfigs.buffer_adders().name, fn(adder) -> 24 | Adder.add(adder, push_message, opts, callback_fun) 25 | end) 26 | end 27 | 28 | @spec delayed_push(PushMessage.t, Keyword.t, Express.callback_fun) :: {:noreply, map()} 29 | defp delayed_push(push_message, opts, callback_fun) do 30 | DelayedPushes.add(push_message, opts, callback_fun) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/express/fcm/push_message.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.FCM.PushMessage do 2 | @moduledoc """ 3 | Defines FCM push message structure. 4 | """ 5 | 6 | @derive [Poison.Encoder] 7 | 8 | alias Express.FCM.PushMessage 9 | 10 | @type t :: %__MODULE__{to: String.t, 11 | registration_ids: [String.t], 12 | priority: String.t, 13 | content_available: boolean(), 14 | collapse_key: String.t, 15 | data: map(), 16 | notification: PushMessage.Notification.t} 17 | 18 | defstruct to: nil, 19 | registration_ids: nil, 20 | priority: "normal", 21 | content_available: nil, 22 | collapse_key: nil, 23 | data: %{}, 24 | notification: nil 25 | end 26 | -------------------------------------------------------------------------------- /lib/express/fcm/push_message/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.FCM.PushMessage.Notification do 2 | @moduledoc """ 3 | Defines FCM push message's notification structure. 4 | """ 5 | 6 | @derive [Poison.Encoder] 7 | 8 | @type t :: %__MODULE__{title: String.t, 9 | body: String.t, 10 | icon: String.t, 11 | sound: String.t, 12 | click_action: String.t, 13 | badge: pos_integer(), 14 | category: String.t} 15 | 16 | defstruct [:title, :body, :icon, :sound, :click_action, :badge, :category] 17 | end 18 | -------------------------------------------------------------------------------- /lib/express/fcm/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.FCM.Supervisor do 2 | use Supervisor 3 | 4 | alias Express.Operations.PoolboyConfigs 5 | 6 | def start_link do 7 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 8 | end 9 | 10 | def init(:ok), do: supervise(children(), opts()) 11 | 12 | defp children do 13 | [ 14 | supervisor( 15 | Express.FCM.DelayedPushes, 16 | [], 17 | restart: :permanent 18 | ), 19 | :poolboy.child_spec( 20 | PoolboyConfigs.fcm_workers().name, 21 | PoolboyConfigs.fcm_workers().config, 22 | [] 23 | ) 24 | ] 25 | end 26 | 27 | defp opts do 28 | [ 29 | strategy: :one_for_one, 30 | name: __MODULE__ 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/express/fcm/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.FCM.Worker do 2 | use GenServer 3 | 4 | alias Express.Operations.FCM.Push 5 | 6 | def start_link, do: start_link(:ok) 7 | def start_link(_), do: GenServer.start_link(__MODULE__, :ok) 8 | 9 | def init(:ok), do: {:ok, %{}} 10 | 11 | def push(worker, push_message, opts, callback_fun) do 12 | GenServer.cast(worker, {:push, push_message, opts, callback_fun}) 13 | end 14 | 15 | def handle_cast({:push, push_message, opts, callback_fun}, state) do 16 | Push.run!( 17 | push_message: push_message, 18 | opts: opts, 19 | callback_fun: callback_fun 20 | ) 21 | 22 | {:noreply, state} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/express/helpers/map_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Helpers.MapHelper do 2 | @moduledoc "Helper functions for a Map module." 3 | 4 | @spec reduce_keys(map(), fun()) :: map() 5 | def reduce_keys(map, fun) when is_map(map) and is_function(fun) do 6 | Enum.reduce(map, %{}, fn({k, v}, new_map) -> 7 | key = fun.(k) 8 | Map.put(new_map, key, v) 9 | end) 10 | end 11 | def reduce_keys(map, _), do: map 12 | 13 | @spec deep_reduce_keys(map(), fun()) :: map() 14 | def deep_reduce_keys(map, fun) when is_map(map) and is_function(fun) do 15 | Enum.reduce(map, %{}, fn({k, v}, new_map) -> 16 | key = fun.(k) 17 | 18 | if is_map(v) do 19 | Map.put(new_map, key, deep_reduce_keys(v, fun)) 20 | else 21 | Map.put(new_map, key, v) 22 | end 23 | end) 24 | end 25 | def deep_reduce_keys(map, _), do: map 26 | 27 | @spec stringify_keys(map()) :: map() 28 | def stringify_keys(map) when is_map(map) do 29 | reduce_keys(map, fn(k) -> 30 | if is_atom(k), do: Atom.to_string(k), else: k 31 | end) 32 | end 33 | def stringify_keys(map), do: map 34 | 35 | @spec deep_stringify_keys(map()) :: map() 36 | def deep_stringify_keys(map) when is_map(map) do 37 | deep_reduce_keys(map, fn(k) -> 38 | if is_atom(k), do: Atom.to_string(k), else: k 39 | end) 40 | end 41 | def deep_stringify_keys(map), do: map 42 | 43 | @spec dasherize_keys(map()) :: map() 44 | def dasherize_keys(map) when is_map(map) do 45 | reduce_keys(map, fn(k) -> 46 | k = if is_atom(k), do: Atom.to_string(k), else: k 47 | String.replace(k, "_", "-") 48 | end) 49 | end 50 | def dasherize_keys(map), do: map 51 | 52 | @spec deep_dasherize_keys(map()) :: map() 53 | def deep_dasherize_keys(map) when is_map(map) do 54 | deep_reduce_keys(map, fn(k) -> 55 | k = if is_atom(k), do: Atom.to_string(k), else: k 56 | String.replace(k, "_", "-") 57 | end) 58 | end 59 | def deep_dasherize_keys(map), do: map 60 | 61 | @spec underscorize_keys(map()) :: map() 62 | def underscorize_keys(map) when is_map(map) do 63 | reduce_keys(map, fn(k) -> 64 | k = if is_atom(k), do: Atom.to_string(k), else: k 65 | String.replace(k, "-", "_") 66 | end) 67 | end 68 | def underscorize_keys(map), do: map 69 | 70 | @spec deep_underscorize_keys(map()) :: map() 71 | def deep_underscorize_keys(map) when is_map(map) do 72 | deep_reduce_keys(map, fn(k) -> 73 | k = if is_atom(k), do: Atom.to_string(k), else: k 74 | String.replace(k, "-", "_") 75 | end) 76 | end 77 | def deep_underscorize_keys(map), do: map 78 | end 79 | -------------------------------------------------------------------------------- /lib/express/network/http2.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Network.HTTP2 do 2 | @moduledoc """ 3 | Module with functions for establishing and working with HTTP2-connection. 4 | """ 5 | 6 | alias Express.Configuration 7 | alias Express.Network.HTTP2.{Connection, Client} 8 | alias Express.APNS 9 | 10 | @doc """ 11 | Establishes a connection for `provider` with either `ssl_config` or `jwt` and `client` which maintains the connection. 12 | Returns the connection. 13 | """ 14 | @spec connect(Client.t, atom(), APNS.SSLConfig.t | String.t) :: {:ok, Connection.t} | 15 | {:error, any()} 16 | def connect(client, provider, ssl_config) when is_map(ssl_config) do 17 | case client.open_socket(provider, ssl_config, 0) do 18 | {:ok, socket} -> 19 | params = %{ 20 | client: client, 21 | provider: provider, 22 | socket: socket, 23 | ssl_config: ssl_config 24 | } 25 | 26 | {:ok, Connection.new(params)} 27 | error -> 28 | error 29 | end 30 | end 31 | 32 | def connect(client, provider) do 33 | mode = Configuration.APNS.mode() 34 | 35 | case client.open_socket(provider, mode, 0) do 36 | {:ok, socket} -> 37 | params = %{ 38 | client: client, 39 | provider: provider, 40 | socket: socket 41 | } 42 | 43 | {:ok, Connection.new(params)} 44 | error -> 45 | error 46 | end 47 | end 48 | 49 | @doc "Sends an async request via a connection with `headers` and `payload`" 50 | @spec send_request(Connection.t, list(), String.t) :: {:ok, pid()} | any() 51 | def send_request(%{client: client, socket: socket} = _connection, headers, payload) do 52 | client.send_request(socket, headers, payload) 53 | end 54 | 55 | @doc "Sends a sync request via a connection with `headers` and `payload`" 56 | @spec sync_request(Connection.t, list(), String.t) :: any() 57 | def sync_request(%{client: client, socket: socket} = _connection, headers, payload) do 58 | client.sync_request(socket, headers, payload) 59 | end 60 | 61 | @doc "Sends a ping via a connection" 62 | @spec ping(Connection.t) :: :ok 63 | def ping(%{client: client, socket: socket} = _connection) do 64 | client.ping(socket) 65 | end 66 | 67 | @doc "Gets a response from connection stream" 68 | @spec get_response(pid(), pid()) :: {:ok, {String.t, String.t}} | any() 69 | def get_response(%{client: client, socket: socket} = _connection, stream) do 70 | client.get_response(socket, stream) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/express/network/http2/chatterbox_client.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Network.HTTP2.ChatterboxClient do 2 | @behaviour Express.Network.HTTP2.Client 3 | @moduledoc """ 4 | HTTP2-client which conforms Express.Network.HTTP2.Client behaviour with chatterbox library. 5 | """ 6 | 7 | def uri(:apns, :prod), do: to_char_list("api.push.apple.com") 8 | def uri(:apns, :dev), do: to_char_list("api.development.push.apple.com") 9 | 10 | def open_socket(_, _, 3), do: {:error, :open_socket, :timeout} 11 | def open_socket(_provider, %{cert: nil}, _tries), do: {:error, :ssl_config, :certificate_missed} 12 | def open_socket(_provider, %{key: nil}, _tries), do: {:error, :ssl_config, :rsa_key_missed} 13 | def open_socket(_provider, %{mode: nil}, _tries), do: {:error, :ssl_config, :mode_missed} 14 | def open_socket(provider, %{mode: mode, cert: cert, key: key} = ssl_config, tries) do 15 | config = socket_config({:cert, cert}, {:key, key}) 16 | result = :h2_client.start_link(:https, uri(provider, mode), config) 17 | case result do 18 | {:ok, socket} -> {:ok, socket} 19 | _ -> open_socket(provider, ssl_config, (tries + 1)) 20 | end 21 | end 22 | def open_socket(provider, mode, tries) when is_atom(mode) do 23 | result = :h2_client.start_link(:https, 24 | uri(provider, mode), 25 | default_socket_config()) 26 | case result do 27 | {:ok, socket} -> {:ok, socket} 28 | _ -> open_socket(provider, mode, (tries + 1)) 29 | end 30 | end 31 | def open_socket(_, _, _), do: {:error, :ssl_config, :invalid_ssl_config} 32 | 33 | def send_request(socket, headers, payload) do 34 | :h2_client.send_request(socket, headers, payload) 35 | end 36 | 37 | def sync_request(socket, headers, payload) do 38 | :h2_client.sync_request(socket, headers, payload) 39 | end 40 | 41 | def ping(socket) do 42 | :h2_client.send_ping(socket) 43 | end 44 | 45 | def get_response(socket, stream) do 46 | :h2_client.get_response(socket, stream) 47 | end 48 | 49 | @spec default_socket_config() :: list() 50 | defp default_socket_config do 51 | [ 52 | {:password, ''}, 53 | {:packet, 0}, 54 | {:reuseaddr, true}, 55 | {:active, true}, 56 | :binary 57 | ] 58 | end 59 | 60 | @spec socket_config(binary(), binary()) :: list() 61 | defp socket_config(cert, key) do 62 | default_socket_config() ++ [cert, key] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/express/network/http2/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Network.HTTP2.Client do 2 | @moduledoc """ 3 | HTTP2-clients behaviour. 4 | """ 5 | @type t :: __MODULE__ 6 | 7 | @doc "Returns URI of a connection" 8 | @callback uri(atom(), atom()) :: list() 9 | 10 | @doc """ 11 | Opens a socket for a `provider` with defined `configuration` and tries `count`. 12 | """ 13 | @callback open_socket(atom(), map(), pos_integer()) :: 14 | {:ok, pid()} | 15 | {:error, :open_socket, :timeout} | 16 | {:error, :ssl_config, :certificate_missed} | 17 | {:error, :ssl_config, :rsa_key_missed} 18 | 19 | @doc """ 20 | Sends a request through a socket by `pid` with `headers` and a `payload`. 21 | """ 22 | @callback send_request(pid(), list(), String.t) :: {:ok, pid()} | any() 23 | 24 | @doc """ 25 | Sends a ping through a socket by `pid`. 26 | """ 27 | @callback ping(pid()) :: :ok 28 | 29 | @doc """ 30 | Receives a response from socket by `pid` for a `stream`. 31 | """ 32 | @callback get_response(pid(), pid()) :: {:ok, {String.t, String.t}} | any() 33 | end 34 | -------------------------------------------------------------------------------- /lib/express/network/http2/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Network.HTTP2.Connection do 2 | @moduledoc "Defines a structure for general HTTP2 connection." 3 | 4 | alias Express.Network.HTTP2 5 | 6 | @derive [Poison.Encoder] 7 | 8 | @type t :: %__MODULE__{client: HTTP2.Client.t, 9 | provider: atom(), 10 | socket: pid(), 11 | ssl_config: HTTP2.SSLConfig.t} 12 | 13 | defstruct ~w(client provider socket ssl_config)a 14 | 15 | @doc "Structure constructor." 16 | @spec new(Keyword.t) :: t 17 | def new(args) do 18 | if args[:ssl_config] do 19 | new_with_ssl_config(args) 20 | else 21 | new_common(args) 22 | end 23 | end 24 | 25 | defp new_common(args) do 26 | %__MODULE__{ 27 | client: args[:client], 28 | provider: args[:provider], 29 | socket: args[:socket] 30 | } 31 | end 32 | 33 | defp new_with_ssl_config(args) do 34 | args |> new_common() |> Map.put(:ssl_config, args[:ssl_config]) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/express/network/http2/ssl_config.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Network.HTTP2.SSLConfig do 2 | @moduledoc """ 3 | Specifies a behaviour and SSL configuration structure, where: 4 | * `mode` - either :dev or :prod 5 | * `cert` - SSL certificate 6 | * `key` - RSA key 7 | """ 8 | 9 | defstruct ~w(mode cert key)a 10 | 11 | @type t :: %__MODULE__{mode: atom() | String.t, 12 | cert: binary(), 13 | key: binary()} 14 | 15 | @doc "SSL-configuration constructor (for provided arguments)." 16 | @callback new(Keyword.t) :: __MODULE__.t 17 | end 18 | -------------------------------------------------------------------------------- /lib/express/operations/apns/push.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Operations.APNS.Push do 2 | @moduledoc """ 3 | Sends push_message synchronously to APNS. 4 | Invokes callback_fun function after response receive. 5 | 6 | [Exop](https://github.com/madeinussr/exop) library operation. 7 | 8 | Takes parameters: 9 | * `connection` (a connection to send push message through) 10 | * `jwt` (a JWT for a request (if you don't use ssl config)) 11 | * `push_message` (a push message to send) 12 | """ 13 | 14 | use Exop.Operation 15 | require Logger 16 | 17 | alias Express.Operations.LogMessage 18 | alias Express.Network.HTTP2 19 | alias Express.APNS.PushMessage 20 | 21 | parameter :connection, struct: %HTTP2.Connection{}, required: true 22 | parameter :jwt, type: :string 23 | parameter :push_message, struct: %PushMessage{}, required: true 24 | parameter :async, type: :boolean, default: true 25 | 26 | def process(contract) do 27 | connection = contract[:connection] 28 | jwt = contract[:jwt] 29 | push_message = contract[:push_message] 30 | async = contract[:async] 31 | 32 | do_push(push_message, connection, jwt, async) 33 | end 34 | 35 | defp do_push(push_message, connection, jwt, async) do 36 | {:ok, payload} = 37 | push_message 38 | |> PushMessage.to_apns_map() 39 | |> Poison.encode() 40 | 41 | if Application.get_env(:express, :environment) == :dev do 42 | LogMessage.run!(message: payload, type: :info) 43 | end 44 | 45 | headers = headers_for(push_message, payload, jwt) 46 | 47 | if async do 48 | HTTP2.send_request(connection, headers, payload) 49 | else 50 | HTTP2.sync_request(connection, headers, payload) 51 | end 52 | end 53 | 54 | defp headers_for(push_message, payload, nil) do 55 | headers = [ 56 | {":method", "POST"}, 57 | {":path", "/3/device/#{push_message.token}"}, 58 | {"content-length", "#{byte_size(payload)}"} 59 | ] 60 | 61 | if push_message.topic do 62 | headers ++ [{"apns-topic", push_message.topic}] 63 | else 64 | headers 65 | end 66 | end 67 | defp headers_for(push_message, payload, jwt) do 68 | headers_for(push_message, payload, nil) ++ [{"authorization", "bearer #{jwt}"}] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/express/operations/establish_http2_connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Operations.EstablishHTTP2Connection do 2 | @moduledoc """ 3 | Establishes HTTP2 connection and returns it. 4 | 5 | [Exop](https://github.com/madeinussr/exop) library operation. 6 | 7 | Takes parameters: 8 | * `http2_client` (a module that conforms `Express.Network.HTTP2.Client` behaviour) 9 | * `ssl_config` (a structure that conforms `Express.Network.HTTP2.SSLConfig` behaviour) 10 | """ 11 | 12 | use Exop.Operation 13 | 14 | alias Express.Network.HTTP2 15 | alias Express.Network.HTTP2.SSLConfig 16 | alias Express.Operations.LogMessage 17 | 18 | parameter :http2_client, required: true 19 | parameter :ssl_config, struct: %SSLConfig{} 20 | 21 | def process(contract) when is_list(contract) do 22 | contract |> Enum.into(%{}) |> process() 23 | end 24 | def process(%{http2_client: http2_client, ssl_config: ssl_config}) 25 | when is_map(ssl_config) do 26 | case HTTP2.connect(http2_client, :apns, ssl_config) do 27 | {:ok, connection} -> 28 | connection 29 | {:error, :open_socket, :timeout} -> 30 | error_message = """ 31 | [APNS supervisor] Could not establish a connection with APNS. 32 | Is certificate valid and signed for :#{inspect(ssl_config.mode)} mode? 33 | """ 34 | LogMessage.run!(message: error_message) 35 | 36 | {:error, :timeout} 37 | {:error, :ssl_config, reason} -> 38 | error_message = """ 39 | [APNS supervisor] Could not establish a connection with APNS. 40 | Invalid SSL configuration: #{inspect(reason)} 41 | """ 42 | LogMessage.run!(message: error_message) 43 | 44 | {:error, :invalid_ssl_config} 45 | _ -> 46 | error_message = """ 47 | [APNS supervisor] Could not establish a connection with APNS. 48 | Unhandled error occured. 49 | """ 50 | LogMessage.run!(message: error_message) 51 | 52 | {:error, :unhandled} 53 | end 54 | end 55 | def process(%{http2_client: http2_client}) do 56 | case HTTP2.connect(http2_client, :apns) do 57 | {:ok, connection} -> 58 | connection 59 | {:error, :open_socket, :timeout} -> 60 | error_message = """ 61 | [APNS supervisor] Could not establish a connection with APNS. 62 | Timeout. 63 | """ 64 | LogMessage.run!(message: error_message) 65 | 66 | {:error, :timeout} 67 | _ -> 68 | error_message = """ 69 | [APNS supervisor] Could not establish a connection with APNS. 70 | Unhandled error occured. 71 | """ 72 | LogMessage.run!(message: error_message) 73 | 74 | {:error, :unhandled} 75 | end 76 | end 77 | def process(_) do 78 | error_message = """ 79 | [APNS supervisor] Could not establish a connection with APNS. 80 | Need to provide either a ssl config or jwt. 81 | """ 82 | LogMessage.run!(message: error_message) 83 | 84 | {:error, :invalid_args} 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/express/operations/fcm/push.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Operations.FCM.Push do 2 | @moduledoc """ 3 | Sends push_message synchronously to FCM. 4 | Invokes callback_fun function after response receive. 5 | 6 | [Exop](https://github.com/madeinussr/exop) library operation. 7 | 8 | Takes parameters: 9 | * `push_message` (a push message to send) 10 | * `opts` (options) 11 | * `callback_fun` (callback function to invoke on response) 12 | """ 13 | 14 | use Exop.Operation 15 | require Logger 16 | 17 | alias Express.Configuration 18 | alias Express.Operations.LogMessage 19 | alias Express.FCM.PushMessage 20 | 21 | @uri_path "https://fcm.googleapis.com/fcm/send" 22 | 23 | parameter :push_message, struct: %PushMessage{}, required: true 24 | parameter :opts, type: :list, default: [] 25 | parameter :callback_fun, type: :function 26 | 27 | def process(contract) do 28 | push_message = contract[:push_message] 29 | opts = contract[:opts] 30 | callback_fun = contract[:callback_fun] 31 | 32 | result = do_push(push_message, opts) 33 | 34 | if callback_fun, do: callback_fun.(push_message, result) 35 | 36 | true 37 | end 38 | 39 | @spec do_push(PushMessage.t, Keyword.t) :: 40 | Express.push_result | 41 | {:error, {:http_error, any()}} | 42 | {:error, :unhandled_error} 43 | defp do_push(push_message, opts) do 44 | api_key = api_key_for(opts) 45 | 46 | headers = [ 47 | {"Content-Type", "application/json"}, 48 | {"Authorization", "key=#{api_key}"} 49 | ] 50 | 51 | payload = Poison.encode!(push_message) 52 | 53 | case HTTPoison.post("#{@uri_path}", payload, headers) do 54 | {:ok, response = %HTTPoison.Response{status_code: 200 = status, 55 | body: body}} -> 56 | if Application.get_env(:express, :environment) == :dev do 57 | LogMessage.run!(message: payload, type: :info) 58 | LogMessage.run!(message: inspect(response), type: :info) 59 | end 60 | 61 | handle_response(push_message, {status, body}) 62 | {:ok, %HTTPoison.Response{status_code: 401 = status, body: body}} -> 63 | error_message = "[FCM worker] Unauthorized API key." 64 | LogMessage.run!(message: error_message) 65 | {:error, %{id: nil, status: status, body: body}} 66 | {:ok, %HTTPoison.Response{status_code: status, body: body}} -> 67 | log_error({status, body}, push_message) 68 | {:error, %{id: nil, status: status, body: body}} 69 | {:error, error} -> 70 | error_message = """ 71 | [FCM worker] HTTPoison could not handle a request. 72 | Error: #{inspect(error)} 73 | """ 74 | LogMessage.run!(message: error_message) 75 | {:error, {:http_error, error}} 76 | _ -> 77 | error_message = "[FCM worker] Unhandled error." 78 | LogMessage.run!(message: error_message) 79 | {:error, :unhandled_error} 80 | end 81 | end 82 | 83 | @spec api_key_for(Keyword.t) :: String.t 84 | defp api_key_for(opts) do 85 | opts[:api_key] || Configuration.FCM.api_key() 86 | end 87 | 88 | @spec handle_response(PushMessage.t, {String.t, String.t}) :: :ok 89 | defp handle_response(push_message, {status, body}) do 90 | decoded_body = Poison.decode!(body) 91 | multicast_id = fetch_multicast_id(decoded_body) 92 | message_id = fetch_message_id(decoded_body) 93 | 94 | errors = 95 | decoded_body 96 | |> Map.get("results", []) 97 | |> Enum.map(&(handle_result(&1))) 98 | |> Enum.reject(&(&1 == :ok)) 99 | 100 | if Enum.any?(errors) do 101 | Enum.each(errors, fn {:error, error_message} -> 102 | log_error({status, error_message}, push_message) 103 | end) 104 | 105 | {:error, %{id: %{message_id: message_id, multicast_id: multicast_id}, 106 | status: status, 107 | body: body}} 108 | else 109 | {:ok, %{id: %{message_id: message_id, multicast_id: multicast_id}, 110 | status: status, 111 | body: body}} 112 | end 113 | end 114 | 115 | @spec handle_result(map()) :: :ok | {:error, String.t} 116 | defp handle_result(%{"error" => message}), do: {:error, message} 117 | defp handle_result(_), do: :ok 118 | 119 | defp fetch_multicast_id(%{"multicast_id" => multicast_id}), do: multicast_id 120 | defp fetch_multicast_id(_), do: nil 121 | 122 | defp fetch_message_id(%{"results" => [%{"message_id" => message_id} | _tail]}) do 123 | message_id 124 | end 125 | defp fetch_message_id(_), do: nil 126 | 127 | @spec log_error({String.t, String.t}, PushMessage.t) :: :ok | {:error, any()} 128 | defp log_error({status, reason}, push_message) do 129 | error_message = """ 130 | [FCM worker] FCM: #{inspect(reason)}[#{status}]\n#{inspect(push_message)} 131 | """ 132 | 133 | LogMessage.run!(message: error_message, type: :warn) 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/express/operations/log_message.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Operations.LogMessage do 2 | @moduledoc """ 3 | Sends a message to the Logger. 4 | 5 | [Exop](https://github.com/madeinussr/exop) library operation. 6 | 7 | Takes parameters: 8 | * `message` (String) 9 | * `type` of the message (error/warn/info) 10 | """ 11 | 12 | use Exop.Operation 13 | 14 | require Logger 15 | 16 | parameter :message, type: :string, required: true 17 | parameter :type, type: :atom, in: ~w(error warn info)a, default: :error 18 | 19 | def process(contract), do: do_log(contract[:message], contract[:type]) 20 | 21 | @spec do_log(String.t, :error | :warn | :info) :: :ok | {:error, any()} 22 | defp do_log(message, :error), do: Logger.error(message) 23 | defp do_log(message, :warn), do: Logger.warn(message) 24 | defp do_log(message, :info), do: Logger.info(message) 25 | end 26 | -------------------------------------------------------------------------------- /lib/express/operations/poolboy_configs.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Operations.PoolboyConfigs do 2 | @moduledoc "Returns poolboy configurations." 3 | 4 | alias Express.Configuration 5 | 6 | @doc """ 7 | Returns poolboy configuration for the Buffer adders. 8 | If configuration described in config file - returns it. 9 | Returns default configuration otherwise. 10 | """ 11 | @spec buffer_adders() :: %{config: Keyword.t, name: atom()} 12 | def buffer_adders do 13 | %{ 14 | config: buffer_adders_pool_config(), 15 | name: buffer_adders_pool_name() 16 | } 17 | end 18 | 19 | @doc """ 20 | Returns poolboy configuration for the FCM workers. 21 | If configuration described in config file - returns it. 22 | Returns default configuration otherwise. 23 | """ 24 | @spec fcm_workers() :: %{config: Keyword.t, name: atom()} 25 | def fcm_workers do 26 | %{ 27 | config: fcm_workers_pool_config(), 28 | name: fcm_workers_pool_name() 29 | } 30 | end 31 | 32 | @doc """ 33 | Returns poolboy configuration for the APNS workers. 34 | If configuration described in config file - returns it. 35 | Returns default configuration otherwise. 36 | """ 37 | @spec apns_workers() :: %{config: Keyword.t, name: atom()} 38 | def apns_workers do 39 | %{ 40 | config: apns_workers_pool_config(), 41 | name: apns_workers_pool_name() 42 | } 43 | end 44 | 45 | @spec buffer_adders_pool_config() :: Keyword.t 46 | defp buffer_adders_pool_config do 47 | Configuration.Buffer.adders_pool_config() || 48 | [ 49 | {:name, {:local, :buffer_adders_pool}}, 50 | {:worker_module, Express.PushRequests.Adder}, 51 | {:size, 5}, 52 | {:max_overflow, 1} 53 | ] 54 | end 55 | 56 | @spec buffer_adders_pool_name() :: atom() 57 | defp buffer_adders_pool_name do 58 | [{:name, {_, name}} | _] = buffer_adders_pool_config() 59 | name 60 | end 61 | 62 | @spec fcm_workers_pool_config() :: Keyword.t 63 | defp fcm_workers_pool_config do 64 | Configuration.FCM.workers_pool_config() || 65 | [ 66 | {:name, {:local, :fcm_workers_pool}}, 67 | {:worker_module, Express.FCM.Worker}, 68 | {:size, System.schedulers_online()} 69 | ] 70 | end 71 | 72 | @spec fcm_workers_pool_name() :: atom() 73 | defp fcm_workers_pool_name do 74 | [{:name, {_, name}} | _] = fcm_workers_pool_config() 75 | name 76 | end 77 | 78 | @spec apns_workers_pool_config() :: Keyword.t 79 | defp apns_workers_pool_config do 80 | Configuration.APNS.workers_pool_config() || 81 | [ 82 | {:name, {:local, :apns_workers_pool}}, 83 | {:worker_module, Express.APNS.Worker}, 84 | {:size, System.schedulers_online()} 85 | ] 86 | end 87 | 88 | @spec apns_workers_pool_name() :: atom() 89 | defp apns_workers_pool_name do 90 | [{:name, {_, name}} | _] = apns_workers_pool_config() 91 | name 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/express/push_requests/adder.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.PushRequests.Adder do 2 | @moduledoc """ 3 | Responsible for adding a push message to Buffer. 4 | If was called with opts[:delay] > 0 - stops after job done. 5 | """ 6 | 7 | use GenServer 8 | 9 | alias Express.PushRequests.Buffer 10 | alias Express.PushRequests.PushRequest 11 | alias Express.{APNS, FCM} 12 | 13 | def start_link, do: start_link(:ok) 14 | def start_link(_), do: GenServer.start_link(__MODULE__, :ok) 15 | 16 | def init(:ok), do: {:ok, %{}} 17 | 18 | @doc "Adds a push message to the Buffer" 19 | @spec add(pid(), APNS.PushMessage | FCM.PushMessage, Keyword.t, Express.callback_fun) :: any() 20 | def add(adder, push_message, opts, callback_fun) do 21 | Process.send(adder, {:add, push_message, opts, callback_fun}, []) 22 | end 23 | 24 | @doc "Adds a push message to the Buffer after a delay provided in `opts`" 25 | @spec add_after(pid(), APNS.PushMessage | FCM.PushMessage, Keyword.t, Express.callback_fun) :: any() 26 | def add_after(adder, push_message, opts, callback_fun) do 27 | delay = (opts[:delay] || 1) * 1000 28 | Process.send_after(adder, {:add, push_message, opts, callback_fun}, delay) 29 | end 30 | 31 | def handle_info({:add, push_message, opts, callback_fun}, state) do 32 | Buffer.add(%PushRequest{ 33 | push_message: push_message, 34 | opts: opts, 35 | callback_fun: callback_fun 36 | }) 37 | 38 | if is_integer(opts[:delay]) && opts[:delay] > 0 do 39 | {:stop, :normal, state} 40 | else 41 | {:noreply, state} 42 | end 43 | end 44 | 45 | def terminate({:timeout, _}, _state) do 46 | pid = Process.whereis(Express.PushRequests.Buffer) 47 | Process.exit(pid, :kill) 48 | :normal 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/express/push_requests/buffer.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.PushRequests.Buffer do 2 | @moduledoc """ 3 | GenStage producer. Acts like a buffer for incoming push messages. 4 | Default buffer size is 5000 events. 5 | This size can be adjusted via config file: 6 | 7 | config :express, 8 | buffer: [ 9 | max_size: 10_000 10 | ] 11 | 12 | Spawns number of GenStage consumers on init. Default amount of the consumers is 5. 13 | This amount can be changed in config file: 14 | 15 | config :express, 16 | buffer: [ 17 | consumers_count: 10 18 | ] 19 | """ 20 | 21 | use GenStage 22 | 23 | alias Express.Configuration 24 | alias Express.PushRequests.{PushRequest, ConsumersSupervisor} 25 | 26 | def start_link do 27 | GenStage.start_link(__MODULE__, :ok, name: __MODULE__) 28 | end 29 | 30 | def init(:ok) do 31 | send(self(), :init) 32 | 33 | {:producer, [], buffer_size: (Configuration.Buffer.max_size() || 5000)} 34 | end 35 | 36 | def handle_info(:init, state) do 37 | consumers_count = Configuration.Buffer.consumers_count() || 5 38 | 39 | Enum.each(1..consumers_count, fn(_) -> 40 | ConsumersSupervisor.start_consumer() 41 | end) 42 | 43 | Process.send_after(self(), :ping, 1000) 44 | 45 | {:noreply, [], state} 46 | end 47 | def handle_info(:ping, state) do 48 | Process.send_after(self(), :ping, 1000) 49 | GenServer.cast(__MODULE__, {:add, nil}) 50 | 51 | {:noreply, [], state} 52 | end 53 | def handle_info(_, state), do: {:noreply, [], state} 54 | 55 | @doc "Adds a push request to the buffer." 56 | @spec add(PushRequest.t) :: :ok | {:error, any()} 57 | def add(push_request) do 58 | GenServer.call(__MODULE__, {:add, push_request}, 100) 59 | end 60 | 61 | def handle_call({:add, push_request}, _from, state) do 62 | {:reply, :ok, [push_request], state} 63 | end 64 | 65 | def handle_cast({:add, push_request}, state) do 66 | {:noreply, [push_request], state} 67 | end 68 | 69 | def handle_demand(_, state), do: {:noreply, [], state} 70 | end 71 | -------------------------------------------------------------------------------- /lib/express/push_requests/consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.PushRequests.Consumer do 2 | @moduledoc """ 3 | GenStage consumer. 4 | Retrieves push requests from `Express.PushRequests.Buffer`, 5 | gets a proper worker from one of poolboy pools and sends a push message via it. 6 | 7 | Maximum demand (size of a bunch) depends on the number of schedulers available 8 | for BEAM VM on the current machine: `System.schedulers_online() * 5` 9 | 10 | The default multiplier (5) can be adjusted via config: 11 | 12 | config :express, 13 | buffer: [ 14 | consumer_demand_multiplier: 10 15 | ] 16 | """ 17 | 18 | use GenStage 19 | 20 | alias Express.APNS.PushMessage, as: APNSPushMessage 21 | alias Express.FCM.PushMessage, as: FCMPushMessage 22 | alias Express.APNS.Worker, as: APNSWorker 23 | alias Express.FCM.Worker, as: FCMWorker 24 | alias Express.Operations.PoolboyConfigs 25 | alias Express.PushRequests.PushRequest 26 | 27 | require Logger 28 | 29 | defmodule State do 30 | @moduledoc "Represents state structure of `Express.PushRequests.Consumer`" 31 | 32 | @type t :: %__MODULE__{producer: pid() | atom(), 33 | subscription: pid() | atom()} 34 | 35 | defstruct ~w(producer subscription)a 36 | end 37 | 38 | def start_link, do: start_link([]) 39 | def start_link(_), do: GenStage.start_link(__MODULE__, :ok) 40 | 41 | def init(:ok) do 42 | state = %State{producer: Express.PushRequests.Buffer} 43 | Process.flag(:trap_exit, true) 44 | GenStage.async_subscribe(self(), to: state.producer, cancel: :temporary) 45 | 46 | {:consumer, state} 47 | end 48 | 49 | def handle_info(_, state), do: {:stop, :normal, state} 50 | 51 | def handle_subscribe(:producer, _opts, from, state) do 52 | state = Map.put(state, :subscription, from) 53 | 54 | {:automatic, state} 55 | end 56 | 57 | def handle_events(push_requests, _from, state) do 58 | handle_push_requests(push_requests, state) 59 | 60 | {:noreply, [], state} 61 | end 62 | 63 | def terminate(_reason, _state), do: :normal 64 | 65 | @spec handle_push_requests([PushRequest.t], State.t) :: :ok | 66 | :noconnect | 67 | :nosuspend 68 | defp handle_push_requests([nil], _state), do: :nothing 69 | defp handle_push_requests(push_requests, state) 70 | when is_list(push_requests) and length(push_requests) > 0 do 71 | results = 72 | Express.TasksSupervisor 73 | |> Task.Supervisor.async_stream_nolink(push_requests, fn(pr) -> 74 | do_push(pr, state) 75 | end 76 | ) 77 | |> Enum.into([]) 78 | 79 | errored_push_requests = 80 | results 81 | |> Enum.filter(fn({_, v}) -> is_tuple(v) && elem(v, 0) == :error end) 82 | |> Enum.map(fn 83 | {_, {:error, %PushRequest{} = push_request}} -> push_request 84 | {_, {:error, _}} -> nil 85 | end) 86 | |> Enum.reject(&(is_nil(&1))) 87 | 88 | if Enum.any?(errored_push_requests) do 89 | handle_push_requests(errored_push_requests, state) 90 | end 91 | end 92 | defp handle_push_requests(_push_requests, _state), do: :nothing 93 | 94 | @spec do_push(PushRequest.t, State.t) :: any() 95 | defp do_push(%{push_message: %APNSPushMessage{} = push_message, 96 | opts: opts, callback_fun: callback_fun} = push_request, _state) do 97 | :poolboy.transaction(PoolboyConfigs.apns_workers().name, fn(worker) -> 98 | case APNSWorker.push(worker, push_message, opts, callback_fun) do 99 | {:error, _reason} -> {:error, push_request} 100 | _ -> :pushed 101 | end 102 | end) 103 | end 104 | defp do_push(%{push_message: %FCMPushMessage{} = push_message, 105 | opts: opts, callback_fun: callback_fun}, _state) do 106 | :poolboy.transaction(PoolboyConfigs.fcm_workers().name, fn(worker) -> 107 | FCMWorker.push(worker, push_message, opts, callback_fun) 108 | end) 109 | end 110 | defp do_push(_push_request, _state), do: {:error, :unknown_push_message_type} 111 | end 112 | -------------------------------------------------------------------------------- /lib/express/push_requests/consumers_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.PushRequests.ConsumersSupervisor do 2 | @moduledoc """ 3 | Dynamically spawns and supervises consumers for the push requests buffer. 4 | """ 5 | 6 | use Supervisor 7 | 8 | def start_link do 9 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 10 | end 11 | 12 | def init(:ok), do: supervise(children(), opts()) 13 | 14 | defp children do 15 | [ 16 | worker( 17 | Express.PushRequests.Consumer, 18 | [], 19 | restart: :permanent 20 | ) 21 | ] 22 | end 23 | 24 | defp opts do 25 | [strategy: :simple_one_for_one, name: __MODULE__] 26 | end 27 | 28 | @doc "Spawns the buffer consumer." 29 | @spec start_consumer() :: Supervisor.on_start_child 30 | def start_consumer do 31 | Supervisor.start_child(__MODULE__, []) 32 | end 33 | 34 | @doc "Checks wether any consumer is present." 35 | @spec any_consumer?() :: pos_integer() 36 | def any_consumer?, do: consumers_count() > 0 37 | 38 | @doc "Returns consumers (childrens) count." 39 | @spec consumers_count() :: pos_integer() 40 | def consumers_count do 41 | %{active: count} = Supervisor.count_children(__MODULE__) 42 | count 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/express/push_requests/push_request.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.PushRequests.PushRequest do 2 | @moduledoc "Defines structure for a push request. Push requests are stored in the buffer." 3 | 4 | alias Express.APNS.PushMessage, as: APNSPushMessage 5 | alias Express.FCM.PushMessage, as: FCMPushMessage 6 | 7 | @type t :: %__MODULE__{push_message: APNSPushMessage.t | FCMPushMessage.t, 8 | opts: Keyword.t, 9 | callback_fun: Express.callback_fun} 10 | 11 | defstruct ~w(push_message opts callback_fun)a 12 | end 13 | -------------------------------------------------------------------------------- /lib/express/push_requests/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.PushRequests.Supervisor do 2 | @moduledoc """ 3 | Push requests supervisor. Responsible for the buffer, consumers and the buffer adders processes. 4 | """ 5 | 6 | use Supervisor 7 | 8 | alias Express.Operations.PoolboyConfigs 9 | 10 | def start_link do 11 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 12 | end 13 | 14 | def init(:ok), do: supervise(children(), opts()) 15 | 16 | defp children do 17 | [ 18 | supervisor( 19 | Express.PushRequests.ConsumersSupervisor, 20 | [], 21 | restart: :permanent 22 | ), 23 | worker( 24 | Express.PushRequests.Buffer, 25 | [], 26 | restart: :permanent, 27 | name: Express.PushRequests.Buffer 28 | ), 29 | :poolboy.child_spec( 30 | PoolboyConfigs.buffer_adders().name, 31 | PoolboyConfigs.buffer_adders().config, 32 | [] 33 | ) 34 | ] 35 | end 36 | 37 | defp opts do 38 | [ 39 | strategy: :one_for_one, 40 | name: __MODULE__ 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/express/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Express.Supervisor do 2 | @moduledoc "Sets up Express's supervision tree." 3 | 4 | use Supervisor 5 | 6 | def start_link do 7 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 8 | end 9 | 10 | def init(:ok), do: supervise(children(), opts()) 11 | 12 | defp children do 13 | [ 14 | supervisor(Express.APNS.Supervisor, [], restart: :permanent), 15 | supervisor(Express.FCM.Supervisor, [], restart: :permanent), 16 | supervisor(Express.PushRequests.Supervisor, [], restart: :permanent), 17 | supervisor( 18 | Task.Supervisor, 19 | [[name: Express.TasksSupervisor, restart: :temporary]], 20 | restart: :permanent 21 | ) 22 | ] 23 | end 24 | 25 | defp opts do 26 | [strategy: :one_for_one, name: __MODULE__] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Express.Mixfile do 2 | use Mix.Project 3 | 4 | @description """ 5 | Library for sending push notifications. 6 | Supports Apple APNS (with either ssl certificate or JWT) and Google FCM services. 7 | """ 8 | 9 | def project do 10 | [ 11 | app: :express, 12 | version: "1.3.3", 13 | elixir: "~> 1.4", 14 | name: "Express", 15 | description: @description, 16 | package: package(), 17 | deps: deps(), 18 | source_url: "https://github.com/madeinussr/express", 19 | docs: [extras: ["README.md"]], 20 | build_embedded: Mix.env == :prod, 21 | start_permanent: Mix.env == :prod 22 | ] 23 | end 24 | 25 | def application do 26 | [extra_applications: [:logger, :poolboy], 27 | mod: {Express.Application, []}] 28 | end 29 | 30 | defp deps do 31 | [ 32 | {:poison, "~> 3.1"}, 33 | {:chatterbox, "~> 0.5"}, 34 | {:poolboy, "~> 1.5"}, 35 | {:httpoison, "~> 0.12"}, 36 | {:exop, "~> 0.4.6"}, 37 | {:mock, "~> 0.2.0", only: :test}, 38 | {:credo, "~> 0.8", only: [:dev, :test], runtime: false}, 39 | {:ex_doc, "~> 0.16", only: [:dev, :test, :docs]}, 40 | {:timex, "~> 3.1"}, 41 | {:joken, "~> 1.4"}, 42 | {:gen_stage, "~> 0.12"} 43 | ] 44 | end 45 | 46 | defp package do 47 | [ 48 | files: ["lib", "mix.exs", "README.md", "LICENSE"], 49 | maintainers: ["Andrey Chernykh"], 50 | licenses: ["MIT"], 51 | links: %{"Github" => "https://github.com/madeinussr/express"} 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [], [], "hexpm"}, 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "certifi": {:hex, :certifi, "1.2.1", "c3904f192bd5284e5b13f20db3ceac9626e14eeacfbb492e19583cf0e37b22be", [:rebar3], [], "hexpm"}, 4 | "chatterbox": {:hex, :chatterbox, "0.5.0", "69f5a1f36f905472b7662a301e67309dd3cae7d0ca1b2e52c14d43ee7df4c3a3", [:rebar3], [{:hpack, "~>0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}, {:lager, "~>3.2.4", [hex: :lager, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [], [], "hexpm"}, 6 | "cowlib": {:hex, :cowlib, "1.3.0", "6c80ca7f2863c0d7a21c946312baf473432b56a79e46e20bc27a3bac07c40439", [], [], "hexpm"}, 7 | "credo": {:hex, :credo, "0.8.4", "4e50acac058cf6292d6066e5b0d03da5e1483702e1ccde39abba385c9f03ead4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "earmark": {:hex, :earmark, "1.2.2", "f718159d6b65068e8daeef709ccddae5f7fdc770707d82e7d126f584cd925b74", [:mix], [], "hexpm"}, 9 | "ex_doc": {:hex, :ex_doc, "0.16.2", "3b3e210ebcd85a7c76b4e73f85c5640c011d2a0b2f06dcdf5acdb2ae904e5084", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "exop": {:hex, :exop, "0.4.6", "3ad2c07cb9816e864c76d9d659c779807c2b852b2b9ccf99a78fd0891a5c72af", [:mix], [], "hexpm"}, 11 | "gen_stage": {:hex, :gen_stage, "0.12.2", "e0e347cbb1ceb5f4e68a526aec4d64b54ad721f0a8b30aa9d28e0ad749419cbb", [], [], "hexpm"}, 12 | "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [], [], "hexpm"}, 13 | "goldrush": {:hex, :goldrush, "0.1.9", "f06e5d5f1277da5c413e84d5a2924174182fb108dabb39d5ec548b27424cd106", [:rebar3], [], "hexpm"}, 14 | "gun": {:hex, :gun, "1.0.0-pre.2", "9c7261a32aed26d6eec69ef7da15ed2f929c10460d5dede805bc5c2436a69ce0", [], [{:cowlib, "1.3.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "hackney": {:hex, :hackney, "1.8.6", "21a725db3569b3fb11a6af17d5c5f654052ce9624219f1317e8639183de4a423", [:rebar3], [{:certifi, "1.2.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.0.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm"}, 17 | "httpoison": {:hex, :httpoison, "0.12.0", "8fc3d791c5afe6beb0093680c667dd4ce712a49d89c38c3fe1a43100dd76cf90", [:mix], [{:hackney, "~> 1.8.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "idna": {:hex, :idna, "5.0.2", "ac203208ada855d95dc591a764b6e87259cb0e2a364218f215ad662daa8cd6b4", [:rebar3], [{:unicode_util_compat, "0.2.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "joken": {:hex, :joken, "1.5.0", "42a0953e80bd933fc98a0874e156771f78bf0e92abe6c3a9c22feb6da28efb0b", [], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 20 | "jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "kadabra": {:hex, :kadabra, "0.3.2", "d7afbcfac044ceffdcfa7ffdef5c41a5c8c984cfacb1b1bb62a8011c280d89b2", [], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}, {:scribe, "~> 0.4", [hex: :scribe, repo: "hexpm", optional: true]}], "hexpm"}, 22 | "lager": {:hex, :lager, "3.2.4", "a6deb74dae7927f46bd13255268308ef03eb206ec784a94eaf7c1c0f3b811615", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm"}, 23 | "meck": {:hex, :meck, "0.8.7", "ebad16ca23f685b07aed3bc011efff65fbaf28881a8adf925428ef5472d390ee", [:rebar3], [], "hexpm"}, 24 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 25 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 26 | "mock": {:hex, :mock, "0.2.1", "bfdba786903e77f9c18772dee472d020ceb8ef000783e737725a4c8f54ad28ec", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 27 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 28 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 29 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [], [], "hexpm"}, 30 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 31 | "timex": {:hex, :timex, "3.1.24", "d198ae9783ac807721cca0c5535384ebdf99da4976be8cefb9665a9262a1e9e3", [], [{:combine, "~> 0.7", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 32 | "tzdata": {:hex, :tzdata, "0.5.12", "1c17b68692c6ba5b6ab15db3d64cc8baa0f182043d5ae9d4b6d35d70af76f67b", [], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 33 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.2.0", "dbbccf6781821b1c0701845eaf966c9b6d83d7c3bfc65ca2b78b88b8678bfa35", [:rebar3], [], "hexpm"}} 34 | -------------------------------------------------------------------------------- /test/apns/delayed_pushes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule APNS.DelayedPushesTest do 2 | @moduledoc false 3 | 4 | alias Express.APNS.DelayedPushes 5 | alias Express.APNS.PushMessage 6 | 7 | use ExUnit.Case, async: false 8 | 9 | setup do 10 | {:ok, sup_pid} = DelayedPushes.start_link() 11 | 12 | {:ok, %{sup_pid: sup_pid}} 13 | end 14 | 15 | test "add/3: spawns a buffer adder", %{sup_pid: sup_pid} do 16 | DelayedPushes.add(%PushMessage{}, [delay: 3], fn(_, _) -> :ok end) 17 | DelayedPushes.add(%PushMessage{}, [delay: 3], fn(_, _) -> :ok end) 18 | 19 | assert %{active: 2} = Supervisor.count_children(sup_pid) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/apns/jwt_holder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule APNS.JWTHolderTest do 2 | @moduledoc false 3 | 4 | alias Express.APNS.JWTHolder 5 | 6 | import Joken 7 | import Mock 8 | 9 | use ExUnit.Case, async: false 10 | 11 | setup do 12 | {:ok, _} = JWTHolder.start_link() 13 | jwt = JWTHolder.get_jwt() 14 | 15 | {:ok, %{jwt: jwt}} 16 | end 17 | 18 | test "creates a new valid jwt after a start", %{jwt: jwt} do 19 | jwt = 20 | jwt 21 | |> token() 22 | |> with_signer(es256(JWTHolder.apns_auth_key())) 23 | |> verify() 24 | 25 | assert (jwt.claims |> Map.keys() |> Enum.any?()) 26 | end 27 | 28 | test "returns the same jwt if it is not expired", %{jwt: jwt} do 29 | assert jwt == JWTHolder.get_jwt() 30 | end 31 | 32 | test "creates a new valid jwt if it is expired", %{jwt: jwt} do 33 | now = Timex.now() 34 | 35 | with_mock Timex, [ 36 | now: fn() -> now end, 37 | to_unix: fn(_) -> 999_999_999 end, 38 | diff: fn(_, _, _) -> 999_999_999 end, 39 | ] 40 | do 41 | assert jwt != JWTHolder.get_jwt() 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/apns/ssl_config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule APNS.SSLConfigTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | alias Express.APNS.SSLConfig 7 | 8 | setup do 9 | file_path = Path.expand("test/fixtures/file") 10 | cert_file_path = Path.expand("test/fixtures/test_apns_cert.pem") 11 | key_file_path = Path.expand("test/fixtures/test_apns_key.pem") 12 | {:ok, %{file_path: file_path, cert_file_path: cert_file_path, key_file_path: key_file_path}} 13 | end 14 | 15 | test "read_file/1: finds a file and reads the file's content if the file exists", %{file_path: file_path} do 16 | content = SSLConfig.read_file(file_path) 17 | assert String.trim(content) == "this is the file content" 18 | end 19 | 20 | test "read_file/1: returns nil unless a file exists", %{file_path: file_path} do 21 | refute SSLConfig.read_file(file_path <> "oops") 22 | end 23 | 24 | test "decode_content/2: returns nil unless decoded file type neither :cert nor :key" do 25 | refute SSLConfig.decode_content("file", :unknown) 26 | end 27 | 28 | test "decode_content/2: returns nil for a file's content that not is pem" do 29 | result = "not pem cert content" |> SSLConfig.decode_content(:cert) 30 | refute result 31 | end 32 | 33 | test "decode_content/2: returns pem for a file's content that is pem", %{cert_file_path: cert_file_path} do 34 | result = 35 | cert_file_path 36 | |> SSLConfig.read_file 37 | |> SSLConfig.decode_content(:cert) 38 | 39 | assert result 40 | end 41 | 42 | test "decode_content/2: returns nil for a file's content that not is key" do 43 | result = "not key content" |> SSLConfig.decode_content(:key) 44 | refute result 45 | end 46 | 47 | test "decode_content/2: returns key for a file's content that is key", %{key_file_path: key_file_path} do 48 | result = 49 | key_file_path 50 | |> SSLConfig.read_file 51 | |> SSLConfig.decode_content(:key) 52 | 53 | assert result 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/configuration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConfigurationTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | alias Express.Configuration 7 | 8 | test "gets configuration from config module" do 9 | assert Configuration.Buffer.consumers_count() == Express.Configuration.Test.buffer()[:consumers_count] 10 | end 11 | 12 | test "configuration from config file has a priority over config module" do 13 | assert Configuration.Buffer.max_size() != Express.Configuration.Test.buffer()[:max_size] 14 | assert Configuration.Buffer.max_size() == Application.get_env(:express, :buffer)[:max_size] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fcm/delayed_pushes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FCM.DelayedPushesTest do 2 | @moduledoc false 3 | 4 | alias Express.FCM.DelayedPushes 5 | alias Express.FCM.PushMessage 6 | 7 | use ExUnit.Case, async: false 8 | 9 | setup do 10 | {:ok, sup_pid} = DelayedPushes.start_link() 11 | 12 | {:ok, %{sup_pid: sup_pid}} 13 | end 14 | 15 | test "add/3: spawns a buffer adder", %{sup_pid: sup_pid} do 16 | DelayedPushes.add(%PushMessage{}, [delay: 3], fn(_, _) -> :ok end) 17 | DelayedPushes.add(%PushMessage{}, [delay: 3], fn(_, _) -> :ok end) 18 | 19 | assert %{active: 2} = Supervisor.count_children(sup_pid) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/fixtures/file: -------------------------------------------------------------------------------- 1 | this is the file content 2 | -------------------------------------------------------------------------------- /test/fixtures/test_apns_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | ///////////////////////CERT///////////////////////////////////// 3 | -----END CERTIFICATE----- 4 | -------------------------------------------------------------------------------- /test/fixtures/test_apns_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | ////////////////////////KEY///////////////////////////////////// 3 | -----END RSA PRIVATE KEY----- 4 | -------------------------------------------------------------------------------- /test/fixtures/test_auth_key.p8: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgbvOY8SMlmX3ZDZw2 3 | OwAckxupZqmPbjjaSm3X3rSfgcegCgYIKoZIzj0DAQehRANCAARnI7zwJM7Xbvxc 4 | JjtjEx54fVeYeiHJxETwLi9DntXKWbMfdGNFEYMtwTwiNQvhDxM8dxZ7vXkC5DDA 5 | DOtrV+ya 6 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /test/helpers/map_helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Helpers.MapHelperTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | alias Express.Helpers.MapHelper 7 | 8 | test "reduce_keys/2: changes map keys with function" do 9 | map = %{"2" => 1, "3" => 2, "4" => %{"5" => 4}} 10 | 11 | assert %{"1" => 1, "2" => 2, "3" => %{"5" => 4}} == MapHelper.reduce_keys(map, fn(key) -> 12 | {i, _} = Integer.parse(key) 13 | Integer.to_string(i - 1) 14 | end) 15 | end 16 | 17 | test "deep_reduce_keys/2: deeply changes map keys with function" do 18 | map = %{"2" => 1, "3" => 2, "4" => %{"5" => 4}} 19 | 20 | assert %{"1" => 1, "2" => 2, "3" => %{"4" => 4}} == MapHelper.deep_reduce_keys(map, fn(key) -> 21 | {i, _} = Integer.parse(key) 22 | Integer.to_string(i - 1) 23 | end) 24 | end 25 | 26 | test "stringify_keys/2: stringifies map keys" do 27 | map = %{a: 1, b: 2, c: %{d: 4}} 28 | 29 | assert %{"a" => 1, "b" => 2, "c" => %{d: 4}} == MapHelper.stringify_keys(map) 30 | end 31 | 32 | test "deep_stringify_keys/2: deeply stringifies map keys" do 33 | map = %{a: 1, b: 2, c: %{d: 4}} 34 | 35 | assert %{"a" => 1, "b" => 2, "c" => %{"d" => 4}} == MapHelper.deep_stringify_keys(map) 36 | end 37 | 38 | test "dasherize_keys/2: dasherizes map keys" do 39 | map = %{"a_a" => 1, "b_b" => 2, "c_c" => %{"d_d" => 4}} 40 | 41 | assert %{"a-a" => 1, "b-b" => 2, "c-c" => %{"d_d" => 4}} == MapHelper.dasherize_keys(map) 42 | end 43 | 44 | test "deep_dasherize_keys/2: deeply dasherizes map keys" do 45 | map = %{"a_a" => 1, "b_b" => 2, "c_c" => %{"d_d" => 4}} 46 | 47 | assert %{"a-a" => 1, "b-b" => 2, "c-c" => %{"d-d" => 4}} == MapHelper.deep_dasherize_keys(map) 48 | end 49 | 50 | test "underscorize_keys/2: underscorizes map keys" do 51 | map = %{"a-a" => 1, "b-b" => 2, "c-c" => %{"d-d" => 4}} 52 | 53 | assert %{"a_a" => 1, "b_b" => 2, "c_c" => %{"d-d" => 4}} == MapHelper.underscorize_keys(map) 54 | end 55 | 56 | test "deep_underscorize_keys/2: deeply underscorizes map keys" do 57 | map = %{"a-a" => 1, "b-b" => 2, "c-c" => %{"d-d" => 4}} 58 | 59 | assert %{"a_a" => 1, "b_b" => 2, "c_c" => %{"d_d" => 4}} == MapHelper.deep_underscorize_keys(map) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/operations/apns/push_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Operations.APNS.PushTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | import Mock 7 | 8 | alias Express.Operations.APNS.Push 9 | alias Express.APNS.{PushMessage, SSLConfig} 10 | alias Express.Network.HTTP2 11 | 12 | describe "with ssl config" do 13 | setup do 14 | token = "device_token" 15 | alert = %PushMessage.Alert{title: "Title", body: "Body"} 16 | aps = %PushMessage.Aps{alert: alert} 17 | push_message = %PushMessage{token: token, aps: aps, acme: %{}} 18 | ssl_config = SSLConfig.new() 19 | connection = 20 | %HTTP2.Connection{client: nil, 21 | provider: :apns, 22 | socket: nil, 23 | ssl_config: ssl_config} 24 | 25 | {:ok, connection: connection, push_message: push_message} 26 | end 27 | 28 | test "sends a request to apns with proper headers", 29 | %{connection: connection, push_message: push_message} do 30 | with_mock HTTP2, [send_request: fn(_connection, headers, _jwt) -> headers end] do 31 | {:ok, json} = 32 | push_message 33 | |> PushMessage.to_apns_map() 34 | |> Poison.encode() 35 | 36 | headers = [ 37 | {":method", "POST"}, 38 | {":path", "/3/device/#{push_message.token}"}, 39 | {"content-length", "#{byte_size(json)}"} 40 | ] 41 | 42 | headers = 43 | if push_message.topic do 44 | headers ++ [{"apns-topic", push_message.topic}] 45 | else 46 | headers 47 | end 48 | 49 | assert headers == Push.run!(connection: connection, push_message: push_message) 50 | 51 | assert called HTTP2.send_request(connection, headers, json) 52 | end 53 | end 54 | end 55 | 56 | describe "with jwt" do 57 | setup do 58 | token = "device_token" 59 | alert = %PushMessage.Alert{title: "Title", body: "Body"} 60 | aps = %PushMessage.Aps{alert: alert} 61 | push_message = %PushMessage{token: token, aps: aps, acme: %{}} 62 | jwt = "this_is_jwt" 63 | 64 | connection = 65 | %HTTP2.Connection{client: nil, 66 | provider: :apns, 67 | socket: nil} 68 | 69 | {:ok, connection: connection, push_message: push_message, jwt: jwt} 70 | end 71 | 72 | test "sends a request to apns with jwt within authorization header", 73 | %{connection: connection, push_message: push_message, jwt: jwt} do 74 | with_mock HTTP2, [send_request: fn(_connection, headers, _jwt) -> headers end] do 75 | {:ok, json} = 76 | push_message 77 | |> PushMessage.to_apns_map() 78 | |> Poison.encode() 79 | 80 | headers = [ 81 | {":method", "POST"}, 82 | {":path", "/3/device/#{push_message.token}"}, 83 | {"content-length", "#{byte_size(json)}"}, 84 | {"authorization", "bearer #{jwt}"} 85 | ] 86 | 87 | headers = 88 | if push_message.topic do 89 | headers ++ [{"apns-topic", push_message.topic}] 90 | else 91 | headers 92 | end 93 | 94 | assert headers == Push.run!(connection: connection, push_message: push_message, jwt: jwt) 95 | 96 | assert called HTTP2.send_request(connection, headers, json) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/operations/establish_http2_connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Operations.EstablishHTTP2ConnectionTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: false 5 | 6 | import Mock 7 | 8 | alias Express.Network.HTTP2 9 | alias Express.Network.HTTP2.Connection 10 | alias Express.Operations.EstablishHTTP2Connection 11 | alias Express.Network.HTTP2.{ChatterboxClient, SSLConfig} 12 | 13 | describe "when ssl config was provided" do 14 | test "opens a connection" do 15 | with_mock HTTP2, [connect: fn(_client, _provider, _ssl_config) -> {:ok, %Connection{}} end] do 16 | params = [http2_client: ChatterboxClient, ssl_config: %SSLConfig{}] 17 | assert {:ok, %Connection{} = _conn} = EstablishHTTP2Connection.run(params) 18 | end 19 | end 20 | 21 | test "returns an error on timeout" do 22 | with_mock HTTP2, [connect: fn(_client, _provider, _ssl_config) -> {:error, :open_socket, :timeout} end] do 23 | params = [http2_client: ChatterboxClient, ssl_config: %SSLConfig{}] 24 | assert {:error, :timeout} = EstablishHTTP2Connection.run(params) 25 | end 26 | end 27 | 28 | test "returns an error with bad ssl config" do 29 | with_mock HTTP2, [connect: fn(_client, _provider, _ssl_config) -> {:error, :ssl_config, "bad bad"} end] do 30 | params = [http2_client: ChatterboxClient, ssl_config: %SSLConfig{}] 31 | assert {:error, :invalid_ssl_config} = EstablishHTTP2Connection.run(params) 32 | end 33 | end 34 | 35 | test "returns an unhandled error for other connection result" do 36 | with_mock HTTP2, [connect: fn(_client, _provider, _ssl_config) -> "unknown result" end] do 37 | params = [http2_client: ChatterboxClient, ssl_config: %SSLConfig{}] 38 | assert {:error, :unhandled} = EstablishHTTP2Connection.run(params) 39 | end 40 | end 41 | end 42 | 43 | describe "without ssl config" do 44 | test "opens a connection" do 45 | with_mock HTTP2, [connect: fn(_client, _provider) -> {:ok, %Connection{}} end] do 46 | params = [http2_client: ChatterboxClient] 47 | assert {:ok, %Connection{} = _conn} = EstablishHTTP2Connection.run(params) 48 | end 49 | end 50 | 51 | test "returns an error on timeout" do 52 | with_mock HTTP2, [connect: fn(_client, _provider) -> {:error, :open_socket, :timeout} end] do 53 | params = [http2_client: ChatterboxClient] 54 | assert {:error, :timeout} = EstablishHTTP2Connection.run(params) 55 | end 56 | end 57 | 58 | test "returns an unhandled error for other connection result" do 59 | with_mock HTTP2, [connect: fn(_client, _provider) -> "unknown result" end] do 60 | params = [http2_client: ChatterboxClient] 61 | assert {:error, :unhandled} = EstablishHTTP2Connection.run(params) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/operations/fcm/push_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Operations.FCM.PushTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | import Mock 7 | 8 | alias Express.Operations.FCM.Push 9 | alias Express.FCM.PushMessage 10 | 11 | @response_200 {:ok, %HTTPoison.Response{ 12 | status_code: 200, 13 | body: ~S({"multicast_id":123456,"results":[{"message_id":"messageid"}]}) 14 | }} 15 | @response_200_error {:ok, %HTTPoison.Response{ 16 | status_code: 200, 17 | body: ~S({"multicast_id":123456,"results":[{"error":"oops","message_id":"messageid"}, {"error":"oops2","message_id":"messageid"}]}) 18 | }} 19 | @response_401_error {:ok, %HTTPoison.Response{ 20 | status_code: 401, 21 | body: ~S({"results":[{"error":"oops"}, {"error":"oops2"}]}) 22 | }} 23 | @response_error {:ok, %HTTPoison.Response{ 24 | status_code: 404, 25 | body: ~S({"results":[{"error":"oops"}, {"error":"oops2"}]}) 26 | }} 27 | @error {:error, :unhappy} 28 | 29 | setup do 30 | registration_id = "your_registration_id" 31 | notification = %PushMessage.Notification{title: "Title", body: "Body"} 32 | push_message = %PushMessage{ 33 | registration_ids: [registration_id], 34 | notification: notification, 35 | data: %{} 36 | } 37 | 38 | {:ok, push_message: push_message} 39 | end 40 | 41 | describe "with callback function provided" do 42 | test "invokes callback function on 200 ok", %{push_message: push_message} do 43 | with_mock HTTPoison, [post: fn(_, _, _) -> @response_200 end] do 44 | Push.run( 45 | push_message: push_message, 46 | opts: [], 47 | callback_fun: fn (_, result) -> 48 | assert result == {:ok, %{id: %{multicast_id: 123456, message_id: "messageid"}, body: "{\"multicast_id\":123456,\"results\":[{\"message_id\":\"messageid\"}]}", status: 200}} 49 | end 50 | ) 51 | end 52 | end 53 | 54 | test "invokes callback function on 200 error", 55 | %{push_message: push_message} do 56 | with_mock HTTPoison, [post: fn(_, _, _) -> @response_200_error end] do 57 | Push.run( 58 | push_message: push_message, 59 | opts: [], 60 | callback_fun: fn (_, result) -> 61 | assert result == {:error, %{id: %{multicast_id: 123456, message_id: "messageid"}, status: 200, body: "{\"multicast_id\":123456,\"results\":[{\"error\":\"oops\",\"message_id\":\"messageid\"}, {\"error\":\"oops2\",\"message_id\":\"messageid\"}]}"}} 62 | end 63 | ) 64 | end 65 | end 66 | 67 | test "invokes callback function on 401 error", 68 | %{push_message: push_message} do 69 | with_mock HTTPoison, [post: fn(_, _, _) -> @response_401_error end] do 70 | Push.run( 71 | push_message: push_message, 72 | opts: [], 73 | callback_fun: fn (_, result) -> 74 | assert result == {:error, %{id: nil, status: 401, body: "{\"results\":[{\"error\":\"oops\"}, {\"error\":\"oops2\"}]}"}} 75 | end 76 | ) 77 | end 78 | end 79 | 80 | test "invokes callback function on error response", 81 | %{push_message: push_message} do 82 | with_mock HTTPoison, [post: fn(_, _, _) -> @response_error end] do 83 | Push.run( 84 | push_message: push_message, 85 | opts: [], 86 | callback_fun: fn (_, result) -> 87 | assert result == {:error, %{id: nil, status: 404, body: "{\"results\":[{\"error\":\"oops\"}, {\"error\":\"oops2\"}]}"}} 88 | end 89 | ) 90 | end 91 | end 92 | 93 | test "invokes callback function on HTTPoison error", 94 | %{push_message: push_message} do 95 | with_mock HTTPoison, [post: fn(_, _, _) -> @error end] do 96 | Push.run( 97 | push_message: push_message, 98 | opts: [], 99 | callback_fun: fn (_, result) -> 100 | assert result == {:error, {:http_error, :unhappy}} 101 | end 102 | ) 103 | end 104 | end 105 | end 106 | 107 | describe "with callback function was not provided" do 108 | test "just sends push message", 109 | %{push_message: push_message} do 110 | 111 | headers = [ 112 | {"Content-Type", "application/json"}, 113 | {"Authorization", "key=your_api_key"} 114 | ] 115 | 116 | payload = Poison.encode!(push_message) 117 | 118 | with_mock HTTPoison, [post: fn(_, _, _) -> @error end] do 119 | Push.run(push_message: push_message, opts: []) 120 | 121 | assert called HTTPoison.post("https://fcm.googleapis.com/fcm/send", 122 | payload, 123 | headers) 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/operations/poolboy_configs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Operations.PoolboyConfigsTest do 2 | @moduledoc false 3 | 4 | alias Express.Configuration 5 | alias Express.Operations.PoolboyConfigs 6 | 7 | use ExUnit.Case, async: true 8 | 9 | describe "default values" do 10 | test "fcm_workers/0" do 11 | default_config = 12 | %{ 13 | config: [ 14 | {:name, {:local, :fcm_workers_pool}}, 15 | {:worker_module, Express.FCM.Worker}, 16 | {:size, System.schedulers_online()} 17 | ], 18 | name: :fcm_workers_pool 19 | } 20 | 21 | assert default_config == PoolboyConfigs.fcm_workers() 22 | end 23 | 24 | test "apns_workers/0" do 25 | default_config = 26 | %{ 27 | config: [ 28 | {:name, {:local, :apns_workers_pool}}, 29 | {:worker_module, Express.APNS.Worker}, 30 | {:size, System.schedulers_online()} 31 | ], 32 | name: :apns_workers_pool 33 | } 34 | 35 | assert default_config == PoolboyConfigs.apns_workers() 36 | end 37 | end 38 | 39 | describe "values from config file" do 40 | test "buffer_adders/0" do 41 | from_config = Configuration.Buffer.adders_pool_config() 42 | 43 | assert from_config == PoolboyConfigs.buffer_adders().config 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/push_requests/buffer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PushRequests.BufferTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: false 5 | 6 | alias Express.PushRequests.Buffer 7 | alias Express.PushRequests.ConsumersSupervisor 8 | 9 | setup do 10 | {:ok, consumers_sup} = ConsumersSupervisor.start_link() 11 | {:ok, _buffer} = Buffer.start_link() 12 | :timer.sleep(100) # dirty hack - need to wait until supervisor starts all children 13 | 14 | {:ok, %{consumers_sup: consumers_sup}} 15 | end 16 | 17 | test "start_consumer/0: starts a consumer", %{consumers_sup: consumers_sup} do 18 | ConsumersSupervisor.start_consumer() 19 | assert %{active: 11} = Supervisor.count_children(consumers_sup) 20 | end 21 | 22 | test "any_consumer?/0: checks wether there is a consumer" do 23 | assert ConsumersSupervisor.any_consumer?() 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------