fizzy-sdk 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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +18 -0
  3. data/Rakefile +26 -0
  4. data/fizzy-sdk.gemspec +45 -0
  5. data/lib/fizzy/auth_strategy.rb +38 -0
  6. data/lib/fizzy/bulkhead.rb +68 -0
  7. data/lib/fizzy/cache.rb +101 -0
  8. data/lib/fizzy/chain_hooks.rb +45 -0
  9. data/lib/fizzy/circuit_breaker.rb +115 -0
  10. data/lib/fizzy/client.rb +212 -0
  11. data/lib/fizzy/config.rb +143 -0
  12. data/lib/fizzy/cookie_auth.rb +27 -0
  13. data/lib/fizzy/errors.rb +291 -0
  14. data/lib/fizzy/generated/metadata.json +1341 -0
  15. data/lib/fizzy/generated/services/boards_service.rb +91 -0
  16. data/lib/fizzy/generated/services/cards_service.rb +313 -0
  17. data/lib/fizzy/generated/services/columns_service.rb +69 -0
  18. data/lib/fizzy/generated/services/comments_service.rb +68 -0
  19. data/lib/fizzy/generated/services/devices_service.rb +35 -0
  20. data/lib/fizzy/generated/services/identity_service.rb +19 -0
  21. data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
  22. data/lib/fizzy/generated/services/notifications_service.rb +65 -0
  23. data/lib/fizzy/generated/services/pins_service.rb +19 -0
  24. data/lib/fizzy/generated/services/reactions_service.rb +80 -0
  25. data/lib/fizzy/generated/services/sessions_service.rb +58 -0
  26. data/lib/fizzy/generated/services/steps_service.rb +69 -0
  27. data/lib/fizzy/generated/services/tags_service.rb +20 -0
  28. data/lib/fizzy/generated/services/uploads_service.rb +24 -0
  29. data/lib/fizzy/generated/services/users_service.rb +52 -0
  30. data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
  31. data/lib/fizzy/generated/types.rb +988 -0
  32. data/lib/fizzy/hooks.rb +70 -0
  33. data/lib/fizzy/http.rb +411 -0
  34. data/lib/fizzy/logger_hooks.rb +46 -0
  35. data/lib/fizzy/magic_link_flow.rb +57 -0
  36. data/lib/fizzy/noop_hooks.rb +9 -0
  37. data/lib/fizzy/operation_info.rb +17 -0
  38. data/lib/fizzy/rate_limiter.rb +68 -0
  39. data/lib/fizzy/request_info.rb +10 -0
  40. data/lib/fizzy/request_result.rb +14 -0
  41. data/lib/fizzy/resilience.rb +59 -0
  42. data/lib/fizzy/security.rb +103 -0
  43. data/lib/fizzy/services/base_service.rb +116 -0
  44. data/lib/fizzy/static_token_provider.rb +24 -0
  45. data/lib/fizzy/token_provider.rb +42 -0
  46. data/lib/fizzy/version.rb +6 -0
  47. data/lib/fizzy/webhooks/verify.rb +36 -0
  48. data/lib/fizzy.rb +95 -0
  49. data/scripts/generate-metadata.rb +105 -0
  50. data/scripts/generate-services.rb +681 -0
  51. data/scripts/generate-types.rb +160 -0
  52. metadata +252 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Interface for observability hooks.
