diff --git a/lib/pigeon/pushy.ex b/lib/pigeon/pushy.ex new file mode 100644 index 00000000..c2f3c60a --- /dev/null +++ b/lib/pigeon/pushy.ex @@ -0,0 +1,235 @@ +defmodule Pigeon.Pushy do + @moduledoc """ + `Pigeon.Adapter` for Pushy push notifications. + + This adapter provides support for sending push notifications via the Pushy API. + It is designed to work with the `Pigeon` library and implements the `Pigeon.Adapter` behaviour. + + ## Example + + + Then, you can send a Pushy push notification like this: + + notif = Pigeon.Pushy.Notification.new(%{"message" => "Hello, world!"}, "device_token") + + Pigeon.push(notif) + + ## Configuration + + The following options can be set in the adapter configuration: + + * `:key` - (required) the API key for your Pushy account. + * `:base_uri` - (optional) the base URI for the Pushy API. Defaults to "api.pushy.me". + + ## Getting Started + + 1. Create a Pushy dispatcher. + + ``` + # lib/pushy.ex + defmodule YourApp.Pushy do + use Pigeon.Dispatcher, otp_app: :your_app + end + ``` + + 2. (Optional) Add configuration to your `config.exs`. + + To use this adapter, simply include it in your Pigeon configuration: + + config :your_app, YourApp.Pushy, + adapter: Pigeon.Pushy, + key: "pushy secret key" + + 3. Start your dispatcher on application boot. + + ``` + defmodule YourApp.Application do + @moduledoc false + + use Application + + @doc false + def start(_type, _args) do + children = [ + YourApp.Pushy + ] + opts = [strategy: :one_for_one, name: YourApp.Supervisor] + Supervisor.start_link(children, opts) + end + end + ``` + + 4. Create a notification. + + ``` + msg = %{ "body" => "your message" } + n = Pigeon.pushy.Notification.new(msg, "your device token") + ``` + + 5. Send the notification. + + ``` + YourApp.Pushy.push(n) + ``` + + ## Handling Push Responses + + 1. Pass an optional anonymous function as your second parameter. + + ``` + data = %{ "message" => "your message" } + n = Pigeon.Pushy.Notification.new(data, "device token") + YourApp.Pushy.push(n, on_response: fn(x) -> IO.inspect(x) end) + ``` + + 2. Responses return a notification with an updated `:response` key. + You could handle responses like so: + + ``` + on_response_handler = fn(x) -> + case x.response do + :success -> + # Push successful + :ok + :failure -> + # Retry or some other handling for x.failed (devices failed to send) + :timeout -> + # request didn't finish within expected time, server didn't respond + error -> + # Handle other errors + end + end + + data = %{ "message" => "your message" } + n = Pigeon.Pushy.Notification.new(data, "your device token") + Pigeon.Pushy.push(n, on_response: on_response_handler) + ``` + """ + import Pigeon.Tasks, only: [process_on_response: 1] + require Logger + + alias Pigeon.Pushy.{ResultParser} + + defstruct config: nil + + @behaviour Pigeon.Adapter + + @impl true + def init(opts) do + config = Pigeon.Pushy.Config.new(opts) + + Pigeon.Pushy.Config.validate!(config) + + state = %__MODULE__{config: config} + + {:ok, state} + end + + @impl true + def handle_push(notification, state) do + :ok = do_push(notification, state) + {:noreply, state} + end + + @impl true + def handle_info({_from, {:ok, %HTTPoison.Response{status_code: 200}}}, state) do + {:noreply, state} + end + + def handle_info(_msg, state) do + {:noreply, state} + end + + defp do_push(notification, state) do + response = fn notification -> + encoded_notification = encode_requests(notification) + + case HTTPoison.post( + pushy_uri(state.config), + encoded_notification, + pushy_headers() + ) do + {:ok, %HTTPoison.Response{status_code: status, body: body}} -> + process_response(status, body, notification) + + {:error, %HTTPoison.Error{reason: :connect_timeout}} -> + notification + |> Map.put(:response, :timeout) + |> process_on_response() + end + end + + Task.Supervisor.start_child(Pigeon.Tasks, fn -> response.(notification) end) + :ok + end + + defp pushy_uri(%Pigeon.Pushy.Config{uri: base_uri, key: secret_key}) do + "https://#{base_uri}/push/?api_key=#{secret_key}" + end + + def pushy_headers() do + [ + {"Content-Type", "application/json"}, + {"Accept", "application/json"} + ] + end + + defp encode_requests(notif) do + %{} + |> encode_to(notif.to) + |> encode_data(notif.data) + |> maybe_encode_attr("time_to_live", notif.time_to_live) + |> maybe_encode_attr("content_available", notif.content_available) + |> maybe_encode_attr("mutable_content", notif.mutable_content) + |> maybe_encode_attr("notification", notif.notification) + |> maybe_encode_attr("schedule", notif.schedule) + |> maybe_encode_attr("collapse_key", notif.collapse_key) + |> Pigeon.json_library().encode!() + end + + defp encode_to(map, value) do + Map.put(map, "to", value) + end + + defp encode_data(map, value) do + Map.put(map, "data", value) + end + + defp maybe_encode_attr(map, _key, nil), do: map + + defp maybe_encode_attr(map, key, val) do + Map.put(map, key, val) + end + + defp process_response(200, body, notification), + do: handle_200_status(body, notification) + + defp process_response(status, body, notification), + do: handle_error_status_code(status, body, notification) + + defp handle_200_status(body, notification) do + {:ok, json} = Pigeon.json_library().decode(body) + + ResultParser.parse(notification, json) + |> process_on_response() + end + + defp handle_error_status_code(status, body, notification) do + case Pigeon.json_library().decode(body) do + {:ok, %{"error" => _reason} = result_json} -> + notification + |> ResultParser.parse(result_json) + |> process_on_response() + + {:error, _} -> + notification + |> Map.put(:response, generic_error_reason(status)) + |> process_on_response() + end + end + + defp generic_error_reason(400), do: :invalid_json + defp generic_error_reason(401), do: :authentication_error + defp generic_error_reason(500), do: :internal_server_error + defp generic_error_reason(_), do: :unknown_error +end diff --git a/lib/pigeon/pushy/config.ex b/lib/pigeon/pushy/config.ex new file mode 100644 index 00000000..d897b3cd --- /dev/null +++ b/lib/pigeon/pushy/config.ex @@ -0,0 +1,104 @@ +defmodule Pigeon.Pushy.Config do + @moduledoc false + + defstruct key: nil, + port: 443, + uri: nil + + @typedoc ~S""" + Pushy configuration struct + + This struct should not be set directly. Instead, use `new/1` + with `t:config_opts/0`. + + ## Examples + + %Pigeon.Pushy.Config{ + key: "some-secret-key", + uri: "api.pushy.me", + port: 443 + } + """ + @type t :: %__MODULE__{ + key: binary | nil, + uri: binary | nil, + port: pos_integer + } + + @typedoc ~S""" + Options for configuring Pushy connections. + + ## Configuration Options + - `:key` - Pushy secrety key. + - `:uri` - Pushy server uri. + - `:port` - Push server port. Can be any value, but Pushy only accepts + `443` + """ + @type config_opts :: [ + key: binary, + uri: binary, + port: pos_integer + ] + + @doc false + def default_name, do: :pushy_default + + @doc ~S""" + Returns a new `Pushy.Config` with given `opts`. + + ## Examples + + iex> Pigeon.Pushy.Config.new( + ...> key: System.get_env("PUSHY_SECRET_KEY"), + ...> uri: "api.pushy.me", + ...> port: 443 + ...> ) + %Pigeon.Pushy.Config{ + key: System.get_env("PUSHY_SECRET_KEY") + port: 443, + uri: "api.pushy.me" + } + """ + def new(opts) when is_list(opts) do + %__MODULE__{ + key: opts |> Keyword.get(:key), + uri: Keyword.get(opts, :uri, "api.pushy.me"), + port: Keyword.get(opts, :port, 443) + } + end + + @doc ~S""" + Returns whether a given config has valid credentials. + + ## Examples + + iex> [] |> new() |> valid?() + false + """ + def valid?(config) do + valid_item?(config.uri) and valid_item?(config.key) + end + + defp valid_item?(item), do: is_binary(item) and String.length(item) > 0 + + @spec validate!(any) :: :ok | no_return + def validate!(config) do + if valid?(config) do + :ok + else + raise Pigeon.ConfigError, + reason: "attempted to start without valid key or uri", + config: redact(config) + end + end + + defp redact(config) do + [:key] + |> Enum.reduce(config, fn k, acc -> + case Map.get(acc, k) do + nil -> acc + _ -> Map.put(acc, k, "[FILTERED]") + end + end) + end +end diff --git a/lib/pigeon/pushy/error.ex b/lib/pigeon/pushy/error.ex new file mode 100644 index 00000000..2903488f --- /dev/null +++ b/lib/pigeon/pushy/error.ex @@ -0,0 +1,27 @@ +defmodule Pigeon.Pushy.Error do + @moduledoc false + + @doc false + @spec parse(Pigeon.Pushy.Notification.t(), map) :: + Pigeon.Pushy.Notification.error_response() + def parse(notification, error) do + error_code = + error + |> Map.get("code") + |> parse_response() + + notification + |> Map.put(:response, error_code) + end + + defp parse_response("NO_RECIPIENTS"), do: :no_recipients + defp parse_response("NO_APNS_AUTH"), do: :no_apns_auth + defp parse_response("PAYLOAD_LIMIT_EXCEEDED"), do: :payload_limit_exceeded + defp parse_response("INVALID_PARAM"), do: :invalid_param + defp parse_response("INVALID_API_KEY"), do: :invalid_api_key + defp parse_response("AUTH_LIMIT_EXCEEDED"), do: :auth_limit_exceeded + defp parse_response("ACCOUNT_SUSPENDED"), do: :account_suspended + defp parse_response("RATE_LIMIT_EXCEEDED"), do: :rate_limit_exceeded + defp parse_response("INTERNAL_SERVER_ERROR"), do: :internal_server_error + defp parse_response(_), do: :unknown_error +end diff --git a/lib/pigeon/pushy/notification.ex b/lib/pigeon/pushy/notification.ex new file mode 100644 index 00000000..63ff799a --- /dev/null +++ b/lib/pigeon/pushy/notification.ex @@ -0,0 +1,216 @@ +defmodule Pigeon.Pushy.Notification do + @moduledoc """ + Defines Pushy notification struct and convenience constructor functions. + + For more information on Pushy notification requests, see + https://pushy.me/docs/api/send-notifications. + """ + + defstruct __meta__: %Pigeon.Metadata{}, + to: "", + data: %{}, + time_to_live: nil, + content_available: nil, + mutable_content: nil, + notification: nil, + schedule: nil, + collapse_key: nil, + response: nil, + push_id: nil, + success: nil, + successful_device_count: nil, + failed: nil + + @typedoc """ + Pushy notification + + ## Examples + %Pigeon.Pushy.Notification{ + __meta__: %Pigeon.Metadata{on_response: nil}, + to: "device token or topic", + data: %{"message" => "hello world"}, + time_to_live: nil, + content_available: nil, + mutable_content: nil, + notification: nil, + schedule: nil, + collapse_key: nil, + response: nil, # Set on push response + success: nil, # Set on push response + push_id: nil, # Set on push response + successful_device_count: nil, # Set on push response + failed: nil # Set on push response + } + """ + @type t :: %__MODULE__{ + __meta__: Pigeon.Metadata.t(), + to: String.t() | [String.t()], + data: map, + time_to_live: integer | nil, + content_available: boolean | nil, + mutable_content: boolean | nil, + notification: map | nil, + schedule: integer | nil, + collapse_key: String.t() | nil, + response: response, + push_id: String.t() | nil, + success: boolean() | nil, + successful_device_count: integer() | nil, + failed: [String.t()] | nil + } + + @typedoc """ + Pushy push response + + - nil - Push has not been sent yet + - `:success` - Push was successfully sent. + - `:failure` - If we don't receive an error code, but the response is not successful, + this message is returned. + - `t:Pigeon.Pushy.Error.error_response/0` - Push attempted + server responded with error. + - `:timeout` - Internal error. Push did not reach Pushy servers. + """ + @type response :: nil | :success | :failure | error_response | :timeout + + @type error_response :: + :no_recipients + | :no_apns_auth + | :payload_limit_exceeded + | :invalid_param + | :invalid_api_key + | :auth_limit_exceeded + | :account_suspended + | :rate_limit_exceeded + | :internal_server_error + | :unknown_error + + @doc """ + Returns a `Pushy.Notification` struct with the given message and destination. + + A destination could be either a single destination, or a list of them. A destination + is either a device token OR a topic (prefix topic names with '/topics/'). + + ## Examples + + iex> Pigeon.Pushy.Notification.new(%{"message" => "Hello, world!"}, "device token") + %Pigeon.APNS.Notification{ + __meta__: %Pigeon.Metadata{}, + to: "device token", + data: %{"message" => "Hello, world!"}, + time_to_live: nil, + content_available: nil, + mutable_content: nil, + notification: nil, + schedule: nil, + collapse_key: nil, + response: nil, + success: nil, + push_id: nil, + successful_device_count: nil, + failed: nil + } + """ + @spec new(String.t() | [String.t()], map()) :: __MODULE__.t() + def new(device_ids, message \\ nil) + + def new(device_ids, message) do + %__MODULE__{ + to: device_ids, + data: message + } + end + + @doc """ + Adds or updates the `data` field in the notification. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_data(notification, %{"message" => "some message"}) + %Pigeon.Pushy.Notification{... data: %{"message" => "some message"} ...} + """ + @spec put_data(__MODULE__.t(), map()) :: __MODULE__.t() + def put_data(notification, data) do + %{notification | data: data} + end + + @doc """ + Adds or updates the `time_to_live` field in the notification. + + This is how long (in seconds) the push notification should be kept if the device is offline. + If unspecified, notifications will be kept for 30 days. The maximum value is 365 days. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_time_to_live(notification, 3600) + %Pigeon.Pushy.Notification{... time_to_live: 3600 ...} + """ + @spec put_time_to_live(__MODULE__.t(), integer()) :: __MODULE__.t() + def put_time_to_live(notification, time_to_live) do + %{notification | time_to_live: time_to_live} + end + + @doc """ + Adds or updates the `content_available` field in the notification. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_content_available(notification, true) + %Pigeon.Pushy.Notification{... content_available: true ...} + """ + @spec put_content_available(__MODULE__.t(), boolean()) :: __MODULE__.t() + def put_content_available(notification, content_available) do + %{notification | content_available: content_available} + end + + @doc """ + Adds or updates the `mutable_content` field in the notification. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_mutable_content(notification, false) + %Pigeon.Pushy.Notification{... mutable_content: false ...} + """ + @spec put_mutable_content(__MODULE__.t(), boolean()) :: __MODULE__.t() + def put_mutable_content(notification, mutable_content) do + %{notification | mutable_content: mutable_content} + end + + @doc """ + Adds or updates the `notification` field in the notification. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_notification(notification, %{"title" => "New message", "body" => "Hello"}) + %Pigeon.Pushy.Notification{... notification: %{"title" => "New message", "body" => "Hello"} ...} + """ + @spec put_notification(__MODULE__.t(), map) :: __MODULE__.t() + def put_notification(notification, notification_details) do + %{notification | notification: notification_details} + end + + @doc """ + Adds or updates the `schedule` field in the notification. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_schedule(notification, 1648686700) + %Pigeon.Pushy.Notification{... schedule: 1648686700 ...} + """ + @spec put_schedule(__MODULE__.t(), integer) :: __MODULE__.t() + def put_schedule(notification, schedule) do + %{notification | schedule: schedule} + end + + @doc """ + Adds or updates the `collapse_key` field in the notification. + + ## Examples + + iex> Pigeon.Pushy.Notification.put_collapse_key(notification, "new_message") + %Pigeon.Pushy.Notification{... collapse_key: "new_message" ...} + """ + @spec put_collapse_key(__MODULE__.t(), String.t()) :: __MODULE__.t() + def put_collapse_key(notification, collapse_key) do + %{notification | collapse_key: collapse_key} + end +end diff --git a/lib/pigeon/pushy/result_parser.ex b/lib/pigeon/pushy/result_parser.ex new file mode 100644 index 00000000..0e08e601 --- /dev/null +++ b/lib/pigeon/pushy/result_parser.ex @@ -0,0 +1,31 @@ +defmodule Pigeon.Pushy.ResultParser do + @moduledoc false + alias Pigeon.Pushy.Error + + def parse( + notification, + %{ + "id" => push_id, + "success" => success_status, + "info" => %{"devices" => num_devices} + } = response + ) do + notification = + notification + |> Map.put(:push_id, push_id) + |> Map.put(:success, success_status) + |> Map.put(:successful_device_count, num_devices) + |> Map.put(:response, if(success_status, do: :success, else: :failure)) + + if match?(%{"info" => %{"failed" => _}}, response) do + notification + |> Map.put(:failed, response["info"]["failed"]) + else + notification + end + end + + def parse(notification, %{"code" => _} = response) do + Error.parse(notification, response) + end +end diff --git a/mix.exs b/mix.exs index 02888a32..bb832c20 100644 --- a/mix.exs +++ b/mix.exs @@ -66,7 +66,8 @@ defmodule Pigeon.Mixfile do Pigeon.FCM.Notification, Pigeon.LegacyFCM, Pigeon.LegacyFCM.Notification - ] + ], + Pushy: [Pigeon.Pushy, Pigeon.Pushy.Notification] ], main: "Pigeon" ] diff --git a/test/pigeon/pushy_test.exs b/test/pigeon/pushy_test.exs new file mode 100644 index 00000000..42b44b88 --- /dev/null +++ b/test/pigeon/pushy_test.exs @@ -0,0 +1,67 @@ +defmodule Pigeon.PushyTest do + use ExUnit.Case + doctest Pigeon.Pushy, import: true + doctest Pigeon.Pushy.Config, import: true + + alias Pigeon.Pushy.Notification + + @invalid_key_msg ~r/^attempted to start without valid key or uri/ + + describe "init/1" do + test "initializes correctly if configured with key" do + opts = [uri: "api.pushy.me", key: "abc123"] + + expected = + {:ok, + %{ + config: %Pigeon.Pushy.Config{ + uri: "api.pushy.me", + key: "abc123" + } + }} + + assert Pigeon.Pushy.init(opts) == expected + end + + test "raises if configured with invalid uri" do + assert_raise(Pigeon.ConfigError, @invalid_key_msg, fn -> + opts = [uri: nil, key: "abc123"] + Pigeon.Pushy.init(opts) + end) + end + + test "raises if configured with invalid key" do + assert_raise(Pigeon.ConfigError, @invalid_key_msg, fn -> + opts = [ + uri: "api.pushy.me", + key: nil + ] + + Pigeon.Pushy.init(opts) + end) + end + end + + describe "handle_push/3" do + test "returns an error on pushing with a bad registration_id" do + token = "bad_token" + n = Notification.new(token, %{"message" => "example"}) + pid = self() + PigeonTest.Pushy.push(n, on_response: fn x -> send(pid, x) end) + + assert_receive(n = %Notification{}, 5000) + assert n.response == :invalid_registration_id + assert n.registration_id == reg_id + assert n.payload == %{"data" => %{"message" => "example"}} + end + + test "handles nil on_response" do + n = Notification.new("bad_token", %{"message" => "example"}) + PigeonTest.Pushy.push(n, on_response: nil) + end + end + + test "handle_info/2 handles random messages" do + assert Pigeon.ADM.handle_info("random", %{}) == {:noreply, %{}} + end +end