customerio 2.1.0 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,29 +1,22 @@
1
- require 'net/http'
2
- require 'multi_json'
1
+ require "addressable/uri"
3
2
 
4
3
  module Customerio
5
- DEFAULT_BASE_URI = 'https://track.customer.io'
6
- DEFAULT_TIMEOUT = 10
7
-
8
4
  class Client
5
+ PUSH_OPENED = 'opened'
6
+ PUSH_CONVERTED = 'converted'
7
+ PUSH_DELIVERED = 'delivered'
8
+
9
+ VALID_PUSH_EVENTS = [PUSH_OPENED, PUSH_CONVERTED, PUSH_DELIVERED]
10
+
9
11
  class MissingIdAttributeError < RuntimeError; end
10
12
  class ParamError < RuntimeError; end
11
- class InvalidRequest < RuntimeError; end
12
- class InvalidResponse < RuntimeError
13
- attr_reader :response
14
13
 
15
- def initialize(message, response)
16
- @message = message
17
- @response = response
18
- end
19
- end
14
+ def initialize(site_id, api_key, options = {})
15
+ options[:region] = Customerio::Regions::US if options[:region].nil?
16
+ raise "region must be an instance of Customerio::Regions::Region" unless options[:region].is_a?(Customerio::Regions::Region)
20
17
 
21
- def initialize(site_id, secret_key, options = {})
22
- @username = site_id
23
- @password = secret_key
24
- @json = options.has_key?(:json) ? options[:json] : true
25
- @base_uri = options[:base_uri] || DEFAULT_BASE_URI
26
- @timeout = options[:timeout] || DEFAULT_TIMEOUT
18
+ options[:url] = options[:region].track_url if options[:url].nil? || options[:url].empty?
19
+ @client = Customerio::BaseClient.new({ site_id: site_id, api_key: api_key }, options)
27
20
  end
28
21
 
29
22
  def identify(attributes)
@@ -31,41 +24,38 @@ module Customerio
31
24
  end
32
25
 
33
26
  def delete(customer_id)
34
- verify_response(request(:delete, customer_path(customer_id)))
27
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
28
+ @client.request_and_verify_response(:delete, customer_path(customer_id))
35
29
  end
36
30
 
37
31
  def suppress(customer_id)
38
- verify_response(request(:post, suppress_path(customer_id)))
32
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
33
+ @client.request_and_verify_response(:post, suppress_path(customer_id))
39
34
  end
40
35
 
41
36
  def unsuppress(customer_id)
42
- verify_response(request(:post, unsuppress_path(customer_id)))
37
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
38
+ @client.request_and_verify_response(:post, unsuppress_path(customer_id))
43
39
  end
44
40
 
45
- def track(*args)
46
- attributes = extract_attributes(args)
47
-
48
- if args.length == 1
49
- # Only passed in an event name, create an anonymous event
50
- event_name = args.first
51
- create_anonymous_event(event_name, attributes)
52
- else
53
- # Passed in a customer id and an event name.
54
- # Track the event for the given customer
55
- customer_id, event_name = args
41
+ def track(customer_id, event_name, attributes = {})
42
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
43
+ raise ParamError.new("event_name must be a non-empty string") if is_empty?(event_name)
56
44
 
57
- create_customer_event(customer_id, event_name, attributes)
58
- end
45
+ create_customer_event(customer_id, event_name, attributes)
59
46
  end
60
47
 
61
- def anonymous_track(event_name, attributes = {})
62
- create_anonymous_event(event_name, attributes)
48
+ def track_anonymous(anonymous_id, event_name, attributes = {})
49
+ raise ParamError.new("anonymous_id must be a non-empty string") if is_empty?(anonymous_id)
50
+ raise ParamError.new("event_name must be a non-empty string") if is_empty?(event_name)
51
+
52
+ create_anonymous_event(anonymous_id, event_name, attributes)
63
53
  end
64
54
 
65
55
  def add_device(customer_id, device_id, platform, data={})
