clever_tap 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +48 -0
  5. data/.travis.yml +6 -0
  6. data/Gemfile +16 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +164 -0
  9. data/Rakefile +6 -0
  10. data/bin/console +8 -0
  11. data/bin/setup +8 -0
  12. data/clever_tap.gemspec +33 -0
  13. data/lib/clever_tap.rb +79 -0
  14. data/lib/clever_tap/client.rb +113 -0
  15. data/lib/clever_tap/config.rb +25 -0
  16. data/lib/clever_tap/entity.rb +87 -0
  17. data/lib/clever_tap/event.rb +30 -0
  18. data/lib/clever_tap/failed_response.rb +28 -0
  19. data/lib/clever_tap/profile.rb +7 -0
  20. data/lib/clever_tap/response.rb +28 -0
  21. data/lib/clever_tap/successful_response.rb +30 -0
  22. data/lib/clever_tap/uploader.rb +72 -0
  23. data/lib/clever_tap/version.rb +3 -0
  24. data/lib/clevertap-ruby.rb +1 -0
  25. data/spec/factories/profile.rb +36 -0
  26. data/spec/integrations/clever_tap_spec.rb +81 -0
  27. data/spec/rubocop_spec.rb +12 -0
  28. data/spec/shared/clever_tap_client.rb +13 -0
  29. data/spec/shared/entity.rb +105 -0
  30. data/spec/spec_helper.rb +18 -0
  31. data/spec/units/clever_tap_client_spec.rb +279 -0
  32. data/spec/units/clever_tap_spec.rb +88 -0
  33. data/spec/units/event_spec.rb +43 -0
  34. data/spec/units/failed_response_spec.rb +31 -0
  35. data/spec/units/profile_spec.rb +29 -0
  36. data/spec/units/response_spec.rb +48 -0
  37. data/spec/units/successful_response_spec.rb +112 -0
  38. data/spec/units/uploader_spec.rb +129 -0
  39. data/spec/vcr_cassettes/CleverTap/uploading_a_many_profiles/when_only_some_are_valid/partially_succeds.yml +42 -0
  40. data/spec/vcr_cassettes/CleverTap/uploading_a_profile/when_is_invalid/fails.yml +41 -0
  41. data/spec/vcr_cassettes/CleverTap/uploading_a_profile/when_is_valid/succeed.yml +35 -0
  42. data/spec/vcr_cassettes/CleverTap/uploading_an_event/when_is_valid/succeed.yml +34 -0
  43. data/spec/vcr_cassettes/CleverTap_Client/_upload/when_upload_records_are_homogenous/and_invalid_records/calls_on_failed_upload_once.yml +38 -0
  44. 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
  45. 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
  46. 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
  47. 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
  48. 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
  49. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_age_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  50. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_education_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +49 -0
  51. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_email_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  52. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_employment_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  53. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_marital_status_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  54. data/spec/vcr_cassettes/CleverTap_Uploader/_call/when_phone_is_invalid/behaves_like_validation_failure/failed_to_upload_the_profiles.yml +48 -0
  55. 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
  56. data/spec/vcr_cassettes/CleverTap_Uploader/_call/with_invalid_credentials/failed_to_upload_the_profiles.yml +36 -0
  57. data/spec/vcr_cassettes/CleverTap_Uploader/_call/with_valid_data/makes_successful_upload.yml +36 -0
  58. data/spec/vcr_config.rb +13 -0
  59. 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,7 @@
1
+ class CleverTap
2
+ class Profile < Entity
3
+ DATA_STRING = 'profileData'.freeze
4
+ TYPE_VALUE_STRING = 'profile'.freeze
5
+ UPLOAD_LIMIT = 100
6
+ end
7
+ 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,3 @@
1
+ class CleverTap
2
+ VERSION = '0.3.0'.freeze
3
+ 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