├── Gemfile
├── lib
├── pusher
│ ├── version.rb
│ ├── resource.rb
│ ├── request.rb
│ ├── webhook.rb
│ ├── channel.rb
│ └── client.rb
└── pusher.rb
├── Rakefile
├── pull_request_template.md
├── .gitignore
├── .github
├── workflows
│ ├── publish.yml
│ ├── test.yml
│ ├── gh-release.yml
│ └── release.yml
└── stale.yml
├── spec
├── spec_helper.rb
├── web_hook_spec.rb
├── channel_spec.rb
└── client_spec.rb
├── examples
├── async_message.rb
└── presence_channels
│ ├── public
│ └── presence_channels.html
│ └── presence_channels.rb
├── LICENSE
├── pusher.gemspec
├── CHANGELOG.md
└── README.md
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | gemspec
3 |
--------------------------------------------------------------------------------
/lib/pusher/version.rb:
--------------------------------------------------------------------------------
1 | module Pusher
2 | VERSION = '2.0.3'
3 | end
4 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler'
2 | Bundler::GemHelper.install_tasks
3 |
4 | require "rspec/core/rake_task"
5 |
6 | RSpec::Core::RakeTask.new(:spec) do |s|
7 | s.pattern = 'spec/**/*.rb'
8 | end
9 |
10 | task :default => :spec
11 | task :test => :spec
12 |
--------------------------------------------------------------------------------
/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | Add a short description of the change. If this is related to an issue, please add a reference to the issue.
4 |
5 | ## CHANGELOG
6 |
7 | * [CHANGED] Describe your change here. Look at CHANGELOG.md to see the format.
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## MAC OS
2 | .DS_Store
3 |
4 | ## TEXTMATE
5 | *.tmproj
6 | tmtags
7 |
8 | ## EMACS
9 | *~
10 | \#*
11 | .\#*
12 |
13 | ## VIM
14 | *.swp
15 |
16 | ## PROJECT::GENERAL
17 | coverage
18 | rdoc
19 | pkg
20 | .yardoc
21 | Gemfile.lock
22 |
23 | ## PROJECT::SPECIFIC
24 | .bundle
25 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: RubyGems release
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v1
13 |
14 | - name: Publish gem
15 | uses: dawidd6/action-publish-gem@v1
16 | with:
17 | api_key: ${{secrets.RUBYGEMS_API_KEY}}
18 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [master, main]
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-20.04
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | ruby: ['2.6', '2.7', '3.0']
15 |
16 | name: Ruby ${{ matrix.ruby }} Test
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v2
21 |
22 | - name: Setup Ruby
23 | uses: ruby/setup-ruby@v1
24 | with:
25 | ruby-version: ${{ matrix.ruby }}
26 |
27 | - name: Install dependencies
28 | run: bundle install
29 |
30 | - name: Run test suite
31 | run: bundle exec rake
32 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | begin
2 | require 'bundler/setup'
3 | rescue LoadError
4 | puts 'although not required, it is recommended that you use bundler when running the tests'
5 | end
6 |
7 | ENV['PUSHER_URL']= 'http://some:secret@api.secret.pusherapp.com:441/apps/54'
8 |
9 | require 'rspec'
10 | require 'em-http' # As of webmock 1.4.0, em-http must be loaded first
11 | require 'webmock/rspec'
12 |
13 | require 'pusher'
14 | require 'eventmachine'
15 |
16 | RSpec.configure do |config|
17 | config.before(:each) do
18 | WebMock.reset!
19 | WebMock.disable_net_connect!
20 | end
21 | end
22 |
23 | def hmac(key, data)
24 | digest = OpenSSL::Digest::SHA256.new
25 | OpenSSL::HMAC.hexdigest(digest, key, data)
26 | end
27 |
--------------------------------------------------------------------------------
/examples/async_message.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'pusher'
3 | require 'eventmachine'
4 | require 'em-http-request'
5 |
6 | # To get these values:
7 | # - Go to https://app.pusherapp.com/
8 | # - Click on Choose App.
9 | # - Click on one of your apps
10 | # - Click API Access
11 | Pusher.app_id = 'your_app_id'
12 | Pusher.key = 'your_key'
13 | Pusher.secret = 'your_secret'
14 |
15 |
16 | EM.run {
17 | deferrable = Pusher['test_channel'].trigger_async('my_event', 'hi')
18 |
19 | deferrable.callback { # called on success
20 | puts "Message sent successfully."
21 | EM.stop
22 | }
23 | deferrable.errback { |error| # called on error
24 | puts "Message could not be sent."
25 | puts error
26 | EM.stop
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/lib/pusher/resource.rb:
--------------------------------------------------------------------------------
1 | module Pusher
2 | class Resource
3 | def initialize(client, path)
4 | @client = client
5 | @path = path
6 | end
7 |
8 | def get(params)
9 | create_request(:get, params).send_sync
10 | end
11 |
12 | def get_async(params)
13 | create_request(:get, params).send_async
14 | end
15 |
16 | def post(params)
17 | body = MultiJson.encode(params)
18 | create_request(:post, {}, body).send_sync
19 | end
20 |
21 | def post_async(params)
22 | body = MultiJson.encode(params)
23 | create_request(:post, {}, body).send_async
24 | end
25 |
26 | private
27 |
28 | def create_request(verb, params, body = nil)
29 | Request.new(@client, verb, url, params, body)
30 | end
31 |
32 | def url
33 | @_url ||= @client.url(@path)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/examples/presence_channels/public/presence_channels.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Pusher Test
4 |
5 |
21 |
22 |
23 | Pusher Test
24 |
25 | Try publishing an event to channel presence-channel-test
26 | with event name test-event.
27 |
28 |
29 |
--------------------------------------------------------------------------------
/.github/workflows/gh-release.yml:
--------------------------------------------------------------------------------
1 | name: Github Release
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | create-release:
9 | name: Create Release
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v2
14 | - name: Setup git
15 | run: |
16 | git config user.email "pusher-ci@pusher.com"
17 | git config user.name "Pusher CI"
18 | - name: Prepare description
19 | run: |
20 | csplit -s CHANGELOG.md "/##/" {1}
21 | cat xx01 > CHANGELOG.tmp
22 | - name: Prepare tag
23 | run: |
24 | export TAG=$(head -1 CHANGELOG.tmp | cut -d' ' -f2)
25 | echo "TAG=$TAG" >> $GITHUB_ENV
26 | - name: Create Release
27 | uses: actions/create-release@v1
28 | env:
29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 | with:
31 | tag_name: ${{ env.TAG }}
32 | release_name: ${{ env.TAG }}
33 | body_path: CHANGELOG.tmp
34 | draft: false
35 | prerelease: false
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2010-2013 Pusher
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Configuration for probot-stale - https://github.com/probot/stale
2 |
3 | # Number of days of inactivity before an Issue or Pull Request becomes stale
4 | daysUntilStale: 90
5 |
6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
8 | daysUntilClose: 7
9 |
10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)
11 | onlyLabels: []
12 |
13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
14 | exemptLabels:
15 | - pinned
16 | - security
17 |
18 | # Set to true to ignore issues with an assignee (defaults to false)
19 | exemptAssignees: true
20 |
21 | # Comment to post when marking as stale. Set to `false` to disable
22 | markComment: >
23 | This issue has been automatically marked as stale because it has not had
24 | recent activity. It will be closed if no further activity occurs. If you'd
25 | like this issue to stay open please leave a comment indicating how this issue
26 | is affecting you. Thank you.
27 |
--------------------------------------------------------------------------------
/pusher.gemspec:
--------------------------------------------------------------------------------
1 | # -*- encoding: utf-8 -*-
2 |
3 | require File.expand_path('../lib/pusher/version', __FILE__)
4 |
5 | Gem::Specification.new do |s|
6 | s.name = "pusher"
7 | s.version = Pusher::VERSION
8 | s.platform = Gem::Platform::RUBY
9 | s.authors = ["Pusher"]
10 | s.email = ["support@pusher.com"]
11 | s.homepage = "http://github.com/pusher/pusher-http-ruby"
12 | s.summary = %q{Pusher Channels API client}
13 | s.description = %q{Wrapper for Pusher Channels REST api: : https://pusher.com/channels}
14 | s.license = "MIT"
15 |
16 | s.required_ruby_version = ">= 2.6"
17 |
18 | s.add_dependency "multi_json", "~> 1.15"
19 | s.add_dependency 'pusher-signature', "~> 0.1.8"
20 | s.add_dependency "httpclient", "~> 2.8"
21 | s.add_dependency "jruby-openssl" if defined?(JRUBY_VERSION)
22 |
23 | s.add_development_dependency "rspec", "~> 3.9"
24 | s.add_development_dependency "webmock", "~> 3.9"
25 | s.add_development_dependency "em-http-request", "~> 1.1"
26 | s.add_development_dependency "addressable", "~> 2.7"
27 | s.add_development_dependency "rake", "~> 13.0"
28 | s.add_development_dependency "rack", "~> 2.2"
29 | s.add_development_dependency "json", "~> 2.3"
30 | s.add_development_dependency "rbnacl", "~> 7.1"
31 |
32 | s.files = Dir["lib/**/*"] + %w[CHANGELOG.md LICENSE README.md]
33 | s.require_paths = ["lib"]
34 | end
35 |
--------------------------------------------------------------------------------
/examples/presence_channels/presence_channels.rb:
--------------------------------------------------------------------------------
1 | require 'sinatra'
2 | require 'sinatra/cookies'
3 | require 'sinatra/json'
4 | require 'pusher'
5 |
6 | # You can get these variables from http://dashboard.pusher.com
7 | pusher = Pusher::Client.new(
8 | app_id: 'your-app-id',
9 | key: 'your-app-key',
10 | secret: 'your-app-secret',
11 | cluster: 'your-app-cluster'
12 | )
13 |
14 | set :public_folder, 'public'
15 |
16 | get '/' do
17 | redirect '/presence_channels.html'
18 | end
19 |
20 | # Emulate rails behaviour where this information would be stored in session
21 | get '/signin' do
22 | cookies[:user_id] = 'example_cookie'
23 | 'Ok'
24 | end
25 |
26 | # Auth endpoint: https://pusher.com/docs/channels/server_api/authenticating-users
27 | post '/pusher/auth' do
28 | channel_data = {
29 | user_id: 'example_user',
30 | user_info: {
31 | name: 'example_name',
32 | email: 'example_email'
33 | }
34 | }
35 |
36 | if cookies[:user_id] == 'example_cookie'
37 | response = pusher.authenticate(params[:channel_name], params[:socket_id], channel_data)
38 | json response
39 | else
40 | status 403
41 | end
42 | end
43 |
44 | get '/pusher_trigger' do
45 | channels = ['presence-channel-test'];
46 |
47 | begin
48 | pusher.trigger(channels, 'test-event', {
49 | message: 'hello world'
50 | })
51 | rescue Pusher::Error => e
52 | # For example, Pusher::AuthenticationError, Pusher::HTTPError, or Pusher::Error
53 | end
54 |
55 | 'Triggered!'
56 | end
57 |
--------------------------------------------------------------------------------
/lib/pusher.rb:
--------------------------------------------------------------------------------
1 | autoload 'Logger', 'logger'
2 | require 'uri'
3 | require 'forwardable'
4 |
5 | require 'pusher/client'
6 |
7 | # Used for configuring API credentials and creating Channel objects
8 | #
9 | module Pusher
10 | # All errors descend from this class so they can be easily rescued
11 | #
12 | # @example
13 | # begin
14 | # Pusher.trigger('channel_name', 'event_name, {:some => 'data'})
15 | # rescue Pusher::Error => e
16 | # # Do something on error
17 | # end
18 | class Error < RuntimeError; end
19 | class AuthenticationError < Error; end
20 | class ConfigurationError < Error
21 | def initialize(key)
22 | super "missing key `#{key}' in the client configuration"
23 | end
24 | end
25 | class HTTPError < Error; attr_accessor :original_error; end
26 |
27 | class << self
28 | extend Forwardable
29 |
30 | def_delegators :default_client, :scheme, :host, :port, :app_id, :key,
31 | :secret, :http_proxy, :encryption_master_key_base64
32 | def_delegators :default_client, :scheme=, :host=, :port=, :app_id=, :key=,
33 | :secret=, :http_proxy=, :encryption_master_key_base64=
34 |
35 | def_delegators :default_client, :authentication_token, :url, :cluster
36 | def_delegators :default_client, :encrypted=, :url=, :cluster=
37 | def_delegators :default_client, :timeout=, :connect_timeout=, :send_timeout=, :receive_timeout=, :keep_alive_timeout=
38 |
39 | def_delegators :default_client, :get, :get_async, :post, :post_async
40 | def_delegators :default_client, :channels, :channel_info, :channel_users
41 | def_delegators :default_client, :trigger, :trigger_batch, :trigger_async, :trigger_batch_async
42 | def_delegators :default_client, :authenticate, :webhook, :channel, :[]
43 | def_delegators :default_client, :notify
44 |
45 | attr_writer :logger
46 |
47 | def logger
48 | @logger ||= begin
49 | log = Logger.new($stdout)
50 | log.level = Logger::INFO
51 | log
52 | end
53 | end
54 |
55 | def default_client
56 | @default_client ||= begin
57 | cli = Pusher::Client
58 | ENV['PUSHER_URL'] ? cli.from_env : cli.new
59 | end
60 | end
61 | end
62 | end
63 |
64 | require 'pusher/version'
65 | require 'pusher/channel'
66 | require 'pusher/request'
67 | require 'pusher/resource'
68 | require 'pusher/webhook'
69 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | pull_request:
5 | types: [ labeled ]
6 | branches:
7 | - master
8 |
9 | jobs:
10 | prepare-release:
11 | name: Prepare release
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Set major release
16 | if: ${{ github.event.label.name == 'release-major' }}
17 | run: echo "RELEASE=major" >> $GITHUB_ENV
18 | - name: Set minor release
19 | if: ${{ github.event.label.name == 'release-minor' }}
20 | run: echo "RELEASE=minor" >> $GITHUB_ENV
21 | - name: Set patch release
22 | if: ${{ github.event.label.name == 'release-patch' }}
23 | run: echo "RELEASE=patch" >> $GITHUB_ENV
24 | - name: Check release env
25 | run: |
26 | if [[ -z "${{ env.RELEASE }}" ]];
27 | then
28 | echo "You need to set a release label on PRs to the main branch"
29 | exit 1
30 | else
31 | exit 0
32 | fi
33 | - name: Install semver-tool
34 | run: |
35 | export DIR=$(mktemp -d)
36 | cd $DIR
37 | curl https://github.com/fsaintjacques/semver-tool/archive/3.2.0.tar.gz -L -o semver.tar.gz
38 | tar -xvf semver.tar.gz
39 | sudo cp semver-tool-3.2.0/src/semver /usr/local/bin
40 | - name: Bump version
41 | run: |
42 | export CURRENT=$(gem info pusher --remote --exact | grep -o "pusher ([0-9]*\.[0-9]*\.[0-9]*)" | awk -F '[()]' '{print $2}')
43 | export NEW_VERSION=$(semver bump ${{ env.RELEASE }} $CURRENT)
44 | echo "VERSION=$NEW_VERSION" >> $GITHUB_ENV
45 | - name: Checkout code
46 | uses: actions/checkout@v2
47 | - name: Setup git
48 | run: |
49 | git config user.email "pusher-ci@pusher.com"
50 | git config user.name "Pusher CI"
51 | git fetch
52 | git checkout ${{ github.event.pull_request.head.ref }}
53 | - name: Prepare CHANGELOG
54 | run: |
55 | echo "${{ github.event.pull_request.body }}" | csplit -s - "/##/"
56 | echo "# Changelog
57 |
58 | ## ${{ env.VERSION }}
59 | " >> CHANGELOG.tmp
60 | grep "^*" xx01 >> CHANGELOG.tmp
61 | grep -v "^# " CHANGELOG.md >> CHANGELOG.tmp
62 | cp CHANGELOG.tmp CHANGELOG.md
63 | - name: Prepare version.rb
64 | run: |
65 | sed -i "s|VERSION = '[^']*'|VERSION = '${{ env.VERSION }}'|" lib/pusher/version.rb
66 | - name: Commit changes
67 | run: |
68 | git add CHANGELOG.md lib/pusher/version.rb
69 | git commit -m "Bump to version ${{ env.VERSION }}"
70 | - name: Push
71 | run: git push
72 |
--------------------------------------------------------------------------------
/lib/pusher/request.rb:
--------------------------------------------------------------------------------
1 | require 'pusher-signature'
2 | require 'digest/md5'
3 | require 'multi_json'
4 |
5 | module Pusher
6 | class Request
7 | attr_reader :body, :params
8 |
9 | def initialize(client, verb, uri, params, body = nil)
10 | @client, @verb, @uri = client, verb, uri
11 | @head = {
12 | 'X-Pusher-Library' => 'pusher-http-ruby ' + Pusher::VERSION
13 | }
14 |
15 | @body = body
16 | if body
17 | params[:body_md5] = Digest::MD5.hexdigest(body)
18 | @head['Content-Type'] = 'application/json'
19 | end
20 |
21 | request = Pusher::Signature::Request.new(verb.to_s.upcase, uri.path, params)
22 | request.sign(client.authentication_token)
23 | @params = request.signed_params
24 | end
25 |
26 | def send_sync
27 | http = @client.sync_http_client
28 |
29 | begin
30 | response = http.request(@verb, @uri, @params, @body, @head)
31 | rescue HTTPClient::BadResponseError, HTTPClient::TimeoutError,
32 | SocketError, Errno::ECONNREFUSED => e
33 | error = Pusher::HTTPError.new("#{e.message} (#{e.class})")
34 | error.original_error = e
35 | raise error
36 | end
37 |
38 | body = response.body ? response.body.chomp : nil
39 |
40 | return handle_response(response.code.to_i, body)
41 | end
42 |
43 | def send_async
44 | if defined?(EventMachine) && EventMachine.reactor_running?
45 | http_client = @client.em_http_client(@uri)
46 | df = EM::DefaultDeferrable.new
47 |
48 | http = case @verb
49 | when :post
50 | http_client.post({
51 | :query => @params, :body => @body, :head => @head
52 | })
53 | when :get
54 | http_client.get({
55 | :query => @params, :head => @head
56 | })
57 | else
58 | raise "Unsupported verb"
59 | end
60 | http.callback {
61 | begin
62 | df.succeed(handle_response(http.response_header.status, http.response.chomp))
63 | rescue => e
64 | df.fail(e)
65 | end
66 | }
67 | http.errback { |e|
68 | message = "Network error connecting to pusher (#{http.error})"
69 | Pusher.logger.debug(message)
70 | df.fail(Error.new(message))
71 | }
72 |
73 | return df
74 | else
75 | http = @client.sync_http_client
76 |
77 | return http.request_async(@verb, @uri, @params, @body, @head)
78 | end
79 | end
80 |
81 | private
82 |
83 | def handle_response(status_code, body)
84 | case status_code
85 | when 200
86 | return symbolize_first_level(MultiJson.decode(body))
87 | when 202
88 | return body.empty? ? true : symbolize_first_level(MultiJson.decode(body))
89 | when 400
90 | raise Error, "Bad request: #{body}"
91 | when 401
92 | raise AuthenticationError, body
93 | when 404
94 | raise Error, "404 Not found (#{@uri.path})"
95 | when 407
96 | raise Error, "Proxy Authentication Required"
97 | when 413
98 | raise Error, "Payload Too Large > 10KB"
99 | else
100 | raise Error, "Unknown error (status code #{status_code}): #{body}"
101 | end
102 | end
103 |
104 | def symbolize_first_level(hash)
105 | hash.inject({}) do |result, (key, value)|
106 | result[key.to_sym] = value
107 | result
108 | end
109 | end
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/lib/pusher/webhook.rb:
--------------------------------------------------------------------------------
1 | require 'multi_json'
2 | require 'openssl'
3 |
4 | module Pusher
5 | # Used to parse and authenticate WebHooks
6 | #
7 | # @example Sinatra
8 | # post '/webhooks' do
9 | # webhook = Pusher::WebHook.new(request)
10 | # if webhook.valid?
11 | # webhook.events.each do |event|
12 | # case event["name"]
13 | # when 'channel_occupied'
14 | # puts "Channel occupied: #{event["channel"]}"
15 | # when 'channel_vacated'
16 | # puts "Channel vacated: #{event["channel"]}"
17 | # end
18 | # end
19 | # else
20 | # status 401
21 | # end
22 | # return
23 | # end
24 | #
25 | class WebHook
26 | attr_reader :key, :signature
27 |
28 | # Provide either a Rack::Request or a Hash containing :key, :signature,
29 | # :body, and :content_type (optional)
30 | #
31 | def initialize(request, client = Pusher)
32 | @client = client
33 | # For Rack::Request and ActionDispatch::Request
34 | if request.respond_to?(:env) && request.respond_to?(:content_type)
35 | @key = request.env['HTTP_X_PUSHER_KEY']
36 | @signature = request.env["HTTP_X_PUSHER_SIGNATURE"]
37 | @content_type = request.content_type
38 |
39 | request.body.rewind
40 | @body = request.body.read
41 | request.body.rewind
42 | else
43 | @key, @signature, @body = request.values_at(:key, :signature, :body)
44 | @content_type = request[:content_type] || 'application/json'
45 | end
46 | end
47 |
48 | # Returns whether the WebHook is valid by checking that the signature
49 | # matches the configured key & secret. In the case that the webhook is
50 | # invalid, the reason is logged
51 | #
52 | # @param extra_tokens [Hash] If you have extra tokens for your Pusher app, you can specify them so that they're used to attempt validation.
53 | #
54 | def valid?(extra_tokens = nil)
55 | extra_tokens = [extra_tokens] if extra_tokens.kind_of?(Hash)
56 | if @key == @client.key
57 | return check_signature(@client.secret)
58 | elsif extra_tokens
59 | extra_tokens.each do |token|
60 | return check_signature(token[:secret]) if @key == token[:key]
61 | end
62 | end
63 | Pusher.logger.warn "Received webhook with unknown key: #{key}"
64 | return false
65 | end
66 |
67 | # Array of events (as Hashes) contained inside the webhook
68 | #
69 | def events
70 | data["events"]
71 | end
72 |
73 | # The time at which the WebHook was initially triggered by Pusher, i.e.
74 | # when the event occurred
75 | #
76 | # @return [Time]
77 | #
78 | def time
79 | Time.at(data["time_ms"].to_f/1000)
80 | end
81 |
82 | # Access the parsed WebHook body
83 | #
84 | def data
85 | @data ||= begin
86 | case @content_type
87 | when 'application/json'
88 | MultiJson.decode(@body)
89 | else
90 | raise "Unknown Content-Type (#{@content_type})"
91 | end
92 | end
93 | end
94 |
95 | private
96 |
97 | # Checks signature against secret and returns boolean
98 | #
99 | def check_signature(secret)
100 | digest = OpenSSL::Digest::SHA256.new
101 | expected = OpenSSL::HMAC.hexdigest(digest, secret, @body)
102 | if @signature == expected
103 | return true
104 | else
105 | Pusher.logger.warn "Received WebHook with invalid signature: got #{@signature}, expected #{expected}"
106 | return false
107 | end
108 | end
109 | end
110 | end
111 |
--------------------------------------------------------------------------------
/spec/web_hook_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | require 'rack'
4 | require 'stringio'
5 |
6 | describe Pusher::WebHook do
7 | before :each do
8 | @hook_data = {
9 | "time_ms" => 123456,
10 | "events" => [
11 | {"name" => 'foo'}
12 | ]
13 | }
14 | end
15 |
16 | describe "initialization" do
17 | it "can be initialized with Rack::Request" do
18 | request = Rack::Request.new({
19 | 'HTTP_X_PUSHER_KEY' => '1234',
20 | 'HTTP_X_PUSHER_SIGNATURE' => 'asdf',
21 | 'CONTENT_TYPE' => 'application/json',
22 | 'rack.input' => StringIO.new(MultiJson.encode(@hook_data))
23 | })
24 | wh = Pusher::WebHook.new(request)
25 | expect(wh.key).to eq('1234')
26 | expect(wh.signature).to eq('asdf')
27 | expect(wh.data).to eq(@hook_data)
28 | end
29 |
30 | it "can be initialized with a hash" do
31 | request = {
32 | :key => '1234',
33 | :signature => 'asdf',
34 | :content_type => 'application/json',
35 | :body => MultiJson.encode(@hook_data),
36 | }
37 | wh = Pusher::WebHook.new(request)
38 | expect(wh.key).to eq('1234')
39 | expect(wh.signature).to eq('asdf')
40 | expect(wh.data).to eq(@hook_data)
41 | end
42 | end
43 |
44 | describe "after initialization" do
45 | before :each do
46 | @body = MultiJson.encode(@hook_data)
47 | request = {
48 | :key => '1234',
49 | :signature => hmac('asdf', @body),
50 | :content_type => 'application/json',
51 | :body => @body
52 | }
53 |
54 | @client = Pusher::Client.new
55 | @wh = Pusher::WebHook.new(request, @client)
56 | end
57 |
58 | it "should validate" do
59 | @client.key = '1234'
60 | @client.secret = 'asdf'
61 | expect(@wh).to be_valid
62 | end
63 |
64 | it "should not validate if key is wrong" do
65 | @client.key = '12345'
66 | @client.secret = 'asdf'
67 | expect(Pusher.logger).to receive(:warn).with("Received webhook with unknown key: 1234")
68 | expect(@wh).not_to be_valid
69 | end
70 |
71 | it "should not validate if secret is wrong" do
72 | @client.key = '1234'
73 | @client.secret = 'asdfxxx'
74 | digest = OpenSSL::Digest::SHA256.new
75 | expected = OpenSSL::HMAC.hexdigest(digest, @client.secret, @body)
76 | expect(Pusher.logger).to receive(:warn).with("Received WebHook with invalid signature: got #{@wh.signature}, expected #{expected}")
77 | expect(@wh).not_to be_valid
78 | end
79 |
80 | it "should validate with an extra token" do
81 | @client.key = '12345'
82 | @client.secret = 'xxx'
83 | expect(@wh.valid?({:key => '1234', :secret => 'asdf'})).to be_truthy
84 | end
85 |
86 | it "should validate with an array of extra tokens" do
87 | @client.key = '123456'
88 | @client.secret = 'xxx'
89 | expect(@wh.valid?([
90 | {:key => '12345', :secret => 'wtf'},
91 | {:key => '1234', :secret => 'asdf'}
92 | ])).to be_truthy
93 | end
94 |
95 | it "should not validate if all keys are wrong with extra tokens" do
96 | @client.key = '123456'
97 | @client.secret = 'asdf'
98 | expect(Pusher.logger).to receive(:warn).with("Received webhook with unknown key: 1234")
99 | expect(@wh.valid?({:key => '12345', :secret => 'asdf'})).to be_falsey
100 | end
101 |
102 | it "should not validate if secret is wrong with extra tokens" do
103 | @client.key = '123456'
104 | @client.secret = 'asdfxxx'
105 | expect(Pusher.logger).to receive(:warn).with(/Received WebHook with invalid signature/)
106 | expect(@wh.valid?({:key => '1234', :secret => 'wtf'})).to be_falsey
107 | end
108 |
109 | it "should expose events" do
110 | expect(@wh.events).to eq(@hook_data["events"])
111 | end
112 |
113 | it "should expose time" do
114 | expect(@wh.time).to eq(Time.at(123.456))
115 | end
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 2.0.3
4 |
5 | * [FIXED] Corrected the channels limit when publishing events. Upped from 10 to 100.
6 |
7 | ## 2.0.2
8 |
9 | * [CHANGED] made encryption_master_key_base64 globally configurable
10 |
11 | ## 2.0.1
12 |
13 | * [CHANGED] Only include lib and essential docs in gem.
14 |
15 | ## 2.0.0
16 |
17 | * [CHANGED] Use TLS by default.
18 | * [REMOVED] Support for Ruby 2.4 and 2.5.
19 | * [FIXED] Handle empty or nil configuration.
20 | * [REMOVED] Legacy Push Notification integration.
21 | * [ADDED] Stalebot and Github actions.
22 |
23 | ## 1.4.3
24 |
25 | * [FIXED] Remove newline from end of base64 encoded strings, some decoders don't like
26 | them.
27 |
28 | ## 1.4.2
29 | ==================
30 |
31 | * [FIXED] Return `shared_secret` to support authenticating encrypted channels. Thanks
32 | @Benjaminpjacobs
33 |
34 | ## 1.4.1
35 |
36 | * [CHANGED] Remove rbnacl from dependencies so we don't get errors when it isn't
37 | required. Thanks @y-yagi!
38 |
39 | ## 1.4.0
40 |
41 | * [ADDED] Support for end-to-end encryption.
42 |
43 | ## 1.3.3
44 |
45 | * [CHANGED] Rewording to clarify "Pusher Channels" or simply "Channels" product name.
46 |
47 | ## 1.3.2
48 |
49 | * [FIXED] Return a specific error for "Request Entity Too Large" (body over 10KB).
50 | * [ADDED] Add a `use_tls` option for SSL (defaults to false).
51 | * [ADDED] Add a `from_url` client method (in addition to existing `from_env` option).
52 | * [CHANGED] Improved documentation and fixed typos.
53 | * [ADDED] Add Ruby 2.4 to test matrix.
54 |
55 | ## 1.3.1
56 |
57 | * [FIXED] Added missing client batch methods to default client delegations
58 | * [CHANGED] Document raised exception in the `authenticate` method
59 | * [FIXED] Fixes em-http-request from using v2.5.0 of `addressable` breaking builds.
60 |
61 | ## 1.3.0
62 |
63 | * [ADDED] Add support for sending push notifications on up to 10 interests.
64 |
65 | ## 1.2.1
66 |
67 | * [FIXED] Fixes Rails 5 compatibility. Use duck-typing to detect request object
68 |
69 | ## 1.2.0
70 |
71 | * [CHANGED] Minor release for Native notifications
72 |
73 | ## 1.2.0.rc1
74 |
75 | * [ADDED] Add support for Native notifications
76 |
77 | ## 1.1.0
78 |
79 | * [ADDED] Add support for batch events
80 |
81 | ## 1.0.0
82 |
83 | * [CHANGED] No breaking changes, this release is just to follow semver and show that we
84 | are stable.
85 |
86 | ## 0.18.0
87 |
88 | * [ADDED] Introduce `Pusher::Client.from_env`
89 | * [FIXED] Improve error handling on missing config
90 |
91 | ## 0.17.0
92 |
93 | * [ADDED] Introduce the `cluster` option.
94 |
95 | ## 0.16.0
96 |
97 | * [CHANGED] Bump httpclient version to 2.7
98 | * [REMOVED] Ruby 1.8.7 is not supported anymore.
99 |
100 | ## 0.15.2
101 |
102 | * [CHANGED] Documented `Pusher.channel_info`, `Pusher.channels`
103 | * [ADDED] Added `Pusher.channel_users`
104 |
105 | ## 0.15.1
106 |
107 | * [FIXED] Fixed a bug where the `authenticate` method added in 0.15.0 wasn't exposed on the Pusher class.
108 |
109 | ## 0.15.0
110 |
111 | * [ADDED] Added `Pusher.authenticate` method for authenticating private and presence channels.
112 | This is prefered over the older `Pusher['a_channel'].authenticate(...)` style.
113 |
114 | ## 0.14.6
115 |
116 | * [CHANGED] Updated to use the `pusher-signature` gem instead of `signature`.
117 | This resolves namespace related issues.
118 |
119 | ## 0.14.5
120 |
121 | * [SECURITY] Prevent auth delegation trough crafted socket IDs
122 |
123 | ## 0.14.4
124 |
125 | * [SECURITY] Prevent timing attack, update signature to v0.1.8
126 | * [SECURITY] Prevent POODLE. Disable SSLv3, update httpclient to v2.5
127 | * [FIXED] Fix channel name character limit.
128 | * [ADDED] Adds support for listing users on a presence channel
129 |
130 | ## 0.14.2
131 |
132 | * [CHANGED] Bump httpclient to v2.4. See #62 (POODLE SSL)
133 | * [CHANGED] Fix limited channel count at README.md. Thanks @tricknotes
134 |
--------------------------------------------------------------------------------
/spec/channel_spec.rb:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | require 'spec_helper'
3 |
4 | describe Pusher::Channel do
5 | before do
6 | @client = Pusher::Client.new({
7 | :app_id => '20',
8 | :key => '12345678900000001',
9 | :secret => '12345678900000001',
10 | :host => 'api.pusherapp.com',
11 | :port => 80,
12 | })
13 | @channel = @client['test_channel']
14 | end
15 |
16 | let(:pusher_url_regexp) { %r{/apps/20/events} }
17 |
18 | def stub_post(status, body = nil)
19 | options = {:status => status}
20 | options.merge!({:body => body}) if body
21 |
22 | stub_request(:post, pusher_url_regexp).to_return(options)
23 | end
24 |
25 | def stub_post_to_raise(e)
26 | stub_request(:post, pusher_url_regexp).to_raise(e)
27 | end
28 |
29 | describe '#trigger!' do
30 | it "should use @client.trigger internally" do
31 | expect(@client).to receive(:trigger)
32 | @channel.trigger!('new_event', 'Some data')
33 | end
34 | end
35 |
36 | describe '#trigger' do
37 | it "should log failure if error raised in http call" do
38 | stub_post_to_raise(HTTPClient::BadResponseError)
39 |
40 | expect(Pusher.logger).to receive(:error).with("Exception from WebMock (HTTPClient::BadResponseError) (Pusher::HTTPError)")
41 | expect(Pusher.logger).to receive(:debug) #backtrace
42 | @channel.trigger('new_event', 'Some data')
43 | end
44 |
45 | it "should log failure if Pusher returns an error response" do
46 | stub_post 401, "some signature info"
47 | expect(Pusher.logger).to receive(:error).with("some signature info (Pusher::AuthenticationError)")
48 | expect(Pusher.logger).to receive(:debug) #backtrace
49 | @channel.trigger('new_event', 'Some data')
50 | end
51 | end
52 |
53 | describe "#initialization" do
54 | it "should not be too long" do
55 | expect { @client['b'*201] }.to raise_error(Pusher::Error)
56 | end
57 |
58 | it "should not use bad characters" do
59 | expect { @client['*^!±`/""'] }.to raise_error(Pusher::Error)
60 | end
61 | end
62 |
63 | describe "#trigger_async" do
64 | it "should use @client.trigger_async internally" do
65 | expect(@client).to receive(:trigger_async)
66 | @channel.trigger_async('new_event', 'Some data')
67 | end
68 | end
69 |
70 | describe '#info' do
71 | it "should call the Client#channel_info" do
72 | expect(@client).to receive(:get)
73 | .with("/channels/mychannel", anything)
74 | .and_return({:occupied => true, :subscription_count => 12})
75 | @channel = @client['mychannel']
76 | @channel.info
77 | end
78 |
79 | it "should assemble the requested attributes into the info option" do
80 | expect(@client).to receive(:get)
81 | .with(anything, {:info => "user_count,connection_count"})
82 | .and_return({:occupied => true, :subscription_count => 12, :user_count => 12})
83 | @channel = @client['presence-foo']
84 | @channel.info(%w{user_count connection_count})
85 | end
86 | end
87 |
88 | describe '#users' do
89 | it "should call the Client#channel_users" do
90 | expect(@client).to receive(:get).with("/channels/presence-mychannel/users", {}).and_return({:users => {'id' => '4'}})
91 | @channel = @client['presence-mychannel']
92 | @channel.users
93 | end
94 | end
95 |
96 | describe "#authentication_string" do
97 | def authentication_string(*data)
98 | lambda { @channel.authentication_string(*data) }
99 | end
100 |
101 | it "should return an authentication string given a socket id" do
102 | auth = @channel.authentication_string('1.1')
103 |
104 | expect(auth).to eq('12345678900000001:02259dff9a2a3f71ea8ab29ac0c0c0ef7996c8f3fd3702be5533f30da7d7fed4')
105 | end
106 |
107 | it "should raise error if authentication is invalid" do
108 | [nil, ''].each do |invalid|
109 | expect(authentication_string(invalid)).to raise_error Pusher::Error
110 | end
111 | end
112 |
113 | describe 'with extra string argument' do
114 | it 'should be a string or nil' do
115 | expect(authentication_string('1.1', 123)).to raise_error Pusher::Error
116 | expect(authentication_string('1.1', {})).to raise_error Pusher::Error
117 |
118 | expect(authentication_string('1.1', 'boom')).not_to raise_error
119 | expect(authentication_string('1.1', nil)).not_to raise_error
120 | end
121 |
122 | it "should return an authentication string given a socket id and custom args" do
123 | auth = @channel.authentication_string('1.1', 'foobar')
124 |
125 | expect(auth).to eq("12345678900000001:#{hmac(@client.secret, "1.1:test_channel:foobar")}")
126 | end
127 | end
128 | end
129 |
130 | describe '#authenticate' do
131 | before :each do
132 | @custom_data = {:uid => 123, :info => {:name => 'Foo'}}
133 | end
134 |
135 | it 'should return a hash with signature including custom data and data as json string' do
136 | allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string'
137 |
138 | response = @channel.authenticate('1.1', @custom_data)
139 |
140 | expect(response).to eq({
141 | :auth => "12345678900000001:#{hmac(@client.secret, "1.1:test_channel:a json string")}",
142 | :channel_data => 'a json string'
143 | })
144 | end
145 |
146 | it 'should fail on invalid socket_ids' do
147 | expect {
148 | @channel.authenticate('1.1:')
149 | }.to raise_error Pusher::Error
150 |
151 | expect {
152 | @channel.authenticate('1.1foo', 'channel')
153 | }.to raise_error Pusher::Error
154 |
155 | expect {
156 | @channel.authenticate(':1.1')
157 | }.to raise_error Pusher::Error
158 |
159 | expect {
160 | @channel.authenticate('foo1.1', 'channel')
161 | }.to raise_error Pusher::Error
162 |
163 | expect {
164 | @channel.authenticate('foo', 'channel')
165 | }.to raise_error Pusher::Error
166 | end
167 | end
168 |
169 | describe `#shared_secret` do
170 | before(:each) do
171 | @channel.instance_variable_set(:@name, 'private-encrypted-1')
172 | end
173 |
174 | it 'should return a shared_secret based on the channel name and encryption master key' do
175 | key = '3W1pfB/Etr+ZIlfMWwZP3gz8jEeCt4s2pe6Vpr+2c3M='
176 | shared_secret = @channel.shared_secret(key)
177 | expect(Base64.strict_encode64(shared_secret)).to eq(
178 | "6zeEp/chneRPS1cbK/hGeG860UhHomxSN6hTgzwT20I="
179 | )
180 | end
181 |
182 | it 'should return nil if missing encryption master key' do
183 | shared_secret = @channel.shared_secret(nil)
184 | expect(shared_secret).to be_nil
185 | end
186 | end
187 | end
188 |
--------------------------------------------------------------------------------
/lib/pusher/channel.rb:
--------------------------------------------------------------------------------
1 | require 'openssl'
2 | require 'multi_json'
3 |
4 | module Pusher
5 | # Delegates operations for a specific channel from a client
6 | class Channel
7 | attr_reader :name
8 | INVALID_CHANNEL_REGEX = /[^A-Za-z0-9_\-=@,.;]/
9 |
10 | def initialize(_, name, client = Pusher)
11 | if Pusher::Channel::INVALID_CHANNEL_REGEX.match(name)
12 | raise Pusher::Error, "Illegal channel name '#{name}'"
13 | elsif name.length > 200
14 | raise Pusher::Error, "Channel name too long (limit 164 characters) '#{name}'"
15 | end
16 | @name = name
17 | @client = client
18 | end
19 |
20 | # Trigger event asynchronously using EventMachine::HttpRequest
21 | #
22 | # [Deprecated] This method will be removed in a future gem version. Please
23 | # switch to Pusher.trigger_async or Pusher::Client#trigger_async instead
24 | #
25 | # @param (see #trigger!)
26 | # @return [EM::DefaultDeferrable]
27 | # Attach a callback to be notified of success (with no parameters).
28 | # Attach an errback to be notified of failure (with an error parameter
29 | # which includes the HTTP status code returned)
30 | # @raise [LoadError] unless em-http-request gem is available
31 | # @raise [Pusher::Error] unless the eventmachine reactor is running. You
32 | # probably want to run your application inside a server such as thin
33 | #
34 | def trigger_async(event_name, data, socket_id = nil)
35 | params = {}
36 | if socket_id
37 | validate_socket_id(socket_id)
38 | params[:socket_id] = socket_id
39 | end
40 | @client.trigger_async(name, event_name, data, params)
41 | end
42 |
43 | # Trigger event
44 | #
45 | # [Deprecated] This method will be removed in a future gem version. Please
46 | # switch to Pusher.trigger or Pusher::Client#trigger instead
47 | #
48 | # @example
49 | # begin
50 | # Pusher['my-channel'].trigger!('an_event', {:some => 'data'})
51 | # rescue Pusher::Error => e
52 | # # Do something on error
53 | # end
54 | #
55 | # @param data [Object] Event data to be triggered in javascript.
56 | # Objects other than strings will be converted to JSON
57 | # @param socket_id Allows excluding a given socket_id from receiving the
58 | # event - see http://pusher.com/docs/publisher_api_guide/publisher_excluding_recipients for more info
59 | #
60 | # @raise [Pusher::Error] on invalid Pusher response - see the error message for more details
61 | # @raise [Pusher::HTTPError] on any error raised inside http client - the original error is available in the original_error attribute
62 | #
63 | def trigger!(event_name, data, socket_id = nil)
64 | params = {}
65 | if socket_id
66 | validate_socket_id(socket_id)
67 | params[:socket_id] = socket_id
68 | end
69 | @client.trigger(name, event_name, data, params)
70 | end
71 |
72 | # Trigger event, catching and logging any errors.
73 | #
74 | # [Deprecated] This method will be removed in a future gem version. Please
75 | # switch to Pusher.trigger or Pusher::Client#trigger instead
76 | #
77 | # @note CAUTION! No exceptions will be raised on failure
78 | # @param (see #trigger!)
79 | #
80 | def trigger(event_name, data, socket_id = nil)
81 | trigger!(event_name, data, socket_id)
82 | rescue Pusher::Error => e
83 | Pusher.logger.error("#{e.message} (#{e.class})")
84 | Pusher.logger.debug(e.backtrace.join("\n"))
85 | end
86 |
87 | # Request info for a channel
88 | #
89 | # @example Response
90 | # [{:occupied=>true, :subscription_count => 12}]
91 | #
92 | # @param info [Array] Array of attributes required (as lowercase strings)
93 | # @return [Hash] Hash of requested attributes for this channel
94 | # @raise [Pusher::Error] on invalid Pusher response - see the error message for more details
95 | # @raise [Pusher::HTTPError] on any error raised inside http client - the original error is available in the original_error attribute
96 | #
97 | def info(attributes = [])
98 | @client.channel_info(name, :info => attributes.join(','))
99 | end
100 |
101 | # Request users for a presence channel
102 | # Only works on presence channels (see: http://pusher.com/docs/client_api_guide/client_presence_channels and https://pusher.com/docs/rest_api)
103 | #
104 | # @example Response
105 | # [{:id=>"4"}]
106 | #
107 | # @param params [Hash] Hash of parameters for the API - see REST API docs
108 | # @return [Hash] Array of user hashes for this channel
109 | # @raise [Pusher::Error] on invalid Pusher response - see the error message for more details
110 | # @raise [Pusher::HTTPError] on any error raised inside Net::HTTP - the original error is available in the original_error attribute
111 | #
112 | def users(params = {})
113 | @client.channel_users(name, params)[:users]
114 | end
115 |
116 | # Compute authentication string required as part of the authentication
117 | # endpoint response. Generally the authenticate method should be used in
118 | # preference to this one
119 | #
120 | # @param socket_id [String] Each Pusher socket connection receives a
121 | # unique socket_id. This is sent from pusher.js to your server when
122 | # channel authentication is required.
123 | # @param custom_string [String] Allows signing additional data
124 | # @return [String]
125 | #
126 | # @raise [Pusher::Error] if socket_id or custom_string invalid
127 | #
128 | def authentication_string(socket_id, custom_string = nil)
129 | validate_socket_id(socket_id)
130 |
131 | unless custom_string.nil? || custom_string.kind_of?(String)
132 | raise Error, 'Custom argument must be a string'
133 | end
134 |
135 | string_to_sign = [socket_id, name, custom_string].
136 | compact.map(&:to_s).join(':')
137 | Pusher.logger.debug "Signing #{string_to_sign}"
138 | token = @client.authentication_token
139 | digest = OpenSSL::Digest::SHA256.new
140 | signature = OpenSSL::HMAC.hexdigest(digest, token.secret, string_to_sign)
141 |
142 | return "#{token.key}:#{signature}"
143 | end
144 |
145 | # Generate the expected response for an authentication endpoint.
146 | # See http://pusher.com/docs/authenticating_users for details.
147 | #
148 | # @example Private channels
149 | # render :json => Pusher['private-my_channel'].authenticate(params[:socket_id])
150 | #
151 | # @example Presence channels
152 | # render :json => Pusher['presence-my_channel'].authenticate(params[:socket_id], {
153 | # :user_id => current_user.id, # => required
154 | # :user_info => { # => optional - for example
155 | # :name => current_user.name,
156 | # :email => current_user.email
157 | # }
158 | # })
159 | #
160 | # @param socket_id [String]
161 | # @param custom_data [Hash] used for example by private channels
162 | #
163 | # @return [Hash]
164 | #
165 | # @raise [Pusher::Error] if socket_id or custom_data is invalid
166 | #
167 | # @private Custom data is sent to server as JSON-encoded string
168 | #
169 | def authenticate(socket_id, custom_data = nil)
170 | custom_data = MultiJson.encode(custom_data) if custom_data
171 | auth = authentication_string(socket_id, custom_data)
172 | r = {:auth => auth}
173 | r[:channel_data] = custom_data if custom_data
174 | r
175 | end
176 |
177 | def shared_secret(encryption_master_key)
178 | return unless encryption_master_key
179 |
180 | secret_string = @name + encryption_master_key
181 | digest = OpenSSL::Digest::SHA256.new
182 | digest << secret_string
183 | digest.digest
184 | end
185 |
186 | private
187 |
188 | def validate_socket_id(socket_id)
189 | unless socket_id && /\A\d+\.\d+\z/.match(socket_id)
190 | raise Pusher::Error, "Invalid socket ID #{socket_id.inspect}"
191 | end
192 | end
193 | end
194 | end
195 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Gem for Pusher Channels
2 |
3 | This Gem provides a Ruby interface to [the Pusher HTTP API for Pusher Channels](https://pusher.com/docs/channels/library_auth_reference/rest-api).
4 |
5 | [](https://github.com/pusher/pusher-http-ruby/actions?query=workflow%3ATests+branch%3Amaster) [](https://rubygems.org/gems/pusher) [](https://rubygems.org/gems/pusher)
6 |
7 | ## Supported Platforms
8 |
9 | * Ruby - supports **Ruby 2.6 or greater**.
10 |
11 | ## Installation and Configuration
12 |
13 | Add `pusher` to your Gemfile, and then run `bundle install`
14 |
15 | ``` ruby
16 | gem 'pusher'
17 | ```
18 |
19 | or install via gem
20 |
21 | ``` bash
22 | gem install pusher
23 | ```
24 |
25 | After registering at [Pusher](https://dashboard.pusher.com/accounts/sign_up), configure your Channels app with the security credentials.
26 |
27 | ### Instantiating a Pusher Channels client
28 |
29 | Creating a new Pusher Channels `client` can be done as follows.
30 |
31 | ``` ruby
32 | require 'pusher'
33 |
34 | pusher = Pusher::Client.new(
35 | app_id: 'your-app-id',
36 | key: 'your-app-key',
37 | secret: 'your-app-secret',
38 | cluster: 'your-app-cluster',
39 | use_tls: true
40 | )
41 | ```
42 |
43 | The `cluster` value will set the `host` to `api-.pusher.com`. The `use_tls` value is optional and defaults to `true`. It will set the `scheme` and `port`. A custom `port` value takes precendence over `use_tls`.
44 |
45 | If you want to set a custom `host` value for your client then you can do so when instantiating a Pusher Channels client like so:
46 |
47 | ``` ruby
48 | require 'pusher'
49 |
50 | pusher = Pusher::Client.new(
51 | app_id: 'your-app-id',
52 | key: 'your-app-key',
53 | secret: 'your-app-secret',
54 | host: 'your-app-host'
55 | )
56 | ```
57 |
58 | If you pass both `host` and `cluster` options, the `host` will take precendence and `cluster` will be ignored.
59 |
60 | Finally, if you have the configuration set in an `PUSHER_URL` environment
61 | variable, you can use:
62 |
63 | ``` ruby
64 | pusher = Pusher::Client.from_env
65 | ```
66 |
67 | ### Global configuration
68 |
69 | The library can also be configured globally on the `Pusher` class.
70 |
71 | ``` ruby
72 | Pusher.app_id = 'your-app-id'
73 | Pusher.key = 'your-app-key'
74 | Pusher.secret = 'your-app-secret'
75 | Pusher.cluster = 'your-app-cluster'
76 | ```
77 |
78 | Global configuration will automatically be set from the `PUSHER_URL` environment variable if it exists. This should be in the form `http://KEY:SECRET@HOST/apps/APP_ID`. On Heroku this environment variable will already be set.
79 |
80 | If you need to make requests via a HTTP proxy then it can be configured
81 |
82 | ``` ruby
83 | Pusher.http_proxy = 'http://(user):(password)@(host):(port)'
84 | ```
85 |
86 | By default API requests are made over HTTPS. HTTP can be used by setting `use_tls` to `false`.
87 | Issuing this command is going to reset `port` value if it was previously specified.
88 |
89 | ``` ruby
90 | Pusher.use_tls = false
91 | ```
92 |
93 | As of version 0.12, SSL certificates are verified when using the synchronous http client. If you need to disable this behaviour for any reason use:
94 |
95 | ``` ruby
96 | Pusher.default_client.sync_http_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
97 | ```
98 |
99 | ## Interacting with the Channels HTTP API
100 |
101 | The `pusher` gem contains a number of helpers for interacting with the API. As a general rule, the library adheres to a set of conventions that we have aimed to make universal.
102 |
103 | ### Handling errors
104 |
105 | Handle errors by rescuing `Pusher::Error` (all errors are descendants of this error)
106 |
107 | ``` ruby
108 | begin
109 | pusher.trigger('a_channel', 'an_event', :some => 'data')
110 | rescue Pusher::Error => e
111 | # (Pusher::AuthenticationError, Pusher::HTTPError, or Pusher::Error)
112 | end
113 | ```
114 |
115 | ### Logging
116 |
117 | Errors are logged to `Pusher.logger`. It will by default log at info level to STDOUT using `Logger` from the standard library, however you can assign any logger:
118 |
119 | ``` ruby
120 | Pusher.logger = Rails.logger
121 | ```
122 |
123 | ### Publishing events
124 |
125 | An event can be published to one or more channels (limited to 10) in one API call:
126 |
127 | ``` ruby
128 | pusher.trigger('channel', 'event', foo: 'bar')
129 | pusher.trigger(['channel_1', 'channel_2'], 'event_name', foo: 'bar')
130 | ```
131 |
132 | An optional fourth argument may be used to send additional parameters to the API, for example to [exclude a single connection from receiving the event](https://pusher.com/docs/channels/server_api/excluding-event-recipients).
133 |
134 | ``` ruby
135 | pusher.trigger('channel', 'event', {foo: 'bar'}, {socket_id: '123.456'})
136 | ```
137 |
138 | #### Batches
139 |
140 | It's also possible to send multiple events with a single API call (max 10
141 | events per call on multi-tenant clusters):
142 |
143 | ``` ruby
144 | pusher.trigger_batch([
145 | {channel: 'channel_1', name: 'event_name', data: { foo: 'bar' }},
146 | {channel: 'channel_1', name: 'event_name', data: { hello: 'world' }}
147 | ])
148 | ```
149 |
150 | #### Deprecated publisher API
151 |
152 | Most examples and documentation will refer to the following syntax for triggering an event:
153 |
154 | ``` ruby
155 | Pusher['a_channel'].trigger('an_event', :some => 'data')
156 | ```
157 |
158 | This will continue to work, but has been replaced by `pusher.trigger` which supports one or multiple channels.
159 |
160 | ### Getting information about the channels in your Pusher Channels app
161 |
162 | This gem provides methods for accessing information from the [Channels HTTP API](https://pusher.com/docs/channels/library_auth_reference/rest-api). The documentation also shows an example of the responses from each of the API endpoints.
163 |
164 | The following methods are provided by the gem.
165 |
166 | - `pusher.channel_info('channel_name', {info:"user_count,subscription_count"})` returns a hash describing the state of the channel([docs](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-)).
167 |
168 | - `pusher.channel_users('presence-channel_name')` returns a list of all the users subscribed to the channel (only for Presence Channels) ([docs](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-)).
169 |
170 | - `pusher.channels({filter_by_prefix: 'presence-', info: 'user_count'})` returns a hash of occupied channels (optionally filtered by prefix, f.i. `presence-`), and optionally attributes for these channels ([docs](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-)).
171 |
172 | ### Asynchronous requests
173 |
174 | There are two main reasons for using the `_async` methods:
175 |
176 | * In a web application where the response from the Channels HTTP API is not used, but you'd like to avoid a blocking call in the request-response cycle
177 | * Your application is running in an event loop and you need to avoid blocking the reactor
178 |
179 | Asynchronous calls are supported either by using an event loop (eventmachine, preferred), or via a thread.
180 |
181 | The following methods are available (in each case the calling interface matches the non-async version):
182 |
183 | * `pusher.get_async`
184 | * `pusher.post_async`
185 | * `pusher.trigger_async`
186 |
187 | It is of course also possible to make calls to the Channels HTTP API via a job queue. This approach is recommended if you're sending a large number of events.
188 |
189 | #### With EventMachine
190 |
191 | * Add the `em-http-request` gem to your Gemfile (it's not a gem dependency).
192 | * Run the EventMachine reactor (either using `EM.run` or by running inside an evented server such as Thin).
193 |
194 | The `_async` methods return an `EM::Deferrable` which you can bind callbacks to:
195 |
196 | ``` ruby
197 | pusher.get_async("/channels").callback { |response|
198 | # use reponse[:channels]
199 | }.errback { |error|
200 | # error is an instance of Pusher::Error
201 | }
202 | ```
203 |
204 | A HTTP error or an error response from Channels will cause the errback to be called with an appropriate error object.
205 |
206 | #### Without EventMachine
207 |
208 | If the EventMachine reactor is not running, async requests will be made using threads (managed by the httpclient gem).
209 |
210 | An `HTTPClient::Connection` object is returned immediately which can be [interrogated](http://rubydoc.info/gems/httpclient/HTTPClient/Connection) to discover the status of the request. The usual response checking and processing is not done when the request completes, and frankly this method is most useful when you're not interested in waiting for the response.
211 |
212 |
213 | ## Authenticating subscription requests
214 |
215 | It's possible to use the gem to authenticate subscription requests to private or presence channels. The `authenticate` method is available on a channel object for this purpose and returns a JSON object that can be returned to the client that made the request. More information on this authentication scheme can be found in the docs on
216 |
217 | ### Private channels
218 |
219 | ``` ruby
220 | pusher.authenticate('private-my_channel', params[:socket_id])
221 | ```
222 |
223 | ### Presence channels
224 |
225 | These work in a very similar way, but require a unique identifier for the user being authenticated, and optionally some attributes that are provided to clients via presence events:
226 |
227 | ``` ruby
228 | pusher.authenticate('presence-my_channel', params[:socket_id],
229 | user_id: 'user_id',
230 | user_info: {} # optional
231 | )
232 | ```
233 |
234 | ## Receiving WebHooks
235 |
236 | A WebHook object may be created to validate received WebHooks against your app credentials, and to extract events. It should be created with the `Rack::Request` object (available as `request` in Rails controllers or Sinatra handlers for example).
237 |
238 | ``` ruby
239 | webhook = pusher.webhook(request)
240 | if webhook.valid?
241 | webhook.events.each do |event|
242 | case event["name"]
243 | when 'channel_occupied'
244 | puts "Channel occupied: #{event["channel"]}"
245 | when 'channel_vacated'
246 | puts "Channel vacated: #{event["channel"]}"
247 | end
248 | end
249 | render text: 'ok'
250 | else
251 | render text: 'invalid', status: 401
252 | end
253 | ```
254 |
255 | ### End-to-end encryption
256 |
257 | This library supports [end-to-end encrypted channels](https://pusher.com/docs/channels/using_channels/encrypted-channels). This means that only you and your connected clients will be able to read your messages. Pusher cannot decrypt them. You can enable this feature by following these steps:
258 |
259 | 1. Add the `rbnacl` gem to your Gemfile (it's not a gem dependency).
260 |
261 | 2. Install [Libsodium](https://github.com/jedisct1/libsodium), which we rely on to do the heavy lifting. [Follow the installation instructions for your platform.](https://github.com/RubyCrypto/rbnacl/wiki/Installing-libsodium)
262 |
263 | 3. Encrypted channel subscriptions must be authenticated in the exact same way as private channels. You should therefore [create an authentication endpoint on your server](https://pusher.com/docs/authenticating_users).
264 |
265 | 4. Next, generate your 32 byte master encryption key, encode it as base64 and pass it to the Pusher constructor.
266 |
267 | This is secret and you should never share this with anyone.
268 | Not even Pusher.
269 |
270 | ```bash
271 | openssl rand -base64 32
272 | ```
273 |
274 | ```rb
275 | pusher = new Pusher::Client.new({
276 | app_id: 'your-app-id',
277 | key: 'your-app-key',
278 | secret: 'your-app-secret',
279 | cluster: 'your-app-cluster',
280 | use_tls: true
281 | encryption_master_key_base64: '',
282 | });
283 | ```
284 |
285 | 5. Channels where you wish to use end-to-end encryption should be prefixed with `private-encrypted-`.
286 |
287 | 6. Subscribe to these channels in your client, and you're done! You can verify it is working by checking out the debug console on the [https://dashboard.pusher.com/](dashboard) and seeing the scrambled ciphertext.
288 |
289 | **Important note: This will __not__ encrypt messages on channels that are not prefixed by `private-encrypted-`.**
290 |
291 | **Limitation**: you cannot trigger a single event on multiple channels in a call to `trigger`, e.g.
292 |
293 | ```rb
294 | pusher.trigger(
295 | ['channel-1', 'private-encrypted-channel-2'],
296 | 'test_event',
297 | { message: 'hello world' },
298 | )
299 | ```
300 |
301 | Rationale: the methods in this library map directly to individual Channels HTTP API requests. If we allowed triggering a single event on multiple channels (some encrypted, some unencrypted), then it would require two API requests: one where the event is encrypted to the encrypted channels, and one where the event is unencrypted for unencrypted channels.
302 |
--------------------------------------------------------------------------------
/lib/pusher/client.rb:
--------------------------------------------------------------------------------
1 | require 'base64'
2 | require 'pusher-signature'
3 |
4 | module Pusher
5 | class Client
6 | attr_accessor :scheme, :host, :port, :app_id, :key, :secret, :encryption_master_key
7 | attr_reader :http_proxy, :proxy
8 | attr_writer :connect_timeout, :send_timeout, :receive_timeout,
9 | :keep_alive_timeout
10 |
11 | ## CONFIGURATION ##
12 | DEFAULT_CONNECT_TIMEOUT = 5
13 | DEFAULT_SEND_TIMEOUT = 5
14 | DEFAULT_RECEIVE_TIMEOUT = 5
15 | DEFAULT_KEEP_ALIVE_TIMEOUT = 30
16 | DEFAULT_CLUSTER = "mt1"
17 |
18 | # Loads the configuration from an url in the environment
19 | def self.from_env(key = 'PUSHER_URL')
20 | url = ENV[key] || raise(ConfigurationError, key)
21 | from_url(url)
22 | end
23 |
24 | # Loads the configuration from a url
25 | def self.from_url(url)
26 | client = new
27 | client.url = url
28 | client
29 | end
30 |
31 | def initialize(options = {})
32 | @scheme = "https"
33 | @port = options[:port] || 443
34 |
35 | if options.key?(:encrypted)
36 | warn "[DEPRECATION] `encrypted` is deprecated and will be removed in the next major version. Use `use_tls` instead."
37 | end
38 |
39 | if options[:use_tls] == false || options[:encrypted] == false
40 | @scheme = "http"
41 | @port = options[:port] || 80
42 | end
43 |
44 | @app_id = options[:app_id]
45 | @key = options[:key]
46 | @secret = options[:secret]
47 |
48 | @host = options[:host]
49 | @host ||= "api-#{options[:cluster]}.pusher.com" unless options[:cluster].nil? || options[:cluster].empty?
50 | @host ||= "api-#{DEFAULT_CLUSTER}.pusher.com"
51 |
52 | @encryption_master_key = Base64.strict_decode64(options[:encryption_master_key_base64]) if options[:encryption_master_key_base64]
53 |
54 | @http_proxy = options[:http_proxy]
55 |
56 | # Default timeouts
57 | @connect_timeout = DEFAULT_CONNECT_TIMEOUT
58 | @send_timeout = DEFAULT_SEND_TIMEOUT
59 | @receive_timeout = DEFAULT_RECEIVE_TIMEOUT
60 | @keep_alive_timeout = DEFAULT_KEEP_ALIVE_TIMEOUT
61 | end
62 |
63 | # @private Returns the authentication token for the client
64 | def authentication_token
65 | raise ConfigurationError, :key unless @key
66 | raise ConfigurationError, :secret unless @secret
67 | Pusher::Signature::Token.new(@key, @secret)
68 | end
69 |
70 | # @private Builds a url for this app, optionally appending a path
71 | def url(path = nil)
72 | raise ConfigurationError, :app_id unless @app_id
73 | URI::Generic.build({
74 | scheme: @scheme,
75 | host: @host,
76 | port: @port,
77 | path: "/apps/#{@app_id}#{path}"
78 | })
79 | end
80 |
81 | # Configure Pusher connection by providing a url rather than specifying
82 | # scheme, key, secret, and app_id separately.
83 | #
84 | # @example
85 | # Pusher.url = http://KEY:SECRET@api.pusherapp.com/apps/APP_ID
86 | #
87 | def url=(url)
88 | uri = URI.parse(url)
89 | @scheme = uri.scheme
90 | @app_id = uri.path.split('/').last
91 | @key = uri.user
92 | @secret = uri.password
93 | @host = uri.host
94 | @port = uri.port
95 | end
96 |
97 | def http_proxy=(http_proxy)
98 | @http_proxy = http_proxy
99 | uri = URI.parse(http_proxy)
100 | @proxy = {
101 | scheme: uri.scheme,
102 | host: uri.host,
103 | port: uri.port,
104 | user: uri.user,
105 | password: uri.password
106 | }
107 | end
108 |
109 | # Configure whether Pusher API calls should be made over SSL
110 | # (default false)
111 | #
112 | # @example
113 | # Pusher.encrypted = true
114 | #
115 | def encrypted=(boolean)
116 | @scheme = boolean ? 'https' : 'http'
117 | # Configure port if it hasn't already been configured
118 | @port = boolean ? 443 : 80
119 | end
120 |
121 | def encrypted?
122 | @scheme == 'https'
123 | end
124 |
125 | def cluster=(cluster)
126 | cluster = DEFAULT_CLUSTER if cluster.nil? || cluster.empty?
127 |
128 | @host = "api-#{cluster}.pusher.com"
129 | end
130 |
131 | # Convenience method to set all timeouts to the same value (in seconds).
132 | # For more control, use the individual writers.
133 | def timeout=(value)
134 | @connect_timeout, @send_timeout, @receive_timeout = value, value, value
135 | end
136 |
137 | # Set an encryption_master_key to use with private-encrypted channels from
138 | # a base64 encoded string.
139 | def encryption_master_key_base64=(s)
140 | @encryption_master_key = s ? Base64.strict_decode64(s) : nil
141 | end
142 |
143 | ## INTERACT WITH THE API ##
144 |
145 | def resource(path)
146 | Resource.new(self, path)
147 | end
148 |
149 | # GET arbitrary REST API resource using a synchronous http client.
150 | # All request signing is handled automatically.
151 | #
152 | # @example
153 | # begin
154 | # Pusher.get('/channels', filter_by_prefix: 'private-')
155 | # rescue Pusher::Error => e
156 | # # Handle error
157 | # end
158 | #
159 | # @param path [String] Path excluding /apps/APP_ID
160 | # @param params [Hash] API params (see http://pusher.com/docs/rest_api)
161 | #
162 | # @return [Hash] See Pusher API docs
163 | #
164 | # @raise [Pusher::Error] Unsuccessful response - see the error message
165 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
166 | #
167 | def get(path, params = {})
168 | resource(path).get(params)
169 | end
170 |
171 | # GET arbitrary REST API resource using an asynchronous http client.
172 | # All request signing is handled automatically.
173 | #
174 | # When the eventmachine reactor is running, the em-http-request gem is used;
175 | # otherwise an async request is made using httpclient. See README for
176 | # details and examples.
177 | #
178 | # @param path [String] Path excluding /apps/APP_ID
179 | # @param params [Hash] API params (see http://pusher.com/docs/rest_api)
180 | #
181 | # @return Either an EM::DefaultDeferrable or a HTTPClient::Connection
182 | #
183 | def get_async(path, params = {})
184 | resource(path).get_async(params)
185 | end
186 |
187 | # POST arbitrary REST API resource using a synchronous http client.
188 | # Works identially to get method, but posts params as JSON in post body.
189 | def post(path, params = {})
190 | resource(path).post(params)
191 | end
192 |
193 | # POST arbitrary REST API resource using an asynchronous http client.
194 | # Works identially to get_async method, but posts params as JSON in post
195 | # body.
196 | def post_async(path, params = {})
197 | resource(path).post_async(params)
198 | end
199 |
200 | ## HELPER METHODS ##
201 |
202 | # Convenience method for creating a new WebHook instance for validating
203 | # and extracting info from a received WebHook
204 | #
205 | # @param request [Rack::Request] Either a Rack::Request or a Hash containing :key, :signature, :body, and optionally :content_type.
206 | #
207 | def webhook(request)
208 | WebHook.new(request, self)
209 | end
210 |
211 | # Return a convenience channel object by name that delegates operations
212 | # on a channel. No API request is made.
213 | #
214 | # @example
215 | # Pusher['my-channel']
216 | # @return [Channel]
217 | # @raise [Pusher::Error] if the channel name is invalid.
218 | # Channel names should be less than 200 characters, and
219 | # should not contain anything other than letters, numbers, or the
220 | # characters "_\-=@,.;"
221 | def channel(channel_name)
222 | Channel.new(nil, channel_name, self)
223 | end
224 |
225 | alias :[] :channel
226 |
227 | # Request a list of occupied channels from the API
228 | #
229 | # GET /apps/[id]/channels
230 | #
231 | # @param params [Hash] Hash of parameters for the API - see REST API docs
232 | #
233 | # @return [Hash] See Pusher API docs
234 | #
235 | # @raise [Pusher::Error] Unsuccessful response - see the error message
236 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
237 | #
238 | def channels(params = {})
239 | get('/channels', params)
240 | end
241 |
242 | # Request info for a specific channel
243 | #
244 | # GET /apps/[id]/channels/[channel_name]
245 | #
246 | # @param channel_name [String] Channel name (max 200 characters)
247 | # @param params [Hash] Hash of parameters for the API - see REST API docs
248 | #
249 | # @return [Hash] See Pusher API docs
250 | #
251 | # @raise [Pusher::Error] Unsuccessful response - see the error message
252 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
253 | #
254 | def channel_info(channel_name, params = {})
255 | get("/channels/#{channel_name}", params)
256 | end
257 |
258 | # Request info for users of a presence channel
259 | #
260 | # GET /apps/[id]/channels/[channel_name]/users
261 | #
262 | # @param channel_name [String] Channel name (max 200 characters)
263 | # @param params [Hash] Hash of parameters for the API - see REST API docs
264 | #
265 | # @return [Hash] See Pusher API docs
266 | #
267 | # @raise [Pusher::Error] Unsuccessful response - see the error message
268 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
269 | #
270 | def channel_users(channel_name, params = {})
271 | get("/channels/#{channel_name}/users", params)
272 | end
273 |
274 | # Trigger an event on one or more channels
275 | #
276 | # POST /apps/[app_id]/events
277 | #
278 | # @param channels [String or Array] 1-10 channel names
279 | # @param event_name [String]
280 | # @param data [Object] Event data to be triggered in javascript.
281 | # Objects other than strings will be converted to JSON
282 | # @param params [Hash] Additional parameters to send to api, e.g socket_id
283 | #
284 | # @return [Hash] See Pusher API docs
285 | #
286 | # @raise [Pusher::Error] Unsuccessful response - see the error message
287 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
288 | #
289 | def trigger(channels, event_name, data, params = {})
290 | post('/events', trigger_params(channels, event_name, data, params))
291 | end
292 |
293 | # Trigger multiple events at the same time
294 | #
295 | # POST /apps/[app_id]/batch_events
296 | #
297 | # @param events [Array] List of events to publish
298 | #
299 | # @return [Hash] See Pusher API docs
300 | #
301 | # @raise [Pusher::Error] Unsuccessful response - see the error message
302 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
303 | #
304 | def trigger_batch(*events)
305 | post('/batch_events', trigger_batch_params(events.flatten))
306 | end
307 |
308 | # Trigger an event on one or more channels asynchronously.
309 | # For parameters see #trigger
310 | #
311 | def trigger_async(channels, event_name, data, params = {})
312 | post_async('/events', trigger_params(channels, event_name, data, params))
313 | end
314 |
315 | # Trigger multiple events asynchronously.
316 | # For parameters see #trigger_batch
317 | #
318 | def trigger_batch_async(*events)
319 | post_async('/batch_events', trigger_batch_params(events.flatten))
320 | end
321 |
322 |
323 | # Generate the expected response for an authentication endpoint.
324 | # See http://pusher.com/docs/authenticating_users for details.
325 | #
326 | # @example Private channels
327 | # render :json => Pusher.authenticate('private-my_channel', params[:socket_id])
328 | #
329 | # @example Presence channels
330 | # render :json => Pusher.authenticate('presence-my_channel', params[:socket_id], {
331 | # :user_id => current_user.id, # => required
332 | # :user_info => { # => optional - for example
333 | # :name => current_user.name,
334 | # :email => current_user.email
335 | # }
336 | # })
337 | #
338 | # @param socket_id [String]
339 | # @param custom_data [Hash] used for example by private channels
340 | #
341 | # @return [Hash]
342 | #
343 | # @raise [Pusher::Error] if channel_name or socket_id are invalid
344 | #
345 | # @private Custom data is sent to server as JSON-encoded string
346 | #
347 | def authenticate(channel_name, socket_id, custom_data = nil)
348 | channel_instance = channel(channel_name)
349 | r = channel_instance.authenticate(socket_id, custom_data)
350 | if channel_name.match(/^private-encrypted-/)
351 | r[:shared_secret] = Base64.strict_encode64(
352 | channel_instance.shared_secret(encryption_master_key)
353 | )
354 | end
355 | r
356 | end
357 |
358 | # @private Construct a net/http http client
359 | def sync_http_client
360 | require 'httpclient'
361 |
362 | @client ||= begin
363 | HTTPClient.new(@http_proxy).tap do |c|
364 | c.connect_timeout = @connect_timeout
365 | c.send_timeout = @send_timeout
366 | c.receive_timeout = @receive_timeout
367 | c.keep_alive_timeout = @keep_alive_timeout
368 | end
369 | end
370 | end
371 |
372 | # @private Construct an em-http-request http client
373 | def em_http_client(uri)
374 | begin
375 | unless defined?(EventMachine) && EventMachine.reactor_running?
376 | raise Error, "In order to use async calling you must be running inside an eventmachine loop"
377 | end
378 | require 'em-http' unless defined?(EventMachine::HttpRequest)
379 |
380 | connection_opts = {
381 | connect_timeout: @connect_timeout,
382 | inactivity_timeout: @receive_timeout,
383 | }
384 |
385 | if defined?(@proxy)
386 | proxy_opts = {
387 | host: @proxy[:host],
388 | port: @proxy[:port]
389 | }
390 | if @proxy[:user]
391 | proxy_opts[:authorization] = [@proxy[:user], @proxy[:password]]
392 | end
393 | connection_opts[:proxy] = proxy_opts
394 | end
395 |
396 | EventMachine::HttpRequest.new(uri, connection_opts)
397 | end
398 | end
399 |
400 | private
401 |
402 | def trigger_params(channels, event_name, data, params)
403 | channels = Array(channels).map(&:to_s)
404 | raise Pusher::Error, "Too many channels (#{channels.length}), max 100" if channels.length > 100
405 |
406 | encoded_data = if channels.any?{ |c| c.match(/^private-encrypted-/) } then
407 | raise Pusher::Error, "Cannot trigger to multiple channels if any are encrypted" if channels.length > 1
408 | encrypt(channels[0], encode_data(data))
409 | else
410 | encode_data(data)
411 | end
412 |
413 | params.merge({
414 | name: event_name,
415 | channels: channels,
416 | data: encoded_data,
417 | })
418 | end
419 |
420 | def trigger_batch_params(events)
421 | {
422 | batch: events.map do |event|
423 | event.dup.tap do |e|
424 | e[:data] = if e[:channel].match(/^private-encrypted-/) then
425 | encrypt(e[:channel], encode_data(e[:data]))
426 | else
427 | encode_data(e[:data])
428 | end
429 | end
430 | end
431 | }
432 | end
433 |
434 | # JSON-encode the data if it's not a string
435 | def encode_data(data)
436 | return data if data.is_a? String
437 | MultiJson.encode(data)
438 | end
439 |
440 | # Encrypts a message with a key derived from the master key and channel
441 | # name
442 | def encrypt(channel_name, encoded_data)
443 | raise ConfigurationError, :encryption_master_key unless @encryption_master_key
444 |
445 | # Only now load rbnacl, so that people that aren't using it don't need to
446 | # install libsodium
447 | require_rbnacl
448 |
449 | secret_box = RbNaCl::SecretBox.new(
450 | channel(channel_name).shared_secret(@encryption_master_key)
451 | )
452 |
453 | nonce = RbNaCl::Random.random_bytes(secret_box.nonce_bytes)
454 | ciphertext = secret_box.encrypt(nonce, encoded_data)
455 |
456 | MultiJson.encode({
457 | "nonce" => Base64::strict_encode64(nonce),
458 | "ciphertext" => Base64::strict_encode64(ciphertext),
459 | })
460 | end
461 |
462 | def configured?
463 | host && scheme && key && secret && app_id
464 | end
465 |
466 | def require_rbnacl
467 | require 'rbnacl'
468 | rescue LoadError => e
469 | $stderr.puts "You don't have rbnacl installed in your application. Please add it to your Gemfile and run bundle install"
470 | raise e
471 | end
472 | end
473 | end
474 |
--------------------------------------------------------------------------------
/spec/client_spec.rb:
--------------------------------------------------------------------------------
1 | require 'base64'
2 |
3 | require 'rbnacl'
4 | require 'em-http'
5 |
6 | require 'spec_helper'
7 |
8 | encryption_master_key = RbNaCl::Random.random_bytes(32)
9 |
10 | describe Pusher do
11 | # The behaviour should be the same when using the Client object, or the
12 | # 'global' client delegated through the Pusher class
13 | [lambda { Pusher }, lambda { Pusher::Client.new }].each do |client_gen|
14 | before :each do
15 | @client = client_gen.call
16 | end
17 |
18 | describe 'default configuration' do
19 | it 'should be preconfigured for api host' do
20 | expect(@client.host).to eq('api-mt1.pusher.com')
21 | end
22 |
23 | it 'should be preconfigured for port 443' do
24 | expect(@client.port).to eq(443)
25 | end
26 |
27 | it 'should use standard logger if no other logger if defined' do
28 | Pusher.logger.debug('foo')
29 | expect(Pusher.logger).to be_kind_of(Logger)
30 | end
31 | end
32 |
33 | describe 'logging configuration' do
34 | it "can be configured to use any logger" do
35 | logger = double("ALogger")
36 | expect(logger).to receive(:debug).with('foo')
37 | Pusher.logger = logger
38 | Pusher.logger.debug('foo')
39 | Pusher.logger = nil
40 | end
41 | end
42 |
43 | describe "configuration using url" do
44 | it "should be possible to configure everything by setting the url" do
45 | @client.url = "test://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87"
46 |
47 | expect(@client.scheme).to eq('test')
48 | expect(@client.host).to eq('api.staging.pusherapp.com')
49 | expect(@client.port).to eq(8080)
50 | expect(@client.key).to eq('somekey')
51 | expect(@client.secret).to eq('somesecret')
52 | expect(@client.app_id).to eq('87')
53 | end
54 |
55 | it "should override scheme and port when setting encrypted=true after url" do
56 | @client.url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87"
57 | @client.encrypted = true
58 |
59 | expect(@client.scheme).to eq('https')
60 | expect(@client.port).to eq(443)
61 | end
62 |
63 | it "should fail on bad urls" do
64 | expect { @client.url = "gopher/somekey:somesecret@://api.staging.pusherapp.co://m:8080\apps\87" }.to raise_error(URI::InvalidURIError)
65 | end
66 |
67 | it "should raise exception if app_id is not configured" do
68 | @client.app_id = nil
69 | expect {
70 | @client.url
71 | }.to raise_error(Pusher::ConfigurationError)
72 | end
73 |
74 | end
75 |
76 | describe 'configuring the cluster' do
77 | it 'should set a new default host' do
78 | @client.cluster = 'eu'
79 | expect(@client.host).to eq('api-eu.pusher.com')
80 | end
81 |
82 | it 'should handle nil gracefully' do
83 | @client.cluster = nil
84 | expect(@client.host).to eq('api-mt1.pusher.com')
85 | end
86 |
87 | it 'should handle empty string' do
88 | @client.cluster = ""
89 | expect(@client.host).to eq('api-mt1.pusher.com')
90 | end
91 |
92 | it 'should be overridden by host if it comes after' do
93 | @client.cluster = 'eu'
94 | @client.host = 'api.staging.pusher.com'
95 | expect(@client.host).to eq('api.staging.pusher.com')
96 | end
97 |
98 | it 'should be overridden by url if it comes after' do
99 | @client.cluster = 'eu'
100 | @client.url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87"
101 |
102 | expect(@client.host).to eq('api.staging.pusherapp.com')
103 | end
104 |
105 | it 'should override the url configuration if it comes after' do
106 | @client.url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87"
107 | @client.cluster = 'eu'
108 | expect(@client.host).to eq('api-eu.pusher.com')
109 | end
110 |
111 | it 'should override the host configuration if it comes after' do
112 | @client.host = 'api.staging.pusher.com'
113 | @client.cluster = 'eu'
114 | expect(@client.host).to eq('api-eu.pusher.com')
115 | end
116 | end
117 |
118 | describe 'configuring TLS' do
119 | it 'should set port and scheme if "use_tls" disabled' do
120 | client = Pusher::Client.new({
121 | :use_tls => false,
122 | })
123 | expect(client.scheme).to eq('http')
124 | expect(client.port).to eq(80)
125 | end
126 |
127 | it 'should set port and scheme if "encrypted" disabled' do
128 | client = Pusher::Client.new({
129 | :encrypted => false,
130 | })
131 | expect(client.scheme).to eq('http')
132 | expect(client.port).to eq(80)
133 | end
134 |
135 | it 'should use TLS port and scheme if "encrypted" or "use_tls" are not set' do
136 | client = Pusher::Client.new
137 | expect(client.scheme).to eq('https')
138 | expect(client.port).to eq(443)
139 | end
140 |
141 | it 'should override port if "use_tls" option set but a different port is specified' do
142 | client = Pusher::Client.new({
143 | :use_tls => true,
144 | :port => 8443
145 | })
146 | expect(client.scheme).to eq('https')
147 | expect(client.port).to eq(8443)
148 | end
149 |
150 | it 'should override port if "use_tls" option set but a different port is specified' do
151 | client = Pusher::Client.new({
152 | :use_tls => false,
153 | :port => 8000
154 | })
155 | expect(client.scheme).to eq('http')
156 | expect(client.port).to eq(8000)
157 | end
158 |
159 | end
160 |
161 | describe 'configuring a http proxy' do
162 | it "should be possible to configure everything by setting the http_proxy" do
163 | @client.http_proxy = 'http://someuser:somepassword@proxy.host.com:8080'
164 |
165 | expect(@client.proxy).to eq({:scheme => 'http', :host => 'proxy.host.com', :port => 8080, :user => 'someuser', :password => 'somepassword'})
166 | end
167 | end
168 |
169 | describe 'configuring from env' do
170 | after do
171 | ENV['PUSHER_URL'] = nil
172 | end
173 |
174 | it "works" do
175 | url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87"
176 | ENV['PUSHER_URL'] = url
177 |
178 | client = Pusher::Client.from_env
179 | expect(client.key).to eq("somekey")
180 | expect(client.secret).to eq("somesecret")
181 | expect(client.app_id).to eq("87")
182 | expect(client.url.to_s).to eq("http://api.staging.pusherapp.com:8080/apps/87")
183 | end
184 | end
185 |
186 | describe 'configuring from url' do
187 | it "works" do
188 | url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87"
189 |
190 | client = Pusher::Client.from_url(url)
191 | expect(client.key).to eq("somekey")
192 | expect(client.secret).to eq("somesecret")
193 | expect(client.app_id).to eq("87")
194 | expect(client.url.to_s).to eq("http://api.staging.pusherapp.com:8080/apps/87")
195 | end
196 | end
197 |
198 | describe 'can set encryption_master_key_base64' do
199 | it "sets encryption_master_key" do
200 | @client.encryption_master_key_base64 =
201 | Base64.strict_encode64(encryption_master_key)
202 |
203 | expect(@client.encryption_master_key).to eq(encryption_master_key)
204 | end
205 | end
206 |
207 | describe 'when configured' do
208 | before :each do
209 | @client.app_id = '20'
210 | @client.key = '12345678900000001'
211 | @client.secret = '12345678900000001'
212 | @client.encryption_master_key_base64 =
213 | Base64.strict_encode64(encryption_master_key)
214 | end
215 |
216 | describe '#[]' do
217 | before do
218 | @channel = @client['test_channel']
219 | end
220 |
221 | it 'should return a channel' do
222 | expect(@channel).to be_kind_of(Pusher::Channel)
223 | end
224 |
225 | it "should raise exception if app_id is not configured" do
226 | @client.app_id = nil
227 | expect {
228 | @channel.trigger!('foo', 'bar')
229 | }.to raise_error(Pusher::ConfigurationError)
230 | end
231 | end
232 |
233 | describe '#channels' do
234 | it "should call the correct URL and symbolise response correctly" do
235 | api_path = %r{/apps/20/channels}
236 | stub_request(:get, api_path).to_return({
237 | :status => 200,
238 | :body => MultiJson.encode('channels' => {
239 | "channel1" => {},
240 | "channel2" => {}
241 | })
242 | })
243 | expect(@client.channels).to eq({
244 | :channels => {
245 | "channel1" => {},
246 | "channel2" => {}
247 | }
248 | })
249 | end
250 | end
251 |
252 | describe '#channel_info' do
253 | it "should call correct URL and symbolise response" do
254 | api_path = %r{/apps/20/channels/mychannel}
255 | stub_request(:get, api_path).to_return({
256 | :status => 200,
257 | :body => MultiJson.encode({
258 | 'occupied' => false,
259 | })
260 | })
261 | expect(@client.channel_info('mychannel')).to eq({
262 | :occupied => false,
263 | })
264 | end
265 | end
266 |
267 | describe '#channel_users' do
268 | it "should call correct URL and symbolise response" do
269 | api_path = %r{/apps/20/channels/mychannel/users}
270 | stub_request(:get, api_path).to_return({
271 | :status => 200,
272 | :body => MultiJson.encode({
273 | 'users' => [{ 'id' => 1 }]
274 | })
275 | })
276 | expect(@client.channel_users('mychannel')).to eq({
277 | :users => [{ 'id' => 1}]
278 | })
279 | end
280 | end
281 |
282 | describe '#authenticate' do
283 | before :each do
284 | @custom_data = {:uid => 123, :info => {:name => 'Foo'}}
285 | end
286 |
287 | it 'should return a hash with signature including custom data and data as json string' do
288 | allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string'
289 |
290 | response = @client.authenticate('test_channel', '1.1', @custom_data)
291 |
292 | expect(response).to eq({
293 | :auth => "12345678900000001:#{hmac(@client.secret, "1.1:test_channel:a json string")}",
294 | :channel_data => 'a json string'
295 | })
296 | end
297 |
298 | it 'should include a shared_secret if the private-encrypted channel' do
299 | allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string'
300 | @client.instance_variable_set(:@encryption_master_key, '3W1pfB/Etr+ZIlfMWwZP3gz8jEeCt4s2pe6Vpr+2c3M=')
301 |
302 | response = @client.authenticate('private-encrypted-test_channel', '1.1', @custom_data)
303 |
304 | expect(response).to eq({
305 | :auth => "12345678900000001:#{hmac(@client.secret, "1.1:private-encrypted-test_channel:a json string")}",
306 | :shared_secret => "o0L3QnIovCeRC8KTD8KBRlmi31dGzHVS2M93uryqDdw=",
307 | :channel_data => 'a json string'
308 | })
309 | end
310 |
311 | end
312 |
313 | describe '#trigger' do
314 | before :each do
315 | @api_path = %r{/apps/20/events}
316 | stub_request(:post, @api_path).to_return({
317 | :status => 200,
318 | :body => MultiJson.encode({})
319 | })
320 | end
321 |
322 | it "should call correct URL" do
323 | expect(@client.trigger(['mychannel'], 'event', {'some' => 'data'})).
324 | to eq({})
325 | end
326 |
327 | it "should not allow too many channels" do
328 | expect {
329 | @client.trigger((0..101).map{|i| 'mychannel#{i}'},
330 | 'event', {'some' => 'data'}, {
331 | :socket_id => "12.34"
332 | })}.to raise_error(Pusher::Error)
333 | end
334 |
335 | it "should pass any parameters in the body of the request" do
336 | @client.trigger(['mychannel', 'c2'], 'event', {'some' => 'data'}, {
337 | :socket_id => "12.34"
338 | })
339 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
340 | parsed = MultiJson.decode(req.body)
341 | expect(parsed["name"]).to eq('event')
342 | expect(parsed["channels"]).to eq(["mychannel", "c2"])
343 | expect(parsed["socket_id"]).to eq('12.34')
344 | }
345 | end
346 |
347 | it "should convert non string data to JSON before posting" do
348 | @client.trigger(['mychannel'], 'event', {'some' => 'data'})
349 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
350 | expect(MultiJson.decode(req.body)["data"]).to eq('{"some":"data"}')
351 | }
352 | end
353 |
354 | it "should accept a single channel as well as an array" do
355 | @client.trigger('mychannel', 'event', {'some' => 'data'})
356 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
357 | expect(MultiJson.decode(req.body)["channels"]).to eq(['mychannel'])
358 | }
359 | end
360 |
361 | %w[app_id key secret].each do |key|
362 | it "should fail in missing #{key}" do
363 | @client.public_send("#{key}=", nil)
364 | expect {
365 | @client.trigger('mychannel', 'event', {'some' => 'data'})
366 | }.to raise_error(Pusher::ConfigurationError)
367 | expect(WebMock).not_to have_requested(:post, @api_path).with { |req|
368 | expect(MultiJson.decode(req.body)["channels"]).to eq(['mychannel'])
369 | }
370 | end
371 | end
372 |
373 | it "should fail to publish to encrypted channels when missing key" do
374 | @client.encryption_master_key_base64 = nil
375 | expect {
376 | @client.trigger('private-encrypted-channel', 'event', {'some' => 'data'})
377 | }.to raise_error(Pusher::ConfigurationError)
378 | expect(WebMock).not_to have_requested(:post, @api_path)
379 | end
380 |
381 | it "should fail to publish to multiple channels if one is encrypted" do
382 | expect {
383 | @client.trigger(
384 | ['private-encrypted-channel', 'some-other-channel'],
385 | 'event',
386 | {'some' => 'data'},
387 | )
388 | }.to raise_error(Pusher::Error)
389 | expect(WebMock).not_to have_requested(:post, @api_path)
390 | end
391 |
392 | it "should encrypt publishes to encrypted channels" do
393 | @client.trigger(
394 | 'private-encrypted-channel',
395 | 'event',
396 | {'some' => 'data'},
397 | )
398 |
399 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
400 | data = MultiJson.decode(MultiJson.decode(req.body)["data"])
401 |
402 | key = RbNaCl::Hash.sha256(
403 | 'private-encrypted-channel' + encryption_master_key
404 | )
405 |
406 | expect(MultiJson.decode(RbNaCl::SecretBox.new(key).decrypt(
407 | Base64.strict_decode64(data["nonce"]),
408 | Base64.strict_decode64(data["ciphertext"]),
409 | ))).to eq({ 'some' => 'data' })
410 | }
411 | end
412 | end
413 |
414 | describe '#trigger_batch' do
415 | before :each do
416 | @api_path = %r{/apps/20/batch_events}
417 | stub_request(:post, @api_path).to_return({
418 | :status => 200,
419 | :body => MultiJson.encode({})
420 | })
421 | end
422 |
423 | it "should call correct URL" do
424 | expect(@client.trigger_batch(channel: 'mychannel', name: 'event', data: {'some' => 'data'})).
425 | to eq({})
426 | end
427 |
428 | it "should convert non string data to JSON before posting" do
429 | @client.trigger_batch(
430 | {channel: 'mychannel', name: 'event', data: {'some' => 'data'}},
431 | {channel: 'mychannel', name: 'event', data: 'already encoded'},
432 | )
433 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
434 | parsed = MultiJson.decode(req.body)
435 | expect(parsed).to eq(
436 | "batch" => [
437 | { "channel" => "mychannel", "name" => "event", "data" => "{\"some\":\"data\"}"},
438 | { "channel" => "mychannel", "name" => "event", "data" => "already encoded"}
439 | ]
440 | )
441 | }
442 | end
443 |
444 | it "should fail to publish to encrypted channels when missing key" do
445 | @client.encryption_master_key_base64 = nil
446 | expect {
447 | @client.trigger_batch(
448 | {
449 | channel: 'private-encrypted-channel',
450 | name: 'event',
451 | data: {'some' => 'data'},
452 | },
453 | {channel: 'mychannel', name: 'event', data: 'already encoded'},
454 | )
455 | }.to raise_error(Pusher::ConfigurationError)
456 | expect(WebMock).not_to have_requested(:post, @api_path)
457 | end
458 |
459 | it "should encrypt publishes to encrypted channels" do
460 | @client.trigger_batch(
461 | {
462 | channel: 'private-encrypted-channel',
463 | name: 'event',
464 | data: {'some' => 'data'},
465 | },
466 | {channel: 'mychannel', name: 'event', data: 'already encoded'},
467 | )
468 |
469 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
470 | batch = MultiJson.decode(req.body)["batch"]
471 | expect(batch.length).to eq(2)
472 |
473 | expect(batch[0]["channel"]).to eq("private-encrypted-channel")
474 | expect(batch[0]["name"]).to eq("event")
475 |
476 | data = MultiJson.decode(batch[0]["data"])
477 |
478 | key = RbNaCl::Hash.sha256(
479 | 'private-encrypted-channel' + encryption_master_key
480 | )
481 |
482 | expect(MultiJson.decode(RbNaCl::SecretBox.new(key).decrypt(
483 | Base64.strict_decode64(data["nonce"]),
484 | Base64.strict_decode64(data["ciphertext"]),
485 | ))).to eq({ 'some' => 'data' })
486 |
487 | expect(batch[1]["channel"]).to eq("mychannel")
488 | expect(batch[1]["name"]).to eq("event")
489 | expect(batch[1]["data"]).to eq("already encoded")
490 | }
491 | end
492 | end
493 |
494 | describe '#trigger_async' do
495 | before :each do
496 | @api_path = %r{/apps/20/events}
497 | stub_request(:post, @api_path).to_return({
498 | :status => 200,
499 | :body => MultiJson.encode({})
500 | })
501 | end
502 |
503 | it "should call correct URL" do
504 | EM.run {
505 | @client.trigger_async('mychannel', 'event', {'some' => 'data'}).callback { |r|
506 | expect(r).to eq({})
507 | EM.stop
508 | }
509 | }
510 | end
511 |
512 | it "should pass any parameters in the body of the request" do
513 | EM.run {
514 | @client.trigger_async('mychannel', 'event', {'some' => 'data'}, {
515 | :socket_id => "12.34"
516 | }).callback {
517 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
518 | expect(MultiJson.decode(req.body)["socket_id"]).to eq('12.34')
519 | }
520 | EM.stop
521 | }
522 | }
523 | end
524 |
525 | it "should convert non string data to JSON before posting" do
526 | EM.run {
527 | @client.trigger_async('mychannel', 'event', {'some' => 'data'}).callback {
528 | expect(WebMock).to have_requested(:post, @api_path).with { |req|
529 | expect(MultiJson.decode(req.body)["data"]).to eq('{"some":"data"}')
530 | }
531 | EM.stop
532 | }
533 | }
534 | end
535 | end
536 |
537 | [:get, :post].each do |verb|
538 | describe "##{verb}" do
539 | before :each do
540 | @url_regexp = %r{api-mt1.pusher.com}
541 | stub_request(verb, @url_regexp).
542 | to_return(:status => 200, :body => "{}")
543 | end
544 |
545 | let(:call_api) { @client.send(verb, '/path') }
546 |
547 | it "should use https by default" do
548 | call_api
549 | expect(WebMock).to have_requested(verb, %r{https://api-mt1.pusher.com/apps/20/path})
550 | end
551 |
552 | it "should use https if configured" do
553 | @client.encrypted = false
554 | call_api
555 | expect(WebMock).to have_requested(verb, %r{http://api-mt1.pusher.com})
556 | end
557 |
558 | it "should format the respose hash with symbols at first level" do
559 | stub_request(verb, @url_regexp).to_return({
560 | :status => 200,
561 | :body => MultiJson.encode({'something' => {'a' => 'hash'}})
562 | })
563 | expect(call_api).to eq({
564 | :something => {'a' => 'hash'}
565 | })
566 | end
567 |
568 | it "should catch all http exceptions and raise a Pusher::HTTPError wrapping the original error" do
569 | stub_request(verb, @url_regexp).to_raise(HTTPClient::TimeoutError)
570 |
571 | error = nil
572 | begin
573 | call_api
574 | rescue => e
575 | error = e
576 | end
577 |
578 | expect(error.class).to eq(Pusher::HTTPError)
579 | expect(error).to be_kind_of(Pusher::Error)
580 | expect(error.message).to eq('Exception from WebMock (HTTPClient::TimeoutError)')
581 | expect(error.original_error.class).to eq(HTTPClient::TimeoutError)
582 | end
583 |
584 | it "should raise Pusher::Error if call returns 400" do
585 | stub_request(verb, @url_regexp).to_return({:status => 400})
586 | expect { call_api }.to raise_error(Pusher::Error)
587 | end
588 |
589 | it "should raise AuthenticationError if pusher returns 401" do
590 | stub_request(verb, @url_regexp).to_return({:status => 401})
591 | expect { call_api }.to raise_error(Pusher::AuthenticationError)
592 | end
593 |
594 | it "should raise Pusher::Error if pusher returns 404" do
595 | stub_request(verb, @url_regexp).to_return({:status => 404})
596 | expect { call_api }.to raise_error(Pusher::Error, '404 Not found (/apps/20/path)')
597 | end
598 |
599 | it "should raise Pusher::Error if pusher returns 407" do
600 | stub_request(verb, @url_regexp).to_return({:status => 407})
601 | expect { call_api }.to raise_error(Pusher::Error, 'Proxy Authentication Required')
602 | end
603 |
604 | it "should raise Pusher::Error if pusher returns 413" do
605 | stub_request(verb, @url_regexp).to_return({:status => 413})
606 | expect { call_api }.to raise_error(Pusher::Error, 'Payload Too Large > 10KB')
607 | end
608 |
609 | it "should raise Pusher::Error if pusher returns 500" do
610 | stub_request(verb, @url_regexp).to_return({:status => 500, :body => "some error"})
611 | expect { call_api }.to raise_error(Pusher::Error, 'Unknown error (status code 500): some error')
612 | end
613 | end
614 | end
615 |
616 | describe "async calling without eventmachine" do
617 | [[:get, :get_async], [:post, :post_async]].each do |verb, method|
618 | describe "##{method}" do
619 | before :each do
620 | @url_regexp = %r{api-mt1.pusher.com}
621 | stub_request(verb, @url_regexp).
622 | to_return(:status => 200, :body => "{}")
623 | end
624 |
625 | let(:call_api) {
626 | @client.send(method, '/path').tap { |c|
627 | # Allow the async thread (inside httpclient) to run
628 | while !c.finished?
629 | sleep 0.01
630 | end
631 | }
632 | }
633 |
634 | it "should use https by default" do
635 | call_api
636 | expect(WebMock).to have_requested(verb, %r{https://api-mt1.pusher.com/apps/20/path})
637 | end
638 |
639 | it "should use http if configured" do
640 | @client.encrypted = false
641 | call_api
642 | expect(WebMock).to have_requested(verb, %r{http://api-mt1.pusher.com})
643 | end
644 |
645 | # Note that the raw httpclient connection object is returned and
646 | # the response isn't handled (by handle_response) in the normal way.
647 | it "should return a httpclient connection object" do
648 | connection = call_api
649 | expect(connection.finished?).to be_truthy
650 | response = connection.pop
651 | expect(response.status).to eq(200)
652 | expect(response.body.read).to eq("{}")
653 | end
654 | end
655 | end
656 | end
657 |
658 | describe "async calling with eventmachine" do
659 | [[:get, :get_async], [:post, :post_async]].each do |verb, method|
660 | describe "##{method}" do
661 | before :each do
662 | @url_regexp = %r{api-mt1.pusher.com}
663 | stub_request(verb, @url_regexp).
664 | to_return(:status => 200, :body => "{}")
665 | end
666 |
667 | let(:call_api) { @client.send(method, '/path') }
668 |
669 | it "should use https by default" do
670 | EM.run {
671 | call_api.callback {
672 | expect(WebMock).to have_requested(verb, %r{https://api-mt1.pusher.com/apps/20/path})
673 | EM.stop
674 | }
675 | }
676 | end
677 |
678 | it "should use http if configured" do
679 | EM.run {
680 | @client.encrypted = false
681 | call_api.callback {
682 | expect(WebMock).to have_requested(verb, %r{http://api-mt1.pusher.com})
683 | EM.stop
684 | }
685 | }
686 | end
687 |
688 | it "should format the respose hash with symbols at first level" do
689 | EM.run {
690 | stub_request(verb, @url_regexp).to_return({
691 | :status => 200,
692 | :body => MultiJson.encode({'something' => {'a' => 'hash'}})
693 | })
694 | call_api.callback { |response|
695 | expect(response).to eq({
696 | :something => {'a' => 'hash'}
697 | })
698 | EM.stop
699 | }
700 | }
701 | end
702 |
703 | it "should errback with Pusher::Error on unsuccessful response" do
704 | EM.run {
705 | stub_request(verb, @url_regexp).to_return({:status => 400})
706 |
707 | call_api.errback { |e|
708 | expect(e.class).to eq(Pusher::Error)
709 | EM.stop
710 | }.callback {
711 | fail
712 | }
713 | }
714 | end
715 | end
716 | end
717 | end
718 | end
719 | end
720 |
721 | describe 'configuring cluster' do
722 | it 'should allow clients to specify the cluster only with the default host' do
723 | client = Pusher::Client.new({
724 | :scheme => 'http',
725 | :cluster => 'eu',
726 | :port => 80
727 | })
728 | expect(client.host).to eq('api-eu.pusher.com')
729 | end
730 |
731 | it 'should always have host override any supplied cluster value' do
732 | client = Pusher::Client.new({
733 | :scheme => 'http',
734 | :host => 'api.staging.pusherapp.com',
735 | :cluster => 'eu',
736 | :port => 80
737 | })
738 | expect(client.host).to eq('api.staging.pusherapp.com')
739 | end
740 | end
741 | end
742 |
--------------------------------------------------------------------------------