zelf-sdk 1.0.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,170 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+ require 'time'
5
+
6
+ module Zelf
7
+ # The production ZelfAI API.
8
+ DEFAULT_BASE_URL = 'https://api.hellozelf.com'.freeze
9
+ # Read when no key is passed explicitly.
10
+ API_KEY_ENV = 'ZELF_API_KEY'.freeze
11
+ # Read when no base URL is passed explicitly.
12
+ BASE_URL_ENV = 'ZELF_BASE_URL'.freeze
13
+
14
+ # Carries out the HTTP work: auth, retries, and envelope decoding.
15
+ class Transport
16
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
17
+ # The API documents no Idempotency-Key header, so a retried write could
18
+ # create a second analysis, vetting, or sync job.
19
+ RETRY_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
20
+ MAX_BACKOFF = 8.0
21
+
22
+ attr_reader :base_url, :max_retries, :retry_writes
23
+
24
+ def initialize(api_key: nil, base_url: nil, timeout: 60,
25
+ max_retries: 2, retry_writes: false, headers: {})
26
+ base_url ||= ENV[BASE_URL_ENV]
27
+ base_url = DEFAULT_BASE_URL if base_url.nil? || base_url.empty?
28
+ key = api_key || ENV[API_KEY_ENV]
29
+ raise ArgumentError, "an API key is required; pass api_key: or set #{API_KEY_ENV}" if key.nil? || key.empty?
30
+ raise ArgumentError, 'max_retries must be zero or greater' if max_retries.negative?
31
+
32
+ @api_key = key
33
+ @base_url = base_url.sub(%r{/+\z}, '')
34
+ @timeout = timeout
35
+ @max_retries = max_retries
36
+ @retry_writes = retry_writes
37
+ # A caller's copy of the API key is dropped whatever its casing, so it
38
+ # cannot replace the client's own.
39
+ @headers = headers.reject { |name, _| name.to_s.casecmp?('x-api-key') }
40
+ end
41
+
42
+ # The key must never reach a log line.
43
+ def inspect
44
+ "#<Zelf::Transport base_url=#{@base_url.inspect} api_key=\"***\">"
45
+ end
46
+
47
+ def resolve_path(path, path_params)
48
+ path_params.each do |name, value|
49
+ if value.nil? || value.to_s.empty?
50
+ raise ArgumentError, "#{name} is required and must not be empty"
51
+ end
52
+
53
+ path = path.sub("{#{name}}", URI.encode_www_form_component(value.to_s).gsub('+', '%20'))
54
+ end
55
+ path
56
+ end
57
+
58
+ def self.encode_query(query)
59
+ pairs = []
60
+ query.each do |key, value|
61
+ next if value.nil?
62
+
63
+ Array(value).each do |item|
64
+ next if item.nil?
65
+
66
+ pairs << [key.to_s, stringify(item)]
67
+ end
68
+ end
69
+ pairs
70
+ end
71
+
72
+ def self.stringify(value)
73
+ case value
74
+ when true then 'true'
75
+ when false then 'false'
76
+ when Time then value.iso8601
77
+ when Date then value.iso8601
78
+ else value.to_s
79
+ end
80
+ end
81
+
82
+ def request(method, path, path_params: {}, query: {}, body: nil)
83
+ resolved = resolve_path(path.dup, path_params)
84
+ pairs = self.class.encode_query(query)
85
+ last = nil
86
+
87
+ (0..@max_retries).each do |attempt|
88
+ response = nil
89
+ begin
90
+ response = send_once(method, resolved, pairs, body)
91
+ return decode(response) if response.code.to_i < 400
92
+
93
+ last = Errors.from_response(response)
94
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
95
+ last = TimeoutError.new(e.message)
96
+ rescue SystemCallError, SocketError, IOError => e
97
+ last = ConnectionError.new(e.message)
98
+ end
99
+
100
+ break if attempt == @max_retries || !retry?(method, response)
101
+
102
+ sleep(backoff(attempt, response))
103
+ end
104
+
105
+ raise(last || ConnectionError.new('the request was never sent'))
106
+ end
107
+
108
+ private
109
+
110
+ def retry?(method, response)
111
+ return false unless RETRY_SAFE_METHODS.include?(method.to_s.upcase) || @retry_writes
112
+ return true if response.nil?
113
+
114
+ RETRY_STATUSES.include?(response.code.to_i)
115
+ end
116
+
117
+ def backoff(attempt, response)
118
+ header = response && response['retry-after']
119
+ if header
120
+ seconds = Float(header, exception: false)
121
+ return [seconds, 60.0].min if seconds && seconds >= 0
122
+ end
123
+ base = [MAX_BACKOFF, 0.5 * (2**attempt)].min
124
+ base * (0.5 + (rand / 2))
125
+ end
126
+
127
+ def send_once(method, path, pairs, body)
128
+ uri = URI.parse("#{@base_url}#{path}")
129
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
130
+
131
+ request_class = Net::HTTP.const_get(method.to_s.capitalize)
132
+ request = request_class.new(uri)
133
+ request['accept'] = 'application/json'
134
+ request['user-agent'] = "zelf-ruby/#{Zelf::VERSION}"
135
+ @headers.each { |name, value| request[name.to_s] = value.to_s }
136
+ # Last, so the client's own key always wins.
137
+ request['x-api-key'] = @api_key
138
+ if body
139
+ request['content-type'] = 'application/json'
140
+ request.body = JSON.generate(body)
141
+ end
142
+
143
+ Net::HTTP.start(uri.hostname, uri.port,
144
+ use_ssl: uri.scheme == 'https',
145
+ open_timeout: @timeout,
146
+ read_timeout: @timeout) do |http|
147
+ http.request(request)
148
+ end
149
+ end
150
+
151
+ def decode(response)
152
+ return {} if response.code.to_i == 204
153
+
154
+ raw = response.body.to_s
155
+ return {} if raw.strip.empty?
156
+
157
+ parsed = begin
158
+ JSON.parse(raw)
159
+ rescue JSON::ParserError
160
+ raise APIError.new('the API returned a response that is not valid JSON',
161
+ status: response.code.to_i)
162
+ end
163
+ unless parsed.is_a?(Hash)
164
+ raise APIError.new('the API returned a response envelope that is not an object',
165
+ status: response.code.to_i)
166
+ end
167
+ parsed
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,5 @@
1
+ # Generated by codegen. Do not edit by hand.
2
+
3
+ module Zelf
4
+ VERSION = '1.0.0'.freeze
5
+ end
@@ -0,0 +1,293 @@
1
+ # Generated by codegen. Do not edit by hand.
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'openssl'
7
+ require 'uri'
8
+
9
+ module Zelf
10
+ # Verifies and routes the deliveries ZelfAI posts to a receiver.
11
+ module Webhooks
12
+ SIGNATURE_HEADER = 'Webhook-Signature'.freeze
13
+ TIMESTAMP_HEADER = 'Webhook-Timestamp'.freeze
14
+ ID_HEADER = 'Webhook-Id'.freeze
15
+
16
+ # How far a delivery's timestamp may sit from now before it reads as a replay.
17
+ DEFAULT_TOLERANCE_SECONDS = 300
18
+
19
+ # Event types the API can deliver, one per `webhooks` declaration.
20
+ module EventType
21
+ # Fires when an AI analysis run reaches a terminal state (COMPLETED or FAILED)
22
+ AI_ANALYSIS_COMPLETED = "ai_analysis.completed".freeze
23
+ # Scheduled snapshot of the content set that a subscription matches
24
+ CONTENT_SNAPSHOT = "content.snapshot".freeze
25
+ # Scheduled snapshot of the creator set that a subscription matches
26
+ CREATOR_SNAPSHOT = "creator.snapshot".freeze
27
+ # Fires when a creator vetting analysis run reaches a terminal state (COMPLETED or...
28
+ CREATOR_VETTING_COMPLETED = "creator_vetting.completed".freeze
29
+
30
+ ALL = [AI_ANALYSIS_COMPLETED, CONTENT_SNAPSHOT, CREATOR_SNAPSHOT, CREATOR_VETTING_COMPLETED].freeze
31
+
32
+ # Whether the API declares this event type.
33
+ def self.known?(value)
34
+ ALL.include?(value.to_s)
35
+ end
36
+ end
37
+
38
+ # Raised for every failed verification. Answer one with a 400 and never
39
+ # process the body.
40
+ class VerificationError < StandardError; end
41
+
42
+ module_function
43
+
44
+ def header(headers, name)
45
+ wanted = name.downcase
46
+ headers.each do |key, value|
47
+ return Array(value).first if key.to_s.downcase == wanted
48
+ end
49
+ nil
50
+ end
51
+
52
+ def parse_signature(raw)
53
+ timestamp = nil
54
+ digests = []
55
+ raw.to_s.split(',').each do |part|
56
+ key, _, value = part.strip.partition('=')
57
+ if key == 't' && timestamp.nil?
58
+ timestamp = Integer(value, exception: false)
59
+ raise VerificationError, 'signature timestamp is not an integer' if timestamp.nil?
60
+ elsif key == 'v1'
61
+ digests << value
62
+ end
63
+ end
64
+ raise VerificationError, 'signature header is missing a t or v1 component' if timestamp.nil? || digests.empty?
65
+
66
+ [timestamp, digests]
67
+ end
68
+
69
+ # Verify a delivery and return the decoded payload as a plain hash.
70
+ #
71
+ # +payload+ must be the exact bytes received: re-encoding the JSON changes
72
+ # the bytes and the signature will not match.
73
+ #
74
+ # A rotation switches at once: the API signs the next delivery with the new
75
+ # secret only and rejects the old one immediately. An array of secrets is
76
+ # accepted, so both can be held during a rollout.
77
+ #
78
+ # The comparison runs through OpenSSL.secure_compare, which is constant
79
+ # time, so a wrong signature cannot be recovered from response timing.
80
+ def verify(payload, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
81
+ secrets = Array(secret)
82
+ raise ArgumentError, 'at least one signing secret is required' if secrets.empty?
83
+
84
+ raw = header(headers, SIGNATURE_HEADER)
85
+ raise VerificationError, "missing #{SIGNATURE_HEADER} header" if raw.nil? || raw.empty?
86
+
87
+ timestamp, digests = parse_signature(raw)
88
+
89
+ if tolerance_seconds.positive?
90
+ age = ((now || Time.now.to_f) - timestamp).abs
91
+ if age > tolerance_seconds
92
+ raise VerificationError,
93
+ "timestamp is #{age.round}s away from now, outside the " \
94
+ "#{tolerance_seconds}s tolerance, so this is a replay"
95
+ end
96
+ end
97
+
98
+ signed = "#{timestamp}.#{payload}"
99
+ matched = false
100
+ secrets.each do |candidate|
101
+ want = OpenSSL::HMAC.hexdigest('SHA256', candidate, signed)
102
+ digests.each do |got|
103
+ matched = true if secure_compare(want, got)
104
+ end
105
+ end
106
+ raise VerificationError, 'no signature matched the supplied secrets' unless matched
107
+
108
+ body = begin
109
+ JSON.parse(payload)
110
+ rescue JSON::ParserError
111
+ raise VerificationError, 'body is not valid JSON'
112
+ end
113
+ raise VerificationError, 'body is not a JSON object' unless body.is_a?(Hash)
114
+
115
+ body
116
+ end
117
+
118
+ def secure_compare(left, right)
119
+ if OpenSSL.respond_to?(:secure_compare)
120
+ OpenSSL.secure_compare(left, right)
121
+ else
122
+ # Ruby 2.6 has no OpenSSL.secure_compare; compare fixed-width digests
123
+ # byte by byte without an early exit.
124
+ return false unless left.bytesize == right.bytesize
125
+
126
+ left.bytes.zip(right.bytes).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero?
127
+ end
128
+ end
129
+
130
+ # Verify a delivery and return it as a typed Event.
131
+ def parse(payload, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
132
+ Event.new(verify(payload, headers, secret,
133
+ tolerance_seconds: tolerance_seconds, now: now))
134
+ end
135
+
136
+ # A delivery whose signature has already been checked.
137
+ class Event
138
+ attr_reader :raw
139
+
140
+ def initialize(raw)
141
+ @raw = raw
142
+ end
143
+
144
+ def id
145
+ @raw['id']
146
+ end
147
+
148
+ # The wire value, which may be newer than this SDK's EVENT_TYPES.
149
+ def type
150
+ @raw['type']
151
+ end
152
+
153
+ # The declared event type, or nil for one the SDK predates.
154
+ def event_type
155
+ EventType.known?(type) ? type : nil
156
+ end
157
+
158
+ def subscription_id
159
+ @raw['subscription_id']
160
+ end
161
+
162
+ def livemode
163
+ @raw['livemode'] ? true : false
164
+ end
165
+
166
+ def delivered_at
167
+ Time.at(@raw['delivered_at'].to_i).utc
168
+ end
169
+
170
+ def delivery_mode
171
+ @raw['delivery_mode']
172
+ end
173
+
174
+ def data
175
+ @raw['data'] || {}
176
+ end
177
+
178
+ def item_count
179
+ data['item_count'].to_i
180
+ end
181
+
182
+ # Downloadable snapshot files. Populated in +manifest+ mode only.
183
+ def files
184
+ Array(data['files'])
185
+ end
186
+
187
+ # Rows carried in the body. Populated in +inline+ mode only.
188
+ def items
189
+ Array(data['items'])
190
+ end
191
+
192
+ def statistics
193
+ data['statistics']
194
+ end
195
+
196
+ def inspect
197
+ "#<Zelf::Webhooks::Event #{type} id=#{id} items=#{item_count}>"
198
+ end
199
+
200
+ # Download the manifest files and yield one decoded row per line.
201
+ #
202
+ # Each download_url is signed and expires, so fetch soon after delivery.
203
+ # With +verify_checksum+ the published SHA-256 is recomputed over the
204
+ # body, and a mismatch or a missing digest raises before any row is
205
+ # yielded.
206
+ def rows(verify_checksum: true, http_get: nil)
207
+ return enum_for(:rows, verify_checksum: verify_checksum, http_get: http_get) unless block_given?
208
+
209
+ files.each do |handle|
210
+ body = (http_get || method(:fetch)).call(handle['download_url'])
211
+ if verify_checksum
212
+ published = handle['sha256'].to_s
213
+ if published.empty?
214
+ raise VerificationError,
215
+ "the manifest published no sha256 for #{handle['download_url']}, so the " \
216
+ 'file cannot be verified; pass verify_checksum: false to read it unchecked'
217
+ end
218
+ digest = Digest::SHA256.hexdigest(body)
219
+ unless Webhooks.secure_compare(digest, published)
220
+ raise VerificationError,
221
+ "downloaded file digest #{digest} does not match the published #{published}"
222
+ end
223
+ end
224
+ body.each_line do |line|
225
+ next if line.strip.empty?
226
+
227
+ yield JSON.parse(line)
228
+ end
229
+ end
230
+ nil
231
+ end
232
+
233
+ private
234
+
235
+ def fetch(url)
236
+ uri = URI.parse(url)
237
+ response = Net::HTTP.get_response(uri)
238
+ unless response.code.to_i < 400
239
+ raise VerificationError, "downloading the snapshot failed with HTTP #{response.code}"
240
+ end
241
+
242
+ response.body.to_s
243
+ end
244
+ end
245
+
246
+ # Verify a delivery once, then route it to the listener for its type.
247
+ #
248
+ # Framework agnostic: hand it the raw body and the request headers from
249
+ # whatever server is in front of it.
250
+ class Handler
251
+ def initialize(secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
252
+ @secret = secret
253
+ @tolerance_seconds = tolerance_seconds
254
+ @listeners = {}
255
+ @fallback = nil
256
+ end
257
+
258
+ # Register a listener for one or more event types.
259
+ def on(*event_types, &listener)
260
+ raise ArgumentError, 'pass at least one event type to listen for' if event_types.empty?
261
+
262
+ unknown = event_types.map(&:to_s).reject { |name| EventType.known?(name) }
263
+ unless unknown.empty?
264
+ raise ArgumentError,
265
+ "unknown event type(s) #{unknown.join(', ')}. " \
266
+ "Known types: #{EventType::ALL.join(', ')}"
267
+ end
268
+
269
+ event_types.each { |name| (@listeners[name.to_s] ||= []) << listener }
270
+ self
271
+ end
272
+
273
+ # Register the listener used when no type specific one matched.
274
+ def on_any(&listener)
275
+ @fallback = listener
276
+ self
277
+ end
278
+
279
+ # Verify, then call every listener registered for the event type. Raises
280
+ # before any listener runs if the delivery does not check out.
281
+ def handle(payload, headers, now: nil)
282
+ event = Webhooks.parse(payload, headers, @secret,
283
+ tolerance_seconds: @tolerance_seconds, now: now)
284
+ listeners = @listeners[event.type]
285
+ if listeners.nil? || listeners.empty?
286
+ return @fallback ? [@fallback.call(event)] : []
287
+ end
288
+
289
+ listeners.map { |listener| listener.call(event) }
290
+ end
291
+ end
292
+ end
293
+ end
data/lib/zelf.rb ADDED
@@ -0,0 +1,59 @@
1
+ # Generated by codegen. Do not edit by hand.
2
+ require_relative 'zelf/version'
3
+ require_relative 'zelf/errors'
4
+ require_relative 'zelf/model'
5
+ require_relative 'zelf/models'
6
+ require_relative 'zelf/transport'
7
+ require_relative 'zelf/page'
8
+ require_relative 'zelf/webhooks'
9
+ require_relative 'zelf/resources/ai_analysis'
10
+ require_relative 'zelf/resources/brand_accounts'
11
+ require_relative 'zelf/resources/brands'
12
+ require_relative 'zelf/resources/comments'
13
+ require_relative 'zelf/resources/content'
14
+ require_relative 'zelf/resources/creator_sync'
15
+ require_relative 'zelf/resources/creator_vetting'
16
+ require_relative 'zelf/resources/creators'
17
+ require_relative 'zelf/resources/detection'
18
+ require_relative 'zelf/resources/sub_brands'
19
+ require_relative 'zelf/resources/usage'
20
+ require_relative 'zelf/resources/webhooks'
21
+
22
+ # Ruby client for the ZelfAI APIs (spec version 1.0.0).
23
+ module Zelf
24
+ # Client for the ZelfAI APIs.
25
+ #
26
+ # The key comes from the api_key: argument, falling back to the
27
+ # ZELF_API_KEY environment variable. It is sent as the x-api-key header
28
+ # and is kept out of inspect output.
29
+ class Client
30
+ attr_reader :ai_analysis, :brand_accounts, :brands, :comments, :content, :creator_sync, :creator_vetting, :creators, :detection, :sub_brands, :usage, :webhooks
31
+
32
+ def initialize(api_key: nil, base_url: nil, timeout: 60,
33
+ max_retries: 2, retry_writes: false, headers: {})
34
+ @transport = Transport.new(api_key: api_key, base_url: base_url, timeout: timeout,
35
+ max_retries: max_retries, retry_writes: retry_writes,
36
+ headers: headers)
37
+ @ai_analysis = Resources::AiAnalysis.new(@transport)
38
+ @brand_accounts = Resources::BrandAccounts.new(@transport)
39
+ @brands = Resources::Brands.new(@transport)
40
+ @comments = Resources::Comments.new(@transport)
41
+ @content = Resources::Content.new(@transport)
42
+ @creator_sync = Resources::CreatorSync.new(@transport)
43
+ @creator_vetting = Resources::CreatorVetting.new(@transport)
44
+ @creators = Resources::Creators.new(@transport)
45
+ @detection = Resources::Detection.new(@transport)
46
+ @sub_brands = Resources::SubBrands.new(@transport)
47
+ @usage = Resources::Usage.new(@transport)
48
+ @webhooks = Resources::Webhooks.new(@transport)
49
+ end
50
+
51
+ def base_url
52
+ @transport.base_url
53
+ end
54
+
55
+ def inspect
56
+ "#<Zelf::Client base_url=#{base_url.inspect} api_key=\"***\">"
57
+ end
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zelf-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Zelf
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Official Ruby client for the Zelf API.
13
+ executables: []
14
+ extensions: []
15
+ extra_rdoc_files: []
16
+ files:
17
+ - LICENSE
18
+ - README.md
19
+ - lib/zelf.rb
20
+ - lib/zelf/errors.rb
21
+ - lib/zelf/model.rb
22
+ - lib/zelf/models.rb
23
+ - lib/zelf/page.rb
24
+ - lib/zelf/resources/ai_analysis.rb
25
+ - lib/zelf/resources/brand_accounts.rb
26
+ - lib/zelf/resources/brands.rb
27
+ - lib/zelf/resources/comments.rb
28
+ - lib/zelf/resources/content.rb
29
+ - lib/zelf/resources/creator_sync.rb
30
+ - lib/zelf/resources/creator_vetting.rb
31
+ - lib/zelf/resources/creators.rb
32
+ - lib/zelf/resources/detection.rb
33
+ - lib/zelf/resources/sub_brands.rb
34
+ - lib/zelf/resources/usage.rb
35
+ - lib/zelf/resources/webhooks.rb
36
+ - lib/zelf/transport.rb
37
+ - lib/zelf/version.rb
38
+ - lib/zelf/webhooks.rb
39
+ homepage: https://github.com/hellozelf/zelf-sdk
40
+ licenses:
41
+ - Apache-2.0
42
+ metadata:
43
+ rubygems_mfa_required: 'true'
44
+ source_code_uri: https://github.com/hellozelf/zelf-sdk
45
+ changelog_uri: https://github.com/hellozelf/zelf-sdk/blob/main/CHANGELOG.md
46
+ bug_tracker_uri: https://github.com/hellozelf/zelf-sdk/issues
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.6.9
62
+ specification_version: 4
63
+ summary: Ruby client for the ZelfAI APIs
64
+ test_files: []