From dbb0da5b4bf23dd287d602589f79e08d88af136a Mon Sep 17 00:00:00 2001 From: Peter Mueller Date: Wed, 1 Feb 2023 15:50:04 -0500 Subject: [PATCH] feat: support for Goth modules for FCM --- config/test.exs | 2 +- lib/pigeon/fcm.ex | 73 +++++++--------------------- lib/pigeon/fcm/config.ex | 101 +++++++++++++++++---------------------- test/pigeon/fcm_test.exs | 11 +++-- test/support/fcm.ex | 48 +++++++++++++++++++ test/test_helper.exs | 7 +++ 6 files changed, 123 insertions(+), 119 deletions(-) diff --git a/config/test.exs b/config/test.exs index 9893ae60..795e2676 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/pigeon/fcm.ex b/lib/pigeon/fcm.ex index 54f47591..9bf9198b 100644 --- a/lib/pigeon/fcm.ex +++ b/lib/pigeon/fcm.ex @@ -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. @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/lib/pigeon/fcm/config.ex b/lib/pigeon/fcm/config.ex index 72b5717d..e2f35ba6 100644 --- a/lib/pigeon/fcm/config.ex +++ b/lib/pigeon/fcm/config.ex @@ -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""" @@ -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) @@ -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"} ] @@ -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 diff --git a/test/pigeon/fcm_test.exs b/test/pigeon/fcm_test.exs index 98f7617f..c304317c 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_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] @@ -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 @@ -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 4e959cf1..98df60a1 100644 --- a/test/support/fcm.ex +++ b/test/support/fcm.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 8bd694e6..aee58082 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,13 @@ ExUnit.start(capture_log: true) +fcm_credentials = + System.fetch_env!("FCM_SERVICE_ACCOUNT_FILE") + |> File.read!() + |> Jason.decode!() + |> Map.fetch!("source_credentials") + workers = [ + {Goth, name: PigeonTest.Goth, source: {:refresh_token, fcm_credentials}}, PigeonTest.ADM, PigeonTest.APNS, PigeonTest.APNS.JWT,