lutaml-hal 0.1.9 → 0.2.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.
@@ -9,7 +9,7 @@ module Lutaml
9
9
  class Link < Lutaml::Model::Serializable
10
10
  # This is the model register that has fetched the origin of this link, and
11
11
  # will be used to resolve unless overriden in resource#realize()
12
- attr_accessor Hal::REGISTER_ID_ATTR_NAME.to_sym
12
+ attr_accessor :_global_register_id
13
13
 
14
14
  # Store reference to parent resource for automatic embedded content detection
15
15
  attr_accessor :parent_resource
@@ -27,17 +27,22 @@ module Lutaml
27
27
  # This method will use the global register according to the source of the Link object.
28
28
  # If the Link does not have a register, a register needs to be provided explicitly
29
29
  # via the `register:` parameter.
30
- def realize(register: nil, parent_resource: nil)
30
+ def realize(register: nil, parent_resource: nil, force_refresh: false)
31
31
  # Use provided parent_resource or fall back to stored parent_resource
32
32
  effective_parent = parent_resource || @parent_resource
33
33
 
34
- # First check if embedded content is available
35
- if effective_parent && (embedded_content = check_embedded_content(effective_parent, register))
34
+ register = find_register(register)
35
+ raise "No register provided for link resolution (class: #{self.class}, href: #{href})" if register.nil?
36
+
37
+ # Priority 1: Check embedded content first (unless force_refresh)
38
+ if !force_refresh && effective_parent && (embedded_content = check_embedded_content(effective_parent, register))
39
+ # Cache embedded content too, so later lookups by href are served locally
40
+ register.cache_manager&.set(href, nil, embedded_content)
36
41
  return embedded_content
37
42
  end
38
43
 
39
- register = find_register(register)
40
- raise "No register provided for link resolution (class: #{self.class}, href: #{href})" if register.nil?
44
+ # Force refresh bypasses any cached entry for this href
45
+ register.cache_manager&.invalidate(href) if force_refresh
41
46
 
42
47
  Hal.debug_log "Resolving link href: #{href} using register"
43
48
  register.resolve_and_cast(self, href)
@@ -86,19 +91,19 @@ module Lutaml
86
91
 
87
92
  # Try to find the model class for this href
88
93
  href_path = href.sub(register.client.api_url, '') if register.client
89
- model_class = register.send(:find_matching_model_class, href_path)
94
+ model_class = register.find_matching_model_class(href_path)
90
95
  return nil unless model_class
91
96
 
92
97
  # Create the resource from embedded data
93
- resource = model_class.from_embedded(embedded_item, instance_variable_get("@#{Hal::REGISTER_ID_ATTR_NAME}"))
94
- register.send(:mark_model_links_with_register, resource)
98
+ resource = model_class.from_embedded(embedded_item, _global_register_id)
99
+ register.mark_model_links_with_register(resource)
95
100
  resource
96
101
  end
97
102
 
98
103
  def find_register(explicit_register)
99
104
  return explicit_register if explicit_register
100
105
 
101
- register_id = instance_variable_get("@#{Hal::REGISTER_ID_ATTR_NAME}")
106
+ register_id = _global_register_id
102
107
  return nil if register_id.nil?
103
108
 
104
109
  register = Lutaml::Hal::GlobalRegister.instance.get(register_id)
@@ -7,7 +7,7 @@ module Lutaml
7
7
  module Hal
8
8
  # HAL Link representation with realization capability
9
9
  class LinkSet < Lutaml::Model::Serializable
10
- attr_accessor Hal::REGISTER_ID_ATTR_NAME.to_sym
10
+ attr_accessor :_global_register_id
11
11
  end
12
12
  end
13
13
  end
@@ -3,18 +3,20 @@
3
3
  require 'cgi'
4
4
  require_relative 'errors'
5
5
  require_relative 'endpoint_configuration'
6
+ require_relative 'cache/cache_manager'
6
7
 
7
8
  module Lutaml
8
9
  module Hal
9
10
  # Register to map URL patterns to model classes with EndpointParameter support
10
11
  class ModelRegister
11
- attr_accessor :models, :client, :register_name
12
+ attr_accessor :models, :client, :register_name, :cache_manager
12
13
 
13
- def initialize(name:, client: nil)
14
+ def initialize(name:, client: nil, cache: nil)
14
15
  @register_name = name
15
16
  # If `client` is not set, it can be set later
16
17
  @client = client
