customerio 2.0.0 → 3.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.
@@ -0,0 +1,87 @@
1
+ require 'net/http'
2
+ require 'multi_json'
3
+
4
+ module Customerio
5
+ DEFAULT_TIMEOUT = 10
6
+
7
+ class InvalidRequest < RuntimeError; end
8
+ class InvalidResponse < RuntimeError
9
+ attr_reader :code, :response
10
+
11
+ def initialize(code, body, response=nil)
12
+ @message = body
13
+ @code = code
14
+ @response = response
15
+
16
+ super(@message)
17
+ end
18
+ end
19
+
20
+ class BaseClient
21
+ def initialize(auth, options = {})
22
+ @auth = auth
23
+ @timeout = options[:timeout] || DEFAULT_TIMEOUT
24
+ @base_uri = options[:url]
25
+ end
26
+
27
+ def request(method, path, body = nil, headers = {})
28
+ execute(method, path, body, headers)
29
+ end
30
+
31
+ def request_and_verify_response(method, path, body = nil, headers = {})
32
+ verify_response(request(method, path, body, headers))
33
+ end
34
+
35
+ private
36
+
37
+ def execute(method, path, body = nil, headers = {})
38
+ uri = URI.join(@base_uri, path)
39
+
40
+ session = Net::HTTP.new(uri.host, uri.port)
41
+ session.use_ssl = (uri.scheme == 'https')
42
+ session.open_timeout = @timeout
43
+ session.read_timeout = @timeout
44
+
45
+ req = request_class(method).new(uri.path)
46
+
47
+ if @auth.has_key?(:site_id) && @auth.has_key?(:api_key)
48
+ req.initialize_http_header(headers)
49
+ req.basic_auth @auth[:site_id], @auth[:api_key]
50
+ else
51
+ headers['Authorization'] = "Bearer #{@auth[:app_key]}"
52
+ req.initialize_http_header(headers)
53
+ end
54
+
55
+ if !body.nil?
56
+ req.add_field('Content-Type', 'application/json')
57
+ req.body = MultiJson.dump(body)
58
+ end
59
+
60
+ session.start do |http|
61
+ http.request(req)
62
+ end
63
+ end
64
+
65
+ def request_class(method)
66
+ case method
67
+ when :post
68
+ Net::HTTP::Post
69
+ when :put
70
+ Net::HTTP::Put
71
+ when :delete
72
+ Net::HTTP::Delete
73
+ else
74
+ raise InvalidRequest.new("Invalid request method #{method.inspect}")
75
+ end
76
+ end
77
+
78
+ def verify_response(response)
79
+ case response
80
+ when Net::HTTPSuccess then
81
+ response
82
+ else
83
+ raise InvalidResponse.new(response.code, response.body, response)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,29 +1,16 @@
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
9
5
  class MissingIdAttributeError < RuntimeError; end
10
6
  class ParamError < RuntimeError; end
11
- class InvalidRequest < RuntimeError; end
12
- class InvalidResponse < RuntimeError
13
- attr_reader :response
14
7
 
15
- def initialize(message, response)
16
- @message = message
17
- @response = response
18
- end
19
- end
8
+ def initialize(site_id, api_key, options = {})
9
+ options[:region] = Customerio::Regions::US if options[:region].nil?
10
+ raise "region must be an instance of Customerio::Regions::Region" unless options[:region].is_a?(Customerio::Regions::Region)
20
11
 
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
12
+ options[:url] = options[:region].track_url if options[:url].nil? || options[:url].empty?
13
+ @client = Customerio::BaseClient.new({ site_id: site_id, api_key: api_key }, options)
27
14
  end
28
15
 
29
16
  def identify(attributes)
@@ -31,33 +18,36 @@ module Customerio
31
18
  end
32
19
 
33
20
  def delete(customer_id)
34
- verify_response(request(:delete, customer_path(customer_id)))
21
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
22
+ @client.request_and_verify_response(:delete, customer_path(customer_id))
23
+ end
24
+
25
+ def suppress(customer_id)
26
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
27
+ @client.request_and_verify_response(:post, suppress_path(customer_id))
35
28
  end
36
29
 
37
- def track(*args)
38
- attributes = extract_attributes(args)
30
+ def unsuppress(customer_id)
31
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
32
+ @client.request_and_verify_response(:post, unsuppress_path(customer_id))
33
+ end
39
34
 
40
- if args.length == 1
41
- # Only passed in an event name, create an anonymous event
42
- event_name = args.first
43
- create_anonymous_event(event_name, attributes)
44
- else
45
- # Passed in a customer id and an event name.
46
- # Track the event for the given customer
47
- customer_id, event_name = args
35
+ def track(customer_id, event_name, attributes = {})
36
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
37
+ raise ParamError.new("event_name must be a non-empty string") if is_empty?(event_name)
48
38
 
49
- create_customer_event(customer_id, event_name, attributes)
50
- end
39
+ create_customer_event(customer_id, event_name, attributes)
51
40
  end
52
41
 
53
42
  def anonymous_track(event_name, attributes = {})
43
+ raise ParamError.new("event_name must be a non-empty string") if is_empty?(event_name)
54
44
  create_anonymous_event(event_name, attributes)
55
45
  end
56
46
 
57
47
  def add_device(customer_id, device_id, platform, data={})
58
- raise ParamError.new("customer_id must be a non-empty string") unless customer_id != "" and !customer_id.nil?
59
- raise ParamError.new("device_id must be a non-empty string") unless device_id != "" and !device_id.nil?
60
- raise ParamError.new("platform must be a non-empty string") unless platform != "" and !platform.nil?
48
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
49
+ raise ParamError.new("device_id must be a non-empty string") if is_empty?(device_id)
50
+ raise ParamError.new("platform must be a non-empty string") if is_empty?(platform)
61
51
 
