├── test
├── fixtures
│ ├── base_notifier
│ │ ├── with_apn_template.json+apn.jbuilder
│ │ ├── with_fcm_template.json+fcm.jbuilder
│ │ ├── missing_apn_template.json+fcm.jbuilder
│ │ ├── missing_fcm_template.json+apn.jbuilder
│ │ ├── with_nil_as_return_value.json+apn.jbuilder
│ │ ├── welcome.json+fcm.jbuilder
│ │ └── welcome.json+apn.jbuilder
│ ├── url_test_notifier
│ │ └── url.json+apn.jbuilder
│ ├── notifier_with_rescue_handler
│ │ ├── apn.json+apn.jbuilder
│ │ └── fcm.json+fcm.jbuilder
│ └── maintainer_notifier
│ │ ├── build_result.json+apn.jbuilder
│ │ ├── build_result.json+fcm.jbuilder
│ │ └── build_result_with_custom_apn_config.json+apn.jbuilder
├── integration
│ ├── adapters
│ │ ├── andpush_test.rb
│ │ ├── fcm_adapter_test.rb
│ │ ├── houston_test.rb
│ │ ├── lowdown_test.rb
│ │ └── apnotic_test.rb
│ ├── test_helper.rb
│ ├── apn_tcp_test_cases.rb
│ ├── apn_http2_test_cases.rb
│ └── fcm_test_cases.rb
├── notifiers
│ ├── notifier_with_rescue_handler.rb
│ ├── maintainer_notifier.rb
│ ├── base_notifier.rb
│ └── delayed_notifier.rb
├── backport
│ └── method_call_assertions.rb
├── generator_test.rb
├── railtie_test.rb
├── test_helper.rb
├── log_subscriber_test.rb
├── platforms_test.rb
├── url_helper_test.rb
├── isolated_test_helper.rb
├── notification_delivery_test.rb
└── base_test.rb
├── lib
├── pushing
│ ├── version.rb
│ ├── template_handlers.rb
│ ├── template_handlers
│ │ └── jbuilder_handler.rb
│ ├── rescuable.rb
│ ├── delivery_job.rb
│ ├── adapters
│ │ ├── test_adapter.rb
│ │ ├── apn
│ │ │ ├── houston_adapter.rb
│ │ │ ├── lowdown_adapter.rb
│ │ │ └── apnotic_adapter.rb
│ │ └── fcm
│ │ │ ├── andpush_adapter.rb
│ │ │ └── fcm_gem_adapter.rb
│ ├── log_subscriber.rb
│ ├── adapters.rb
│ ├── railtie.rb
│ ├── notification_delivery.rb
│ ├── platforms.rb
│ └── base.rb
├── generators
│ └── pushing
│ │ ├── templates
│ │ ├── application_notifier.rb
│ │ ├── notifier.rb
│ │ ├── template.json+apn.jbuilder
│ │ ├── initializer.rb
│ │ └── template.json+fcm.jbuilder
│ │ ├── USAGE
│ │ └── notifier_generator.rb
└── pushing.rb
├── certs
├── apns_example_production.pem.enc
└── apns_auth_key_for_jwt_auth.p8.enc
├── bin
├── setup
└── console
├── .gitignore
├── Gemfile
├── gemfiles
├── rails_51.gemfile
├── rails_52.gemfile
├── rails_edge.gemfile
├── rails_42.gemfile
└── rails_50.gemfile
├── Rakefile
├── Appraisals
├── CHANGELOG.md
├── LICENSE.txt
├── pushing.gemspec
├── .travis.yml
├── CODE_OF_CONDUCT.md
└── README.md
/test/fixtures/base_notifier/with_apn_template.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/fixtures/base_notifier/with_fcm_template.json+fcm.jbuilder:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/fixtures/base_notifier/missing_apn_template.json+fcm.jbuilder:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/fixtures/base_notifier/missing_fcm_template.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/pushing/version.rb:
--------------------------------------------------------------------------------
1 | module Pushing
2 | VERSION = "0.2.0"
3 | end
4 |
--------------------------------------------------------------------------------
/certs/apns_example_production.pem.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/polydice/pushing/master/certs/apns_example_production.pem.enc
--------------------------------------------------------------------------------
/test/fixtures/base_notifier/with_nil_as_return_value.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | json.aps do
2 | json.alert "New message!"
3 | end
4 |
--------------------------------------------------------------------------------
/certs/apns_auth_key_for_jwt_auth.p8.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/polydice/pushing/master/certs/apns_auth_key_for_jwt_auth.p8.enc
--------------------------------------------------------------------------------
/test/fixtures/base_notifier/welcome.json+fcm.jbuilder:
--------------------------------------------------------------------------------
1 | json.data do
2 | json.message 'Hello FCM!'
3 | end
4 | json.to 'device-token'
5 |
--------------------------------------------------------------------------------
/lib/generators/pushing/templates/application_notifier.rb:
--------------------------------------------------------------------------------
1 | <% module_namespacing do -%>
2 | class ApplicationNotifier < Pushing::Base
3 | end
4 | <% end %>
5 |
--------------------------------------------------------------------------------
/test/fixtures/base_notifier/welcome.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | json.aps do
2 | json.alert "New message!"
3 | json.badge 9
4 | json.sound "bingbong.aiff"
5 | end
6 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /Gemfile.lock
4 | /_yardoc/
5 | /coverage/
6 | /doc/
7 | /pkg/
8 | /spec/reports/
9 | /tmp/
10 | gemfiles/*.lock
11 | certs/apns_example_production.pem
12 | gemfiles/.bundle/
13 | certs/apns_auth_key_for_jwt_auth.p8
14 |
--------------------------------------------------------------------------------
/lib/generators/pushing/templates/notifier.rb:
--------------------------------------------------------------------------------
1 | <% module_namespacing do -%>
2 | class <%= class_name %>Notifier < ApplicationNotifier
3 | <% actions.each do |action| -%>
4 | def <%= action %>
5 | @greeting = "Hi"
6 |
7 | push apn: "device-token", fcm: true
8 | end
9 | <% end -%>
10 | end
11 | <% end -%>
12 |
--------------------------------------------------------------------------------
/test/integration/adapters/andpush_test.rb:
--------------------------------------------------------------------------------
1 | require 'integration/test_helper'
2 | require 'integration/fcm_test_cases'
3 |
4 | class AndpushIntegrationTest < ActiveSupport::TestCase
5 | include FcmTestCases
6 |
7 | setup do
8 | Pushing.config.fcm.adapter = :andpush
9 | end
10 |
11 | private
12 |
13 | def adapter
14 | 'andpush'
15 | end
16 | end
17 |
18 |
--------------------------------------------------------------------------------
/test/integration/adapters/fcm_adapter_test.rb:
--------------------------------------------------------------------------------
1 | require 'integration/test_helper'
2 | require 'integration/fcm_test_cases'
3 |
4 | class FcmAdapterIntegrationTest < ActiveSupport::TestCase
5 | include FcmTestCases
6 |
7 | setup do
8 | Pushing.config.fcm.adapter = :fcm_gem
9 | end
10 |
11 | private
12 |
13 | def adapter
14 | 'fcm'
15 | end
16 | end
17 |
18 |
--------------------------------------------------------------------------------
/test/fixtures/url_test_notifier/url.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | json.aps do
2 | json.alert "I'm sending you URLs!"
3 | end
4 |
5 | json.url_from_action @url_for
6 | json.url_in_view url_for(@options)
7 |
8 | json.welcome_url_from_action @welcome_url
9 | json.welcome_url_in_view welcome_url
10 |
11 | json.asset_url asset_url('puppy.jpeg')
12 | json.image_url image_url('puppy.jpeg')
13 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | # Specify your gem's dependencies in pushing.gemspec
4 | gemspec
5 |
6 | # APNs
7 | gem 'houston', require: false
8 | gem 'apnotic', '>= 1.2.0', require: false
9 | gem 'lowdown', require: false
10 |
11 | # FCM
12 | gem 'andpush', require: false
13 | gem 'fcm', require: false
14 |
15 | # Debugging
16 | gem 'pry'
17 | gem 'pry-byebug', platforms: :mri
18 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "pushing"
5 |
6 | # You can add fixtures and/or initialization code here to make experimenting
7 | # with your gem easier. You can also use a different console, if you like.
8 |
9 | # (If you use this, don't forget to add pry to your Gemfile!)
10 | # require "pry"
11 | # Pry.start
12 |
13 | require "irb"
14 | IRB.start(__FILE__)
15 |
--------------------------------------------------------------------------------
/test/fixtures/notifier_with_rescue_handler/apn.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | json.aps do
2 | json.alert do
3 | json.title "How Much Snow Has Fallen"
4 | json.body "The New York City region was predicted to receive as much as 20 inches of snow."
5 | end
6 |
7 | json.badge 9
8 | json.sound "bingbong.aiff"
9 | end
10 |
11 | json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.'
12 |
--------------------------------------------------------------------------------
/test/fixtures/notifier_with_rescue_handler/fcm.json+fcm.jbuilder:
--------------------------------------------------------------------------------
1 | json.to ENV.fetch("FCM_TEST_REGISTRATION_TOKEN")
2 |
3 | json.notification do
4 | json.title "How Much Snow Has Fallen"
5 | json.body "The New York City region was predicted to receive as much as 20 inches of snow."
6 | end
7 |
8 | json.data do
9 | json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.'
10 | end
11 |
--------------------------------------------------------------------------------
/lib/pushing/template_handlers.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | module Pushing
4 | module TemplateHandlers
5 | extend ActiveSupport::Autoload
6 |
7 | autoload :JbuilderHandler
8 |
9 | def self.lookup(template)
10 | const_get("#{template.to_s.camelize}Handler")
11 | rescue NameError
12 | raise NotImplementedError.new("The template engine `#{template}' is not yet supported.")
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/test/fixtures/maintainer_notifier/build_result.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | json.aps do
2 | json.alert do
3 | json.title "Build was successfully run"
4 | json.body "The tests for #{@ruby_version}, Rails #{@rails_version}, adapter #{@adapter} has passed."
5 | end
6 |
7 | json.badge 1
8 | json.sound "bingbong.aiff"
9 | end
10 |
11 | # json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.'
12 |
13 |
--------------------------------------------------------------------------------
/test/fixtures/maintainer_notifier/build_result.json+fcm.jbuilder:
--------------------------------------------------------------------------------
1 | json.to ENV.fetch("FCM_TEST_REGISTRATION_TOKEN")
2 | json.dry_run true
3 |
4 | json.notification do
5 | json.title "Build was successfully run"
6 | json.body "The tests for #{@ruby_version}, Rails #{@rails_version} andadapter #{@adapter} has passed."
7 | end
8 |
9 | # json.data do
10 | # json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.'
11 | # end
12 |
--------------------------------------------------------------------------------
/test/fixtures/maintainer_notifier/build_result_with_custom_apn_config.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | json.aps do
2 | json.alert do
3 | json.title "Build was successfully run"
4 | json.body "The tests for #{@ruby_version}, Rails #{@rails_version}, adapter #{@adapter} has passed."
5 | end
6 |
7 | json.badge 1
8 | json.sound "bingbong.aiff"
9 | end
10 |
11 | # json.full_message 'The New York City region was predicted to receive as much as 20 inches of snow.'
12 |
13 |
--------------------------------------------------------------------------------
/test/integration/adapters/houston_test.rb:
--------------------------------------------------------------------------------
1 | require 'integration/test_helper'
2 | require 'integration/apn_tcp_test_cases'
3 |
4 | class HoustonIntegrationTest < ActiveSupport::TestCase
5 | include ApnTcpTestCases
6 |
7 | setup do
8 | Pushing.config.apn.adapter = :houston
9 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH'))
10 | end
11 |
12 | private
13 |
14 | def adapter
15 | 'houston'
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/gemfiles/rails_51.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "houston", require: false
6 | gem "apnotic", ">= 1.2.0", require: false
7 | gem "lowdown", require: false
8 | gem "andpush", require: false
9 | gem "fcm", require: false
10 | gem "pry"
11 | gem "pry-byebug", platforms: :mri
12 | gem "railties", "~> 5.1.0"
13 | gem "actionpack", "~> 5.1.0"
14 | gem "actionview", "~> 5.1.0"
15 | gem "activejob", "~> 5.1.0"
16 |
17 | gemspec path: "../"
18 |
--------------------------------------------------------------------------------
/gemfiles/rails_52.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "houston", require: false
6 | gem "apnotic", ">= 1.2.0", require: false
7 | gem "lowdown", require: false
8 | gem "andpush", require: false
9 | gem "fcm", require: false
10 | gem "pry"
11 | gem "pry-byebug", platforms: :mri
12 | gem "railties", "~> 5.2.0.rc1"
13 | gem "actionpack", "~> 5.2.0.rc1"
14 | gem "actionview", "~> 5.2.0.rc1"
15 | gem "activejob", "~> 5.2.0.rc1"
16 |
17 | gemspec path: "../"
18 |
--------------------------------------------------------------------------------
/gemfiles/rails_edge.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | git "git://github.com/rails/rails.git" do
6 | gem "railties"
7 | gem "actionpack"
8 | gem "actionview"
9 | gem "activejob"
10 | end
11 |
12 | gem "houston", require: false
13 | gem "apnotic", ">= 1.2.0", require: false
14 | gem "lowdown", require: false
15 | gem "andpush", require: false
16 | gem "fcm", require: false
17 | gem "pry"
18 | gem "pry-byebug", platforms: :mri
19 |
20 | gemspec path: "../"
21 |
--------------------------------------------------------------------------------
/lib/pushing/template_handlers/jbuilder_handler.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require 'jbuilder/jbuilder_template'
4 |
5 | module Pushing
6 | module TemplateHandlers
7 | class JbuilderHandler < ::JbuilderHandler
8 | def self.call(*)
9 | super.gsub("json.target!", "json.attributes!").gsub("new(self)", "new(self, key_formatter: ::Pushing::TemplateHandlers::JbuilderHandler)")
10 | end
11 |
12 | def self.format(key)
13 | key
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/gemfiles/rails_42.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "houston", require: false
6 | gem "apnotic", ">= 1.2.0", require: false
7 | gem "lowdown", require: false
8 | gem "andpush", require: false
9 | gem "fcm", require: false
10 | gem "pry"
11 | gem "pry-byebug", platforms: :mri
12 | gem "rails", "~> 4.2.0"
13 | gem "actionpack", "~> 4.2.0"
14 | gem "actionview", "~> 4.2.0"
15 | gem "activejob", "~> 4.2.0"
16 | gem "minitest", "5.10.3"
17 |
18 | gemspec path: "../"
19 |
--------------------------------------------------------------------------------
/gemfiles/rails_50.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "houston", require: false
6 | gem "apnotic", ">= 1.2.0", require: false
7 | gem "lowdown", require: false
8 | gem "andpush", require: false
9 | gem "fcm", require: false
10 | gem "pry"
11 | gem "pry-byebug", platforms: :mri
12 | gem "railties", "~> 5.0.0"
13 | gem "actionpack", "~> 5.0.0"
14 | gem "actionview", "~> 5.0.0"
15 | gem "activejob", "~> 5.0.0"
16 | gem "minitest", "5.10.3"
17 |
18 | gemspec path: "../"
19 |
--------------------------------------------------------------------------------
/test/integration/adapters/lowdown_test.rb:
--------------------------------------------------------------------------------
1 | require 'integration/test_helper'
2 | require 'integration/apn_http2_test_cases'
3 |
4 | class LowdownTest < ActiveSupport::TestCase
5 | include ApnHttp2TestCases
6 |
7 | setup do
8 | Pushing.config.apn.adapter = :lowdown
9 | Pushing.config.apn.connection_scheme = :certificate
10 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH'))
11 | end
12 |
13 | private
14 |
15 | def adapter
16 | 'lowdown'
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/notifiers/notifier_with_rescue_handler.rb:
--------------------------------------------------------------------------------
1 | class NotifierWithRescueHandler < Pushing::Base
2 | cattr_accessor :last_response_from_apn
3 | cattr_accessor :last_response_from_fcm
4 |
5 | rescue_from Pushing::ApnDeliveryError do |exception|
6 | self.class.last_response_from_apn = exception.response
7 | end
8 |
9 | rescue_from Pushing::FcmDeliveryError do |exception|
10 | self.class.last_response_from_fcm = exception.response
11 | end
12 |
13 | def fcm
14 | push fcm: true
15 | end
16 |
17 | def apn
18 | push apn: 'invalid-token'
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/pushing.rb:
--------------------------------------------------------------------------------
1 | require "active_support/dependencies/autoload"
2 | require "pushing/version"
3 |
4 | module Pushing
5 | extend ::ActiveSupport::Autoload
6 |
7 | autoload :Adapters
8 | autoload :Base
9 | autoload :DeliveryJob
10 | autoload :NotificationDelivery
11 | autoload :Platforms
12 |
13 | def self.configure(&block)
14 | Base.configure(&block)
15 | Base.config.each { |k, v| Base.public_send("#{k}=", v) if Base.respond_to?("#{k}=") }
16 | end
17 |
18 | def self.config
19 | Base.config
20 | end
21 | end
22 |
23 | if defined?(Rails)
24 | require 'pushing/railtie'
25 | end
26 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rake/testtask"
3 |
4 | Rake::TestTask.new(:test) do |t|
5 | t.libs << "test"
6 | t.libs << "lib"
7 | t.test_files = FileList['test/**/*_test.rb'] - FileList['test/integration/**/*'] - ["test/railtie_test.rb"]
8 | end
9 |
10 | Rake::TestTask.new('test:integration') do |t|
11 | t.libs << "test"
12 | t.libs << "lib"
13 | t.test_files = FileList['test/integration/**/*_test.rb']
14 | end
15 |
16 | Rake::TestTask.new('test:isolated') do |t|
17 | t.libs << "test"
18 | t.libs << "lib"
19 | t.test_files = ["test/railtie_test.rb"]
20 | end
21 |
22 | task default: [:test, 'test:isolated']
23 |
--------------------------------------------------------------------------------
/lib/generators/pushing/USAGE:
--------------------------------------------------------------------------------
1 | Description:
2 | Stubs out a new notifier and its views. Passes the notifier name, either
3 | CamelCased or under_scored, and an optional list of push notifications
4 | as arguments.
5 |
6 | This generates a notifier class in app/notifiers.
7 |
8 | Example:
9 | rails generate pushing:notifier TweetNotifier new_mention_in_tweet
10 |
11 | creates a TweetNotifier class and views:
12 | Mailer: app/notifiers/tweet_notifier.rb
13 | Views: app/views/tweet_notifier/new_mention_in_tweet.json+apn.jbuilder
14 | app/views/tweet_notifier/new_mention_in_tweet.json+fcm.jbuilder
15 |
--------------------------------------------------------------------------------
/lib/pushing/rescuable.rb:
--------------------------------------------------------------------------------
1 | require 'active_support/rescuable'
2 |
3 | module Pushing #:nodoc:
4 | module Rescuable
5 | extend ActiveSupport::Concern
6 | include ActiveSupport::Rescuable
7 |
8 | class_methods do
9 | def handle_exception(exception) #:nodoc:
10 | rescue_with_handler(exception) || raise(exception)
11 | end
12 | end
13 |
14 | def handle_exceptions #:nodoc:
15 | yield
16 | rescue => exception
17 | rescue_with_handler(exception) || raise
18 | end
19 |
20 | private
21 |
22 | def process(*)
23 | handle_exceptions do
24 | super
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/notifiers/maintainer_notifier.rb:
--------------------------------------------------------------------------------
1 | class MaintainerNotifier < Pushing::Base
2 | def build_result(adapter, apn: false, fcm: false)
3 | @adapter = adapter
4 | @ruby_version = RUBY_DESCRIPTION
5 | @rails_version = Rails::VERSION::STRING
6 |
7 | push apn: (apn == true ? ENV.fetch('APN_TEST_DEVICE_TOKEN') : apn), fcm: fcm
8 | end
9 |
10 | def build_result_with_custom_apn_config(adapter, env, headers)
11 | @adapter = adapter
12 | @ruby_version = RUBY_DESCRIPTION
13 | @rails_version = Rails::VERSION::STRING
14 |
15 | push apn: { device_token: ENV.fetch('APN_TEST_DEVICE_TOKEN'), environment: env, headers: headers }
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/notifiers/base_notifier.rb:
--------------------------------------------------------------------------------
1 | class BaseNotifier < Pushing::Base
2 | def welcome(hash = {})
3 | push apn: 'device-token', fcm: true
4 | end
5 |
6 | def missing_apn_template
7 | push apn: 'device-token'
8 | end
9 |
10 | def missing_fcm_template
11 | push fcm: true
12 | end
13 |
14 | def with_apn_template
15 | push apn: 'device-token'
16 | end
17 |
18 | def with_no_apn_device_token
19 | push apn: { device_token: nil }
20 | end
21 |
22 | def with_fcm_template
23 | push fcm: true
24 | end
25 |
26 | def without_push_call
27 | end
28 |
29 | def with_nil_as_return_value
30 | push apn: 'device-token'
31 | nil
32 | end
33 | end
34 |
35 |
--------------------------------------------------------------------------------
/test/integration/test_helper.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | require 'webmock/minitest'
3 |
4 | require 'notifiers/maintainer_notifier'
5 | require 'notifiers/notifier_with_rescue_handler'
6 |
7 | WebMock.allow_net_connect!
8 |
9 | Pushing::Base.logger = Logger.new(STDOUT)
10 | Pushing.configure do |config|
11 | config.fcm.server_key = ENV.fetch('FCM_TEST_SERVER_KEY')
12 |
13 | config.apn.environment = :development
14 | config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH'))
15 | config.apn.certificate_password = ENV.fetch('APN_TEST_CERTIFICATE_PASSWORD')
16 | config.apn.default_headers = {
17 | apns_topic: ENV.fetch('APN_TEST_TOPIC')
18 | }
19 | end
20 |
--------------------------------------------------------------------------------
/test/notifiers/delayed_notifier.rb:
--------------------------------------------------------------------------------
1 | require "active_job/arguments"
2 |
3 | class DelayedNotifierError < StandardError; end
4 |
5 | class DelayedNotifier < Pushing::Base
6 | cattr_accessor :last_error
7 | cattr_accessor :last_rescue_from_instance
8 |
9 | if ActiveSupport::VERSION::MAJOR > 4
10 | rescue_from DelayedNotifierError do |error|
11 | @@last_error = error
12 | @@last_rescue_from_instance = self
13 | end
14 |
15 | rescue_from ActiveJob::DeserializationError do |error|
16 | @@last_error = error
17 | @@last_rescue_from_instance = self
18 | end
19 | end
20 |
21 | def test_message(*)
22 | push fcm: true
23 | end
24 |
25 | def test_raise(klass_name)
26 | raise klass_name.constantize, "boom"
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/pushing/delivery_job.rb:
--------------------------------------------------------------------------------
1 | require 'active_job'
2 |
3 | module Pushing
4 | class DeliveryJob < ActiveJob::Base # :nodoc:
5 | queue_as { Pushing::Base.deliver_later_queue_name }
6 |
7 | if ActiveSupport::VERSION::MAJOR > 4
8 | rescue_from StandardError, with: :handle_exception_with_notifier_class
9 | end
10 |
11 | def perform(notifier, mail_method, delivery_method, *args) #:nodoc:
12 | notifier.constantize.public_send(mail_method, *args).send(delivery_method)
13 | end
14 |
15 | private
16 |
17 | def notifier_class
18 | if notifier = Array(@serialized_arguments).first || Array(arguments).first
19 | notifier.constantize
20 | end
21 | end
22 |
23 | def handle_exception_with_notifier_class(exception)
24 | if klass = notifier_class
25 | klass.handle_exception exception
26 | else
27 | raise exception
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/pushing/adapters/test_adapter.rb:
--------------------------------------------------------------------------------
1 | require 'active_support/core_ext/module/attribute_accessors'
2 |
3 | module Pushing
4 | module Adapters
5 | class TestAdapter
6 | class Deliveries
7 | include Enumerable
8 |
9 | def initialize
10 | @deliveries = []
11 | end
12 |
13 | delegate :each, :empty?, :clear, :<<, :length, :size, to: :@deliveries
14 |
15 | def apn
16 | select {|delivery| delivery.is_a?(Platforms::ApnPayload) }
17 | end
18 |
19 | def fcm
20 | select {|delivery| delivery.is_a?(Platforms::FcmPayload) }
21 | end
22 | end
23 |
24 | private_constant :Deliveries
25 | cattr_accessor :deliveries
26 | self.deliveries = Deliveries.new
27 |
28 | def initialize(*)
29 | end
30 |
31 | def push!(notification)
32 | self.class.deliveries << notification if notification
33 | notification
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise "rails_edge" do
2 | git 'git://github.com/rails/rails.git' do
3 | gem "railties"
4 | gem "actionpack"
5 | gem "actionview"
6 | gem "activejob"
7 | end
8 | end
9 |
10 | appraise "rails_52" do
11 | gem "railties", '~> 5.2.0.rc1'
12 | gem "actionpack", '~> 5.2.0.rc1'
13 | gem "actionview", '~> 5.2.0.rc1'
14 | gem "activejob", '~> 5.2.0.rc1'
15 | end
16 |
17 | appraise "rails_51" do
18 | gem "railties", '~> 5.1.0'
19 | gem "actionpack", '~> 5.1.0'
20 | gem "actionview", '~> 5.1.0'
21 | gem "activejob", '~> 5.1.0'
22 | end
23 |
24 | appraise "rails_50" do
25 | gem "railties", '~> 5.0.0'
26 | gem "actionpack", '~> 5.0.0'
27 | gem "actionview", '~> 5.0.0'
28 | gem "activejob", '~> 5.0.0'
29 | gem "minitest", '5.10.3'
30 | end
31 |
32 | appraise "rails_42" do
33 | gem "rails", '~> 4.2.0'
34 | gem "actionpack", '~> 4.2.0'
35 | gem "actionview", '~> 4.2.0'
36 | gem "activejob", '~> 4.2.0'
37 | gem "minitest", '5.10.3'
38 | end
39 |
--------------------------------------------------------------------------------
/test/integration/apn_tcp_test_cases.rb:
--------------------------------------------------------------------------------
1 | module ApnTcpTestCases
2 | def setup
3 | super
4 | Pushing::Adapters.const_get(:ADAPTER_INSTANCES).clear
5 | end
6 |
7 | def test_actually_push_notification
8 | assert_nothing_raised do
9 | MaintainerNotifier.build_result(adapter, apn: true).deliver_now!
10 | end
11 | end
12 |
13 | def test_actually_push_notification_with_custom_config
14 | # Set the wrong topic/environment to make sure you can override these on the fly
15 | Pushing.config.apn.environment = :production
16 | Pushing.config.apn.default_headers = {
17 | apns_topic: 'wrong.topicname.com'
18 | }
19 |
20 | assert_nothing_raised do
21 | MaintainerNotifier.build_result_with_custom_apn_config(adapter, :development, {}).deliver_now!
22 | end
23 | ensure
24 | Pushing.config.apn.environment = :development
25 | Pushing.config.apn.default_headers = {
26 | apns_topic: ENV.fetch('APN_TEST_TOPIC')
27 | }
28 | end
29 |
30 | def adapter
31 | raise NotImplementedError
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [v0.2.0](https://github.com/yuki24/pushing/tree/v0.2.0)
2 |
3 | _released at 2018-03-11 00:38:57 UTC_
4 |
5 | #### New Features
6 |
7 | - Add support for Rails 5.2 ([9345a53](https://github.com/yuki24/pushing/commit/9345a53e12745e0d4fbaf83a103fe9b1f008fe35))
8 | - Log APNs environment ([#16](https://github.com/yuki24/pushing/issues/16), [88c46ce](https://github.com/yuki24/pushing/commit/88c46ce6937f12adf5296049423f419c348106f1), [27dd2b6](https://github.com/yuki24/pushing/commit/27dd2b65119f53fcceaf3ae6688152d9aa6c36a8))
9 | - Add support for APNs' token-based authentication ([6f2a5dc](https://github.com/yuki24/pushing/commit/6f2a5dc1ee885ba76a687f361e9c2fa1bb8cdce8))
10 | - Add the ability to call URL helpers in notifiers ([#9](https://github.com/yuki24/pushing/issues/9), [3fecf17](https://github.com/yuki24/pushing/commit/3fecf175b8f3a5aad6bc93698acd2e6d1cc47a5f))
11 |
12 | ## [v0.1.0: First release](https://github.com/yuki24/pushing/tree/v0.1.0)
13 |
14 | _released at 2018-03-08 01:36:18 UTC_
15 |
16 |
--------------------------------------------------------------------------------
/test/integration/adapters/apnotic_test.rb:
--------------------------------------------------------------------------------
1 | require 'integration/test_helper'
2 | require 'integration/apn_http2_test_cases'
3 |
4 | class ApnoticIntegrationForCertificateConnectionTest < ActiveSupport::TestCase
5 | include ApnHttp2TestCases
6 |
7 | setup do
8 | Pushing.config.apn.adapter = :apnotic
9 | Pushing.config.apn.connection_scheme = :certificate
10 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_CERTIFICATE_PATH'))
11 | end
12 |
13 | private
14 |
15 | def adapter
16 | 'apnotic'
17 | end
18 | end
19 |
20 | class ApnoticIntegrationTestForJwtConnection < ActiveSupport::TestCase
21 | include ApnHttp2TestCases
22 |
23 | setup do
24 | Pushing.config.apn.adapter = :apnotic
25 | Pushing.config.apn.connection_scheme = :token
26 | Pushing.config.apn.certificate_path = File.join(File.expand_path("./"), ENV.fetch('APN_TEST_AUTH_KEY_PATH'))
27 | Pushing.config.apn.key_id = ENV['APN_TEST_KEY_ID']
28 | Pushing.config.apn.team_id = ENV['APN_TEST_TEAM_ID']
29 | end
30 |
31 | private
32 |
33 | def adapter
34 | 'apnotic'
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Yuki Nishijima
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/pushing/adapters/apn/houston_adapter.rb:
--------------------------------------------------------------------------------
1 | require 'houston'
2 |
3 | module Pushing
4 | module Adapters
5 | class HoustonAdapter
6 | attr_reader :certificate_path, :client
7 |
8 | def initialize(apn_settings)
9 | @certificate_path = apn_settings.certificate_path
10 |
11 | @client = {
12 | production: Houston::Client.production,
13 | development: Houston::Client.development
14 | }
15 | @client[:production].certificate = @client[:development].certificate = File.read(certificate_path)
16 | end
17 |
18 | def push!(notification)
19 | payload = notification.payload.dup
20 | aps = payload.delete(:aps)
21 | aps[:device] = notification.device_token
22 |
23 | houston_notification = Houston::Notification.new(payload.merge(aps))
24 | client[notification.environment].push(houston_notification)
25 | rescue => cause
26 | error = Pushing::ApnDeliveryError.new("Error while trying to send push notification: #{cause.message}", nil, notification)
27 |
28 | raise error, error.message, cause.backtrace
29 | end
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/test/backport/method_call_assertions.rb:
--------------------------------------------------------------------------------
1 | require "minitest/mock"
2 |
3 | module MethodCallAssertions # :nodoc:
4 | private
5 | def assert_called(object, method_name, message = nil, times: 1, returns: nil)
6 | times_called = 0
7 |
8 | object.stub(method_name, proc { times_called += 1; returns }) { yield }
9 |
10 | error = "Expected #{method_name} to be called #{times} times, " \
11 | "but was called #{times_called} times"
12 | error = "#{message}.\n#{error}" if message
13 | assert_equal times, times_called, error
14 | end
15 |
16 | def assert_called_with(object, method_name, args = [], returns: nil)
17 | mock = Minitest::Mock.new
18 |
19 | if args.all? { |arg| arg.is_a?(Array) }
20 | args.each { |arg| mock.expect(:call, returns, arg) }
21 | else
22 | mock.expect(:call, returns, args)
23 | end
24 |
25 | object.stub(method_name, mock) { yield }
26 |
27 | mock.verify
28 | end
29 |
30 | def assert_not_called(object, method_name, message = nil, &block)
31 | assert_called(object, method_name, message, times: 0, &block)
32 | end
33 |
34 | def stub_any_instance(klass, instance: klass.new)
35 | klass.stub(:new, instance) { yield instance }
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/pushing.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'pushing/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "pushing"
8 | spec.version = Pushing::VERSION
9 | spec.authors = ["Yuki Nishijima"]
10 | spec.email = ["yk.nishijima@gmail.com"]
11 | spec.summary = %q{Push notification framework that does not hurt}
12 | spec.description = %q{Finally, push notification framework that does not hurt. Currently supports Android (FCM) and iOS (APNs)}
13 | spec.homepage = "https://github.com/yuki24/pushing"
14 | spec.license = "MIT"
15 | spec.files = `git ls-files -z`.split("\x0").reject {|f| f.match(%r{^(test)/}) }
16 | spec.require_paths = ["lib"]
17 |
18 | spec.add_dependency "actionpack", ">= 4.2.0"
19 | spec.add_dependency "actionview", ">= 4.2.0"
20 | spec.add_dependency "activejob", ">= 4.2.0"
21 |
22 | spec.add_development_dependency "bundler"
23 | spec.add_development_dependency "rake"
24 | spec.add_development_dependency "minitest"
25 | spec.add_development_dependency "appraisal"
26 | spec.add_development_dependency "jbuilder"
27 | spec.add_development_dependency "webmock"
28 | end
29 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | nguage: ruby
2 | script: bundle exec rake test test:isolated test:integration
3 | cache: bundler
4 | sudo: false
5 |
6 | before_install:
7 | - gem install bundler
8 | - openssl aes-256-cbc -K $encrypted_86ed69f44076_key -iv $encrypted_86ed69f44076_iv -in ./certs/apns_auth_key_for_jwt_auth.p8.enc -out certs/apns_auth_key_for_jwt_auth.p8 -d
9 | - openssl aes-256-cbc -K $encrypted_86ed69f44076_key -iv $encrypted_86ed69f44076_iv -in ./certs/apns_example_production.pem.enc -out certs/apns_example_production.pem -d
10 |
11 | rvm:
12 | - 2.2.10
13 | - 2.3.8
14 | - 2.4.5
15 | - 2.5.3
16 | - 2.6.1
17 | - ruby-head
18 | - jruby-9.2.6.0
19 | - jruby-head
20 |
21 | gemfile:
22 | - gemfiles/rails_42.gemfile
23 | - gemfiles/rails_50.gemfile
24 | - gemfiles/rails_51.gemfile
25 | - gemfiles/rails_52.gemfile
26 | - gemfiles/rails_edge.gemfile
27 |
28 | matrix:
29 | allow_failures:
30 | - rvm: ruby-head
31 | - rvm: jruby-9.2.6.0
32 | - rvm: jruby-head
33 | - gemfile: gemfiles/rails_edge.gemfile
34 | exclude:
35 | - rvm: 2.4.5
36 | gemfile: gemfiles/rails_edge.gemfile
37 | - rvm: 2.3.8
38 | gemfile: gemfiles/rails_edge.gemfile
39 | - rvm: 2.2.10
40 | gemfile: gemfiles/rails_52.gemfile
41 | - rvm: 2.2.10
42 | gemfile: gemfiles/rails_edge.gemfile
43 |
--------------------------------------------------------------------------------
/lib/pushing/adapters/fcm/andpush_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require 'andpush'
4 | require 'active_support/core_ext/hash/transform_values'
5 |
6 | module Pushing
7 | module Adapters
8 | class AndpushAdapter
9 | attr_reader :server_key
10 |
11 | def initialize(fcm_settings)
12 | @server_key = fcm_settings.server_key
13 | end
14 |
15 | def push!(notification)
16 | FcmResponse.new(client.push(notification.payload))
17 | rescue => e
18 | response = e.respond_to?(:response) ? FcmResponse.new(e.response) : nil
19 | error = Pushing::FcmDeliveryError.new("Error while trying to send push notification: #{e.message}", response, notification)
20 |
21 | raise error, error.message, e.backtrace
22 | end
23 |
24 | private
25 |
26 | def client
27 | @client ||= Andpush.build(server_key)
28 | end
29 |
30 | class FcmResponse < SimpleDelegator
31 | def json
32 | @json ||= __getobj__.json
33 | end
34 |
35 | def code
36 | __getobj__.code.to_i
37 | end
38 |
39 | def headers
40 | __getobj__.headers.transform_values {|value| value.join(", ") }
41 | end
42 | end
43 |
44 | private_constant :FcmResponse
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/test/generator_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 | require "rails/generators/test_case"
3 |
4 | require 'generators/pushing/notifier_generator'
5 |
6 | class NotifierGeneratorTest < Rails::Generators::TestCase
7 | tests Pushing::NotifierGenerator
8 | arguments %w(TweetNotifier new_mention_in_tweet)
9 | destination File.join(File.expand_path('../', File.dirname(__FILE__)), "tmp")
10 | setup :prepare_destination
11 |
12 | test "notifier skeleton is created" do
13 | run_generator
14 |
15 | assert_file "config/initializers/pushing.rb" do |notifier|
16 | assert_match(/Pushing.configure do |config|/, notifier)
17 | end
18 |
19 | assert_file "app/notifiers/application_notifier.rb" do |notifier|
20 | assert_match(/class ApplicationNotifier < Pushing::Base/, notifier)
21 | end
22 |
23 | assert_file "app/notifiers/tweet_notifier.rb" do |notifier|
24 | assert_match(/class TweetNotifier < ApplicationNotifier/, notifier)
25 | end
26 |
27 | assert_file "app/views/tweet_notifier/new_mention_in_tweet.json+apn.jbuilder" do |view|
28 | assert_match(/json\.aps do/, view)
29 | end
30 |
31 | assert_file "app/views/tweet_notifier/new_mention_in_tweet.json+fcm.jbuilder" do |view|
32 | assert_match(/json\.to 'REPLACE_WITH_ACTUAL_REGISTRATION_ID_OR_TOPIC'/, view)
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/railtie_test.rb:
--------------------------------------------------------------------------------
1 | require "isolated_test_helper"
2 |
3 | class RailtieTest < ActiveSupport::TestCase
4 | include ActiveSupport::Testing::Isolation
5 | include Generation
6 |
7 | setup do
8 | build_app
9 | FileUtils.rm_rf "#{app_path}/config/environments"
10 | end
11 |
12 | teardown do
13 | teardown_app
14 | end
15 |
16 | test "sets pushing load paths" do
17 | add_to_config <<-RUBY
18 | config.root = "#{app_path}"
19 | RUBY
20 |
21 | require "#{app_path}/config/environment"
22 |
23 | expanded_path = File.expand_path("app/views", app_path)
24 | assert_equal expanded_path, Pushing::Base.view_paths[0].to_s
25 | end
26 |
27 | test "sets default url options" do
28 | add_to_initializer <<-RUBY
29 | Pushing.configure do |config|
30 | config.default_url_options[:host] = 'www.example.org'
31 | end
32 | RUBY
33 |
34 | require "#{app_path}/config/environment"
35 |
36 | assert_equal 'www.example.org', Pushing::Base.default_url_options[:host]
37 | end
38 |
39 | test "sets asset host" do
40 | add_to_initializer <<-RUBY
41 | Pushing.configure do |config|
42 | config.asset_host = 'https://www.example.org'
43 | end
44 | RUBY
45 |
46 | require "#{app_path}/config/environment"
47 |
48 | assert_equal 'https://www.example.org', Pushing::Base.asset_host
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/pushing/log_subscriber.rb:
--------------------------------------------------------------------------------
1 | require "active_support/log_subscriber"
2 | require "active_support/core_ext/string/indent"
3 |
4 | module Pushing
5 | # Implements the ActiveSupport::LogSubscriber for logging notifications when
6 | # a push notification is delivered.
7 | class LogSubscriber < ActiveSupport::LogSubscriber
8 | # A notification was delivered.
9 | def deliver(event)
10 | return unless logger.info?
11 |
12 | event.payload[:notification].each do |platform, payload|
13 | info do
14 | recipients = payload.recipients.map {|r| r.truncate(32) }.join(", ")
15 | " #{platform.upcase}: sent push notification to #{recipients} (#{event.duration.round(1)}ms)"
16 | end
17 |
18 | next unless logger.debug?
19 | debug do
20 | "Payload:\n#{JSON.pretty_generate(payload.payload).indent(2)}\n".indent(2)
21 | end
22 | end
23 | end
24 |
25 | # A notification was generated.
26 | def process(event)
27 | return unless logger.debug?
28 |
29 | debug do
30 | notifier = event.payload[:notifier]
31 | action = event.payload[:action]
32 |
33 | "#{notifier}##{action}: processed outbound push notification in #{event.duration.round(1)}ms"
34 | end
35 | end
36 |
37 | # Use the logger configured for Pushing::Base.
38 | def logger
39 | Pushing::Base.logger
40 | end
41 | end
42 | end
43 |
44 | Pushing::LogSubscriber.attach_to :push_notification
45 |
--------------------------------------------------------------------------------
/lib/pushing/adapters.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | module Pushing
4 | module Adapters
5 | extend ActiveSupport::Autoload
6 |
7 | autoload :HoustonAdapter, 'pushing/adapters/apn/houston_adapter'
8 | autoload :ApnoticAdapter, 'pushing/adapters/apn/apnotic_adapter'
9 | autoload :LowdownAdapter, 'pushing/adapters/apn/lowdown_adapter'
10 | autoload :AndpushAdapter, 'pushing/adapters/fcm/andpush_adapter'
11 | autoload :FcmGemAdapter, 'pushing/adapters/fcm/fcm_gem_adapter'
12 | autoload :TestAdapter
13 |
14 | # Hash object that holds referenses to adapter instances.
15 | ADAPTER_INSTANCES = {}
16 |
17 | # Mutex object used to ensure the +instance+ method creates a singleton object.
18 | MUTEX = Mutex.new
19 |
20 | private_constant :ADAPTER_INSTANCES, :MUTEX
21 |
22 | class << self
23 | ##
24 | # Returns the constant for the specified adapter name.
25 | #
26 | # Pushing::Adapters.lookup(:apnotic)
27 | # # => Pushing::Adapters::ApnoticAdapter
28 | def lookup(name)
29 | const_get("#{name.to_s.camelize}Adapter")
30 | end
31 |
32 | ##
33 | # Provides an adapter instance specified in the +configuration+. If the adapter is not found in
34 | # +ADAPTER_INSTANCES+, it'll look up the adapter class and create a new instance using the
35 | # +configuration+.
36 | def instance(configuration)
37 | ADAPTER_INSTANCES[configuration.adapter] || MUTEX.synchronize do
38 | ADAPTER_INSTANCES[configuration.adapter] ||= lookup(configuration.adapter).new(configuration)
39 | end
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/pushing/railtie.rb:
--------------------------------------------------------------------------------
1 | require "active_job/railtie"
2 | require "rails"
3 | require "abstract_controller/railties/routes_helpers"
4 |
5 | module Pushing
6 | class Railtie < Rails::Railtie # :nodoc:
7 | config.eager_load_namespaces << Pushing
8 |
9 | initializer "pushing.logger" do
10 | ActiveSupport.on_load(:pushing) { self.logger ||= Rails.logger }
11 | end
12 |
13 | initializer "pushing.add_view_paths" do |app|
14 | views = app.config.paths["app/views"].existent
15 | if !views.empty?
16 | ActiveSupport.on_load(:pushing) { prepend_view_path(views) }
17 | end
18 | end
19 |
20 | initializer "pushing.set_configs" do |app|
21 | paths = app.config.paths
22 | options = ActiveSupport::OrderedOptions.new
23 | options.default_url_options = {}
24 |
25 | if app.config.force_ssl
26 | options.default_url_options[:protocol] ||= "https"
27 | end
28 |
29 | options.assets_dir ||= paths["public"].first
30 |
31 | # make sure readers methods get compiled
32 | options.asset_host ||= app.config.asset_host
33 | options.relative_url_root ||= app.config.relative_url_root
34 |
35 | ActiveSupport.on_load(:pushing) do
36 | include AbstractController::UrlFor
37 | extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false)
38 | include app.routes.mounted_helpers
39 |
40 | options.each { |k, v| send("#{k}=", v) }
41 | config.merge!(options)
42 | end
43 | end
44 |
45 | initializer "pushing.compile_config_methods" do
46 | ActiveSupport.on_load(:pushing) do
47 | config.compile_methods! if config.respond_to?(:compile_methods!)
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2 |
3 | require 'active_support/core_ext/kernel/reporting'
4 | require 'pushing'
5 |
6 | # These are the normal settings that will be set up by Railties
7 | # TODO: Have these tests support other combinations of these values
8 | silence_warnings do
9 | Encoding.default_internal = "UTF-8"
10 | Encoding.default_external = "UTF-8"
11 | end
12 |
13 | require 'active_support/testing/autorun'
14 | require 'minitest/pride'
15 | require 'pry'
16 | require 'pry-byebug' if RUBY_ENGINE == 'ruby'
17 |
18 | # Emulate AV railtie
19 | require 'action_view'
20 |
21 | # Show backtraces for deprecated behavior for quicker cleanup.
22 | ActiveSupport::Deprecation.debug = true
23 |
24 | FIXTURE_LOAD_PATH = File.expand_path('fixtures', File.dirname(__FILE__))
25 | Pushing::Base.view_paths = FIXTURE_LOAD_PATH
26 |
27 | require "rails"
28 | require 'jbuilder'
29 | require 'jbuilder/jbuilder_template'
30 |
31 | ActionView::Template.register_template_handler :jbuilder, JbuilderHandler
32 |
33 | begin
34 | require 'active_support/testing/method_call_assertions'
35 | ActiveSupport::TestCase.include ActiveSupport::Testing::MethodCallAssertions
36 | rescue LoadError
37 | # Rails 4.2 doesn't come with ActiveSupport::Testing::MethodCallAssertions
38 | require 'backport/method_call_assertions'
39 | ActiveSupport::TestCase.include MethodCallAssertions
40 |
41 | # FIXME: we have tests that depend on run order, we should fix that and
42 | # remove this method call.
43 | require 'active_support/test_case'
44 | ActiveSupport::TestCase.test_order = :sorted
45 | end
46 |
47 | Pushing.config.apn.environment = :development
48 | Pushing.config.apn.adapter = :test
49 | Pushing.config.fcm.adapter = :test
50 |
--------------------------------------------------------------------------------
/lib/generators/pushing/notifier_generator.rb:
--------------------------------------------------------------------------------
1 | module Pushing
2 | class NotifierGenerator < Rails::Generators::NamedBase
3 | source_root File.expand_path("../templates", __FILE__)
4 |
5 | argument :actions, type: :array, default: [], banner: "method method"
6 |
7 | check_class_collision suffix: "Notifier"
8 |
9 | def create_notifier_file
10 | template "notifier.rb", File.join("app/notifiers", class_path, "#{file_name}_notifier.rb")
11 |
12 | actions.each do |action|
13 | template "template.json+apn.jbuilder", File.join("app/views/", "#{file_name}_notifier", "#{action}.json+apn.jbuilder")
14 | template "template.json+fcm.jbuilder", File.join("app/views/", "#{file_name}_notifier", "#{action}.json+fcm.jbuilder")
15 | end
16 |
17 | in_root do
18 | if behavior == :invoke && !File.exist?(application_notifier_file_name)
19 | template "application_notifier.rb", application_notifier_file_name
20 | end
21 |
22 | if behavior == :invoke && !File.exist?(initializer_file_name)
23 | template "initializer.rb", initializer_file_name
24 | end
25 | end
26 | end
27 |
28 | private
29 |
30 | def file_name # :doc:
31 | @_file_name ||= super.gsub(/_notifier/i, "")
32 | end
33 |
34 | def initializer_file_name
35 | @_initializer_file_name ||= "config/initializers/pushing.rb"
36 | end
37 |
38 | def application_notifier_file_name
39 | @_application_notifier_file_name ||= if mountable_engine?
40 | "app/notifiers/#{namespaced_path}/application_notifier.rb"
41 | else
42 | "app/notifiers/application_notifier.rb"
43 | end
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/pushing/adapters/fcm/fcm_gem_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require 'json'
4 | require 'fcm'
5 | require 'active_support/core_ext/hash/slice'
6 |
7 | module Pushing
8 | module Adapters
9 | class FcmGemAdapter
10 | SUCCESS_CODES = (200..299).freeze
11 |
12 | attr_reader :server_key
13 |
14 | def initialize(fcm_settings)
15 | @server_key = fcm_settings.server_key
16 | end
17 |
18 | def push!(notification)
19 | json = notification.payload
20 | ids = json.delete(:registration_ids) || Array(json.delete(:to))
21 | response = FCM.new(server_key).send(ids, json)
22 |
23 | if SUCCESS_CODES.include?(response[:status_code])
24 | FcmResponse.new(response.slice(:body, :headers, :status_code).merge(raw_response: response))
25 | else
26 | raise "#{response[:response]} (response body: #{response[:body]})"
27 | end
28 | rescue => cause
29 | resopnse = FcmResponse.new(response.slice(:body, :headers, :status_code).merge(raw_response: response)) if response
30 | error = Pushing::FcmDeliveryError.new("Error while trying to send push notification: #{cause.message}", resopnse, notification)
31 |
32 | raise error, error.message, cause.backtrace
33 | end
34 |
35 | class FcmResponse
36 | attr_reader :body, :headers, :status_code, :raw_response
37 |
38 | alias code status_code
39 |
40 | def initialize(body: , headers: , status_code: , raw_response: )
41 | @body, @headers, @status_code, @raw_response = body, headers, status_code, raw_response
42 | end
43 |
44 | def json
45 | @json ||= JSON.parse(body, symbolize_names: true) if body.is_a?(String)
46 | end
47 | end
48 |
49 | private_constant :FcmResponse
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/test/log_subscriber_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 | require "notifiers/base_notifier"
3 | require "active_support/core_ext/string/strip"
4 | require "active_support/log_subscriber/test_helper"
5 |
6 | class LogSubscriberTest < ActiveSupport::TestCase
7 | include ActiveSupport::LogSubscriber::TestHelper
8 |
9 | def setup
10 | super
11 | Pushing::LogSubscriber.attach_to :push_notification
12 | end
13 |
14 | def set_logger(logger)
15 | Pushing::Base.logger = logger
16 | end
17 |
18 | def test_deliver_is_notified
19 | Pushing::Base.logger.level = 0
20 | BaseNotifier.welcome.deliver_now!
21 | wait
22 |
23 | assert_equal(2, @logger.logged(:info).size)
24 | assert_match(/APN: sent push notification to development\/device-token/, @logger.logged(:info).first)
25 | assert_match(/FCM: sent push notification to device-token/, @logger.logged(:info).second)
26 |
27 | assert_equal(3, @logger.logged(:debug).size)
28 | assert_match(/BaseNotifier#welcome: processed outbound push notification in [\d.]+ms/, @logger.logged(:debug).first)
29 | assert_equal(<<-DEBUG_LOG.strip_heredoc.strip, @logger.logged(:debug).second)
30 | Payload:
31 | {
32 | "aps": {
33 | "alert": "New message!",
34 | "badge": 9,
35 | "sound": "bingbong.aiff"
36 | }
37 | }
38 | DEBUG_LOG
39 | ensure
40 | BaseNotifier.deliveries.clear
41 | end
42 |
43 | def test_deliver_is_notified_in_info
44 | Pushing::Base.logger.level = 1
45 | BaseNotifier.welcome.deliver_now!
46 | wait
47 |
48 | assert_equal(2, @logger.logged(:info).size)
49 | assert_match(/APN: sent push notification to development\/device-token/, @logger.logged(:info).first)
50 | assert_match(/FCM: sent push notification to device-token/, @logger.logged(:info).second)
51 |
52 | assert_equal 0, @logger.logged(:debug).size
53 | ensure
54 | BaseNotifier.deliveries.clear
55 | end
56 |
57 | def test_deliver_is_not_notified_in_warn
58 | Pushing::Base.logger.level = 2
59 | BaseNotifier.welcome.deliver_now!
60 | wait
61 |
62 | assert_equal 0, @logger.logged(:info).size
63 | assert_equal 0, @logger.logged(:debug).size
64 | ensure
65 | BaseNotifier.deliveries.clear
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/test/integration/apn_http2_test_cases.rb:
--------------------------------------------------------------------------------
1 | module ApnHttp2TestCases
2 | def setup
3 | super
4 | Pushing::Adapters.const_get(:ADAPTER_INSTANCES).clear
5 | end
6 |
7 | def test_actually_push_notification
8 | responses = MaintainerNotifier.build_result(adapter, apn: true).deliver_now!
9 | response = responses.first
10 |
11 | assert_equal 200, response.code
12 | assert_nil response.json
13 | assert_match /\A\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\z/, response.headers["apns-id"]
14 | end
15 |
16 | def test_push_notification_with_custom_config
17 | # Set the wrong topic/environment to make sure you can override these on the fly
18 | Pushing.config.apn.environment = :production
19 | Pushing.config.apn.default_headers = {
20 | apns_topic: 'wrong.topicname.com'
21 | }
22 |
23 | apns_id = SecureRandom.uuid
24 | headers = {
25 | apns_id: apns_id,
26 | apns_expiration: 1.hour.from_now,
27 | apns_priority: 5,
28 | apns_topic: ENV.fetch('APN_TEST_TOPIC'),
29 | apns_collapse_id: 'pushing-testing'
30 | }
31 |
32 | responses = MaintainerNotifier.build_result_with_custom_apn_config(adapter, :development, headers).deliver_now!
33 | response = responses.first
34 |
35 | assert_equal 200, response.code
36 | assert_nil response.json
37 |
38 | if adapter == 'lowdown'
39 | assert_match(/\A\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\z/, response.headers["apns-id"])
40 | else
41 | assert_match apns_id, response.headers["apns-id"]
42 | end
43 | ensure
44 | Pushing.config.apn.environment = :development
45 | Pushing.config.apn.default_headers = {
46 | apns_topic: ENV.fetch('APN_TEST_TOPIC')
47 | }
48 | end
49 |
50 | def test_notifier_raises_exception_on_http_client_error
51 | error = assert_raises Pushing::ApnDeliveryError do
52 | MaintainerNotifier.build_result(adapter, apn: 'bad-token').deliver_now!
53 | end
54 |
55 | assert_equal 400, error.response.code
56 | assert_equal "BadDeviceToken", error.response.json[:reason]
57 | assert_equal 'bad-token', error.notification.device_token
58 | end
59 |
60 | def test_raise_error_on_error_response
61 | assert_nothing_raised do
62 | NotifierWithRescueHandler.apn.deliver_now!
63 | end
64 |
65 | response = NotifierWithRescueHandler.last_response_from_apn
66 | assert_equal 400, response.code
67 | end
68 |
69 | def adapter
70 | raise NotImplementedError
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/lib/generators/pushing/templates/template.json+apn.jbuilder:
--------------------------------------------------------------------------------
1 | # In this file you can customize the APN payload. For more details about key preference, see
2 | # Apple's offitial documentation here: http://bit.ly/apns-doc
3 |
4 | # the JSON dictionary can include custom keys and values with your app-specific content.
5 | # json.custom_data1 'content1...'
6 | # json.custom_data2 'content2...'
7 |
8 | # The aps dictionary contains the keys used by Apple to deliver the notification to the user's device.
9 | json.aps do
10 |
11 | # Include this key when you want the system to display a standard alert or a banner (Dictionary or String).
12 | # json.alert 'REPLACE_WITH_ACTUAL_TITLE'
13 | json.alert do
14 | # A short string describing the purpose of the notification.
15 | json.title 'REPLACE_WITH_ACTUAL_TITLE'
16 |
17 | # The text of the alert message.
18 | json.body 'REPLACE_WITH_ACTUAL_BODY'
19 |
20 | # The key to a title string in the Localizable.strings file for the current localization.
21 | # json.set! 'title-loc-key', 'key in Localizable.strings'
22 |
23 | # Variable string values to appear in place of the format specifiers in 'title-loc-key'.
24 | # json.set! 'title-loc-args', ['arg1', 'arg2']
25 |
26 | # If a string is specified, the system displays an alert that includes the Close and View buttons.
27 | # json.set! 'action-loc-key', 'key in Localizable.strings'
28 |
29 | # A key to an alert-message string in a Localizable.strings file for the current localization.
30 | # json.set! 'loc-key', 'key in Localizable.strings'
31 |
32 | # Variable string values to appear in place of the format specifiers in loc-key.
33 | # json.set! 'loc-args', ['arg1', 'arg2']
34 |
35 | # The filename of an image file in the app bundle, with or without the filename extension.
36 | # json.set! 'launch-image', 'your_launch_iamge.png'
37 | end
38 |
39 | # Include this key when you want the system to modify the badge of your app icon.
40 | json.badge 1
41 |
42 | # Include this key when you want the system to play a sound.
43 | json.sound 'bingbong.aiff'
44 |
45 | # Provide this key with a string value that represents the notification's type.
46 | # json.category 'your-category-type', 'CATEGORY-TYPE'
47 |
48 | # Include this key with a value of 1 to configure a silent notification.
49 | # json.set! 'content-available', 1
50 |
51 | # Provide this key with a string value that represents the app-specific identifier for grouping notifications.
52 | # json.set! 'thread-id', 'THREAD-ID'
53 | end
54 |
--------------------------------------------------------------------------------
/lib/pushing/adapters/apn/lowdown_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require 'json'
4 | require 'delegate'
5 |
6 | module Pushing
7 | module Adapters
8 | class LowdownAdapter
9 | attr_reader :clients
10 |
11 | def initialize(apn_settings)
12 | # Don't load lowdown earlier as it may load Celluloid (and start it)
13 | # before daemonizing the workers spun up by a gem (e,g, delayed_job).
14 | require 'lowdown' unless defined?(Lodwown)
15 |
16 | cert = File.read(apn_settings.certificate_path)
17 | @clients = {
18 | development: Lowdown::Client.production(false, certificate: cert, keep_alive: true),
19 | production: Lowdown::Client.production(true, certificate: cert, keep_alive: true)
20 | }
21 | end
22 |
23 | def push!(notification)
24 | if notification.headers[:'apns-id']
25 | warn("The lowdown gem does not allow for overriding `apns_id'.")
26 | end
27 |
28 | if notification.headers[:'apns-collapse-id']
29 | warn("The lowdown gem does not allow for overriding `apns-collapse-id'.")
30 | end
31 |
32 | lowdown_notification = Lowdown::Notification.new(token: notification.device_token)
33 | lowdown_notification.payload = notification.payload
34 |
35 | lowdown_notification.expiration = notification.headers[:'apns-expiration'].to_i if notification.headers[:'apns-expiration']
36 | lowdown_notification.priority = notification.headers[:'apns-priority']
37 | lowdown_notification.topic = notification.headers[:'apns-topic']
38 |
39 | response = nil
40 | clients[notification.environment].group do |group|
41 | group.send_notification(lowdown_notification) do |_response|
42 | response = _response
43 | end
44 | end
45 |
46 | raise response.raw_body if !response.success?
47 | ApnResponse.new(response)
48 | rescue => cause
49 | response = response ? ApnResponse.new(response) : nil
50 | error = Pushing::ApnDeliveryError.new("Error while trying to send push notification: #{cause.message}", response, notification)
51 |
52 | raise error, error.message, cause.backtrace
53 | end
54 |
55 | class ApnResponse < SimpleDelegator
56 | def code
57 | __getobj__.status
58 | end
59 |
60 | def json
61 | @json ||= JSON.parse(__getobj__.raw_body, symbolize_names: true) if __getobj__.raw_body
62 | end
63 | end
64 |
65 | private_constant :ApnResponse
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/test/integration/fcm_test_cases.rb:
--------------------------------------------------------------------------------
1 | module FcmTestCases
2 | def test_actually_push_notification
3 | responses = MaintainerNotifier.build_result(adapter, fcm: true).deliver_now!
4 | response = responses.first
5 |
6 | assert_equal 200, response.code
7 | assert_equal 1, response.json[:success]
8 | assert_equal "application/json; charset=UTF-8", response.headers["content-type"]
9 | end
10 |
11 | class FcmTokenHandler
12 | cattr_accessor :canonical_ids
13 | self.canonical_ids = []
14 |
15 | def delivered_notification(payload, response)
16 | response.json[:results].select {|result| result[:registration_id] }.each do |result|
17 | self.class.canonical_ids << result[:registration_id]
18 | end
19 | end
20 | end
21 |
22 | def test_observer_can_observe_responses_from_fcm
23 | MaintainerNotifier.register_observer FcmTokenHandler.new
24 |
25 | stub_request(:post, "https://fcm.googleapis.com/fcm/send").to_return(
26 | status: 200,
27 | body: {
28 | multicast_id: 216,
29 | success: 3,
30 | failure: 3,
31 | canonical_ids: 1,
32 | results: [
33 | { message_id: "1:0408" },
34 | { error: "Unavailable" },
35 | { error: "InvalidRegistration" },
36 | { message_id: "1:1516" },
37 | { message_id: "1:2342", registration_id: "32" },
38 | { error: "NotRegistered"}
39 | ]
40 | }.to_json
41 | )
42 |
43 | MaintainerNotifier.build_result(adapter, fcm: true).deliver_now!
44 |
45 | assert_equal ["32"], FcmTokenHandler.canonical_ids
46 | ensure
47 | FcmTokenHandler.canonical_ids.clear
48 | MaintainerNotifier.delivery_notification_observers.clear
49 | end
50 |
51 | def test_notifier_raises_exception_on_http_client_error
52 | stub_request(:post, "https://fcm.googleapis.com/fcm/send").to_return(status: 400)
53 |
54 | error = assert_raises Pushing::FcmDeliveryError do
55 | MaintainerNotifier.build_result(adapter, fcm: true).deliver_now!
56 | end
57 |
58 | assert_equal 400, error.response.code
59 | assert_equal true, error.notification.payload[:dry_run]
60 | end
61 |
62 | def test_notifier_can_rescue_error_on_error_response
63 | stub_request(:post, "https://fcm.googleapis.com/fcm/send").to_return(status: 400)
64 |
65 | assert_nothing_raised do
66 | NotifierWithRescueHandler.fcm.deliver_now!
67 | end
68 |
69 | response = NotifierWithRescueHandler.last_response_from_fcm
70 | assert_equal 400, response.code
71 | end
72 |
73 | def adapter
74 | raise NotImplementedError
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/test/platforms_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class PlatformsTest < ActiveSupport::TestCase
4 | setup do
5 | @id = 1
6 | @expiration = 1.hour.from_now
7 | @priority = 5
8 | @topic = 'com.yuki.ios'
9 | @collapse_id = 'pushing-testing'
10 | end
11 |
12 | test "APN headers are normalized" do
13 | payload = Pushing::Platforms::ApnPayload.new({}, environment: 'development', headers: {
14 | authorization: "",
15 | 'apns-id': @id,
16 | 'apns-expiration': @expiration,
17 | 'apns-priority': @priority,
18 | 'apns-topic': @topic,
19 | 'apns-collapse-id': @collapse_id
20 | })
21 |
22 | assert_apn_headers payload.headers
23 | end
24 |
25 | test "APN headers with underscore are normalized" do
26 | payload = Pushing::Platforms::ApnPayload.new({}, environment: 'development', headers: {
27 | authorization: "",
28 | apns_id: @id,
29 | apns_expiration: @expiration,
30 | apns_priority: @priority,
31 | apns_topic: @topic,
32 | apns_collapse_id: @collapse_id
33 | })
34 |
35 | assert_apn_headers payload.headers
36 | end
37 |
38 | test "APN headers without 'apn-' prefix are normalized" do
39 | payload = Pushing::Platforms::ApnPayload.new({}, environment: 'development', headers: {
40 | authorization: "",
41 | id: @id,
42 | expiration: @expiration,
43 | priority: @priority,
44 | topic: @topic,
45 | collapse_id: @collapse_id
46 | })
47 |
48 | assert_apn_headers payload.headers
49 | end
50 |
51 | test "default headers do not take the precedence" do
52 | options = {
53 | environment: 'development',
54 | headers: {
55 | priority: 10
56 | }
57 | }
58 |
59 | config = {
60 | default_headers: {
61 | apns_priority: 5
62 | }
63 | }
64 |
65 | payload = Pushing::Platforms::ApnPayload.new({}, options, config)
66 |
67 | assert_equal 10, payload.headers[:'apns-priority']
68 | assert_not payload.headers.include?(:apns_priority)
69 | assert_not payload.headers.include?(:priority)
70 | end
71 |
72 | private
73 |
74 | def assert_apn_headers(headers)
75 | assert_equal '', headers[:authorization]
76 | assert_equal @id, headers[:'apns-id']
77 | assert_equal @expiration, headers[:'apns-expiration']
78 | assert_equal @priority, headers[:'apns-priority']
79 | assert_equal @topic, headers[:'apns-topic']
80 | assert_equal @collapse_id, headers[:'apns-collapse-id']
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/generators/pushing/templates/initializer.rb:
--------------------------------------------------------------------------------
1 | Pushing.configure do |config|
2 | # Adapter that is used to send push notifications through FCM
3 | config.fcm.adapter = Rails.env.test? ? :test : :andpush
4 |
5 | # Your FCM servery key that can be found here: https://console.firebase.google.com/project/_/settings/cloudmessaging
6 | config.fcm.server_key = 'YOUR_FCM_SERVER_KEY'
7 |
8 | # Adapter that is used to send push notifications through APNs
9 | config.apn.adapter = Rails.env.test? ? :test : :apnotic
10 |
11 | # The environment that is used by default to send push notifications through APNs
12 | config.apn.environment = Rails.env.production? ? :production : :development
13 |
14 | # The scheme that is used for negotiating connection trust between your provider
15 | # servers and Apple Push Notification service. As documented in the offitial doc,
16 | # there are two schemes available:
17 | #
18 | # :token - Token-based provider connection trust (default)
19 | # :certificate - Certificate-based provider connection trust
20 | #
21 | # This option is only applied when using an adapter that uses the HTTP/2-based
22 | # API.
23 | config.apn.connection_scheme = :token
24 |
25 | # Path to the certificate or auth key for establishing a connection to APNs.
26 | #
27 | # This config is always required.
28 | config.apn.certificate_path = 'path/to/your/certificate'
29 |
30 | # Password for the certificate specified above if there's any.
31 | # config.apn.certificate_password = 'passphrase'
32 |
33 | # A 10-character key identifier (kid) key, obtained from your developer account.
34 | # If you haven't created an Auth Key for your app, create a new one at:
35 | # https://developer.apple.com/account/ios/authkey/
36 | #
37 | # Required if the +connection_scheme+ is set to +:token+.
38 | config.apn.key_id = 'DEF123GHIJ'
39 |
40 | # The issuer (iss) registered claim key, whose value is your 10-character Team ID,
41 | # obtained from your developer account. Your team id could be found at:
42 | # https://developer.apple.com/account/#/membership
43 | #
44 | # Required if the +connection_scheme+ is set to +:token+.
45 | config.apn.team_id = 'ABC123DEFG'
46 |
47 | # Header values that are added to every request to APNs. documentation for the
48 | # headers available can be found here:
49 | # https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW13
50 | config.apn.default_headers = {
51 | apns_priority: 10,
52 | apns_topic: 'your.awesomeapp.ios',
53 | apns_collapse_id: 'wrong.topicname.com'
54 | }
55 | end
56 |
--------------------------------------------------------------------------------
/lib/pushing/notification_delivery.rb:
--------------------------------------------------------------------------------
1 | require "delegate"
2 |
3 | module Pushing
4 | class NotificationDelivery < Delegator
5 | def initialize(notifier_class, action, *args) #:nodoc:
6 | @notifier_class, @action, @args = notifier_class, action, args
7 |
8 | # The notification is only processed if we try to call any methods on it.
9 | # Typical usage will leave it unloaded and call deliver_later.
10 | @processed_notifier = nil
11 | @notification_message = nil
12 | end
13 |
14 | def __getobj__ #:nodoc:
15 | @notification_message ||= processed_notifier.notification
16 | end
17 |
18 | # Unused except for delegator internals (dup, marshaling).
19 | def __setobj__(notification_message) #:nodoc:
20 | @notification_message = notification_message
21 | end
22 |
23 | def message
24 | __getobj__
25 | end
26 |
27 | def processed?
28 | @processed_notifier || @notification_message
29 | end
30 |
31 | def deliver_later!(options = {})
32 | enqueue_delivery :deliver_now!, options
33 | end
34 |
35 | def deliver_now!
36 | processed_notifier.handle_exceptions do
37 | message.process { do_deliver }
38 | end
39 | end
40 |
41 | private
42 |
43 | def do_deliver
44 | @notifier_class.inform_interceptors(self)
45 |
46 | responses = nil
47 | @notifier_class.deliver_notification(self) do
48 | responses = message.to_h.map do |platform, payload|
49 | Adapters.instance(@notifier_class.config[platform]).push!(payload) if @notifier_class.config[platform]
50 | end.compact
51 | end
52 |
53 | responses.each {|response| @notifier_class.inform_observers(self, response) }
54 | responses
55 | end
56 |
57 | def processed_notifier
58 | @processed_notifier ||= begin
59 | notifier = @notifier_class.new
60 | notifier.process @action, *@args
61 | notifier
62 | end
63 | end
64 |
65 | def enqueue_delivery(delivery_method, options = {})
66 | if processed?
67 | ::Kernel.raise "You've accessed the message before asking to " \
68 | "deliver it later, so you may have made local changes that would " \
69 | "be silently lost if we enqueued a job to deliver it. Why? Only " \
70 | "the notifier method *arguments* are passed with the delivery job! " \
71 | "Do not access the message in any way if you mean to deliver it " \
72 | "later. Workarounds: 1. don't touch the message before calling " \
73 | "#deliver_later, 2. only touch the message *within your notifier " \
74 | "method*, or 3. use a custom Active Job instead of #deliver_later."
75 | else
76 | args = @notifier_class.name, @action.to_s, delivery_method.to_s, *@args
77 | ::Pushing::DeliveryJob.set(options).perform_later(*args)
78 | end
79 | end
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/lib/generators/pushing/templates/template.json+fcm.jbuilder:
--------------------------------------------------------------------------------
1 | # In this file you can customize the FCM payload. For more details about Firebase Cloud Messaging HTTP Protocol, see
2 | # Google's offitial documentation here: https://goo.gl/tgssxy
3 |
4 | # The recipient of a message. The value can be a device's registration token, a device group's notification key, or a
5 | # single topic (prefixed with /topics/). Either the 'to' or 'registration_ids' key should be present.
6 | json.to 'REPLACE_WITH_ACTUAL_REGISTRATION_ID_OR_TOPIC'
7 |
8 | # The recipient of a multicast message, a message sent to more than one registration token. Either the 'to' or
9 | # 'registration_ids' key should be present.
10 | # json.registration_ids 'REPLACE_WITH_ACTUAL_REGISTRATION_IDS_OR_TOPIC'
11 |
12 | # A logical expression of conditions that determine the message target.
13 | # json.condition 'Topic'
14 |
15 | # This parameter identifies a group of messages (e.g., with collapse_key:
16 | # "Updates Available") that can be collapsed.
17 | # json.collapse_key "Updates Available"
18 |
19 | # Sets the priority of the message. Valid values are "normal" and "high".
20 | # json.priority "high"
21 |
22 | # How long (in seconds) the message should be kept in FCM storage if the device is offline.
23 | # json.time_to_live 4.weeks.to_i
24 |
25 | # The package name of the application where the registration tokens must match in order to receive the message.
26 | # json.restricted_package_name 'com.yourdomain.app'
27 |
28 | # This parameter, when set to true, allows developers to test a request without actually sending a message.
29 | # json.dry_run true
30 |
31 | # The custom key-value pairs of the message's payload.
32 | # json.data do
33 | # json.custom_data "data..."
34 | # end
35 |
36 | # The predefined, user-visible key-value pairs of the notification payload.
37 | json.notification do
38 | # The notification's title.
39 | json.title 'REPLACE_WITH_ACTUAL_TITLE'
40 |
41 | # The notification's body text.
42 | json.body 'REPLACE_WITH_ACTUAL_BODY'
43 |
44 | # The notification's channel id (new in Android O).
45 | # json.android_channel_id "my_channel_01"
46 |
47 | # The notification's icon.
48 | json.icon 1
49 |
50 | # The sound to play when the device receives the notificatio
51 | json.sound 'default'
52 |
53 | # Identifier used to replace existing notifications in the notification drawer.
54 | # json.tag 'tag-name'
55 |
56 | # The notification's icon color, expressed in #rrggbb format.
57 | # json.color "#ffffff"
58 |
59 | # The action associated with a user click on the notification.
60 | # json.click_action "OPEN_ACTIVITY_1"
61 |
62 | # The key to the body in the app's string resources to use to localize the body to the user's current localization.
63 | # json.body_loc_key 'key in Localizable.strings'
64 |
65 | # String values to be used in place of the format specifiers in 'body_loc_key' to use to localize the body to the
66 | # user's current localization.
67 | # json.body_loc_args ['arg1', 'arg2']
68 |
69 | # The key to the title in the app's string resources to use to localize the title to the user's current localization.
70 | # json.title_loc_key 'key in Localizable.strings'
71 |
72 | # String values to be used in place of the format specifiers in title_loc_key to use to localize the title text to
73 | # the user's current localization.
74 | # json.title_loc_args ['arg1', 'arg2']
75 | end
76 |
--------------------------------------------------------------------------------
/test/url_helper_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'test_helper'
4 | require "action_controller"
5 |
6 | class WelcomeController < ActionController::Base
7 | end
8 |
9 | AppRoutes = ActionDispatch::Routing::RouteSet.new
10 |
11 | Pushing::Base.include AppRoutes.url_helpers
12 |
13 | class UrlTestNotifier < Pushing::Base
14 | self.default_url_options[:host] = "example.org"
15 | self.asset_host = "https://example.org"
16 |
17 | configure do |c|
18 | c.assets_dir = "" # To get the tests to pass
19 | end
20 |
21 | def url(options)
22 | @options = options
23 | @url_for = url_for(options)
24 | @welcome_url = url_for host: "example.org", controller: "welcome", action: "greeting"
25 |
26 | push apn: 'token'
27 | end
28 | end
29 |
30 | class UrlHelperTest < ActiveSupport::TestCase
31 | class DummyModel
32 | def self.model_name
33 | OpenStruct.new(route_key: "dummy_model")
34 | end
35 |
36 | def persisted?
37 | false
38 | end
39 |
40 | def model_name
41 | self.class.model_name
42 | end
43 |
44 | def to_model
45 | self
46 | end
47 | end
48 |
49 | def assert_url_for(expected, options, relative = false)
50 | expected = "http://example.org#{expected}" if expected.start_with?("/") && !relative
51 | urls = UrlTestNotifier.url(options).apn.payload.reject{ |key, _| key == :aps }.values
52 |
53 | assert_equal expected, urls.first
54 | assert_equal expected, urls.second
55 | end
56 |
57 | test '#url_for' do
58 | AppRoutes.draw do
59 | ActiveSupport::Deprecation.silence do
60 | get ":controller(/:action(/:id))"
61 | get "/welcome" => "foo#bar", as: "welcome"
62 | get "/dummy_model" => "foo#baz", as: "dummy_model"
63 | end
64 | end
65 |
66 | # string
67 | assert_url_for "http://foo/", "http://foo/"
68 |
69 | # symbol
70 | assert_url_for "/welcome", :welcome
71 |
72 | # hash
73 | assert_url_for "/a/b/c", controller: "a", action: "b", id: "c"
74 | assert_url_for "/a/b/c", { controller: "a", action: "b", id: "c", only_path: true }, true
75 |
76 | # model
77 | assert_url_for "/dummy_model", DummyModel.new
78 |
79 | # class
80 | assert_url_for "/dummy_model", DummyModel
81 |
82 | # array
83 | assert_url_for "/dummy_model", [DummyModel]
84 | end
85 |
86 | test 'url helpers' do
87 | AppRoutes.draw do
88 | ActiveSupport::Deprecation.silence do
89 | get ":controller(/:action(/:id))"
90 | get "/welcome" => "foo#bar", as: "welcome"
91 | end
92 | end
93 |
94 | payload = UrlTestNotifier.url(:welcome).apn.payload
95 |
96 | assert_equal 'http://example.org/welcome/greeting', payload[:welcome_url_from_action]
97 | assert_equal 'http://example.org/welcome', payload[:welcome_url_in_view]
98 | end
99 |
100 | test 'asset url helpers' do
101 | AppRoutes.draw do
102 | ActiveSupport::Deprecation.silence do
103 | get ":controller(/:action(/:id))"
104 | get "/welcome" => "foo#bar", as: "welcome"
105 | end
106 | end
107 |
108 | payload = UrlTestNotifier.url(:welcome).apn.payload
109 |
110 | assert_equal 'https://example.org/puppy.jpeg', payload[:asset_url]
111 | assert_equal 'https://example.org/images/puppy.jpeg', payload[:image_url]
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at mail@yukinishijima.net. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/lib/pushing/adapters/apn/apnotic_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require 'apnotic'
4 | require 'active_support/core_ext/hash/keys'
5 |
6 | module Pushing
7 | module Adapters
8 | class ApnoticAdapter
9 | APS_DICTIONARY_KEYS = %i[
10 | alert
11 | badge
12 | sound
13 | content_available
14 | category
15 | url_args
16 | mutable_content
17 | ].freeze
18 |
19 | DEFAULT_ADAPTER_OPTIONS = {
20 | size: Process.getrlimit(Process::RLIMIT_NOFILE).first / 8
21 | }.freeze
22 |
23 | attr_reader :connection_pool
24 |
25 | def initialize(apn_settings)
26 | options = case apn_settings.connection_scheme.to_sym
27 | when :token
28 | {
29 | auth_method: :token,
30 | cert_path: apn_settings.certificate_path,
31 | key_id: apn_settings.key_id,
32 | team_id: apn_settings.team_id
33 | }
34 | when :certificate
35 | {
36 | cert_path: apn_settings.certificate_path,
37 | cert_pass: apn_settings.certificate_password
38 | }
39 | else
40 | raise "Unknown connection scheme #{apn_settings.connection_scheme.inspect}. " \
41 | "The connection scheme should either be :token or :certificate."
42 | end
43 |
44 | @connection_pool = {
45 | development: Apnotic::ConnectionPool.development(options, DEFAULT_ADAPTER_OPTIONS) { },
46 | production: Apnotic::ConnectionPool.new(options, DEFAULT_ADAPTER_OPTIONS) { },
47 | }
48 | end
49 |
50 | def push!(notification)
51 | message = Apnotic::Notification.new(notification.device_token)
52 | json = notification.payload.dup
53 |
54 | if aps = json.delete(:aps)
55 | APS_DICTIONARY_KEYS.each {|key| message.instance_variable_set(:"@#{key}", aps[key]) }
56 | end
57 |
58 | message.custom_payload = json
59 |
60 | message.apns_id = notification.headers[:'apns-id'] || message.apns_id
61 | message.expiration = notification.headers[:'apns-expiration'].to_i
62 | message.priority = notification.headers[:'apns-priority']
63 | message.topic = notification.headers[:'apns-topic']
64 | message.apns_collapse_id = notification.headers[:'apns-collapse-id']
65 |
66 | response = connection_pool[notification.environment].with {|connection| connection.push(message) }
67 |
68 | if !response
69 | raise "Timeout sending a push notification"
70 | elsif response.status != '200'
71 | raise response.body.to_s
72 | end
73 |
74 | ApnResponse.new(response)
75 | rescue => cause
76 | response = response ? ApnResponse.new(response) : nil
77 | error = Pushing::ApnDeliveryError.new("Error while trying to send push notification: #{cause.message}", response, notification)
78 |
79 | raise error, error.message, cause.backtrace
80 | end
81 |
82 | class ApnResponse < SimpleDelegator
83 | def code
84 | __getobj__.status.to_i
85 | end
86 | alias status code
87 |
88 | def json
89 | @json ||= __getobj__.body.symbolize_keys if !__getobj__.body.empty?
90 | end
91 | alias body json
92 | end
93 |
94 | private_constant :ApnResponse
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/lib/pushing/platforms.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require 'active_support/core_ext/hash/keys'
4 |
5 | module Pushing
6 | module Platforms
7 | class << self
8 | def configure(&block) #:nodoc:
9 | ActiveSupport::Deprecation.warn "`Pushing::Platforms.configure' is deprecated and will be removed in 0.4.0. " \
10 | "Please use `Pushing.configure' instead"
11 |
12 | Pushing.configure(&block)
13 | end
14 |
15 | def config #:nodoc:
16 | ActiveSupport::Deprecation.warn "`Pushing::Platforms.config' is deprecated and will be removed in 0.4.0. " \
17 | "Please use `Pushing.config' instead"
18 |
19 | Pushing.config
20 | end
21 |
22 | def lookup(platform_name)
23 | const_get(:"#{platform_name.capitalize}Payload")
24 | end
25 | end
26 |
27 | class ApnPayload
28 | attr_reader :payload, :headers, :device_token, :environment
29 |
30 | EMPTY_HASH = {}.freeze
31 |
32 | def self.should_render?(options)
33 | options.is_a?(Hash) ? options[:device_token].present? : options.present?
34 | end
35 |
36 | def initialize(payload, options, config = EMPTY_HASH)
37 | @payload = payload
38 | @environment = config[:environment]
39 | @headers = normalize_headers(config[:default_headers] || EMPTY_HASH)
40 |
41 | if config[:topic]
42 | ActiveSupport::Deprecation.warn "`config.apn.topic' is deprecated and will be removed in 0.3.0. " \
43 | "Please use `config.apn.default_headers' instead:\n\n" \
44 | " config.apn.default_headers = {\n" \
45 | " apns_topic: '#{config[:topic]}'\n" \
46 | " }", caller
47 |
48 | @headers['apns-topic'] ||= config[:topic]
49 | end
50 |
51 | if options.is_a?(String)
52 | @device_token = options
53 | elsif options.is_a?(Hash)
54 | @device_token = options[:device_token]
55 | @environment = options[:environment] || @environment
56 | @headers = @headers.merge(normalize_headers(options[:headers] || EMPTY_HASH))
57 | else
58 | raise TypeError, "The :apn key only takes a device token as a string or a hash that has `device_token: \"...\"'."
59 | end
60 |
61 | # raise("APNs environment is required.") if @environment.nil?
62 | # raise("APNs device token is required.") if @device_token.nil?
63 |
64 | @environment = @environment.to_sym
65 | end
66 |
67 | def recipients
68 | Array("#{@environment}/#{@device_token}")
69 | end
70 |
71 | def normalize_headers(headers)
72 | h = headers.stringify_keys
73 | h.transform_keys!(&:dasherize)
74 |
75 | {
76 | authorization: h['authorization'],
77 | 'apns-id': h['apns-id'] || h['id'],
78 | 'apns-expiration': h['apns-expiration'] || h['expiration'],
79 | 'apns-priority': h['apns-priority'] || h['priority'],
80 | 'apns-topic': h['apns-topic'] || h['topic'],
81 | 'apns-collapse-id': h['apns-collapse-id'] || h['collapse-id'],
82 | }
83 | end
84 | end
85 |
86 | class FcmPayload
87 | attr_reader :payload
88 |
89 | def self.should_render?(options)
90 | options.present?
91 | end
92 |
93 | def initialize(payload, *)
94 | @payload = payload
95 | end
96 |
97 | def recipients
98 | Array(payload[:to] || payload[:registration_ids])
99 | end
100 | end
101 | end
102 |
103 | class DeliveryError < RuntimeError
104 | attr_reader :response, :notification
105 |
106 | def initialize(message, response = nil, notification = nil)
107 | super(message)
108 | @response = response
109 | @notification = notification
110 | end
111 | end
112 |
113 | class ApnDeliveryError < DeliveryError
114 | end
115 |
116 | class FcmDeliveryError < DeliveryError
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/test/isolated_test_helper.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2 |
3 | require "active_support"
4 | require 'active_support/core_ext/kernel/reporting'
5 | require 'active_support/deprecation'
6 | require 'active_support/testing/autorun'
7 |
8 | require 'minitest/pride'
9 | require 'pry'
10 | require 'pry-byebug' if RUBY_ENGINE == 'ruby'
11 | require "rails/railtie"
12 |
13 | begin
14 | require 'active_support/testing/method_call_assertions'
15 | ActiveSupport::TestCase.include ActiveSupport::Testing::MethodCallAssertions
16 | rescue LoadError
17 | # Rails 4.2 doesn't come with ActiveSupport::Testing::MethodCallAssertions
18 | require 'backport/method_call_assertions'
19 | ActiveSupport::TestCase.include MethodCallAssertions
20 |
21 | # FIXME: we have tests that depend on run order, we should fix that and
22 | # remove this method call.
23 | require 'active_support/test_case'
24 | ActiveSupport::TestCase.test_order = :sorted
25 | end
26 |
27 | module Paths
28 | def app_template_path
29 | File.join Dir.tmpdir, "app_template"
30 | end
31 |
32 | def tmp_path(*args)
33 | @tmp_path ||= File.realpath(Dir.mktmpdir)
34 | File.join(@tmp_path, *args)
35 | end
36 |
37 | def app_path(*args)
38 | tmp_path(*%w[app] + args)
39 | end
40 | end
41 |
42 | module Generation
43 | extend Paths
44 | include Paths
45 |
46 | # Build an application by invoking the generator and going through the whole stack.
47 | def build_app(options = {})
48 | @prev_rails_env = ENV["RAILS_ENV"]
49 | ENV["RAILS_ENV"] = "development"
50 | ENV["SECRET_KEY_BASE"] ||= SecureRandom.hex(16)
51 |
52 | FileUtils.rm_rf(app_path)
53 | FileUtils.cp_r(app_template_path, app_path)
54 |
55 | # Delete the initializers unless requested
56 | unless options[:initializers]
57 | Dir["#{app_path}/config/initializers/**/*.rb"].each do |initializer|
58 | File.delete(initializer)
59 | end
60 | end
61 |
62 | routes = File.read("#{app_path}/config/routes.rb")
63 | if routes =~ /(\n\s*end\s*)\z/
64 | File.open("#{app_path}/config/routes.rb", "w") do |f|
65 | f.puts $` + "\nActiveSupport::Deprecation.silence { match ':controller(/:action(/:id))(.:format)', via: :all }\n" + $1
66 | end
67 | end
68 |
69 | File.open("#{app_path}/config/database.yml", "w") do |f|
70 | f.puts <<-YAML
71 | default: &default
72 | adapter: sqlite3
73 | pool: 5
74 | timeout: 5000
75 | development:
76 | <<: *default
77 | database: db/development.sqlite3
78 | test:
79 | <<: *default
80 | database: db/test.sqlite3
81 | production:
82 | <<: *default
83 | database: db/production.sqlite3
84 | YAML
85 | end
86 |
87 | add_to_config <<-RUBY
88 | config.eager_load = false
89 | config.session_store :cookie_store, key: "_myapp_session"
90 | config.active_support.deprecation = :log
91 | config.active_support.test_order = :random
92 | config.action_controller.allow_forgery_protection = false
93 | config.log_level = :info
94 | RUBY
95 | end
96 |
97 | def teardown_app
98 | ENV["RAILS_ENV"] = @prev_rails_env if @prev_rails_env
99 | FileUtils.rm_rf(tmp_path)
100 | end
101 |
102 | def add_to_config(str)
103 | environment = File.read("#{app_path}/config/application.rb")
104 | if environment =~ /(\n\s*end\s*end\s*)\z/
105 | File.open("#{app_path}/config/application.rb", "w") do |f|
106 | f.puts $` + "\n#{str}\n" + $1
107 | end
108 | end
109 | end
110 |
111 | def add_to_initializer(str)
112 | File.open("#{app_path}/config/initializers/pushing.rb", "w") do |f|
113 | f.puts "#{str}\n"
114 | end
115 | end
116 |
117 | def self.initialize_app
118 | FileUtils.rm_rf(app_template_path)
119 | FileUtils.mkdir(app_template_path)
120 |
121 | `rails new #{app_template_path} --skip-gemfile --skip-listen --no-rc`
122 | File.open("#{app_template_path}/config/boot.rb", "w") do |f|
123 | f.puts "require 'rails/all'"
124 | end
125 | end
126 | end
127 |
128 | Generation.initialize_app
129 |
130 | require 'pushing'
131 |
--------------------------------------------------------------------------------
/test/notification_delivery_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | require 'active_job'
3 | require 'notifiers/base_notifier'
4 | require 'notifiers/delayed_notifier'
5 |
6 | class NotificationDeliveryTest < ActiveSupport::TestCase
7 | include ActiveJob::TestHelper
8 |
9 | setup do
10 | @previous_logger = ActiveJob::Base.logger
11 | @previous_deliver_later_queue_name = Pushing::Base.deliver_later_queue_name
12 | Pushing::Base.deliver_later_queue_name = :test_queue
13 | ActiveJob::Base.logger = Logger.new(nil)
14 | Pushing::Base.deliveries.clear
15 | ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true
16 | ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true
17 |
18 | DelayedNotifier.last_error = nil
19 | DelayedNotifier.last_rescue_from_instance = nil
20 |
21 | @notification = Pushing::NotificationDelivery.new(BaseNotifier, :welcome, args: "value")
22 | end
23 |
24 | teardown do
25 | ActiveJob::Base.logger = @previous_logger
26 | Pushing::Base.deliver_later_queue_name = @previous_deliver_later_queue_name
27 |
28 | DelayedNotifier.last_error = nil
29 | DelayedNotifier.last_rescue_from_instance = nil
30 | end
31 |
32 | def test_should_enqueue_and_run_correctly_in_activejob
33 | @notification.deliver_later!
34 | assert_equal 2, Pushing::Base.deliveries.size
35 | assert_equal 1, Pushing::Base.deliveries.apn.size
36 | assert_equal 1, Pushing::Base.deliveries.fcm.size
37 | ensure
38 | Pushing::Base.deliveries.clear
39 | end
40 |
41 | test "should enqueue the notification with :deliver_now! delivery method" do
42 | args = [
43 | "BaseNotifier",
44 | "welcome",
45 | "deliver_now!",
46 | { args: "value" }
47 | ]
48 |
49 | assert_performed_with job: Pushing::DeliveryJob, args: args, queue: "test_queue" do
50 | @notification.deliver_later!
51 | end
52 | end
53 |
54 | test "should enqueue a delivery with a delay" do
55 | args = [
56 | "BaseNotifier",
57 | "welcome",
58 | "deliver_now!",
59 | { args: "value" }
60 | ]
61 |
62 | travel_to Time.new(2004, 11, 24, 01, 04, 44) do
63 | assert_performed_with job: Pushing::DeliveryJob, at: Time.current.to_f + 600, args: args do
64 | @notification.deliver_later!(wait: 600.seconds)
65 | end
66 | end
67 | end
68 |
69 | test "should enqueue a delivery at a specific time" do
70 | args = [
71 | "BaseNotifier",
72 | "welcome",
73 | "deliver_now!",
74 | { args: "value" }
75 | ]
76 |
77 | later_time = Time.now.to_f + 3600
78 | assert_performed_with job: Pushing::DeliveryJob, at: later_time, args: args do
79 | @notification.deliver_later!(wait_until: later_time)
80 | end
81 | end
82 |
83 | test "can override the queue when enqueuing notification" do
84 | args = [
85 | "BaseNotifier",
86 | "welcome",
87 | "deliver_now!",
88 | { args: "value" }
89 | ]
90 |
91 | assert_performed_with job: Pushing::DeliveryJob, args: args, queue: "another_queue" do
92 | @notification.deliver_later!(queue: :another_queue)
93 | end
94 | end
95 |
96 | test "deliver_later! after accessing the message is disallowed" do
97 | @notification.message # Load the message, which calls the notifier method.
98 |
99 | assert_raise RuntimeError do
100 | @notification.deliver_later!
101 | end
102 | end
103 |
104 | class DeserializationErrorFixture
105 | include GlobalID::Identification
106 |
107 | def self.find(id)
108 | raise "boom, missing find"
109 | end
110 |
111 | attr_reader :id
112 | def initialize(id = 1)
113 | @id = id
114 | end
115 |
116 | def to_global_id(options = {})
117 | super app: "foo"
118 | end
119 | end
120 |
121 | if ActiveSupport::VERSION::MAJOR > 4
122 | test "job delegates error handling to notifier" do
123 | # Superclass not rescued by notifier's rescue_from RuntimeError
124 | message = DelayedNotifier.test_raise("StandardError")
125 | assert_raise(StandardError) { message.deliver_later! }
126 | assert_nil DelayedNotifier.last_error
127 | assert_nil DelayedNotifier.last_rescue_from_instance
128 |
129 | # Rescued by notifier's rescue_from RuntimeError
130 | message = DelayedNotifier.test_raise("DelayedNotifierError")
131 | assert_nothing_raised { message.deliver_later! }
132 | assert_equal "boom", DelayedNotifier.last_error.message
133 | assert_kind_of DelayedNotifier, DelayedNotifier.last_rescue_from_instance
134 | end
135 |
136 | test "job delegates deserialization errors to notifier class" do
137 | # Inject an argument that can't be deserialized.
138 | message = DelayedNotifier.test_message(arg: DeserializationErrorFixture.new)
139 |
140 | # DeserializationError is raised, rescued, and delegated to the handler
141 | # on the notifier class.
142 | assert_nothing_raised { message.deliver_later! }
143 | assert_equal DelayedNotifier, DelayedNotifier.last_rescue_from_instance
144 | assert_equal "Error while trying to deserialize arguments: boom, missing find", DelayedNotifier.last_error.message
145 | end
146 | else
147 | test "job does not delegate error handling to notifier" do
148 | message = DelayedNotifier.test_raise("StandardError")
149 | assert_raise(StandardError) { message.deliver_later! }
150 | assert_nil DelayedNotifier.last_error
151 | assert_nil DelayedNotifier.last_rescue_from_instance
152 |
153 | message = DelayedNotifier.test_raise("DelayedNotifierError")
154 | assert_raise(DelayedNotifierError, /boom/) { message.deliver_later! }
155 | end
156 |
157 | test "job does not delegate deserialization errors to notifier class" do
158 | # Inject an argument that can't be deserialized.
159 | message = DelayedNotifier.test_message(arg: DeserializationErrorFixture.new)
160 |
161 | assert_raise(ActiveJob::DeserializationError) { message.deliver_later! }
162 | end
163 | end
164 | end
165 |
--------------------------------------------------------------------------------
/lib/pushing/base.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 |
3 | require "abstract_controller"
4 | require 'active_support/core_ext/module/attribute_accessors'
5 | require 'active_support/core_ext/object/blank'
6 |
7 | require 'pushing/log_subscriber'
8 | require 'pushing/rescuable'
9 | require 'pushing/platforms'
10 | require 'pushing/template_handlers'
11 |
12 | module Pushing
13 | class Base < AbstractController::Base
14 | include Rescuable
15 |
16 | config.apn = ActiveSupport::OrderedOptions.new
17 | config.fcm = ActiveSupport::OrderedOptions.new
18 |
19 | abstract!
20 |
21 | include AbstractController::Rendering
22 | include AbstractController::Logger
23 | include AbstractController::Helpers
24 | include AbstractController::Translation
25 | include AbstractController::AssetPaths
26 | include AbstractController::Callbacks
27 | begin
28 | include AbstractController::Caching
29 | rescue NameError
30 | # AbstractController::Caching does not exist in rails 4.2. No-op.
31 | end
32 |
33 | include ActionView::Rendering
34 |
35 | PROTECTED_IVARS = AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES + [:@_action_has_layout]
36 |
37 | def _protected_ivars # :nodoc:
38 | PROTECTED_IVARS
39 | end
40 |
41 | cattr_accessor :deliver_later_queue_name
42 | self.deliver_later_queue_name = :notifiers
43 |
44 | cattr_reader :delivery_notification_observers
45 | @@delivery_notification_observers = []
46 |
47 | cattr_reader :delivery_interceptors
48 | @@delivery_interceptors = []
49 |
50 | class << self
51 | delegate :deliveries, :deliveries=, to: Pushing::Adapters::TestAdapter
52 |
53 | # Register one or more Observers which will be notified when notification is delivered.
54 | def register_observers(*observers)
55 | observers.flatten.compact.each { |observer| register_observer(observer) }
56 | end
57 |
58 | # Register one or more Interceptors which will be called before notification is sent.
59 | def register_interceptors(*interceptors)
60 | interceptors.flatten.compact.each { |interceptor| register_interceptor(interceptor) }
61 | end
62 |
63 | # Register an Observer which will be notified when notification is delivered.
64 | # Either a class, string or symbol can be passed in as the Observer.
65 | # If a string or symbol is passed in it will be camelized and constantized.
66 | def register_observer(observer)
67 | unless delivery_notification_observers.include?(observer)
68 | delivery_notification_observers << observer
69 | end
70 | end
71 |
72 | # Register an Interceptor which will be called before notification is sent.
73 | # Either a class, string or symbol can be passed in as the Interceptor.
74 | # If a string or symbol is passed in it will be camelized and constantized.
75 | def register_interceptor(interceptor)
76 | unless delivery_interceptors.include?(interceptor)
77 | delivery_interceptors << interceptor
78 | end
79 | end
80 |
81 | def inform_observers(notification, response)
82 | delivery_notification_observers.each do |observer|
83 | observer.delivered_notification(notification, response)
84 | end
85 | end
86 |
87 | def inform_interceptors(notification)
88 | delivery_interceptors.each do |interceptor|
89 | interceptor.delivering_notification(notification)
90 | end
91 | end
92 |
93 | def notifier_name
94 | @notifier_name ||= anonymous? ? "anonymous" : name.underscore
95 | end
96 | # Allows to set the name of current notifier.
97 | attr_writer :notifier_name
98 | alias :controller_path :notifier_name
99 |
100 | # Wraps a notification delivery inside of ActiveSupport::Notifications instrumentation.
101 | def deliver_notification(notification) #:nodoc:
102 | ActiveSupport::Notifications.instrument("deliver.push_notification") do |payload|
103 | set_payload_for_notification(payload, notification)
104 | yield # Let NotificationDelivery do the delivery actions
105 | end
106 | end
107 |
108 | # Push notifications do not support relative path links.
109 | def supports_path? # :doc:
110 | false
111 | end
112 |
113 | private
114 |
115 | def set_payload_for_notification(payload, notification)
116 | payload[:notifier] = name
117 | payload[:notification] = notification.message.to_h
118 | end
119 |
120 | def method_missing(method_name, *args)
121 | if action_methods.include?(method_name.to_s)
122 | NotificationDelivery.new(self, method_name, *args)
123 | else
124 | super
125 | end
126 | end
127 |
128 | def respond_to_missing?(method, include_all = false)
129 | action_methods.include?(method.to_s) || super
130 | end
131 | end
132 |
133 | def process(method_name, *args) #:nodoc:
134 | payload = {
135 | notifier: self.class.name,
136 | action: method_name,
137 | args: args
138 | }
139 |
140 | ActiveSupport::Notifications.instrument("process.push_notification", payload) do
141 | super
142 | @_notification ||= NullNotification.new
143 | end
144 | end
145 |
146 | class NullNotification #:nodoc:
147 | def respond_to?(string, include_all = false)
148 | true
149 | end
150 |
151 | def method_missing(*args)
152 | nil
153 | end
154 | end
155 |
156 | class Notification < OpenStruct #:nodoc:
157 | def process
158 | yield
159 | end
160 | end
161 |
162 | private_constant :NullNotification, :Notification
163 |
164 | attr_internal :notification
165 |
166 | def push(headers)
167 | return notification if notification && headers.blank?
168 |
169 | payload = {}
170 | headers.select {|platform, _| self.class.config[platform] }.each do |platform, options|
171 | payload_class = ::Pushing::Platforms.lookup(platform)
172 |
173 | if payload_class.should_render?(options)
174 | json = render_json(platform, headers)
175 | payload[platform] = payload_class.new(json, headers[platform], config[platform])
176 | end
177 | end
178 |
179 | @_notification = Notification.new(payload)
180 | end
181 |
182 | private
183 |
184 | def render_json(platform, headers)
185 | templates_path = headers[:template_path] || self.class.notifier_name
186 | templates_name = headers[:template_name] || action_name
187 |
188 | lookup_context.variants = platform
189 | template = lookup_context.find(templates_name, Array(templates_path))
190 |
191 | unless template.instance_variable_get(:@compiled)
192 | engine = File.extname(template.identifier).tr!(".", "")
193 | handler = ::Pushing::TemplateHandlers.lookup(engine)
194 | template.instance_variable_set(:@handler, handler)
195 | end
196 |
197 | view_renderer.render_template(view_context, template: template)
198 | end
199 |
200 | ActiveSupport.run_load_hooks(:pushing, self)
201 | end
202 | end
203 |
--------------------------------------------------------------------------------
/test/base_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | require "set"
3 | require "action_dispatch"
4 | require "active_support/time"
5 |
6 | require 'notifiers/base_notifier'
7 |
8 | class BaseTest < ActiveSupport::TestCase
9 | setup do
10 | BaseNotifier.deliveries.clear
11 | end
12 |
13 | test "method call to notification does not raise error" do
14 | assert_nothing_raised { BaseNotifier.welcome }
15 | end
16 |
17 | # Class level API with method missing
18 | test "should respond to action methods" do
19 | assert_respond_to BaseNotifier, :welcome
20 | assert_not BaseNotifier.respond_to?(:push)
21 | end
22 |
23 | # Basic push notification usage without block
24 | test "push() should set the device tokens and generate json payload" do
25 | notification = BaseNotifier.welcome
26 |
27 | assert_equal 'device-token', notification.apn.device_token
28 |
29 | apn_payload = {
30 | aps: {
31 | alert: "New message!",
32 | badge: 9,
33 | sound: "bingbong.aiff"
34 | }
35 | }
36 |
37 | assert_equal apn_payload, notification.apn.payload
38 |
39 | fcm_payload = {
40 | data: {
41 | message: "Hello FCM!"
42 | },
43 | to: "device-token"
44 | }
45 |
46 | assert_equal fcm_payload, notification.fcm.payload
47 | end
48 |
49 | test "should be able to render only with a single service" do
50 | BaseNotifier.with_apn_template.deliver_now!
51 | assert_equal 1, BaseNotifier.deliveries.length
52 | assert_equal 1, BaseNotifier.deliveries.apn.length
53 | assert_equal 0, BaseNotifier.deliveries.fcm.length
54 |
55 | BaseNotifier.with_fcm_template.deliver_now!
56 | assert_equal 2, BaseNotifier.deliveries.length
57 | assert_equal 1, BaseNotifier.deliveries.apn.length
58 | assert_equal 1, BaseNotifier.deliveries.fcm.length
59 | end
60 |
61 | test "should not render if apn device token is falsy" do
62 | BaseNotifier.with_no_apn_device_token.deliver_now!
63 | assert_equal 0, BaseNotifier.deliveries.length
64 | assert_equal 0, BaseNotifier.deliveries.apn.length
65 | assert_equal 0, BaseNotifier.deliveries.fcm.length
66 | end
67 |
68 | test "calling deliver on the action should increment the deliveries collection if using the test notifier" do
69 | BaseNotifier.welcome.deliver_now!
70 | assert_equal 2, BaseNotifier.deliveries.length
71 | assert_equal 1, BaseNotifier.deliveries.apn.length
72 | assert_equal 1, BaseNotifier.deliveries.fcm.length
73 | end
74 |
75 | test "should raise if missing template" do
76 | assert_raises ActionView::MissingTemplate do
77 | BaseNotifier.missing_apn_template.deliver_now!
78 | end
79 | assert_raises ActionView::MissingTemplate do
80 | BaseNotifier.missing_fcm_template.deliver_now!
81 | end
82 |
83 | assert_equal 0, BaseNotifier.deliveries.length
84 | end
85 |
86 | test "the view is not rendered when notification was never called" do
87 | notification = BaseNotifier.without_push_call
88 | notification.deliver_now!
89 |
90 | assert_nil notification.apn
91 | assert_nil notification.fcm
92 | end
93 |
94 | test "the return value of notifier methods is not relevant" do
95 | notification = BaseNotifier.with_nil_as_return_value
96 |
97 | apn_payload = {
98 | aps: {
99 | alert: "New message!",
100 | }
101 | }
102 |
103 | assert_equal apn_payload, notification.apn.payload
104 | assert_equal 'device-token', notification.apn.device_token
105 |
106 | notification.deliver_now!
107 | end
108 |
109 | # Before and After hooks
110 |
111 | class MyObserver
112 | def self.delivered_notification(notification, response)
113 | end
114 | end
115 |
116 | class MySecondObserver
117 | def self.delivered_notification(notification, response)
118 | end
119 | end
120 |
121 | test "you can register an observer to the notifier object that gets informed on notification delivery" do
122 | notification_side_effects do
123 | Pushing::Base.register_observer(MyObserver)
124 | notification = BaseNotifier.with_apn_template
125 | assert_called_with(MyObserver, :delivered_notification, [notification, notification.apn]) do
126 | notification.deliver_now!
127 | end
128 | end
129 | end
130 |
131 | def notification_side_effects
132 | old_observers = Pushing::Base.class_variable_get(:@@delivery_notification_observers)
133 | old_delivery_interceptors = Pushing::Base.class_variable_get(:@@delivery_interceptors)
134 | yield
135 | ensure
136 | Pushing::Base.class_variable_set(:@@delivery_notification_observers, old_observers)
137 | Pushing::Base.class_variable_set(:@@delivery_interceptors, old_delivery_interceptors)
138 | end
139 |
140 | test "you can register multiple observers to the notification object that both get informed on notification delivery" do
141 | notification_side_effects do
142 | Pushing::Base.register_observers(BaseTest::MyObserver, MySecondObserver)
143 | notification = BaseNotifier.with_apn_template
144 | assert_called_with(MyObserver, :delivered_notification, [notification, notification.apn]) do
145 | assert_called_with(MySecondObserver, :delivered_notification, [notification, notification.apn]) do
146 | notification.deliver_now!
147 | end
148 | end
149 | end
150 | end
151 |
152 | class MyInterceptor
153 | def self.delivering_notification(notification); end
154 | def self.previewing_notification(notification); end
155 | end
156 |
157 | class MySecondInterceptor
158 | def self.delivering_notification(notification); end
159 | def self.previewing_notification(notification); end
160 | end
161 |
162 | test "you can register an interceptor to the notification object that gets passed the notification object before delivery" do
163 | notification_side_effects do
164 | Pushing::Base.register_interceptor(MyInterceptor)
165 | notification = BaseNotifier.welcome
166 | assert_called_with(MyInterceptor, :delivering_notification, [notification]) do
167 | notification.deliver_now!
168 | end
169 | end
170 | end
171 |
172 | test "you can register multiple interceptors to the notification object that both get passed the notification object before delivery" do
173 | notification_side_effects do
174 | Pushing::Base.register_interceptors(BaseTest::MyInterceptor, MySecondInterceptor)
175 | notification = BaseNotifier.welcome
176 | assert_called_with(MyInterceptor, :delivering_notification, [notification]) do
177 | assert_called_with(MySecondInterceptor, :delivering_notification, [notification]) do
178 | notification.deliver_now!
179 | end
180 | end
181 | end
182 | end
183 |
184 | test "modifying the notification message with a before_action" do
185 | class BeforeActionNotifier < Pushing::Base
186 | before_action :filter
187 |
188 | def welcome ; notification ; end
189 |
190 | cattr_accessor :called
191 | self.called = false
192 |
193 | private
194 | def filter
195 | self.class.called = true
196 | end
197 | end
198 |
199 | BeforeActionNotifier.welcome.message
200 |
201 | assert BeforeActionNotifier.called, "Before action didn't get called."
202 | end
203 |
204 | test "modifying the notification message with an after_action" do
205 | class AfterActionNotifier < Pushing::Base
206 | after_action :filter
207 |
208 | def welcome ; notification ; end
209 |
210 | cattr_accessor :called
211 | self.called = false
212 |
213 | private
214 | def filter
215 | self.class.called = true
216 | end
217 | end
218 |
219 | AfterActionNotifier.welcome.message
220 |
221 | assert AfterActionNotifier.called, "After action didn't get called."
222 | end
223 |
224 | test "action methods should be refreshed after defining new method" do
225 | class FooNotifier < Pushing::Base
226 | # This triggers action_methods.
227 | respond_to?(:foo)
228 |
229 | def notify
230 | end
231 | end
232 |
233 | assert_equal Set.new(["notify"]), FooNotifier.action_methods
234 | end
235 |
236 | test "notification for process" do
237 | begin
238 | events = []
239 | ActiveSupport::Notifications.subscribe("process.push_notification") do |*args|
240 | events << ActiveSupport::Notifications::Event.new(*args)
241 | end
242 |
243 | BaseNotifier.welcome.deliver_now!
244 |
245 | assert_equal 1, events.length
246 | assert_equal "process.push_notification", events[0].name
247 | assert_equal "BaseNotifier", events[0].payload[:notifier]
248 | assert_equal :welcome, events[0].payload[:action]
249 | assert_equal [], events[0].payload[:args]
250 | ensure
251 | ActiveSupport::Notifications.unsubscribe "process.push_notification"
252 | end
253 | end
254 | end
255 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pushing: ActionMailer for Push Notifications [](https://travis-ci.org/yuki24/pushing)
2 |
3 | Pushing is a push notification framework that implements interfaces similar to ActionMailer.
4 |
5 | * **Convention over Configuration**: Pushing brings Convention over Configuration to your app for organizing your push notification implementations.
6 | * **Extremely Easy to Learn**: If you know how to use ActionMailer, you already know how to use Pushing. Send notifications asynchronously with ActiveJob at no learning cost.
7 | * **Testability**: First-class support for push notification. No more hassle writing custom code or stubs/mocks for your tests.
8 |
9 | ## Getting Started
10 |
11 | Add this line to your application's Gemfile:
12 |
13 | ```ruby
14 | gem 'pushing'
15 | gem 'jbuilder' # if you don't have it in your Gemfile
16 | ```
17 |
18 | At the time of writing, Pushing only has support for [jbuilder](https://github.com/rails/jbuilder) (Rails' default JSON constructor), but there are plans to add support for [jb](https://github.com/amatsuda/jb) and [rabl](https://github.com/nesquena/rabl).
19 |
20 | ### Supported Platforms
21 |
22 | Pushing itself doesn't make HTTP requests. Instead, it uses an adapter to make actual calls. Currently, Pushing has support for the following client gems:
23 |
24 | * [APNs](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html#//apple_ref/doc/uid/TP40008194-CH8-SW1) (iOS):
25 | * [anpotic](https://github.com/ostinelli/apnotic) (recommended)
26 | * [lowdown](https://github.com/alloy/lowdown)
27 | * [houston](https://github.com/nomad/houston)
28 |
29 | * [FCM](https://firebase.google.com/docs/cloud-messaging/) (Android):
30 | * [andpush](https://github.com/yuki24/andpush) (recommended)
31 | * [fcm](https://github.com/spacialdb/fcm)
32 |
33 | If you are starting from scratch, it is recommended using [anpotic](https://github.com/ostinelli/apnotic) for APNs and [andpush](https://github.com/yuki24/andpush) for FCM due to their reliability and performance:
34 |
35 | ```ruby
36 | gem 'apnotic' # APNs
37 | gem 'andpush' # FCM
38 | ```
39 |
40 | ### Walkthrough to Writing a Notifier
41 |
42 | #### Generate a new notifier:
43 |
44 | ```sh
45 | $ rails g pushing:notifier TweetNotifier new_direct_message
46 | ```
47 |
48 | ```ruby
49 | # app/notifiers/tweet_notifier.rb
50 | class TweetNotifier < ApplicationNotifier
51 | def new_direct_message(message_id, token_id)
52 | @message = DirectMessage.find(message_id)
53 | @token = DeviceToken.find(token_id)
54 |
55 | push apn: @token.apn? && @token.device_token, fcm: @token.fcm?
56 | end
57 | end
58 | ```
59 |
60 | #### Edit the push notification payload:
61 |
62 | APNs:
63 |
64 | ```ruby
65 | # app/views/tweet_notifier/new_direct_message.json+apn.jbuilder
66 | json.aps do
67 | json.alert do
68 | json.title "#{@tweet.user.display_name} tweeted:"
69 | json.body truncate(@tweet.body, length: 235)
70 | end
71 |
72 | json.badge 1
73 | json.sound 'bingbong.aiff'
74 | end
75 | ```
76 |
77 | FCM:
78 |
79 | ```ruby
80 | # app/views/tweet_notifier/new_direct_message.json+fcm.jbuilder
81 | json.to @token.registration_id
82 |
83 | json.notification do
84 | json.title "#{@tweet.user.display_name} tweeted:"
85 | json.body truncate(@tweet.body, length: 1024)
86 |
87 | json.icon 1
88 | json.sound 'default'
89 | end
90 | ```
91 |
92 | ### Deliver the push notifications:
93 |
94 | ```ruby
95 | TweetNotifier.new_direct_message(message_id, device_token.id).deliver_now!
96 | # => sends a push notification immediately
97 |
98 | TweetNotifier.new_direct_message(message_id, device_token.id).deliver_later!
99 | # => enqueues a job that sends a push notification later
100 | ```
101 |
102 | ## Advanced Usage
103 |
104 | ### Pushing only to one platform
105 |
106 | Pushing only sends a notification for the platforms that are given a truthy value. For example, give the following code:
107 |
108 | ```ruby
109 | push apn: @token.device_token, fcm: false
110 | # => only sends a push notification to APNs
111 |
112 | push apn: @token.device_token
113 | # => same as above but without the `:fcm` key, only sends a push notification to APNs
114 | ```
115 |
116 | This will only send a push notification to APNs and skip the call to FCM.
117 |
118 | ### APNs
119 |
120 | It is often necessary to switch the environment endpoint or adjust the request headers depending on the notification you want to send. Pushing's `#push` method allows for overriding APNs request headers on a delivery-basis:
121 |
122 | #### Overriding the default environment:
123 |
124 | ```ruby
125 | push apn: { device_token: @token.device_token, environment: @token.apn_environment }
126 | ```
127 |
128 | #### Overriding the default APN topic:
129 |
130 | ```ruby
131 | push apn: { device_token: @token.device_token, headers: { apns_topic: 'your.otherapp.ios' } }
132 | ```
133 |
134 | #### Or all of the above:
135 |
136 | ```ruby
137 | push fcm: @token.fcm?,
138 | apn: {
139 | device_token: @token.apn? && @token.device_token,
140 | environment: @token.apn_environment,
141 | headers: {
142 | apns_id: uuid,
143 | apns_expiration: 7.days.from_now,
144 | apns_priority: 5,
145 | apns_topic: 'your.otherapp.ios',
146 | apns_collapse_id: 'not-so-important-notification'
147 | }
148 | }
149 | ```
150 |
151 | The `:fcm` key, on the other hand, doesn't have any options as everything's configurable through the request body.
152 |
153 | ## Error Handling
154 |
155 | Like ActionMailer, you can use the `rescue_from` hook to handle exceptions. A common use-case would be to handle a **'BadDeviceToken'** response from APNs or a response with a **'Retry-After'** header from FCM.
156 |
157 | **Handling a 'BadDeviceToken' response from APNs**:
158 |
159 | ```ruby
160 | class ApplicationNotifier < Pushing::Base
161 | rescue_from Pushing::ApnDeliveryError do |error|
162 | response = error.response
163 |
164 | if response.status == 410 || (response.status == 400 && response.json[:reason] == 'BadDeviceToken')
165 | token = error.notification.device_token
166 | Rails.logger.info("APN device token #{token} has been expired and will be removed.")
167 |
168 | # delete or expire device token accordingly
169 | else
170 | raise # Make sure to raise any other types of error to re-enqueue the job
171 | end
172 | end
173 | end
174 | ```
175 |
176 | **Handling a 'Retry-After' header from FCM**:
177 |
178 | ```ruby
179 | class ApplicationNotifier < Pushing::Base
180 | rescue_from Pushing::FcmDeliveryError do |error|
181 | if error.response&.headers['Retry-After']
182 | # re-enqueue the job honoring the 'Retry-After' header
183 | else
184 | raise # Make sure to raise any other types of error to re-enqueue the job
185 | end
186 | end
187 | end
188 | ```
189 |
190 | ## Interceptors and Observers
191 |
192 | Pushing implements the Interceptor and Observer patterns. A common use-case would be to update registration ids with canonical ids from FCM:
193 |
194 | ```ruby
195 | # app/observers/fcm_token_handler.rb
196 | class FcmTokenHandler
197 | def delivered_notification(payload, response)
198 | return if response.json[:canonical_ids].to_i.zero?
199 |
200 | response.json[:results].select {|result| result[:registration_id] }.each do |result|
201 | result[:registration_id] # => returns a canonical id
202 |
203 | # Update registration ids accordingly
204 | end
205 | end
206 | end
207 |
208 | # app/notifiers/application_notifier.rb
209 | class ApplicationNotifier < Pushing::Base
210 | register_observer FcmTokenHandler.new
211 |
212 | ...
213 | end
214 | ```
215 |
216 | ## Configuration
217 |
218 | ##### TODO: Make this section more helpful
219 |
220 | ```ruby
221 | Pushing.configure do |config|
222 | # Adapter that is used to send push notifications through FCM
223 | config.fcm.adapter = Rails.env.test? ? :test : :andpush
224 |
225 | # Your FCM servery key that can be found here: https://console.firebase.google.com/project/_/settings/cloudmessaging
226 | config.fcm.server_key = 'YOUR_FCM_SERVER_KEY'
227 |
228 | # Adapter that is used to send push notifications through APNs
229 | config.apn.adapter = Rails.env.test? ? :test : :apnotic
230 |
231 | # The environment that is used by default to send push notifications through APNs
232 | config.apn.environment = Rails.env.production? ? :production : :development
233 |
234 | # The scheme that is used for negotiating connection trust between your provider
235 | # servers and Apple Push Notification service. As documented in the offitial doc,
236 | # there are two schemes available:
237 | #
238 | # :token - Token-based provider connection trust (default)
239 | # :certificate - Certificate-based provider connection trust
240 | #
241 | # This option is only applied when using an adapter that uses the HTTP/2-based
242 | # API.
243 | config.apn.connection_scheme = :token
244 |
245 | # Path to the certificate or auth key for establishing a connection to APNs.
246 | #
247 | # This config is always required.
248 | config.apn.certificate_path = 'path/to/your/certificate'
249 |
250 | # Password for the certificate specified above if there's any.
251 | # config.apn.certificate_password = 'passphrase'
252 |
253 | # A 10-character key identifier (kid) key, obtained from your developer account.
254 | # If you haven't created an Auth Key for your app, create a new one at:
255 | # https://developer.apple.com/account/ios/authkey/
256 | #
257 | # Required if the +connection_scheme+ is set to +:token+.
258 | config.apn.key_id = 'DEF123GHIJ'
259 |
260 | # The issuer (iss) registered claim key, whose value is your 10-character Team ID,
261 | # obtained from your developer account. Your team id could be found at:
262 | # https://developer.apple.com/account/#/membership
263 | #
264 | # Required if the +connection_scheme+ is set to +:token+.
265 | config.apn.team_id = 'ABC123DEFG'
266 |
267 | # Header values that are added to every request to APNs. documentation for the
268 | # headers available can be found here:
269 | # https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW13
270 | config.apn.default_headers = {
271 | apns_priority: 10,
272 | apns_topic: 'your.awesomeapp.ios',
273 | apns_collapse_id: 'wrong.topicname.com'
274 | }
275 | end
276 |
277 | ```
278 |
279 | ## Testing
280 |
281 | Pushing provides first-class support for testing. In order to test your notifier, use the `:test` adapter in the test environment instead of an actual adapter in development/production.
282 |
283 | ```ruby
284 | # config/initializers/pushing.rb
285 | Pushing.configure do |config|
286 | config.apn.adapter = Rails.env.test? ? :test : :apnotic
287 | config.fcm.adapter = Rails.env.test? ? :test : :andpush
288 | end
289 | ```
290 |
291 | Now you can call the `#deliveries` method on the notifier. Here is an example with [ActiveSupport::TestCase](http://api.rubyonrails.org/classes/ActiveSupport/TestCase.html):
292 |
293 | ```ruby
294 | TweetNotifier.deliveries.clear # => clears the test inbox
295 |
296 | assert_changes -> { TweetNotifier.deliveries.apn.size }, from: 0, to: 1 do
297 | TweetNotifier.new_direct_message(message.id, apn_device_token.id).deliver_now!
298 | end
299 |
300 | apn_message = TweetNotifier.deliveries.apn.first
301 | assert_equal 'apn-device-token', apn_message.device_token
302 | assert_equal "Hey coffee break?", apn_message.payload[:aps][:alert][:body]
303 |
304 | assert_changes -> { TweetNotifier.deliveries.fcm.size }, from: 0, to: 1 do
305 | TweetNotifier.new_direct_message(message.id, fcm_registration_id.id).deliver_now!
306 | end
307 |
308 | fcm_payload = TweetNotifier.deliveries.fcm.first.payload
309 | assert_equal 'fcm-registration-id', fcm_payload[:to]
310 | assert_equal "Hey coffee break?", fcm_payload[:notification][:body]
311 | ```
312 |
313 | ## Contributing
314 |
315 | Bug reports and pull requests are welcome on GitHub at https://github.com/yuki24/pushing. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
316 |
317 | ## License
318 |
319 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
320 |
--------------------------------------------------------------------------------