5
+ # Implement this to add logging, metrics, or tracing to HTTP requests.
6
+ #
7
+ # @example Custom hooks with logging
8
+ # class LoggingHooks
9
+ # include Fizzy::Hooks
10
+ #
11
+ # def on_request_start(info)
12
+ # puts "Starting #{info.method} #{info.url}"
13
+ # end
14
+ #
15
+ # def on_request_end(info, result)
16
+ # puts "Completed #{info.method} #{info.url} - #{result.status_code} (#{result.duration}s)"
17
+ # end
18
+ # end
19
+ #
20
+ # client = Fizzy::Client.new(config: config, token_provider: provider, hooks: LoggingHooks.new)
21
+ module Hooks
22
+ # Called when a service operation starts (e.g., boards.list, cards.create).
23
+ # @param info [OperationInfo] operation information
24
+ # @return [void]
25
+ def on_operation_start(info)
26
+ # Override in implementation
27
+ end
28
+
29
+ # Called when a service operation completes (success or failure).
30
+ # @param info [OperationInfo] operation information
31
+ # @param result [OperationResult] result information
32
+ # @return [void]
33
+ def on_operation_end(info, result)
34
+ # Override in implementation
35
+ end
36
+
37
+ # Called when an HTTP request starts.
38
+ # @param info [RequestInfo] request information
39
+ # @return [void]
40
+ def on_request_start(info)
41
+ # Override in implementation
42
+ end
43
+
44
+ # Called when an HTTP request completes (success or failure).
45
+ # @param info [RequestInfo] request information
46
+ # @param result [RequestResult] result information
47
+ # @return [void]
48
+ def on_request_end(info, result)
49
+ # Override in implementation
50
+ end
51
+
52
+ # Called when a request is retried.
53
+ # @param info [RequestInfo] request information
54
+ # @param attempt [Integer] the next attempt number
55
+ # @param error [Exception] the error that triggered the retry
56
+ # @param delay [Float] seconds until retry
57
+ # @return [void]
58
+ def on_retry(info, attempt, error, delay)
59
+ # Override in implementation
60
+ end
61
+
62
+ # Called when pagination fetches the next page.
63
+ # @param url [String] the next page URL
64
+ # @param page [Integer] the page number
65
+ # @return [void]
66
+ def on_paginate(url, page)
67
+ # Override in implementation
68
+ end
69
+ end
70
+ end
data/lib/fizzy/http.rb ADDED
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Fizzy
9
+ # HTTP client layer with retry, backoff, and caching support.
10
+ # This is an internal class used by Client; you typically don't use it directly.
11
+ class Http
12
+ # Default User-Agent header
13
+ USER_AGENT = "fizzy-sdk-ruby/#{VERSION} (api:#{API_VERSION})".freeze
14
+
15
+ # @param config [Config] configuration settings
16
+ # @param token_provider [TokenProvider, nil] token provider (deprecated, use auth_strategy)
17
+ # @param auth_strategy [AuthStrategy, nil] authentication strategy
18
+ # @param hooks [Hooks] observability hooks
19
+ def initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil)
20
+ @config = config
21
+ @auth_strategy = auth_strategy || BearerAuth.new(token_provider)
22
+ @token_provider = token_provider || (@auth_strategy.is_a?(BearerAuth) ? @auth_strategy.token_provider : nil)
23
+ @hooks = hooks || NoopHooks.new
24
+ @faraday = build_faraday_client
25
+ end
26
+
27
+ # @return [String] the configured base URL
28
+ def base_url
29
+ @config.base_url
30
+ end
31
+
32
+ # Performs a GET request.
33
+ # @param path [String] URL path
34
+ # @param params [Hash] query parameters
35
+ # @return [Response]
36
+ def get(path, params: {})
37
+ request(:get, path, params: params)
38
+ end
39
+
40
+ # Performs a GET request to an absolute URL.
41
+ # Used for endpoints not on the base API.
42
+ # @param url [String] absolute URL
43
+ # @param params [Hash] query parameters
44
+ # @return [Response]
45
+ def get_absolute(url, params: {})
46
+ request(:get, url, params: params)
47
+ end
48
+
49
+ # Performs a POST request.
50
+ # @param path [String] URL path
51
+ # @param body [Hash, nil] request body
52
+ # @param retryable [Boolean, nil] override retry behavior (true for idempotent POSTs)
53
+ # @return [Response]
54
+ def post(path, body: nil, retryable: nil)
55
+ request(:post, path, body: body, retryable: retryable)
56
+ end
57
+
58
+ # Performs a PUT request.
59
+ # @param path [String] URL path
60
+ # @param body [Hash, nil] request body
61
+ # @return [Response]
62
+ def put(path, body: nil)
63
+ request(:put, path, body: body)
64
+ end
65
+
66
+ # Performs a PATCH request.
67
+ # @param path [String] URL path
68
+ # @param body [Hash, nil] request body
69
+ # @return [Response]
70
+ def patch(path, body: nil)
71
+ request(:patch, path, body: body)
72
+ end
73
+
74
+ # Performs a DELETE request.
75
+ # @param path [String] URL path
76
+ # @param retryable [Boolean, nil] override retry behavior
77
+ # @return [Response]
78
+ def delete(path, retryable: nil)
79
+ request(:delete, path, retryable: retryable)
80
+ end
81
+
82
+ # Performs a POST request with raw binary data.
83
+ # Used for file uploads.
84
+ # @param path [String] URL path
85
+ # @param body [String, IO] raw binary data
86
+ # @param content_type [String] MIME content type
87
+ # @return [Response]
88
+ def post_raw(path, body:, content_type:)
89
+ url = build_url(path)
90
+ single_request_raw(:post, url, body: body, content_type: content_type, attempt: 1)
91
+ end
92
+
93
+ # Fetches all pages of a paginated resource.
94
+ # @param path [String] initial URL path
95
+ # @param params [Hash] query parameters
96
+ # @yield [Hash] each item from the response
97
+ # @return [Enumerator] if no block given
98
+ def paginate(path, params: {}, &block)
99
+ return to_enum(:paginate, path, params: params) unless block
100
+
101
+ base_url = build_url(path)
102
+ url = base_url
103
+ page = 0
104
+
105
+ loop do
106
+ page += 1
107
+ break if page > @config.max_pages
108
+
109
+ @hooks.on_paginate(url, page)
110
+ response = get(url, params: page == 1 ? params : {})
111
+
112
+ Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
113
+
114
+ begin
115
+ items = JSON.parse(response.body)
116
+ rescue JSON::ParserError => e
117
+ raise Fizzy::APIError.new(
118
+ "Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}"
119
+ )
120
+ end
121
+ items.each(&block)
122
+
123
+ next_url = parse_next_link(response.headers["Link"])
124
+ break if next_url.nil?
125
+
126
+ next_url = Security.resolve_url(url, next_url)
127
+
128
+ unless Security.same_origin?(next_url, base_url)
129
+ raise Fizzy::APIError.new(
130
+ "Cross-origin pagination link rejected; refusing to follow " \
131
+ "from #{Security.truncate(url.to_s)} to #{Security.truncate(next_url.to_s)}"
132
+ )
133
+ end
134
+
135
+ url = next_url
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def build_faraday_client
142
+ Faraday.new(url: @config.base_url) do |f|
143
+ f.options.timeout = @config.timeout
144
+ f.options.open_timeout = 10
145
+ f.request :json
146
+ f.response :raise_error
147
+ f.adapter Faraday.default_adapter
148
+ end
149
+ end
150
+
151
+ def request(method, path, params: {}, body: nil, retryable: nil)
152
+ url = build_url(path)
153
+ should_retry = retryable.nil? ? (method != :post) : retryable
154
+
155
+ if should_retry
156
+ request_with_retry(method, url, params: params, body: body)
157
+ else
158
+ single_request(method, url, params: params, body: body, attempt: 1)
159
+ end
160
+ end
161
+
162
+ def request_with_retry(method, url, params: {}, body: nil)
163
+ attempt = 0
164
+ last_error = nil
165
+
166
+ loop do
167
+ attempt += 1
168
+ break if attempt > @config.max_retries
169
+
170
+ begin
171
+ return single_request(method, url, params: params, body: body, attempt: attempt)
172
+ rescue Fizzy::RateLimitError, Fizzy::NetworkError, Fizzy::APIError => e
173
+ raise e unless e.retryable?
174
+
175
+ last_error = e
176
+
177
+ # Don't sleep if this was the last attempt
178
+ break if attempt >= @config.max_retries
179
+
180
+ delay = calculate_delay(attempt, e.retry_after)
181
+
182
+ @hooks.on_retry(
183
+ RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt),
184
+ attempt + 1, e, delay
185
+ )
186
+ sleep(delay)
187
+ end
188
+ end
189
+
190
+ raise last_error || Fizzy::APIError.new("Request failed after #{@config.max_retries} retries")
191
+ end
192
+
193
+ def single_request(method, url, params:, body:, attempt:, retry_count: 0)
194
+ info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
195
+ @hooks.on_request_start(info)
196
+
197
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
+
199
+ begin
200
+ response = @faraday.run_request(method, url, body, request_headers) do |req|
201
+ req.params.merge!(params) if params.any?
202
+ end
203
+
204
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
205
+ result = RequestResult.new(status_code: response.status, duration: duration)
206
+ @hooks.on_request_end(info, result)
207
+
208
+ Response.new(
209
+ body: response.body,
210
+ status: response.status,
211
+ headers: response.headers
212
+ )
213
+ rescue Faraday::ServerError, Faraday::ClientError => e
214
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
215
+ error = handle_error(e)
216
+ result = RequestResult.new(
217
+ status_code: e.response&.dig(:status),
218
+ duration: duration,
219
+ error: error,
220
+ retry_after: error.respond_to?(:retry_after) ? error.retry_after : nil
221
+ )
222
+ @hooks.on_request_end(info, result)
223
+
224
+ # After a successful token refresh on 401, retry the request once
225
+ if error.is_a?(Fizzy::AuthError) && error.http_status == 401 && retry_count < 1 && @token_refreshed
226
+ @token_refreshed = false
227
+ return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1)
228
+ end
229
+
230
+ raise error
231
+ rescue Faraday::Error => e
232
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
233
+ error = Fizzy::NetworkError.new("Connection failed", cause: e)
234
+ result = RequestResult.new(duration: duration, error: error)
235
+ @hooks.on_request_end(info, result)
236
+ raise error
237
+ end
238
+ end
239
+
240
+ def request_headers
241
+ headers = {
242
+ "User-Agent" => USER_AGENT,
243
+ "Accept" => "application/json"
244
+ }
245
+ @auth_strategy.authenticate(headers)
246
+ headers
247
+ end
248
+
249
+ def single_request_raw(method, url, body:, content_type:, attempt:)
250
+ info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
251
+ @hooks.on_request_start(info)
252
+
253
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
254
+
255
+ begin
256
+ headers = request_headers.merge("Content-Type" => content_type)
257
+ response = @faraday.run_request(method, url, body, headers)
258
+
259
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
260
+ result = RequestResult.new(status_code: response.status, duration: duration)
261
+ @hooks.on_request_end(info, result)
262
+
263
+ Response.new(
264
+ body: response.body,
265
+ status: response.status,
266
+ headers: response.headers
267
+ )
268
+ rescue Faraday::ServerError, Faraday::ClientError => e
269
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
270
+ error = handle_error(e)
271
+ result = RequestResult.new(
272
+ status_code: e.response&.dig(:status),
273
+ duration: duration,
274
+ error: error,
275
+ retry_after: error.respond_to?(:retry_after) ? error.retry_after : nil
276
+ )
277
+ @hooks.on_request_end(info, result)
278
+ raise error
279
+ rescue Faraday::Error => e
280
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
281
+ error = Fizzy::NetworkError.new("Connection failed", cause: e)
282
+ result = RequestResult.new(duration: duration, error: error)
283
+ @hooks.on_request_end(info, result)
284
+ raise error
285
+ end
286
+ end
287
+
288
+ def handle_error(error)
289
+ status = error.response&.dig(:status)
290
+ body = error.response&.dig(:body)
291
+ headers = error.response&.dig(:headers) || {}
292
+
293
+ retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
294
+ request_id = headers["X-Request-Id"] || headers["x-request-id"]
295
+
296
+ err = case status
297
+ when 401
298
+ # Try token refresh; flag for caller to retry
299
+ @token_refreshed = @token_provider&.refreshable? && @token_provider.refresh
300
+ Fizzy::AuthError.new("Authentication failed")
301
+ when 403
302
+ Fizzy::ForbiddenError.new("Access denied")
303
+ when 404
304
+ Fizzy::NotFoundError.new("Resource", "unknown")
305
+ when 429
306
+ Fizzy::RateLimitError.new(retry_after: retry_after)
307
+ when 400, 422
308
+ message = Security.truncate(Fizzy.parse_error_message(body) || "Validation failed")
309
+ Fizzy::ValidationError.new(message, http_status: status)
310
+ when 500
311
+ Fizzy::APIError.new("Server error (500)", http_status: 500, retryable: true)
312
+ when 502, 503, 504
313
+ Fizzy::APIError.new("Gateway error (#{status})", http_status: status, retryable: true)
314
+ else
315
+ message = Security.truncate(Fizzy.parse_error_message(body) || "Request failed (HTTP #{status})")
316
+ Fizzy::APIError.from_status(status || 0, message)
317
+ end
318
+
319
+ err.instance_variable_set(:@request_id, request_id) if request_id
320
+ err
321
+ end
322
+
323
+ def build_url(path)
324
+ if path.start_with?("https://")
325
+ return path
326
+ elsif path.start_with?("http://")
327
+ return path if Security.localhost?(path)
328
+ raise Fizzy::UsageError.new("URL must use HTTPS: #{path}")
329
+ end
330
+
331
+ path = "/#{path}" unless path.start_with?("/")
332
+ "#{@config.base_url}#{path}"
333
+ end
334
+
335
+ def calculate_delay(attempt, server_retry_after)
336
+ return server_retry_after if server_retry_after&.positive?
337
+
338
+ # Exponential backoff: base_delay * 2^(attempt-1) + jitter
339
+ base = @config.base_delay * (2**(attempt - 1))
340
+ jitter = rand * @config.max_jitter
341
+ base + jitter
342
+ end
343
+
344
+ def parse_retry_after(value)
345
+ return nil if value.nil? || value.empty?
346
+
347
+ # Try parsing as seconds (integer)
348
+ seconds = Integer(value, exception: false)
349
+ return seconds if seconds&.positive?
350
+
351
+ # Try parsing as HTTP-date
352
+ begin
353
+ date = Time.httpdate(value)
354
+ diff = (date - Time.now).to_i
355
+ return diff if diff.positive?
356
+ rescue ArgumentError
357
+ # Not a valid HTTP-date
358
+ end
359
+
360
+ nil
361
+ end
362
+
363
+ def parse_next_link(link_header)
364
+ return nil if link_header.nil? || link_header.empty?
365
+
366
+ link_header.split(",").each do |part|
367
+ part = part.strip
368
+ next unless part.include?('rel="next"')
369
+
370
+ start = part.index("<")
371
+ finish = part.index(">", start || 0)
372
+ return part[(start + 1)...finish] if start && finish
373
+ end
374
+
375
+ nil
376
+ end
377
+ end
378
+
379
+ # Wraps an HTTP response.
380
+ class Response
381
+ # @return [String] response body
382
+ attr_reader :body
383
+
384
+ # @return [Integer] HTTP status code
385
+ attr_reader :status
386
+
387
+ # @return [Hash] response headers
388
+ attr_reader :headers
389
+
390
+ def initialize(body:, status:, headers:)
391
+ @body = body
392
+ @status = status
393
+ @headers = headers
394
+ end
395
+
396
+ # Parses the response body as JSON.
397
+ # @return [Hash, Array]
398
+ def json
399
+ @json ||= begin
400
+ Security.check_body_size!(@body, Security::MAX_RESPONSE_BODY_BYTES)
401
+ JSON.parse(@body)
402
+ end
403
+ end
404
+
405
+ # Returns whether the response was successful (2xx).
406
+ # @return [Boolean]
407
+ def success?
408
+ status >= 200 && status < 300
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Hooks implementation that logs to Ruby's Logger.
5
+ #
6
+ # @example
7
+ # require "logger"
8
+ # logger = Logger.new($stdout)
9
+ # hooks = Fizzy::LoggerHooks.new(logger)
10
+ # client = Fizzy::Client.new(config: config, token_provider: provider, hooks: hooks)
11
+ class LoggerHooks
12
+ include Hooks
13
+
14
+ # @param logger [Logger] Ruby logger instance
15
+ # @param level [Symbol] log level (:debug, :info, :warn, :error)
16
+ def initialize(logger, level: :debug)
17
+ @logger = logger
18
+ @level = level
19
+ end
20
+
21
+ def on_request_start(info)
22
+ @logger.send(@level, "HTTP #{info.method} #{info.url} (attempt #{info.attempt})")
23
+ end
24
+
25
+ def on_request_end(info, result)
26
+ if result.error
27
+ @logger.send(@level, "HTTP #{info.method} #{info.url} failed: #{result.error.message}")
28
+ else
29
+ cache_info = result.from_cache ? " (cached)" : ""
30
+ @logger.send(@level, \
31
+ "HTTP #{info.method} #{info.url} -> #{result.status_code}#{cache_info}" \
32
+ " (#{format("%.3f", result.duration)}s)")
33
+ end
34
+ end
35
+
36
+ def on_retry(info, attempt, error, delay)
37
+ @logger.send(@level, \
38
+ "Retrying #{info.method} #{info.url} (attempt #{attempt})" \
39
+ " in #{format("%.2f", delay)}s: #{error.message}")
40
+ end
41
+
42
+ def on_paginate(url, page)
43
+ @logger.send(@level, "Fetching page #{page}: #{url}")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Orchestrates the passwordless magic link authentication flow.
5
+ #
6
+ # The flow works in two steps:
7
+ # 1. Call CreateSession with an email address - this sends a magic link email
8
+ # 2. Call RedeemMagicLink with the token from the magic link URL
9
+ #
10
+ # After redemption, the response contains a session token that can be used
11
+ # with CookieAuth or BearerAuth for subsequent requests.
12
+ #
13
+ # @example
14
+ # flow = Fizzy::MagicLinkFlow.new(base_url: "https://fizzy.do")
15
+ # flow.request_magic_link(email: "user@example.com")
16
+ # # User clicks magic link in email, your app extracts the token
17
+ # session = flow.redeem(token: "magic-link-token-from-url")
18
+ # # Use session token for authenticated requests
19
+ # client = Fizzy.client(auth: Fizzy::CookieAuth.new(session["session_token"]))
20
+ class MagicLinkFlow
21
+ # @param base_url [String] Fizzy API base URL
22
+ # @param hooks [Hooks, nil] observability hooks
23
+ def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil)
24
+ @config = Config.new(base_url: base_url)
25
+ @hooks = hooks || NoopHooks.new
26
+ @http = Http.new(config: @config, auth_strategy: NullAuth.new, hooks: @hooks)
27
+ end
28
+
29
+ # Step 1: Request a magic link email.
30
+ #
31
+ # @param email [String] the user's email address
32
+ # @return [Hash] response from the API
33
+ def request_magic_link(email:)
34
+ response = @http.post("/sessions", body: { email: email })
35
+ response.json
36
+ end
37
+
38
+ # Step 2: Redeem a magic link token to get a session.
39
+ #
40
+ # @param token [String] the magic link token from the URL
41
+ # @return [Hash] response containing session_token and user info
42
+ def redeem(token:)
43
+ response = @http.post("/sessions/redeem", body: { token: token })
44
+ response.json
45
+ end
46
+
47
+ # Null authentication strategy for unauthenticated requests.
48
+ # @api private
49
+ class NullAuth
50
+ include AuthStrategy
51
+
52
+ def authenticate(headers)
53
+ # No authentication needed for magic link flow initiation
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # No-op implementation of Hooks.
5
+ # Used as the default when no hooks are configured.
6
+ class NoopHooks
7
+ include Hooks
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Information about a service operation for observability hooks.
5
+ OperationInfo = Data.define(:service, :operation, :resource_type, :is_mutation, :resource_id) do
6
+ def initialize(service:, operation:, resource_type: nil, is_mutation: false, resource_id: nil)
7
+ super
8
+ end
9
+ end
10
+
11
+ # Result information for completed service operations.
12
+ OperationResult = Data.define(:duration_ms, :error) do
13
+ def initialize(duration_ms: 0, error: nil)
14
+ super
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Token bucket rate limiter for client-side rate limiting.
5
+ #
6
+ # Prevents the client from exceeding a configurable request rate,
7
+ # avoiding 429 responses from the server.
8
+ #
9
+ # @example
10
+ # limiter = Fizzy::RateLimiter.new(rate: 10, burst: 20)
11
+ # limiter.acquire # blocks until a token is available
12
+ class RateLimiter
13
+ # @param rate [Numeric] tokens per second (sustained rate)
14
+ # @param burst [Integer] maximum token bucket size (burst capacity)
15
+ def initialize(rate: 10, burst: 20)
16
+ @rate = rate.to_f
17
+ @burst = burst
18
+ @tokens = burst.to_f
19
+ @last_refill = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Acquires a token, blocking if necessary until one is available.
24
+ #
25
+ # @return [void]
26
+ def acquire
27
+ loop do
28
+ wait_time = nil
29
+
30
+ @mutex.synchronize do
31
+ refill
32
+ if @tokens >= 1.0
33
+ @tokens -= 1.0
34
+ return
35
+ else
36
+ wait_time = (1.0 - @tokens) / @rate
37
+ end
38
+ end
39
+
40
+ sleep(wait_time) if wait_time&.positive?
41
+ end
42
+ end
43
+
44
+ # Attempts to acquire a token without blocking.
45
+ #
46
+ # @return [Boolean] true if a token was acquired
47
+ def try_acquire
48
+ @mutex.synchronize do
49
+ refill
50
+ if @tokens >= 1.0
51
+ @tokens -= 1.0
52
+ true
53
+ else
54
+ false
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def refill
62
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ elapsed = now - @last_refill
64
+ @tokens = [ @tokens + elapsed * @rate, @burst.to_f ].min
65
+ @last_refill = now
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Information about an HTTP request for observability hooks.
5
+ RequestInfo = Data.define(:method, :url, :attempt) do
6
+ def initialize(method:, url:, attempt: 1)
7
+ super
8
+ end
9
+ end
10
+ end