eventflit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/.gemtest +0 -0
- data/.gitignore +24 -0
- data/.travis.yml +16 -0
- data/CHANGELOG.md +0 -0
- data/Gemfile +2 -0
- data/LICENSE +20 -0
- data/README.md +301 -0
- data/Rakefile +11 -0
- data/eventflit.gemspec +33 -0
- data/examples/async_message.rb +28 -0
- data/lib/eventflit.rb +69 -0
- data/lib/eventflit/channel.rb +185 -0
- data/lib/eventflit/client.rb +437 -0
- data/lib/eventflit/native_notification/client.rb +68 -0
- data/lib/eventflit/request.rb +109 -0
- data/lib/eventflit/resource.rb +36 -0
- data/lib/eventflit/version.rb +3 -0
- data/lib/eventflit/webhook.rb +110 -0
- data/spec/channel_spec.rb +170 -0
- data/spec/client_spec.rb +629 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/web_hook_spec.rb +117 -0
- metadata +207 -0
@@ -0,0 +1,68 @@
|
|
1
|
+
module Eventflit
|
2
|
+
module NativeNotification
|
3
|
+
class Client
|
4
|
+
attr_reader :app_id, :host
|
5
|
+
|
6
|
+
API_PREFIX = "publisher/app/"
|
7
|
+
|
8
|
+
def initialize(app_id, host, scheme, eventflit_client)
|
9
|
+
@app_id = app_id
|
10
|
+
@host = host
|
11
|
+
@scheme = scheme
|
12
|
+
@eventflit_client = eventflit_client
|
13
|
+
end
|
14
|
+
|
15
|
+
# Send a notification via the native notifications API
|
16
|
+
def notify(interests, data = {})
|
17
|
+
Request.new(
|
18
|
+
@eventflit_client,
|
19
|
+
:post,
|
20
|
+
url("/publishes"),
|
21
|
+
{},
|
22
|
+
payload(interests, data)
|
23
|
+
).send_sync
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# {
|
29
|
+
# interests: [Array of interests],
|
30
|
+
# apns: {
|
31
|
+
# See https://docs.eventflit.com/push_notifications/ios/server
|
32
|
+
# },
|
33
|
+
# gcm: {
|
34
|
+
# See https://docs.eventflit.com/push_notifications/android/server
|
35
|
+
# }
|
36
|
+
# }
|
37
|
+
#
|
38
|
+
# @raise [Eventflit::Error] if the interests array is empty
|
39
|
+
# @return [String]
|
40
|
+
def payload(interests, data)
|
41
|
+
interests = Array(interests).map(&:to_s)
|
42
|
+
|
43
|
+
raise Eventflit::Error, "Interests array must not be empty" if interests.length == 0
|
44
|
+
|
45
|
+
data = deep_symbolize_keys!(data)
|
46
|
+
|
47
|
+
data.merge!(interests: interests)
|
48
|
+
|
49
|
+
MultiJson.encode(data)
|
50
|
+
end
|
51
|
+
|
52
|
+
def url(path = nil)
|
53
|
+
URI.parse("#{@scheme}://#{@host}/#{API_PREFIX}/#{@app_id}#{path}")
|
54
|
+
end
|
55
|
+
|
56
|
+
# Symbolize all keys in the hash recursively
|
57
|
+
def deep_symbolize_keys!(hash)
|
58
|
+
hash.keys.each do |k|
|
59
|
+
ks = k.respond_to?(:to_sym) ? k.to_sym : k
|
60
|
+
hash[ks] = hash.delete(k)
|
61
|
+
deep_symbolize_keys!(hash[ks]) if hash[ks].kind_of?(Hash)
|
62
|
+
end
|
63
|
+
|
64
|
+
hash
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'eventflit-signature'
|
2
|
+
require 'digest/md5'
|
3
|
+
require 'multi_json'
|
4
|
+
|
5
|
+
module Eventflit
|
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-Eventflit-Library' => 'eventflit-http-ruby ' + Eventflit::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 = Eventflit::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 = Eventflit::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 eventflit (#{http.error})"
|
69
|
+
Eventflit.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
|
+
else
|
98
|
+
raise Error, "Unknown error (status code #{status_code}): #{body}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def symbolize_first_level(hash)
|
103
|
+
hash.inject({}) do |result, (key, value)|
|
104
|
+
result[key.to_sym] = value
|
105
|
+
result
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Eventflit
|
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
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'multi_json'
|
2
|
+
require 'openssl'
|
3
|
+
|
4
|
+
module Eventflit
|
5
|
+
# Used to parse and authenticate WebHooks
|
6
|
+
#
|
7
|
+
# @example Sinatra
|
8
|
+
# post '/webhooks' do
|
9
|
+
# webhook = Eventflit::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 = Eventflit)
|
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_EVENTFLIT_KEY']
|
36
|
+
@signature = request.env["HTTP_X_EVENTFLIT_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 Eventflit 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
|
+
Eventflit.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 Eventflit, 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
|
+
Eventflit.logger.warn "Received WebHook with invalid signature: got #{@signature}, expected #{expected}"
|
106
|
+
return false
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Eventflit::Channel do
|
5
|
+
before do
|
6
|
+
@client = Eventflit::Client.new({
|
7
|
+
:app_id => '20',
|
8
|
+
:key => '12345678900000001',
|
9
|
+
:secret => '12345678900000001',
|
10
|
+
:host => 'service.eventflit.com',
|
11
|
+
:port => 80,
|
12
|
+
})
|
13
|
+
@channel = @client['test_channel']
|
14
|
+
end
|
15
|
+
|
16
|
+
let(:eventflit_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, eventflit_url_regexp).to_return(options)
|
23
|
+
end
|
24
|
+
|
25
|
+
def stub_post_to_raise(e)
|
26
|
+
stub_request(:post, eventflit_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(Eventflit.logger).to receive(:error).with("Exception from WebMock (HTTPClient::BadResponseError) (Eventflit::HTTPError)")
|
41
|
+
expect(Eventflit.logger).to receive(:debug) #backtrace
|
42
|
+
channel = Eventflit::Channel.new(@client.url, 'test_channel', @client)
|
43
|
+
channel.trigger('new_event', 'Some data')
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should log failure if Eventflit returns an error response" do
|
47
|
+
stub_post 401, "some signature info"
|
48
|
+
expect(Eventflit.logger).to receive(:error).with("some signature info (Eventflit::AuthenticationError)")
|
49
|
+
expect(Eventflit.logger).to receive(:debug) #backtrace
|
50
|
+
channel = Eventflit::Channel.new(@client.url, 'test_channel', @client)
|
51
|
+
channel.trigger('new_event', 'Some data')
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "#initialization" do
|
56
|
+
it "should not be too long" do
|
57
|
+
expect { @client['b'*201] }.to raise_error(Eventflit::Error)
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should not use bad characters" do
|
61
|
+
expect { @client['*^!±`/""'] }.to raise_error(Eventflit::Error)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "#trigger_async" do
|
66
|
+
it "should use @client.trigger_async internally" do
|
67
|
+
expect(@client).to receive(:trigger_async)
|
68
|
+
@channel.trigger_async('new_event', 'Some data')
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe '#info' do
|
73
|
+
it "should call the Client#channel_info" do
|
74
|
+
expect(@client).to receive(:get)
|
75
|
+
.with("/channels/mychannel", anything)
|
76
|
+
.and_return({:occupied => true, :subscription_count => 12})
|
77
|
+
@channel = @client['mychannel']
|
78
|
+
@channel.info
|
79
|
+
end
|
80
|
+
|
81
|
+
it "should assemble the requested attributes into the info option" do
|
82
|
+
expect(@client).to receive(:get)
|
83
|
+
.with(anything, {:info => "user_count,connection_count"})
|
84
|
+
.and_return({:occupied => true, :subscription_count => 12, :user_count => 12})
|
85
|
+
@channel = @client['presence-foo']
|
86
|
+
@channel.info(%w{user_count connection_count})
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
describe '#users' do
|
91
|
+
it "should call the Client#channel_users" do
|
92
|
+
expect(@client).to receive(:get).with("/channels/presence-mychannel/users", {}).and_return({:users => {'id' => '4'}})
|
93
|
+
@channel = @client['presence-mychannel']
|
94
|
+
@channel.users
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
describe "#authentication_string" do
|
99
|
+
def authentication_string(*data)
|
100
|
+
lambda { @channel.authentication_string(*data) }
|
101
|
+
end
|
102
|
+
|
103
|
+
it "should return an authentication string given a socket id" do
|
104
|
+
auth = @channel.authentication_string('1.1')
|
105
|
+
|
106
|
+
expect(auth).to eq('12345678900000001:02259dff9a2a3f71ea8ab29ac0c0c0ef7996c8f3fd3702be5533f30da7d7fed4')
|
107
|
+
end
|
108
|
+
|
109
|
+
it "should raise error if authentication is invalid" do
|
110
|
+
[nil, ''].each do |invalid|
|
111
|
+
expect(authentication_string(invalid)).to raise_error Eventflit::Error
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
describe 'with extra string argument' do
|
116
|
+
it 'should be a string or nil' do
|
117
|
+
expect(authentication_string('1.1', 123)).to raise_error Eventflit::Error
|
118
|
+
expect(authentication_string('1.1', {})).to raise_error Eventflit::Error
|
119
|
+
|
120
|
+
expect(authentication_string('1.1', 'boom')).not_to raise_error
|
121
|
+
expect(authentication_string('1.1', nil)).not_to raise_error
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should return an authentication string given a socket id and custom args" do
|
125
|
+
auth = @channel.authentication_string('1.1', 'foobar')
|
126
|
+
|
127
|
+
expect(auth).to eq("12345678900000001:#{hmac(@client.secret, "1.1:test_channel:foobar")}")
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
describe '#authenticate' do
|
133
|
+
before :each do
|
134
|
+
@custom_data = {:uid => 123, :info => {:name => 'Foo'}}
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'should return a hash with signature including custom data and data as json string' do
|
138
|
+
allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string'
|
139
|
+
|
140
|
+
response = @channel.authenticate('1.1', @custom_data)
|
141
|
+
|
142
|
+
expect(response).to eq({
|
143
|
+
:auth => "12345678900000001:#{hmac(@client.secret, "1.1:test_channel:a json string")}",
|
144
|
+
:channel_data => 'a json string'
|
145
|
+
})
|
146
|
+
end
|
147
|
+
|
148
|
+
it 'should fail on invalid socket_ids' do
|
149
|
+
expect {
|
150
|
+
@channel.authenticate('1.1:')
|
151
|
+
}.to raise_error Eventflit::Error
|
152
|
+
|
153
|
+
expect {
|
154
|
+
@channel.authenticate('1.1foo', 'channel')
|
155
|
+
}.to raise_error Eventflit::Error
|
156
|
+
|
157
|
+
expect {
|
158
|
+
@channel.authenticate(':1.1')
|
159
|
+
}.to raise_error Eventflit::Error
|
160
|
+
|
161
|
+
expect {
|
162
|
+
@channel.authenticate('foo1.1', 'channel')
|
163
|
+
}.to raise_error Eventflit::Error
|
164
|
+
|
165
|
+
expect {
|
166
|
+
@channel.authenticate('foo', 'channel')
|
167
|
+
}.to raise_error Eventflit::Error
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|