lutaml-hal 0.2.2 → 0.2.4
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 +4 -4
- data/lib/lutaml/hal/cache/cache_configuration.rb +17 -75
- data/lib/lutaml/hal/cache/cache_entry.rb +2 -23
- data/lib/lutaml/hal/cache/cache_manager.rb +17 -225
- data/lib/lutaml/hal/cache/cache_metadata.rb +3 -46
- data/lib/lutaml/hal/cache/response_adapter.rb +33 -0
- data/lib/lutaml/hal/cache.rb +13 -0
- data/lib/lutaml/hal/client.rb +8 -20
- data/lib/lutaml/hal/global_register.rb +2 -13
- data/lib/lutaml/hal/link.rb +4 -32
- data/lib/lutaml/hal/link_class_factory.rb +0 -7
- data/lib/lutaml/hal/link_set.rb +0 -2
- data/lib/lutaml/hal/link_set_class_factory.rb +0 -4
- data/lib/lutaml/hal/model_register.rb +17 -79
- data/lib/lutaml/hal/page.rb +1 -59
- data/lib/lutaml/hal/rate_limiter.rb +12 -28
- data/lib/lutaml/hal/resource.rb +3 -44
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +29 -13
- metadata +6 -5
- data/lib/lutaml/hal/cache/simple_cache_store.rb +0 -83
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'cgi'
|
|
4
|
-
require_relative 'errors'
|
|
5
|
-
require_relative 'endpoint_configuration'
|
|
6
|
-
require_relative 'cache/cache_manager'
|
|
7
|
-
require_relative 'single_flight'
|
|
8
4
|
|
|
9
5
|
module Lutaml
|
|
10
6
|
module Hal
|
|
11
|
-
# Register to map URL patterns to model classes with EndpointParameter support
|
|
12
7
|
class ModelRegister
|
|
13
8
|
attr_accessor :models, :client, :register_name, :cache_manager
|
|
14
9
|
|
|
15
10
|
def initialize(name:, client: nil, cache: nil)
|
|
16
11
|
@register_name = name
|
|
17
|
-
# If `client` is not set, it can be set later
|
|
18
12
|
@client = client
|
|
19
13
|
@models = {}
|
|
20
14
|
@cache_manager = Cache::CacheManager.new(cache, client: @client) if cache
|
|
21
15
|
@single_flight = SingleFlight.new
|
|
22
16
|
end
|
|
23
17
|
|
|
24
|
-
# Register a model with its URL pattern and parameters
|
|
25
18
|
def add_endpoint(id:, type:, url:, model:, parameters: [])
|
|
26
19
|
@models ||= {}
|
|
27
20
|
|
|
28
21
|
raise "Model with ID #{id} already registered" if @models[id]
|
|
29
22
|
|
|
30
|
-
# Validate all parameters
|
|
31
23
|
parameters.each(&:validate!)
|
|
32
24
|
|
|
33
|
-
# Ensure path parameters in URL have corresponding parameter definitions
|
|
34
25
|
validate_path_parameters(url, parameters)
|
|
35
26
|
|
|
36
|
-
# Check for duplicate endpoints
|
|
37
27
|
if @models.values.any? do |m|
|
|
38
28
|
m[:url] == url && m[:type] == type && parameters_match?(m[:parameters], parameters)
|
|
39
29
|
end
|
|
@@ -49,7 +39,6 @@ module Lutaml
|
|
|
49
39
|
}
|
|
50
40
|
end
|
|
51
41
|
|
|
52
|
-
# Register an endpoint using block configuration syntax
|
|
53
42
|
def register_endpoint(id, model, type: :index)
|
|
54
43
|
config = EndpointConfiguration.new
|
|
55
44
|
yield(config) if block_given?
|
|
@@ -65,22 +54,18 @@ module Lutaml
|
|
|
65
54
|
)
|
|
66
55
|
end
|
|
67
56
|
|
|
68
|
-
# Resolve and cast data to the appropriate model based on URL
|
|
69
57
|
def fetch(endpoint_id, **params)
|
|
70
58
|
endpoint = @models[endpoint_id] || raise("Unknown endpoint: #{endpoint_id}")
|
|
71
59
|
raise 'Client not configured' unless client
|
|
72
60
|
|
|
73
|
-
# Process parameters through EndpointParameter objects
|
|
74
61
|
processed_params = process_parameters(endpoint[:parameters], params)
|
|
75
62
|
|
|
76
|
-
# Build URL with path and query parameters
|
|
77
63
|
url = build_url_with_path_params(endpoint[:url], processed_params[:path])
|
|
78
64
|
final_url = build_url_with_query_params(url, processed_params[:query])
|
|
79
65
|
|
|
80
66
|
cached = cached_endpoint_model(final_url)
|
|
81
67
|
return cached if cached
|
|
82
68
|
|
|
83
|
-
# Coalesce concurrent fetches of the same URL into a single request.
|
|
84
69
|
coalesce(final_url) do
|
|
85
70
|
cached_endpoint_model(final_url) ||
|
|
86
71
|
fetch_uncached(endpoint, final_url, processed_params[:headers])
|
|
@@ -95,36 +80,29 @@ module Lutaml
|
|
|
95
80
|
|
|
96
81
|
debug_log("resolve_and_cast: link #{link}, href #{href}")
|
|
97
82
|
|
|
98
|
-
# Coalesce concurrent resolutions of the same href into one request.
|
|
99
83
|
coalesce(href) do
|
|
100
84
|
cached_resolved_model(href) || resolve_and_cast_uncached(href)
|
|
101
85
|
end
|
|
102
86
|
end
|
|
103
87
|
|
|
104
|
-
# Recursively mark all models in the link with the register name
|
|
105
|
-
# This is used to ensure that all links in the model are registered
|
|
106
|
-
# with the same register name for consistent resolution
|
|
107
88
|
def mark_model_links_with_register(inspecting_model)
|
|
108
89
|
return unless inspecting_model.is_a?(Lutaml::Model::Serializable)
|
|
109
90
|
|
|
110
91
|
inspecting_model._global_register_id = @register_name
|
|
111
92
|
|
|
112
|
-
# Recursively process model attributes to mark links with this register
|
|
113
93
|
inspecting_model.class.attributes.each_pair do |key, config|
|
|
114
94
|
attr_type = config.type
|
|
115
95
|
next unless attr_type < Lutaml::Hal::Resource ||
|
|
116
96
|
attr_type < Lutaml::Hal::Link ||
|
|
117
97
|
attr_type < Lutaml::Hal::LinkSet
|
|
118
98
|
|
|
119
|
-
value = inspecting_model.
|
|
99
|
+
value = inspecting_model.public_send(key)
|
|
120
100
|
next if value.nil?
|
|
121
101
|
|
|
122
|
-
# Handle both array and single values with the same logic
|
|
123
102
|
values = value.is_a?(Array) ? value : [value]
|
|
124
103
|
values.each do |item|
|
|
125
104
|
mark_model_links_with_register(item)
|
|
126
105
|
|
|
127
|
-
# If this is a Link, set the parent resource for automatic embedded content detection
|
|
128
106
|
item.parent_resource = inspecting_model if item.is_a?(Lutaml::Hal::Link)
|
|
129
107
|
end
|
|
130
108
|
end
|
|
@@ -132,7 +110,6 @@ module Lutaml
|
|
|
132
110
|
inspecting_model
|
|
133
111
|
end
|
|
134
112
|
|
|
135
|
-
# Cache management methods
|
|
136
113
|
def cache_stats
|
|
137
114
|
@cache_manager&.stats || {}
|
|
138
115
|
end
|
|
@@ -145,17 +122,13 @@ module Lutaml
|
|
|
145
122
|
@cache_manager&.info
|
|
146
123
|
end
|
|
147
124
|
|
|
148
|
-
# Find the registered model class whose URL pattern matches the given href.
|
|
149
|
-
# Public so that Link#realize can resolve embedded resources.
|
|
150
125
|
def find_matching_model_class(href)
|
|
151
|
-
# Find all matching patterns and select the most specific one (longest pattern)
|
|
152
126
|
matching_models = @models.values.select do |model_data|
|
|
153
127
|
matches_url_with_params?(model_data, href)
|
|
154
128
|
end
|
|
155
129
|
|
|
156
130
|
return nil if matching_models.empty?
|
|
157
131
|
|
|
158
|
-
# Sort by pattern length (descending) to get the most specific match first
|
|
159
132
|
result = matching_models.max_by { |model_data| model_data[:url].length }
|
|
160
133
|
|
|
161
134
|
result[:model]
|
|
@@ -164,21 +137,15 @@ module Lutaml
|
|
|
164
137
|
private
|
|
165
138
|
|
|
166
139
|
def debug_log(message)
|
|
167
|
-
|
|
140
|
+
Hal.debug_log(message)
|
|
168
141
|
end
|
|
169
142
|
|
|
170
|
-
# Run the block, coalescing concurrent calls that share `key` into one
|
|
171
|
-
# execution (single-flight) so duplicate in-flight requests for the same
|
|
172
|
-
# URL collapse into a single fetch. Only active when caching is enabled —
|
|
173
|
-
# the cache is what lets the shared result be reused. Different keys run
|
|
174
|
-
# in parallel.
|
|
175
143
|
def coalesce(key, &block)
|
|
176
144
|
return block.call unless @cache_manager&.available?
|
|
177
145
|
|
|
178
146
|
@single_flight.run(key, &block)
|
|
179
147
|
end
|
|
180
148
|
|
|
181
|
-
# Cached, register-marked model for a fully-built endpoint URL, or nil.
|
|
182
149
|
def cached_endpoint_model(url)
|
|
183
150
|
return nil unless @cache_manager&.available?
|
|
184
151
|
|
|
@@ -191,7 +158,6 @@ module Lutaml
|
|
|
191
158
|
model
|
|
192
159
|
end
|
|
193
160
|
|
|
194
|
-
# The fetch miss path: HTTP request, 304 handling, model build and cache.
|
|
195
161
|
def fetch_uncached(endpoint, final_url, headers)
|
|
196
162
|
request_headers = headers.dup
|
|
197
163
|
if @cache_manager&.available?
|
|
@@ -205,21 +171,13 @@ module Lutaml
|
|
|
205
171
|
client.get(final_url)
|
|
206
172
|
end
|
|
207
173
|
|
|
208
|
-
|
|
209
|
-
@cache_manager&.refresh_entry(final_url, response)
|
|
210
|
-
cached_entry = @cache_manager.get(final_url)
|
|
211
|
-
realized_model = cached_entry.hal_resource if cached_entry
|
|
212
|
-
else
|
|
213
|
-
realized_model = endpoint[:model].from_json(response.to_json)
|
|
214
|
-
@cache_manager&.set(final_url, response, realized_model)
|
|
215
|
-
end
|
|
174
|
+
realized_model = build_model_from_response(response, final_url, endpoint[:model])
|
|
216
175
|
|
|
217
176
|
realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
|
|
218
177
|
mark_model_links_with_register(realized_model)
|
|
219
178
|
realized_model
|
|
220
179
|
end
|
|
221
180
|
|
|
222
|
-
# Cached, register-marked model for a link href, or nil.
|
|
223
181
|
def cached_resolved_model(href)
|
|
224
182
|
return nil unless @cache_manager&.available?
|
|
225
183
|
|
|
@@ -228,14 +186,10 @@ module Lutaml
|
|
|
228
186
|
|
|
229
187
|
debug_log("Cache hit for: #{href}")
|
|
230
188
|
model = entry.hal_resource
|
|
231
|
-
# A model rebuilt from a persistent cache needs to be (re)marked so its
|
|
232
|
-
# links can be realized against this register.
|
|
233
189
|
mark_model_links_with_register(model)
|
|
234
190
|
model
|
|
235
191
|
end
|
|
236
192
|
|
|
237
|
-
# The resolve_and_cast miss path: HTTP request, 304 handling, model build
|
|
238
|
-
# and cache.
|
|
239
193
|
def resolve_and_cast_uncached(href)
|
|
240
194
|
conditional_headers = @cache_manager&.conditional_request_headers(href) || {}
|
|
241
195
|
|
|
@@ -245,26 +199,32 @@ module Lutaml
|
|
|
245
199
|
client.get_by_url(href)
|
|
246
200
|
end
|
|
247
201
|
|
|
248
|
-
if response.respond_to?(:status) && response.status == 304
|
|
249
|
-
@cache_manager&.refresh_entry(href, response)
|
|
250
|
-
cached_entry = @cache_manager.get(href)
|
|
251
|
-
return cached_entry.hal_resource if cached_entry
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# TODO: Merge full Link content into the resource?
|
|
255
|
-
response_with_link_details = response.to_h.merge({ 'href' => href })
|
|
256
202
|
href_path = href.sub(client.api_url, '')
|
|
257
203
|
|
|
258
204
|
model_class = find_matching_model_class(href_path)
|
|
259
205
|
raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
|
|
260
206
|
|
|
261
207
|
debug_log("resolve_and_cast: resolved to model_class #{model_class}")
|
|
208
|
+
|
|
209
|
+
response_with_link_details = response.to_h.merge({ 'href' => href })
|
|
262
210
|
model = model_class.from_json(response_with_link_details.to_json)
|
|
263
211
|
mark_model_links_with_register(model)
|
|
264
212
|
@cache_manager&.set(href, response, model)
|
|
265
213
|
model
|
|
266
214
|
end
|
|
267
215
|
|
|
216
|
+
def build_model_from_response(response, url, model_class)
|
|
217
|
+
if response.is_a?(Hash) && response['status'] == 304
|
|
218
|
+
@cache_manager&.refresh_entry(url, response)
|
|
219
|
+
cached_entry = @cache_manager.get(url)
|
|
220
|
+
return cached_entry&.hal_resource
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
realized_model = model_class.from_json(response.to_json)
|
|
224
|
+
@cache_manager&.set(url, response, realized_model)
|
|
225
|
+
realized_model
|
|
226
|
+
end
|
|
227
|
+
|
|
268
228
|
def process_parameters(parameter_definitions, provided_params)
|
|
269
229
|
result = { path: {}, query: {}, headers: {}, cookies: {} }
|
|
270
230
|
|
|
@@ -272,23 +232,18 @@ module Lutaml
|
|
|
272
232
|
param_name = param_def.name.to_sym
|
|
273
233
|
provided_value = provided_params[param_name]
|
|
274
234
|
|
|
275
|
-
# Check required parameters
|
|
276
235
|
if param_def.required && provided_value.nil?
|
|
277
236
|
raise ArgumentError, "Required parameter '#{param_def.name}' is missing"
|
|
278
237
|
end
|
|
279
238
|
|
|
280
|
-
# Use default value if not provided
|
|
281
239
|
value = provided_value || param_def.default_value
|
|
282
240
|
|
|
283
|
-
# Skip if still nil and not required
|
|
284
241
|
next if value.nil?
|
|
285
242
|
|
|
286
|
-
# Validate parameter value
|
|
287
243
|
unless param_def.validate_value(value)
|
|
288
244
|
raise ArgumentError, "Invalid value for parameter '#{param_def.name}': #{value}"
|
|
289
245
|
end
|
|
290
246
|
|
|
291
|
-
# Store in appropriate category
|
|
292
247
|
case param_def.location
|
|
293
248
|
when :path
|
|
294
249
|
result[:path][param_def.name] = value
|
|
@@ -305,19 +260,15 @@ module Lutaml
|
|
|
305
260
|
end
|
|
306
261
|
|
|
307
262
|
def validate_path_parameters(url, parameters)
|
|
308
|
-
# Extract path parameter names from URL template
|
|
309
263
|
url_params = url.scan(/\{([^}]+)\}/).flatten
|
|
310
264
|
|
|
311
|
-
# Find path parameters in parameter definitions
|
|
312
265
|
path_params = parameters.select(&:path_parameter?).map(&:name)
|
|
313
266
|
|
|
314
|
-
# Check that all URL parameters have definitions
|
|
315
267
|
missing_params = url_params - path_params
|
|
316
268
|
unless missing_params.empty?
|
|
317
269
|
raise ArgumentError, "URL contains undefined path parameters: #{missing_params.join(', ')}"
|
|
318
270
|
end
|
|
319
271
|
|
|
320
|
-
# Check that all path parameter definitions are used in URL
|
|
321
272
|
unused_params = path_params - url_params
|
|
322
273
|
return if unused_params.empty?
|
|
323
274
|
|
|
@@ -341,12 +292,9 @@ module Lutaml
|
|
|
341
292
|
end
|
|
342
293
|
|
|
343
294
|
def build_url_with_query_params(base_url, query_params, params = nil)
|
|
344
|
-
# Handle both 2-argument and 3-argument calls for backward compatibility
|
|
345
295
|
if params.nil?
|
|
346
|
-
# 2-argument call: query_params is the final query parameters
|
|
347
296
|
final_query_params = query_params
|
|
348
297
|
else
|
|
349
|
-
# 3-argument call: query_params is template, params contains values
|
|
350
298
|
final_query_params = {}
|
|
351
299
|
query_params.each do |key, template_value|
|
|
352
300
|
if template_value.is_a?(String) && template_value.match?(/\{(\w+)\}/)
|
|
@@ -364,7 +312,6 @@ module Lutaml
|
|
|
364
312
|
"#{base_url}?#{query_string}"
|
|
365
313
|
end
|
|
366
314
|
|
|
367
|
-
# Interpolate path parameters in a URL template
|
|
368
315
|
def interpolate_url(url_template, params)
|
|
369
316
|
params.reduce(url_template) do |url, (key, value)|
|
|
370
317
|
url.gsub("{#{key}}", value.to_s)
|
|
@@ -383,7 +330,6 @@ module Lutaml
|
|
|
383
330
|
path_match_result = path_matches?(pattern_path, uri.path)
|
|
384
331
|
return false unless path_match_result
|
|
385
332
|
|
|
386
|
-
# Check query parameters if any are defined
|
|
387
333
|
query_params = parameters.select(&:query_parameter?)
|
|
388
334
|
return true if query_params.empty?
|
|
389
335
|
|
|
@@ -405,21 +351,17 @@ module Lutaml
|
|
|
405
351
|
end
|
|
406
352
|
|
|
407
353
|
def query_params_match?(expected_params, actual_params)
|
|
408
|
-
# Query parameters should be optional unless marked as required
|
|
409
354
|
expected_params.all? do |param_def|
|
|
410
355
|
actual_value = actual_params[param_def.name]
|
|
411
356
|
|
|
412
|
-
# Required parameters must be present
|
|
413
357
|
if param_def.required
|
|
414
358
|
return false if actual_value.nil?
|
|
415
359
|
|
|
416
360
|
return param_def.validate_value(actual_value)
|
|
417
361
|
end
|
|
418
362
|
|
|
419
|
-
# Optional parameters are always considered matching if not present
|
|
420
363
|
return true if actual_value.nil?
|
|
421
364
|
|
|
422
|
-
# If present, they must be valid
|
|
423
365
|
param_def.validate_value(actual_value)
|
|
424
366
|
end
|
|
425
367
|
end
|
|
@@ -433,14 +375,10 @@ module Lutaml
|
|
|
433
375
|
end
|
|
434
376
|
end
|
|
435
377
|
|
|
436
|
-
# Match URL pattern (supports {param} templates)
|
|
437
378
|
def pattern_match?(pattern, url)
|
|
438
379
|
return false unless pattern && url
|
|
439
380
|
|
|
440
|
-
# Convert {param} to wildcards for matching
|
|
441
381
|
pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
|
|
442
|
-
# Convert * wildcards to regex pattern - use [^/]+ to match path segments, not across slashes
|
|
443
|
-
# This ensures that {param} only matches a single path segment
|
|
444
382
|
regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
|
|
445
383
|
|
|
446
384
|
debug_log("pattern_match?: regex: #{regex.inspect}")
|
data/lib/lutaml/hal/page.rb
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'resource'
|
|
4
|
-
|
|
5
3
|
module Lutaml
|
|
6
4
|
module Hal
|
|
7
|
-
# Models the pagination of a collection of resources
|
|
8
|
-
# This class is used to represent the pagination information
|
|
9
|
-
# for a collection of resources in the HAL format.
|
|
10
5
|
class Page < Resource
|
|
11
6
|
attribute :page, :integer
|
|
12
7
|
attribute :limit, :integer
|
|
@@ -23,153 +18,100 @@ module Lutaml
|
|
|
23
18
|
def self.inherited(subclass)
|
|
24
19
|
super
|
|
25
20
|
|
|
26
|
-
# Skip automatic link creation for anonymous classes (used in tests)
|
|
27
21
|
return unless subclass.name
|
|
28
22
|
|
|
29
23
|
page_links_symbols = %i[self next prev first last up]
|
|
30
24
|
subclass_name = subclass.name
|
|
31
25
|
subclass.class_eval do
|
|
32
|
-
# Define common page links
|
|
33
26
|
page_links_symbols.each do |link_symbol|
|
|
34
27
|
hal_link link_symbol, key: link_symbol.to_s, realize_class: subclass_name
|
|
35
28
|
end
|
|
36
29
|
end
|
|
37
30
|
end
|
|
38
31
|
|
|
39
|
-
# Returns the next page of results, or nil if on the last page
|
|
40
|
-
#
|
|
41
|
-
# @return [Object, nil] The next page instance or nil
|
|
42
32
|
def next
|
|
43
33
|
return nil unless links.next
|
|
44
34
|
|
|
45
35
|
links.next.realize
|
|
46
36
|
end
|
|
47
37
|
|
|
48
|
-
# Returns the previous page of results, or nil if on the first page
|
|
49
|
-
#
|
|
50
|
-
# @return [Object, nil] The previous page instance or nil
|
|
51
38
|
def prev
|
|
52
|
-
# If the API provides a prev link, use it
|
|
53
39
|
return links.prev.realize if links.prev
|
|
54
40
|
|
|
55
|
-
# If we're on page 1, there's no previous page
|
|
56
41
|
return nil if page <= 1
|
|
57
42
|
|
|
58
|
-
# Construct the previous page URL manually
|
|
59
43
|
prev_page_url = construct_page_url(page - 1)
|
|
60
44
|
return nil unless prev_page_url
|
|
61
45
|
|
|
62
|
-
# Use the HAL register to fetch the previous page
|
|
63
46
|
register_name = _global_register_id
|
|
64
47
|
return nil unless register_name
|
|
65
48
|
|
|
66
|
-
hal_register =
|
|
49
|
+
hal_register = GlobalRegister.instance.get(register_name)
|
|
67
50
|
return nil unless hal_register
|
|
68
51
|
|
|
69
52
|
hal_register.resolve_and_cast(nil, prev_page_url)
|
|
70
53
|
end
|
|
71
54
|
|
|
72
|
-
# Returns the first page of results
|
|
73
|
-
#
|
|
74
|
-
# @return [Object, nil] The first page instance or nil
|
|
75
55
|
def first
|
|
76
56
|
return nil unless links.first
|
|
77
57
|
|
|
78
58
|
links.first.realize
|
|
79
59
|
end
|
|
80
60
|
|
|
81
|
-
# Returns the last page of results
|
|
82
|
-
#
|
|
83
|
-
# @return [Object, nil] The last page instance or nil
|
|
84
61
|
def last
|
|
85
62
|
return nil unless links.last
|
|
86
63
|
|
|
87
64
|
links.last.realize
|
|
88
65
|
end
|
|
89
66
|
|
|
90
|
-
# Returns the total number of pages
|
|
91
|
-
#
|
|
92
|
-
# @return [Integer] The total number of pages
|
|
93
67
|
def total_pages
|
|
94
68
|
pages
|
|
95
69
|
end
|
|
96
70
|
|
|
97
|
-
# Checks if there is a next page available
|
|
98
|
-
#
|
|
99
|
-
# @return [Boolean] true if next page exists, false otherwise
|
|
100
71
|
def next?
|
|
101
72
|
!links.next.nil?
|
|
102
73
|
end
|
|
103
74
|
|
|
104
|
-
# Checks if there is a previous page available
|
|
105
|
-
#
|
|
106
|
-
# @return [Boolean] true if previous page exists, false otherwise
|
|
107
75
|
def prev?
|
|
108
76
|
!links.prev.nil?
|
|
109
77
|
end
|
|
110
78
|
|
|
111
|
-
# Checks if there is a first page link available
|
|
112
|
-
#
|
|
113
|
-
# @return [Boolean] true if first page link exists, false otherwise
|
|
114
79
|
def first?
|
|
115
80
|
!links.first.nil?
|
|
116
81
|
end
|
|
117
82
|
|
|
118
|
-
# Checks if there is a last page link available
|
|
119
|
-
#
|
|
120
|
-
# @return [Boolean] true if last page link exists, false otherwise
|
|
121
83
|
def last?
|
|
122
84
|
!links.last.nil?
|
|
123
85
|
end
|
|
124
86
|
|
|
125
|
-
# Returns the next page link, or nil if on the last page
|
|
126
|
-
#
|
|
127
|
-
# @return [Object, nil] The next page link or nil
|
|
128
87
|
def next_page
|
|
129
88
|
links.next
|
|
130
89
|
end
|
|
131
90
|
|
|
132
|
-
# Returns the previous page link, or nil if on the first page
|
|
133
|
-
#
|
|
134
|
-
# @return [Object, nil] The previous page link or nil
|
|
135
91
|
def prev_page
|
|
136
92
|
links.prev
|
|
137
93
|
end
|
|
138
94
|
|
|
139
|
-
# Returns the first page link
|
|
140
|
-
#
|
|
141
|
-
# @return [Object, nil] The first page link or nil
|
|
142
95
|
def first_page
|
|
143
96
|
links.first
|
|
144
97
|
end
|
|
145
98
|
|
|
146
|
-
# Returns the last page link
|
|
147
|
-
#
|
|
148
|
-
# @return [Object, nil] The last page link or nil
|
|
149
99
|
def last_page
|
|
150
100
|
links.last
|
|
151
101
|
end
|
|
152
102
|
|
|
153
103
|
private
|
|
154
104
|
|
|
155
|
-
# Constructs a URL for a specific page based on the current page's URL pattern
|
|
156
|
-
#
|
|
157
|
-
# @param target_page [Integer] The page number to construct URL for
|
|
158
|
-
# @return [String, nil] The constructed URL or nil if unable to construct
|
|
159
105
|
def construct_page_url(target_page)
|
|
160
|
-
# Try to get a reference URL from next, first, or last links
|
|
161
106
|
reference_url = links.next&.href || links.first&.href || links.last&.href
|
|
162
107
|
return nil unless reference_url
|
|
163
108
|
|
|
164
|
-
# Parse the reference URL and modify the page parameter
|
|
165
109
|
uri = URI.parse(reference_url)
|
|
166
110
|
query_params = URI.decode_www_form(uri.query || '')
|
|
167
111
|
|
|
168
|
-
# Update the page parameter
|
|
169
112
|
query_params = query_params.reject { |key, _| key == 'page' }
|
|
170
113
|
query_params << ['page', target_page.to_s]
|
|
171
114
|
|
|
172
|
-
# Reconstruct the URL
|
|
173
115
|
uri.query = URI.encode_www_form(query_params)
|
|
174
116
|
uri.to_s
|
|
175
117
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Lutaml
|
|
4
4
|
module Hal
|
|
5
|
-
# Rate limiter to handle API rate limiting with exponential backoff
|
|
6
5
|
class RateLimiter
|
|
7
6
|
DEFAULT_MAX_RETRIES = 5
|
|
8
7
|
DEFAULT_BASE_DELAY = 0.05
|
|
@@ -16,10 +15,9 @@ module Lutaml
|
|
|
16
15
|
@base_delay = options[:base_delay] || DEFAULT_BASE_DELAY
|
|
17
16
|
@max_delay = options[:max_delay] || DEFAULT_MAX_DELAY
|
|
18
17
|
@backoff_factor = options[:backoff_factor] || DEFAULT_BACKOFF_FACTOR
|
|
19
|
-
@enabled = options[:enabled] != false
|
|
18
|
+
@enabled = options[:enabled] != false
|
|
20
19
|
end
|
|
21
20
|
|
|
22
|
-
# Execute a block with rate limiting and retry logic
|
|
23
21
|
def with_rate_limiting
|
|
24
22
|
return yield unless @enabled
|
|
25
23
|
|
|
@@ -36,47 +34,27 @@ module Lutaml
|
|
|
36
34
|
end
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
# Check if we should retry based on the error and attempt count
|
|
40
37
|
def should_retry?(error, attempt)
|
|
41
38
|
return false if attempt > @max_retries
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
when TooManyRequestsError
|
|
45
|
-
true
|
|
46
|
-
when ServerError
|
|
47
|
-
# Always retry on server errors
|
|
48
|
-
true
|
|
49
|
-
else
|
|
50
|
-
false
|
|
51
|
-
end
|
|
40
|
+
error.is_a?(TooManyRequestsError) || error.is_a?(ServerError)
|
|
52
41
|
end
|
|
53
42
|
|
|
54
|
-
# Calculate delay with exponential backoff
|
|
55
43
|
def calculate_delay(attempt, error = nil)
|
|
56
|
-
|
|
57
|
-
if error.is_a?(TooManyRequestsError) && error.respond_to?(:response) && error.response
|
|
58
|
-
retry_after = extract_retry_after(error.response)
|
|
59
|
-
return retry_after if retry_after
|
|
60
|
-
end
|
|
44
|
+
return retry_after_from_error(error) if error.is_a?(TooManyRequestsError) && retry_after_from_error(error)
|
|
61
45
|
|
|
62
|
-
# Exponential backoff: base_delay * (backoff_factor ^ (attempt - 1))
|
|
63
46
|
delay = @base_delay * (@backoff_factor**(attempt - 1))
|
|
64
|
-
|
|
65
|
-
# Cap at max_delay
|
|
66
47
|
[delay, @max_delay].min
|
|
67
48
|
end
|
|
68
49
|
|
|
69
|
-
# Extract Retry-After header value
|
|
70
50
|
def extract_retry_after(response)
|
|
71
51
|
headers = response[:headers] || {}
|
|
72
52
|
retry_after = headers['retry-after'] || headers['Retry-After']
|
|
73
53
|
return nil unless retry_after
|
|
74
54
|
|
|
75
|
-
# Retry-After can be in seconds (integer) or HTTP date
|
|
76
55
|
if retry_after.match?(/^\d+$/)
|
|
77
56
|
retry_after.to_i
|
|
78
57
|
else
|
|
79
|
-
# Parse HTTP date and calculate seconds from now
|
|
80
58
|
begin
|
|
81
59
|
retry_time = Time.parse(retry_after)
|
|
82
60
|
[retry_time - Time.now, 0].max
|
|
@@ -86,20 +64,26 @@ module Lutaml
|
|
|
86
64
|
end
|
|
87
65
|
end
|
|
88
66
|
|
|
89
|
-
# Enable rate limiting
|
|
90
67
|
def enable!
|
|
91
68
|
@enabled = true
|
|
92
69
|
end
|
|
93
70
|
|
|
94
|
-
# Disable rate limiting
|
|
95
71
|
def disable!
|
|
96
72
|
@enabled = false
|
|
97
73
|
end
|
|
98
74
|
|
|
99
|
-
# Check if rate limiting is enabled
|
|
100
75
|
def enabled?
|
|
101
76
|
@enabled
|
|
102
77
|
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def retry_after_from_error(error)
|
|
82
|
+
return nil unless error.is_a?(TooManyRequestsError)
|
|
83
|
+
return nil unless error.response
|
|
84
|
+
|
|
85
|
+
extract_retry_after(error.response)
|
|
86
|
+
end
|
|
103
87
|
end
|
|
104
88
|
end
|
|
105
89
|
end
|