bento-sdk 0.5.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c620722fa7d6cb6cfa291f2a8aeda31dbc237a6096387dd43534868d934d3396
4
+ data.tar.gz: 6426e53c3fdb01e6c38bfe48602f1bc8e95a66280335775932bce4c0e0792c95
5
+ SHA512:
6
+ metadata.gz: 50c445e4580135141dcf05baf11bb68a354c90b85352d221abfeeac83f0d4acc16beadfdf22561221bb3302b8ee422723a04afbd53e41684a11647190aa3e9e1
7
+ data.tar.gz: a5564c55a86070b04120d4e8125c6a3b8df694ad860584bdb5f5b48eecf2848b5889178e0fcff4c98ea3af6ff38eaa5322459246e0265ec61921162c3902eb2f
@@ -0,0 +1,47 @@
1
+ require "bento/sdk/version"
2
+ require "bento/sdk/defaults"
3
+ require "bento/sdk/utils"
4
+ require "bento/sdk/field_parser"
5
+ require "bento/sdk/client"
6
+ require "bento/sdk/worker"
7
+ require "bento/sdk/transport"
8
+ require "bento/sdk/response"
9
+ require "bento/sdk/logging"
10
+
11
+ module Bento
12
+ class << self
13
+ attr_writer :write_key
14
+ end
15
+
16
+ def self.configure(&block)
17
+ yield self
18
+ end
19
+
20
+ class Analytics
21
+ # Initializes a new instance of {Bento::Analytics::Client}, to which all
22
+ # method calls are proxied.
23
+ #
24
+ # @param options includes options that are passed down to
25
+ # {Bento::Analytics::Client#initialize}
26
+ # @option options [Boolean] :stub (false) If true, requests don't hit the
27
+ # server and are stubbed to be successful.
28
+ def initialize(options = {})
29
+ Transport.stub = options[:stub] if options.key?(:stub)
30
+ @client = Bento::Analytics::Client.new options
31
+ end
32
+
33
+ def method_missing(message, *args, &block)
34
+ if @client.respond_to? message
35
+ @client.send message, *args, &block
36
+ else
37
+ super
38
+ end
39
+ end
40
+
41
+ def respond_to_missing?(method_name, include_private = false)
42
+ @client.respond_to?(method_name) || super
43
+ end
44
+
45
+ include Logging
46
+ end
47
+ end
@@ -0,0 +1,47 @@
1
+ module Bento
2
+ # Client class for interacting with the Bento API.
3
+ # This class provides methods for making HTTP GET and POST requests to the API,
4
+ # handles authentication, connection errors, and response parsing.
5
+ # It also supports a development mode for local testing.
6
+ class Client
7
+ def get(endpoint)
8
+ handle_connection_errors { parse_response(conn.get(endpoint)) }
9
+ end
10
+
11
+ def post(endpoint, payload = nil)
12
+ handle_connection_errors { parse_response(conn.post(endpoint, payload)) }
13
+ end
14
+
15
+ private
16
+
17
+ def parse_response(response)
18
+ if response.success?
19
+ JSON.parse(response.body)
20
+ else
21
+ Bento::Error.raise_with_response(response)
22
+ end
23
+ end
24
+
25
+ def authorization
26
+ @authorization ||= "Basic #{Base64.strict_encode64("#{Bento.publishable_key}:#{Bento.secret_key}")}"
27
+ end
28
+
29
+ def conn
30
+ Faraday.new(
31
+ url: Bento.dev_mode ? 'http://localhost:3000' : 'https://app.bentonow.com',
32
+ headers: {
33
+ 'Content-Type' => 'application/json',
34
+ 'Accept' => 'application/json',
35
+ 'User-Agent' => 'bento-rails-' + Bento.site_uuid,
36
+ 'Authorization' => authorization
37
+ }
38
+ )
39
+ end
40
+
41
+ def handle_connection_errors
42
+ yield
43
+ rescue Faraday::ConnectionFailed => exception
44
+ raise Bento::ConnectionError, "Failed to connect to the server: #{exception.message}"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,26 @@
1
+ module Bento
2
+ # Custom error class for handling Bento API errors.
3
+ # This class encapsulates the HTTP response and provides
4
+ # a formatted error message for easier debugging and error handling.
5
+ class Error < StandardError
6
+ attr_reader :response
7
+
8
+ def initialize(response)
9
+ @response = response
10
+ super(formatted_error_message)
11
+ end
12
+
13
+ # Raises an instance of Bento::Error with the given response
14
+ def self.raise_with_response(response)
15
+ raise new(response)
16
+ end
17
+
18
+ private
19
+
20
+ def formatted_error_message
21
+ "HTTP #{response.status}: #{response.body}"
22
+ end
23
+ end
24
+ end
25
+ # Example usage:
26
+ # Bento::Error.raise_with_response(response)
@@ -0,0 +1,23 @@
1
+ module Bento
2
+ # Represents a response from the Bento API.
3
+ # This class encapsulates the API response data and provides
4
+ # methods to check the status of the operation (success or failure)
5
+ # based on the number of successful and failed results.
6
+ class Response
7
+ attr_reader :results, :failed
8
+
9
+ def initialize(response)
10
+ @response = response
11
+ @results = response['results'].to_i
12
+ @failed = response['failed'].to_i
13
+ end
14
+
15
+ def success?
16
+ failed.zero?
17
+ end
18
+
19
+ def failure?
20
+ failed.positive?
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ module Bento
2
+ module Validators
3
+ module Base
4
+ def validate_email(email)
5
+ raise ArgumentError, 'Email is required' if email.nil? || email.empty?
6
+ raise ArgumentError, 'Invalid email format' unless email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
7
+ end
8
+
9
+ def validate_type(type)
10
+ raise ArgumentError, 'Type is required' if type.nil? || type.empty?
11
+ end
12
+
13
+ def validate_fields(fields)
14
+ raise ArgumentError, 'Fields must be a hash' unless fields.is_a?(Hash)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ module Bento
2
+ module Validators
3
+ module EmailValidators
4
+ def validate_author(author)
5
+ raise ArgumentError, 'Author is required' if author.nil? || author.empty?
6
+ # Additional validation can be implemented based on system requirements
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,46 @@
1
+ module Bento
2
+ module Validators
3
+ module EventValidators
4
+ def validate_details(details)
5
+ raise ArgumentError, 'Details must be a hash' unless details.is_a?(Hash)
6
+ validate_unique(details[:unique]) if details.key?(:unique)
7
+ validate_value(details[:value]) if details.key?(:value)
8
+ validate_cart(details[:cart]) if details.key?(:cart)
9
+ end
10
+
11
+ def validate_unique(unique)
12
+ raise ArgumentError, 'Unique must be a hash' unless unique.is_a?(Hash)
13
+ raise ArgumentError, 'Unique key is required' unless unique.key?(:key)
14
+ end
15
+
16
+ def validate_value(value)
17
+ raise ArgumentError, 'Value must be a hash' unless value.is_a?(Hash)
18
+ raise ArgumentError, 'Currency is required in value' unless value.key?(:currency)
19
+ raise ArgumentError, 'Amount is required in value' unless value.key?(:amount)
20
+ end
21
+
22
+ def validate_cart(cart)
23
+ raise ArgumentError, 'Cart must be a hash' unless cart.is_a?(Hash)
24
+ validate_cart_items(cart[:items]) if cart.key?(:items)
25
+ end
26
+
27
+ def validate_cart_items(items)
28
+ raise ArgumentError, 'Cart items must be an array' unless items.is_a?(Array)
29
+ items.each do |item|
30
+ raise ArgumentError, 'Cart item must be a hash' unless item.is_a?(Hash)
31
+ raise ArgumentError, 'Product SKU is required in cart item' unless item.key?(:product_sku)
32
+ raise ArgumentError, 'Product name is required in cart item' unless item.key?(:product_name)
33
+ raise ArgumentError, 'Quantity is required in cart item' unless item.key?(:quantity)
34
+ end
35
+ end
36
+
37
+ def validate_event(event)
38
+ raise ArgumentError, 'Event must be a hash' unless event.is_a?(Hash)
39
+ validate_email(event[:email])
40
+ validate_type(event[:type])
41
+ validate_fields(event[:fields]) if event.key?(:fields)
42
+ validate_details(event[:details]) if event.key?(:details)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,3 @@
1
+ module Bento
2
+ VERSION = "0.5.0".freeze
3
+ end
@@ -0,0 +1,59 @@
1
+ module Bento
2
+ class Emails
3
+ class << self
4
+ include Bento::Validators::Base
5
+ include Bento::Validators::EmailValidators
6
+ # Send an email that honors subscription status
7
+ def send(to:, from:, subject:, html_body:, personalizations: {})
8
+ validate_email(to)
9
+ validate_author(from)
10
+
11
+ payload = {
12
+ to: to,
13
+ from: from,
14
+ subject: subject,
15
+ html_body: html_body,
16
+ personalizations: personalizations
17
+ }
18
+
19
+ send_bulk([payload])
20
+ end
21
+
22
+ # Send a transactional email that always sends, even if user is unsubscribed
23
+ def send_transactional(to:, from:, subject:, html_body:, personalizations: {})
24
+ validate_email(to)
25
+ validate_author(from)
26
+
27
+ payload = {
28
+ to: to,
29
+ from: from,
30
+ subject: subject,
31
+ html_body: html_body,
32
+ personalizations: personalizations,
33
+ transactional: true
34
+ }
35
+
36
+ send_bulk([payload])
37
+ end
38
+
39
+ def send_bulk(emails)
40
+ raise ArgumentError, 'Emails must be an array' unless emails.is_a?(Array)
41
+ emails.each { |email| validate_email(email[:to]); validate_email(email[:from]) }
42
+
43
+ payload = { emails: emails }.to_json
44
+ response = client.post("api/v1/batch/emails?#{URI.encode_www_form(default_params)}", payload)
45
+ Bento::Response.new(response)
46
+ end
47
+
48
+ private
49
+
50
+ def client
51
+ @client ||= Bento::Client.new
52
+ end
53
+
54
+ def default_params
55
+ { site_uuid: Bento.config.site_uuid }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,77 @@
1
+ module Bento
2
+ class Events
3
+ class << self
4
+ include Bento::Validators::Base
5
+ include Bento::Validators::EventValidators
6
+ # Track an event
7
+ # Usage examples:
8
+ #
9
+ # Basic event:
10
+ # Bento::Events.track(email: 'test@test.com', type: '$completed_onboarding')
11
+ #
12
+ # Event with fields:
13
+ # Bento::Events.track(
14
+ # email: 'test@test.com',
15
+ # type: '$completed_onboarding',
16
+ # fields: { first_name: 'Jesse', last_name: 'Pinkman' }
17
+ # )
18
+ #
19
+ # Complex event with fields and details:
20
+ # Bento::Events.track(
21
+ # email: 'test@test.com',
22
+ # type: '$purchase',
23
+ # fields: { first_name: 'Jesse' },
24
+ # details: {
25
+ # unique: { key: 'test123' },
26
+ # value: { currency: 'USD', amount: 8000 },
27
+ # cart: {
28
+ # items: [
29
+ # {
30
+ # product_sku: 'SKU123',
31
+ # product_name: 'Test',
32
+ # quantity: 100
33
+ # }
34
+ # ],
35
+ # abandoned_checkout_url: 'https://test.com'
36
+ # }
37
+ # }
38
+ # )
39
+ def track(email:, type:, fields: {}, details: {})
40
+ validate_email(email)
41
+ validate_type(type)
42
+ validate_fields(fields)
43
+ validate_details(details)
44
+
45
+ event = {
46
+ email: email,
47
+ type: type
48
+ }
49
+ event[:fields] = fields unless fields.empty?
50
+ event[:details] = details unless details.empty?
51
+
52
+ import([event])
53
+ end
54
+
55
+ # Batch track multiple events
56
+ # Usage: Bento::Events.import([{email: 'test@bentonow.com', type: 'Login'}, {email: 'test@bentonow.com', type: 'Purchase', fields: { first_name: 'Jesse', last_name: 'Hanley' }}])
57
+ def import(events)
58
+ raise ArgumentError, 'Events must be an array' unless events.is_a?(Array)
59
+ events.each { |event| validate_event(event) }
60
+
61
+ payload = { events: events }.to_json
62
+ response = client.post("api/v1/batch/events?#{URI.encode_www_form(default_params)}", payload)
63
+ Bento::Response.new(response)
64
+ end
65
+
66
+ private
67
+
68
+ def client
69
+ @client ||= Bento::Client.new
70
+ end
71
+
72
+ def default_params
73
+ { site_uuid: Bento.config.site_uuid }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,26 @@
1
+ module Bento
2
+ class Spam
3
+ class << self
4
+ def valid?(email)
5
+ payload = {
6
+ email: email
7
+ }
8
+
9
+ response = client.post('api/v1/experimental/validation', payload.to_json)
10
+
11
+ return response['valid']
12
+ end
13
+
14
+ # Example: Bento::Spam.risky?('test@bentonow.com')
15
+ def risky?(email)
16
+ !self.valid?(email)
17
+ end
18
+
19
+ private
20
+
21
+ def client
22
+ @client ||= Bento::Client.new
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,137 @@
1
+ module Bento
2
+ class Subscribers
3
+ class << self
4
+ # Find a subscriber by email or uuid
5
+ # Usage: Bento::Subscribers.find_by(email: 'test@bentonow.com')
6
+ # or: Bento::Subscribers.find_by(uuid: 'subscriber-uuid')
7
+ def find_by(email: nil, uuid: nil)
8
+ params = default_params
9
+ params[:email] = email if email
10
+ params[:uuid] = uuid if uuid
11
+ response = client.get("api/v1/fetch/subscribers?#{URI.encode_www_form(params)}")
12
+
13
+ if response['data'].nil?
14
+ raise StandardError, 'Bento Error: No user found with the given email or uuid'
15
+ else
16
+ Subscriber.new(response['data'])
17
+ end
18
+ end
19
+
20
+ # Find or create a subscriber by email or uuid
21
+ # Usage: Bento::Subscribers.find_or_create_by(email: 'test@bentonow.com')
22
+ # or: Bento::Subscribers.find_or_create_by(uuid: 'subscriber-uuid')
23
+ def find_or_create_by(email: nil)
24
+ params = default_params
25
+ payload = {
26
+ subscriber: {
27
+ email: email
28
+ }.compact
29
+ }.to_json
30
+
31
+ response = client.post("api/v1/fetch/subscribers?#{URI.encode_www_form(params)}", payload)
32
+
33
+ if response['data'].nil?
34
+ raise StandardError, 'Bento Error: No user found with the given email or uuid'
35
+ else
36
+ Subscriber.new(response['data'])
37
+ end
38
+ end
39
+
40
+ # Import or update subscribers in bulk
41
+ # Usage: Bento::Subscribers.import([{email: 'user1@example.com', first_name: 'John'}, {email: 'user2@example.com', last_name: 'Doe'}])
42
+ def import(subscribers)
43
+ payload = { subscribers: subscribers }.to_json
44
+ response = client.post("api/v1/batch/subscribers?#{URI.encode_www_form(default_params)}", payload)
45
+ Bento::Response.new(response)
46
+ end
47
+
48
+ # Run a command to change a subscriber's data
49
+ # Usage: Bento::Subscribers.run_command(command: 'add_tag', email: 'test@bentonow.com', query: 'new_tag')
50
+ def run_command(command:, email:, query: nil)
51
+ payload = {
52
+ command: [{
53
+ command: command,
54
+ email: email,
55
+ query: query
56
+ }]
57
+ }.to_json
58
+
59
+ response = client.post("api/v1/fetch/commands?#{URI.encode_www_form(default_params)}", payload)
60
+ Bento::Response.new(response)
61
+ end
62
+
63
+ # Add a tag to a subscriber
64
+ # Usage: Bento::Subscribers.add_tag(email: 'test@bentonow.com', tag: 'new_tag')
65
+ def add_tag(email:, tag:)
66
+ run_command(command: 'add_tag', email: email, query: tag)
67
+ end
68
+
69
+ # Add a tag to a subscriber via an event
70
+ # Usage: Bento::Subscribers.add_tag_via_event(email: 'test@bentonow.com', tag: 'event_tag')
71
+ def add_tag_via_event(email:, tag:)
72
+ run_command(command: 'add_tag_via_event', email: email, query: tag)
73
+ end
74
+
75
+ # Remove a tag from a subscriber
76
+ # Usage: Bento::Subscribers.remove_tag(email: 'test@bentonow.com', tag: 'old_tag')
77
+ def remove_tag(email:, tag:)
78
+ run_command(command: 'remove_tag', email: email, query: tag)
79
+ end
80
+
81
+ # Add a field to a subscriber
82
+ # Usage: Bento::Subscribers.add_field(email: 'test@bentonow.com', key: 'company', value: 'Acme Inc')
83
+ def add_field(email:, key:, value:)
84
+ run_command(command: 'add_field', email: email, query: { key: key, value: value })
85
+ end
86
+
87
+ # Remove a field from a subscriber
88
+ # Usage: Bento::Subscribers.remove_field(email: 'test@bentonow.com', field: 'company')
89
+ def remove_field(email:, field:)
90
+ run_command(command: 'remove_field', email: email, query: field)
91
+ end
92
+
93
+ # Subscribe a user
94
+ # Usage: Bento::Subscribers.subscribe(email: 'test@bentonow.com')
95
+ def subscribe(email:)
96
+ run_command(command: 'subscribe', email: email)
97
+ end
98
+
99
+ # Unsubscribe a user
100
+ # Usage: Bento::Subscribers.unsubscribe(email: 'test@bentonow.com')
101
+ def unsubscribe(email:)
102
+ run_command(command: 'unsubscribe', email: email)
103
+ end
104
+
105
+ # Change a subscriber's email
106
+ # Usage: Bento::Subscribers.change_email(old_email: 'old@example.com', new_email: 'new@example.com')
107
+ def change_email(old_email:, new_email:)
108
+ run_command(command: 'change_email', email: old_email, query: new_email)
109
+ end
110
+
111
+ private
112
+
113
+ def client
114
+ @client ||= Bento::Client.new
115
+ end
116
+
117
+ def default_params
118
+ { site_uuid: Bento.config.site_uuid }
119
+ end
120
+ end
121
+ end
122
+
123
+ class Subscriber
124
+ attr_reader :id, :uuid, :email, :fields, :cached_tag_ids, :unsubscribed_at, :navigation_url
125
+
126
+ def initialize(data)
127
+ @id = data['id']
128
+ attributes = data['attributes']
129
+ @uuid = attributes['uuid']
130
+ @email = attributes['email']
131
+ @fields = attributes['fields']
132
+ @cached_tag_ids = attributes['cached_tag_ids']
133
+ @unsubscribed_at = attributes['unsubscribed_at']
134
+ @navigation_url = attributes['navigation_url']
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,49 @@
1
+ require "bento/sdk/defaults"
2
+
3
+ module Bento
4
+ class Analytics
5
+ class BackoffPolicy
6
+ include Bento::Analytics::Defaults::BackoffPolicy
7
+
8
+ # @param [Hash] opts
9
+ # @option opts [Numeric] :min_timeout_ms The minimum backoff timeout
10
+ # @option opts [Numeric] :max_timeout_ms The maximum backoff timeout
11
+ # @option opts [Numeric] :multiplier The value to multiply the current
12
+ # interval with for each retry attempt
13
+ # @option opts [Numeric] :randomization_factor The randomization factor
14
+ # to use to create a range around the retry interval
15
+ def initialize(opts = {})
16
+ @min_timeout_ms = opts[:min_timeout_ms] || MIN_TIMEOUT_MS
17
+ @max_timeout_ms = opts[:max_timeout_ms] || MAX_TIMEOUT_MS
18
+ @multiplier = opts[:multiplier] || MULTIPLIER
19
+ @randomization_factor = opts[:randomization_factor] || RANDOMIZATION_FACTOR
20
+
21
+ @attempts = 0
22
+ end
23
+
24
+ # @return [Numeric] the next backoff interval, in milliseconds.
25
+ def next_interval
26
+ interval = @min_timeout_ms * (@multiplier**@attempts)
27
+ interval = add_jitter(interval, @randomization_factor)
28
+
29
+ @attempts += 1
30
+
31
+ [interval, @max_timeout_ms].min
32
+ end
33
+
34
+ private
35
+
36
+ def add_jitter(base, randomization_factor)
37
+ random_number = rand
38
+ max_deviation = base * randomization_factor
39
+ deviation = random_number * max_deviation
40
+
41
+ if random_number < 0.5
42
+ base - deviation
43
+ else
44
+ base + deviation
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end