diff --git a/.gitignore b/.gitignore index b04a8c8..9bc861e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,7 @@ /spec/reports/ /tmp/ +/Gemfile.lock + # rspec failure tracking .rspec_status diff --git a/Gemfile b/Gemfile index ec95aec..08225d8 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/lib/simple_fcm.rb b/lib/simple_fcm.rb index 23c03e3..1f23e5a 100644 --- a/lib/simple_fcm.rb +++ b/lib/simple_fcm.rb @@ -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 diff --git a/lib/simple_fcm/authorization_middleware.rb b/lib/simple_fcm/authorization_middleware.rb new file mode 100644 index 0000000..49a0786 --- /dev/null +++ b/lib/simple_fcm/authorization_middleware.rb @@ -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) diff --git a/lib/simple_fcm/client.rb b/lib/simple_fcm/client.rb new file mode 100644 index 0000000..7a67ad6 --- /dev/null +++ b/lib/simple_fcm/client.rb @@ -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 diff --git a/lib/simple_fcm/error.rb b/lib/simple_fcm/error.rb new file mode 100644 index 0000000..246ce6a --- /dev/null +++ b/lib/simple_fcm/error.rb @@ -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 diff --git a/sig/simple_fcm.rbs b/sig/simple_fcm.rbs deleted file mode 100644 index a8c78d7..0000000 --- a/sig/simple_fcm.rbs +++ /dev/null @@ -1,4 +0,0 @@ -module SimpleFCM - VERSION: String - # See the writing guide of rbs: https://github.com/ruby/rbs#guides -end diff --git a/simple_fcm.gemspec b/simple_fcm.gemspec index 9ca298f..780d619 100644 --- a/simple_fcm.gemspec +++ b/simple_fcm.gemspec @@ -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. @@ -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 diff --git a/spec/simple_fcm/client_spec.rb b/spec/simple_fcm/client_spec.rb new file mode 100644 index 0000000..ab4b82a --- /dev/null +++ b/spec/simple_fcm/client_spec.rb @@ -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 diff --git a/spec/simple_fcm/error_spec.rb b/spec/simple_fcm/error_spec.rb new file mode 100644 index 0000000..5dc7f08 --- /dev/null +++ b/spec/simple_fcm/error_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SimpleFCM::Error do + describe SimpleFCM::APIError do + { + 400 => SimpleFCM::InvalidArgumentError, + 401 => SimpleFCM::ThirdPartyAuthenticationError, + 403 => SimpleFCM::SenderMismatchError, + 404 => SimpleFCM::UnregisteredError, + 429 => SimpleFCM::QuotaExceededError, + 500 => SimpleFCM::InternalServerError, + 503 => SimpleFCM::ServiceUnavailableError + }.each do |status, error| + it "returns #{status} returns a #{error}" do + resp = double(Faraday::Response, status: status, body: { + error: { + code: status, + status: "ERROR", + message: error.to_s, + details: {} + } + }) + expect(SimpleFCM::APIError.exception(resp)).to be_a(error) + end + end + end +end diff --git a/spec/simple_fcm_spec.rb b/spec/simple_fcm_spec.rb index 597fae8..a113924 100644 --- a/spec/simple_fcm_spec.rb +++ b/spec/simple_fcm_spec.rb @@ -4,8 +4,4 @@ it "has a version number" do expect(SimpleFCM::VERSION).not_to be nil end - - it "does something useful" do - expect(false).to eq(true) - end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9b6ba32..4739236 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "simple_fcm" +require "webmock/rspec" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure