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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3193d7b68f48f5930b5247d0b962ed31fc39f5280b171084bd1ef36ffd2ff6b5
4
- data.tar.gz: 8cc11026dfbe93ab84546cf2e358d8a14fd9e71acef677675201bf1e288bdcb2
3
+ metadata.gz: a0879e2f05a3b6cfe0046dc4924ae0ba2bf5ef1925445e4469585c8ed77dd691
4
+ data.tar.gz: bc6e2d28adb425d5a29eab74c655bf4d721e81693a2109d4f5a61bb942d9cea4
5
5
  SHA512:
6
- metadata.gz: 87f583829974459e7edbeadfa3a4bca22b616a7e2b02afba0171159728c612265a1147dc7a32ac4677e798eec1129377b3bcba74ce8f24262590c5ac060453e7
7
- data.tar.gz: 46e96d2f8d1ca38a7c71d48280046d2765749acc19877b2628305d7de4c6949dbcb0512933343ea68453d44e052ac6553d18e051839d23cb50d17fbb6075ece3
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
- === Comprehensive guides
107
+ === Detailed topics
107
108
 
108
- For detailed documentation, see these comprehensive guides:
109
+ For detailed documentation, see these topics:
109
110
 
110
- * **link:docs/getting-started-guide.adoc[Getting started guide]** - Step-by-step
111
- tutorial for your first HAL API client (15 minutes)
111
+ link:docs/getting-started.adoc[Getting started]::
112
+ Get started with your HAL API client (15 minutes)
112
113
 
113
- * **link:docs/data-definition-guide.adoc[Data definition guide]** - Complete
114
- reference for defining HAL resources, model registers, and API endpoints
114
+ link:docs/data-definition.adoc[Data definition]::
115
+ Full reference to define HAL resources, model registers, and API endpoints
115
116
 
116
- * **link:docs/runtime-usage-guide.adoc[Runtime usage guide]** - Patterns for
117
- fetching resources, navigating links, and handling pagination
117
+ link:docs/runtime-usage.adoc[Runtime usage]::
118
+ Patterns to fetch resources, navigate links, and handle pagination
118
119
 
119
- * **link:docs/hal-links-reference.adoc[HAL links reference]** - Advanced
120
- configuration and customization of HAL links
120
+ link:docs/hal-links-reference.adoc[HAL links reference]::
121
+ Advanced configuration of HAL links
121
122
 
122
- * **link:docs/pagination-guide.adoc[Pagination guide]** - Working with
123
- paginated APIs and large datasets
123
+ link:docs/pagination.adoc[Pagination]::
124
+ Working with paginated APIs and large datasets
124
125
 
125
- * **link:docs/complex-path-patterns.adoc[Complex path patterns]** - Advanced
126
- URL patterns and path matching examples
126
+ link:docs/complex-path-patterns.adoc[Complex path patterns]::
127
+ Advanced URL patterns and path matching examples
127
128
 
128
- * **link:docs/embedded-resources.adoc[Embedded resources]** - Implementing and
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