lutaml-hal 0.1.10 → 0.2.1
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/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 +83 -0
- data/lib/lutaml/hal/client.rb +9 -18
- 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 +4 -4
- data/lib/lutaml/hal/resource.rb +5 -6
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +1 -2
- metadata +21 -2
|
@@ -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,83 @@
|
|
|
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
|
+
#
|
|
8
|
+
# Thread-safe: a single mutex guards the cache and its LRU bookkeeping so
|
|
9
|
+
# it can back the register cache when consumers realize links from many
|
|
10
|
+
# threads (e.g. a parallel fetcher).
|
|
11
|
+
class SimpleCacheStore
|
|
12
|
+
attr_reader :max_size
|
|
13
|
+
|
|
14
|
+
def initialize(max_size = 100)
|
|
15
|
+
@max_size = max_size
|
|
16
|
+
@cache = {}
|
|
17
|
+
@access_order = []
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(key)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
return nil unless @cache.key?(key)
|
|
24
|
+
|
|
25
|
+
# Update access order for LRU
|
|
26
|
+
@access_order.delete(key)
|
|
27
|
+
@access_order.push(key)
|
|
28
|
+
|
|
29
|
+
@cache[key]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set(key, value)
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
# Remove existing entry if present
|
|
36
|
+
if @cache.key?(key)
|
|
37
|
+
@access_order.delete(key)
|
|
38
|
+
elsif @cache.size >= @max_size
|
|
39
|
+
# Evict least recently used item
|
|
40
|
+
lru_key = @access_order.shift
|
|
41
|
+
@cache.delete(lru_key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@cache[key] = value
|
|
45
|
+
@access_order.push(key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete(key)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@access_order.delete(key)
|
|
52
|
+
@cache.delete(key)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@cache.clear
|
|
59
|
+
@access_order.clear
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def size
|
|
64
|
+
@mutex.synchronize { @cache.size }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stats
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
{
|
|
70
|
+
size: @cache.size,
|
|
71
|
+
max_size: @max_size,
|
|
72
|
+
keys: @cache.keys
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cache_info
|
|
78
|
+
stats
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/lutaml/hal/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/lutaml/hal/link.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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.
|
|
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,
|
|
94
|
-
register.
|
|
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 =
|
|
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)
|
data/lib/lutaml/hal/link_set.rb
CHANGED
|
@@ -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
|
|
80
|
-
client.get_with_headers(final_url,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
434
|
+
debug_log("pattern_match?: matches = #{matches}")
|
|
348
435
|
|
|
349
436
|
matches
|
|
350
437
|
end
|
data/lib/lutaml/hal/page.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
8
|
-
DEFAULT_BASE_DELAY =
|
|
9
|
-
DEFAULT_MAX_DELAY =
|
|
10
|
-
DEFAULT_BACKOFF_FACTOR =
|
|
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
|
|
data/lib/lutaml/hal/resource.rb
CHANGED
|
@@ -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
|
|
14
|
+
attr_accessor :_global_register_id
|
|
15
15
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
33
|
+
instance._global_register_id = register_name if register_name
|
|
35
34
|
instance
|
|
36
35
|
end
|
|
37
36
|
|
data/lib/lutaml/hal/version.rb
CHANGED
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
|