17
18
  @models = {}
19
+ @cache_manager = Cache::CacheManager.new(cache, client: @client) if cache
18
20
  end
19
21
 
20
22
  # Register a model with its URL pattern and parameters
@@ -75,27 +77,89 @@ module Lutaml
75
77
  # Add query parameters
76
78
  final_url = build_url_with_query_params(url, processed_params[:query])
77
79
 
80
+ realized_model = nil
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
+
78
102
  # Make request with headers
79
- response = if processed_params[:headers].any?
80
- client.get_with_headers(final_url, processed_params[:headers])
103
+ response = if request_headers.any?
104
+ client.get_with_headers(final_url, request_headers)
81
105
  else
82
106
  client.get(final_url)
83
107
  end
84
108
 
85
- realized_model = endpoint[:model].from_json(response.to_json)
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)
117
+
118
+ # Cache the realized model with metadata
119
+ @cache_manager&.set(final_url, response, realized_model)
120
+ end
86
121
 
87
122
  # Store embedded data for later resolution
88
- realized_model.instance_variable_set(:@_embedded, response['_embedded']) if response['_embedded']
123
+ realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
89
124
 
90
125
  mark_model_links_with_register(realized_model)
126
+
91
127
  realized_model
92
128
  end
93
129
 
94
130
  def resolve_and_cast(link, href)
95
131
  raise 'Client not configured' unless client
96
132
 
97
- Hal.debug_log("resolve_and_cast: link #{link}, href #{href}")
98
- response = client.get_by_url(href)
133
+ # Check cache first
134
+ if @cache_manager&.available?
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
145
+
146
+ debug_log("resolve_and_cast: link #{link}, href #{href}")
147
+
148
+ # Add conditional headers if we have cached metadata
149
+ conditional_headers = @cache_manager&.conditional_request_headers(href) || {}
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
162
+ end
99
163
 
100
164
  # TODO: Merge full Link content into the resource?
101
165
  response_with_link_details = response.to_h.merge({ 'href' => href })
@@ -105,12 +169,16 @@ module Lutaml
105
169
  model_class = find_matching_model_class(href_path)
106
170
  raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
107
171
 
108
- Hal.debug_log("resolve_and_cast: resolved to model_class #{model_class}")
109
- Hal.debug_log("resolve_and_cast: response: #{response.inspect}")
110
- Hal.debug_log("resolve_and_cast: amended: #{response_with_link_details}")
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}")
111
175
 
112
176
  model = model_class.from_json(response_with_link_details.to_json)
113
177
  mark_model_links_with_register(model)
178
+
179
+ # Cache the realized model with metadata
180
+ @cache_manager&.set(href, response, model)
181
+
114
182
  model
115
183
  end
116
184
 
@@ -120,7 +188,7 @@ module Lutaml
120
188
  def mark_model_links_with_register(inspecting_model)
121
189
  return unless inspecting_model.is_a?(Lutaml::Model::Serializable)
122
190
 
123
- inspecting_model.instance_variable_set("@#{Hal::REGISTER_ID_ATTR_NAME}", @register_name)
191
+ inspecting_model._global_register_id = @register_name
124
192
 
125
193
  # Recursively process model attributes to mark links with this register
126
194
  inspecting_model.class.attributes.each_pair do |key, config|
@@ -145,8 +213,41 @@ module Lutaml
145
213
  inspecting_model
146
214
  end
147
215
 
216
+ # Cache management methods
217
+ def cache_stats
218
+ @cache_manager&.stats || {}
219
+ end
220
+
221
+ def clear_cache
222
+ @cache_manager&.clear
223
+ end
224
+
225
+ def cache_info
226
+ @cache_manager&.info
227
+ end
228
+
229
+ # Find the registered model class whose URL pattern matches the given href.
230
+ # Public so that Link#realize can resolve embedded resources.
231
+ def find_matching_model_class(href)
232
+ # Find all matching patterns and select the most specific one (longest pattern)
233
+ matching_models = @models.values.select do |model_data|
234
+ matches_url_with_params?(model_data, href)
235
+ end
236
+
237
+ return nil if matching_models.empty?
238
+
239
+ # Sort by pattern length (descending) to get the most specific match first
240
+ result = matching_models.max_by { |model_data| model_data[:url].length }
241
+
242
+ result[:model]
243
+ end
244
+
148
245
  private
149
246
 
