customerio 1.0.0 → 3.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.
@@ -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,28 +1,15 @@
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
- class MissingIdAttributeError < RuntimeError; end
10
- class InvalidRequest < RuntimeError; end
11
- class InvalidResponse < RuntimeError
12
- attr_reader :response
5
+ DEFAULT_TRACK_URL = 'https://track.customer.io'
13
6
 
14
- def initialize(message, response)
15
- @message = message
16
- @response = response
17
- end
18
- end
7
+ class MissingIdAttributeError < RuntimeError; end
8
+ class ParamError < RuntimeError; end
19
9
 
20
- def initialize(site_id, secret_key, options = {})
21
- @username = site_id
22
- @password = secret_key
23
- @json = options.has_key?(:json) ? options[:json] : true
24
- @base_uri = options[:base_uri] || DEFAULT_BASE_URI
25
- @timeout = options[:timeout] || DEFAULT_TIMEOUT
10
+ def initialize(site_id, api_key, options = {})
11
+ options[:url] = DEFAULT_TRACK_URL if options[:url].nil? || options[:url].empty?
12
+ @client = Customerio::BaseClient.new({ site_id: site_id, api_key: api_key }, options)
26
13
  end
27
14
 
28
15
  def identify(attributes)
@@ -30,117 +17,113 @@ module Customerio
30
17
  end
31
18
 
32
19
  def delete(customer_id)
33
- verify_response(request(:delete, customer_path(customer_id)))
20
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
21
+ @client.request_and_verify_response(:delete, customer_path(customer_id))
34
22
  end
35
23
 
36
- def track(*args)
37
- attributes = extract_attributes(args)
24
+ def suppress(customer_id)
25
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
26
+ @client.request_and_verify_response(:post, suppress_path(customer_id))
27
+ end
38
28
 
39
- if args.length == 1
40
- # Only passed in an event name, create an anonymous event
41
- event_name = args.first
42
- create_anonymous_event(event_name, attributes)
43
- else
44
- # Passed in a customer id and an event name.
45
- # Track the event for the given customer
46
- customer_id, event_name = args
29
+ def unsuppress(customer_id)
30
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
31
+ @client.request_and_verify_response(:post, unsuppress_path(customer_id))
32
+ end
47
33
 
48
- create_customer_event(customer_id, event_name, attributes)
49
- end
34
+ def track(customer_id, event_name, attributes = {})
35
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
36
+ raise ParamError.new("event_name must be a non-empty string") if is_empty?(event_name)
37
+
38
+ create_customer_event(customer_id, event_name, attributes)
50
39
  end
51
40
 
52
41
  def anonymous_track(event_name, attributes = {})
42
+ raise ParamError.new("event_name must be a non-empty string") if is_empty?(event_name)
53
43
  create_anonymous_event(event_name, attributes)
54
44
  end
55
45
 
56
- private
57
-
58
- def create_or_update(attributes = {})
59
- attributes = Hash[attributes.map { |(k,v)| [ k.to_sym, v ] }]
46
+ def add_device(customer_id, device_id, platform, data={})
47
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
48
+ raise ParamError.new("device_id must be a non-empty string") if is_empty?(device_id)
49
+ raise ParamError.new("platform must be a non-empty string") if is_empty?(platform)
60
50
 
61
- raise MissingIdAttributeError.new("Must provide a customer id") unless attributes[:id]
51
+ if data.nil?
52
+ data = {}
53
+ end
62
54
 
63
- url = customer_path(attributes[:id])
55
+ raise ParamError.new("data parameter must be a hash") unless data.is_a?(Hash)
64
56
 
65
- verify_response(request(:put, url, attributes))
57
+ @client.request_and_verify_response(:put, device_path(customer_id), {
58
+ :device => data.update({
59
+ :id => device_id,
60
+ :platform => platform,
61
+ })
62
+ })
66
63
  end
67
64
 
68
- def create_customer_event(customer_id, event_name, attributes = {})
69
- create_event("#{customer_path(customer_id)}/events", event_name, attributes)
70
- end
65
+ def delete_device(customer_id, device_id)
66
+ raise ParamError.new("customer_id must be a non-empty string") if is_empty?(customer_id)
67
+ raise ParamError.new("device_id must be a non-empty string") if is_empty?(device_id)
71
68
 
