customerio 2.0.0 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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