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.
- checksums.yaml +5 -5
- data/.github/workflows/main.yml +30 -0
- data/CHANGELOG.markdown +103 -50
- data/README.md +77 -11
- data/customerio.gemspec +3 -2
- data/lib/customerio.rb +4 -0
- data/lib/customerio/api.rb +35 -0
- data/lib/customerio/base_client.rb +87 -0
- data/lib/customerio/client.rb +86 -117
- data/lib/customerio/regions.rb +11 -0
- data/lib/customerio/requests/send_email_request.rb +49 -0
- data/lib/customerio/version.rb +1 -1
- data/spec/api_client_spec.rb +172 -0
- data/spec/base_client_spec.rb +67 -0
- data/spec/client_spec.rb +357 -201
- data/spec/spec_helper.rb +2 -2
- metadata +33 -25
- data/.travis.yml +0 -9
data/lib/customerio/client.rb
CHANGED
@@ -1,29 +1,22 @@
|
|
1
|
-
require
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
22
|
-
@
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
46
|
-
|
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
|
-
|
58
|
-
end
|
45
|
+
create_customer_event(customer_id, event_name, attributes)
|
59
46
|
end
|
60
47
|
|
61
|
-
def
|
62
|
-
|
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")
|
67
|
-
raise ParamError.new("device_id must be a non-empty string")
|
68
|
-
raise ParamError.new("platform must be a non-empty string")
|
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
|
-
|
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")
|
86
|
-
raise ParamError.new("device_id must be a non-empty string")
|
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
|
-
|
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
|
102
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
112
|
-
|
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
|
116
|
-
|
101
|
+
def device_path(customer_id)
|
102
|
+
"/api/v1/customers/#{escape(customer_id)}/devices"
|
117
103
|
end
|
118
104
|
|
119
|
-
def
|
120
|
-
|
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
|
138
|
-
|
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
|
-
|
143
|
-
|
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
|
151
|
-
|
152
|
-
|
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
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
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
|
-
|
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
|
-
|
170
|
-
http.request(req)
|
171
|
-
end
|
155
|
+
@client.request_and_verify_response(:post, url, body)
|
172
156
|
end
|
173
157
|
|
174
|
-
def
|
175
|
-
|
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
|
188
|
-
|
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
|
data/lib/customerio/version.rb
CHANGED
@@ -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
|