Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Pushy push notifications #254

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ce1eff4
fix: move token generation to agent (#1)
dballance Feb 1, 2023
ae61126
chore: undo random formatting changes that aren't necessary
jmhossler Mar 17, 2023
757e948
feat: implement a pushy push notification adapter
jmhossler Mar 20, 2023
bb265f9
fix: implement connect_socket method
jmhossler Mar 20, 2023
2aa8eee
feat: add pushy notification constructor
jmhossler Mar 20, 2023
4c3abe2
fix: do @spec new instead of @new smh
jmhossler Mar 20, 2023
917711f
fix: add return type to new spec
jmhossler Mar 20, 2023
b0f48c6
fix: update connect parameters and add docs link in mix
jmhossler Mar 20, 2023
fa4b59d
debug: add error log so I can tell why connection to pushy isn't working
jmhossler Mar 20, 2023
addf2ae
fix: require logger in pushy dispatcher
jmhossler Mar 20, 2023
f4c279c
fix: do a regular inspect of the error instead when connecting to pushy
jmhossler Mar 20, 2023
a39adde
fix: adjust socket connection settings to attempt to resolve handshak…
jmhossler Mar 20, 2023
ff3ed8a
refactor: don't use socket when connecting to pushy, just make http r…
jmhossler Mar 20, 2023
3c89181
fix: correctly call pushy_headers and remove unnecessary match
jmhossler Mar 20, 2023
d725ba5
fix: use notification encode_requests to encode the payload
jmhossler Mar 20, 2023
73cea75
fix: move over encoding logic so I can encode properly
jmhossler Mar 20, 2023
e42caab
fix: remove unmatched curly brace and use correct encode request impl
jmhossler Mar 20, 2023
839ac03
fix: don't pass in more params than necessary to error.parse
jmhossler Mar 20, 2023
c062ec2
fix: correct the pushy path for pushing notifications
jmhossler Mar 21, 2023
f7230b1
fix: remove error parse on good data, just return response from pushy
jmhossler Mar 21, 2023
a7d40d8
fix: pass notification around appropriately in do_push for pushy
jmhossler Mar 21, 2023
be63a72
feat: add result parser to handle both error and success cases
jmhossler Mar 21, 2023
1f8985b
fix: add additional notification fields to hold response info
jmhossler Mar 21, 2023
c9ec79a
fix: address some invalid variable refs and remove unused param from …
jmhossler Mar 21, 2023
0884f66
fix: pass the resulting json instead of some fictitious result parser…
jmhossler Mar 21, 2023
83bf249
fix: handle case where failed devices might not be included
jmhossler Mar 21, 2023
29fba72
fix: remove old reference to failed devices
jmhossler Mar 21, 2023
5b19e6d
fix: parse errors into notification structs instead of just atom
jmhossler Mar 21, 2023
bf90370
feat: add helper methods for filling in additional parameters for pus…
jmhossler Mar 21, 2023
7af0b96
fix: add missing empty curly bracket and format files
jmhossler Mar 21, 2023
72c33cf
docs(pushy): add documentation for notification and pushy module
jmhossler Mar 29, 2023
a5cc940
refactor: change order of new to take destination as first parameter …
jmhossler Mar 29, 2023
f46512f
test: implement basic pushy test implementation
jmhossler Mar 29, 2023
9251f98
feat: ensure pushy config is validated
jmhossler Mar 29, 2023
0de75f6
Fix compilation errors and warnings
nathanalderson Feb 29, 2024
fefcc3c
Make the default uri a string, not a charlist
nathanalderson Feb 29, 2024
acb29b8
fix: put variable on the right side of pattern match in parameter list
jmhossler Apr 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions lib/pigeon/pushy.ex
Original file line number Diff line number Diff line change
@@ -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
104 changes: 104 additions & 0 deletions lib/pigeon/pushy/config.ex
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions lib/pigeon/pushy/error.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading