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.
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cache_configuration'
4
+ require_relative 'cache_entry'
5
+ require_relative 'cache_metadata'
6
+ require_relative 'simple_cache_store'
7
+
8
+ # Try to require lutaml-store. Requiring the entry point (rather than the
9
+ # individual files) sets up the autoloads it relies on internally, e.g.
10
+ # Lutaml::Store::HttpCacheConfig referenced from HttpCache#initialize.
11
+ begin
12
+ require 'lutaml/store'
13
+ CACHE_STORE_AVAILABLE = true
14
+ rescue LoadError
15
+ CACHE_STORE_AVAILABLE = false
16
+ end
17
+
18
+ module Lutaml
19
+ module Hal
20
+ module Cache
21
+ # Manages all cache operations with a clean, unified interface
22
+ class CacheManager
23
+ attr_reader :configuration, :cache_store
24
+
25
+ # @param config cache configuration (see CacheConfiguration.from_config)
26
+ # @param client [Lutaml::Hal::Client, nil] used to canonicalize relative
27
+ # URLs so that a resource fetched by endpoint path and the same
28
+ # resource realized from an absolute link href share a cache entry.
29
+ def initialize(config = nil, client: nil)
30
+ @client = client
31
+ @configuration = CacheConfiguration.from_config(config)
32
+ begin
33
+ @configuration.validate!
34
+ rescue ArgumentError => e
35
+ raise ArgumentError, "Invalid cache configuration: #{e.message}"
36
+ end
37
+ @cache_store = create_cache_store
38
+ end
39
+
40
+ # Get a cache entry by URL
41
+ def get(url)
42
+ return nil unless cache_store
43
+
44
+ key = cache_key(url)
45
+
46
+ if http_aware_cache?
47
+ get_from_http_cache(url, key)
48
+ else
49
+ get_from_basic_cache(key)
50
+ end
51
+ end
52
+
53
+ # Store a cache entry
54
+ def set(url, response, hal_resource)
55
+ return unless cache_store
56
+
57
+ entry = CacheEntry.create(url, response, hal_resource)
58
+ return unless entry.cacheable?
59
+
60
+ key = cache_key(url)
61
+
62
+ if http_aware_cache?
63
+ set_in_http_cache(key, entry, response)
64
+ else
65
+ set_in_basic_cache(key, entry)
66
+ end
67
+
68
+ entry
69
+ end
70
+
71
+ # Make a conditional request using cached metadata
72
+ def conditional_request_headers(url)
73
+ entry = get(url)
74
+ return {} unless entry&.revalidatable?
75
+
76
+ entry.conditional_headers
77
+ end
78
+
79
+ # Update cache entry after a 304 Not Modified response
80
+ def refresh_entry(url, response)
81
+ entry = get(url)
82
+ return unless entry
83
+
84
+ entry.refresh_metadata(response)
85
+ set_refreshed_entry(url, entry)
86
+ end
87
+
88
+ # Remove a specific cache entry
89
+ def invalidate(url)
90
+ return unless cache_store
91
+
92
+ key = cache_key(url)
93
+ cache_store.delete(key)
94
+ end
95
+
96
+ # Clear all cache entries
97
+ def clear
98
+ return unless cache_store
99
+
100
+ cache_store.clear
101
+ end
102
+
103
+ # Get cache statistics
104
+ def stats
105
+ return {} unless cache_store
106
+
107
+ if cache_store.respond_to?(:cache_info)
108
+ cache_store.cache_info
109
+ elsif cache_store.respond_to?(:stats)
110
+ cache_store.stats
111
+ else
112
+ {}
113
+ end
114
+ end
115
+
116
+ # Get cache information
117
+ def info
118
+ return nil unless cache_store
119
+
120
+ {
121
+ adapter_type: cache_store.class.name,
122
+ configuration: configuration,
123
+ current_size: cache_store.respond_to?(:size) ? cache_store.size : 'unknown',
124
+ stats: stats
125
+ }
126
+ end
127
+
128
+ # Check if cache is available and configured
129
+ def available?
130
+ !cache_store.nil?
131
+ end
132
+
133
+ # Check if using HTTP-aware cache
134
+ def http_aware_cache?
135
+ configuration.http_aware? && cache_store.respond_to?(:fetch)
136
+ end
137
+
138
+ private
139
+
140
+ def create_cache_store
141
+ # A persistent adapter (filesystem / sqlite) uses lutaml-store's
142
+ # CacheStore, which serializes each entry to JSON; CacheEntry knows how
143
+ # to round-trip itself (and rebuild its HAL model) for that path.
144
+ #
145
+ # The default in-memory adapter uses SimpleCacheStore, which keeps live
146
+ # CacheEntry objects so cache hits avoid any serialization cost.
147
+ #
148
+ # NOTE: Backing the HTTP-aware mode with lutaml-store's HttpCache
149
+ # response cache is deferred until realized models can be
150
+ # reconstructed from a cached response (requires the resource class);
151
+ # the create_http_cache / *_http_cache helpers remain as scaffolding.
152
+ if CACHE_STORE_AVAILABLE && persistent_adapter?
153
+ create_basic_cache
154
+ else
155
+ create_simple_cache
156
+ end
157
+ rescue StandardError => e
158
+ Lutaml::Hal.debug_log("Failed to create cache store: #{e.message}")
159
+ create_simple_cache
160
+ end
161
+
162
+ # Whether the configured adapter persists beyond the process.
163
+ def persistent_adapter?
164
+ %w[filesystem sqlite].include?(configuration.effective_adapter_type)
165
+ end
166
+
167
+ def create_http_cache
168
+ return nil unless defined?(::Lutaml::Store::HttpCache)
169
+
170
+ ::Lutaml::Store::HttpCache.new(configuration.http_cache_config)
171
+ end
172
+
173
+ def create_basic_cache
174
+ return nil unless defined?(::Lutaml::Store::CacheStore)
175
+
176
+ ::Lutaml::Store::CacheStore.new(configuration.basic_cache_config)
177
+ end
178
+
179
+ def create_simple_cache
180
+ SimpleCacheStore.new(configuration.effective_max_size)
181
+ end
182
+
183
+ def cache_key(url)
184
+ "hal_resource:#{canonical_url(url)}"
185
+ end
186
+
187
+ # Normalize a URL to an absolute form so that the same resource is
188
+ # cached under one key regardless of whether it was reached by a
189
+ # relative endpoint path (fetch) or an absolute link href (realize).
190
+ def canonical_url(url)
191
+ url = url.to_s
192
+ return url if url.start_with?('http')
193
+ return url unless @client&.api_url
194
+
195
+ "#{@client.api_url}#{url}"
196
+ end
197
+
198
+ def get_from_http_cache(url, key)
199
+ # HTTP cache handles conditional requests internally
200
+ cached_response = cache_store.get(key)
201
+ return nil unless cached_response
202
+
203
+ # Convert HTTP cache response back to CacheEntry
204
+ convert_http_response_to_entry(url, cached_response)
205
+ end
206
+
207
+ def get_from_basic_cache(key)
208
+ cached_data = cache_store.get(key)
209
+ return nil unless cached_data
210
+
211
+ # In-memory stores keep a live CacheEntry; persistent stores return a
212
+ # plain hash that we rebuild (with its HAL model) here.
213
+ case cached_data
214
+ when CacheEntry
215
+ cached_data.valid?(configuration.effective_ttl) ? cached_data : nil
216
+ when Hash
217
+ if CacheEntry.storage_format?(cached_data)
218
+ entry = CacheEntry.from_storage_h(cached_data)
219
+ entry&.valid?(configuration.effective_ttl) ? entry : nil
220
+ else
221
+ # Legacy in-memory hash format support
222
+ convert_legacy_cache_data(cached_data)
223
+ end
224
+ end
225
+ end
226
+
227
+ def set_in_http_cache(key, entry, response)
228
+ # Convert CacheEntry to HTTP cache format
229
+ http_response = convert_entry_to_http_response(entry, response)
230
+ cache_store.set(key, http_response)
231
+ end
232
+
233
+ def set_in_basic_cache(key, entry)
234
+ cache_store.set(key, entry)
235
+ end
236
+
237
+ def set_refreshed_entry(url, entry)
238
+ key = cache_key(url)
239
+
240
+ if http_aware_cache?
241
+ # For HTTP cache, we need to update the stored response
242
+ http_response = convert_entry_to_http_response(entry, nil)
243
+ cache_store.set(key, http_response)
244
+ else
245
+ cache_store.set(key, entry)
246
+ end
247
+ end
248
+
249
+ def convert_http_response_to_entry(url, http_response)
250
+ # Extract HAL resource and metadata from HTTP cache response
251
+ body = http_response[:body] || http_response['body']
252
+ headers = http_response[:headers] || http_response['headers'] || {}
253
+
254
+ # Parse the body back to get the HAL resource
255
+ # Note: This is a simplified conversion - in practice, we might need
256
+ # to store the HAL resource separately or use serialization
257
+ hal_resource = parse_hal_resource_from_body(body)
258
+
259
+ CacheEntry.new(
260
+ url: url,
261
+ cached_at: Time.now, # HTTP cache manages its own timestamps
262
+ metadata: CacheMetadata.from_response(headers),
263
+ hal_resource: hal_resource
264
+ )
265
+ end
266
+
267
+ def convert_entry_to_http_response(entry, _original_response)
268
+ {
269
+ status_code: entry.metadata&.status_code || 200,
270
+ headers: extract_headers_from_metadata(entry.metadata),
271
+ body: serialize_hal_resource(entry.hal_resource)
272
+ }
273
+ end
274
+
275
+ def convert_legacy_cache_data(cached_data)
276
+ # Support for legacy cache format
277
+ return nil unless cached_data[:realized_model] && cached_data[:cached_at]
278
+
279
+ CacheEntry.new(
280
+ url: cached_data[:url],
281
+ cached_at: cached_data[:cached_at],
282
+ metadata: create_metadata_from_legacy(cached_data),
283
+ hal_resource: cached_data[:realized_model]
284
+ )
285
+ end
286
+
287
+ def create_metadata_from_legacy(cached_data)
288
+ CacheMetadata.new(
289
+ etag: cached_data[:etag],
290
+ last_modified: cached_data[:last_modified],
291
+ status_code: 200
292
+ )
293
+ end
294
+
295
+ def extract_headers_from_metadata(metadata)
296
+ return {} unless metadata
297
+
298
+ {
299
+ 'etag' => metadata.etag,
300
+ 'last-modified' => metadata.last_modified,
301
+ 'cache-control' => metadata.cache_control,
302
+ 'expires' => metadata.expires,
303
+ 'content-type' => metadata.content_type,
304
+ 'date' => metadata.date,
305
+ 'vary' => metadata.vary
306
+ }.compact
307
+ end
308
+
309
+ def parse_hal_resource_from_body(body)
310
+ # This is a placeholder - in practice, we might need to store
311
+ # the HAL resource class information to properly deserialize
312
+ case body
313
+ when String
314
+ JSON.parse(body)
315
+ else
316
+ body
317
+ end
318
+ rescue JSON::ParserError
319
+ body
320
+ end
321
+
322
+ def serialize_hal_resource(hal_resource)
323
+ # Serialize HAL resource for storage
324
+ case hal_resource
325
+ when ->(r) { r.respond_to?(:to_json) }
326
+ hal_resource.to_json
327
+ else
328
+ hal_resource.to_s
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Lutaml
6
+ module Hal
7
+ module Cache
8
+ # Represents HTTP response metadata from the request that created the HAL resource
9
+ class CacheMetadata < Lutaml::Model::Serializable
10
+ attribute :etag, :string
11
+ attribute :last_modified, :string
12
+ attribute :cache_control, :string
13
+ attribute :expires, :string
14
+ attribute :status_code, :integer
15
+ attribute :content_type, :string
16
+ attribute :date, :string
17
+ attribute :vary, :string
18
+
19
+ # Extract metadata from HTTP response headers
20
+ def self.from_response(response)
21
+ headers = extract_headers(response)
22
+
23
+ new(
24
+ etag: headers['etag'],
25
+ last_modified: headers['last-modified'],
26
+ cache_control: headers['cache-control'],
27
+ expires: headers['expires'],
28
+ status_code: extract_status_code(response),
29
+ content_type: headers['content-type'],
30
+ date: headers['date'],
31
+ vary: headers['vary']
32
+ )
33
+ end
34
+
35
+ # Generate conditional request headers for cache validation
36
+ def conditional_headers
37
+ headers = {}
38
+ headers['If-None-Match'] = etag if etag
39
+ headers['If-Modified-Since'] = last_modified if last_modified
40
+ headers
41
+ end
42
+
43
+ # Check if the metadata indicates the response is cacheable
44
+ def cacheable?
45
+ return false if cache_control&.include?('no-cache')
46
+ return false if cache_control&.include?('no-store')
47
+ return false if cache_control&.include?('private')
48
+
49
+ true
50
+ end
51
+
52
+ # Extract TTL from cache-control header
53
+ def max_age
54
+ return nil unless cache_control
55
+
56
+ match = cache_control.match(/max-age=(\d+)/)
57
+ match ? match[1].to_i : nil
58
+ end
59
+
60
+ # Check if the response can be revalidated with conditional requests
61
+ def revalidatable?
62
+ !etag.nil? && !etag.empty? || !last_modified.nil? && !last_modified.empty?
63
+ end
64
+
65
+ def self.extract_headers(response)
66
+ case response
67
+ when Hash
68
+ # Response is already a hash, extract headers directly
69
+ response.select { |k, _| k.is_a?(String) && k.match?(/^[a-z-]+$/) }
70
+ when ->(r) { r.respond_to?(:headers) }
71
+ # Response has headers method
72
+ response.headers.to_h
73
+ when ->(r) { r.respond_to?(:[]) }
74
+ # Response is hash-like, try to extract common headers
75
+ {
76
+ 'etag' => response['etag'],
77
+ 'last-modified' => response['last-modified'],
78
+ 'cache-control' => response['cache-control'],
79
+ 'expires' => response['expires'],
80
+ 'content-type' => response['content-type'],
81
+ 'date' => response['date'],
82
+ 'vary' => response['vary']
83
+ }.compact
84
+ else
85
+ {}
86
+ end
87
+ end
88
+
89
+ def self.extract_status_code(response)
90
+ case response
91
+ when Hash
92
+ response['status'] || response[:status] || 200
93
+ when ->(r) { r.respond_to?(:status) }
94
+ response.status
95
+ when ->(r) { r.respond_to?(:code) }
96
+ response.code.to_i
97
+ else
98
+ 200
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Hal
5
+ module Cache
6
+ # Simple in-memory cache store for testing and fallback scenarios
7
+ class SimpleCacheStore
8
+ attr_reader :max_size
9
+
10
+ def initialize(max_size = 100)
11
+ @max_size = max_size
12
+ @cache = {}
13
+ @access_order = []
14
+ end
15
+
16
+ def get(key)
17
+ return nil unless @cache.key?(key)
18
+
19
+ # Update access order for LRU
20
+ @access_order.delete(key)
21
+ @access_order.push(key)
22
+
23
+ @cache[key]
24
+ end
25
+
26
+ def set(key, value)
27
+ # Remove existing entry if present
28
+ if @cache.key?(key)
29
+ @access_order.delete(key)
30
+ elsif @cache.size >= @max_size
31
+ # Evict least recently used item
32
+ lru_key = @access_order.shift
33
+ @cache.delete(lru_key)
34
+ end
35
+
36
+ @cache[key] = value
37
+ @access_order.push(key)
38
+ end
39
+
40
+ def delete(key)
41
+ @access_order.delete(key)
42
+ @cache.delete(key)
43
+ end
44
+
45
+ def clear
46
+ @cache.clear
47
+ @access_order.clear
48
+ end
49
+
50
+ def size
51
+ @cache.size
52
+ end
53
+
54
+ def stats
55
+ {
56
+ size: @cache.size,
57
+ max_size: @max_size,
58
+ keys: @cache.keys
59
+ }
60
+ end
61
+
62
+ def cache_info
63
+ stats
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -5,20 +5,20 @@ require 'faraday/follow_redirects'
5
5
  require 'json'
