├── .dialyzer.ignore-warnings ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── docs ├── extending.md ├── getting-started.md ├── main.md └── usage.md ├── lib ├── cartel.ex └── cartel │ ├── dealer.ex │ ├── http.ex │ ├── http │ ├── request.ex │ └── response.ex │ ├── message.ex │ ├── message │ ├── apns.ex │ ├── gcm.ex │ └── wns.ex │ ├── pusher.ex │ ├── pusher │ ├── apns.ex │ ├── gcm.ex │ └── wns.ex │ └── supervisor.ex ├── mix.exs ├── mix.lock └── test ├── cartel_test.exs └── test_helper.exs /.dialyzer.ignore-warnings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 7 | strategy: 8 | matrix: 9 | otp: ['21', '22', '23'] 10 | elixir: ['1.8', '1.9', '1.10', '1.11'] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: erlef/setup-beam@v1 14 | with: 15 | otp-version: ${{matrix.otp}} 16 | elixir-version: ${{matrix.elixir}} 17 | - uses: actions/cache@v1 18 | env: 19 | cache-name: mix 20 | with: 21 | path: ~/.mix 22 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.otp }}-${{ matrix.elixir }} 23 | restore-keys: | 24 | ${{ runner.os }}-${{ env.cache-name }}- 25 | - uses: actions/cache@v1 26 | env: 27 | cache-name: build 28 | with: 29 | path: _build 30 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.otp }}-${{ matrix.elixir }} 31 | restore-keys: | 32 | ${{ runner.os }}-${{ env.cache-name }}- 33 | - run: mix deps.get 34 | - run: mix credo --strict --all 35 | - run: mix dialyzer 36 | - run: mix test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ez 2 | /_build 3 | /cover 4 | /deps 5 | /doc 6 | /log 7 | /rel 8 | erl_crash.dump 9 | /.elixir_ls -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Luca Corti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cartel 2 | 3 | **Multi platform, multi app push notifications** 4 | 5 | Get the package from hex.pm at https://hex.pm/packages/cartel 6 | 7 | See the documentation at https://hexdocs.pm/cartel 8 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :cartel, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:cartel, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :debug 4 | 5 | config :cartel, dealers: %{} 6 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :debug 4 | 5 | config :cartel, dealers: %{} 6 | -------------------------------------------------------------------------------- /docs/extending.md: -------------------------------------------------------------------------------- 1 | # Extending 2 | 3 | ## Implementation 4 | 5 | You can easily add unsupported push technologies to your application without 6 | directly modifying **Cartel**. 7 | 8 | ### Message 9 | 10 | Define a struct and implement the `Cartel.Message` protocol for it. 11 | 12 | ```elixir 13 | defmodule MyMessage do 14 | ... 15 | @defstruct [ ... ] 16 | end 17 | 18 | defimpl Cartel.Message, for: MyMessage do 19 | ... 20 | end 21 | ``` 22 | 23 | ### Pusher 24 | 25 | You also need a matching pusher module adopting the `Cartel.Pusher` behaviour: 26 | 27 | ```elixir 28 | defmodule MyPusher do 29 | use Cartel.Pusher, message_module: MyMessage 30 | 31 | ... 32 | end 33 | ``` 34 | 35 | ## Configuration 36 | 37 | To configure your pusher in the **Cartel** configuration just add a pusher in 38 | your application pushers section. 39 | 40 | ```elixir 41 | config :cartel, dealers: %{ 42 | "myappid": %{ 43 | MyPusher => %{ 44 | opt1: "value1", 45 | opt2: 10, 46 | opt3: :test 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | All options in the config will passed to the `start_link/1` function of your 53 | pusher module. 54 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Installation 4 | 5 | Add cartel to your list of dependencies in `mix.exs`: 6 | 7 | ```elixir 8 | def deps do 9 | [{:cartel, "~> 0.0.0"}] 10 | end 11 | ``` 12 | 13 | Ensure cartel is started before your application: 14 | 15 | ```elixir 16 | def application do 17 | [applications: [:cartel]] 18 | end 19 | ``` 20 | 21 | ## Configuration 22 | 23 | Cartel supports preconfigured dealers as well as dynamically adding and removing 24 | dealers at runtime. 25 | 26 | ### Static configuration ### 27 | 28 | You can configure your mobile applications in `config.exs`: 29 | 30 | ```elixir 31 | config :cartel, dealers: %{ 32 | "app1": %{ 33 | Cartel.Pusher.Apns => %{ 34 | env: :sandbox, 35 | cert: "/path/to/app1-cert.pem", 36 | key: "/path/to/app1-key.pem", 37 | cacert: "/path/to/entrust_2048_ca.cer" 38 | }, 39 | Cartel.Pusher.Wns => %{ 40 | sid: "ms-app://wns-sid", 41 | secret: "wns-secret" 42 | } 43 | }, 44 | "app2": %{ 45 | Cartel.Pusher.Apns => %{ 46 | env: :production, 47 | cert: "/path/to/app2-crt.pem", 48 | key: "/path/to/app2-key.pem", 49 | cacert: "/path/to/entrust_2048_ca.cer" 50 | } 51 | }, 52 | "app3": %{ 53 | Cartel.Pusher.Gcm => %{ 54 | key: "gcm-key" 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | ### Dynamically adding and removing dealers ### 61 | 62 | If you wish you can dynamically add and remove dealers at runtime, to do so call 63 | `Cartel.Dealer.add/2` and `Cartel.Dealer.remove/1`: 64 | 65 | ```elixir 66 | Cartel.Dealer.add("app3", %{ 67 | Cartel.Pusher.Gcm => %{ 68 | key: "gcm-key" 69 | } 70 | }) 71 | 72 | ... 73 | 74 | Cartel.Dealer.remove("app3") 75 | ``` 76 | 77 | ### Pooling ### 78 | 79 | [poolboy](https://github.com/devinus/poolboy) is used to pool pusher processes. 80 | By default `poolboy` creates a pool of 5 workers. You can change pooling options 81 | per pusher by adding a `pool` key: 82 | 83 | ```elixir 84 | ... 85 | "app4": %{ 86 | Cartel.Pusher.Gcm => %{ 87 | key: "gcm-key", 88 | pool: [size: 10, max_overflow: 20] 89 | } 90 | } 91 | ... 92 | ``` 93 | 94 | Refer to the poolboy docs for more information. 95 | Please note that `name` and `worker_module` values, if present in the passed 96 | `Keyword` list, are silently ignored. 97 | -------------------------------------------------------------------------------- /docs/main.md: -------------------------------------------------------------------------------- 1 | # Cartel 2 | 3 | ## About 4 | 5 | **Cartel** is a multi platform, multi app push notifications OTP Application. 6 | 7 | The following pusher modules are distributed with **Cartel**: 8 | 9 | - `Apns`: Apple APNS Provider API (iOS), implemented for push 10 | - `Gcm`: Google GCM (Android), implemented for push 11 | - `Wns`: Microsoft WNS (Windows Phone), implemented for push 12 | 13 | See [Getting Started](getting-started.html) and [Usage](usage.html) for more 14 | information on how to use the package. 15 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Pusher API 4 | 5 | All the pusher types share a common interface for message sending. 6 | You can use `send/3` for both single recipient and bulk sending. 7 | 8 | ```elixir 9 | alias Cartel.Pusher., as: Pusher 10 | alias Cartel.Message., as: Message 11 | 12 | Pusher.send("appid", ) 13 | Pusher.send("appid", , ["devicetoken", "devicetoken"]) 14 | ``` 15 | 16 | When passing the token list, the device token in the message struct, if present, 17 | is ignored. 18 | 19 | Each pusher type uses a different message format, examples are provided below. 20 | 21 | 22 | ## Message Formats 23 | 24 | 25 | ### APNS 26 | 27 | `Cartel.Message.Apns`: 28 | 29 | ```elixir 30 | alias Cartel.Message.Apns, as: Message 31 | 32 | %Message{ 33 | token: "devicetoken", 34 | payload: %{aps: %{alert: "Hello"}} 35 | } 36 | ``` 37 | 38 | ### GCM 39 | 40 | `Cartel.Message.Gcm`: 41 | 42 | ```elixir 43 | alias Cartel.Message.Gcm, as: Message 44 | 45 | %Message{ 46 | to: "devicetoken", 47 | data: %{"message": "Hello"} 48 | } 49 | ``` 50 | 51 | ### WNS 52 | 53 | `Cartel.Message.Wns`: 54 | 55 | ```elixir 56 | alias Cartel.Message.Wns, as: Message 57 | 58 | %Message{ 59 | channel: "channeluri", 60 | payload: "..." 61 | } 62 | ``` -------------------------------------------------------------------------------- /lib/cartel.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel do 2 | @moduledoc """ 3 | Cartel OTP Application 4 | """ 5 | use Application 6 | 7 | alias Cartel.{Dealer, Supervisor} 8 | 9 | def start(_type, _args) do 10 | supervisor = Supervisor.start_link() 11 | 12 | dealers = Application.get_env(:cartel, :dealers, []) 13 | for {appid, pushers} <- dealers, do: {:ok, _} = Dealer.add(appid, pushers) 14 | 15 | supervisor 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/cartel/dealer.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Dealer do 2 | @moduledoc """ 3 | OTP Supervisor for each application 4 | """ 5 | use Supervisor 6 | 7 | @doc """ 8 | Starts the dealer 9 | """ 10 | @spec start_link(id: String.t(), pushers: %{}) :: Supervisor.on_start() 11 | def start_link([id: app_id, pushers: _] = args) do 12 | Supervisor.start_link(__MODULE__, args, id: app_id) 13 | end 14 | 15 | @spec name(String.t()) :: atom() 16 | defp name(app_id), do: String.to_atom("#{__MODULE__}@#{app_id}") 17 | 18 | def init(%{id: id, pushers: pushers}) do 19 | pushers 20 | |> Enum.map(fn {type, options} -> 21 | pusher_name = type.name(id) 22 | 23 | pool_options = 24 | options 25 | |> Map.get(:pool, []) 26 | |> Keyword.put(:name, {:local, pusher_name}) 27 | |> Keyword.put(:worker_module, type) 28 | 29 | :poolboy.child_spec(pusher_name, pool_options, options) 30 | end) 31 | |> supervise(strategy: :one_for_one) 32 | end 33 | 34 | @doc """ 35 | Adds a new Dealer to the supervision tree. 36 | 37 | - app_id: The app name 38 | - pushers: pushers as you specify in the static configuration 39 | """ 40 | @spec add(String.t(), %{}) :: Supervisor.on_start_child() 41 | def add(app_id, pushers) do 42 | Supervisor.start_child(Cartel.Supervisor, [[id: app_id, pushers: pushers]]) 43 | end 44 | 45 | @doc """ 46 | Removes a Dealer from the supervision tree. 47 | 48 | - app_id: The app name 49 | """ 50 | @spec remove(String.t()) :: :ok | {:error, :not_found} 51 | def remove(app_id) do 52 | case whereis(app_id) do 53 | {:ok, pid} -> 54 | Supervisor.terminate_child(Cartel.Supervisor, pid) 55 | 56 | error -> 57 | error 58 | end 59 | end 60 | 61 | defp whereis(app_id) do 62 | app_name = name(app_id) 63 | case Process.whereis(app_name) do 64 | pid when is_pid(pid) -> 65 | {:ok, pid} 66 | 67 | _ -> 68 | {:error, :not_found} 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/cartel/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.HTTP do 2 | @moduledoc false 3 | 4 | alias Cartel.HTTP.{Request, Response} 5 | 6 | @type t :: %__MODULE__{conn: Mint.HTTP.t() | nil} 7 | @type scheme :: :http | :https 8 | @type status :: integer() 9 | @type host :: String.t() 10 | @type method :: String.t() 11 | @type url :: String.t() 12 | @type body :: iodata() | nil 13 | @type header :: {String.t(), String.t()} 14 | @type headers :: [header()] 15 | 16 | @typedoc """ 17 | HTTP request options 18 | 19 | Available options are 20 | - `follow_redirects`: If true, when an HTTP redirect is received a new request is made to the redirect URL, else the redirect is returned. Defaults to `true` 21 | - `max_redirects`: Maximum number of redirects to follow, defaults to `10` 22 | - `request_timeout`: Timeout for the request, defaults to `20 seconds` 23 | - `query_params`: Enumerable containing key-value query parameters to add to the url 24 | 25 | Defaults can be changed by setting values in the app configuration: 26 | ```elixir 27 | config :cartel, :http, 28 | max_redirects: 4, 29 | request_timeout: 5_000 30 | ``` 31 | """ 32 | @type options :: [ 33 | request_timeout: integer(), 34 | max_redirects: integer(), 35 | follow_redirects: boolean(), 36 | query_params: Enum.t() 37 | ] 38 | 39 | defstruct conn: nil 40 | 41 | @doc """ 42 | Establish an HTTP connection 43 | 44 | Returns the connection stucture to use for subsequent requests. 45 | """ 46 | @spec connect(url, options) :: {:ok, t()} | {:error, term} 47 | def connect(url, options \\ []) do 48 | with %URI{scheme: scheme, host: host, port: port} <- URI.parse(url), 49 | {:ok, conn} <- 50 | scheme 51 | |> String.downcase() 52 | |> String.to_existing_atom() 53 | |> Mint.HTTP.connect(host, port, options), 54 | do: {:ok, %__MODULE__{conn: conn}} 55 | end 56 | 57 | @doc """ 58 | Close an HTTP connection 59 | """ 60 | @spec close(t()) :: :ok | {:error, term} 61 | def close(%{conn: conn}) do 62 | with {:ok, _conn} <- Mint.HTTP.close(conn), do: :ok 63 | end 64 | 65 | @doc """ 66 | Performs an HTTP request 67 | 68 | Returns the connection stucture to use for subsequent requests. 69 | """ 70 | @spec request(t(), Request.t()) :: {:ok, t(), Response.t()} | {:error, term} 71 | def request(connection, %Request{ 72 | method: method, 73 | url: url, 74 | headers: headers, 75 | body: body, 76 | options: options 77 | }) do 78 | request(connection, method, url, body, headers, options) 79 | end 80 | 81 | @doc """ 82 | Performs an HTTP request 83 | 84 | Returns the connection stucture to use for subsequent requests. 85 | """ 86 | @spec request(t, method, url, body, headers, options) :: 87 | {:ok, t(), Response.t()} | {:error, term} 88 | def request(connection, method, url, body \\ nil, headers \\ [], options \\ []) 89 | 90 | def request(%__MODULE__{conn: nil}, method, url, body, headers, options) do 91 | with {:ok, connection} <- connect(url, options), 92 | do: request(connection, method, url, body, headers, options) 93 | end 94 | 95 | def request(%__MODULE__{conn: conn} = connection, method, url, body, headers, options) do 96 | follow_redirects = get_option(options, :follow_redirects, true) 97 | 98 | with %URI{path: path, query: query} <- URI.parse(url), 99 | {:ok, conn, request_ref} <- 100 | Mint.HTTP.request( 101 | conn, 102 | method, 103 | process_request_url(path, query, options), 104 | headers, 105 | body 106 | ), 107 | {:ok, conn, response} when conn != :error and not follow_redirects <- 108 | receive_msg(conn, %Response{}, request_ref, options) do 109 | {:ok, %{connection | conn: conn}, response} 110 | else 111 | {:ok, conn, %Response{status: status, headers: response_headers} = response} -> 112 | case Enum.find(response_headers, fn {header, _value} -> header == "location" end) do 113 | {_header, redirect_url} when follow_redirects and (status >= 300 and status < 400) -> 114 | max_redirects = get_option(options, :max_redirects, 10) 115 | 116 | redirect( 117 | %{connection | conn: conn}, 118 | method, 119 | URI.parse(url), 120 | URI.parse(redirect_url), 121 | body, 122 | headers, 123 | options, 124 | max_redirects 125 | ) 126 | 127 | _ -> 128 | {:ok, %{connection | conn: conn}, response} 129 | end 130 | 131 | {:error, %Mint.TransportError{reason: reason}} -> 132 | {:error, reason} 133 | 134 | {:error, reason} -> 135 | {:error, reason} 136 | 137 | error -> 138 | error 139 | end 140 | end 141 | 142 | defp redirect( 143 | _connection, 144 | _method, 145 | _original_url, 146 | _redirect_url, 147 | _body, 148 | _headers, 149 | _options, 150 | max_redirects 151 | ) 152 | when max_redirects == 0 do 153 | {:error, :too_many_redirects} 154 | end 155 | 156 | defp redirect( 157 | connection, 158 | method, 159 | %URI{scheme: original_scheme} = original_url, 160 | %URI{scheme: redirect_scheme} = redirect_url, 161 | body, 162 | headers, 163 | options, 164 | max_redirects 165 | ) 166 | when is_nil(redirect_scheme) do 167 | redirect( 168 | connection, 169 | method, 170 | original_url, 171 | %{redirect_url | scheme: original_scheme}, 172 | body, 173 | headers, 174 | options, 175 | max_redirects - 1 176 | ) 177 | end 178 | 179 | defp redirect( 180 | _connection, 181 | method, 182 | %URI{scheme: original_scheme, host: original_host, port: original_port}, 183 | %URI{scheme: redirect_scheme, host: redirect_host, port: redirect_port} = redirect_url, 184 | body, 185 | headers, 186 | options, 187 | max_redirects 188 | ) 189 | when redirect_scheme != original_scheme or 190 | redirect_host != original_host or 191 | redirect_port != original_port do 192 | options = put_option(options, :max_redirects, max_redirects - 1) 193 | request(%__MODULE__{}, method, URI.to_string(redirect_url), body, headers, options) 194 | end 195 | 196 | defp redirect( 197 | connection, 198 | method, 199 | _original_url, 200 | redirect_url, 201 | body, 202 | headers, 203 | options, 204 | max_redirects 205 | ) do 206 | options = put_option(options, :max_redirects, max_redirects - 1) 207 | request(connection, method, URI.to_string(redirect_url), body, headers, options) 208 | end 209 | 210 | defp receive_msg(conn, response, request_ref, options) do 211 | socket = Mint.HTTP.get_socket(conn) 212 | timeout = get_option(options, :request_timeout, 20_000) 213 | 214 | receive do 215 | {tag, ^socket, _data} = msg when tag in [:tcp, :ssl] -> 216 | handle_msg(conn, request_ref, msg, response, options) 217 | 218 | {tag, ^socket} = msg when tag in [:tcp_closed, :ssl_closed] -> 219 | handle_msg(conn, request_ref, msg, response, options) 220 | 221 | {tag, ^socket, _reason} = msg when tag in [:tcp_error, :ssl_error] -> 222 | handle_msg(conn, request_ref, msg, response, options) 223 | after 224 | timeout -> 225 | {:error, :timeout} 226 | end 227 | end 228 | 229 | defp handle_msg(conn, request_ref, msg, response, options) do 230 | with {:ok, conn, responses} <- Mint.HTTP.stream(conn, msg), 231 | {:ok, conn, {response, true}} <- 232 | handle_responses(conn, response, responses, request_ref) do 233 | {:ok, conn, response} 234 | else 235 | :unknown -> 236 | receive_msg(conn, response, request_ref, options) 237 | 238 | {:error, _, %{reason: reason}, _} -> 239 | {:error, reason} 240 | 241 | {:ok, conn, {response, false}} -> 242 | receive_msg(conn, response, request_ref, options) 243 | end 244 | end 245 | 246 | defp handle_responses(conn, response, responses, request_ref) do 247 | {response, complete} = 248 | responses 249 | |> Enum.reduce({response, false}, fn 250 | {:status, ^request_ref, v}, {response, complete} -> 251 | {%Response{response | status: v}, complete} 252 | 253 | {:data, ^request_ref, v}, {%Response{body: body} = response, complete} -> 254 | {%Response{response | body: [v | body]}, complete} 255 | 256 | {:headers, ^request_ref, v}, {response, complete} -> 257 | {%Response{response | headers: v}, complete} 258 | 259 | {:done, ^request_ref}, {%Response{body: body} = response, _complete} -> 260 | {%Response{response | body: Enum.reverse(body)}, true} 261 | end) 262 | 263 | {:ok, conn, {response, complete}} 264 | end 265 | 266 | defp get_option(options, option, default) do 267 | default_value = 268 | :cartel 269 | |> Application.get_env(:http, []) 270 | |> Keyword.get(option, default) 271 | 272 | Keyword.get(options, option, default_value) 273 | end 274 | 275 | defp put_option(options, option, value) do 276 | Keyword.put(options, option, value) 277 | end 278 | 279 | defp process_request_url(nil = _path, query, options), 280 | do: process_request_url("/", query, options) 281 | 282 | defp process_request_url(path, nil = _query, options), 283 | do: process_request_url(path, "", options) 284 | 285 | defp process_request_url(path, query, options) do 286 | query_params = 287 | options 288 | |> Keyword.get(:query_params, []) 289 | |> encode_query() 290 | 291 | path <> "?" <> query <> "&" <> query_params 292 | end 293 | 294 | defp encode_query([]), do: "" 295 | defp encode_query(%{}), do: "" 296 | 297 | defp encode_query(query_params) do 298 | query_params 299 | |> URI.encode_query() 300 | end 301 | end 302 | -------------------------------------------------------------------------------- /lib/cartel/http/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.HTTP.Request do 2 | @moduledoc false 3 | 4 | alias Cartel.HTTP 5 | 6 | @typedoc "HTTP request" 7 | @type t :: %__MODULE__{ 8 | method: HTTP.method(), 9 | url: HTTP.url(), 10 | headers: HTTP.headers(), 11 | body: HTTP.body(), 12 | options: HTTP.options() 13 | } 14 | defstruct method: nil, url: nil, headers: [], body: [], options: [] 15 | 16 | @spec new(HTTP.url(), HTTP.method(), HTTP.headers(), HTTP.body(), HTTP.options()) :: t() 17 | def new(url, method \\ "GET", headers \\ [], body \\ [], options \\ []), 18 | do: %__MODULE__{url: url, method: method, headers: headers, body: body, options: options} 19 | 20 | @spec set_method(t(), HTTP.method()) :: t() 21 | def set_method(request, method), do: %__MODULE__{request | method: method} 22 | 23 | @spec set_body(t(), HTTP.body()) :: t() 24 | def set_body(request, body), do: %__MODULE__{request | body: body} 25 | 26 | @spec set_headers(t(), HTTP.headers()) :: t() 27 | def set_headers(request, headers), do: %__MODULE__{request | headers: headers} 28 | 29 | @spec set_options(t(), HTTP.options()) :: t() 30 | def set_options(request, options), do: %__MODULE__{request | options: options} 31 | 32 | @spec put_header(t(), HTTP.header()) :: t() 33 | def put_header(%__MODULE__{headers: headers} = request, header), 34 | do: %__MODULE__{request | headers: [header | headers]} 35 | 36 | @spec put_option(t(), HTTP.option()) :: t() 37 | def put_option(%__MODULE__{options: options} = request, option), 38 | do: %__MODULE__{request | options: [option | options]} 39 | end 40 | -------------------------------------------------------------------------------- /lib/cartel/http/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.HTTP.Response do 2 | @moduledoc false 3 | 4 | alias Cartel.HTTP 5 | 6 | @typedoc "HTTP response" 7 | @type t :: %__MODULE__{ 8 | status: HTTP.status(), 9 | headers: HTTP.headers(), 10 | body: HTTP.body() 11 | } 12 | defstruct status: nil, headers: [], body: [] 13 | 14 | @spec status(t()) :: HTTP.status() 15 | def status(%__MODULE__{status: status}), do: status 16 | 17 | @spec headers(t()) :: HTTP.headers() 18 | def headers(%__MODULE__{headers: headers}), do: headers 19 | 20 | @spec body(t()) :: HTTP.body() 21 | def body(%__MODULE__{body: body}), do: body 22 | end 23 | -------------------------------------------------------------------------------- /lib/cartel/message.ex: -------------------------------------------------------------------------------- 1 | defprotocol Cartel.Message do 2 | @moduledoc """ 3 | Protocol for the implementation of message formats 4 | """ 5 | 6 | @typedoc """ 7 | Struct conforming to the `Cartel.Message` protocol 8 | """ 9 | @type t :: struct 10 | 11 | @doc """ 12 | Serializes the message struct for sending 13 | """ 14 | @spec serialize(t) :: binary 15 | def serialize(message) 16 | 17 | @doc """ 18 | Returns a copy of message with the `token` updated 19 | """ 20 | @spec update_token(t, String.t()) :: t 21 | def update_token(message, token) 22 | end 23 | -------------------------------------------------------------------------------- /lib/cartel/message/apns.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Message.Apns do 2 | @moduledoc """ 3 | Apple APNS Provider API interface message 4 | 5 | For more details on the format see the [APNS Provider API](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH101-SW1) 6 | section of Apple [Local and Remote Notification Programming Guide](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/Introduction.html) 7 | """ 8 | 9 | @typedoc """ 10 | Apple APNS Provider API interface message 11 | 12 | - `token`: token of the recipient 13 | - `id`: canonical form UUID for delivery errors 14 | - `expiration`: UNIX timestamp of notification expiration 15 | - `priority`: `priority_immediately/0` or `priority_when_convenient/0` 16 | - `topic`: If your certificate includes multiple topics 17 | - `payload`: the notification payload 18 | 19 | At a minimum, `id` and `payload` items must be populated. 20 | """ 21 | @type t :: %__MODULE__{ 22 | token: String.t(), 23 | id: String.t(), 24 | expiration: Integer.t(), 25 | priority: Integer.t(), 26 | topic: String.t(), 27 | payload: %{} 28 | } 29 | 30 | defstruct token: nil, id: nil, expiration: 0, priority: 10, topic: nil, payload: %{} 31 | 32 | @priority_immediately 10 33 | 34 | @doc """ 35 | Returns the `priority_immediately` protocol value 36 | """ 37 | @spec priority_immediately :: Integer.t() 38 | def priority_immediately, do: @priority_immediately 39 | 40 | @priority_when_convenient 5 41 | 42 | @doc """ 43 | Returns the `priority_when_convenient` protocol value 44 | """ 45 | @spec priority_when_convenient :: Integer.t() 46 | def priority_when_convenient, do: @priority_when_convenient 47 | end 48 | 49 | defimpl Cartel.Message, for: Cartel.Message.Apns do 50 | def serialize(message) do 51 | Jason.encode!(message.payload) 52 | end 53 | 54 | def update_token(message, token) do 55 | %{message | token: token} 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/cartel/message/gcm.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Message.Gcm do 2 | @moduledoc """ 3 | Google GCM message 4 | 5 | For more details on the format see [Simple Downstream Messaging](https://developers.google.com/cloud-messaging/downstream) 6 | section of the [Google Cloud Messaging Documentation](https://developers.google.com/cloud-messaging/) 7 | """ 8 | 9 | @typedoc """ 10 | Google GCM message 11 | 12 | - `to`: recipient registration token 13 | - `data`: the notification payload 14 | """ 15 | @type t :: %__MODULE__{to: String.t(), data: %{}} 16 | 17 | defstruct [:to, :data] 18 | end 19 | 20 | defimpl Cartel.Message, for: Cartel.Message.Gcm do 21 | def serialize(message) do 22 | Jason.encode!(message) 23 | end 24 | 25 | def update_token(message, token) do 26 | %{message | to: token} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/cartel/message/wns.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Message.Wns do 2 | @moduledoc """ 3 | Microsoft WNS message 4 | 5 | For more details on the format see [Push notification service request and response headers (Windows Runtime apps)](https://msdn.microsoft.com/en-us/library/windows/apps/hh465435.aspx) 6 | section of the [Sending push notifications with WNS](https://msdn.microsoft.com/en-us/library/windows/apps/hh465460.aspx) 7 | """ 8 | 9 | @type_toast "wns/toast" 10 | 11 | @doc """ 12 | Returns the `X-WNS-Type` HTTP header value for type toast 13 | """ 14 | @spec type_toast :: String.t() 15 | def type_toast, do: @type_toast 16 | 17 | @type_badge "wns/badge" 18 | 19 | @doc """ 20 | Returns the `X-WNS-Type` HTTP header value for type badge 21 | """ 22 | @spec type_badge :: String.t() 23 | def type_badge, do: @type_badge 24 | 25 | @type_tile "wns/tile" 26 | 27 | @doc """ 28 | Returns the `X-WNS-Type` HTTP header value for type tile 29 | """ 30 | @spec type_tile :: String.t() 31 | def type_tile, do: @type_tile 32 | 33 | @type_raw "wns/raw" 34 | 35 | @doc """ 36 | Returns the `X-WNS-Type` HTTP header value for type raw 37 | """ 38 | @spec type_raw :: String.t() 39 | def type_raw, do: @type_raw 40 | 41 | @typedoc """ 42 | Microsoft WNS message 43 | 44 | - `channel`: recipient channel URI obtained from the user 45 | - `type`: one of `type_toast/0`, `type_badge/0`, `type_tile/0` or `type_raw/0` 46 | - `tag`: notification tag 47 | - `group`: notification group 48 | - `ttl`: seconds since sending after which the notification expires 49 | - `cache_policy`: wether to cache notification when device is offline. 50 | - `suppress_popup`: suppress popups for `type_toast/0` notification 51 | - `request_for_status`: add device and connection status in reply 52 | - `payload`: raw octet stream data when `type` is `type_raw/0`, serialized XML string otherwise 53 | """ 54 | @type t :: %__MODULE__{ 55 | channel: String.t(), 56 | type: String.t(), 57 | tag: String.t(), 58 | group: String.t(), 59 | ttl: Integer.t(), 60 | cache_policy: boolean, 61 | suppress_popup: boolean, 62 | request_for_status: boolean, 63 | payload: binary | String.t() 64 | } 65 | defstruct channel: nil, 66 | type: @type_toast, 67 | cache_policy: nil, 68 | tag: nil, 69 | ttl: 0, 70 | suppress_popup: nil, 71 | request_for_status: nil, 72 | group: nil, 73 | payload: "" 74 | 75 | @doc """ 76 | Returns the `Content-Type` HTTP header value for the message 77 | """ 78 | @spec content_type(message :: %__MODULE__{}) :: String.t() 79 | def content_type(%__MODULE__{type: @type_raw}) do 80 | "application/octet-stream" 81 | end 82 | 83 | def content_type(%__MODULE__{}) do 84 | "text/xml" 85 | end 86 | end 87 | 88 | defimpl Cartel.Message, for: Cartel.Message.Wns do 89 | def serialize(message) do 90 | message.payload 91 | end 92 | 93 | def update_token(message, token) do 94 | %{message | channel: token} 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/cartel/pusher.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Pusher do 2 | @moduledoc """ 3 | Behaviour for the implementation of push workers 4 | """ 5 | 6 | alias Cartel.Message 7 | 8 | @doc """ 9 | Pushers must implement actual message sending via this callback 10 | 11 | - `message`: The message struct of the message to be sent, included to allow 12 | metadata additions by the `Cartel.Pusher` implementation. 13 | - `payload`: binary to be used for wire transmission, encoded via the message 14 | `Cartel.Message.serialize/1` implementation. 15 | """ 16 | @callback handle_push(pid :: pid, message :: Message.t(), payload :: binary) :: 17 | :ok | :error 18 | 19 | defmacro __using__(message_module: message_module) do 20 | quote do 21 | @behaviour Cartel.Pusher 22 | 23 | alias Cartel.{Message, Pusher} 24 | 25 | @doc """ 26 | Generate the process name for the requested app 27 | """ 28 | @spec name(String.t()) :: atom 29 | def name(appid), do: String.to_atom("#{__MODULE__}@#{appid}") 30 | 31 | @doc """ 32 | Sends a push notification 33 | 34 | - `appid`: target application identifier present in `config.exs` 35 | - `message`: message struct 36 | - `tokens`: list of recipient device tokens 37 | """ 38 | @spec send(String.t(), unquote(message_module).t, [String.t()]) :: 39 | {:ok | :error} 40 | def send(appid, message, tokens \\ []) 41 | 42 | def send(appid, %unquote(message_module){} = message, []) do 43 | :poolboy.transaction(name(appid), fn 44 | worker -> 45 | payload = Message.serialize(message) 46 | __MODULE__.handle_push(worker, message, payload) 47 | end) 48 | end 49 | 50 | def send(appid, %unquote(message_module){} = message, tokens) 51 | when is_list(tokens) do 52 | :poolboy.transaction(name(appid), fn 53 | worker -> 54 | tokens 55 | |> Enum.map(fn 56 | token -> 57 | message = Message.update_token(message, token) 58 | payload = Message.serialize(message) 59 | __MODULE__.handle_push(worker, message, payload) 60 | end) 61 | end) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/cartel/pusher/apns.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Pusher.Apns do 2 | @moduledoc """ 3 | Apple APNS Provider API worker 4 | """ 5 | 6 | use GenServer 7 | use Cartel.Pusher, message_module: Cartel.Message.Apns 8 | 9 | alias Cartel.HTTP 10 | alias Cartel.Message.Apns 11 | alias HTTP.{Request, Response} 12 | 13 | @production_url "https://api.push.apple.com" 14 | @sandbox_url "https://api.development.push.apple.com" 15 | 16 | @doc """ 17 | Starts the pusher 18 | """ 19 | @spec start_link(%{ 20 | env: :production | :sandbox, 21 | cert: String.t(), 22 | key: String.t(), 23 | cacert: String.t() 24 | }) :: GenServer.on_start() 25 | def start_link(args), do: GenServer.start_link(__MODULE__, args, []) 26 | 27 | @impl Cartel.Pusher 28 | def handle_push(process, message, payload) do 29 | GenServer.call(process, {:push, message, payload}) 30 | end 31 | 32 | @impl GenServer 33 | def init(conf), do: {:ok, %{conf: conf, headers: nil, pid: nil}} 34 | 35 | @impl GenServer 36 | def handle_call({:push, message, payload}, from, %{pid: nil, headers: nil, conf: conf} = state) do 37 | {:ok, pid, url} = connect(conf) 38 | handle_call({:push, message, payload}, from, %{state | pid: pid, url: url}) 39 | end 40 | 41 | def handle_call({:push, message, payload}, _from, %{url: url} = state) do 42 | headers = message_headers(message) 43 | 44 | request = 45 | url 46 | |> Request.new("POST") 47 | |> Request.set_body(payload) 48 | |> Request.set_headers(headers) 49 | |> Request.put_header({"accept", "application/json"}) 50 | |> Request.put_header({"accept-encoding", "gzip, deflate"}) 51 | 52 | case HTTP.request(%HTTP{}, request) do 53 | {:ok, _, %Response{status: code}} when code >= 400 -> 54 | {:reply, {:error, :unauthorized}, state} 55 | 56 | {:ok, _, %Response{body: body}} -> 57 | case Jason.decode!(body) do 58 | %{"results" => [%{"message_id" => _id}]} -> 59 | {:reply, :ok, state} 60 | 61 | %{"results" => [%{"error" => error}]} -> 62 | {:reply, {:error, error}, state} 63 | end 64 | 65 | {:error, reason} -> 66 | {:error, reason} 67 | end 68 | end 69 | 70 | defp connect(%{env: :sandbox, cert: cert, key: key, cacert: cacert}) do 71 | {:ok, pid} = HTTP.connect(@sandbox_url, certfile: cert, keyfile: key, cacertfile: cacert) 72 | {:ok, pid, @sandbox_url} 73 | end 74 | 75 | defp connect(%{env: :production, cert: cert, key: key, cacert: cacert}) do 76 | {:ok, pid} = HTTP.connect(@production_url, certfile: cert, keyfile: key, cacertfile: cacert) 77 | {:ok, pid, @production_url} 78 | end 79 | 80 | defp message_headers(message) do 81 | [] 82 | |> add_message_priority_header(message) 83 | |> add_message_expiration_header(message) 84 | |> add_message_id_header(message) 85 | |> add_message_topic_header(message) 86 | |> add_message_path_header(message) 87 | end 88 | 89 | defp add_message_priority_header(headers, %Apns{priority: priority}) 90 | when is_integer(priority) do 91 | [{":apns-priority", "#{priority}"} | headers] 92 | end 93 | 94 | defp add_message_expiration_header(headers, %Apns{expiration: expiration}) 95 | when is_integer(expiration) do 96 | [{":apns-expiration", "#{expiration}"} | headers] 97 | end 98 | 99 | defp add_message_id_header(headers, %Apns{id: id}) when is_binary(id) do 100 | [{":apns-id", "#{id}"} | headers] 101 | end 102 | 103 | defp add_message_id_header(headers, _), do: headers 104 | 105 | defp add_message_topic_header(headers, %Apns{topic: topic}) when is_binary(topic) do 106 | [{":apns-topic", "#{topic}"} | headers] 107 | end 108 | 109 | defp add_message_topic_header(headers, _), do: headers 110 | 111 | defp add_message_path_header(headers, %Apns{token: token}) when is_binary(token) do 112 | [{":path", "/3/device/#{token}"} | headers] 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/cartel/pusher/gcm.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Pusher.Gcm do 2 | @moduledoc """ 3 | Google GCM interface worker 4 | """ 5 | 6 | use GenServer 7 | use Cartel.Pusher, message_module: Cartel.Message.Gcm 8 | 9 | alias Cartel.HTTP 10 | alias HTTP.{Request, Response} 11 | 12 | @gcm_server_url "https://gcm-http.googleapis.com/gcm/send" 13 | 14 | @doc """ 15 | Starts the pusher 16 | """ 17 | @spec start_link(%{key: String.t()}) :: GenServer.on_start() 18 | def start_link(args), do: GenServer.start_link(__MODULE__, args, []) 19 | 20 | @impl Cartel.Pusher 21 | def handle_push(pid, message, payload) do 22 | GenServer.call(pid, {:push, message, payload}) 23 | end 24 | 25 | @impl GenServer 26 | def init(conf), do: {:ok, conf} 27 | 28 | @impl GenServer 29 | def handle_call({:push, _message, payload}, _from, state) do 30 | request = 31 | @gcm_server_url 32 | |> Request.new("POST") 33 | |> Request.set_body(payload) 34 | |> Request.put_header({"content-type", "application/json"}) 35 | |> Request.put_header({"authorization", "key=" <> state[:key]}) 36 | 37 | case HTTP.request(%HTTP{}, request) do 38 | {:ok, _, %Response{status: code}} when code >= 400 -> 39 | {:reply, {:error, :unauthorized}, state} 40 | 41 | {:ok, _, %Response{body: body}} -> 42 | case Jason.decode!(body) do 43 | %{"results" => [%{"message_id" => _id}]} -> 44 | {:reply, :ok, state} 45 | 46 | %{"results" => [%{"error" => error}]} -> 47 | {:reply, {:error, error}, state} 48 | end 49 | 50 | {:error, reason} -> 51 | {:error, reason} 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/cartel/pusher/wns.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Pusher.Wns do 2 | @moduledoc """ 3 | Microsoft WNS interface worker 4 | """ 5 | 6 | use GenServer 7 | use Cartel.Pusher, message_module: Cartel.Message.Wns 8 | 9 | alias Cartel.HTTP 10 | alias Cartel.Message.Wns 11 | alias HTTP.{Request, Response} 12 | 13 | @wns_login_url "https://login.live.com/accesstoken.srf" 14 | 15 | @doc """ 16 | Starts the pusher 17 | """ 18 | @spec start_link(%{sid: String.t(), secret: String.t()}) :: GenServer.on_start() 19 | def start_link(args), do: GenServer.start_link(__MODULE__, args) 20 | 21 | @impl Cartel.Pusher 22 | def handle_push(pid, message, payload) do 23 | GenServer.call(pid, {:push, message, payload}) 24 | end 25 | 26 | @impl GenServer 27 | def init(conf), do: {:ok, %{conf: conf, token: nil}} 28 | 29 | @impl GenServer 30 | def handle_call( 31 | {:push, message, payload}, 32 | from, 33 | %{token: nil, conf: %{sid: sid, secret: secret}} = state 34 | ) do 35 | case login(sid, secret) do 36 | {:ok, token} -> 37 | handle_call({:push, message, payload}, from, %{state | token: token}) 38 | 39 | {:error, reason} -> 40 | {:stop, {:error, reason}, state} 41 | end 42 | end 43 | 44 | @impl GenServer 45 | def handle_call({:push, %Wns{channel: channel} = message, payload}, _from, state) do 46 | headers = message_headers(message) 47 | 48 | request = 49 | channel 50 | |> Request.new("POST") 51 | |> Request.set_body(payload) 52 | |> Request.set_headers(headers) 53 | |> Request.put_header({"content-type", Wns.content_type(message)}) 54 | |> Request.put_header({"authorization", "Bearer " <> state[:key]}) 55 | |> Request.put_header({"x-wns-type", message.type}) 56 | 57 | case HTTP.request(%HTTP{}, request) do 58 | {:ok, _, %Response{status: code, headers: headers}} when code >= 400 -> 59 | {:reply, {:error, headers}, state} 60 | 61 | {:ok, _, %Response{}} -> 62 | {:reply, :ok, state} 63 | 64 | {:error, reason} -> 65 | {:stop, {:error, reason}, state} 66 | end 67 | end 68 | 69 | defp login(client_id, client_secret) do 70 | body = 71 | %{ 72 | grant_type: "client_credentials", 73 | scope: "notify.windows.com", 74 | client_id: client_id, 75 | client_secret: client_secret 76 | } 77 | |> URI.encode_query() 78 | 79 | request = 80 | @wns_login_url 81 | |> Request.new("POST") 82 | |> Request.set_body(body) 83 | |> Request.put_header({"content-type", "application/x-www-form-urlencoded"}) 84 | 85 | case HTTP.request(%HTTP{}, request) do 86 | {:ok, _, %Response{body: body}} -> 87 | {:ok, Jason.decode!(body)["access_token"]} 88 | 89 | {:error, reason} -> 90 | {:error, reason} 91 | end 92 | end 93 | 94 | defp message_headers(message) do 95 | [] 96 | |> add_message_header_cache_policy(message) 97 | |> add_message_header_ttl(message) 98 | |> add_message_header_suppress_popup(message) 99 | |> add_message_header_request_for_status(message) 100 | end 101 | 102 | defp add_message_header_cache_policy(headers, %Wns{cache_policy: true}) do 103 | [{"X-WNS-Cache-Policy", "cache"} | headers] 104 | end 105 | 106 | defp add_message_header_cache_policy(headers, %Wns{cache_policy: false}) do 107 | [{"X-WNS-Cache-Policy", "no-cache"} | headers] 108 | end 109 | 110 | defp add_message_header_cache_policy(headers, _), do: headers 111 | 112 | defp add_message_header_ttl(headers, %Wns{ttl: ttl}) when is_integer(ttl) and ttl > 0 do 113 | [{"X-WNS-TTL", ttl} | headers] 114 | end 115 | 116 | defp add_message_header_ttl(headers, _), do: headers 117 | 118 | defp add_message_header_suppress_popup(headers, %Wns{suppress_popup: suppress_popup}) 119 | when is_boolean(suppress_popup) and suppress_popup == true do 120 | [{"X-WNS-SuppressPopup", "true"} | headers] 121 | end 122 | 123 | defp add_message_header_suppress_popup(headers, _), do: headers 124 | 125 | defp add_message_header_request_for_status(headers, %Wns{request_for_status: request_for_status}) 126 | when is_boolean(request_for_status) and request_for_status == true do 127 | [{"X-WNS-RequestForStatus", "true"} | headers] 128 | end 129 | 130 | defp add_message_header_request_for_status(headers, _), do: headers 131 | end 132 | -------------------------------------------------------------------------------- /lib/cartel/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Supervisor do 2 | @moduledoc """ 3 | Cartel supervisor for dealer processes 4 | """ 5 | use Supervisor 6 | 7 | alias Cartel.Dealer 8 | 9 | @doc """ 10 | Starts the supervisor 11 | """ 12 | @spec start_link :: Supervisor.on_start() 13 | def start_link do 14 | Supervisor.start_link(__MODULE__, [], name: __MODULE__) 15 | end 16 | 17 | def init(_) do 18 | [supervisor(Dealer, [], restart: :permanent)] 19 | |> supervise(strategy: :simple_one_for_one) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Cartel.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cartel, 7 | version: "0.7.0", 8 | elixir: "~> 1.5", 9 | description: "Multi platform, multi app push notifications", 10 | package: package(), 11 | docs: docs(), 12 | build_embedded: Mix.env() == :prod, 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps() 15 | ] 16 | end 17 | 18 | def application do 19 | [extra_applications: [:logger], mod: {Cartel, []}] 20 | end 21 | 22 | defp package do 23 | [ 24 | maintainers: ["Luca Corti"], 25 | licenses: ["MIT"], 26 | links: %{GitHub: "https://github.com/lucacorti/cartel"} 27 | ] 28 | end 29 | 30 | defp deps do 31 | [ 32 | {:ex_doc, ">= 0.0.0", only: [:dev]}, 33 | {:earmark, ">= 0.0.0", only: [:dev]}, 34 | {:credo, "~> 1.1", only: [:dev]}, 35 | {:dialyxir, "~> 1.1.0", only: [:dev]}, 36 | {:jason, "~> 1.2.0"}, 37 | {:mint, "~> 1.4.0"}, 38 | {:castore, ">= 0.0.0"}, 39 | {:poolboy, "~> 1.5.1"} 40 | ] 41 | end 42 | 43 | defp docs do 44 | [ 45 | main: "main", 46 | extras: [ 47 | "docs/main.md", 48 | "docs/getting-started.md", 49 | "docs/usage.md" 50 | # "docs/extending.md" 51 | ] 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "castore": {:hex, :castore, "0.1.12", "b5755d7668668a74c0e3c4c68df91da927e063a5cade17d693eff04e6ab64805", [:mix], [], "hexpm", "981c79528f88ec4ffd627214ad4cdd25052dc56c002996c603011ae37ec1b4b0"}, 4 | "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"}, 5 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 6 | "earmark": {:hex, :earmark, "1.4.16", "2188754e590a3c379fdd2783bb44eedd8c54968fa0256b6f336f6d56b089d793", [:mix], [{:earmark_parser, ">= 1.4.16", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "46f853f7ae10bee06923430dca522ba9dcbdc6b7a9729748e8dd5344d21b8418"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.16", "607709303e1d4e3e02f1444df0c821529af1c03b8578dfc81bb9cf64553d02b9", [:mix], [], "hexpm", "69fcf696168f5a274dd012e3e305027010658b2d1630cef68421d6baaeaccead"}, 8 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 9 | "ex_doc": {:hex, :ex_doc, "0.25.3", "3edf6a0d70a39d2eafde030b8895501b1c93692effcbd21347296c18e47618ce", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9ebebc2169ec732a38e9e779fd0418c9189b3ca93f4a676c961be6c1527913f5"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 12 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 15 | "mint": {:hex, :mint, "1.4.0", "cd7d2451b201fc8e4a8fd86257fb3878d9e3752899eb67b0c5b25b180bde1212", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "10a99e144b815cbf8522dccbc8199d15802440fc7a64d67b6853adb6fa170217"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 17 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 18 | } 19 | -------------------------------------------------------------------------------- /test/cartel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CartelTest do 2 | use ExUnit.Case 3 | doctest Cartel 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------