├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── andpush.gemspec
├── assets
└── curl-version.png
├── bin
├── console
└── setup
├── fcm.apispec
├── lib
├── andpush.rb
└── andpush
│ ├── client.rb
│ ├── exceptions.rb
│ ├── json_handler.rb
│ └── version.rb
├── response_types.yml
└── test
├── andpush_test.rb
└── test_helper.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /Gemfile.lock
4 | /_yardoc/
5 | /coverage/
6 | /doc/
7 | /pkg/
8 | /spec/reports/
9 | /tmp/
10 | .env
11 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | cache: bundler
3 | script: bundle exec rake test
4 | sudo: false
5 |
6 | before_install:
7 | - gem update --system
8 | - gem install bundler
9 |
10 | rvm:
11 | - 2.2.10
12 | - 2.3.8
13 | - 2.4.5
14 | - 2.5.3
15 | - 2.6.0
16 | - ruby-head
17 | - jruby-9.2.5.0
18 | - jruby-head
19 |
20 | matrix:
21 | allow_failures:
22 | - rvm: ruby-head
23 | - rvm: jruby-9.2.5.0
24 | - rvm: jruby-head
25 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [v0.2.0](https://github.com/yuki24/andpush/tree/v0.2.0)
2 |
3 | _released on 2018-03-08 01:33:21 UTC_
4 |
5 | #### New Features
6 |
7 | - Add support for Ruby 2.5.0
8 | - Add the ability to pass in the `name`, `proxy` and`pool_size` options to the `Android.new` method to configure connection pool
9 |
10 | ## [v0.1.0: First release](https://github.com/yuki24/andpush/tree/v0.1.0)
11 |
12 | _released on 2017-06-03 02:04:28 UTC_
13 |
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | # Specify your gem's dependencies in andpush.gemspec
4 | gemspec
5 |
6 | gem 'oven', '0.1.0.rc1'
7 | gem 'rubocop'
8 | gem 'pry'
9 | gem 'http_logger'
10 | gem 'curb'
11 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Andpush [](https://travis-ci.org/yuki24/andpush)
2 |
3 | Andpush is an HTTP client for FCM (Firebase Cloud Messaging). It implements [the Firebase Cloud Messaging HTTP Protocol](https://firebase.google.com/docs/cloud-messaging/http-server-ref).
4 |
5 | The `andpush` gem performs **about 3.7x faster** than [the fcm gem](https://github.com/spacialdb/fcm) in a single-threaded environment.
6 |
7 | **If you are thinking to send push notifications from Rails, consider using the [pushing gem](https://github.com/yuki24/pushing), a push notification framework that does not hurt.**
8 |
9 | ## Installation
10 |
11 | Add this line to your application's Gemfile:
12 |
13 | ```ruby
14 | gem 'andpush'
15 | ```
16 |
17 | Or install it yourself as:
18 |
19 | $ gem install andpush
20 |
21 | ## Usage
22 |
23 | You'll need your application's server key, whose value is available in the [Cloud Messaging](https://console.firebase.google.com/project/_/settings/cloudmessaging) tab of the Firebase console Settings pane.
24 |
25 | ```ruby
26 | require 'andpush'
27 |
28 | server_key = "..." # Your server key
29 | device_token = "..." # The device token of the device you'd like to push a message to
30 |
31 | client = Andpush.new(server_key, pool_size: 25)
32 | payload = {
33 | to: device_token,
34 | notification: {
35 | title: "Update",
36 | body: "Your weekly summary is ready"
37 | },
38 | data: { extra: "data" }
39 | }
40 |
41 | response = client.push(payload)
42 |
43 | headers = response.headers
44 | headers['Retry-After'] # => returns 'Retry-After'
45 |
46 | json = response.json
47 | json[:canonical_ids] # => 0
48 | json[:failure] # => 0
49 | json[:multicast_id] # => 8478364278516813477
50 |
51 | result = json[:results].first
52 | result[:message_id] # => "0:1489498959348701%3b8aef473b8aef47"
53 | result[:error] # => nil, "InvalidRegistration" or something else
54 | result[:registration_id] # => nil
55 | ```
56 |
57 | ### Topic Messaging:
58 |
59 | ```ruby
60 | topic = "/topics/foo-bar"
61 | payload = {
62 | to: topic,
63 | data: {
64 | message: "This is a Firebase Cloud Messaging Topic Message!",
65 | }
66 | }
67 |
68 | response = client.push(payload) # => sends a message to the topic
69 | ```
70 |
71 | ## Using HTTP/2 (Experimental)
72 |
73 | The current GitHub master branch ships with experimental support for HTTP/2. It takes advantage of the fantastic library, [libcurl](https://curl.haxx.se/libcurl/). In order to use it, replace `Andpush.new(...)` with `Andpush.http2(...)`:
74 |
75 | ```diff
76 | +# Do not forget to add the curb gem to your Gemfile
77 | +require 'curb'
78 |
79 | -client = Andpush.new(server_key, pool_size: 25)
80 | +client = Andpush.http2(server_key) # no need to specify the `pool_size' as HTTP/2 maintains a single connection
81 | ```
82 |
83 | ### Prerequisites
84 |
85 | * [libcurl](https://curl.haxx.se/download.html) 7.43.0 or later
86 | * [nghttp2](https://nghttp2.org/blog/) 1.0 or later
87 |
88 | **Make sure that your production environment has the compatible versions installed. If you are not sure what version of libcurl you are using, try running `curl --version` and make sure it has `HTTP2` listed in the Features:**
89 |
90 | 
91 |
92 | **If you wish to use the HTTP/2 client in heroku, make sure you are using [the `Heroku-18` stack](https://devcenter.heroku.com/articles/heroku-18-stack). Older stacks, such as `Heroku-16` and `Cedar-14` do not ship with a version of libcurl that has support for HTTP/2.**
93 |
94 | If you are using an older version of libcurl that doesn't support HTTP/2, don't worry. It will just fall back to HTTP 1.1 (of course without header compression and multiplexing.)
95 |
96 | ## Performance
97 |
98 | The andpush gem uses [HTTP persistent connections](https://en.wikipedia.org/wiki/HTTP_persistent_connection) to improve performance. This is done by [the net-http-persistent gem](https://github.com/drbrain/net-http-persistent). [A simple benchmark](https://gist.github.com/yuki24/e0db97e887b8b6eb1932c41b4cea4a99) shows that the andpush gem performs at least 3x faster than the fcm gem:
99 |
100 | ```sh
101 | $ ruby bench.rb
102 | Warming up --------------------------------------
103 | andpush 2.000 i/100ms
104 | fcm 1.000 i/100ms
105 | Calculating -------------------------------------
106 | andpush 28.009 (± 7.1%) i/s - 140.000 in 5.019399s
107 | fcm 7.452 (±13.4%) i/s - 37.000 in 5.023139s
108 |
109 | Comparison:
110 | andpush: 28.0 i/s
111 | fcm: 7.5 i/s - 3.76x slower
112 | ```
113 |
114 | ## Contributing
115 |
116 | Bug reports and pull requests are welcome on GitHub at https://github.com/yuki24/andpush. 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.
117 |
118 | ## License
119 |
120 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
121 |
--------------------------------------------------------------------------------
/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']
8 | end
9 |
10 | desc 'Generate an API client with the oven gem (the cmd overrides the client if it already exists)'
11 | task :generate do
12 | sh "ruby -roven fcm.apispec && rubocop -a lib/"
13 | end
14 |
15 | task :default => :test
16 |
--------------------------------------------------------------------------------
/andpush.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'andpush/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "andpush"
8 | spec.version = Andpush::VERSION
9 | spec.authors = ["Yuki Nishijima"]
10 | spec.email = ["mail@yukinishijima.net"]
11 | spec.summary = %q{Simple, fast, high-quality client for FCM (Firebase Cloud Messaging)}
12 | spec.description = %q{Android Push Notification in Ruby: simple, fast, high-quality client for FCM (Firebase Cloud Messaging)}
13 | spec.homepage = "https://github.com/yuki24/andpush"
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 'net-http-persistent', '>= 3.0.0'
19 |
20 | spec.add_development_dependency "bundler"
21 | spec.add_development_dependency "rake"
22 | spec.add_development_dependency "minitest"
23 | end
24 |
--------------------------------------------------------------------------------
/assets/curl-version.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yuki24/andpush/2b5001ceb2279801c2ae3a2763267b88e21b22f0/assets/curl-version.png
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "andpush"
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/fcm.apispec:
--------------------------------------------------------------------------------
1 | require 'oven'
2 |
3 | Oven.bake :'Andpush::Client', destination: 'lib/andpush/' do
4 | format :json
5 |
6 | post :message, '/fcm/send', as: :push, class: 'Response'
7 | end
8 |
--------------------------------------------------------------------------------
/lib/andpush.rb:
--------------------------------------------------------------------------------
1 | require 'net/http/persistent'
2 |
3 | require 'andpush/version'
4 | require 'andpush/client'
5 |
6 | module Andpush
7 | DOMAIN = 'https://fcm.googleapis.com'.freeze
8 |
9 | class << self
10 | def build(server_key, domain: nil, name: nil, proxy: nil, pool_size: Net::HTTP::Persistent::DEFAULT_POOL_SIZE)
11 | ::Andpush::Client
12 | .new(domain || DOMAIN, request_handler: ConnectionPool.new(name: name, proxy: proxy, pool_size: pool_size))
13 | .register_interceptor(Authenticator.new(server_key))
14 | end
15 | alias new build
16 |
17 | def http2(server_key, domain: nil)
18 | begin
19 | require 'curb' if !defined?(Curl)
20 | rescue LoadError => error
21 | raise LoadError, "Could not load the curb gem. Make sure to install the gem by running:\n\n" \
22 | " $ gem i curb\n\n" \
23 | "Or the Gemfile has the following declaration:\n\n" \
24 | " gem 'curb'\n\n" \
25 | " (#{error.class}: #{error.message})"
26 | end
27 |
28 | ::Andpush::Client
29 | .new(domain || DOMAIN, request_handler: Http2RequestHandler.new)
30 | .register_interceptor(Authenticator.new(server_key))
31 | end
32 | end
33 |
34 | class Authenticator
35 | def initialize(server_key)
36 | @server_key = server_key
37 | end
38 |
39 | def before_request(uri, body, headers, options)
40 | headers['Authorization'] = "key=#{@server_key}"
41 |
42 | [uri, body, headers, options]
43 | end
44 | end
45 |
46 | class ConnectionPool
47 | attr_reader :connection
48 |
49 | def initialize(name: nil, proxy: nil, pool_size: Net::HTTP::Persistent::DEFAULT_POOL_SIZE)
50 | @connection = Net::HTTP::Persistent.new(name: name, proxy: proxy, pool_size: pool_size)
51 | end
52 |
53 | def call(request_class, uri, headers, body, *_)
54 | req = request_class.new(uri, headers)
55 | req.set_body_internal(body)
56 |
57 | connection.request(uri, req)
58 | end
59 | end
60 |
61 | class Http2RequestHandler
62 | BY_HEADER_LINE = /[\r\n]+/.freeze
63 | HEADER_VALUE = /^(\S+): (.+)/.freeze
64 | EMPTY_HEADERS = {}.freeze
65 |
66 | attr_reader :multi
67 |
68 | def initialize(max_connects: 100)
69 | @multi = Curl::Multi.new
70 |
71 | @multi.pipeline = Curl::CURLPIPE_MULTIPLEX if defined?(Curl::CURLPIPE_MULTIPLEX)
72 | @multi.max_connects = max_connects
73 | end
74 |
75 | def call(request_class, uri, headers, body, *_)
76 | easy = Curl::Easy.new(uri.to_s)
77 |
78 | easy.multi = @multi
79 | easy.headers = headers || EMPTY_HEADERS
80 | easy.post_body = body if request_class::REQUEST_HAS_BODY
81 |
82 | if defined?(Curl::CURLPIPE_MULTIPLEX)
83 | # This ensures libcurl waits for the connection to reveal if it is
84 | # possible to pipeline/multiplex on before it continues.
85 | easy.setopt(Curl::CURLOPT_PIPEWAIT, 1)
86 | easy.version = Curl::HTTP_2_0
87 | end
88 |
89 | easy.public_send(:"http_#{request_class::METHOD.downcase}")
90 |
91 | Response.new(
92 | Hash[easy.header_str.split(BY_HEADER_LINE).flat_map {|s| s.scan(HEADER_VALUE) }],
93 | easy.body,
94 | easy.response_code.to_s, # to_s for compatibility with Net::HTTP
95 | easy,
96 | ).freeze
97 | end
98 |
99 | Response = Struct.new(:headers, :body, :code, :raw_response) do
100 | alias to_hash headers
101 | end
102 |
103 | private_constant :BY_HEADER_LINE, :HEADER_VALUE, :Response
104 | end
105 |
106 | private_constant :Authenticator, :ConnectionPool, :Http2RequestHandler
107 | end
108 |
--------------------------------------------------------------------------------
/lib/andpush/client.rb:
--------------------------------------------------------------------------------
1 | # frozen-string-literal: true
2 | require 'net/http'
3 | require 'andpush/exceptions'
4 | require 'andpush/json_handler'
5 |
6 | module Andpush
7 | class Client
8 | attr_reader :domain, :proxy_addr, :proxy_port, :proxy_user, :proxy_password, :request_handler
9 |
10 | def initialize(domain, proxy_addr: nil, proxy_port: nil, proxy_user: nil, proxy_password: nil, request_handler: RequestHandler.new, **options)
11 | @domain = domain
12 | @proxy_addr = proxy_addr
13 | @proxy_port = proxy_port
14 | @proxy_user = proxy_user
15 | @proxy_password = proxy_password
16 | @interceptors = []
17 | @observers = []
18 | @request_handler = request_handler
19 | @options = DEFAULT_OPTIONS.merge(options)
20 |
21 | register_interceptor(JsonSerializer.new)
22 | register_observer(ResponseHandler.new)
23 | register_observer(JsonDeserializer.new)
24 | end
25 |
26 | def register_interceptor(interceptor)
27 | @interceptors << interceptor
28 | self
29 | end
30 |
31 | def register_observer(observer)
32 | @observers << observer
33 | self
34 | end
35 |
36 | def push(body, query: {}, headers: {}, **options)
37 | request(Net::HTTP::Post, uri('/fcm/send', query), body, headers, method: :push, **options)
38 | end
39 |
40 | private
41 |
42 | DEFAULT_OPTIONS = {
43 | ca_file: nil,
44 | ca_path: nil,
45 | cert: nil,
46 | cert_store: nil,
47 | ciphers: nil,
48 | close_on_empty_response: nil,
49 | key: nil,
50 | open_timeout: nil,
51 | read_timeout: nil,
52 | ssl_timeout: nil,
53 | ssl_version: nil,
54 | use_ssl: nil,
55 | verify_callback: nil,
56 | verify_depth: nil,
57 | verify_mode: nil
58 | }.freeze
59 |
60 | HTTPS = 'https'.freeze
61 |
62 | def request(request_class, uri, body, headers, options = {})
63 | uri, body, headers, options = @interceptors.reduce([uri, body, headers, @options.merge(options)]) { |r, i| i.before_request(*r) }
64 |
65 | response = begin
66 | request_handler.call(request_class, uri, headers, body, proxy_addr, proxy_port, proxy_user, proxy_password, options)
67 | rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e
68 | raise NetworkError, "A network error occurred: #{e.class} (#{e.message})"
69 | end
70 |
71 | @observers.reduce(response) { |r, o| o.received_response(r, options) }
72 | end
73 |
74 | def uri(path, query = {})
75 | uri = URI.join(domain, path)
76 | uri.query = URI.encode_www_form(query) unless query.empty?
77 | uri
78 | end
79 |
80 | class RequestHandler
81 | def call(request_class, uri, headers, body, proxy_addr, proxy_port, proxy_user, proxy_password, options = {})
82 | Net::HTTP.start(uri.host, uri.port, proxy_addr, proxy_port, proxy_user, proxy_password, options, use_ssl: (uri.scheme == HTTPS)) do |http|
83 | http.request request_class.new(uri, headers), body
84 | end
85 | end
86 | end
87 |
88 | private_constant :RequestHandler
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/andpush/exceptions.rb:
--------------------------------------------------------------------------------
1 | module Andpush
2 | class APIError < StandardError; end
3 | class NetworkError < APIError; end
4 |
5 | class HttpError < APIError
6 | attr_reader :response
7 |
8 | def initialize(message, response)
9 | super(message)
10 | @response = response
11 | end
12 | end
13 |
14 | class ClientError < HttpError; end
15 |
16 | class BadRequest < ClientError; end # status: 400
17 | class Unauthorized < ClientError; end # status: 401
18 | class PaymentRequired < ClientError; end # status: 402
19 | class Forbidden < ClientError; end # status: 403
20 | class NotFound < ClientError; end # status: 404
21 | class MethodNotAllowed < ClientError; end # status: 405
22 | class NotAcceptable < ClientError; end # status: 406
23 | class ProxyAuthenticationRequired < ClientError; end # status: 407
24 | class RequestTimeout < ClientError; end # status: 408
25 | class Conflict < ClientError; end # status: 409
26 | class Gone < ClientError; end # status: 410
27 | class LengthRequired < ClientError; end # status: 411
28 | class PreconditionFailed < ClientError; end # status: 412
29 | class PayloadTooLarge < ClientError; end # status: 413
30 | class URITooLong < ClientError; end # status: 414
31 | class UnsupportedMediaType < ClientError; end # status: 415
32 | class RangeNotSatisfiable < ClientError; end # status: 416
33 | class ExpectationFailed < ClientError; end # status: 417
34 | class ImaTeapot < ClientError; end # status: 418
35 | class MisdirectedRequest < ClientError; end # status: 421
36 | class UnprocessableEntity < ClientError; end # status: 422
37 | class Locked < ClientError; end # status: 423
38 | class FailedDependency < ClientError; end # status: 424
39 | class UpgradeRequired < ClientError; end # status: 426
40 | class PreconditionRequired < ClientError; end # status: 428
41 | class TooManyRequests < ClientError; end # status: 429
42 | class RequestHeaderFieldsTooLarge < ClientError; end # status: 431
43 | class UnavailableForLegalReasons < ClientError; end # status: 451
44 |
45 | class ServerError < HttpError; end
46 |
47 | class InternalServerError < ServerError; end # status: 500
48 | class NotImplemented < ServerError; end # status: 501
49 | class BadGateway < ServerError; end # status: 502
50 | class ServiceUnavailable < ServerError; end # status: 503
51 | class GatewayTimeout < ServerError; end # status: 504
52 | class HTTPVersionNotSupported < ServerError; end # status: 505
53 | class VariantAlsoNegotiates < ServerError; end # status: 506
54 | class InsufficientStorage < ServerError; end # status: 507
55 | class LoopDetected < ServerError; end # status: 508
56 | class NotExtended < ServerError; end # status: 510
57 | class NetworkAuthenticationRequired < ServerError; end # status: 511
58 |
59 | STATUS_TO_EXCEPTION_MAPPING = {
60 | '400' => BadRequest,
61 | '401' => Unauthorized,
62 | '402' => PaymentRequired,
63 | '403' => Forbidden,
64 | '404' => NotFound,
65 | '405' => MethodNotAllowed,
66 | '406' => NotAcceptable,
67 | '407' => ProxyAuthenticationRequired,
68 | '408' => RequestTimeout,
69 | '409' => Conflict,
70 | '410' => Gone,
71 | '411' => LengthRequired,
72 | '412' => PreconditionFailed,
73 | '413' => PayloadTooLarge,
74 | '414' => URITooLong,
75 | '415' => UnsupportedMediaType,
76 | '416' => RangeNotSatisfiable,
77 | '417' => ExpectationFailed,
78 | '418' => ImaTeapot,
79 | '421' => MisdirectedRequest,
80 | '422' => UnprocessableEntity,
81 | '423' => Locked,
82 | '424' => FailedDependency,
83 | '426' => UpgradeRequired,
84 | '428' => PreconditionRequired,
85 | '429' => TooManyRequests,
86 | '431' => RequestHeaderFieldsTooLarge,
87 | '451' => UnavailableForLegalReasons,
88 | '500' => InternalServerError,
89 | '501' => NotImplemented,
90 | '502' => BadGateway,
91 | '503' => ServiceUnavailable,
92 | '504' => GatewayTimeout,
93 | '505' => HTTPVersionNotSupported,
94 | '506' => VariantAlsoNegotiates,
95 | '507' => InsufficientStorage,
96 | '508' => LoopDetected,
97 | '510' => NotExtended,
98 | '511' => NetworkAuthenticationRequired
99 | }.freeze
100 |
101 | class ResponseHandler
102 | def received_response(response, _options)
103 | error = STATUS_TO_EXCEPTION_MAPPING[response.code]
104 | raise error.new("Receieved an error response #{response.code} #{error.to_s.split('::').last}: #{response.body}", response) if error
105 | response
106 | end
107 | end
108 |
109 | private_constant :ResponseHandler
110 | end
111 |
--------------------------------------------------------------------------------
/lib/andpush/json_handler.rb:
--------------------------------------------------------------------------------
1 | require 'json'
2 | require 'delegate'
3 |
4 | module Andpush
5 | class JsonResponse < DelegateClass(Net::HTTPResponse)
6 | alias response __getobj__
7 | alias headers to_hash
8 | HAS_SYMBOL_GC = RUBY_VERSION > '2.2.0'
9 |
10 | def json
11 | parsable? ? JSON.parse(body, symbolize_names: HAS_SYMBOL_GC) : nil
12 | end
13 |
14 | def inspect
15 | "#"
16 | end
17 | alias to_s inspect
18 |
19 | def parsable?
20 | !!body && !body.empty?
21 | end
22 | end
23 |
24 | class JsonSerializer
25 | APPLICATION_JSON = 'application/json'.freeze
26 | JSON_REQUEST_HEADERS = {
27 | 'Content-Type' => APPLICATION_JSON,
28 | 'Accept' => APPLICATION_JSON
29 | }.freeze
30 |
31 | def before_request(uri, body, headers, options)
32 | headers = headers.merge(JSON_REQUEST_HEADERS)
33 | body = body.nil? || body.is_a?(String) ? body : body.to_json
34 |
35 | [uri, body, headers, options]
36 | end
37 | end
38 |
39 | class JsonDeserializer
40 | def received_response(response, _options)
41 | JsonResponse.new(response)
42 | end
43 | end
44 |
45 | private_constant :JsonSerializer, :JsonDeserializer
46 | end
47 |
--------------------------------------------------------------------------------
/lib/andpush/version.rb:
--------------------------------------------------------------------------------
1 | module Andpush
2 | VERSION = '0.2.1'.freeze
3 | end
4 |
--------------------------------------------------------------------------------
/response_types.yml:
--------------------------------------------------------------------------------
1 | Response:
2 | multicast_id: Integer
3 | success: Integer
4 | failure: Integer
5 | canonical_ids: Integer
6 | results: Array(Result)
7 |
8 | Result:
9 | message_id: String
10 | registration_id: String
11 | error: String
12 |
13 | ErrorResponse:
14 | operation: String
15 | notification_key_name: String
16 | notification_key: String
17 | registration_ids: Array(String)
18 |
--------------------------------------------------------------------------------
/test/andpush_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class AndpushTest < Minitest::Test
4 | def test_it_makes_http_request_to_fcm
5 | server_key = ENV.fetch('FCM_TEST_SERVER_KEY')
6 | device_token = ENV.fetch('FCM_TEST_REGISTRATION_TOKEN')
7 |
8 | client = Andpush.build(server_key)
9 | json = {
10 | to: device_token,
11 | dry_run: true,
12 | notification: {
13 | title: "Update",
14 | body: "Your weekly summary is ready"
15 | },
16 | data: {
17 | extra: "data"
18 | }
19 | }
20 |
21 | response = client.push(json)
22 |
23 | assert_equal '200', response.code
24 |
25 | json = response.json
26 |
27 | assert_equal(-1, json[:multicast_id])
28 | assert_equal 1, json[:success]
29 | assert_equal 0, json[:failure]
30 | assert_equal 0, json[:canonical_ids]
31 | assert_equal "fake_message_id", json[:results][0][:message_id]
32 | end
33 |
34 | def test_http2_client_makes_http2_request_to_fcm
35 | server_key = ENV.fetch('FCM_TEST_SERVER_KEY')
36 | device_token = ENV.fetch('FCM_TEST_REGISTRATION_TOKEN')
37 |
38 | client = Andpush.http2(server_key)
39 | json = {
40 | to: device_token,
41 | dry_run: true,
42 | notification: {
43 | title: "Update",
44 | body: "Your weekly summary is ready"
45 | },
46 | data: {
47 | extra: "data"
48 | }
49 | }
50 |
51 | response = client.push(json)
52 |
53 | assert_equal '200', response.code
54 |
55 | json = response.json
56 |
57 | assert_equal(-1, json[:multicast_id])
58 | assert_equal 1, json[:success]
59 | assert_equal 0, json[:failure]
60 | assert_equal 0, json[:canonical_ids]
61 | assert_equal "fake_message_id", json[:results][0][:message_id]
62 |
63 | if defined?(Curl::CURLPIPE_MULTIPLEX)
64 | assert_match "HTTP/2", response.raw_response.header_str
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2 | require 'andpush'
3 | require 'minitest/autorun'
4 |
--------------------------------------------------------------------------------