lutaml-store 0.1.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 +7 -0
- data/.github/workflows/main.yml +27 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +10 -0
- data/.rubocop_todo.yml +450 -0
- data/CLAUDE.md +57 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
- data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +220 -0
- data/README.adoc +1430 -0
- data/Rakefile +12 -0
- data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
- data/TODO.impl/1-lutaml-hal-migration.md +60 -0
- data/TODO.impl/2-glossarist-migration.md +359 -0
- data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/demo/Gemfile +15 -0
- data/demo/Gemfile.lock +61 -0
- data/demo/README.adoc +301 -0
- data/demo/data/vcards/co/contact_10_thompson.data +1 -0
- data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
- data/demo/data/vcards/co/contact_1_doe.data +1 -0
- data/demo/data/vcards/co/contact_1_doe.meta +1 -0
- data/demo/data/vcards/co/contact_2_smith.data +1 -0
- data/demo/data/vcards/co/contact_2_smith.meta +1 -0
- data/demo/data/vcards/co/contact_3_johnson.data +1 -0
- data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
- data/demo/data/vcards/co/contact_4_garcia.data +1 -0
- data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
- data/demo/data/vcards/co/contact_5_wilson.data +1 -0
- data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
- data/demo/data/vcards/co/contact_6_brown.data +1 -0
- data/demo/data/vcards/co/contact_6_brown.meta +1 -0
- data/demo/data/vcards/co/contact_7_davis.data +1 -0
- data/demo/data/vcards/co/contact_7_davis.meta +1 -0
- data/demo/data/vcards/co/contact_8_anderson.data +1 -0
- data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
- data/demo/data/vcards/co/contact_9_taylor.data +1 -0
- data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
- data/demo/data/vcards.db +0 -0
- data/demo/pottery_class_demo.rb +164 -0
- data/demo/vcard_models.rb +140 -0
- data/demo/vcard_store_demo.rb +526 -0
- data/lib/lutaml/store/adapter/base.rb +65 -0
- data/lib/lutaml/store/adapter/filesystem.rb +288 -0
- data/lib/lutaml/store/adapter/memory.rb +225 -0
- data/lib/lutaml/store/adapter/sqlite.rb +193 -0
- data/lib/lutaml/store/adapter.rb +12 -0
- data/lib/lutaml/store/attribute_updater.rb +198 -0
- data/lib/lutaml/store/basic_store.rb +190 -0
- data/lib/lutaml/store/cache.rb +108 -0
- data/lib/lutaml/store/cache_store.rb +282 -0
- data/lib/lutaml/store/composite_model_handler.rb +169 -0
- data/lib/lutaml/store/compression.rb +137 -0
- data/lib/lutaml/store/config.rb +178 -0
- data/lib/lutaml/store/database_store.rb +425 -0
- data/lib/lutaml/store/events.rb +92 -0
- data/lib/lutaml/store/format/base.rb +33 -0
- data/lib/lutaml/store/format/json.rb +25 -0
- data/lib/lutaml/store/format/jsonl.rb +37 -0
- data/lib/lutaml/store/format/marshal_format.rb +37 -0
- data/lib/lutaml/store/format/yaml.rb +29 -0
- data/lib/lutaml/store/format/yamls.rb +35 -0
- data/lib/lutaml/store/format.rb +33 -0
- data/lib/lutaml/store/http_cache.rb +279 -0
- data/lib/lutaml/store/http_cache_config.rb +53 -0
- data/lib/lutaml/store/http_cache_entry.rb +69 -0
- data/lib/lutaml/store/http_header_processor.rb +175 -0
- data/lib/lutaml/store/integrity.rb +102 -0
- data/lib/lutaml/store/model_registration.rb +75 -0
- data/lib/lutaml/store/model_registry.rb +123 -0
- data/lib/lutaml/store/model_serializer.rb +69 -0
- data/lib/lutaml/store/monitor.rb +192 -0
- data/lib/lutaml/store/storage_key.rb +40 -0
- data/lib/lutaml/store/version.rb +7 -0
- data/lib/lutaml/store.rb +41 -0
- data/lutaml-store.gemspec +35 -0
- data/plan.adoc +606 -0
- data/sig/lutaml/store.rbs +6 -0
- data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
- data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
- data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
- data/spec/lutaml/store/autoload_spec.rb +34 -0
- data/spec/lutaml/store/cache_store_spec.rb +271 -0
- data/spec/lutaml/store/compression_spec.rb +78 -0
- data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
- data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
- data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
- data/spec/lutaml/store/database_store_spec.rb +279 -0
- data/spec/lutaml/store/file_io_spec.rb +219 -0
- data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
- data/spec/lutaml/store/format_spec.rb +70 -0
- data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
- data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
- data/spec/lutaml/store/http_cache_spec.rb +422 -0
- data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
- data/spec/lutaml/store/import_spec.rb +90 -0
- data/spec/lutaml/store/integrity_spec.rb +157 -0
- data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
- data/spec/lutaml/store/load_save_spec.rb +107 -0
- data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
- data/spec/lutaml/store/model_serializer_spec.rb +140 -0
- data/spec/lutaml/store/store_spec.rb +182 -0
- data/spec/lutaml/store_spec.rb +21 -0
- data/spec/spec_helper.rb +16 -0
- metadata +166 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/store/http_cache"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Store::HttpCache do
|
|
7
|
+
let(:cache_config) do
|
|
8
|
+
{
|
|
9
|
+
adapter_type: "memory",
|
|
10
|
+
default_ttl: 3600,
|
|
11
|
+
respect_http_headers: true,
|
|
12
|
+
enable_conditional_requests: true
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:http_cache) { described_class.new(cache_config) }
|
|
17
|
+
|
|
18
|
+
describe "#initialize" do
|
|
19
|
+
it "accepts hash configuration" do
|
|
20
|
+
cache = described_class.new(cache_config)
|
|
21
|
+
expect(cache).to be_a(described_class)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "accepts HttpCacheConfig object" do
|
|
25
|
+
config = Lutaml::Store::HttpCacheConfig.new(cache_config)
|
|
26
|
+
cache = described_class.new(config)
|
|
27
|
+
expect(cache).to be_a(described_class)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "validates configuration" do
|
|
31
|
+
invalid_config = { adapter_type: "memory", default_ttl: -1 }
|
|
32
|
+
expect { described_class.new(invalid_config) }.to raise_error(ArgumentError)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "#fetch" do
|
|
37
|
+
let(:url) { "http://example.com/api" }
|
|
38
|
+
let(:method) { "GET" }
|
|
39
|
+
let(:headers) { { "accept" => "application/json" } }
|
|
40
|
+
let(:response_data) do
|
|
41
|
+
{
|
|
42
|
+
status_code: 200,
|
|
43
|
+
headers: { "content-type" => "application/json", "cache-control" => "max-age=3600" },
|
|
44
|
+
body: '{"test": "data"}'
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "requires a block" do
|
|
49
|
+
expect { http_cache.fetch(method, url, headers) }.to raise_error(ArgumentError)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
context "cache miss" do
|
|
53
|
+
it "calls block and caches response" do
|
|
54
|
+
block_called = false
|
|
55
|
+
result = http_cache.fetch(method, url, headers) do |request_headers|
|
|
56
|
+
block_called = true
|
|
57
|
+
expect(request_headers).to eq(headers)
|
|
58
|
+
response_data
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
expect(block_called).to be true
|
|
62
|
+
expect(result).to eq(response_data)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "stores cacheable responses" do
|
|
66
|
+
http_cache.fetch(method, url, headers) { response_data }
|
|
67
|
+
|
|
68
|
+
# Second request should use cache
|
|
69
|
+
block_called = false
|
|
70
|
+
result = http_cache.fetch(method, url, headers) do
|
|
71
|
+
block_called = true
|
|
72
|
+
raise "Should not be called"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
expect(block_called).to be false
|
|
76
|
+
expect(result).to eq(response_data)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
context "cache hit" do
|
|
81
|
+
before do
|
|
82
|
+
http_cache.fetch(method, url, headers) { response_data }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "returns cached response without calling block" do
|
|
86
|
+
block_called = false
|
|
87
|
+
result = http_cache.fetch(method, url, headers) do
|
|
88
|
+
block_called = true
|
|
89
|
+
raise "Should not be called"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
expect(block_called).to be false
|
|
93
|
+
expect(result).to eq(response_data)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
context "conditional requests" do
|
|
98
|
+
let(:etag_response) do
|
|
99
|
+
{
|
|
100
|
+
status_code: 200,
|
|
101
|
+
headers: { "etag" => '"abc123"', "cache-control" => "max-age=1" },
|
|
102
|
+
body: "original data"
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "makes conditional request for stale entries" do
|
|
107
|
+
# Cache initial response
|
|
108
|
+
http_cache.fetch(method, url, headers) { etag_response }
|
|
109
|
+
|
|
110
|
+
# Wait for entry to become stale
|
|
111
|
+
sleep(1.1)
|
|
112
|
+
|
|
113
|
+
# Should make conditional request
|
|
114
|
+
conditional_request_made = false
|
|
115
|
+
result = http_cache.fetch(method, url, headers) do |request_headers|
|
|
116
|
+
conditional_request_made = true
|
|
117
|
+
expect(request_headers["If-None-Match"]).to eq('"abc123"')
|
|
118
|
+
{ status_code: 304, headers: {}, body: "" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
expect(conditional_request_made).to be true
|
|
122
|
+
expect(result[:body]).to eq("original data") # Returns cached body
|
|
123
|
+
expect(result[:status_code]).to eq(200) # Returns cached status
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "caches new response when content is modified" do
|
|
127
|
+
# Cache initial response
|
|
128
|
+
http_cache.fetch(method, url, headers) { etag_response }
|
|
129
|
+
|
|
130
|
+
# Wait for entry to become stale
|
|
131
|
+
sleep(1.1)
|
|
132
|
+
|
|
133
|
+
new_response = {
|
|
134
|
+
status_code: 200,
|
|
135
|
+
headers: { "etag" => '"def456"' },
|
|
136
|
+
body: "new data"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result = http_cache.fetch(method, url, headers) do |request_headers|
|
|
140
|
+
expect(request_headers["If-None-Match"]).to eq('"abc123"')
|
|
141
|
+
new_response
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
expect(result).to eq(new_response)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
context "uncacheable responses" do
|
|
149
|
+
let(:error_response) do
|
|
150
|
+
{
|
|
151
|
+
status_code: 404,
|
|
152
|
+
headers: {},
|
|
153
|
+
body: "Not Found"
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "does not cache error responses" do
|
|
158
|
+
result1 = http_cache.fetch(method, url, headers) { error_response }
|
|
159
|
+
expect(result1).to eq(error_response)
|
|
160
|
+
|
|
161
|
+
# Second request should call block again
|
|
162
|
+
block_called = false
|
|
163
|
+
result2 = http_cache.fetch(method, url, headers) do
|
|
164
|
+
block_called = true
|
|
165
|
+
error_response
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
expect(block_called).to be true
|
|
169
|
+
expect(result2).to eq(error_response)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "does not cache no-store responses" do
|
|
173
|
+
no_store_response = {
|
|
174
|
+
status_code: 200,
|
|
175
|
+
headers: { "cache-control" => "no-store" },
|
|
176
|
+
body: "sensitive data"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
result1 = http_cache.fetch(method, url, headers) { no_store_response }
|
|
180
|
+
expect(result1).to eq(no_store_response)
|
|
181
|
+
|
|
182
|
+
# Second request should call block again
|
|
183
|
+
block_called = false
|
|
184
|
+
http_cache.fetch(method, url, headers) do
|
|
185
|
+
block_called = true
|
|
186
|
+
no_store_response
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
expect(block_called).to be true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe "#get" do
|
|
195
|
+
let(:url) { "http://example.com/api" }
|
|
196
|
+
let(:method) { "GET" }
|
|
197
|
+
let(:headers) { { "accept" => "application/json" } }
|
|
198
|
+
let(:response_data) do
|
|
199
|
+
{
|
|
200
|
+
status_code: 200,
|
|
201
|
+
headers: { "content-type" => "application/json" },
|
|
202
|
+
body: '{"test": "data"}'
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "returns nil for cache miss" do
|
|
207
|
+
result = http_cache.get(method, url, headers)
|
|
208
|
+
expect(result).to be_nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "returns cached response for cache hit" do
|
|
212
|
+
# Cache a response first
|
|
213
|
+
http_cache.fetch(method, url, headers) { response_data }
|
|
214
|
+
|
|
215
|
+
# Get should return cached response
|
|
216
|
+
result = http_cache.get(method, url, headers)
|
|
217
|
+
expect(result).to eq(response_data)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "returns nil for stale entries" do
|
|
221
|
+
stale_response = {
|
|
222
|
+
status_code: 200,
|
|
223
|
+
headers: { "cache-control" => "max-age=1" },
|
|
224
|
+
body: "data"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Cache a response
|
|
228
|
+
http_cache.fetch(method, url, headers) { stale_response }
|
|
229
|
+
|
|
230
|
+
# Wait for it to become stale
|
|
231
|
+
sleep(1.1)
|
|
232
|
+
|
|
233
|
+
# Get should return nil for stale entry
|
|
234
|
+
result = http_cache.get(method, url, headers)
|
|
235
|
+
expect(result).to be_nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
describe "#set" do
|
|
240
|
+
let(:url) { "http://example.com/api" }
|
|
241
|
+
let(:method) { "GET" }
|
|
242
|
+
let(:headers) { { "accept" => "application/json" } }
|
|
243
|
+
let(:response_data) do
|
|
244
|
+
{
|
|
245
|
+
status_code: 200,
|
|
246
|
+
headers: { "content-type" => "application/json" },
|
|
247
|
+
body: '{"test": "data"}'
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "stores cacheable response" do
|
|
252
|
+
result = http_cache.set(method, url, headers, response_data)
|
|
253
|
+
expect(result).to eq(response_data)
|
|
254
|
+
|
|
255
|
+
# Verify it was cached
|
|
256
|
+
cached = http_cache.get(method, url, headers)
|
|
257
|
+
expect(cached).to eq(response_data)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it "does not store uncacheable response" do
|
|
261
|
+
error_response = {
|
|
262
|
+
status_code: 404,
|
|
263
|
+
headers: {},
|
|
264
|
+
body: "Not Found"
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
result = http_cache.set(method, url, headers, error_response)
|
|
268
|
+
expect(result).to eq(error_response)
|
|
269
|
+
|
|
270
|
+
# Verify it was not cached
|
|
271
|
+
cached = http_cache.get(method, url, headers)
|
|
272
|
+
expect(cached).to be_nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
describe "#delete" do
|
|
277
|
+
let(:url) { "http://example.com/api" }
|
|
278
|
+
let(:method) { "GET" }
|
|
279
|
+
let(:headers) { { "accept" => "application/json" } }
|
|
280
|
+
let(:response_data) do
|
|
281
|
+
{
|
|
282
|
+
status_code: 200,
|
|
283
|
+
headers: { "content-type" => "application/json" },
|
|
284
|
+
body: '{"test": "data"}'
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
it "removes cached entry" do
|
|
289
|
+
# Cache a response
|
|
290
|
+
http_cache.set(method, url, headers, response_data)
|
|
291
|
+
expect(http_cache.get(method, url, headers)).to eq(response_data)
|
|
292
|
+
|
|
293
|
+
# Delete it
|
|
294
|
+
http_cache.delete(method, url, headers)
|
|
295
|
+
|
|
296
|
+
# Verify it's gone
|
|
297
|
+
expect(http_cache.get(method, url, headers)).to be_nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "handles missing entries gracefully" do
|
|
301
|
+
expect { http_cache.delete(method, url, headers) }.not_to raise_error
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
describe "#clear" do
|
|
306
|
+
it "clears all cached entries" do
|
|
307
|
+
# Cache multiple responses
|
|
308
|
+
http_cache.set("GET", "http://example.com/api1", {}, {
|
|
309
|
+
status_code: 200, headers: {}, body: "data1"
|
|
310
|
+
})
|
|
311
|
+
http_cache.set("GET", "http://example.com/api2", {}, {
|
|
312
|
+
status_code: 200, headers: {}, body: "data2"
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
# Clear cache
|
|
316
|
+
http_cache.clear
|
|
317
|
+
|
|
318
|
+
# Verify entries are gone
|
|
319
|
+
expect(http_cache.get("GET", "http://example.com/api1", {})).to be_nil
|
|
320
|
+
expect(http_cache.get("GET", "http://example.com/api2", {})).to be_nil
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
describe "#stats" do
|
|
325
|
+
it "returns cache statistics" do
|
|
326
|
+
stats = http_cache.stats
|
|
327
|
+
|
|
328
|
+
expect(stats).to include(
|
|
329
|
+
adapter_type: "memory",
|
|
330
|
+
config: hash_including(
|
|
331
|
+
default_ttl: 3600,
|
|
332
|
+
max_entries: 10_000,
|
|
333
|
+
respect_http_headers: true,
|
|
334
|
+
enable_conditional_requests: true
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
describe "vary header support" do
|
|
341
|
+
let(:url) { "http://example.com/api" }
|
|
342
|
+
let(:method) { "GET" }
|
|
343
|
+
|
|
344
|
+
it "caches different responses for different vary headers" do
|
|
345
|
+
pending "WIP: vary header caching not yet implemented"
|
|
346
|
+
vary_response = {
|
|
347
|
+
status_code: 200,
|
|
348
|
+
headers: { "vary" => "Accept-Encoding", "content-type" => "application/json" },
|
|
349
|
+
body: '{"compressed": false}'
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
gzip_response = {
|
|
353
|
+
status_code: 200,
|
|
354
|
+
headers: { "vary" => "Accept-Encoding", "content-type" => "application/json" },
|
|
355
|
+
body: '{"compressed": true}'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Cache response for no encoding
|
|
359
|
+
result1 = http_cache.fetch(method, url, {}) { vary_response }
|
|
360
|
+
expect(result1).to eq(vary_response)
|
|
361
|
+
|
|
362
|
+
# Cache response for gzip encoding
|
|
363
|
+
result2 = http_cache.fetch(method, url, { "accept-encoding" => "gzip" }) { gzip_response }
|
|
364
|
+
expect(result2).to eq(gzip_response)
|
|
365
|
+
|
|
366
|
+
# Verify both are cached separately
|
|
367
|
+
cached1 = http_cache.get(method, url, {})
|
|
368
|
+
cached2 = http_cache.get(method, url, { "accept-encoding" => "gzip" })
|
|
369
|
+
|
|
370
|
+
expect(cached1[:body]).to eq('{"compressed": false}')
|
|
371
|
+
expect(cached2[:body]).to eq('{"compressed": true}')
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
describe "query parameter handling" do
|
|
376
|
+
let(:method) { "GET" }
|
|
377
|
+
let(:headers) { {} }
|
|
378
|
+
let(:response_data) do
|
|
379
|
+
{
|
|
380
|
+
status_code: 200,
|
|
381
|
+
headers: {},
|
|
382
|
+
body: "data"
|
|
383
|
+
}
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it "normalizes query parameter order" do
|
|
387
|
+
url1 = "http://example.com/api?b=2&a=1"
|
|
388
|
+
url2 = "http://example.com/api?a=1&b=2"
|
|
389
|
+
|
|
390
|
+
# Cache response for first URL
|
|
391
|
+
http_cache.set(method, url1, headers, response_data)
|
|
392
|
+
|
|
393
|
+
# Second URL should hit cache (same normalized key)
|
|
394
|
+
cached = http_cache.get(method, url2, headers)
|
|
395
|
+
expect(cached).to eq(response_data)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
context "with ignored parameters" do
|
|
399
|
+
let(:cache_config_with_ignored_params) do
|
|
400
|
+
{
|
|
401
|
+
adapter_type: "memory",
|
|
402
|
+
default_ttl: 3600,
|
|
403
|
+
ignore_query_params: %w[timestamp nonce]
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
let(:http_cache) { described_class.new(cache_config_with_ignored_params) }
|
|
408
|
+
|
|
409
|
+
it "ignores specified query parameters" do
|
|
410
|
+
url1 = "http://example.com/api?data=value×tamp=123"
|
|
411
|
+
url2 = "http://example.com/api?data=value×tamp=456"
|
|
412
|
+
|
|
413
|
+
# Cache response for first URL
|
|
414
|
+
http_cache.set(method, url1, headers, response_data)
|
|
415
|
+
|
|
416
|
+
# Second URL should hit cache (timestamp ignored)
|
|
417
|
+
cached = http_cache.get(method, url2, headers)
|
|
418
|
+
expect(cached).to eq(response_data)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/store/http_header_processor"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Store::HttpHeaderProcessor do
|
|
7
|
+
describe ".parse_cache_control" do
|
|
8
|
+
it "parses simple directives" do
|
|
9
|
+
result = described_class.parse_cache_control("no-cache, no-store")
|
|
10
|
+
expect(result).to eq({
|
|
11
|
+
"no-cache" => true,
|
|
12
|
+
"no-store" => true
|
|
13
|
+
})
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "parses directives with values" do
|
|
17
|
+
result = described_class.parse_cache_control("max-age=3600, s-maxage=7200")
|
|
18
|
+
expect(result).to eq({
|
|
19
|
+
"max-age" => 3600,
|
|
20
|
+
"s-maxage" => 7200
|
|
21
|
+
})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "parses quoted values" do
|
|
25
|
+
result = described_class.parse_cache_control('max-age="3600", custom="value"')
|
|
26
|
+
expect(result).to eq({
|
|
27
|
+
"max-age" => 3600,
|
|
28
|
+
"custom" => "value"
|
|
29
|
+
})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "handles nil input" do
|
|
33
|
+
result = described_class.parse_cache_control(nil)
|
|
34
|
+
expect(result).to eq({})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "handles empty string" do
|
|
38
|
+
result = described_class.parse_cache_control("")
|
|
39
|
+
expect(result).to eq({})
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe ".calculate_expiry" do
|
|
44
|
+
let(:cached_at) { Time.now }
|
|
45
|
+
let(:default_ttl) { 3600 }
|
|
46
|
+
|
|
47
|
+
context "with Expires header" do
|
|
48
|
+
it "uses Expires header when present" do
|
|
49
|
+
expires_time = Time.now + 7200
|
|
50
|
+
headers = { "expires" => expires_time.httpdate }
|
|
51
|
+
|
|
52
|
+
result = described_class.calculate_expiry(headers, cached_at, default_ttl)
|
|
53
|
+
expect(result).to be_within(1).of(expires_time)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "handles invalid Expires header gracefully" do
|
|
57
|
+
headers = { "expires" => "invalid-date" }
|
|
58
|
+
|
|
59
|
+
result = described_class.calculate_expiry(headers, cached_at, default_ttl)
|
|
60
|
+
expect(result).to be_within(1).of(cached_at + default_ttl)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context "with Cache-Control max-age" do
|
|
65
|
+
it "uses max-age when present" do
|
|
66
|
+
headers = { "cache-control" => "max-age=7200" }
|
|
67
|
+
|
|
68
|
+
result = described_class.calculate_expiry(headers, cached_at, default_ttl)
|
|
69
|
+
expect(result).to be_within(1).of(cached_at + 7200)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
context "with no caching headers" do
|
|
74
|
+
it "falls back to default TTL" do
|
|
75
|
+
headers = {}
|
|
76
|
+
|
|
77
|
+
result = described_class.calculate_expiry(headers, cached_at, default_ttl)
|
|
78
|
+
expect(result).to be_within(1).of(cached_at + default_ttl)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe ".should_cache_response?" do
|
|
84
|
+
it "caches successful responses" do
|
|
85
|
+
result = described_class.should_cache_response?(200, {})
|
|
86
|
+
expect(result).to be true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "does not cache error responses" do
|
|
90
|
+
result = described_class.should_cache_response?(404, {})
|
|
91
|
+
expect(result).to be false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "does not cache responses with no-store" do
|
|
95
|
+
headers = { "cache-control" => "no-store" }
|
|
96
|
+
result = described_class.should_cache_response?(200, headers)
|
|
97
|
+
expect(result).to be false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "does not cache private responses" do
|
|
101
|
+
headers = { "cache-control" => "private" }
|
|
102
|
+
result = described_class.should_cache_response?(200, headers)
|
|
103
|
+
expect(result).to be false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
describe ".generate_cache_key" do
|
|
108
|
+
it "generates basic cache key" do
|
|
109
|
+
result = described_class.generate_cache_key("GET", "http://example.com/api")
|
|
110
|
+
expect(result).to eq("GET:http://example.com/api")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "normalizes query parameters" do
|
|
114
|
+
result = described_class.generate_cache_key("GET", "http://example.com/api?b=2&a=1")
|
|
115
|
+
expect(result).to eq("GET:http://example.com/api?a=1&b=2")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "filters ignored parameters" do
|
|
119
|
+
result = described_class.generate_cache_key(
|
|
120
|
+
"GET",
|
|
121
|
+
"http://example.com/api?timestamp=123&data=value",
|
|
122
|
+
{},
|
|
123
|
+
["timestamp"]
|
|
124
|
+
)
|
|
125
|
+
expect(result).to eq("GET:http://example.com/api?data=value")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "includes vary headers in key" do
|
|
129
|
+
vary_headers = { "accept-encoding" => "gzip", "accept-language" => "en" }
|
|
130
|
+
result = described_class.generate_cache_key("GET", "http://example.com/api", vary_headers)
|
|
131
|
+
|
|
132
|
+
expect(result).to start_with("GET:http://example.com/api|")
|
|
133
|
+
expect(result).to include(Digest::SHA256.hexdigest("accept-encoding:gzip|accept-language:en")[0..8])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe ".parse_vary_header" do
|
|
138
|
+
it "parses single header" do
|
|
139
|
+
result = described_class.parse_vary_header("Accept-Encoding")
|
|
140
|
+
expect(result).to eq(["accept-encoding"])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "parses multiple headers" do
|
|
144
|
+
result = described_class.parse_vary_header("Accept-Encoding, Accept-Language, User-Agent")
|
|
145
|
+
expect(result).to eq(%w[accept-encoding accept-language user-agent])
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "handles nil input" do
|
|
149
|
+
result = described_class.parse_vary_header(nil)
|
|
150
|
+
expect(result).to eq([])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe ".extract_vary_headers" do
|
|
155
|
+
let(:request_headers) do
|
|
156
|
+
{
|
|
157
|
+
"Accept-Encoding" => "gzip, deflate",
|
|
158
|
+
"Accept-Language" => "en-US,en;q=0.9",
|
|
159
|
+
"User-Agent" => "Mozilla/5.0"
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "extracts specified headers" do
|
|
164
|
+
vary_names = %w[accept-encoding accept-language]
|
|
165
|
+
result = described_class.extract_vary_headers(request_headers, vary_names)
|
|
166
|
+
|
|
167
|
+
expect(result).to eq({
|
|
168
|
+
"accept-encoding" => "gzip, deflate",
|
|
169
|
+
"accept-language" => "en-US,en;q=0.9"
|
|
170
|
+
})
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "handles case-insensitive matching" do
|
|
174
|
+
vary_names = ["ACCEPT-ENCODING"]
|
|
175
|
+
result = described_class.extract_vary_headers(request_headers, vary_names)
|
|
176
|
+
|
|
177
|
+
expect(result).to eq({
|
|
178
|
+
"accept-encoding" => "gzip, deflate"
|
|
179
|
+
})
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "returns empty hash for empty vary names" do
|
|
183
|
+
result = described_class.extract_vary_headers(request_headers, [])
|
|
184
|
+
expect(result).to eq({})
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe ".fresh?" do
|
|
189
|
+
let(:cached_at) { Time.now - 1800 } # 30 minutes ago
|
|
190
|
+
|
|
191
|
+
it "returns true when not expired" do
|
|
192
|
+
expires_at = Time.now + 1800 # 30 minutes from now
|
|
193
|
+
result = described_class.fresh?(cached_at, 3600, expires_at)
|
|
194
|
+
expect(result).to be true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "returns false when expires_at is past" do
|
|
198
|
+
expires_at = Time.now - 100
|
|
199
|
+
result = described_class.fresh?(cached_at, 3600, expires_at)
|
|
200
|
+
expect(result).to be false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "returns false when max_age is exceeded" do
|
|
204
|
+
result = described_class.fresh?(cached_at, 1000, nil) # max_age less than age
|
|
205
|
+
expect(result).to be false
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe ".build_conditional_headers" do
|
|
210
|
+
let(:cache_entry) do
|
|
211
|
+
double(
|
|
212
|
+
etag: '"abc123"',
|
|
213
|
+
last_modified: Time.parse("2023-01-01 12:00:00 UTC")
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it "adds If-None-Match for ETag" do
|
|
218
|
+
original_headers = { "accept" => "application/json" }
|
|
219
|
+
result = described_class.build_conditional_headers(cache_entry, original_headers)
|
|
220
|
+
|
|
221
|
+
expect(result["If-None-Match"]).to eq('"abc123"')
|
|
222
|
+
expect(result["accept"]).to eq("application/json")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "adds If-Modified-Since for Last-Modified" do
|
|
226
|
+
original_headers = { "accept" => "application/json" }
|
|
227
|
+
result = described_class.build_conditional_headers(cache_entry, original_headers)
|
|
228
|
+
|
|
229
|
+
expect(result["If-Modified-Since"]).to eq("Sun, 01 Jan 2023 12:00:00 GMT")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "handles missing ETag and Last-Modified" do
|
|
233
|
+
cache_entry_without_headers = double(etag: nil, last_modified: nil)
|
|
234
|
+
original_headers = { "accept" => "application/json" }
|
|
235
|
+
|
|
236
|
+
result = described_class.build_conditional_headers(cache_entry_without_headers, original_headers)
|
|
237
|
+
|
|
238
|
+
expect(result["If-None-Match"]).to be_nil
|
|
239
|
+
expect(result["If-Modified-Since"]).to be_nil
|
|
240
|
+
expect(result["accept"]).to eq("application/json")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
describe ".parse_last_modified" do
|
|
245
|
+
it "parses valid date" do
|
|
246
|
+
date_string = "Sun, 01 Jan 2023 12:00:00 GMT"
|
|
247
|
+
result = described_class.parse_last_modified(date_string)
|
|
248
|
+
|
|
249
|
+
expect(result).to be_a(Time)
|
|
250
|
+
expect(result.year).to eq(2023)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "handles invalid date gracefully" do
|
|
254
|
+
result = described_class.parse_last_modified("invalid-date")
|
|
255
|
+
expect(result).to be_nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "handles nil input" do
|
|
259
|
+
result = described_class.parse_last_modified(nil)
|
|
260
|
+
expect(result).to be_nil
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
describe ".has_caching_directives?" do
|
|
265
|
+
it "returns true for Cache-Control header" do
|
|
266
|
+
headers = { "cache-control" => "max-age=3600" }
|
|
267
|
+
expect(described_class.has_caching_directives?(headers)).to be true
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "returns true for Expires header" do
|
|
271
|
+
headers = { "expires" => "Sun, 01 Jan 2024 12:00:00 GMT" }
|
|
272
|
+
expect(described_class.has_caching_directives?(headers)).to be true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "returns true for ETag header" do
|
|
276
|
+
headers = { "etag" => '"abc123"' }
|
|
277
|
+
expect(described_class.has_caching_directives?(headers)).to be true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "returns true for Last-Modified header" do
|
|
281
|
+
headers = { "last-modified" => "Sun, 01 Jan 2023 12:00:00 GMT" }
|
|
282
|
+
expect(described_class.has_caching_directives?(headers)).to be true
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it "returns false for no caching headers" do
|
|
286
|
+
headers = { "content-type" => "application/json" }
|
|
287
|
+
expect(described_class.has_caching_directives?(headers)).to be false
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|