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,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/store/http_cache_entry"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Store::HttpCacheEntry do
|
|
7
|
+
let(:cache_entry) do
|
|
8
|
+
described_class.new(
|
|
9
|
+
cache_key: "GET:http://example.com/api",
|
|
10
|
+
url: "http://example.com/api",
|
|
11
|
+
method: "GET",
|
|
12
|
+
request_headers: { "accept" => "application/json" },
|
|
13
|
+
response_body: '{"test": "data"}',
|
|
14
|
+
response_headers: { "content-type" => "application/json" },
|
|
15
|
+
status_code: 200,
|
|
16
|
+
cached_at: Time.now - 1800, # 30 minutes ago
|
|
17
|
+
etag: '"abc123"',
|
|
18
|
+
last_modified: Time.now - 3600, # 1 hour ago
|
|
19
|
+
expires_at: Time.now + 1800, # 30 minutes from now
|
|
20
|
+
max_age: 3600,
|
|
21
|
+
cache_control: { "max-age" => 3600 },
|
|
22
|
+
vary_headers: ["accept-encoding"]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe "#fresh?" do
|
|
27
|
+
context "when entry is not expired and doesn't require revalidation" do
|
|
28
|
+
it "returns true" do
|
|
29
|
+
expect(cache_entry.fresh?).to be true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context "when entry is expired" do
|
|
34
|
+
before do
|
|
35
|
+
cache_entry.expires_at = Time.now - 100
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "returns false" do
|
|
39
|
+
expect(cache_entry.fresh?).to be false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context "when entry must revalidate" do
|
|
44
|
+
before do
|
|
45
|
+
cache_entry.cache_control = { "must-revalidate" => true }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "returns false" do
|
|
49
|
+
expect(cache_entry.fresh?).to be false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe "#expired?" do
|
|
55
|
+
context "when expires_at is in the past" do
|
|
56
|
+
before do
|
|
57
|
+
cache_entry.expires_at = Time.now - 100
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "returns true" do
|
|
61
|
+
expect(cache_entry.expired?).to be true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
context "when max_age is exceeded" do
|
|
66
|
+
before do
|
|
67
|
+
cache_entry.cached_at = Time.now - 7200 # 2 hours ago
|
|
68
|
+
cache_entry.max_age = 3600 # 1 hour
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "returns true" do
|
|
72
|
+
expect(cache_entry.expired?).to be true
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
context "when entry is still valid" do
|
|
77
|
+
it "returns false" do
|
|
78
|
+
expect(cache_entry.expired?).to be false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe "#must_revalidate?" do
|
|
84
|
+
context "with must-revalidate directive" do
|
|
85
|
+
before do
|
|
86
|
+
cache_entry.cache_control = { "must-revalidate" => true }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "returns true" do
|
|
90
|
+
expect(cache_entry.must_revalidate?).to be true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
context "with no-cache directive" do
|
|
95
|
+
before do
|
|
96
|
+
cache_entry.cache_control = { "no-cache" => true }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "returns true" do
|
|
100
|
+
expect(cache_entry.must_revalidate?).to be true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
context "without revalidation directives" do
|
|
105
|
+
it "returns false" do
|
|
106
|
+
expect(cache_entry.must_revalidate?).to be false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
describe "#stale?" do
|
|
112
|
+
it "returns opposite of fresh?" do
|
|
113
|
+
expect(cache_entry.stale?).to eq(!cache_entry.fresh?)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe "#cacheable?" do
|
|
118
|
+
context "with successful status code" do
|
|
119
|
+
it "returns true" do
|
|
120
|
+
expect(cache_entry.cacheable?).to be true
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
context "with error status code" do
|
|
125
|
+
before do
|
|
126
|
+
cache_entry.status_code = 404
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "returns false" do
|
|
130
|
+
expect(cache_entry.cacheable?).to be false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
context "with no-store directive" do
|
|
135
|
+
before do
|
|
136
|
+
cache_entry.cache_control = { "no-store" => true }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "returns false" do
|
|
140
|
+
expect(cache_entry.cacheable?).to be false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
context "with private directive" do
|
|
145
|
+
before do
|
|
146
|
+
cache_entry.cache_control = { "private" => true }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "returns false" do
|
|
150
|
+
expect(cache_entry.cacheable?).to be false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
describe "#age" do
|
|
156
|
+
it "returns time since cached" do
|
|
157
|
+
age = cache_entry.age
|
|
158
|
+
expect(age).to be_within(1).of(1800) # approximately 30 minutes
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
describe "#remaining_ttl" do
|
|
163
|
+
context "when not expired" do
|
|
164
|
+
it "returns remaining time to live" do
|
|
165
|
+
ttl = cache_entry.remaining_ttl
|
|
166
|
+
expect(ttl).to be_within(1).of(1800) # approximately 30 minutes
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
context "when expired" do
|
|
171
|
+
before do
|
|
172
|
+
cache_entry.expires_at = Time.now - 100
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "returns 0" do
|
|
176
|
+
expect(cache_entry.remaining_ttl).to eq(0)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
context "when no expiry set" do
|
|
181
|
+
before do
|
|
182
|
+
cache_entry.expires_at = nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "returns infinity" do
|
|
186
|
+
expect(cache_entry.remaining_ttl).to eq(Float::INFINITY)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
describe "serialization" do
|
|
192
|
+
it "can be serialized and deserialized" do
|
|
193
|
+
hash = cache_entry.to_hash
|
|
194
|
+
restored = described_class.from_hash(hash)
|
|
195
|
+
|
|
196
|
+
expect(restored.cache_key).to eq(cache_entry.cache_key)
|
|
197
|
+
expect(restored.url).to eq(cache_entry.url)
|
|
198
|
+
expect(restored.method).to eq(cache_entry.method)
|
|
199
|
+
expect(restored.status_code).to eq(cache_entry.status_code)
|
|
200
|
+
expect(restored.response_body).to eq(cache_entry.response_body)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/store/http_cache"
|
|
5
|
+
require "lutaml/store/http_cache_config"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
# Mock HAL components for testing
|
|
9
|
+
module MockHal
|
|
10
|
+
class MockClient
|
|
11
|
+
attr_accessor :api_url
|
|
12
|
+
|
|
13
|
+
def initialize(api_url = "https://api.example.com")
|
|
14
|
+
@api_url = api_url
|
|
15
|
+
@responses = {}
|
|
16
|
+
@request_count = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_response(url, response, headers = {})
|
|
20
|
+
@responses[url] = {
|
|
21
|
+
body: response,
|
|
22
|
+
headers: headers
|
|
23
|
+
}
|
|
24
|
+
@request_count[url] = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get(url)
|
|
28
|
+
full_url = url.start_with?("http") ? url : "#{@api_url}#{url}"
|
|
29
|
+
@request_count[full_url] = (@request_count[full_url] || 0) + 1
|
|
30
|
+
|
|
31
|
+
response_data = @responses[full_url]
|
|
32
|
+
raise "No mock response for #{full_url}" unless response_data
|
|
33
|
+
|
|
34
|
+
MockResponse.new(response_data[:body], response_data[:headers])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_with_headers(url, headers)
|
|
38
|
+
# For conditional requests, check if we should return 304
|
|
39
|
+
full_url = url.start_with?("http") ? url : "#{@api_url}#{url}"
|
|
40
|
+
response_data = @responses[full_url]
|
|
41
|
+
|
|
42
|
+
if response_data && headers["if-none-match"] == response_data[:headers]["etag"]
|
|
43
|
+
@request_count[full_url] = (@request_count[full_url] || 0) + 1
|
|
44
|
+
MockResponse.new("", { "status" => "304" }, 304)
|
|
45
|
+
else
|
|
46
|
+
get(url)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_by_url(url)
|
|
51
|
+
get(url)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def request_count(url)
|
|
55
|
+
full_url = url.start_with?("http") ? url : "#{@api_url}#{url}"
|
|
56
|
+
@request_count[full_url] || 0
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class MockResponse
|
|
61
|
+
attr_reader :headers, :status
|
|
62
|
+
|
|
63
|
+
def initialize(body, headers = {}, status = 200)
|
|
64
|
+
@body = body.is_a?(String) ? JSON.parse(body) : body
|
|
65
|
+
@headers = headers
|
|
66
|
+
@status = status
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
@body = body
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_json(*_args)
|
|
72
|
+
@body.to_json
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
@body.is_a?(Hash) ? @body : { "data" => @body }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def [](key)
|
|
80
|
+
to_h[key]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class MockModel
|
|
85
|
+
include Lutaml::Model::Serialize
|
|
86
|
+
|
|
87
|
+
attribute :id, Lutaml::Model::Type::String
|
|
88
|
+
attribute :name, Lutaml::Model::Type::String
|
|
89
|
+
attribute :description, Lutaml::Model::Type::String
|
|
90
|
+
|
|
91
|
+
json do
|
|
92
|
+
map "id", to: :id
|
|
93
|
+
map "name", to: :name
|
|
94
|
+
map "description", to: :description
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Simplified ModelRegister for testing
|
|
99
|
+
class MockModelRegister
|
|
100
|
+
attr_accessor :client, :cache_store
|
|
101
|
+
|
|
102
|
+
def initialize(client:, cache: nil)
|
|
103
|
+
@client = client
|
|
104
|
+
@cache_store = setup_cache_store(cache) if cache
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetch_resource(url, headers = {})
|
|
108
|
+
# Use HTTP cache if available
|
|
109
|
+
if @cache_store.respond_to?(:fetch)
|
|
110
|
+
response = @cache_store.fetch("GET", url, headers) do |request_headers|
|
|
111
|
+
raw_response = if request_headers.any?
|
|
112
|
+
@client.get_with_headers(url, request_headers)
|
|
113
|
+
else
|
|
114
|
+
@client.get(url)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
convert_client_response_to_http_format(raw_response)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
convert_http_response_to_client_format(response)
|
|
121
|
+
else
|
|
122
|
+
@client.get(url)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def setup_cache_store(cache_config)
|
|
129
|
+
return nil unless cache_config
|
|
130
|
+
|
|
131
|
+
config = cache_config.is_a?(Hash) ? cache_config : { adapter: cache_config }
|
|
132
|
+
|
|
133
|
+
http_config = Lutaml::Store::HttpCacheConfig.new(
|
|
134
|
+
adapter_type: config[:adapter_type] || :memory,
|
|
135
|
+
default_ttl: config[:ttl] || 3600,
|
|
136
|
+
max_entries: config[:max_size] || 1000,
|
|
137
|
+
respect_http_headers: config[:respect_http_headers] != false,
|
|
138
|
+
enable_conditional_requests: config[:enable_conditional_requests] != false
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Add adapter options if needed
|
|
142
|
+
http_config.adapter_options = { path: config[:path] } if config[:path]
|
|
143
|
+
|
|
144
|
+
Lutaml::Store::HttpCache.new(http_config)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def convert_client_response_to_http_format(client_response)
|
|
148
|
+
headers = {}
|
|
149
|
+
headers = client_response.headers if client_response.respond_to?(:headers)
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
status_code: client_response.respond_to?(:status) ? client_response.status : 200,
|
|
153
|
+
headers: headers,
|
|
154
|
+
body: client_response.respond_to?(:to_json) ? client_response.to_json : client_response.to_s
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def convert_http_response_to_client_format(http_response)
|
|
159
|
+
if http_response[:body].is_a?(String)
|
|
160
|
+
begin
|
|
161
|
+
JSON.parse(http_response[:body])
|
|
162
|
+
rescue JSON::ParserError
|
|
163
|
+
http_response[:body]
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
http_response[:body]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
RSpec.describe "HTTP Cache HAL Integration", skip: "WIP: HAL integration not yet implemented" do
|
|
173
|
+
let(:client) { MockHal::MockClient.new }
|
|
174
|
+
let(:cache_config) do
|
|
175
|
+
{
|
|
176
|
+
adapter_type: :memory,
|
|
177
|
+
ttl: 3600,
|
|
178
|
+
max_size: 100,
|
|
179
|
+
respect_http_headers: true,
|
|
180
|
+
enable_conditional_requests: true
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
let(:register) { MockHal::MockModelRegister.new(client: client, cache: cache_config) }
|
|
184
|
+
|
|
185
|
+
describe "basic caching functionality" do
|
|
186
|
+
let(:url) { "https://api.example.com/resources/123" }
|
|
187
|
+
let(:response_data) do
|
|
188
|
+
{
|
|
189
|
+
"id" => "123",
|
|
190
|
+
"name" => "Test Resource",
|
|
191
|
+
"description" => "A test resource"
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
before do
|
|
196
|
+
client.set_response(url, response_data, {
|
|
197
|
+
"etag" => '"abc123"',
|
|
198
|
+
"cache-control" => "max-age=3600",
|
|
199
|
+
"last-modified" => "Wed, 21 Oct 2015 07:28:00 GMT"
|
|
200
|
+
})
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "caches responses and avoids duplicate requests" do
|
|
204
|
+
# First request should hit the server
|
|
205
|
+
result1 = register.fetch_resource(url)
|
|
206
|
+
expect(client.request_count(url)).to eq(1)
|
|
207
|
+
expect(result1["id"]).to eq("123")
|
|
208
|
+
|
|
209
|
+
# Second request should use cache
|
|
210
|
+
result2 = register.fetch_resource(url)
|
|
211
|
+
expect(client.request_count(url)).to eq(1) # Still 1, no new request
|
|
212
|
+
expect(result2["id"]).to eq("123")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "respects cache-control headers" do
|
|
216
|
+
# Set a very short TTL in the response
|
|
217
|
+
client.set_response(url, response_data, {
|
|
218
|
+
"etag" => '"abc123"',
|
|
219
|
+
"cache-control" => "max-age=1"
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
# First request
|
|
223
|
+
register.fetch_resource(url)
|
|
224
|
+
expect(client.request_count(url)).to eq(1)
|
|
225
|
+
|
|
226
|
+
# Wait for cache to expire
|
|
227
|
+
sleep(1.1)
|
|
228
|
+
|
|
229
|
+
# Second request should hit server again due to expired cache
|
|
230
|
+
register.fetch_resource(url)
|
|
231
|
+
expect(client.request_count(url)).to eq(2)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "uses conditional requests with ETags" do
|
|
235
|
+
# First request to populate cache
|
|
236
|
+
register.fetch_resource(url)
|
|
237
|
+
expect(client.request_count(url)).to eq(1)
|
|
238
|
+
|
|
239
|
+
# Wait for cache to expire naturally, then make another request
|
|
240
|
+
# This should trigger a conditional request
|
|
241
|
+
sleep(0.1) # Small delay to ensure different timestamps
|
|
242
|
+
|
|
243
|
+
# The cache should still be valid, so no new request
|
|
244
|
+
result = register.fetch_resource(url)
|
|
245
|
+
expect(client.request_count(url)).to eq(1) # Still cached
|
|
246
|
+
expect(result["id"]).to eq("123")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
describe "cache configuration" do
|
|
251
|
+
it "works with memory adapter" do
|
|
252
|
+
memory_register = MockHal::MockModelRegister.new(
|
|
253
|
+
client: client,
|
|
254
|
+
cache: { adapter_type: :memory, ttl: 1800 }
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
url = "https://api.example.com/memory-test"
|
|
258
|
+
client.set_response(url, { "data" => "memory test" })
|
|
259
|
+
|
|
260
|
+
result = memory_register.fetch_resource(url)
|
|
261
|
+
expect(result["data"]).to eq("memory test")
|
|
262
|
+
expect(client.request_count(url)).to eq(1)
|
|
263
|
+
|
|
264
|
+
# Second request should use cache
|
|
265
|
+
memory_register.fetch_resource(url)
|
|
266
|
+
expect(client.request_count(url)).to eq(1)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "works with filesystem adapter" do
|
|
270
|
+
Dir.mktmpdir do |tmpdir|
|
|
271
|
+
fs_register = MockHal::MockModelRegister.new(
|
|
272
|
+
client: client,
|
|
273
|
+
cache: {
|
|
274
|
+
adapter_type: :filesystem,
|
|
275
|
+
ttl: 1800,
|
|
276
|
+
path: tmpdir
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
url = "https://api.example.com/fs-test"
|
|
281
|
+
client.set_response(url, { "data" => "filesystem test" })
|
|
282
|
+
|
|
283
|
+
result = fs_register.fetch_resource(url)
|
|
284
|
+
expect(result["data"]).to eq("filesystem test")
|
|
285
|
+
expect(client.request_count(url)).to eq(1)
|
|
286
|
+
|
|
287
|
+
# Second request should use cache
|
|
288
|
+
fs_register.fetch_resource(url)
|
|
289
|
+
expect(client.request_count(url)).to eq(1)
|
|
290
|
+
|
|
291
|
+
# Verify cache file was created
|
|
292
|
+
cache_files = Dir.glob(File.join(tmpdir, "**", "*")).select { |f| File.file?(f) }
|
|
293
|
+
expect(cache_files).not_to be_empty
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
describe "HTTP semantics compliance" do
|
|
299
|
+
let(:url) { "https://api.example.com/semantics-test" }
|
|
300
|
+
|
|
301
|
+
it "respects no-cache directive" do
|
|
302
|
+
client.set_response(url, { "data" => "no-cache test" }, {
|
|
303
|
+
"cache-control" => "no-cache"
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
# First request
|
|
307
|
+
register.fetch_resource(url)
|
|
308
|
+
expect(client.request_count(url)).to eq(1)
|
|
309
|
+
|
|
310
|
+
# Second request should not use cache due to no-cache
|
|
311
|
+
register.fetch_resource(url)
|
|
312
|
+
expect(client.request_count(url)).to eq(2)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it "respects no-store directive" do
|
|
316
|
+
client.set_response(url, { "data" => "no-store test" }, {
|
|
317
|
+
"cache-control" => "no-store"
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
# Requests should never be cached
|
|
321
|
+
register.fetch_resource(url)
|
|
322
|
+
register.fetch_resource(url)
|
|
323
|
+
expect(client.request_count(url)).to eq(2)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "handles must-revalidate directive" do
|
|
327
|
+
client.set_response(url, { "data" => "revalidate test" }, {
|
|
328
|
+
"cache-control" => "max-age=1, must-revalidate",
|
|
329
|
+
"etag" => '"revalidate123"'
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
# First request
|
|
333
|
+
register.fetch_resource(url)
|
|
334
|
+
expect(client.request_count(url)).to eq(1)
|
|
335
|
+
|
|
336
|
+
# Wait for cache to expire
|
|
337
|
+
sleep(1.1)
|
|
338
|
+
|
|
339
|
+
# Should revalidate with conditional request
|
|
340
|
+
register.fetch_resource(url)
|
|
341
|
+
expect(client.request_count(url)).to eq(2)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
describe "performance benefits" do
|
|
346
|
+
let(:url) { "https://api.example.com/performance-test" }
|
|
347
|
+
|
|
348
|
+
it "provides significant performance improvement" do
|
|
349
|
+
client.set_response(url, { "data" => "performance test" }, {
|
|
350
|
+
"etag" => '"perf123"',
|
|
351
|
+
"cache-control" => "max-age=3600"
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
# Measure time for first request (cache miss)
|
|
355
|
+
start_time = Time.now
|
|
356
|
+
register.fetch_resource(url)
|
|
357
|
+
first_request_time = Time.now - start_time
|
|
358
|
+
|
|
359
|
+
# Measure time for second request (cache hit)
|
|
360
|
+
start_time = Time.now
|
|
361
|
+
register.fetch_resource(url)
|
|
362
|
+
second_request_time = Time.now - start_time
|
|
363
|
+
|
|
364
|
+
# Cache hit should be significantly faster
|
|
365
|
+
expect(second_request_time).to be < (first_request_time * 0.1)
|
|
366
|
+
expect(client.request_count(url)).to eq(1)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "handles high request volume efficiently" do
|
|
370
|
+
client.set_response(url, { "data" => "volume test" }, {
|
|
371
|
+
"cache-control" => "max-age=3600"
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
# Make many requests
|
|
375
|
+
100.times { register.fetch_resource(url) }
|
|
376
|
+
|
|
377
|
+
# Should only hit the server once
|
|
378
|
+
expect(client.request_count(url)).to eq(1)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
describe "error handling" do
|
|
383
|
+
it "handles network errors gracefully" do
|
|
384
|
+
url = "https://api.example.com/error-test"
|
|
385
|
+
|
|
386
|
+
# Don't set up a mock response to simulate network error
|
|
387
|
+
expect do
|
|
388
|
+
register.fetch_resource(url)
|
|
389
|
+
end.to raise_error(/No mock response/)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it "handles malformed cache headers" do
|
|
393
|
+
url = "https://api.example.com/malformed-test"
|
|
394
|
+
client.set_response(url, { "data" => "malformed test" }, {
|
|
395
|
+
"cache-control" => "invalid-directive",
|
|
396
|
+
"expires" => "not-a-date"
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
# Should still work despite malformed headers
|
|
400
|
+
result = register.fetch_resource(url)
|
|
401
|
+
expect(result["data"]).to eq("malformed test")
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|