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 +4 -4
- data/lib/lutaml/hal/model_register.rb +112 -95
- data/lib/lutaml/hal/single_flight.rb +63 -0
- data/lib/lutaml/hal/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 443ab6a0ccf5927a142a841eb7e7178bb21aaaea9d64e67d84e325250cf23093
|
|
4
|
+
data.tar.gz: 9190a8b564e5e375d6605bea71e0ba9d9489536b862aca74272d28d49961c606
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
80
|
+
cached = cached_endpoint_model(final_url)
|
|
81
|
+
return cached if cached
|
|
81
82
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
134
|
-
if
|
|
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
|
-
#
|
|
149
|
-
|
|
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
|
data/lib/lutaml/hal/version.rb
CHANGED
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.
|
|
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
|