Skip to content

Commit

Permalink
feat: support for Goth modules for FCM
Browse files Browse the repository at this point in the history
  • Loading branch information
petermueller committed Feb 14, 2023
1 parent e5ca4be commit dbb0da5
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 119 deletions.
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ config :pigeon, PigeonTest.LegacyFCM,
config :pigeon, PigeonTest.FCM,
adapter: Pigeon.FCM,
project_id: System.get_env("FCM_PROJECT"),
service_account_json: System.get_env("FCM_SERVICE_ACCOUNT_JSON")
goth: PigeonTest.Goth

config :pigeon, PigeonTest.Sandbox, adapter: Pigeon.Sandbox
73 changes: 18 additions & 55 deletions lib/pigeon/fcm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ defmodule Pigeon.FCM do
```
n = Pigeon.FCM.Notification.new({:token, "reg ID"}, %{"body" => "test message"})
```
5. Send the notification.
On successful response, `:name` will be set to the name returned from the FCM
API and `:response` will be `:success`. If there was an error, `:error` will
5. Send the notification.
On successful response, `:name` will be set to the name returned from the FCM
API and `:response` will be `:success`. If there was an error, `:error` will
contain a JSON map of the response and `:response` will be an atomized version
of the error type.
Expand All @@ -92,46 +92,37 @@ defmodule Pigeon.FCM do

defstruct config: nil,
queue: Pigeon.NotificationQueue.new(),
refresh_before: 5 * 60,
retries: @max_retries,
socket: nil,
stream_id: 1,
token: nil
stream_id: 1

@behaviour Pigeon.Adapter

alias Pigeon.{Configurable, NotificationQueue}
alias Pigeon.Http2.{Client, Stream}

@refresh :"$refresh"
@retry_after 1000

@scopes [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/firebase.messaging"
]

@impl true
@impl Pigeon.Adapter
def init(opts) do
config = Pigeon.FCM.Config.new(opts)

Configurable.validate!(config)

state = %__MODULE__{config: config}

with {:ok, socket} <- connect_socket(config),
{:ok, token} <- fetch_token(config) do
Configurable.schedule_ping(config)
schedule_refresh(state, token)
{:ok, %{state | socket: socket, token: token}}
else
{:error, reason} -> {:stop, reason}
case connect_socket(config) do
{:ok, socket} ->
Configurable.schedule_ping(config)
{:ok, %{state | socket: socket}}

{:error, reason} ->
{:stop, reason}
end
end

@impl true
@impl Pigeon.Adapter
def handle_push(notification, state) do
%{config: config, queue: queue, token: token} = state
headers = Configurable.push_headers(config, notification, token: token)
%{config: config, queue: queue} = state
headers = Configurable.push_headers(config, notification, [])
payload = Configurable.push_payload(config, notification, [])

Client.default().send_request(state.socket, headers, payload)
Expand All @@ -146,7 +137,7 @@ defmodule Pigeon.FCM do
{:noreply, state}
end

@impl true
@impl Pigeon.Adapter
def handle_info(:ping, state) do
Client.default().send_ping(state.socket)
Configurable.schedule_ping(state.config)
Expand All @@ -171,22 +162,6 @@ defmodule Pigeon.FCM do
end
end

def handle_info(@refresh, %{config: config} = state) do
case fetch_token(config) do
{:ok, token} ->
schedule_refresh(state, token)
{:noreply, %{state | retries: @max_retries, token: token}}

{:error, exception} ->
if state.retries > 0 do
Process.send_after(self(), @refresh, @retry_after)
{:noreply, %{state | retries: state.retries - 1}}
else
raise "too many failed attempts to refresh, last error: #{inspect(exception)}"
end
end
end

def handle_info(msg, state) do
case Client.default().handle_end_stream(msg, state) do
{:ok, %Stream{} = stream} -> process_end_stream(stream, state)
Expand All @@ -210,18 +185,6 @@ defmodule Pigeon.FCM do
end
end

defp fetch_token(config) do
source = {:service_account, config.service_account_json, [scopes: @scopes]}
Goth.Token.fetch(%{source: source})
end

defp schedule_refresh(state, token) do
time_in_seconds =
max(token.expires - System.system_time(:second) - state.refresh_before, 0)