72
- def create_anonymous_event(event_name, attributes = {})
73
- create_event("/api/v1/events", event_name, attributes)
69
+ @client.request_and_verify_response(:delete, device_id_path(customer_id, device_id))
74
70
  end
75
71
 
76
- def create_event(url, event_name, attributes = {})
77
- body = { :name => event_name, :data => attributes }
78
- body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp])
79
- verify_response(request(:post, url, body))
72
+ private
73
+
74
+ def escape(val)
75
+ # CGI.escape is recommended for escaping, however, it doesn't correctly escape spaces.
76
+ Addressable::URI.encode_component(val.to_s, Addressable::URI::CharacterClasses::UNRESERVED)
80
77
  end
81
78
 
82
- def customer_path(id)
83
- "/api/v1/customers/#{id}"
79
+ def device_path(customer_id)
80
+ "/api/v1/customers/#{escape(customer_id)}/devices"
84
81
  end
85
82
 
86
- def valid_timestamp?(timestamp)
87
- timestamp && timestamp.is_a?(Integer) && timestamp > 999999999 && timestamp < 100000000000
83
+ def device_id_path(customer_id, device_id)
84
+ "/api/v1/customers/#{escape(customer_id)}/devices/#{escape(device_id)}"
88
85
  end
89
86
 
87
+ def customer_path(id)
88
+ "/api/v1/customers/#{escape(id)}"
89
+ end
90
90
 
91
- def verify_response(response)
92
- if response.code.to_i >= 200 && response.code.to_i < 300
93
- response
94
- else
95
- raise InvalidResponse.new("Customer.io API returned an invalid response: #{response.code}", response)
96
- end
91
+ def suppress_path(customer_id)
92
+ "/api/v1/customers/#{escape(customer_id)}/suppress"
97
93
  end
98
94
 
99
- def extract_attributes(args)
100
- hash = args.last.is_a?(Hash) ? args.pop : {}
101
- hash.inject({}){ |hash, (k,v)| hash[k.to_sym] = v; hash }
95
+ def unsuppress_path(customer_id)
96
+ "/api/v1/customers/#{escape(customer_id)}/unsuppress"
102
97
  end
103
98
 
104
- def request(method, path, body = nil, headers = {})
105
- uri = URI.join(@base_uri, path)
99
+ def create_or_update(attributes = {})
100
+ attributes = Hash[attributes.map { |(k,v)| [ k.to_sym, v ] }]
101
+ raise MissingIdAttributeError.new("Must provide a customer id") if is_empty?(attributes[:id])
106
102
 
107
- session = Net::HTTP.new(uri.host, uri.port)
108
- session.use_ssl = (uri.scheme == 'https')
109
- session.open_timeout = @timeout
110
- session.read_timeout = @timeout
103
+ url = customer_path(attributes[:id])
104
+ @client.request_and_verify_response(:put, url, attributes)
105
+ end
111
106
 
112
- req = request_class(method).new(uri.path)
113
- req.initialize_http_header(headers)
114
- req.basic_auth @username, @password
107
+ def create_customer_event(customer_id, event_name, attributes = {})
108
+ create_event("#{customer_path(customer_id)}/events", event_name, attributes)
109
+ end
115
110
 
116
- add_request_body(req, body) unless body.nil?
111
+ def create_anonymous_event(event_name, attributes = {})
112
+ create_event("/api/v1/events", event_name, attributes)
113
+ end
117
114
 
118
- session.start do |http|
119
- http.request(req)
120
- end
115
+ def create_event(url, event_name, attributes = {})
116
+ body = { :name => event_name, :data => attributes }
117
+ body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp])
118
+ @client.request_and_verify_response(:post, url, body)
121
119
  end
122
120
 
123
- def request_class(method)
124
- case method
125
- when :post
126
- Net::HTTP::Post
127
- when :put
128
- Net::HTTP::Put
129
- when :delete
130
- Net::HTTP::Delete
131
- else
132
- raise InvalidRequest.new("Invalid request method #{method.inspect}")
133
- end
121
+ def valid_timestamp?(timestamp)
122
+ timestamp && timestamp.is_a?(Integer) && timestamp > 999999999 && timestamp < 100000000000
134
123
  end
135
124
 
