lutaml-hal 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.adoc +23 -30
- data/lib/lutaml/hal/cache/cache_configuration.rb +185 -0
- data/lib/lutaml/hal/cache/cache_entry.rb +140 -0
- data/lib/lutaml/hal/cache/cache_manager.rb +334 -0
- data/lib/lutaml/hal/cache/cache_metadata.rb +104 -0
- data/lib/lutaml/hal/cache/simple_cache_store.rb +68 -0
- data/lib/lutaml/hal/client.rb +36 -33
- data/lib/lutaml/hal/errors.rb +3 -0
- data/lib/lutaml/hal/global_register.rb +19 -0
- data/lib/lutaml/hal/link.rb +15 -10
- data/lib/lutaml/hal/link_set.rb +1 -1
- data/lib/lutaml/hal/model_register.rb +117 -30
- data/lib/lutaml/hal/page.rb +1 -1
- data/lib/lutaml/hal/rate_limiter.rb +105 -0
- data/lib/lutaml/hal/resource.rb +5 -6
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +2 -2
- metadata +22 -2
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
|
data/README.adoc
CHANGED
|
@@ -31,6 +31,7 @@ APIs.
|
|
|
31
31
|
* Integration with the `lutaml-model` serialization framework
|
|
32
32
|
* Error handling and response validation for API interactions
|
|
33
33
|
* Comprehensive embed support for reducing HTTP requests and improving performance
|
|
34
|
+
* Built-in rate limiting with exponential backoff and retry logic
|
|
34
35
|
|
|
35
36
|
== Installation
|
|
36
37
|
|
|
@@ -103,30 +104,36 @@ puts category.name
|
|
|
103
104
|
|
|
104
105
|
== Documentation
|
|
105
106
|
|
|
106
|
-
===
|
|
107
|
+
=== Detailed topics
|
|
107
108
|
|
|
108
|
-
For detailed documentation, see these
|
|
109
|
+
For detailed documentation, see these topics:
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
link:docs/getting-started.adoc[Getting started]::
|
|
112
|
+
Get started with your HAL API client (15 minutes)
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
link:docs/data-definition.adoc[Data definition]::
|
|
115
|
+
Full reference to define HAL resources, model registers, and API endpoints
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
link:docs/runtime-usage.adoc[Runtime usage]::
|
|
118
|
+
Patterns to fetch resources, navigate links, and handle pagination
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
link:docs/hal-links-reference.adoc[HAL links reference]::
|
|
121
|
+
Advanced configuration of HAL links
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
link:docs/pagination.adoc[Pagination]::
|
|
124
|
+
Working with paginated APIs and large datasets
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
link:docs/complex-path-patterns.adoc[Complex path patterns]::
|
|
127
|
+
Advanced URL patterns and path matching examples
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
using embedded resources in HAL APIs with automatic link realization
|
|
129
|
+
link:docs/embedded-resources.adoc[Embedded resources]::
|
|
130
|
+
Implementing and using embedded resources in HAL APIs with automatic link realization
|
|
131
|
+
|
|
132
|
+
link:docs/rate-limiting.adoc[Rate limiting]::
|
|
133
|
+
Configuring and using built-in rate limiting functionality
|
|
134
|
+
|
|
135
|
+
link:docs/error-handling.adoc[Error handling]::
|
|
136
|
+
Understanding and handling different types of errors when working with HAL APIs
|
|
130
137
|
|
|
131
138
|
|
|
132
139
|
=== Architecture overview
|
|
@@ -213,20 +220,6 @@ register.add_endpoint(
|
|
|
213
220
|
For complex path pattern examples, see
|
|
214
221
|
link:docs/complex-path-patterns.adoc[Complex Path Patterns].
|
|
215
222
|
|
|
216
|
-
== Error handling
|
|
217
|
-
|
|
218
|
-
The library provides structured error handling:
|
|
219
|
-
|
|
220
|
-
[source,ruby]
|
|
221
|
-
----
|
|
222
|
-
begin
|
|
223
|
-
product = register.fetch(:product_resource, id: '123')
|
|
224
|
-
rescue Lutaml::Hal::Errors::NotFoundError => e
|
|
225
|
-
puts "Product not found: #{e.message}"
|
|
226
|
-
rescue Lutaml::Hal::Errors::ApiError => e
|
|
227
|
-
puts "API Error: #{e.message}"
|
|
228
|
-
end
|
|
229
|
-
----
|
|
230
223
|
|
|
231
224
|
== Contributing
|
|
232
225
|
|
|
@@ -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
|