clever_tap 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +1 -0
- data/.rubocop.yml +48 -0
- data/.travis.yml +6 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/Rakefile +6 -0
- data/bin/console +8 -0
- data/bin/setup +8 -0
- data/clever_tap.gemspec +33 -0
- data/lib/clever_tap.rb +79 -0
- data/lib/clever_tap/client.rb +113 -0
- data/lib/clever_tap/config.rb +25 -0
- data/lib/clever_tap/entity.rb +87 -0
- data/lib/clever_tap/event.rb +30 -0
- data/lib/clever_tap/failed_response.rb +28 -0
- data/lib/clever_tap/profile.rb +7 -0
- data/lib/clever_tap/response.rb +28 -0
- data/lib/clever_tap/successful_response.rb +30 -0
- data/lib/clever_tap/uploader.rb +72 -0
- data/lib/clever_tap/version.rb +3 -0
- data/lib/clevertap-ruby.rb +1 -0
- data/spec/factories/profile.rb +36 -0
- data/spec/integrations/clever_tap_spec.rb +81 -0
- data/spec/rubocop_spec.rb +12 -0
- data/spec/shared/clever_tap_client.rb +13 -0
- data/spec/shared/entity.rb +105 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/units/clever_tap_client_spec.rb +279 -0
- data/spec/units/clever_tap_spec.rb +88 -0
- data/spec/units/event_spec.rb +43 -0
- data/spec/units/failed_response_spec.rb +31 -0
- data/spec/units/profile_spec.rb +29 -0
- data/spec/units/response_spec.rb +48 -0
- data/spec/units/successful_response_spec.rb +112 -0
- data/spec/units/uploader_spec.rb +129 -0
- data/spec/vcr_cassettes/CleverTap/uploading_a_many_profiles/when_only_some_are_valid/partially_succeds.yml +42 -0
- data/spec/vcr_cassettes/CleverTap/uploading_a_profile/when_is_invalid/fails.yml +41 -0
- data/spec/vcr_cassettes/CleverTap/uploading_a_profile/when_is_valid/succeed.yml +35 -0
- data/spec/vcr_cassettes/CleverTap/uploading_an_event/when_is_valid/succeed.yml +34 -0
- data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_invalid_records/calls_on_failed_upload_once.yml +38 -0
- data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_invalid_records/returns_an_array_with_one_failed_Response_object.yml +38 -0
- data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_do_not_fit_upload_limit_/calls_on_successful_upload_proc_twice.yml +67 -0
- data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_do_not_fit_upload_limit_/returns_an_array_with_two_successful_Response_objects.yml +67 -0
- data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_fit_upload_limit_/calls_on_successful_upload_proc_once.yml +36 -0
- data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_valid_records/and_objects_fit_upload_limit_/returns_an_array_with_one_successful_Response_object.yml +36 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_age_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_education_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +49 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_email_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_employment_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_marital_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_phone_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_the_creation_date_field_is_missing/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/with_invalid_credentials/failed_to_upload_the_profiles.yml +36 -0
- data/spec/vcr_cassettes/CleverTap_Uploader/_call/with_valid_data/makes_successful_upload.yml +36 -0
- data/spec/vcr_config.rb +13 -0
- metadata +199 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
class CleverTap
|
2
|
+
# CleverTap instance's config store object
|
3
|
+
class Config
|
4
|
+
DEFAULT_IDENTITY_FIELD = 'identity'.freeze
|
5
|
+
|
6
|
+
attr_accessor :account_id, :passcode, :identity_field
|
7
|
+
|
8
|
+
def initialize(**config)
|
9
|
+
@account_id = config[:account_id]
|
10
|
+
@passcode = config[:passcode]
|
11
|
+
@identity_field = config[:identity_field] || DEFAULT_IDENTITY_FIELD
|
12
|
+
@configure_faraday = config[:configure_faraday]
|
13
|
+
end
|
14
|
+
|
15
|
+
# NOTE: reader or writer depending if the block is given
|
16
|
+
def configure_faraday(&block)
|
17
|
+
block ? @configure_faraday = block : @configure_faraday
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate
|
21
|
+
raise 'Missing authentication parameter `account_id`' unless account_id
|
22
|
+
raise 'Missing authentication parameter `passcode`' unless passcode
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
class CleverTap
|
2
|
+
class NoDataError < RuntimeError
|
3
|
+
def message
|
4
|
+
'No `data` param provided for Event'
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class MissingIdentityError < RuntimeError
|
9
|
+
def message
|
10
|
+
"Couldn'n find `identity` in CleverTap.config or `data`"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class Entity
|
15
|
+
ALLOWED_IDENTITIES = %w(objectId FBID GPID).freeze
|
16
|
+
IDENTITY_STRING = 'identity'.freeze
|
17
|
+
TIMESTAMP_STRING = 'ts'.freeze
|
18
|
+
TYPE_KEY_STRING = 'type'.freeze
|
19
|
+
UPLOAD_LIMIT = 'Needs child class value'.freeze
|
20
|
+
TYPE_VALUE_STRING = 'Needs child class value'.freeze
|
21
|
+
|
22
|
+
class << self
|
23
|
+
def upload_limit
|
24
|
+
self::UPLOAD_LIMIT
|
25
|
+
end
|
26
|
+
|
27
|
+
def all_same_type?(items)
|
28
|
+
items.all? { |i| i.class == self }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(**args)
|
33
|
+
@data = args[:data]
|
34
|
+
@identity = choose_identity(args)
|
35
|
+
@timestamp = choose_timestamp(args)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_h
|
39
|
+
put_identity_pair
|
40
|
+
.merge(put_timestamp_pair)
|
41
|
+
.merge(put_type_pair)
|
42
|
+
.merge(put_data)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def put_identity_pair
|
48
|
+
raise NoDataError if @data.to_h.empty?
|
49
|
+
raise MissingIdentityError if @identity == '' || @data[@identity].nil?
|
50
|
+
return { @identity => @data[@identity].to_s } if allowed?(@identity)
|
51
|
+
{ IDENTITY_STRING => @data[@identity].to_s }
|
52
|
+
end
|
53
|
+
|
54
|
+
def put_timestamp_pair
|
55
|
+
return {} unless @timestamp
|
56
|
+
{ TIMESTAMP_STRING => @timestamp }
|
57
|
+
end
|
58
|
+
|
59
|
+
def put_type_pair
|
60
|
+
{ TYPE_KEY_STRING => self.class::TYPE_VALUE_STRING }
|
61
|
+
end
|
62
|
+
|
63
|
+
def put_data
|
64
|
+
raise NoDataError if @data.to_h.empty?
|
65
|
+
@data.delete(@identity) if allowed?(@identity)
|
66
|
+
{
|
67
|
+
self.class::DATA_STRING => @data
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
def choose_identity(args)
|
72
|
+
identity = args[:identity].to_s
|
73
|
+
|
74
|
+
return identity if allowed?(identity) && @data.to_h.key?(identity)
|
75
|
+
CleverTap.identity_field.to_s
|
76
|
+
end
|
77
|
+
|
78
|
+
def choose_timestamp(args)
|
79
|
+
return args[:custom_timestamp].to_i if args[:custom_timestamp]
|
80
|
+
return @data.delete(args[:timestamp_field].to_s).to_i if args[:timestamp_field]
|
81
|
+
end
|
82
|
+
|
83
|
+
def allowed?(identity)
|
84
|
+
ALLOWED_IDENTITIES.include?(identity)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class CleverTap
|
2
|
+
class MissingEventNameError < RuntimeError
|
3
|
+
def message
|
4
|
+
"Couldn't find `name:` with value in Event#new(options)"
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class Event < Entity
|
9
|
+
DATA_STRING = 'evtData'.freeze
|
10
|
+
EVENT_NAME_STRING = 'evtName'.freeze
|
11
|
+
TYPE_VALUE_STRING = 'event'.freeze
|
12
|
+
UPLOAD_LIMIT = 1000
|
13
|
+
|
14
|
+
def initialize(**args)
|
15
|
+
super(**args)
|
16
|
+
@name = args[:name]
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_h
|
20
|
+
super.merge(put_event_name_pair)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def put_event_name_pair
|
26
|
+
raise MissingEventNameError if @name.nil?
|
27
|
+
{ EVENT_NAME_STRING => @name }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
class CleverTap
|
2
|
+
# Introduce unified interface as the `SuccessfulResponse`
|
3
|
+
class FailedResponse
|
4
|
+
FAIL_STATUS = 'fail'.freeze
|
5
|
+
|
6
|
+
attr_reader :records, :message, :code
|
7
|
+
|
8
|
+
def initialize(records:, message:, code: -1)
|
9
|
+
@records = records
|
10
|
+
@message = message
|
11
|
+
@code = code
|
12
|
+
end
|
13
|
+
|
14
|
+
def status
|
15
|
+
FAIL_STATUS
|
16
|
+
end
|
17
|
+
|
18
|
+
def success
|
19
|
+
false
|
20
|
+
end
|
21
|
+
|
22
|
+
def errors
|
23
|
+
records.map do |record|
|
24
|
+
{ 'status' => FAIL_STATUS, 'code' => code, 'error' => message, 'record' => record }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
class CleverTap
|
2
|
+
class Response
|
3
|
+
attr_accessor :response, :success, :failures
|
4
|
+
|
5
|
+
def initialize(response)
|
6
|
+
@response = JSON.parse(response.body)
|
7
|
+
process_response
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def process_response
|
13
|
+
return process_success if response['status'] == 'success'
|
14
|
+
@success = false
|
15
|
+
@failures = [response]
|
16
|
+
end
|
17
|
+
|
18
|
+
def process_success
|
19
|
+
if response['unprocessed'].to_a.empty?
|
20
|
+
@success = true
|
21
|
+
@failures = []
|
22
|
+
else
|
23
|
+
@success = false
|
24
|
+
@failures = response['unprocessed']
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class CleverTap
|
2
|
+
# Normalize the success response data to one interface with the failure one
|
3
|
+
class SuccessfulResponse
|
4
|
+
attr_reader :raw_response, :unprocessed, :message, :code
|
5
|
+
|
6
|
+
# NOTE: raw_response can include processed, unprocessed, status
|
7
|
+
def initialize(raw_response = {})
|
8
|
+
@raw_response = raw_response
|
9
|
+
@unprocessed = raw_response['unprocessed']
|
10
|
+
@message = ''
|
11
|
+
@code = 200
|
12
|
+
end
|
13
|
+
|
14
|
+
def errors
|
15
|
+
unprocessed
|
16
|
+
end
|
17
|
+
|
18
|
+
def status
|
19
|
+
case
|
20
|
+
when success then 'success'
|
21
|
+
when raw_response['processed'].positive? then 'partial'
|
22
|
+
else 'fail'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def success
|
27
|
+
unprocessed.empty?
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
class CleverTap
|
2
|
+
# unit uploading profile data to CleverTap
|
3
|
+
class Uploader
|
4
|
+
HTTP_PATH = 'upload'.freeze
|
5
|
+
|
6
|
+
TYPE_EVENT = :event
|
7
|
+
TYPE_PROFILE = :profile
|
8
|
+
|
9
|
+
ENTITY_DATA_NAMES = {
|
10
|
+
TYPE_EVENT => 'evtData',
|
11
|
+
TYPE_PROFILE => 'profileData'
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
attr_reader :records, :type, :identity_field, :date_field, :event_name, :dry_run
|
15
|
+
|
16
|
+
# TODO: make defaults configurable
|
17
|
+
# date_field should be a date object responding to `to_i` which
|
18
|
+
# should returns epoch time
|
19
|
+
# profile respond to .to_h
|
20
|
+
def initialize(records, identity_field:, date_field: nil, event_name: nil, dry_run: false)
|
21
|
+
@type = event_name ? TYPE_EVENT : TYPE_PROFILE
|
22
|
+
@records = records
|
23
|
+
|
24
|
+
@identity_field = identity_field
|
25
|
+
@date_field = date_field
|
26
|
+
@event_name = event_name
|
27
|
+
@dry_run = dry_run
|
28
|
+
end
|
29
|
+
|
30
|
+
def call(client)
|
31
|
+
response = client.post(HTTP_PATH, build_request_body) do |request|
|
32
|
+
request.params.merge!(dryRun: 1) if dry_run
|
33
|
+
end
|
34
|
+
|
35
|
+
parse_response(response)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def build_request_body
|
41
|
+
records.each_with_object('d' => []) do |record, request_body|
|
42
|
+
request_body['d'] << normalize_record(record)
|
43
|
+
end.to_json
|
44
|
+
end
|
45
|
+
|
46
|
+
def normalize_record(record)
|
47
|
+
ts = date_field ? record[date_field] : Time.now
|
48
|
+
|
49
|
+
{
|
50
|
+
'identity' => pluck_identity(record).to_s,
|
51
|
+
'ts' => ts.to_i,
|
52
|
+
'type' => type,
|
53
|
+
ENTITY_DATA_NAMES[type] => record.to_h
|
54
|
+
}.tap do |hash|
|
55
|
+
hash.merge!('evtName' => event_name) if type == TYPE_EVENT
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_response(http_response)
|
60
|
+
http_response
|
61
|
+
end
|
62
|
+
|
63
|
+
def pluck_identity(record)
|
64
|
+
# TODO: symbolize record keys
|
65
|
+
identity = record[identity_field.to_s] || record[identity_field.to_sym]
|
66
|
+
|
67
|
+
raise "Missing identity field with name: '#{identity_field}' for #{record}" unless identity
|
68
|
+
|
69
|
+
identity
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'clever_tap'
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# Unit representing a profile and it's different states
|
2
|
+
class Profile
|
3
|
+
class << self
|
4
|
+
attr_accessor :store
|
5
|
+
end
|
6
|
+
|
7
|
+
self.store = {
|
8
|
+
id: 0
|
9
|
+
}
|
10
|
+
|
11
|
+
def self.build_valid(extra = {})
|
12
|
+
new({
|
13
|
+
'identity' => store[:id] += 1,
|
14
|
+
'created_at' => Time.new,
|
15
|
+
'Name' => 'John Rush',
|
16
|
+
'Email' => 'example@gmail.com',
|
17
|
+
'Gender' => 'M',
|
18
|
+
'Phone' => '+35922333232',
|
19
|
+
'Employed' => 'Y',
|
20
|
+
'Education' => 'Graduate',
|
21
|
+
'Married' => 'Y',
|
22
|
+
'Age' => '18'
|
23
|
+
}.merge(extra))
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_reader :data
|
27
|
+
alias to_h data
|
28
|
+
|
29
|
+
def initialize(data = {})
|
30
|
+
@data = data
|
31
|
+
end
|
32
|
+
|
33
|
+
def [](key)
|
34
|
+
data[key]
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe CleverTap, vcr: true do
|
4
|
+
# NOTE: clear mutations in CleverTap config
|
5
|
+
subject(:clever_tap) { CleverTap.new(account_id: AUTH_ACCOUNT_ID, passcode: AUTH_PASSCODE) }
|
6
|
+
|
7
|
+
describe 'uploading a profile' do
|
8
|
+
context 'when is valid' do
|
9
|
+
let(:profile) { Profile.build_valid }
|
10
|
+
|
11
|
+
it 'succeed' do
|
12
|
+
response = clever_tap.upload_profile(profile)
|
13
|
+
|
14
|
+
aggregate_failures do
|
15
|
+
expect(response.status).to eq('success')
|
16
|
+
expect(response.errors).to be_empty
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
context 'when is invalid' do
|
22
|
+
let(:profile) { Profile.build_valid('Email' => '$$$$$') }
|
23
|
+
|
24
|
+
it 'fails' do
|
25
|
+
response = clever_tap.upload_profile(profile)
|
26
|
+
|
27
|
+
aggregate_failures do
|
28
|
+
expect(response.status).to eq('fail')
|
29
|
+
expect(response.errors).to all(be_a(Hash))
|
30
|
+
expect(response.errors).to all(
|
31
|
+
include('status', 'code', 'error', 'record')
|
32
|
+
)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe 'uploading a many profiles' do
|
39
|
+
context 'when only some are valid' do
|
40
|
+
let(:valid_profile) { Profile.build_valid }
|
41
|
+
let(:invalid_profile) { Profile.build_valid('Email' => '$$$$$') }
|
42
|
+
let(:profiles) { [valid_profile, invalid_profile] }
|
43
|
+
|
44
|
+
it 'partially succeds' do
|
45
|
+
response = clever_tap.upload_profiles(profiles)
|
46
|
+
|
47
|
+
aggregate_failures do
|
48
|
+
expect(response.status).to eq('partial')
|
49
|
+
expect(response.errors).to all(be_a(Hash))
|
50
|
+
expect(response.errors).to all(
|
51
|
+
include('status', 'code', 'error', 'record')
|
52
|
+
)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe 'uploading an event' do
|
59
|
+
subject(:clever_tap) do
|
60
|
+
CleverTap.new(account_id: AUTH_ACCOUNT_ID, passcode: AUTH_PASSCODE, identity_field: 'ID')
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'when is valid' do
|
64
|
+
let(:event) do
|
65
|
+
{
|
66
|
+
'ID' => 555,
|
67
|
+
'mobile' => true
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'succeed' do
|
72
|
+
response = clever_tap.upload_event(event, name: 'register', identity_field: 'user_id')
|
73
|
+
|
74
|
+
aggregate_failures do
|
75
|
+
expect(response.status).to eq('success')
|
76
|
+
expect(response.errors).to be_empty
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'rubocop'
|
2
|
+
|
3
|
+
gem_root = File.expand_path('../', __FILE__)
|
4
|
+
CONFIG_FILE_PATH = File.join(gem_root, 'config', 'rubocop_spec.yml')
|
5
|
+
|
6
|
+
describe 'rubocop' do
|
7
|
+
let(:args) { ['--format', 'simple', '-D', gem_root] }
|
8
|
+
|
9
|
+
it 'passes all cops' do
|
10
|
+
expect { RuboCop::CLI.new.run(args) }.to output(/no offenses detected/).to_stdout
|
11
|
+
end
|
12
|
+
end
|