lutaml-hal 0.2.1 → 0.2.2

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: 5860a26a1d8b235f8138e37fe835c3f0de0d4cc065f99b33da9ed17c0cb0a8cf
4
- data.tar.gz: e558941f569c2d8d55ac62c66924a06a7445438281c261e060471bdf2d8c9f5f
3
+ metadata.gz: 443ab6a0ccf5927a142a841eb7e7178bb21aaaea9d64e67d84e325250cf23093
4
+ data.tar.gz: 9190a8b564e5e375d6605bea71e0ba9d9489536b862aca74272d28d49961c606
5
5
  SHA512:
6
- metadata.gz: a3cda001e3ebb066cf62e8aa54fbec2a41ae93a173a056919da2151cf8f28fcba143352ad2dc287d478c7365fa3e087abc1757770264acc05a4be925378072d7
7
- data.tar.gz: d34fd6af3af5e78d1b223f04fa9f1e9e71ff524b2ac9425935d6bd5e07e4d8fa10a2c255c063c70026388d5f2406a05a41cde1492482cd66a911fb1fe80fb623
6
+ metadata.gz: e0baa1f526af80dd8fdb4366f637ad059d0e7f58f4836b6a1377adca4834f846c29889c3bd6782109dc3da65f93e4387d675477794fbc0b08573b8a03684eeae
7
+ data.tar.gz: aabbfd4f298149c3f71b8145e79d17bbff4293eaf9b8db99fbcf1aadb114efeb82b9d2db7ec571ce2e4651aeac0863be424a3a0335595096e104728ffcd65127
@@ -4,6 +4,7 @@ require 'cgi'
4
4
  require_relative 'errors'
5
5
  require_relative 'endpoint_configuration'
6
6
  require_relative 'cache/cache_manager'
7
+ require_relative 'single_flight'
7
8
 
8
9
  module Lutaml
9
10
  module Hal
@@ -17,6 +18,7 @@ module Lutaml
17
18
  @client = client
18
19
  @models = {}
19
20
  @cache_manager = Cache::CacheManager.new(cache, client: @client) if cache
21
+ @single_flight = SingleFlight.new
20
22
  end
21
23
 
22
24
  # Register a model with its URL pattern and parameters
@@ -71,115 +73,32 @@ module Lutaml
71
73
  # Process parameters through EndpointParameter objects
72
74
  processed_params = process_parameters(endpoint[:parameters], params)
73
75
 
74
- # Build URL with path parameters
76
+ # Build URL with path and query parameters
75
77
  url = build_url_with_path_params(endpoint[:url], processed_params[:path])
76
-
77
- # Add query parameters
78
78
  final_url = build_url_with_query_params(url, processed_params[:query])
79
79
 
80
- realized_model = nil
80
+ cached = cached_endpoint_model(final_url)
81
+ return cached if cached
81
82
 
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
83
+ # Coalesce concurrent fetches of the same URL into a single request.
84
+ coalesce(final_url) do
85
+ cached_endpoint_model(final_url) ||
86
+ fetch_uncached(endpoint, final_url, processed_params[:headers])
100
87
  end
101
-
102
- # Make request with headers
103
- response = if request_headers.any?
104
- client.get_with_headers(final_url, request_headers)
105
- else
106
- client.get(final_url)
107
- end
108
-
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
121
-
122
- # Store embedded data for later resolution
123
- realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
124
-
125
- mark_model_links_with_register(realized_model)
126
-
127
- realized_model
128
88
  end
129
89
 
130
90
  def resolve_and_cast(link, href)
131
91
  raise 'Client not configured' unless client
132
92
 
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
93
+ cached = cached_resolved_model(href)
94
+ return cached if cached
145
95
 
146
96
  debug_log("resolve_and_cast: link #{link}, href #{href}")
147
97
 
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
98
+ # Coalesce concurrent resolutions of the same href into one request.
99
+ coalesce(href) do
100
+ cached_resolved_model(href) || resolve_and_cast_uncached(href)
162
101
  end
163
-
164
- # TODO: Merge full Link content into the resource?
165
- response_with_link_details = response.to_h.merge({ 'href' => href })
166
-
167
- href_path = href.sub(client.api_url, '')
168
-
169
- model_class = find_matching_model_class(href_path)
170
- raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
171
-
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}")
175
-
176
- model = model_class.from_json(response_with_link_details.to_json)
177
- mark_model_links_with_register(model)
178
-
179
- # Cache the realized model with metadata
180
- @cache_manager&.set(href, response, model)
181
-
182
- model
183
102
  end
184
103
 
185
104
  # Recursively mark all models in the link with the register name
@@ -248,6 +167,104 @@ module Lutaml
248
167
  puts "[Lutaml::Hal] DEBUG: #{message}" if ENV['DEBUG_API']
249
168
  end
250
169
 
