├── .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 [![Build Status](https://travis-ci.org/yuki24/andpush.svg?branch=master)](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 | ![Curl version](assets/curl-version.png "Curl version") 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 | --------------------------------------------------------------------------------