247
+ def debug_log(message)
248
+ puts "[Lutaml::Hal] DEBUG: #{message}" if ENV['DEBUG_API']
249
+ end
250
+
150
251
  def process_parameters(parameter_definitions, provided_params)
151
252
  result = { path: {}, query: {}, headers: {}, cookies: {} }
152
253
 
@@ -253,20 +354,6 @@ module Lutaml
253
354
  end
254
355
  end
255
356
 
256
- def find_matching_model_class(href)
257
- # Find all matching patterns and select the most specific one (longest pattern)
258
- matching_models = @models.values.select do |model_data|
259
- matches_url_with_params?(model_data, href)
260
- end
261
-
262
- return nil if matching_models.empty?
263
-
264
- # Sort by pattern length (descending) to get the most specific match first
265
- result = matching_models.max_by { |model_data| model_data[:url].length }
266
-
267
- result[:model]
268
- end
269
-
270
357
  def matches_url_with_params?(model_data, href)
271
358
  pattern = model_data[:url]
272
359
  parameters = model_data[:parameters]
@@ -339,12 +426,12 @@ module Lutaml
339
426
  # This ensures that {param} only matches a single path segment
340
427
  regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
341
428
 
342
- Hal.debug_log("pattern_match?: regex: #{regex.inspect}")
343
- Hal.debug_log("pattern_match?: href to match #{url}")
344
- Hal.debug_log("pattern_match?: pattern to match #{pattern_with_wildcards}")
429
+ debug_log("pattern_match?: regex: #{regex.inspect}")
430
+ debug_log("pattern_match?: href to match #{url}")
431
+ debug_log("pattern_match?: pattern to match #{pattern_with_wildcards}")
345
432
 
346
433
  matches = regex.match?(url)
347
- Hal.debug_log("pattern_match?: matches = #{matches}")
434
+ debug_log("pattern_match?: matches = #{matches}")
348
435
 
349
436
  matches
350
437
  end
@@ -60,7 +60,7 @@ module Lutaml
60
60
  return nil unless prev_page_url
61
61
 
62
62
  # Use the HAL register to fetch the previous page
63
- register_name = instance_variable_get("@#{Lutaml::Hal::REGISTER_ID_ATTR_NAME}")
63
+ register_name = _global_register_id
64
64
  return nil unless register_name
65
65
 
66
66
  hal_register = Lutaml::Hal::GlobalRegister.instance.get(register_name)
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Hal
5
+ # Rate limiter to handle API rate limiting with exponential backoff
6
+ class RateLimiter
7
+ DEFAULT_MAX_RETRIES = 5
8
+ DEFAULT_BASE_DELAY = 0.05
9
+ DEFAULT_MAX_DELAY = 5.0
10
+ DEFAULT_BACKOFF_FACTOR = 1.5
11
+
12
+ attr_reader :max_retries, :base_delay, :max_delay, :backoff_factor
13
+
14
+ def initialize(options = {})
15
+ @max_retries = options[:max_retries] || DEFAULT_MAX_RETRIES
16
+ @base_delay = options[:base_delay] || DEFAULT_BASE_DELAY
17
+ @max_delay = options[:max_delay] || DEFAULT_MAX_DELAY
18
+ @backoff_factor = options[:backoff_factor] || DEFAULT_BACKOFF_FACTOR
19
+ @enabled = options[:enabled] != false # Default to enabled unless explicitly disabled
20
+ end
21
+
22
+ # Execute a block with rate limiting and retry logic
23
+ def with_rate_limiting
24
+ return yield unless @enabled
25
+
26
+ attempt = 0
27
+ begin
28
+ attempt += 1
29
+ yield
30
+ rescue TooManyRequestsError, ServerError => e
31
+ raise unless should_retry?(e, attempt)
32
+
33
+ delay = calculate_delay(attempt, e)
34
+ sleep(delay)
35
+ retry
36
+ end
37
+ end
38
+
39
+ # Check if we should retry based on the error and attempt count
40
+ def should_retry?(error, attempt)
41
+ return false if attempt > @max_retries
42
+
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
52
+ end
53
+
54
+ # Calculate delay with exponential backoff
55
+ 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
61
+
62
+ # Exponential backoff: base_delay * (backoff_factor ^ (attempt - 1))
63
+ delay = @base_delay * (@backoff_factor**(attempt - 1))
64
+
65
+ # Cap at max_delay
66
+ [delay, @max_delay].min
67
+ end
68
+
69
+ # Extract Retry-After header value
70
+ def extract_retry_after(response)
71
+ headers = response[:headers] || {}
72
+ retry_after = headers['retry-after'] || headers['Retry-After']
73
+ return nil unless retry_after
74
+
75
+ # Retry-After can be in seconds (integer) or HTTP date
76
+ if retry_after.match?(/^\d+$/)
77
+ retry_after.to_i
78
+ else
79
+ # Parse HTTP date and calculate seconds from now
80
+ begin
81
+ retry_time = Time.parse(retry_after)
82
+ [retry_time - Time.now, 0].max
83
+ rescue ArgumentError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+
89
+ # Enable rate limiting
90
+ def enable!
91
+ @enabled = true
92
+ end
93
+
94
+ # Disable rate limiting
95
+ def disable!
96
+ @enabled = false
97
+ end
98
+
99
+ # Check if rate limiting is enabled
100
+ def enabled?
101
+ @enabled
102
+ end
103
+ end
104
+ end
105
+ end
@@ -11,12 +11,11 @@ module Lutaml
11
11
  class Resource < Lutaml::Model::Serializable
