lutaml-hal 0.1.10 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f3ce9deb2f8607008c4b5d6950dd281a79bcba5856b03769c0a764dd0c95228
4
- data.tar.gz: 0d8bd9e4a6f78dd18ce1c58288276d8e0334a48e4858fcbbfb694fd68dc5deb1
3
+ metadata.gz: a0879e2f05a3b6cfe0046dc4924ae0ba2bf5ef1925445e4469585c8ed77dd691
4
+ data.tar.gz: bc6e2d28adb425d5a29eab74c655bf4d721e81693a2109d4f5a61bb942d9cea4
5
5
  SHA512:
6
- metadata.gz: d29f19722b4aa33f3404cd084394573cfa7dc5af3811fb0d42a5cd78319de4e1ff8e8e71b6de59eb23441b39ff0588a36c02ae35156eee99aa845e90929691b6
7
- data.tar.gz: 4d00fc04f030bda2eec1558ea616f1c3533b8e7109c3224678a96de41b374f4ddadae7a9cf2fd6cbe807633e77a185e8ab893e944b98dc4433898b1cbb1571cc
6
+ metadata.gz: 9498991d51a700e261ccf411121797d88a5e205a1f2210ba8558615e69aacb89c61280fbf5454f579b18207de3ebfe5aedc3f99950f7e5bcd1ba6169770a3d31
7
+ data.tar.gz: 336e55e9740cef31a1e30f1208c212e41e9c643f8756394844bd14da89b221d34aabc62a4830e0b850d540c30feb31b765a02543a200086f39e417d36efce7ee
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Lutaml
6
+ module Hal
7
+ module Cache
8
+ # Represents cache configuration with validation and defaults
9
+ class CacheConfiguration < Lutaml::Model::Serializable
10
+ attribute :adapter_type, :string
11
+ attribute :adapter_config, :hash
12
+ attribute :ttl, :integer
13
+ attribute :max_size, :integer
14
+ attribute :http_aware, :boolean
15
+ attribute :respect_http_headers, :boolean
16
+ attribute :enable_conditional_requests, :boolean
17
+ attribute :ignore_query_params, :string
18
+
19
+ # Default configuration values
20
+ DEFAULT_TTL = 3600
21
+ DEFAULT_MAX_SIZE = 1000
22
+ DEFAULT_ADAPTER_TYPE = 'memory'
23
+
24
+ # Create configuration from hash or symbol
25
+ def self.from_config(config)
26
+ return new if config.nil?
27
+
28
+ case config
29
+ when Hash
30
+ from_hash(config)
31
+ when Symbol, String
32
+ from_simple_config(config)
33
+ else
34
+ raise ArgumentError, "Invalid cache configuration: #{config.inspect}"
35
+ end
36
+ end
37
+
38
+ # Validate the configuration
39
+ def validate!
40
+ validate_adapter_type!
41
+ validate_ttl!
42
+ validate_max_size!
43
+ validate_adapter_config!
44
+ end
45
+
46
+ # Check if HTTP-aware caching should be used
47
+ #
48
+ # HTTP-aware caching (conditional requests backed by a response cache)
49
+ # is opt-in: it only applies when explicitly enabled and the
50
+ # lutaml-store HTTP cache backend is available. By default the register
51
+ # uses the basic object cache, which stores realized models directly.
52
+ def http_aware?
53
+ http_aware == true && http_cache_available?
54
+ end
55
+
56
+ # Check if basic caching should be used
57
+ def basic_cache?
58
+ !http_aware?
59
+ end
60
+
61
+ # Get the effective TTL (with fallback to default)
62
+ def effective_ttl
63
+ ttl || DEFAULT_TTL
64
+ end
65
+
66
+ # Get the effective max size (with fallback to default)
67
+ def effective_max_size
68
+ max_size || DEFAULT_MAX_SIZE
69
+ end
70
+
71
+ # Get the effective adapter type (with fallback to default)
72
+ def effective_adapter_type
73
+ adapter_type || DEFAULT_ADAPTER_TYPE
74
+ end
75
+
76
+ # Get HTTP cache configuration hash
77
+ def http_cache_config
78
+ {
79
+ adapter_type: effective_adapter_type.to_sym,
80
+ default_ttl: effective_ttl,
81
+ max_entries: effective_max_size,
82
+ respect_http_headers: respect_http_headers != false,
83
+ enable_conditional_requests: enable_conditional_requests != false,
84
+ ignore_query_params: parse_ignore_query_params
85
+ }.merge(adapter_config || {})
86
+ end
87
+
88
+ # Get basic cache configuration hash
89
+ def basic_cache_config
90
+ {
91
+ adapter: adapter_config || { type: effective_adapter_type.to_sym },
92
+ default_ttl: effective_ttl,
93
+ max_size: effective_max_size
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def self.from_hash(config)
100
+ adapter_info = config[:adapter] || config['adapter'] || {}
101
+
102
+ # Handle direct adapter_type specification
103
+ adapter_type = config[:adapter_type] || config['adapter_type'] || extract_adapter_type(adapter_info)
104
+
105
+ new(
106
+ adapter_type: adapter_type,
107
+ adapter_config: adapter_info.is_a?(Hash) ? adapter_info : nil,
108
+ ttl: config[:ttl] || config['ttl'],
109
+ max_size: config[:max_size] || config['max_size'],
110
+ http_aware: config.key?(:http_aware) ? config[:http_aware] : config['http_aware'],
111
+ respect_http_headers: config.key?(:respect_http_headers) ? config[:respect_http_headers] : config['respect_http_headers'],
112
+ enable_conditional_requests: config.key?(:enable_conditional_requests) ? config[:enable_conditional_requests] : config['enable_conditional_requests'],
113
+ ignore_query_params: config[:ignore_query_params] || config['ignore_query_params']
114
+ )
115
+ end
116
+
117
+ def self.from_simple_config(config)
118
+ new(adapter_type: config.to_s)
119
+ end
120
+
121
+ def self.extract_adapter_type(adapter_info)
122
+ case adapter_info
123
+ when Hash
124
+ type = adapter_info[:type] || adapter_info['type']
125
+ type&.to_s
126
+ when Symbol, String
127
+ adapter_info.to_s
128
+ end
129
+ end
130
+
131
+ def validate_adapter_type!
132
+ valid_types = %w[memory filesystem sqlite]
133
+ return if valid_types.include?(effective_adapter_type)
134
+
135
+ raise ArgumentError, "Invalid adapter type: #{effective_adapter_type}. Valid types: #{valid_types.join(', ')}"
136
+ end
137
+
138
+ def validate_ttl!
139
+ return unless ttl
140
+ return if ttl.is_a?(Integer) && ttl > 0
141
+
142
+ raise ArgumentError, "TTL must be a positive integer, got: #{ttl.inspect}"
143
+ end
144
+
145
+ def validate_max_size!
146
+ return unless max_size
147
+ return if max_size.is_a?(Integer) && max_size > 0
148
+
149
+ raise ArgumentError, "Max size must be a positive integer, got: #{max_size.inspect}"
150
+ end
151
+
152
+ def validate_adapter_config!
153
+ return unless adapter_config
154
+ return if adapter_config.is_a?(Hash)
155
+
156
+ raise ArgumentError, "Adapter config must be a hash, got: #{adapter_config.class}"
157
+ end
158
+
159
+ # Override the setter to validate before assignment
160
+ def adapter_config=(value)
161
+ raise ArgumentError, "Adapter config must be a hash, got: #{value.class}" if value && !value.is_a?(Hash)
162
+
163
+ super(value)
164
+ end
165
+
166
+ def http_cache_available?
167
+ defined?(::Lutaml::Store::HttpCache)
168
+ end
169
+
170
+ def parse_ignore_query_params
171
+ return [] unless ignore_query_params
172
+
173
+ case ignore_query_params
174
+ when String
175
+ ignore_query_params.split(',').map(&:strip)
176
+ when Array
177
+ ignore_query_params
178
+ else
179
+ []
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'cache_metadata'
5
+
6
+ module Lutaml
7
+ module Hal
8
+ module Cache
9
+ # Represents a complete cached entry with metadata and HAL resource
10
+ class CacheEntry
11
+ attr_accessor :url, :cached_at, :metadata, :hal_resource
12
+
13
+ def initialize(url: nil, cached_at: nil, metadata: nil, hal_resource: nil)
14
+ @url = url
15
+ @cached_at = cached_at
16
+ @metadata = metadata
17
+ @hal_resource = hal_resource
18
+ end
19
+
20
+ # Plain-hash representation suitable for JSON persistence. The HAL
21
+ # resource and its class are recorded so the model can be rebuilt; the
22
+ # metadata is kept as its own JSON document.
23
+ def to_storage_h
24
+ {
25
+ 'url' => url,
26
+ 'cached_at' => cached_at,
27
+ 'metadata' => metadata&.to_json,
28
+ 'model_class' => hal_resource&.class&.name,
29
+ 'model' => hal_resource&.to_json
30
+ }
31
+ end
32
+
33
+ # Called by lutaml-store's CacheStore when serializing a persisted entry.
34
+ def to_json(*_args)
35
+ JSON.generate(to_storage_h)
36
+ end
37
+
38
+ # True if the given hash looks like a to_storage_h document (as opposed
39
+ # to a legacy in-memory cache hash holding a live :realized_model).
40
+ def self.storage_format?(hash)
41
+ hash.key?('model') || hash.key?(:model) ||
42
+ hash.key?('model_class') || hash.key?(:model_class)
43
+ end
44
+
45
+ # Rebuild a CacheEntry from a to_storage_h document. Tolerates string or
46
+ # symbol keys, since lutaml-store parses persisted JSON with
47
+ # symbolize_names.
48
+ def self.from_storage_h(hash)
49
+ h = hash.transform_keys(&:to_s)
50
+ new(
51
+ url: h['url'],
52
+ cached_at: h['cached_at'],
53
+ metadata: h['metadata'] ? CacheMetadata.from_json(h['metadata']) : nil,
54
+ hal_resource: rebuild_model(h['model_class'], h['model'])
55
+ )
56
+ end
57
+
58
+ def self.rebuild_model(class_name, model_json)
59
+ return nil unless class_name && model_json
60
+
61
+ Object.const_get(class_name).from_json(model_json)
62
+ rescue NameError
63
+ nil
64
+ end
65
+
66
+ # Create a cache entry from a URL, response, and realized HAL resource
67
+ def self.create(url, response, hal_resource)
68
+ new(
69
+ url: url,
70
+ cached_at: Time.now.to_s,
71
+ metadata: CacheMetadata.from_response(response),
72
+ hal_resource: hal_resource
73
+ )
74
+ end
75
+
76
+ # Check if the cache entry is still valid based on TTL
77
+ def valid?(default_ttl)
78
+ return false unless cached_at
79
+
80
+ cached_time = cached_at.is_a?(String) ? Time.parse(cached_at) : cached_at
81
+ age = Time.now - cached_time
82
+ ttl = metadata&.max_age || default_ttl
83
+
84
+ age < ttl
85
+ end
86
+
87
+ # Check if the entry is expired and needs revalidation
88
+ def expired?(default_ttl)
89
+ !valid?(default_ttl)
90
+ end
91
+
92
+ # Check if the entry can be revalidated with conditional requests
93
+ def revalidatable?
94
+ return false unless metadata
95
+
96
+ !!(metadata.etag || metadata.last_modified)
97
+ end
98
+
99
+ # Get conditional headers for revalidation
100
+ def conditional_headers
101
+ metadata&.conditional_headers || {}
102
+ end
103
+
104
+ # Check if the response is cacheable based on metadata
105
+ def cacheable?
106
+ metadata&.cacheable? != false
107
+ end
108
+
109
+ # Update the cache entry with fresh metadata (for 304 responses)
110
+ def refresh_metadata(response)
111
+ self.cached_at = Time.now.to_s
112
+ self.metadata = CacheMetadata.from_response(response)
113
+ end
114
+
115
+ # Get cache age in seconds
116
+ def age
117
+ return 0 unless cached_at
118
+
119
+ cached_time = cached_at.is_a?(String) ? Time.parse(cached_at) : cached_at
120
+ Time.now - cached_time
121
+ end
122
+
123
+ # Check if entry should be served stale (useful for error scenarios)
124
+ def serve_stale?(max_stale = nil)
125
+ return false unless max_stale
126
+ return false if valid?(Float::INFINITY) # Still fresh
127
+
128
+ cached_time = cached_at.is_a?(String) ? Time.parse(cached_at) : cached_at
129
+ current_age = Time.now - cached_time
130
+ ttl = metadata&.max_age || 0
131
+
132
+ # Entry is stale if current_age > ttl
133
+ # But we can serve it if the staleness is within the max_stale window
134
+ staleness = current_age - ttl
135
+ current_age > ttl && staleness < max_stale
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -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
@@ -18,8 +18,6 @@ module Lutaml
18
18
  @connection = options[:connection] || create_connection
19
19
  @params_default = options[:params_default] || {}
20
20
  @debug = options[:debug] || !ENV['DEBUG_API'].nil?
21
- @cache = options[:cache] || {}
22
- @cache_enabled = options[:cache_enabled] || false
23
21
  @rate_limiter = options[:rate_limiter] || RateLimiter.new(options[:rate_limiting] || {})
24
22
 
25
23
  @api_url = strip_api_url(@api_url)
@@ -37,19 +35,19 @@ module Lutaml
37
35
  get(path, params)
38
36
  end
39
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
+
40
45
  # Make a GET request to the API
41
46
  def get(url, params = {})
42
- cache_key = "#{url}:#{params.to_json}"
43
-
44
- return @cache[cache_key] if @cache_enabled && @cache.key?(cache_key)
45
-
46
- response = @rate_limiter.with_rate_limiting do
47
+ @rate_limiter.with_rate_limiting do
47
48
  @last_response = @connection.get(url, params)
48
49
  handle_response(@last_response, url)
49
50
  end
50
-
51
- @cache[cache_key] = response if @cache_enabled
52
- response
53
51
  rescue Faraday::ConnectionFailed => e
54
52
  raise Lutaml::Hal::ConnectionError, "Connection failed: #{e.message}"
55
53
  rescue Faraday::TimeoutError => e
@@ -62,19 +60,12 @@ module Lutaml
62
60
 
63
61
  # Make a GET request with custom headers
64
62
  def get_with_headers(url, headers = {})
65
- cache_key = "#{url}:#{headers.to_json}"
66
-
67
- return @cache[cache_key] if @cache_enabled && @cache.key?(cache_key)
68
-
69
- response = @rate_limiter.with_rate_limiting do
63
+ @rate_limiter.with_rate_limiting do
70
64
  @last_response = @connection.get(url) do |req|
71
65
  headers.each { |key, value| req.headers[key] = value }
72
66
  end
73
67
  handle_response(@last_response, url)
74
68
  end
75
-
76
- @cache[cache_key] = response if @cache_enabled
77
- response
78
69
  rescue Faraday::ConnectionFailed => e
79
70
  raise Lutaml::Hal::ConnectionError, "Connection failed: #{e.message}"
80
71
  rescue Faraday::TimeoutError => e
@@ -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
@@ -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)
@@ -4,10 +4,10 @@ module Lutaml
4
4
  module Hal
5
5
  # Rate limiter to handle API rate limiting with exponential backoff
6
6
  class RateLimiter
7
- DEFAULT_MAX_RETRIES = 3
8
- DEFAULT_BASE_DELAY = 1.0
9
- DEFAULT_MAX_DELAY = 60.0
10
- DEFAULT_BACKOFF_FACTOR = 2.0
7
+ DEFAULT_MAX_RETRIES = 5
8
+ DEFAULT_BASE_DELAY = 0.05
9
+ DEFAULT_MAX_DELAY = 5.0
10
+ DEFAULT_BACKOFF_FACTOR = 1.5
11
11
 
12
12
  attr_reader :max_retries, :base_delay, :max_delay, :backoff_factor
13
13
 
@@ -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.10'
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
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.10
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-09 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