66
- raise ParamError.new("customer_id must be a non-empty string") unless customer_id != "" and !customer_id.nil?
67
- raise ParamError.new("device_id must be a non-empty string") unless device_id != "" and !device_id.nil?
68
- raise ParamError.new("platform must be a non-empty string") unless platform != "" and !platform.nil?
56
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
57
+ raise ParamError.new("device_id must be a non-empty string") if is_empty?(device_id)
58
+ raise ParamError.new("platform must be a non-empty string") if is_empty?(platform)
69
59
 
70
60
  if data.nil?
71
61
  data = {}
@@ -73,125 +63,104 @@ module Customerio
73
63
 
74
64
  raise ParamError.new("data parameter must be a hash") unless data.is_a?(Hash)
75
65
 
76
- verify_response(request(:put, device_path(customer_id), {
66
+ @client.request_and_verify_response(:put, device_path(customer_id), {
77
67
  :device => data.update({
78
68
  :id => device_id,
79
69
  :platform => platform,
80
70
  })
81
- }))
71
+ })
82
72
  end
83
73
 
84
74
  def delete_device(customer_id, device_id)
85
- raise ParamError.new("customer_id must be a non-empty string") unless customer_id != "" and !customer_id.nil?
86
- raise ParamError.new("device_id must be a non-empty string") unless device_id != "" and !device_id.nil?
87
-
88
- verify_response(request(:delete, device_id_path(customer_id, device_id)))
89
- end
90
-
91
- private
75
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
76
+ raise ParamError.new("device_id must be a non-empty string") if is_empty?(device_id)
92
77
 
93
- def device_path(customer_id)
94
- "/api/v1/customers/#{customer_id}/devices"
95
- end
96
-
97
- def device_id_path(customer_id, device_id)
98
- "/api/v1/customers/#{customer_id}/devices/#{device_id}"
78
+ @client.request_and_verify_response(:delete, device_id_path(customer_id, device_id))
99
79
  end
100
80
 
101
- def create_or_update(attributes = {})
102
- attributes = Hash[attributes.map { |(k,v)| [ k.to_sym, v ] }]
81
+ def track_push_notification_event(event_name, attributes = {})
82
+ keys = [:delivery_id, :device_id, :timestamp]
83
+ attributes = Hash[attributes.map { |(k,v)| [ k.to_sym, v ] }].
84
+ select { |k, v| keys.include?(k) }
103
85
 
104
- raise MissingIdAttributeError.new("Must provide a customer id") unless attributes[:id]
86
+ raise ParamError.new('event_name must be one of opened, converted, or delivered') unless VALID_PUSH_EVENTS.include?(event_name)
87
+ raise ParamError.new('delivery_id must be a non-empty string') unless attributes[:delivery_id] != "" and !attributes[:delivery_id].nil?
88
+ raise ParamError.new('device_id must be a non-empty string') unless attributes[:device_id] != "" and !attributes[:device_id].nil?
89
+ raise ParamError.new('timestamp must be a valid timestamp') unless valid_timestamp?(attributes[:timestamp])
105
90
 
106
- url = customer_path(attributes[:id])
107
-
108
- verify_response(request(:put, url, attributes))
91
+ @client.request_and_verify_response(:post, track_push_notification_event_path, attributes.merge(event: event_name))
109
92
  end
110
93
 
111
- def create_customer_event(customer_id, event_name, attributes = {})
112
- create_event("#{customer_path(customer_id)}/events", event_name, attributes)
94
+ private
95
+
96
+ def escape(val)
97
+ # CGI.escape is recommended for escaping, however, it doesn't correctly escape spaces.
98
+ Addressable::URI.encode_component(val.to_s, Addressable::URI::CharacterClasses::UNRESERVED)
113
99
  end
114
100
 
115
- def create_anonymous_event(event_name, attributes = {})
116
- create_event("/api/v1/events", event_name, attributes)
101
+ def device_path(customer_id)
102
+ "/api/v1/customers/#{escape(customer_id)}/devices"
117
103
  end
118
104
 
119
- def create_event(url, event_name, attributes = {})
120
- body = { :name => event_name, :data => attributes }
121
- body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp])
122
- verify_response(request(:post, url, body))
105
+ def device_id_path(customer_id, device_id)
106
+ "/api/v1/customers/#{escape(customer_id)}/devices/#{escape(device_id)}"
123
107
  end
