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.
@@ -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.send(key)
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
- puts "[Lutaml::Hal] DEBUG: #{message}" if ENV['DEBUG_API']
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
- if response.respond_to?(:status) && response.status == 304
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}")
@@ -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 = Lutaml::Hal::GlobalRegister.instance.get(register_name)
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 # Default to enabled unless explicitly disabled
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
- case error
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
- # Check for Retry-After header if it's a rate limit error
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