diff --git a/config/test.exs b/config/test.exs index c601570..8d8cf0f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -29,6 +29,6 @@ config :pigeon, PigeonTest.APNS.JWT, config :pigeon, PigeonTest.FCM, adapter: Pigeon.FCM, project_id: System.get_env("FCM_PROJECT"), - service_account_json: System.get_env("FCM_SERVICE_ACCOUNT_JSON") + token_fetcher: PigeonTest.Goth config :pigeon, PigeonTest.Sandbox, adapter: Pigeon.Sandbox diff --git a/lib/pigeon/dispatcher.ex b/lib/pigeon/dispatcher.ex index d41f73b..d75dbff 100644 --- a/lib/pigeon/dispatcher.ex +++ b/lib/pigeon/dispatcher.ex @@ -13,7 +13,7 @@ defmodule Pigeon.Dispatcher do opts = [ adapter: Pigeon.FCM, project_id: "example-project-123", - service_account_json: File.read!("service-account.json") + token_fetcher: YourApp.Goth ] {:ok, pid} = Pigeon.Dispatcher.start_link(opts) @@ -33,6 +33,7 @@ defmodule Pigeon.Dispatcher do @doc false def start(_type, _args) do children = [ + {Goth, name: YourApp.Goth}, YourApp.Repo, {Registry, keys: :unique, name: Registry.YourApp} ] ++ push_workers() @@ -62,7 +63,7 @@ defmodule Pigeon.Dispatcher do adapter: Pigeon.FCM, name: {:via, Registry, {Registry.YourApp, config.name}}, project_id: config.project_id, - service_account_json: config.service_account_json + token_fetcher: String.to_existing_atom(config.token_fetcher) ]} end end diff --git a/lib/pigeon/fcm.ex b/lib/pigeon/fcm.ex index 54f4759..190dcd9 100644 --- a/lib/pigeon/fcm.ex +++ b/lib/pigeon/fcm.ex @@ -13,15 +13,16 @@ defmodule Pigeon.FCM do end ``` - 2. (Optional) Add configuration to your `config.exs`. + 2. Configure the [`goth`](https://hexdocs.pm/goth/1.4.3/readme.html#installation) library, and add it to `config.exs` ``` # config.exs + # See Step 3 for alternative configuration config :your_app, YourApp.FCM, adapter: Pigeon.FCM, project_id: "example-project-123", - service_account_json: File.read!("service-account.json") + token_fetcher: YourApp.Goth ``` 3. Start your dispatcher on application boot. @@ -35,6 +36,7 @@ defmodule Pigeon.FCM do @doc false def start(_type, _args) do children = [ + {Goth, name: YourApp.Goth}, YourApp.FCM ] opts = [strategy: :one_for_one, name: YourApp.Supervisor] @@ -43,7 +45,7 @@ defmodule Pigeon.FCM do end ``` - If you skipped step two, include your configuration. + If preferred, you can include your configuration directly ``` defmodule YourApp.Application do @@ -54,6 +56,7 @@ defmodule Pigeon.FCM do @doc false def start(_type, _args) do children = [ + {Goth, name: YourApp.Goth}, {YourApp.FCM, fcm_opts()} ] opts = [strategy: :one_for_one, name: YourApp.Supervisor] @@ -64,7 +67,7 @@ defmodule Pigeon.FCM do [ adapter: Pigeon.FCM, project_id: "example-project-123", - service_account_json: File.read!("service-account.json") + token_fetcher: YourApp.Goth ] end end @@ -75,63 +78,97 @@ 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. ``` YourApp.FCM.push(n) ``` + + ## Customizable Goth Token Fetcher + If you need a customizable `:token_fetcher` that handles fetching its own configuration, here's + an example you can use to get started. + + For other `:source` configurations of `YourApp.Goth`, check out the `goth` documentation for [`Goth.start_link/1`](https://hexdocs.pm/goth/Goth.html#start_link/1) + + ``` + # lib/your_app/goth.ex + defmodule YourApp.Goth + + @spec child_spec(any()) :: Supervisor.child_spec() + def child_spec(_args) do + env_opts = Keyword.new(Application.get_env(:your_app, YourApp.Goth, [])) + opts = Keyword.merge([name: YourApp.Goth], env_opts) + + %{ + :id => YourApp.Goth, + :start => {Goth, :start_link, [opts]} + } + end + end + + # config.exs + config :your_app, YourApp.Goth, source: {:metadata, []} + + # config/test.exs + config :your_app, YourApp.Goth, + source: {:metadata, []}, + http_client: {&PigeonTest.GothHttpClient.Stub.access_token_response/1, []} + + # application.exs + def start(_type, _args) do + children = [ + # The `child_spec/1` handles fetching the proper config + YourApp.Goth, + YourApp.FCM + ] + opts = [strategy: :one_for_one, name: YourApp.Supervisor] + Supervisor.start_link(children, opts) + end + ``` + """ @max_retries 3 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) @@ -146,7 +183,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) @@ -171,22 +208,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) @@ -210,18 +231,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 diff --git a/lib/pigeon/fcm/config.ex b/lib/pigeon/fcm/config.ex index ab8183e..ec388cb 100644 --- a/lib/pigeon/fcm/config.ex +++ b/lib/pigeon/fcm/config.ex @@ -1,16 +1,23 @@ defmodule Pigeon.FCM.Config do @moduledoc false - defstruct port: 443, + defstruct token_fetcher: nil, project_id: nil, - service_account_json: nil, - uri: ~c"fcm.googleapis.com" + uri: ~c"fcm.googleapis.com", + port: 443 + + @typedoc """ + The name, or custom module, of your Goth implementation, e.g. `YourApp.Goth`. + + This is passed directly to `Goth.fetch!/1`. + """ + @type token_fetcher :: module() | term() @type t :: %__MODULE__{ - port: pos_integer, - project_id: binary, - service_account_json: binary, - uri: charlist + token_fetcher: nil | token_fetcher(), + project_id: nil | String.t(), + uri: String.t(), + port: pos_integer() } @doc ~S""" @@ -20,70 +27,41 @@ defmodule Pigeon.FCM.Config do iex> Pigeon.FCM.Config.new( ...> project_id: "example-project", - ...> service_account_json: "{\"dummy\":\"contents\"}" + ...> token_fetcher: YourApp.Goth ...> ) %Pigeon.FCM.Config{ port: 443, project_id: "example-project", - service_account_json: %{"dummy" => "contents"}, + token_fetcher: YourApp.Goth, uri: ~c"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, ~c"fcm.googleapis.com") + port: Map.get(opts, :port, 443), + project_id: opts[:project_id], + token_fetcher: opts[:token_fetcher], + uri: Map.get(opts, :uri, ~c"fcm.googleapis.com") } 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) @@ -104,18 +82,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.token_fetcher) + [ {":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"} ] @@ -148,19 +128,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!({:token_fetcher, 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 :token_fetcher 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 diff --git a/mix.exs b/mix.exs index 3bb3aa2..ce9a9d2 100644 --- a/mix.exs +++ b/mix.exs @@ -63,7 +63,8 @@ defmodule Pigeon.Mixfile do "APNS - Apple iOS": [Pigeon.APNS, Pigeon.APNS.Notification], "FCM - Firebase Cloud Messaging": [ Pigeon.FCM, - Pigeon.FCM.Notification + Pigeon.FCM.Notification, + PigeonTest.GothHttpClient.Stub ] ], main: "Pigeon" diff --git a/test/pigeon/fcm_test.exs b/test/pigeon/fcm_test.exs index 98f7617..5c1dff0 100644 --- a/test/pigeon/fcm_test.exs +++ b/test/pigeon/fcm_test.exs @@ -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_fetcher_msg ~r/^attempted to start without valid :token_fetcher module/ defp valid_fcm_reg_id do Application.get_env(:pigeon, :test)[:valid_fcm_reg_id] @@ -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, token_fetcher: 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 token_fetcher module" do + assert_raise(Pigeon.ConfigError, @invalid_fetcher_msg, fn -> + [project_id: "example", token_fetcher: nil] |> Pigeon.FCM.init() end) end @@ -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) diff --git a/test/support/fcm.ex b/test/support/fcm.ex index 8497297..4d0d3d5 100644 --- a/test/support/fcm.ex +++ b/test/support/fcm.ex @@ -2,3 +2,69 @@ defmodule PigeonTest.FCM 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. + + + ## Usage + ``` + # lib/your_app/goth.ex + defmodule YourApp.Goth + + @spec child_spec(any()) :: Supervisor.child_spec() + def child_spec(_args) do + env_opts = Keyword.new(Application.get_env(:your_app, YourApp.Goth, [])) + opts = Keyword.merge([name: YourApp.Goth], env_opts) + + %{ + :id => YourApp.Goth, + :start => {Goth, :start_link, [opts]} + } + end + end + + # config/test.exs + # Config for the Goth genserver, YourApp.Goth + config :your_app, YourApp.Goth, + source: {:metadata, []}, + http_client: {&PigeonTest.GothHttpClient.Stub.access_token_response/1, []} + + + # application.exs + def start(_type, _args) do + children = [ + # The `child_spec/1` handles fetching the proper config + YourApp.Goth, + YourApp.FCM + ] + opts = [strategy: :one_for_one, name: YourApp.Supervisor] + Supervisor.start_link(children, opts) + end + ``` + """ + + @doc """ + Always returns a stub access_token response, as if being requested of a Google Metadata Server. + + See module documentation for usage. + """ + @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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 7f9158a..956bff0 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,12 @@ ExUnit.start(capture_log: true) +fcm_credentials = + System.fetch_env!("FCM_SERVICE_ACCOUNT_JSON") + |> Jason.decode!() + workers = [ + {Goth, + name: PigeonTest.Goth, source: {:service_account, fcm_credentials, []}}, PigeonTest.ADM, PigeonTest.APNS, PigeonTest.APNS.JWT,