124
108
 
125
109
  def customer_path(id)
126
- "/api/v1/customers/#{id}"
110
+ "/api/v1/customers/#{escape(id)}"
127
111
  end
128
112
 
129
113
  def suppress_path(customer_id)
130
- "/api/v1/customers/#{customer_id}/suppress"
114
+ "/api/v1/customers/#{escape(customer_id)}/suppress"
131
115
  end
132
116
 
133
117
  def unsuppress_path(customer_id)
134
- "/api/v1/customers/#{customer_id}/unsuppress"
118
+ "/api/v1/customers/#{escape(customer_id)}/unsuppress"
135
119
  end
136
120
 
137
- def valid_timestamp?(timestamp)
138
- timestamp && timestamp.is_a?(Integer) && timestamp > 999999999 && timestamp < 100000000000
121
+ def track_push_notification_event_path
122
+ "/push/events"
139
123
  end
140
124
 
125
+ def create_or_update(attributes = {})
126
+ attributes = Hash[attributes.map { |(k,v)| [ k.to_sym, v ] }]
127
+ raise MissingIdAttributeError.new("Must provide a customer id") if is_empty?(attributes[:id])
141
128
 
142
- def verify_response(response)
143
- if response.code.to_i >= 200 && response.code.to_i < 300
144
- response
145
- else
146
- raise InvalidResponse.new("Customer.io API returned an invalid response: #{response.code}", response)
147
- end
129
+ url = customer_path(attributes[:id])
130
+ @client.request_and_verify_response(:put, url, attributes)
148
131
  end
149
132
 
150
- def extract_attributes(args)
151
- hash = args.last.is_a?(Hash) ? args.pop : {}
152
- hash.inject({}){ |hash, (k,v)| hash[k.to_sym] = v; hash }
133
+ def create_customer_event(customer_id, event_name, attributes = {})
134
+ create_event(
135
+ url: "#{customer_path(customer_id)}/events",
136
+ event_name: event_name,
137
+ attributes: attributes
138
+ )
153
139
  end
154
140
 
155
- def request(method, path, body = nil, headers = {})
156
- uri = URI.join(@base_uri, path)
157
-
158
- session = Net::HTTP.new(uri.host, uri.port)
159
- session.use_ssl = (uri.scheme == 'https')
160
- session.open_timeout = @timeout
161
- session.read_timeout = @timeout
162
-
163
- req = request_class(method).new(uri.path)
164
- req.initialize_http_header(headers)
165
- req.basic_auth @username, @password
141
+ def create_anonymous_event(anonymous_id, event_name, attributes = {})
142
+ create_event(
143
+ url: "/api/v1/events",
144
+ event_name: event_name,
145
+ anonymous_id: anonymous_id,
146
+ attributes: attributes
147
+ )
148
+ end
166
149
 
167
- add_request_body(req, body) unless body.nil?
150
+ def create_event(url:, event_name:, anonymous_id: nil, attributes: {})
151
+ body = { :name => event_name, :data => attributes }
152
+ body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp])
153
+ body[:anonymous_id] = anonymous_id unless anonymous_id.nil?
168
154
 
169
- session.start do |http|
170
- http.request(req)
171
- end
155
+ @client.request_and_verify_response(:post, url, body)
172
156
  end
173
157
 
174
- def request_class(method)
175
- case method
176
- when :post
177
- Net::HTTP::Post
178
- when :put
179
- Net::HTTP::Put
180
- when :delete
181
- Net::HTTP::Delete
182
- else
183
- raise InvalidRequest.new("Invalid request method #{method.inspect}")
184
- end
158
+ def valid_timestamp?(timestamp)
159
+ timestamp && timestamp.is_a?(Integer) && timestamp > 999999999 && timestamp < 100000000000
185
160
  end
186
161
 
187
- def add_request_body(req, body)
188
- if @json
189
- req.add_field('Content-Type', 'application/json')
190
- req.body = MultiJson.dump(body)
191
- else
192
- req.add_field('Content-Type', 'application/x-www-form-urlencoded')
193
- req.body = ParamEncoder.to_params(body)
194
- end
162
+ def is_empty?(val)
163
+ val.nil? || (val.is_a?(String) && val.strip == "")
195
164
  end