6
6
  require 'rainbow'
7
7
  require_relative 'errors'
8
+ require_relative 'rate_limiter'
8
9
 
9
10
  module Lutaml
10
11
  module Hal
11
12
  # HAL Client for making HTTP requests to HAL APIs
12
13
  class Client
13
- attr_reader :last_response, :api_url, :connection
14
+ attr_reader :last_response, :api_url, :connection, :rate_limiter
14
15
 
15
16
  def initialize(options = {})
16
17
  @api_url = options[:api_url] || raise(ArgumentError, 'api_url is required')
17
18
  @connection = options[:connection] || create_connection
18
19
  @params_default = options[:params_default] || {}
19
20
  @debug = options[:debug] || !ENV['DEBUG_API'].nil?
20
- @cache = options[:cache] || {}
21
- @cache_enabled = options[:cache_enabled] || false
21
+ @rate_limiter = options[:rate_limiter] || RateLimiter.new(options[:rate_limiting] || {})
22
22
 
23
23
  @api_url = strip_api_url(@api_url)
24
24
  end
@@ -35,50 +35,45 @@ module Lutaml
35
35
  get(path, params)
36
36
  end
37
37
 
38
+ # Get a resource by its full URL with custom headers (e.g. conditional
39
+ # request headers for cache revalidation)
40
+ def get_by_url_with_headers(url, headers = {})
41
+ path = strip_api_url(url)
42
+ get_with_headers(path, headers)
43
+ end
44
+
38
45
  # Make a GET request to the API