12
12
  # This is the model register that has fetched this resource, and
13
13
  # will be used to resolve links unless overriden in resource#realize()
14
- attr_accessor Hal::REGISTER_ID_ATTR_NAME.to_sym
14
+ attr_accessor :_global_register_id
15
15
 
16
- # Access embedded data if available
17
- def embedded_data
18
- @_embedded
19
- end
16
+ # Embedded resources from the HAL `_embedded` section, retained so that
17
+ # links can be realized from already-fetched content.
18
+ attr_accessor :embedded_data
20
19
 
21
20
  # Check if embedded data exists for a given key
22
21
  def has_embedded?(key)
@@ -31,7 +30,7 @@ module Lutaml
31
30
  # Create a resource instance from embedded JSON data
32
31
  def self.from_embedded(json_data, register_name = nil)
33
32
  instance = from_json(json_data.to_json)
34
- instance.instance_variable_set("@#{Hal::REGISTER_ID_ATTR_NAME}", register_name) if register_name
33
+ instance._global_register_id = register_name if register_name
35
34
  instance
36
35
  end
37
36
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- VERSION = '0.1.9'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
data/lib/lutaml/hal.rb CHANGED
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'lutaml/model'
4
+ require 'lutaml/store'
4
5
 
5
6
  module Lutaml
6
7
  # HAL implementation for Lutaml
7
8
  module Hal
8
- REGISTER_ID_ATTR_NAME = '_global_register_id'
9
-
10
9
  def self.debug_log(message)
11
10
  puts "[Lutaml::Hal] DEBUG: #{message}" if ENV['DEBUG_API']
12
11
  end
@@ -15,6 +14,7 @@ end
15
14
 
16
15
  require_relative 'hal/version'
17
16
  require_relative 'hal/errors'
17
+ require_relative 'hal/rate_limiter'
18
18
  require_relative 'hal/endpoint_parameter'
19
19
  require_relative 'hal/link'
20
20
  require_relative 'hal/link_set'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-hal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-08 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: lutaml-store
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.1.1
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.1.1
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: rainbow
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -78,6 +92,11 @@ files:
78
92
  - README.adoc
79
93
  - lib/lutaml-hal.rb
80
94
  - lib/lutaml/hal.rb
95
+ - lib/lutaml/hal/cache/cache_configuration.rb
96
+ - lib/lutaml/hal/cache/cache_entry.rb
97
+ - lib/lutaml/hal/cache/cache_manager.rb
98
+ - lib/lutaml/hal/cache/cache_metadata.rb
99
+ - lib/lutaml/hal/cache/simple_cache_store.rb
81
100
  - lib/lutaml/hal/client.rb
82
101
  - lib/lutaml/hal/endpoint_configuration.rb
83
102
  - lib/lutaml/hal/endpoint_parameter.rb
@@ -89,6 +108,7 @@ files:
89
108
  - lib/lutaml/hal/link_set_class_factory.rb
90
109
  - lib/lutaml/hal/model_register.rb
91
110
  - lib/lutaml/hal/page.rb
111
+ - lib/lutaml/hal/rate_limiter.rb
92
112
  - lib/lutaml/hal/resource.rb
93
113
  - lib/lutaml/hal/type_resolver.rb
94
114
  - lib/lutaml/hal/version.rb