62
52
  if data.nil?
63
53
  data = {}
@@ -65,39 +55,54 @@ module Customerio
65
55
 
66
56
  raise ParamError.new("data parameter must be a hash") unless data.is_a?(Hash)
67
57
 
68
- verify_response(request(:put, device_path(customer_id), {
58
+ @client.request_and_verify_response(:put, device_path(customer_id), {
69
59
  :device => data.update({
70
60
  :id => device_id,
71
61
  :platform => platform,
72
62
  })
73
- }))
63
+ })
74
64
  end
75
65
 
76
66
  def delete_device(customer_id, device_id)
77
- raise ParamError.new("customer_id must be a non-empty string") unless customer_id != "" and !customer_id.nil?
78
- raise ParamError.new("device_id must be a non-empty string") unless device_id != "" and !device_id.nil?
79
-
80
- verify_response(request(:delete, device_id_path(customer_id, device_id)))
67
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
68
+ raise ParamError.new("device_id must be a non-empty string") if is_empty?(device_id)
69
+
70
+ @client.request_and_verify_response(:delete, device_id_path(customer_id, device_id))
81
71
  end
82
72
 
83
73
  private
84
74
 
75
+ def escape(val)
76
+ # CGI.escape is recommended for escaping, however, it doesn't correctly escape spaces.
77
+ Addressable::URI.encode_component(val.to_s, Addressable::URI::CharacterClasses::UNRESERVED)
78
+ end
79
+
85
80
  def device_path(customer_id)
86
- "/api/v1/customers/#{customer_id}/devices"
81
+ "/api/v1/customers/#{escape(customer_id)}/devices"
87
82
  end
88
83
 
89
84
  def device_id_path(customer_id, device_id)
90
- "/api/v1/customers/#{customer_id}/devices/#{device_id}"
85
+ "/api/v1/customers/#{escape(customer_id)}/devices/#{escape(device_id)}"
86
+ end
87
+
88
+ def customer_path(id)
89
+ "/api/v1/customers/#{escape(id)}"
90
+ end
91
+
92
+ def suppress_path(customer_id)
93
+ "/api/v1/customers/#{escape(customer_id)}/suppress"
94
+ end
95
+
96
+ def unsuppress_path(customer_id)
97
+ "/api/v1/customers/#{escape(customer_id)}/unsuppress"
91
98
  end
92
99
 
93
100
  def create_or_update(attributes = {})
94
101
  attributes = Hash[attributes.map { |(k,v)| [ k.to_sym, v ] }]
95
-
96
- raise MissingIdAttributeError.new("Must provide a customer id") unless attributes[:id]
102
+ raise MissingIdAttributeError.new("Must provide a customer id") if is_empty?(attributes[:id])
97
103
 
98
104
  url = customer_path(attributes[:id])
99
-
100
- verify_response(request(:put, url, attributes))
105
+ @client.request_and_verify_response(:put, url, attributes)
101
106
  end
102
107
 
103
108
  def create_customer_event(customer_id, event_name, attributes = {})
@@ -111,71 +116,15 @@ module Customerio
111
116
  def create_event(url, event_name, attributes = {})
112
117
  body = { :name => event_name, :data => attributes }
113
118
  body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp])
114
- verify_response(request(:post, url, body))
115
- end
116
-
117
- def customer_path(id)
118
- "/api/v1/customers/#{id}"
119
+ @client.request_and_verify_response(:post, url, body)
119
120
  end
120
121
 
121
122
  def valid_timestamp?(timestamp)
122
123
  timestamp && timestamp.is_a?(Integer) && timestamp > 999999999 && timestamp < 100000000000
123
124
  end
124
125
 
125
-
126
- def verify_response(response)
127
- if response.code.to_i >= 200 && response.code.to_i < 300
128
- response
129
- else
130
- raise InvalidResponse.new("Customer.io API returned an invalid response: #{response.code}", response)
131
- end
132
- end
133
-
134
- def extract_attributes(args)
135
- hash = args.last.is_a?(Hash) ? args.pop : {}
136
- hash.inject({}){ |hash, (k,v)| hash[k.to_sym] = v; hash }
137
- end
138
-
139
- def request(method, path, body = nil, headers = {})
140
- uri = URI.join(@base_uri, path)
141
-
142
- session = Net::HTTP.new(uri.host, uri.port)
143
- session.use_ssl = (uri.scheme == 'https')
144
- session.open_timeout = @timeout
145
- session.read_timeout = @timeout
146
-
147
- req = request_class(method).new(uri.path)
148
- req.initialize_http_header(headers)
149
- req.basic_auth @username, @password
150
-
151
- add_request_body(req, body) unless body.nil?
152
-
153
- session.start do |http|
154
- http.request(req)
155
- end
156
- end
157
-
158
- def request_class(method)
159
- case method
160
- when :post
161
- Net::HTTP::Post
162
- when :put
163
- Net::HTTP::Put
164
- when :delete
165
- Net::HTTP::Delete
166
- else
167
- raise InvalidRequest.new("Invalid request method #{method.inspect}")
168
- end
169
- end
170
-
171
- def add_request_body(req, body)
172
- if @json
173
- req.add_field('Content-Type', 'application/json')
174
- req.body = MultiJson.dump(body)
175
- else
176
- req.add_field('Content-Type', 'application/x-www-form-urlencoded')
177
- req.body = ParamEncoder.to_params(body)
178
- end
126
+ def is_empty?(val)
127
+ val.nil? || (val.is_a?(String) && val.strip == "")
179
128
  end
180
129
  end
181
130
  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.0.0"
2
+ VERSION = "3.1.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