Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

**Changed**

- Use Mint for all HTTP/1 and HTTP/2 connections. This replaces the use of `:httpoison`
and `:kadabra`. ([#296](https://github.com/codedge-llc/pigeon/pull/296))

**Fixed**

- Return `:permission_denied` FCM error response if missing privileges. ([#290](https://github.com/codedge-llc/pigeon/pull/290))
- Minor documentation fixes. ([#294](https://github.com/codedge-llc/pigeon/pull/294))

## v2.0.1 - 2024-12-28

Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2015-2024 Codedge LLC (https://www.codedge.io/)
Copyright (c) 2015-2025 Codedge LLC (https://www.codedge.io/)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ Git commit subjects use the [Karma style](http://karma-runner.github.io/5.0/dev/

## License

Copyright (c) 2015-2024 Codedge LLC (https://www.codedge.io/)
Copyright (c) 2015-2025 Codedge LLC (https://www.codedge.io/)

This library is MIT licensed. See the [LICENSE](https://github.com/codedge-llc/pigeon/blob/master/LICENSE) for details.
178 changes: 74 additions & 104 deletions lib/pigeon/adm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,10 @@ defmodule Pigeon.ADM do
@behaviour Pigeon.Adapter

import Pigeon.Tasks, only: [process_on_response: 1]
alias Pigeon.ADM.{Config, ResultParser}
alias Pigeon.ADM.{Config, ResultParser, Token}
alias Pigeon.HTTP.RequestQueue
require Logger

@token_refresh_uri "https://api.amazon.com/auth/O2/token"
@token_refresh_early_seconds 5

@impl true
def init(opts) do
config = %Config{
Expand All @@ -159,21 +157,25 @@ defmodule Pigeon.ADM do

Config.validate!(config)

{:ok, socket} = Mint.HTTP.connect(:https, "api.amazon.com", 443)

{:ok,
%{
config: config,
access_token: nil,
access_token_refreshed_datetime_erl: {{0, 0, 0}, {0, 0, 0}},
access_token_expiration_seconds: 0,
access_token_type: nil
access_token_type: nil,
socket: socket,
queue: RequestQueue.new()
}}
end

@impl true
def handle_push(notification, state) do
case refresh_access_token_if_needed(state) do
{:ok, state} ->
:ok = do_push(notification, state)
{:ok, state} = do_push(notification, state)
Comment thread
hpopp marked this conversation as resolved.
{:noreply, state}

{:error, reason} ->
Expand All @@ -186,14 +188,40 @@ defmodule Pigeon.ADM do
end

@impl true
def handle_info({_from, {:ok, %HTTPoison.Response{status_code: 200}}}, state) do
{:noreply, state}
def handle_info(msg, state) do
Pigeon.HTTP.handle_info(msg, state, &process_response/1)
end

defp process_response(%{status: 200} = request) do
%{body: body, notification: notification} = request
{:ok, json} = Pigeon.json_library().decode(body)

notification
|> ResultParser.parse(json)
|> process_on_response()
end

def handle_info(_msg, state) do
{:noreply, state}
defp process_response(request) do
%{status: status, body: body, notification: notification} = request

case Pigeon.json_library().decode(body) do
{:ok, %{"reason" => _reason} = result_json} ->
notification
|> ResultParser.parse(result_json)
|> process_on_response()

{:error, _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

defp refresh_access_token_if_needed(state) do
%{
access_token: access_token,
Expand All @@ -205,44 +233,38 @@ defmodule Pigeon.ADM do
is_nil(access_token) ->
refresh_access_token(state)

access_token_expired?(access_ref_dt_erl, access_ref_exp_secs) ->
Token.expired?(access_ref_dt_erl, access_ref_exp_secs) ->
refresh_access_token(state)

true ->
{:ok, state}
end
end

defp access_token_expired?(_refreshed_datetime_erl, 0), do: true

defp access_token_expired?(refreshed_datetime_erl, expiration_seconds) do
seconds_since(refreshed_datetime_erl) >=
expiration_seconds - @token_refresh_early_seconds
end
defp refresh_access_token(%{config: config} = state) do
headers = Token.refresh_headers()
body = Token.refresh_body(config.client_id, config.client_secret)
method = "POST"
path = "/auth/O2/token"

defp seconds_since(datetime_erl) do
gregorian_seconds =
datetime_erl
|> :calendar.datetime_to_gregorian_seconds()
{:ok, socket, ref} =
Mint.HTTP.request(state.socket, method, path, headers, body)

now_gregorian_seconds =
:os.timestamp()
|> :calendar.now_to_universal_time()
|> :calendar.datetime_to_gregorian_seconds()
new_q = RequestQueue.add(state.queue, ref, nil)

now_gregorian_seconds - gregorian_seconds
end
{:ok, socket, responses} =
receive do
message ->
Mint.HTTP.stream(socket, message)
end

defp refresh_access_token(state) do
Comment thread
hpopp marked this conversation as resolved.
post =
HTTPoison.post(
@token_refresh_uri,
token_refresh_body(state),
token_refresh_headers()
)
{request, new_q} =
responses
|> RequestQueue.process(new_q)
|> RequestQueue.pop(ref)

case post do
{:ok, %{status_code: 200, body: response_body}} ->
case request do
%{status: 200, body: response_body} ->
{:ok, response_json} = Pigeon.json_library().decode(response_body)

%{
Expand All @@ -260,54 +282,35 @@ defmodule Pigeon.ADM do
| access_token: access_token,
access_token_refreshed_datetime_erl: now_datetime_erl,
access_token_expiration_seconds: expiration_seconds,
access_token_type: token_type
access_token_type: token_type,
queue: new_q,
socket: socket
}}

{:ok, %{body: response_body}} ->
%{body: response_body} ->
{:ok, response_json} = Pigeon.json_library().decode(response_body)
Logger.error("Refresh token response: #{inspect(response_json)}")
{:error, response_json["reason"]}
end
end

defp token_refresh_body(%{
config: %{client_id: client_id, client_secret: client_secret}
}) do
%{
"grant_type" => "client_credentials",
"scope" => "messaging:push",
"client_id" => client_id,
"client_secret" => client_secret
}
|> URI.encode_query()
end
defp do_push(notification, %{queue: queue, socket: socket} = state) do
headers = adm_headers(state)
body = encode_payload(notification)
method = "POST"
path = adm_path(notification.registration_id)

defp token_refresh_headers do
[{"Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"}]
end

defp do_push(notification, state) do
request = {notification.registration_id, encode_payload(notification)}
{:ok, socket, ref} =
Mint.HTTP.request(socket, method, path, headers, body)

response = fn {reg_id, payload} ->
case HTTPoison.post(adm_uri(reg_id), payload, adm_headers(state)) do
{:ok, %HTTPoison.Response{status_code: status, body: body}} ->
notification = %{notification | registration_id: reg_id}
process_response(status, body, notification)

{:error, %HTTPoison.Error{reason: :connect_timeout}} ->
notification
|> Map.put(:response, :timeout)
|> process_on_response()
end
end
new_q = RequestQueue.add(queue, ref, notification)

Task.Supervisor.start_child(Pigeon.Tasks, fn -> response.(request) end)
:ok
{:ok, %{state | queue: new_q, socket: socket}}
end

defp adm_uri(reg_id) do
"https://api.amazon.com/messaging/registrations/#{reg_id}/messages"
@spec adm_path(String.t()) :: String.t()
defp adm_path(reg_id) do
"/messaging/registrations/#{reg_id}/messages"
end

defp adm_headers(%{access_token: access_token, access_token_type: token_type}) do
Expand Down Expand Up @@ -345,37 +348,4 @@ defmodule Pigeon.ADM do
defp put_md5(payload, md5) do
payload |> Map.put("md5", md5)
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)

notification
|> ResultParser.parse(json)
|> process_on_response()
end

defp handle_error_status_code(status, body, notification) do
case Pigeon.json_library().decode(body) do
{:ok, %{"reason" => _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
43 changes: 43 additions & 0 deletions lib/pigeon/adm/token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
defmodule Pigeon.ADM.Token do
@moduledoc false

# We expire the token slightly sooner than the actual expiration
# to account for network latency and other factors.
@token_refresh_early_seconds 5
Comment thread
hpopp marked this conversation as resolved.

def expired?(_refreshed_datetime_erl, 0), do: true

def expired?(refreshed_datetime_erl, expiration_seconds) do
seconds_since(refreshed_datetime_erl) >=
expiration_seconds - @token_refresh_early_seconds
end

defp seconds_since(datetime_erl) do
gregorian_seconds =
datetime_erl
|> :calendar.datetime_to_gregorian_seconds()

now_gregorian_seconds =
:os.timestamp()
|> :calendar.now_to_universal_time()
|> :calendar.datetime_to_gregorian_seconds()

now_gregorian_seconds - gregorian_seconds
end

@spec refresh_body(String.t(), String.t()) :: String.t()
def refresh_body(client_id, client_secret) do
%{
"grant_type" => "client_credentials",
"scope" => "messaging:push",
"client_id" => client_id,
"client_secret" => client_secret
}
|> URI.encode_query()
end

@spec refresh_headers :: [{String.t(), String.t()}]
def refresh_headers do
[{"Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"}]
end
end
Loading