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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f3ce9deb2f8607008c4b5d6950dd281a79bcba5856b03769c0a764dd0c95228
4
- data.tar.gz: 0d8bd9e4a6f78dd18ce1c58288276d8e0334a48e4858fcbbfb694fd68dc5deb1
3
+ metadata.gz: 5860a26a1d8b235f8138e37fe835c3f0de0d4cc065f99b33da9ed17c0cb0a8cf
4
+ data.tar.gz: e558941f569c2d8d55ac62c66924a06a7445438281c261e060471bdf2d8c9f5f
5
5
  SHA512:
6
- metadata.gz: d29f19722b4aa33f3404cd084394573cfa7dc5af3811fb0d42a5cd78319de4e1ff8e8e71b6de59eb23441b39ff0588a36c02ae35156eee99aa845e90929691b6
7
- data.tar.gz: 4d00fc04f030bda2eec1558ea616f1c3533b8e7109c3224678a96de41b374f4ddadae7a9cf2fd6cbe807633e77a185e8ab893e944b98dc4433898b1cbb1571cc
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