39
46
  def get(url, params = {})
40
- cache_key = "#{url}:#{params.to_json}"
41
-
42
- return @cache[cache_key] if @cache_enabled && @cache.key?(cache_key)
43
-
44
- @last_response = @connection.get(url, params)
45
-
46
- response = handle_response(@last_response, url)
47
-
48
- @cache[cache_key] = response if @cache_enabled
49
- response
47
+ @rate_limiter.with_rate_limiting do
48
+ @last_response = @connection.get(url, params)
49
+ handle_response(@last_response, url)
50
+ end
50
51
  rescue Faraday::ConnectionFailed => e
51
- raise ConnectionError, "Connection failed: #{e.message}"
52
+ raise Lutaml::Hal::ConnectionError, "Connection failed: #{e.message}"
52
53
  rescue Faraday::TimeoutError => e
53
- raise TimeoutError, "Request timed out: #{e.message}"
54
+ raise Lutaml::Hal::TimeoutError, "Request timed out: #{e.message}"
54
55
  rescue Faraday::ParsingError => e
55
- raise ParsingError, "Response parsing error: #{e.message}"
56
+ raise Lutaml::Hal::ParsingError, "Response parsing error: #{e.message}"
56
57
  rescue Faraday::Adapter::Test::Stubs::NotFound => e
