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.
- checksums.yaml +4 -4
- data/README.adoc +23 -30
- data/lib/lutaml/hal/cache/cache_configuration.rb +185 -0
- data/lib/lutaml/hal/cache/cache_entry.rb +140 -0
- data/lib/lutaml/hal/cache/cache_manager.rb +334 -0
- data/lib/lutaml/hal/cache/cache_metadata.rb +104 -0
- data/lib/lutaml/hal/cache/simple_cache_store.rb +68 -0
- data/lib/lutaml/hal/client.rb +36 -33
- data/lib/lutaml/hal/errors.rb +3 -0
- data/lib/lutaml/hal/global_register.rb +19 -0
- data/lib/lutaml/hal/link.rb +15 -10
- data/lib/lutaml/hal/link_set.rb +1 -1
- data/lib/lutaml/hal/model_register.rb +117 -30
- data/lib/lutaml/hal/page.rb +1 -1
- data/lib/lutaml/hal/rate_limiter.rb +105 -0
- data/lib/lutaml/hal/resource.rb +5 -6
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +2 -2
- metadata +22 -2
|
@@ -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
|
data/lib/lutaml/hal/client.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
data/lib/lutaml/hal/errors.rb
CHANGED
|
@@ -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
|