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 +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 +68 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0879e2f05a3b6cfe0046dc4924ae0ba2bf5ef1925445e4469585c8ed77dd691
|
|
4
|
+
data.tar.gz: bc6e2d28adb425d5a29eab74c655bf4d721e81693a2109d4f5a61bb942d9cea4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
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.
|
|
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:
|
|
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
|