170
+ # Run the block, coalescing concurrent calls that share `key` into one
171
+ # execution (single-flight) so duplicate in-flight requests for the same
172
+ # URL collapse into a single fetch. Only active when caching is enabled —
173
+ # the cache is what lets the shared result be reused. Different keys run
174
+ # in parallel.
175
+ def coalesce(key, &block)
176
+ return block.call unless @cache_manager&.available?
177
+
178
+ @single_flight.run(key, &block)
179
+ end
180
+
181
+ # Cached, register-marked model for a fully-built endpoint URL, or nil.
182
+ def cached_endpoint_model(url)
183
+ return nil unless @cache_manager&.available?
184
+
185
+ entry = @cache_manager.get(url)
186
+ return nil unless entry
187
+
188
+ debug_log("Cache hit for fetch: #{url}")
189
+ model = entry.hal_resource
190
+ mark_model_links_with_register(model)
191
+ model
192
+ end
193
+
194
+ # The fetch miss path: HTTP request, 304 handling, model build and cache.
195
+ def fetch_uncached(endpoint, final_url, headers)
196
+ request_headers = headers.dup
197
+ if @cache_manager&.available?
198
+ conditional_headers = @cache_manager.conditional_request_headers(final_url)
199
+ request_headers.merge!(conditional_headers) if conditional_headers
200
+ end
201
+
202
+ response = if request_headers.any?
203
+ client.get_with_headers(final_url, request_headers)
204
+ else
205
+ client.get(final_url)
206
+ end
207
+
208
+ if response.respond_to?(:status) && response.status == 304
209
+ @cache_manager&.refresh_entry(final_url, response)
210
+ cached_entry = @cache_manager.get(final_url)
211
+ realized_model = cached_entry.hal_resource if cached_entry
212
+ else
213
+ realized_model = endpoint[:model].from_json(response.to_json)
214
+ @cache_manager&.set(final_url, response, realized_model)
215
+ end
216
+
217
+ realized_model.embedded_data = response['_embedded'] if realized_model && response && response['_embedded']
218
+ mark_model_links_with_register(realized_model)
219
+ realized_model
220
+ end
221
+
222
+ # Cached, register-marked model for a link href, or nil.
223
+ def cached_resolved_model(href)
224
+ return nil unless @cache_manager&.available?
225
+
226
+ entry = @cache_manager.get(href)
227
+ return nil unless entry
228
+
229
+ debug_log("Cache hit for: #{href}")
230
+ model = entry.hal_resource
231
+ # A model rebuilt from a persistent cache needs to be (re)marked so its
232
+ # links can be realized against this register.
233
+ mark_model_links_with_register(model)
234
+ model
235
+ end
236
+
237
+ # The resolve_and_cast miss path: HTTP request, 304 handling, model build
238
+ # and cache.
239
+ def resolve_and_cast_uncached(href)
240
+ conditional_headers = @cache_manager&.conditional_request_headers(href) || {}
241
+
242
+ response = if conditional_headers.any?
243
+ client.get_by_url_with_headers(href, conditional_headers)
244
+ else
245
+ client.get_by_url(href)
246
+ end
247
+
248
+ if response.respond_to?(:status) && response.status == 304
249
+ @cache_manager&.refresh_entry(href, response)
250
+ cached_entry = @cache_manager.get(href)
251
+ return cached_entry.hal_resource if cached_entry
252
+ end
253
+
254
+ # TODO: Merge full Link content into the resource?
255
+ response_with_link_details = response.to_h.merge({ 'href' => href })
256
+ href_path = href.sub(client.api_url, '')
257
+
258
+ model_class = find_matching_model_class(href_path)
259
+ raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
260
+
261
+ debug_log("resolve_and_cast: resolved to model_class #{model_class}")
262
+ model = model_class.from_json(response_with_link_details.to_json)
263
+ mark_model_links_with_register(model)
264
+ @cache_manager&.set(href, response, model)
265
+ model
266
+ end
267
+
251
268
  def process_parameters(parameter_definitions, provided_params)
252
269
  result = { path: {}, query: {}, headers: {}, cookies: {} }
253
270
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Hal
5
+ # Coalesces concurrent calls that share a key so the work runs once and the
6
+ # result (or error) is shared by all callers. Calls for *different* keys run
7
+ # in parallel — only same-key callers wait on the in-flight leader.
8
+ #
9
+ # Pure stdlib (Mutex + ConditionVariable); no external dependency. In-flight
10
+ # entries are removed once resolved, so memory is bounded by concurrency,
11
+ # not by the number of distinct keys ever seen.
12
+ class SingleFlight
13
+ Call = Struct.new(:mutex, :cond, :done, :value, :error)
14
+
15
+ def initialize
16
+ @registry_mutex = Mutex.new
17
+ @calls = {}
18
+ end
19
+
20
+ # Run the block at most once per key under concurrency, returning its
21
+ # result. The first caller for a key (the leader) runs the block; others
22
+ # wait and receive the same value (or re-raise the same error).
23
+ def run(key)
24
+ leader = false
25
+ call = @registry_mutex.synchronize do
26
+ @calls[key] ||= begin
27
+ leader = true
28
+ Call.new(Mutex.new, ConditionVariable.new, false)
29
+ end
30
+ end
31
+
32
+ return await(call) unless leader
33
+
34
+ begin
35
+ call.value = yield
36
+ rescue StandardError => e
37
+ call.error = e
38
+ ensure
39
+ @registry_mutex.synchronize { @calls.delete(key) }
40
+ call.mutex.synchronize do
41
+ call.done = true
42
+ call.cond.broadcast
43
+ end
44
+ end
45
+
46
+ raise call.error if call.error
47
+
48
+ call.value
49
+ end
50
+
51
+ private
52
+
53
+ def await(call)
54
+ call.mutex.synchronize do
55
+ call.cond.wait(call.mutex) until call.done
56
+ end
57
+ raise call.error if call.error
58
+
59
+ call.value
60
+ end
61
+ end
62
+ end
63
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- VERSION = '0.2.1'
5
+ VERSION = '0.2.2'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-hal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -110,6 +110,7 @@ files:
110
110
  - lib/lutaml/hal/page.rb
111
111
  - lib/lutaml/hal/rate_limiter.rb
112
112
  - lib/lutaml/hal/resource.rb
113
+ - lib/lutaml/hal/single_flight.rb
113
114
  - lib/lutaml/hal/type_resolver.rb
114
115
  - lib/lutaml/hal/version.rb
115
116
  homepage: https://github.com/lutaml/lutaml-hal