57
- raise LinkResolutionError, "Resource not found: #{e.message}"
58
+ raise Lutaml::Hal::LinkResolutionError, "Resource not found: #{e.message}"
58
59
  end
59
60
 
60
61
  # Make a GET request with custom headers
61
62
  def get_with_headers(url, headers = {})
62
- cache_key = "#{url}:#{headers.to_json}"
63
-
64
- return @cache[cache_key] if @cache_enabled && @cache.key?(cache_key)
65
-
66
- @last_response = @connection.get(url) do |req|
67
- headers.each { |key, value| req.headers[key] = value }
63
+ @rate_limiter.with_rate_limiting do
64
+ @last_response = @connection.get(url) do |req|
65
+ headers.each { |key, value| req.headers[key] = value }
66
+ end
67
+ handle_response(@last_response, url)
68
68
  end
69
-
70
- response = handle_response(@last_response, url)
71
-
72
- @cache[cache_key] = response if @cache_enabled
73
- response
74
69
  rescue Faraday::ConnectionFailed => e
75
- raise ConnectionError, "Connection failed: #{e.message}"
70
+ raise Lutaml::Hal::ConnectionError, "Connection failed: #{e.message}"
76
71
  rescue Faraday::TimeoutError => e
77
- raise TimeoutError, "Request timed out: #{e.message}"
72
+ raise Lutaml::Hal::TimeoutError, "Request timed out: #{e.message}"
78
73
  rescue Faraday::ParsingError => e
