customerio 2.1.0 → 4.0.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.
@@ -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