Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
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
gaffneyc committed Jun 26, 2024
1 parent 2d83aca commit 1cb71fd
Show file tree
Hide file tree
Showing 12 changed files with 354 additions and 30 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
/spec/reports/
/tmp/

/Gemfile.lock

# rspec failure tracking
.rspec_status
9 changes: 4 additions & 5 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ source "https://rubygems.org"
# Specify your gem's dependencies in simple_fcm.gemspec
gemspec

gem "rake", "~> 13.0"

gem "rspec", "~> 3.0"

gem "standard", "~> 1.3"
gem "rake"
gem "rspec"
gem "standard"
gem "webmock"
6 changes: 3 additions & 3 deletions lib/simple_fcm.rb
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
46 changes: 46 additions & 0 deletions lib/simple_fcm/authorization_middleware.rb
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)
81 changes: 81 additions & 0 deletions lib/simple_fcm/client.rb
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
130 changes: 130 additions & 0 deletions lib/simple_fcm/error.rb
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
4 changes: 0 additions & 4 deletions sig/simple_fcm.rbs

This file was deleted.

25 changes: 11 additions & 14 deletions simple_fcm.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ Gem::Specification.new do |spec|
spec.authors = ["Chris Gaffney"]
spec.email = ["gaffneyc@gmail.com"]

spec.summary = "TODO: Write a short summary, because RubyGems requires one."
spec.description = "TODO: Write a longer description or delete this line."
spec.homepage = "TODO: Put your gem's website or public repo URL here."
spec.summary = "Firebase Cloud Messaging V1 API Client"
spec.description = <<~DESCRIPTION
SimpleFCM is a minimal client for sending push notifications using the the
Firebase Cloud Messaging V1 API.
DESCRIPTION
spec.homepage = "https://github.com/deadmanssnitch/simple_fcm"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.0.0"

spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"

spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = "#{spec.homepage}/blog/main/CHANGELOG.md"

# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
Expand All @@ -29,13 +30,9 @@ Gem::Specification.new do |spec|
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
end
end
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]

# Uncomment to register a new dependency of your gem
# spec.add_dependency "example-gem", "~> 1.0"
spec.require_paths = ["lib"]

# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
spec.add_dependency "faraday", "~> 2.0"
spec.add_dependency "googleauth", "~> 1.0"
end
47 changes: 47 additions & 0 deletions spec/simple_fcm/client_spec.rb
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
Loading

0 comments on commit 1cb71fd

Please sign in to comment.