Process.send_after(self(), @refresh, time_in_seconds * 1000)
end

@doc false
def process_end_stream(%Stream{id: stream_id} = stream, state) do
%{queue: queue, config: config} = state
Expand Down
101 changes: 43 additions & 58 deletions lib/pigeon/fcm/config.ex
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
defmodule Pigeon.FCM.Config do
@moduledoc false

defstruct port: 443,
defstruct goth: nil,
project_id: nil,
service_account_json: nil,
uri: 'fcm.googleapis.com'
uri: "fcm.googleapis.com",
port: 443

@typedoc """
TODO - the name or via-tuple of your Goth implementation, e.g. `YourApp.Goth`
"""
@type goth_name :: module() | term()

@type t :: %__MODULE__{
port: pos_integer,
project_id: binary,
service_account_json: binary,
uri: charlist
goth: nil | goth_name(),
project_id: nil | String.t(),
uri: String.t(),
port: pos_integer()
}

@doc ~S"""
Expand All @@ -20,70 +25,41 @@ defmodule Pigeon.FCM.Config do
iex> Pigeon.FCM.Config.new(
...> project_id: "example-project",
...> service_account_json: "{\"dummy\":\"contents\"}"
...> goth: YourApp.Goth
...> )
%Pigeon.FCM.Config{
port: 443,
project_id: "example-project",
service_account_json: %{"dummy" => "contents"},
uri: 'fcm.googleapis.com'
goth: YourApp.Goth,
uri: "fcm.googleapis.com"
}
"""
def new(opts) when is_list(opts) do
project_id =
opts
|> Keyword.get(:project_id)
|> decode_bin()

service_account_json =
opts
|> Keyword.get(:service_account_json)
|> decode_json()
def new(opts) do
opts = Map.new(opts)

%__MODULE__{
port: Keyword.get(opts, :port, 443),
project_id: project_id,
service_account_json: service_account_json,
uri: Keyword.get(opts, :uri, 'fcm.googleapis.com')
goth: opts[:goth],
project_id: opts[:project_id],
uri: Map.get(opts, :uri, "fcm.googleapis.com"),
port: Map.get(opts, :port, 443)
}
end

def decode_bin(bin) when is_binary(bin) do
bin
end

def decode_bin(other) do
{:error, {:invalid, other}}
end

def decode_json(bin) when is_binary(bin) do
case Pigeon.json_library().decode(bin) do
{:ok, json} -> json
{:error, _reason} -> {:error, {:invalid, bin}}
end
end

def decode_json(other) do
{:error, {:invalid, other}}
end
end

defimpl Pigeon.Configurable, for: Pigeon.FCM.Config do
@moduledoc false

require Logger

import Pigeon.Tasks, only: [process_on_response: 1]

alias Pigeon.Encodable
alias Pigeon.FCM.{Config, Error}
alias Pigeon.FCM.Error

@type sock :: {:sslsocket, any, pid | {any, any}}

# Configurable Callbacks

@spec connect(any) :: {:ok, sock} | {:error, String.t()}
def connect(%Config{uri: uri} = config) do
def connect(%@for{uri: uri} = config) do
case connect_socket_options(config) do
{:ok, options} ->
Pigeon.Http2.Client.default().connect(uri, :https, options)
Expand All @@ -104,18 +80,20 @@ defimpl Pigeon.Configurable, for: Pigeon.FCM.Config do
{:ok, opts}
end

def add_port(opts, %Config{port: 443}), do: opts
def add_port(opts, %Config{port: port}), do: [{:port, port} | opts]
def add_port(opts, %@for{port: 443}), do: opts
def add_port(opts, %@for{port: port}), do: [{:port, port} | opts]

def push_headers(
%Config{project_id: project_id},
config,
_notification,
opts
_opts
) do
token = Goth.fetch!(config.goth)

[
{":method", "POST"},
{":path", "/v1/projects/#{project_id}/messages:send"},
{"authorization", "Bearer #{opts[:token].token}"},
{":path", "/v1/projects/#{config.project_id}/messages:send"},
{"authorization", "#{token.type} #{token.token}"},
{"content-type", "application/json"},
{"accept", "application/json"}
]
Expand Down Expand Up @@ -148,19 +126,26 @@ defimpl Pigeon.Configurable, for: Pigeon.FCM.Config do
def close(_config) do
end