79
- raise ParsingError, "Response parsing error: #{e.message}"
74
+ raise Lutaml::Hal::ParsingError, "Response parsing error: #{e.message}"
80
75
  rescue Faraday::Adapter::Test::Stubs::NotFound => e
81
- raise LinkResolutionError, "Resource not found: #{e.message}"
76
+ raise Lutaml::Hal::LinkResolutionError, "Resource not found: #{e.message}"
82
77
  end
83
78
 
84
79
  private
@@ -104,8 +99,16 @@ module Lutaml
104
99
  raise UnauthorizedError, response_message(response)
105
100
  when 404
106
101
  raise NotFoundError, response_message(response)
102
+ when 429
103
+ TooManyRequestsError.new(response_message(response)).tap do |error|
104
+ error.define_singleton_method(:response) { { status: response.status, headers: response.headers } }
105
+ raise error
106
+ end
107
107
  when 500..599
108
- raise ServerError, response_message(response)
108
+ ServerError.new(response_message(response)).tap do |error|
109
+ error.define_singleton_method(:response) { { status: response.status, headers: response.headers } }
110
+ raise error
111
+ end
109
112
  else
110
113
  raise Error, response_message(response)
111
114
  end
@@ -9,5 +9,8 @@ module Lutaml
9
9
  class ServerError < Error; end
10
10
  class LinkResolutionError < Error; end
11
11
  class ParsingError < Error; end
12
+ class ConnectionError < Error; end
13
+ class TimeoutError < Error; end
14
+ class TooManyRequestsError < Error; end
12
15
  end
13
16
  end
@@ -43,6 +43,25 @@ module Lutaml
43
43
  def unregister(name)
44
44
  delete(name)
45
45
  end
46
+
47
+ # Cache management methods for all registered model registers
48
+ def clear_all_caches
49
+ @model_registers.each_value do |register|
50
+ register.clear_cache if register.respond_to?(:clear_cache)
51
+ end
52
+ end
53
+
54
+ def cache_stats
55
+ stats = {}
56
+ @model_registers.each do |name, register|
57
+ stats[name] = register.cache_info if register.respond_to?(:cache_info)
58
+ end
59
+ stats
60
+ end
61
+
62
+ def list_registers
63
+ @model_registers.keys
64
+ end
46
65
  end
47
66
  end
48
67
  end