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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5860a26a1d8b235f8138e37fe835c3f0de0d4cc065f99b33da9ed17c0cb0a8cf
|
|
4
|
+
data.tar.gz: e558941f569c2d8d55ac62c66924a06a7445438281c261e060471bdf2d8c9f5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3cda001e3ebb066cf62e8aa54fbec2a41ae93a173a056919da2151cf8f28fcba143352ad2dc287d478c7365fa3e087abc1757770264acc05a4be925378072d7
|
|
7
|
+
data.tar.gz: d34fd6af3af5e78d1b223f04fa9f1e9e71ff524b2ac9425935d6bd5e07e4d8fa10a2c255c063c70026388d5f2406a05a41cde1492482cd66a911fb1fe80fb623
|
|
@@ -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
|