broadcast-ruby 0.1.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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Broadcast
8
+ class Client
9
+ attr_reader :config
10
+
11
+ def initialize(**settings)
12
+ @config = Configuration.new
13
+ settings.each { |k, v| @config.public_send(:"#{k}=", v) }
14
+ @config.validate!
15
+ end
16
+
17
+ # --- Transactional email ---
18
+
19
+ def send_email(to:, subject:, body:, reply_to: nil)
20
+ payload = { to: to, subject: subject, body: body }
21
+ payload[:reply_to] = reply_to if reply_to
22
+ request(:post, '/api/v1/transactionals.json', payload)
23
+ end
24
+
25
+ def get_email(id)
26
+ request(:get, "/api/v1/transactionals/#{id}.json")
27
+ end
28
+
29
+ # --- Resource sub-clients ---
30
+
31
+ def subscribers
32
+ @subscribers ||= Resources::Subscribers.new(self)
33
+ end
34
+
35
+ def sequences
36
+ @sequences ||= Resources::Sequences.new(self)
37
+ end
38
+
39
+ def broadcasts
40
+ @broadcasts ||= Resources::Broadcasts.new(self)
41
+ end
42
+
43
+ def segments
44
+ @segments ||= Resources::Segments.new(self)
45
+ end
46
+
47
+ def templates
48
+ @templates ||= Resources::Templates.new(self)
49
+ end
50
+
51
+ def webhook_endpoints
52
+ @webhook_endpoints ||= Resources::WebhookEndpoints.new(self)
53
+ end
54
+
55
+ # @api private
56
+ def request(method, path, body_or_params = nil)
57
+ uri = URI("#{@config.host}#{path}")
58
+
59
+ if method == :get && body_or_params.is_a?(Hash) && body_or_params.any?
60
+ uri.query = URI.encode_www_form(flatten_params(body_or_params))
61
+ end
62
+
63
+ retry_with_backoff do
64
+ http = Net::HTTP.new(uri.host, uri.port)
65
+ http.use_ssl = uri.scheme == 'https'
66
+ http.open_timeout = @config.open_timeout
67
+ http.read_timeout = @config.timeout
68
+
69
+ req = build_request(method, uri)
70
+ req.body = body_or_params.to_json if method != :get && body_or_params.is_a?(Hash) && body_or_params.any?
71
+
72
+ log_request(req, method == :get ? nil : body_or_params) if @config.debug
73
+
74
+ response = http.request(req)
75
+ log_response(response) if @config.debug
76
+ handle_response(response)
77
+ end
78
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
79
+ raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
80
+ end
81
+
82
+ private
83
+
84
+ def build_request(method, uri)
85
+ klass = case method
86
+ when :get then Net::HTTP::Get
87
+ when :post then Net::HTTP::Post
88
+ when :patch then Net::HTTP::Patch
89
+ when :delete then Net::HTTP::Delete
90
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
91
+ end
92
+
93
+ req = klass.new(uri)
94
+ req['Authorization'] = "Bearer #{@config.api_token}"
95
+ req['Content-Type'] = 'application/json'
96
+ req['User-Agent'] = "broadcast-ruby/#{Broadcast::VERSION}"
97
+ req
98
+ end
99
+
100
+ def handle_response(response)
101
+ case response.code.to_i
102
+ when 200, 201
103
+ return {} if response.body.nil? || response.body.strip.empty?
104
+
105
+ JSON.parse(response.body)
106
+ when 401
107
+ raise AuthenticationError, parse_error(response) || 'Authentication failed'
108
+ when 404
109
+ raise NotFoundError, parse_error(response) || 'Resource not found'
110
+ when 422
111
+ raise ValidationError, parse_error(response) || 'Validation failed'
112
+ when 429
113
+ raise RateLimitError, parse_error(response) || 'Rate limit exceeded'
114
+ when 500, 502, 503, 504
115
+ raise APIError, parse_error(response) || "Server error (#{response.code})"
116
+ else
117
+ raise APIError, parse_error(response) || "Unexpected response: #{response.code}"
118
+ end
119
+ end
120
+
121
+ def parse_error(response)
122
+ JSON.parse(response.body)['error']
123
+ rescue JSON::ParserError
124
+ nil
125
+ end
126
+
127
+ def retry_with_backoff
128
+ attempts = 0
129
+ begin
130
+ attempts += 1
131
+ yield
132
+ rescue Net::OpenTimeout, Net::ReadTimeout
133
+ raise if attempts >= @config.retry_attempts
134
+
135
+ sleep(@config.retry_delay * attempts)
136
+ retry
137
+ rescue APIError => e
138
+ raise unless attempts < @config.retry_attempts && e.message.include?('Server error')
139
+
140
+ sleep(@config.retry_delay * attempts)
141
+ retry
142
+ end
143
+ end
144
+
145
+ def flatten_params(params)
146
+ result = []
147
+ params.each do |key, value|
148
+ case value
149
+ when Array
150
+ value.each { |v| result << ["#{key}[]", v.to_s] }
151
+ when Hash
152
+ value.each { |k, v| result << ["#{key}[#{k}]", v.to_s] }
153
+ when nil
154
+ next
155
+ else
156
+ result << [key.to_s, value.to_s]
157
+ end
158
+ end
159
+ result
160
+ end
161
+
162
+ def log_request(request, body)
163
+ return unless @config.logger
164
+
165
+ @config.logger.debug("[Broadcast] #{request.method} #{request.uri}")
166
+ @config.logger.debug("[Broadcast] Body: #{body.to_json}") if body.is_a?(Hash) && body.any?
167
+ end
168
+
169
+ def log_response(response)
170
+ return unless @config.logger
171
+
172
+ @config.logger.debug("[Broadcast] Response: #{response.code} #{response.body}")
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ class Configuration
5
+ attr_accessor :api_token,
6
+ :host,
7
+ :timeout,
8
+ :open_timeout,
9
+ :retry_attempts,
10
+ :retry_delay,
11
+ :logger,
12
+ :debug
13
+
14
+ def initialize
15
+ @api_token = nil
16
+ @host = 'https://sendbroadcast.com'
17
+ @timeout = 30
18
+ @open_timeout = 10
19
+ @retry_attempts = 3
20
+ @retry_delay = 1
21
+ @logger = nil
22
+ @debug = false
23
+ end
24
+
25
+ def validate!
26
+ raise ConfigurationError, 'api_token is required' if api_token.nil? || api_token.to_s.strip.empty?
27
+
28
+ self.host = host.chomp('/') if host&.end_with?('/')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class APIError < Error; end
9
+
10
+ class AuthenticationError < APIError; end
11
+
12
+ class NotFoundError < APIError; end
13
+
14
+ class RateLimitError < APIError; end
15
+
16
+ class ValidationError < Error; end
17
+
18
+ class TimeoutError < Error; end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Base
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ private
11
+
12
+ def get(path, params = {})
13
+ @client.request(:get, path, params)
14
+ end
15
+
16
+ def post(path, body = {})
17
+ @client.request(:post, path, body)
18
+ end
19
+
20
+ def patch(path, body = {})
21
+ @client.request(:patch, path, body)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Broadcasts < Base
6
+ def list(**params)
7
+ get('/api/v1/broadcasts', params)
8
+ end
9
+
10
+ def get_broadcast(id)
11
+ get("/api/v1/broadcasts/#{id}")
12
+ end
13
+
14
+ def create(**attrs)
15
+ post('/api/v1/broadcasts', attrs)
16
+ end
17
+
18
+ def update(id, **attrs)
19
+ patch("/api/v1/broadcasts/#{id}", attrs)
20
+ end
21
+
22
+ def delete(id)
23
+ @client.request(:delete, "/api/v1/broadcasts/#{id}")
24
+ end
25
+
26
+ def send_broadcast(id)
27
+ post("/api/v1/broadcasts/#{id}/send_broadcast")
28
+ end
29
+
30
+ def schedule(id, scheduled_send_at:, scheduled_timezone:)
31
+ post("/api/v1/broadcasts/#{id}/schedule_broadcast", {
32
+ scheduled_send_at: scheduled_send_at,
33
+ scheduled_timezone: scheduled_timezone
34
+ })
35
+ end
36
+
37
+ def cancel_schedule(id)
38
+ post("/api/v1/broadcasts/#{id}/cancel_schedule")
39
+ end
40
+
41
+ def statistics(id)
42
+ get("/api/v1/broadcasts/#{id}/statistics")
43
+ end
44
+
45
+ def statistics_timeline(id, **params)
46
+ get("/api/v1/broadcasts/#{id}/statistics/timeline", params)
47
+ end
48
+
49
+ def statistics_links(id, **params)
50
+ get("/api/v1/broadcasts/#{id}/statistics/links", params)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Segments < Base
6
+ def list(**params)
7
+ get('/api/v1/segments.json', params)
8
+ end
9
+
10
+ def get_segment(id, page: nil)
11
+ params = page ? { page: page } : {}
12
+ get("/api/v1/segments/#{id}.json", params)
13
+ end
14
+
15
+ def create(**attrs)
16
+ post('/api/v1/segments', { segment: attrs })
17
+ end
18
+
19
+ def update(id, **attrs)
20
+ patch("/api/v1/segments/#{id}", { segment: attrs })
21
+ end
22
+
23
+ def delete(id)
24
+ @client.request(:delete, "/api/v1/segments/#{id}")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Sequences < Base
6
+ def list(**params)
7
+ get('/api/v1/sequences', params)
8
+ end
9
+
10
+ def get_sequence(id, include_steps: false)
11
+ params = include_steps ? { include_steps: true } : {}
12
+ get("/api/v1/sequences/#{id}", params)
13
+ end
14
+
15
+ def create(**attrs)
16
+ post('/api/v1/sequences', attrs)
17
+ end
18
+
19
+ def update(id, **attrs)
20
+ patch("/api/v1/sequences/#{id}", attrs)
21
+ end
22
+
23
+ def delete(id)
24
+ @client.request(:delete, "/api/v1/sequences/#{id}")
25
+ end
26
+
27
+ # --- Subscriber enrollment ---
28
+
29
+ def add_subscriber(sequence_id, **attrs)
30
+ post("/api/v1/sequences/#{sequence_id}/add_subscriber", attrs)
31
+ end
32
+
33
+ def remove_subscriber(sequence_id, email:)
34
+ @client.request(:delete, "/api/v1/sequences/#{sequence_id}/remove_subscriber", { email: email })
35
+ end
36
+
37
+ def list_subscribers(sequence_id, page: 1)
38
+ get("/api/v1/sequences/#{sequence_id}/list_subscribers", { page: page })
39
+ end
40
+
41
+ # --- Steps ---
42
+
43
+ def list_steps(sequence_id)
44
+ get("/api/v1/sequences/#{sequence_id}/steps")
45
+ end
46
+
47
+ def get_step(sequence_id, step_id)
48
+ get("/api/v1/sequences/#{sequence_id}/steps/#{step_id}")
49
+ end
50
+
51
+ def create_step(sequence_id, **attrs)
52
+ post("/api/v1/sequences/#{sequence_id}/steps", attrs)
53
+ end
54
+
55
+ def update_step(sequence_id, step_id, **attrs)
56
+ patch("/api/v1/sequences/#{sequence_id}/steps/#{step_id}", attrs)
57
+ end
58
+
59
+ def move_step(sequence_id, step_id, under_id:)
60
+ post("/api/v1/sequences/#{sequence_id}/steps/#{step_id}/move", { under_id: under_id })
61
+ end
62
+
63
+ def delete_step(sequence_id, step_id)
64
+ @client.request(:delete, "/api/v1/sequences/#{sequence_id}/steps/#{step_id}")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Subscribers < Base
6
+ def list(**params)
7
+ get('/api/v1/subscribers.json', params)
8
+ end
9
+
10
+ def find(email:)
11
+ get('/api/v1/subscribers/find.json', { email: email })
12
+ end
13
+
14
+ def create(**attrs)
15
+ post('/api/v1/subscribers.json', { subscriber: attrs })
16
+ end
17
+
18
+ def update(email, **attrs)
19
+ patch('/api/v1/subscribers.json', { email: email, subscriber: attrs })
20
+ end
21
+
22
+ def add_tags(email, tags)
23
+ post('/api/v1/subscribers/add_tag.json', { email: email, tags: tags })
24
+ end
25
+
26
+ def remove_tags(email, tags)
27
+ @client.request(:delete, '/api/v1/subscribers/remove_tag.json', { email: email, tags: tags })
28
+ end
29
+
30
+ def deactivate(email)
31
+ post('/api/v1/subscribers/deactivate.json', { email: email })
32
+ end
33
+
34
+ def activate(email)
35
+ post('/api/v1/subscribers/activate.json', { email: email })
36
+ end
37
+
38
+ def unsubscribe(email)
39
+ post('/api/v1/subscribers/unsubscribe.json', { email: email })
40
+ end
41
+
42
+ def resubscribe(email)
43
+ post('/api/v1/subscribers/resubscribe.json', { email: email })
44
+ end
45
+
46
+ def redact(email)
47
+ post('/api/v1/subscribers/redact.json', { email: email })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Templates < Base
6
+ def list(**params)
7
+ get('/api/v1/templates', params)
8
+ end
9
+
10
+ def get_template(id)
11
+ get("/api/v1/templates/#{id}")
12
+ end
13
+
14
+ def create(**attrs)
15
+ post('/api/v1/templates', { template: attrs })
16
+ end
17
+
18
+ def update(id, **attrs)
19
+ patch("/api/v1/templates/#{id}", { template: attrs })
20
+ end
21
+
22
+ def delete(id)
23
+ @client.request(:delete, "/api/v1/templates/#{id}")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class WebhookEndpoints < Base
6
+ def list(**params)
7
+ get('/api/v1/webhook_endpoints', params)
8
+ end
9
+
10
+ def get_endpoint(id)
11
+ get("/api/v1/webhook_endpoints/#{id}")
12
+ end
13
+
14
+ def create(**attrs)
15
+ post('/api/v1/webhook_endpoints', { webhook_endpoint: attrs })
16
+ end
17
+
18
+ def update(id, **attrs)
19
+ patch("/api/v1/webhook_endpoints/#{id}", { webhook_endpoint: attrs })
20
+ end
21
+
22
+ def delete(id)
23
+ @client.request(:delete, "/api/v1/webhook_endpoints/#{id}")
24
+ end
25
+
26
+ def test(id, event_type: 'test.webhook')
27
+ post("/api/v1/webhook_endpoints/#{id}/test", { event_type: event_type })
28
+ end
29
+
30
+ def deliveries(id, **params)
31
+ get("/api/v1/webhook_endpoints/#{id}/deliveries", params)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module Broadcast
7
+ module Webhook
8
+ TIMESTAMP_TOLERANCE = 300 # 5 minutes
9
+
10
+ module_function
11
+
12
+ def verify(payload, signature_header, timestamp_header, secret:, now: nil)
13
+ return false if payload.nil? || signature_header.nil? || timestamp_header.nil? || secret.nil?
14
+
15
+ timestamp = timestamp_header.to_i
16
+ current_time = (now || Time.now).to_i
17
+ return false unless timestamp_valid?(timestamp, current_time)
18
+
19
+ expected = compute_signature(payload, timestamp, secret)
20
+ actual = extract_signature(signature_header)
21
+ return false if actual.nil?
22
+
23
+ secure_compare(expected, actual)
24
+ end
25
+
26
+ def compute_signature(payload, timestamp, secret)
27
+ signed_content = "#{timestamp}.#{payload}"
28
+ hmac = OpenSSL::HMAC.digest('SHA256', secret, signed_content)
29
+ Base64.strict_encode64(hmac)
30
+ end
31
+
32
+ def timestamp_valid?(timestamp, current_time = Time.now.to_i)
33
+ (current_time - timestamp).abs <= TIMESTAMP_TOLERANCE
34
+ end
35
+
36
+ def extract_signature(header)
37
+ return nil unless header&.start_with?('v1,')
38
+
39
+ sig = header.delete_prefix('v1,')
40
+ return nil if sig.empty?
41
+
42
+ sig
43
+ end
44
+
45
+ def secure_compare(a, b)
46
+ return false unless a.bytesize == b.bytesize
47
+
48
+ OpenSSL.fixed_length_secure_compare(a, b)
49
+ end
50
+ end
51
+ end
data/lib/broadcast.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'broadcast/version'
4
+ require_relative 'broadcast/errors'
5
+ require_relative 'broadcast/configuration'
6
+ require_relative 'broadcast/client'
7
+ require_relative 'broadcast/webhook'
8
+ require_relative 'broadcast/resources/base'
9
+ require_relative 'broadcast/resources/subscribers'
10
+ require_relative 'broadcast/resources/sequences'
11
+ require_relative 'broadcast/resources/broadcasts'
12
+ require_relative 'broadcast/resources/segments'
13
+ require_relative 'broadcast/resources/templates'
14
+ require_relative 'broadcast/resources/webhook_endpoints'
15
+
16
+ module Broadcast
17
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: broadcast-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Chiu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Full API client for Broadcast. Subscribers, sequences, broadcasts, segments,
27
+ templates, webhooks, and transactional email. Works with any Broadcast instance.
28
+ email:
29
+ - simon@furvur.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rubocop.yml"
35
+ - Gemfile
36
+ - Gemfile.lock
37
+ - LICENSE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - lib/broadcast.rb
41
+ - lib/broadcast/client.rb
42
+ - lib/broadcast/configuration.rb
43
+ - lib/broadcast/errors.rb
44
+ - lib/broadcast/resources/base.rb
45
+ - lib/broadcast/resources/broadcasts.rb
46
+ - lib/broadcast/resources/segments.rb
47
+ - lib/broadcast/resources/sequences.rb
48
+ - lib/broadcast/resources/subscribers.rb
49
+ - lib/broadcast/resources/templates.rb
50
+ - lib/broadcast/resources/webhook_endpoints.rb
51
+ - lib/broadcast/version.rb
52
+ - lib/broadcast/webhook.rb
53
+ homepage: https://github.com/send-broadcast/broadcast-ruby
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ allowed_push_host: https://rubygems.org
58
+ homepage_uri: https://github.com/send-broadcast/broadcast-ruby
59
+ source_code_uri: https://github.com/send-broadcast/broadcast-ruby
60
+ changelog_uri: https://github.com/send-broadcast/broadcast-ruby/blob/main/CHANGELOG.md
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.7.2
77
+ specification_version: 4
78
+ summary: Ruby client for the Broadcast email platform
79
+ test_files: []