136
- def add_request_body(req, body)
137
- if @json
138
- req.add_field('Content-Type', 'application/json')
139
- req.body = MultiJson.dump(body)
140
- else
141
- req.add_field('Content-Type', 'application/x-www-form-urlencoded')
142
- req.body = ParamEncoder.to_params(body)
143
- end
125
+ def is_empty?(val)
126
+ val.nil? || (val.is_a?(String) && val.strip == "")
144
127
  end
145
128
  end
146
129
  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 = "1.0.0"
2
+ VERSION = "3.0.0"
3
3
  end
@@ -0,0 +1,130 @@
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
+ describe "#send_email" do
25
+ it "sends a POST request to the /api/send/email path" do
26
+ req = Customerio::SendEmailRequest.new(
27
+ identifiers: {
28
+ id: 'c1',
29
+ },
30
+ transactional_message_id: 1,
31
+ )
32
+
33
+ stub_request(:post, api_uri('/v1/send/email'))
34
+ .with(headers: request_headers, body: req.message)
35
+ .to_return(status: 200, body: { delivery_id: 1 }.to_json, headers: {})
36
+
37
+ client.send_email(req).should eq({ "delivery_id" => 1 })
38
+ end
39
+
40
+ it "handles validation failures (400)" do
41
+ req = Customerio::SendEmailRequest.new(
42
+ identifiers: {
43
+ id: 'c1',
44
+ },
45
+ transactional_message_id: 1,
46
+ )
47
+
48
+ err_json = { meta: { error: "example error" } }.to_json
49
+
50
+ stub_request(:post, api_uri('/v1/send/email'))
51
+ .with(headers: request_headers, body: req.message)
52
+ .to_return(status: 400, body: err_json, headers: {})
53
+
54
+ lambda { client.send_email(req) }.should(
55
+ raise_error(Customerio::InvalidResponse) { |error|
56
+ error.message.should eq "example error"
57
+ error.code.should eq "400"
58
+ }
59
+ )
60
+ end
61
+
62
+ it "handles other failures (5xx)" do
63
+ req = Customerio::SendEmailRequest.new(
64
+ identifiers: {
65
+ id: 'c1',
66
+ },
67
+ transactional_message_id: 1,
68
+ )
69
+
70
+ stub_request(:post, api_uri('/v1/send/email'))
71
+ .with(headers: request_headers, body: req.message)
72
+ .to_return(status: 500, body: "Server unavailable", headers: {})
73
+
74
+ lambda { client.send_email(req) }.should(
75
+ raise_error(Customerio::InvalidResponse) { |error|
76
+ error.message.should eq "Server unavailable"
77
+ error.code.should eq "500"
78
+ }
79
+ )
80
+ end
81
+
82
+ it "allows attaching file content without encoding" do
83
+ content = 'sample content'
84
+
85
+ req = Customerio::SendEmailRequest.new(
86
+ customer_id: 'c1',
87
+ transactional_message_id: 1,
88
+ )
89
+
90
+ req.attach('test', content, encode: false)
91
+ req.message[:attachments]['test'].should eq content
92
+
93
+ stub_request(:post, api_uri('/v1/send/email'))
94
+ .with(headers: request_headers, body: req.message)
95
+ .to_return(status: 200, body: { delivery_id: 1 }.to_json, headers: {})
96
+
97
+ client.send_email(req)
98
+ end
99
+
100
+ it "allows attaching files with encoding (default)" do
101
+ content = 'sample content'
102
+
103
+ req = Customerio::SendEmailRequest.new(
104
+ customer_id: 'c1',
105
+ transactional_message_id: 1,
106
+ )
107
+
108
+ req.attach('test', content)
109
+ req.message[:attachments]['test'].should eq Base64.strict_encode64(content)
110
+
111
+ stub_request(:post, api_uri('/v1/send/email'))
112
+ .with(headers: request_headers, body: req.message)
113
+ .to_return(status: 200, body: { delivery_id: 1 }.to_json, headers: {})
114
+
115
+ client.send_email(req)
116
+ end
117
+
118
+ it "raises error when attaching the same key again" do
119
+ req = Customerio::SendEmailRequest.new(
120
+ customer_id: 'c1',
121
+ transactional_message_id: 1,
122
+ )
123
+
124
+ req.attach('test', 'test-content')
125
+
126
+ lambda { req.attach('test', '') }.should raise_error(/attachment test already exists/)
127
+ req.message[:attachments].should eq({ "test" => Base64.strict_encode64("test-content") })
128
+ end
129
+ end
130
+ end