-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This extracts a version of the client from deadmanssnitch.com. The tests are light but it's been hand tested quite a bit.
- Loading branch information
Showing
12 changed files
with
354 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,5 +7,7 @@ | |
/spec/reports/ | ||
/tmp/ | ||
|
||
/Gemfile.lock | ||
|
||
# rspec failure tracking | ||
.rspec_status |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,8 @@ | ||
# frozen_string_literal: true | ||
|
||
require_relative "simple_fcm/version" | ||
require "simple_fcm/version" | ||
require "simple_fcm/error" | ||
require "simple_fcm/client" | ||
|
||
module SimpleFCM | ||
class Error < StandardError; end | ||
# Your code goes here... | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
# frozen_string_literal: true | ||
|
||
require "faraday" | ||
|
||
module SimpleFCM | ||
class AuthorizationMiddleware < Faraday::Middleware | ||
# Automatically refresh the access token when it is due to expire within | ||
# the next 3 minutes. | ||
DEFAULT_EXPIRY_THRESHOLD = 180 | ||
|
||
def initialize(app, credentials, expiry_threshold: DEFAULT_EXPIRY_THRESHOLD) | ||
@credentials = credentials | ||
@expiry_threshold = expiry_threshold | ||
|
||
if @expiry_threshold < 0 | ||
raise ArugmentError, "expiry_threshold must be greater than or equal to 0" | ||
end | ||
|
||
super(app) | ||
end | ||
|
||
# Force a refresh of the access token | ||
def refresh! | ||
fetched = @credentials.fetch_access_token! | ||
|
||
@token = fetched["access_token"] | ||
@type = fetched["token_type"] | ||
@expires_at = Time.now.utc + fetched["expires_in"] | ||
end | ||
|
||
# Returns true when the access token should be fetched or refreshed. | ||
def refresh? | ||
@expires_at.nil? || @expires_at <= (Time.now.utc - @expiry_threshold) | ||
end | ||
|
||
def on_request(env) | ||
if refresh? | ||
refresh! | ||
end | ||
|
||
env.request_headers["Authorization"] = "#{@type} #{@token}" | ||
end | ||
end | ||
end | ||
|
||
Faraday::Request.register_middleware(simple_fcm_auth: SimpleFCM::AuthorizationMiddleware) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
# frozen_string_literal: true | ||
|
||
require "googleauth/service_account" | ||
|
||
require "simple_fcm/error" | ||
require "simple_fcm/authorization_middleware" | ||
|
||
module SimpleFCM | ||
class Client | ||
ENDPOINT = "https://fcm.googleapis.com" | ||
private_constant :ENDPOINT | ||
|
||
SCOPE = "https://www.googleapis.com/auth/firebase.messaging" | ||
private_constant :SCOPE | ||
|
||
# @param config [string, nil] Either the path to a service account json | ||
# config file, the contents of that file as a string, or nil to load | ||
# config options from environment configs. | ||
# | ||
# @yield [conn] Yields into the connection building process | ||
# @yieldparam [Faraday::Connection] The backend connection used for requests | ||
def initialize(config = nil) | ||
@credentials = build_credentials(config) | ||
|
||
@client = Faraday::Connection.new(ENDPOINT) do |conn| | ||
conn.request :simple_fcm_auth, @credentials | ||
conn.request :json | ||
conn.response :json, parser_options: {symbolize_names: true} | ||
|
||
if block_given? | ||
yield conn | ||
end | ||
end | ||
end | ||
|
||
# Send a push notification to a device or topic through Firebase Cloud | ||
# Messaging. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send | ||
# | ||
# @param message [Hash] Message payload | ||
# | ||
# @raise [APIError] | ||
# @return [string] Message ID returned by the API | ||
def push(message) | ||
resp = @client.post("/v1/projects/#{project_id}/messages:send", message) | ||
|
||
if !resp.success? | ||
raise ::SimpleFCM::APIError, resp | ||
end | ||
|
||
resp.body[:name] | ||
end | ||
|
||
private | ||
|
||
def build_credentials(config = nil) | ||
# Config options are pulled from environment variables. | ||
if config.nil? | ||
return Google::Auth::ServiceAccountCredentials.make_creds(scope: SCOPE) | ||
end | ||
|
||
if !config.is_a?(String) | ||
raise ArgumentError, "config must be a string" | ||
end | ||
|
||
if File.exist?(config) | ||
config = File.read(config) | ||
end | ||
|
||
::Google::Auth::ServiceAccountCredentials.make_creds( | ||
json_key_io: StringIO.new(config), | ||
scope: SCOPE | ||
) | ||
end | ||
|
||
def project_id | ||
@credentials.project_id | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
# frozen_string_literal: true | ||
|
||
module SimpleFCM | ||
# Error is the root class for all errors generated by SimpleFCM. | ||
class Error < StandardError; end | ||
|
||
# API errors are raised when there is an issue making a request to the | ||
# Firebase Cloud Messaging API. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class APIError < Error | ||
attr_reader :code | ||
attr_reader :status | ||
attr_reader :details | ||
|
||
def initialize(response) | ||
if response.body.is_a?(String) | ||
@code = response.status | ||
|
||
super("FCM returned #{response.status}: #{response.body}") | ||
else | ||
payload = response.body[:error] | ||
|
||
@code = payload[:code] | ||
@status = payload[:status] | ||
@details = payload[:details] | ||
|
||
super(payload[:message]) | ||
end | ||
end | ||
|
||
def retriable? | ||
true | ||
end | ||
|
||
# exception is called instead of `new` when using the form: | ||
# raise SimpleFCM::APIError, response | ||
# | ||
# @param response [Faraday::Response] | ||
def self.exception(response) | ||
klass = | ||
case response.status | ||
when 400 then InvalidArgumentError | ||
when 401 then ThirdPartyAuthenticationError | ||
when 403 then SenderMismatchError | ||
when 404 then UnregisteredError | ||
when 429 then QuotaExceededError | ||
when 500 then InternalServerError | ||
when 503 then ServiceUnavailableError | ||
else | ||
APIError | ||
end | ||
|
||
klass.new(response) | ||
end | ||
end | ||
|
||
# InvalidArgumentError is raised when an invalid parameter or request payload | ||
# was sent to Firebase Cloud Messaging. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class InvalidArgumentError < APIError | ||
def retriable? | ||
false | ||
end | ||
end | ||
|
||
# ThirdPartyAuthenticationError is raised when sending a message targeted at | ||
# an iOS device or sending a web push registration and the third party | ||
# credentials are incorrect. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class ThirdPartyAuthenticationError < APIError | ||
def retriable? | ||
false | ||
end | ||
end | ||
|
||
# SenderMismatchError is raised when the given Sender ID is not tied to the | ||
# registration token the message is being sent to. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class SenderMismatchError < APIError | ||
def retriable? | ||
false | ||
end | ||
end | ||
|
||
# UnregisteredError is raised when the given device token is no longer valid. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class UnregisteredError < APIError | ||
def retriable? | ||
false | ||
end | ||
end | ||
|
||
# QuotaExceededError is raised when the quota for sending push notifications | ||
# to either a device or topic has been exceeded. Firebase recommends using an | ||
# exponential backoff with a minimum delay of 1 minute before retrying the | ||
# message. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class QuotaExceededError < APIError | ||
def retriable? | ||
true | ||
end | ||
end | ||
|
||
# InternalServerError is raised when Firebase encountered an unknown internal | ||
# error. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class InternalServerError < APIError | ||
def retriable? | ||
true | ||
end | ||
end | ||
|
||
# ServiceUnavailableError is raised when Firebase's push notification | ||
# services are overloaded in some way. | ||
# | ||
# @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode | ||
class ServiceUnavailableError < APIError | ||
def retriable? | ||
true | ||
end | ||
end | ||
end |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
require "securerandom" | ||
require "googleauth/credentials" | ||
|
||
RSpec.describe SimpleFCM::Client do | ||
let(:token) do | ||
{ | ||
"access_token" => SecureRandom.uuid, | ||
"token_type" => "Bearer", | ||
"expires_in" => 3600 | ||
} | ||
end | ||
|
||
subject(:client) do | ||
allow(Google::Auth::ServiceAccountCredentials) | ||
.to receive(:make_creds) | ||
.and_return( | ||
double(Google::Auth::Credentials, project_id: "widgets", fetch_access_token!: token) | ||
) | ||
|
||
SimpleFCM::Client.new("MOCK") | ||
end | ||
|
||
let(:url) { "#{SimpleFCM::Client.const_get(:ENDPOINT)}/v1/projects/widgets/messages:send" } | ||
let(:headers) { {authorization: "Bearer #{token["access_token"]}"} } | ||
|
||
it "can send a push notification" do | ||
stub = stub_request(:post, url) | ||
.with(headers: headers) | ||
.to_return( | ||
headers: {content_type: "application/json"}, | ||
body: '{"name": "projects/widgets/messages/0:1"}' | ||
) | ||
|
||
id = client.push({ | ||
message: { | ||
token: "1234" | ||
} | ||
}) | ||
|
||
expect(stub).to have_been_made | ||
expect(id).to eq("projects/widgets/messages/0:1") | ||
end | ||
end |
Oops, something went wrong.