customerio 1.0.0 → 3.0.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,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