def validate!(%{project_id: {:error, _}} = config) do
def validate!(config) do
config
|> Map.from_struct()
|> Enum.each(&do_validate!(&1, config))
end

defp do_validate!({:goth, mod}, config)
when not is_atom(mod) or is_nil(mod) do
raise Pigeon.ConfigError,
reason: "attempted to start without valid :project_id",
reason: "attempted to start without valid :goth module",
config: redact(config)
end

def validate!(%{service_account_json: {:error, _}} = config) do
defp do_validate!({:project_id, value}, config) when not is_binary(value) do
raise Pigeon.ConfigError,
reason: "attempted to start without valid :service_account_json",
reason: "attempted to start without valid :project_id",
config: redact(config)
end

def validate!(_config), do: :ok
defp do_validate!({_key, _value}, _config), do: :ok

@doc false
def redact(config) when is_map(config) do
Expand Down
11 changes: 6 additions & 5 deletions test/pigeon/fcm_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule Pigeon.FCMTest do

@data %{"message" => "Test push"}
@invalid_project_msg ~r/^attempted to start without valid :project_id/
@invalid_service_account_json_msg ~r/^attempted to start without valid :service_account_json/
@invalid_goth_msg ~r/^attempted to start without valid :goth module/

defp valid_fcm_reg_id do
Application.get_env(:pigeon, :test)[:valid_fcm_reg_id]
Expand All @@ -18,14 +18,14 @@ defmodule Pigeon.FCMTest do
describe "init/1" do
test "raises if configured with invalid project" do
assert_raise(Pigeon.ConfigError, @invalid_project_msg, fn ->
[project_id: nil, service_account_json: "{}"]
[project_id: nil, goth: PigeonTest.Goth]
|> Pigeon.FCM.init()
end)
end

test "raises if configured with invalid service account JSON" do
assert_raise(Pigeon.ConfigError, @invalid_service_account_json_msg, fn ->
[project_id: "example", service_account_json: nil]
test "raises if configured with invalid goth module" do
assert_raise(Pigeon.ConfigError, @invalid_goth_msg, fn ->
[project_id: "example", goth: nil]
|> Pigeon.FCM.init()
end)
end
Expand All @@ -52,6 +52,7 @@ defmodule Pigeon.FCMTest do
assert n.response == :success
end

@tag :focus
test "successfully sends a valid push with a dynamic dispatcher" do
target = {:token, valid_fcm_reg_id()}
n = Notification.new(target, %{}, @data)
Expand Down
48 changes: 48 additions & 0 deletions test/support/fcm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,51 @@ defmodule PigeonTest.LegacyFCM do
@moduledoc false
use Pigeon.Dispatcher, otp_app: :pigeon
end

defmodule PigeonTest.GothHttpClient.Stub do
@moduledoc """
A collection of functions that can be used as custom `:http_client` values. Used to avoid
calling out to GCP during tests.
"""

@doc """
Always returns a stub access_token response, as if being requested of a Google Metadata Server
## Usage
```
goth_opts = [
name: PigeonTest.Goth,
source: {:metadata, []}
http_client: {&PigeonTest.GothHttpClient.Stub.access_token_response/1, []}
]
fcm_opts = [
adapter: Pigeon.Sandbox,
project_id: "example-123",
goth: PigeonTest.Goth
]
children = [
{Goth, goth_opts}
{PigeonTest.FCM, fcm_opts}
]
Supervisor.start_link(children, strategy: :one_for_one)
"""
@spec access_token_response(keyword()) ::
{:ok,
%{
status: pos_integer(),
headers: list(),
body: String.t()
}}
def access_token_response(_) do
body = %{
"access_token" => "FAKE_APPLICATION_DEFAULT_CREDENTIALS_ACCESS_TOKEN",
"expires_in" => :timer.minutes(30),
"token_type" => "Bearer"
}

{:ok, %{status: 200, headers: [], body: Jason.encode!(body)}}
end
end
Loading

0 comments on commit dbb0da5

Please sign in to comment.