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.
- checksums.yaml +7 -0
- data/.rubocop.yml +18 -0
- data/Rakefile +26 -0
- data/fizzy-sdk.gemspec +45 -0
- data/lib/fizzy/auth_strategy.rb +38 -0
- data/lib/fizzy/bulkhead.rb +68 -0
- data/lib/fizzy/cache.rb +101 -0
- data/lib/fizzy/chain_hooks.rb +45 -0
- data/lib/fizzy/circuit_breaker.rb +115 -0
- data/lib/fizzy/client.rb +212 -0
- data/lib/fizzy/config.rb +143 -0
- data/lib/fizzy/cookie_auth.rb +27 -0
- data/lib/fizzy/errors.rb +291 -0
- data/lib/fizzy/generated/metadata.json +1341 -0
- data/lib/fizzy/generated/services/boards_service.rb +91 -0
- data/lib/fizzy/generated/services/cards_service.rb +313 -0
- data/lib/fizzy/generated/services/columns_service.rb +69 -0
- data/lib/fizzy/generated/services/comments_service.rb +68 -0
- data/lib/fizzy/generated/services/devices_service.rb +35 -0
- data/lib/fizzy/generated/services/identity_service.rb +19 -0
- data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
- data/lib/fizzy/generated/services/notifications_service.rb +65 -0
- data/lib/fizzy/generated/services/pins_service.rb +19 -0
- data/lib/fizzy/generated/services/reactions_service.rb +80 -0
- data/lib/fizzy/generated/services/sessions_service.rb +58 -0
- data/lib/fizzy/generated/services/steps_service.rb +69 -0
- data/lib/fizzy/generated/services/tags_service.rb +20 -0
- data/lib/fizzy/generated/services/uploads_service.rb +24 -0
- data/lib/fizzy/generated/services/users_service.rb +52 -0
- data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
- data/lib/fizzy/generated/types.rb +988 -0
- data/lib/fizzy/hooks.rb +70 -0
- data/lib/fizzy/http.rb +411 -0
- data/lib/fizzy/logger_hooks.rb +46 -0
- data/lib/fizzy/magic_link_flow.rb +57 -0
- data/lib/fizzy/noop_hooks.rb +9 -0
- data/lib/fizzy/operation_info.rb +17 -0
- data/lib/fizzy/rate_limiter.rb +68 -0
- data/lib/fizzy/request_info.rb +10 -0
- data/lib/fizzy/request_result.rb +14 -0
- data/lib/fizzy/resilience.rb +59 -0
- data/lib/fizzy/security.rb +103 -0
- data/lib/fizzy/services/base_service.rb +116 -0
- data/lib/fizzy/static_token_provider.rb +24 -0
- data/lib/fizzy/token_provider.rb +42 -0
- data/lib/fizzy/version.rb +6 -0
- data/lib/fizzy/webhooks/verify.rb +36 -0
- data/lib/fizzy.rb +95 -0
- data/scripts/generate-metadata.rb +105 -0
- data/scripts/generate-services.rb +681 -0
- data/scripts/generate-types.rb +160 -0
- metadata +252 -0
data/lib/fizzy/hooks.rb
ADDED
|
@@ -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,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
|