196
165
  end
197
166
  end
@@ -0,0 +1,11 @@
1
+ require 'net/http'
2
+ require 'multi_json'
3
+
4
+ module Customerio
5
+ module Regions
6
+ Region = Struct.new(:track_url, :api_url)
7
+
8
+ US = Customerio::Regions::Region.new('https://track.customer.io', 'https://api.customer.io').freeze
9
+ EU = Customerio::Regions::Region.new('https://track-eu.customer.io', 'https://api-eu.customer.io').freeze
10
+ end
11
+ end
@@ -0,0 +1,49 @@
1
+ require 'base64'
2
+
3
+ module Customerio
4
+ class SendEmailRequest
5
+ attr_reader :message
6
+
7
+ def initialize(opts)
8
+ @message = opts.delete_if { |field| invalid_field?(field) }
9
+ @message[:attachments] = {}
10
+ @message[:headers] = {}
11
+ end
12
+
13
+ def attach(name, data, encode: true)
14
+ raise "attachment #{name} already exists" if @message[:attachments].has_key?(name)
15
+ @message[:attachments][name] = encode ? Base64.strict_encode64(data) : data
16
+ end
17
+
18
+ private
19
+
20
+ REQUIRED_FIELDS = %i(to identifiers)
21
+
22
+ OPTIONAL_FIELDS = %i(
23
+ transactional_message_id
24
+ message_data
25
+ headers
26
+ preheader
27
+ from
28
+ reply_to
29
+ bcc
30
+ subject
31
+ body
32
+ plaintext_body
33
+ amp_body
34
+ fake_bcc
35
+ disable_message_retention
36
+ send_to_unsubscribed
37
+ tracked
38
+ queue_draft
39
+ )
40
+
41
+ def invalid_field?(field)
42
+ !REQUIRED_FIELDS.include?(field) && !OPTIONAL_FIELDS.include?(field)
43
+ end
44
+
45
+ def encode(data)
46
+ Base64.strict_encode64(data)
47
+ end
48
+ end
49
+ end
@@ -1,3 +1,3 @@
1
1
  module Customerio
2
- VERSION = "2.1.0"
2
+ VERSION = "4.0.0"
3
3
  end
