lutaml-hal 0.2.1 → 0.2.3
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 +97 -142
- 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/single_flight.rb +63 -0
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +29 -13
- metadata +5 -3
- data/lib/lutaml/hal/cache/simple_cache_store.rb +0 -83
|
@@ -1,37 +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
4
|
|
|
8
5
|
module Lutaml
|
|
9
6
|
module Hal
|
|
10
|
-
# Register to map URL patterns to model classes with EndpointParameter support
|
|
11
7
|
class ModelRegister
|
|
12
8
|
attr_accessor :models, :client, :register_name, :cache_manager
|
|
13
9
|
|
|
14
10
|
def initialize(name:, client: nil, cache: nil)
|
|
15
11
|
@register_name = name
|
|
16
|
-
# If `client` is not set, it can be set later
|
|
17
12
|
@client = client
|
|
18
13
|
@models = {}
|
|
19
14
|
@cache_manager = Cache::CacheManager.new(cache, client: @client) if cache
|
|
15
|
+
@single_flight = SingleFlight.new
|
|
20
16
|
end
|
|
21
17
|
|
|
22
|
-
# Register a model with its URL pattern and parameters
|
|
23
18
|
def add_endpoint(id:, type:, url:, model:, parameters: [])
|
|
24
19
|
@models ||= {}
|
|
25
20
|
|
|
26
21
|
raise "Model with ID #{id} already registered" if @models[id]
|
|
27
22
|
|
|
28
|
-
# Validate all parameters
|
|
29
23
|
parameters.each(&:validate!)
|
|
30
24
|
|
|
31
|
-
# Ensure path parameters in URL have corresponding parameter definitions
|
|
32
25
|
validate_path_parameters(url, parameters)
|
|
33
26
|
|
|
34
|
-
# Check for duplicate endpoints
|
|
35
27
|
if @models.values.any? do |m|
|
|
36
28
|
m[:url] == url && m[:type] == type && parameters_match?(m[:parameters], parameters)
|
|
37
29
|
end
|
|
@@ -47,7 +39,6 @@ module Lutaml
|
|
|
47
39
|
}
|
|
48
40
|
end
|
|
49
41
|
|
|
50
|
-
# Register an endpoint using block configuration syntax
|
|
51
42
|
def register_endpoint(id, model, type: :index)
|
|
52
43
|
config = EndpointConfiguration.new
|
|
53
44
|
yield(config) if block_given?
|
|
@@ -63,149 +54,55 @@ module Lutaml
|
|
|
63
54
|
)
|
|
64
55
|
end
|
|
65
56
|
|
|
66
|
-
# Resolve and cast data to the appropriate model based on URL
|
|
67
57
|
def fetch(endpoint_id, **params)
|
|
68
58
|
endpoint = @models[endpoint_id] || raise("Unknown endpoint: #{endpoint_id}")
|
|
69
59
|
raise 'Client not configured' unless client
|
|
70
60
|
|
|
71
|
-
# Process parameters through EndpointParameter objects
|
|
72
61
|
processed_params = process_parameters(endpoint[:parameters], params)
|
|
73
62
|
|
|
74
|
-
# Build URL with path parameters
|
|
75
63
|
url = build_url_with_path_params(endpoint[:url], processed_params[:path])
|
|
76
|
-
|
|
77
|
-
# Add query parameters
|
|
78
64
|
final_url = build_url_with_query_params(url, processed_params[:query])
|
|
79
65
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
# Check cache first
|
|
83
|
-
if @cache_manager&.available?
|
|
84
|
-
cached_entry = @cache_manager.get(final_url)
|
|
85
|
-
if cached_entry
|
|
86
|
-
debug_log("Cache hit for fetch: #{final_url}")
|
|
87
|
-
realized_model = cached_entry.hal_resource
|
|
88
|
-
# Return cached model directly if valid
|
|
89
|
-
mark_model_links_with_register(realized_model)
|
|
90
|
-
return realized_model
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Make request if not cached
|
|
95
|
-
# Add conditional headers if we have cached metadata
|
|
96
|
-
request_headers = processed_params[:headers].dup
|
|
97
|
-
if @cache_manager&.available?
|
|
98
|
-
conditional_headers = @cache_manager.conditional_request_headers(final_url)
|
|
99
|
-
request_headers.merge!(conditional_headers) if conditional_headers
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Make request with headers
|
|
103
|
-
response = if request_headers.any?
|
|
104
|
-
client.get_with_headers(final_url, request_headers)
|
|
105
|
-
else
|
|
106
|
-
client.get(final_url)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Handle 304 Not Modified
|
|
110
|
-
if response.respond_to?(:status) && response.status == 304
|
|
111
|
-
@cache_manager&.refresh_entry(final_url, response)
|
|
112
|
-
cached_entry = @cache_manager.get(final_url)
|
|
113
|
-
realized_model = cached_entry.hal_resource if cached_entry
|
|
114
|
-
else
|
|
115
|
-
# Create model from response
|
|
116
|
-
realized_model = endpoint[:model].from_json(response.to_json)
|
|
66
|
+
cached = cached_endpoint_model(final_url)
|
|
67
|
+
return cached if cached
|
|
117
68
|
|
|
118
|
-
|
|
119
|
-
|
|
69
|
+
coalesce(final_url) do
|
|
70
|
+
cached_endpoint_model(final_url) ||
|
|
71
|
+
fetch_uncached(endpoint, final_url, processed_params[:headers])
|
|
120
72
|
end
|
|
121
|
-
|
|
122
|
-
# Store embedded data for later resolution
|
|
123
|
-
realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
|
|
124
|
-
|
|
125
|
-
mark_model_links_with_register(realized_model)
|
|
126
|
-
|
|
127
|
-
realized_model
|
|
128
73
|
end
|
|
129
74
|
|
|
130
75
|
def resolve_and_cast(link, href)
|
|
131
76
|
raise 'Client not configured' unless client
|
|
132
77
|
|
|
133
|
-
|
|
134
|
-
if
|
|
135
|
-
cached_entry = @cache_manager.get(href)
|
|
136
|
-
if cached_entry
|
|
137
|
-
debug_log("Cache hit for: #{href}")
|
|
138
|
-
cached_model = cached_entry.hal_resource
|
|
139
|
-
# A model rebuilt from a persistent cache needs to be (re)marked so
|
|
140
|
-
# its links can be realized against this register.
|
|
141
|
-
mark_model_links_with_register(cached_model)
|
|
142
|
-
return cached_model
|
|
143
|
-
end
|
|
144
|
-
end
|
|
78
|
+
cached = cached_resolved_model(href)
|
|
79
|
+
return cached if cached
|
|
145
80
|
|
|
146
81
|
debug_log("resolve_and_cast: link #{link}, href #{href}")
|
|
147
82
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
response = if conditional_headers.any?
|
|
152
|
-
client.get_by_url_with_headers(href, conditional_headers)
|
|
153
|
-
else
|
|
154
|
-
client.get_by_url(href)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
# Handle 304 Not Modified
|
|
158
|
-
if response.respond_to?(:status) && response.status == 304
|
|
159
|
-
@cache_manager&.refresh_entry(href, response)
|
|
160
|
-
cached_entry = @cache_manager.get(href)
|
|
161
|
-
return cached_entry.hal_resource if cached_entry
|
|
83
|
+
coalesce(href) do
|
|
84
|
+
cached_resolved_model(href) || resolve_and_cast_uncached(href)
|
|
162
85
|
end
|
|
163
|
-
|
|
164
|
-
# TODO: Merge full Link content into the resource?
|
|
165
|
-
response_with_link_details = response.to_h.merge({ 'href' => href })
|
|
166
|
-
|
|
167
|
-
href_path = href.sub(client.api_url, '')
|
|
168
|
-
|
|
169
|
-
model_class = find_matching_model_class(href_path)
|
|
170
|
-
raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
|
|
171
|
-
|
|
172
|
-
debug_log("resolve_and_cast: resolved to model_class #{model_class}")
|
|
173
|
-
debug_log("resolve_and_cast: response: #{response.inspect}")
|
|
174
|
-
debug_log("resolve_and_cast: amended: #{response_with_link_details}")
|
|
175
|
-
|
|
176
|
-
model = model_class.from_json(response_with_link_details.to_json)
|
|
177
|
-
mark_model_links_with_register(model)
|
|
178
|
-
|
|
179
|
-
# Cache the realized model with metadata
|
|
180
|
-
@cache_manager&.set(href, response, model)
|
|
181
|
-
|
|
182
|
-
model
|
|
183
86
|
end
|
|
184
87
|
|
|
185
|
-
# Recursively mark all models in the link with the register name
|
|
186
|
-
# This is used to ensure that all links in the model are registered
|
|
187
|
-
# with the same register name for consistent resolution
|
|
188
88
|
def mark_model_links_with_register(inspecting_model)
|
|
189
89
|
return unless inspecting_model.is_a?(Lutaml::Model::Serializable)
|
|
190
90
|
|
|
191
91
|
inspecting_model._global_register_id = @register_name
|
|
192
92
|
|
|
193
|
-
# Recursively process model attributes to mark links with this register
|
|
194
93
|
inspecting_model.class.attributes.each_pair do |key, config|
|
|
195
94
|
attr_type = config.type
|
|
196
95
|
next unless attr_type < Lutaml::Hal::Resource ||
|
|
197
96
|
attr_type < Lutaml::Hal::Link ||
|
|
198
97
|
attr_type < Lutaml::Hal::LinkSet
|
|
199
98
|
|
|
200
|
-
value = inspecting_model.
|
|
99
|
+
value = inspecting_model.public_send(key)
|
|
201
100
|
next if value.nil?
|
|
202
101
|
|
|
203
|
-
# Handle both array and single values with the same logic
|
|
204
102
|
values = value.is_a?(Array) ? value : [value]
|
|
205
103
|
values.each do |item|
|
|
206
104
|
mark_model_links_with_register(item)
|
|
207
105
|
|
|
208
|
-
# If this is a Link, set the parent resource for automatic embedded content detection
|
|
209
106
|
item.parent_resource = inspecting_model if item.is_a?(Lutaml::Hal::Link)
|
|
210
107
|
end
|
|
211
108
|
end
|
|
@@ -213,7 +110,6 @@ module Lutaml
|
|
|
213
110
|
inspecting_model
|
|
214
111
|
end
|
|
215
112
|
|
|
216
|
-
# Cache management methods
|
|
217
113
|
def cache_stats
|
|
218
114
|
@cache_manager&.stats || {}
|
|
219
115
|
end
|
|
@@ -226,17 +122,13 @@ module Lutaml
|
|
|
226
122
|
@cache_manager&.info
|
|
227
123
|
end
|
|
228
124
|
|
|
229
|
-
# Find the registered model class whose URL pattern matches the given href.
|
|
230
|
-
# Public so that Link#realize can resolve embedded resources.
|
|
231
125
|
def find_matching_model_class(href)
|
|
232
|
-
# Find all matching patterns and select the most specific one (longest pattern)
|
|
233
126
|
matching_models = @models.values.select do |model_data|
|
|
234
127
|
matches_url_with_params?(model_data, href)
|
|
235
128
|
end
|
|
236
129
|
|
|
237
130
|
return nil if matching_models.empty?
|
|
238
131
|
|
|
239
|
-
# Sort by pattern length (descending) to get the most specific match first
|
|
240
132
|
result = matching_models.max_by { |model_data| model_data[:url].length }
|
|
241
133
|
|
|
242
134
|
result[:model]
|
|
@@ -245,7 +137,92 @@ module Lutaml
|
|
|
245
137
|
private
|
|
246
138
|
|
|
247
139
|
def debug_log(message)
|
|
248
|
-
|
|
140
|
+
Hal.debug_log(message)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def coalesce(key, &block)
|
|
144
|
+
return block.call unless @cache_manager&.available?
|
|
145
|
+
|
|
146
|
+
@single_flight.run(key, &block)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def cached_endpoint_model(url)
|
|
150
|
+
return nil unless @cache_manager&.available?
|
|
151
|
+
|
|
152
|
+
entry = @cache_manager.get(url)
|
|
153
|
+
return nil unless entry
|
|
154
|
+
|
|
155
|
+
debug_log("Cache hit for fetch: #{url}")
|
|
156
|
+
model = entry.hal_resource
|
|
157
|
+
mark_model_links_with_register(model)
|
|
158
|
+
model
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fetch_uncached(endpoint, final_url, headers)
|
|
162
|
+
request_headers = headers.dup
|
|
163
|
+
if @cache_manager&.available?
|
|
164
|
+
conditional_headers = @cache_manager.conditional_request_headers(final_url)
|
|
165
|
+
request_headers.merge!(conditional_headers) if conditional_headers
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
response = if request_headers.any?
|
|
169
|
+
client.get_with_headers(final_url, request_headers)
|
|
170
|
+
else
|
|
171
|
+
client.get(final_url)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
realized_model = build_model_from_response(response, final_url, endpoint[:model])
|
|
175
|
+
|
|
176
|
+
realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
|
|
177
|
+
mark_model_links_with_register(realized_model)
|
|
178
|
+
realized_model
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cached_resolved_model(href)
|
|
182
|
+
return nil unless @cache_manager&.available?
|
|
183
|
+
|
|
184
|
+
entry = @cache_manager.get(href)
|
|
185
|
+
return nil unless entry
|
|
186
|
+
|
|
187
|
+
debug_log("Cache hit for: #{href}")
|
|
188
|
+
model = entry.hal_resource
|
|
189
|
+
mark_model_links_with_register(model)
|
|
190
|
+
model
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def resolve_and_cast_uncached(href)
|
|
194
|
+
conditional_headers = @cache_manager&.conditional_request_headers(href) || {}
|
|
195
|
+
|
|
196
|
+
response = if conditional_headers.any?
|
|
197
|
+
client.get_by_url_with_headers(href, conditional_headers)
|
|
198
|
+
else
|
|
199
|
+
client.get_by_url(href)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
href_path = href.sub(client.api_url, '')
|
|
203
|
+
|
|
204
|
+
model_class = find_matching_model_class(href_path)
|
|
205
|
+
raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
|
|
206
|
+
|
|
207
|
+
debug_log("resolve_and_cast: resolved to model_class #{model_class}")
|
|
208
|
+
|
|
209
|
+
response_with_link_details = response.to_h.merge({ 'href' => href })
|
|
210
|
+
model = model_class.from_json(response_with_link_details.to_json)
|
|
211
|
+
mark_model_links_with_register(model)
|
|
212
|
+
@cache_manager&.set(href, response, model)
|
|
213
|
+
model
|
|
214
|
+
end
|
|
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
|
|
249
226
|
end
|
|
250
227
|
|
|
251
228
|
def process_parameters(parameter_definitions, provided_params)
|
|
@@ -255,23 +232,18 @@ module Lutaml
|
|
|
255
232
|
param_name = param_def.name.to_sym
|
|
256
233
|
provided_value = provided_params[param_name]
|
|
257
234
|
|
|
258
|
-
# Check required parameters
|
|
259
235
|
if param_def.required && provided_value.nil?
|
|
260
236
|
raise ArgumentError, "Required parameter '#{param_def.name}' is missing"
|
|
261
237
|
end
|
|
262
238
|
|
|
263
|
-
# Use default value if not provided
|
|
264
239
|
value = provided_value || param_def.default_value
|
|
265
240
|
|
|
266
|
-
# Skip if still nil and not required
|
|
267
241
|
next if value.nil?
|
|
268
242
|
|
|
269
|
-
# Validate parameter value
|
|
270
243
|
unless param_def.validate_value(value)
|
|
271
244
|
raise ArgumentError, "Invalid value for parameter '#{param_def.name}': #{value}"
|
|
272
245
|
end
|
|
273
246
|
|
|
274
|
-
# Store in appropriate category
|
|
275
247
|
case param_def.location
|
|
276
248
|
when :path
|
|
277
249
|
result[:path][param_def.name] = value
|
|
@@ -288,19 +260,15 @@ module Lutaml
|
|
|
288
260
|
end
|
|
289
261
|
|
|
290
262
|
def validate_path_parameters(url, parameters)
|
|
291
|
-
# Extract path parameter names from URL template
|
|
292
263
|
url_params = url.scan(/\{([^}]+)\}/).flatten
|
|
293
264
|
|
|
294
|
-
# Find path parameters in parameter definitions
|
|
295
265
|
path_params = parameters.select(&:path_parameter?).map(&:name)
|
|
296
266
|
|
|
297
|
-
# Check that all URL parameters have definitions
|
|
298
267
|
missing_params = url_params - path_params
|
|
299
268
|
unless missing_params.empty?
|
|
300
269
|
raise ArgumentError, "URL contains undefined path parameters: #{missing_params.join(', ')}"
|
|
301
270
|
end
|
|
302
271
|
|
|
303
|
-
# Check that all path parameter definitions are used in URL
|
|
304
272
|
unused_params = path_params - url_params
|
|
305
273
|
return if unused_params.empty?
|
|
306
274
|
|
|
@@ -324,12 +292,9 @@ module Lutaml
|
|
|
324
292
|
end
|
|
325
293
|
|
|
326
294
|
def build_url_with_query_params(base_url, query_params, params = nil)
|
|
327
|
-
# Handle both 2-argument and 3-argument calls for backward compatibility
|
|
328
295
|
if params.nil?
|
|
329
|
-
# 2-argument call: query_params is the final query parameters
|
|
330
296
|
final_query_params = query_params
|
|
331
297
|
else
|
|
332
|
-
# 3-argument call: query_params is template, params contains values
|
|
333
298
|
final_query_params = {}
|
|
334
299
|
query_params.each do |key, template_value|
|
|
335
300
|
if template_value.is_a?(String) && template_value.match?(/\{(\w+)\}/)
|
|
@@ -347,7 +312,6 @@ module Lutaml
|
|
|
347
312
|
"#{base_url}?#{query_string}"
|
|
348
313
|
end
|
|
349
314
|
|
|
350
|
-
# Interpolate path parameters in a URL template
|
|
351
315
|
def interpolate_url(url_template, params)
|
|
352
316
|
params.reduce(url_template) do |url, (key, value)|
|
|
353
317
|
url.gsub("{#{key}}", value.to_s)
|
|
@@ -366,7 +330,6 @@ module Lutaml
|
|
|
366
330
|
path_match_result = path_matches?(pattern_path, uri.path)
|
|
367
331
|
return false unless path_match_result
|
|
368
332
|
|
|
369
|
-
# Check query parameters if any are defined
|
|
370
333
|
query_params = parameters.select(&:query_parameter?)
|
|
371
334
|
return true if query_params.empty?
|
|
372
335
|
|
|
@@ -388,21 +351,17 @@ module Lutaml
|
|
|
388
351
|
end
|
|
389
352
|
|
|
390
353
|
def query_params_match?(expected_params, actual_params)
|
|
391
|
-
# Query parameters should be optional unless marked as required
|
|
392
354
|
expected_params.all? do |param_def|
|
|
393
355
|
actual_value = actual_params[param_def.name]
|
|
394
356
|
|
|
395
|
-
# Required parameters must be present
|
|
396
357
|
if param_def.required
|
|
397
358
|
return false if actual_value.nil?
|
|
398
359
|
|
|
399
360
|
return param_def.validate_value(actual_value)
|
|
400
361
|
end
|
|
401
362
|
|
|
402
|
-
# Optional parameters are always considered matching if not present
|
|
403
363
|
return true if actual_value.nil?
|
|
404
364
|
|
|
405
|
-
# If present, they must be valid
|
|
406
365
|
param_def.validate_value(actual_value)
|
|
407
366
|
end
|
|
408
367
|
end
|
|
@@ -416,14 +375,10 @@ module Lutaml
|
|
|
416
375
|
end
|
|
417
376
|
end
|
|
418
377
|
|
|
419
|
-
# Match URL pattern (supports {param} templates)
|
|
420
378
|
def pattern_match?(pattern, url)
|
|
421
379
|
return false unless pattern && url
|
|
422
380
|
|
|
423
|
-
# Convert {param} to wildcards for matching
|
|
424
381
|
pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
|
|
425
|
-
# Convert * wildcards to regex pattern - use [^/]+ to match path segments, not across slashes
|
|
426
|
-
# This ensures that {param} only matches a single path segment
|
|
427
382
|
regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
|
|
428
383
|
|
|
429
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
|