@@ -0,0 +1,172 @@
1
+ require 'spec_helper'
2
+ require 'multi_json'
3
+ require 'base64'
4
+ require 'tempfile'
5
+
6
+ describe Customerio::APIClient do
7
+ let(:app_key) { "appkey" }
8
+
9
+ let(:client) { Customerio::APIClient.new(app_key) }
10
+ let(:response) { double("Response", code: 200) }
11
+
12
+ def api_uri(path)
13
+ "https://api.customer.io#{path}"
14
+ end
15
+
16
+ def request_headers
17
+ { 'Authorization': "Bearer #{app_key}", 'Content-Type': 'application/json' }
18
+ end
19
+
20
+ def json(data)
21
+ MultiJson.dump(data)
22
+ end
23
+
24
+ it "the base client is initialised with the correct values when no region is passed in" do
25
+ app_key = "appkey"
26
+
27
+ expect(Customerio::BaseClient).to(
28
+ receive(:new)
29
+ .with(
30
+ { app_key: app_key },
31
+ {
32
+ region: Customerio::Regions::US,
33
+ url: Customerio::Regions::US.api_url
34
+ }
35
+ )
36
+ )
37
+
38
+ client = Customerio::APIClient.new(app_key)
39
+ end
40
+
41
+ it "raises an error when an incorrect region is passed in" do
42
+ expect {
43
+ Customerio::APIClient.new("appkey", region: :au)
44
+ }.to raise_error /region must be an instance of Customerio::Regions::Region/
45
+ end
46
+
47
+ [Customerio::Regions::US, Customerio::Regions::EU].each do |region|
48
+ it "the base client is initialised with the correct values when the region \"#{region}\" is passed in" do
49
+ app_key = "appkey"
50
+
51
+ expect(Customerio::BaseClient).to(
52
+ receive(:new)
53
+ .with(
54
+ { app_key: app_key },
55
+ {
56
+ region: region,
57
+ url: region.api_url
58
+ }
59
+ )
60
+ )
61
+
62
+ client = Customerio::APIClient.new(app_key, { region: region })
63
+ end
64
+ end
65
+
66
+ describe "#send_email" do
67
+ it "sends a POST request to the /api/send/email path" do
68
+ req = Customerio::SendEmailRequest.new(
69
+ identifiers: {
70
+ id: 'c1',
71
+ },
72
+ transactional_message_id: 1,
73
+ )
74
+
75
+ stub_request(:post, api_uri('/v1/send/email'))
76
+ .with(headers: request_headers, body: req.message)
77
+ .to_return(status: 200, body: { delivery_id: 1 }.to_json, headers: {})
78
+
79
+ client.send_email(req).should eq({ "delivery_id" => 1 })
80
+ end
81
+
82
+ it "handles validation failures (400)" do
83
+ req = Customerio::SendEmailRequest.new(
84
+ identifiers: {
85
+ id: 'c1',
86
+ },
87
+ transactional_message_id: 1,
88
+ )
89
+
90
+ err_json = { meta: { error: "example error" } }.to_json
91
+
92
+ stub_request(:post, api_uri('/v1/send/email'))
93
+ .with(headers: request_headers, body: req.message)
94
+ .to_return(status: 400, body: err_json, headers: {})
95
+
96
+ lambda { client.send_email(req) }.should(
97
+ raise_error(Customerio::InvalidResponse) { |error|
98
+ error.message.should eq "example error"
99
+ error.code.should eq "400"
100
+ }
101
+ )
102
+ end
103
+
104
+ it "handles other failures (5xx)" do
105
+ req = Customerio::SendEmailRequest.new(
106
+ identifiers: {
107
+ id: 'c1',
108
+ },
109
+ transactional_message_id: 1,
110
+ )
111
+
112
+ stub_request(:post, api_uri('/v1/send/email'))
113
+ .with(headers: request_headers, body: req.message)
114
+ .to_return(status: 500, body: "Server unavailable", headers: {})
115
+
116
+ lambda { client.send_email(req) }.should(
117
+ raise_error(Customerio::InvalidResponse) { |error|
118
+ error.message.should eq "Server unavailable"
119
+ error.code.should eq "500"
120
+ }
121
+ )
122
+ end
123
+
124
+ it "allows attaching file content without encoding" do
125
+ content = 'sample content'
126
+
127
+ req = Customerio::SendEmailRequest.new(
128
+ customer_id: 'c1',
129
+ transactional_message_id: 1,
130
+ )
131
+
132
+ req.attach('test', content, encode: false)
133
+ req.message[:attachments]['test'].should eq content
134
+
135
+ stub_request(:post, api_uri('/v1/send/email'))
136
+ .with(headers: request_headers, body: req.message)
137
+ .to_return(status: 200, body: { delivery_id: 1 }.to_json, headers: {})
138
+
139
+ client.send_email(req)
140
+ end
141
+
142
+ it "allows attaching files with encoding (default)" do
143
+ content = 'sample content'
144
+
145
+ req = Customerio::SendEmailRequest.new(
146
+ customer_id: 'c1',
147
+ transactional_message_id: 1,
148
+ )
149
+
150
+ req.attach('test', content)
151
+ req.message[:attachments]['test'].should eq Base64.strict_encode64(content)
152
+
153
+ stub_request(:post, api_uri('/v1/send/email'))
154
+ .with(headers: request_headers, body: req.message)
155
+ .to_return(status: 200, body: { delivery_id: 1 }.to_json, headers: {})
156
+
157
+ client.send_email(req)
158
+ end
159
+
160
+ it "raises error when attaching the same key again" do
161
+ req = Customerio::SendEmailRequest.new(
162
+ customer_id: 'c1',
163
+ transactional_message_id: 1,
164
+ )
165
+
166
+ req.attach('test', 'test-content')
167
+
168
+ lambda { req.attach('test', '') }.should raise_error(/attachment test already exists/)
169
+ req.message[:attachments].should eq({ "test" => Base64.strict_encode64("test-content") })
170
+ end
171
+ end
172
+ end