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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class Base
|
|
7
|
+
def extension
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def glob_pattern
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(model)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def deserialize(data, model_class)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def serialize_many(models)
|
|
24
|
+
models.map { |m| serialize(m) }.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def deserialize_many(_data, _model_class)
|
|
28
|
+
raise NotImplementedError, "#{self.class} does not support multi-document deserialization"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class Json < Base
|
|
7
|
+
def extension
|
|
8
|
+
".json"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def glob_pattern
|
|
12
|
+
"*.json"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(model)
|
|
16
|
+
model.to_json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def deserialize(data, model_class)
|
|
20
|
+
model_class.from_json(data)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class Jsonl < Base
|
|
7
|
+
def extension
|
|
8
|
+
".jsonl"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def glob_pattern
|
|
12
|
+
"*.jsonl"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(model)
|
|
16
|
+
model.to_json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serialize_many(models)
|
|
20
|
+
"#{models.map(&:to_json).join("\n")}\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def deserialize(data, model_class)
|
|
24
|
+
model_class.from_json(data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def deserialize_many(data, model_class)
|
|
28
|
+
data.lines.filter_map do |line|
|
|
29
|
+
next if line.strip.empty?
|
|
30
|
+
|
|
31
|
+
model_class.from_json(line)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class MarshalFormat < Base
|
|
7
|
+
def extension
|
|
8
|
+
".bin"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def glob_pattern
|
|
12
|
+
"*.bin"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(model)
|
|
16
|
+
hash_data = model.to_hash
|
|
17
|
+
::Marshal.dump(hash_data)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def deserialize(data, model_class)
|
|
21
|
+
hash_data = ::Marshal.load(data)
|
|
22
|
+
model_class.from_hash(hash_data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def serialize_many(models)
|
|
26
|
+
hash_array = models.map(&:to_hash)
|
|
27
|
+
::Marshal.dump(hash_array)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def deserialize_many(data, model_class)
|
|
31
|
+
hash_array = ::Marshal.load(data)
|
|
32
|
+
hash_array.map { |h| model_class.from_hash(h) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class Yaml < Base
|
|
7
|
+
def extension
|
|
8
|
+
".yaml"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def glob_pattern
|
|
12
|
+
"*.{yaml,yml}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(model)
|
|
16
|
+
model.to_yaml
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def deserialize(data, model_class)
|
|
20
|
+
model_class.from_yaml(data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def deserialize_many(data, model_class)
|
|
24
|
+
model_class.from_yamls(data)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class Yamls < Base
|
|
7
|
+
def extension
|
|
8
|
+
".yaml"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def glob_pattern
|
|
12
|
+
"*.{yaml,yml}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(model)
|
|
16
|
+
model.to_yaml
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serialize_many(models)
|
|
20
|
+
models.map(&:to_yaml).join
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def deserialize(data, model_class)
|
|
24
|
+
docs = model_class.from_yamls(data)
|
|
25
|
+
docs.is_a?(Array) ? docs.first : docs
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def deserialize_many(data, model_class)
|
|
29
|
+
result = model_class.from_yamls(data)
|
|
30
|
+
result.is_a?(Array) ? result : [result]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Format
|
|
6
|
+
class Error < Lutaml::Store::Error; end
|
|
7
|
+
class FormatError < Error; end
|
|
8
|
+
class UnsupportedFormatError < FormatError; end
|
|
9
|
+
|
|
10
|
+
autoload :Base, "lutaml/store/format/base"
|
|
11
|
+
autoload :Yaml, "lutaml/store/format/yaml"
|
|
12
|
+
autoload :Yamls, "lutaml/store/format/yamls"
|
|
13
|
+
autoload :Json, "lutaml/store/format/json"
|
|
14
|
+
autoload :Jsonl, "lutaml/store/format/jsonl"
|
|
15
|
+
autoload :MarshalFormat, "lutaml/store/format/marshal_format"
|
|
16
|
+
|
|
17
|
+
FORMATS = {
|
|
18
|
+
yaml: "Yaml",
|
|
19
|
+
yamls: "Yamls",
|
|
20
|
+
json: "Json",
|
|
21
|
+
jsonl: "Jsonl",
|
|
22
|
+
marshal: "MarshalFormat"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def self.resolve(format)
|
|
26
|
+
entry = FORMATS[format.to_sym]
|
|
27
|
+
raise UnsupportedFormatError, "Unknown format: #{format}" unless entry
|
|
28
|
+
|
|
29
|
+
const_get(entry).new
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Store
|
|
7
|
+
class HttpCache
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config.is_a?(HttpCacheConfig) ? config : HttpCacheConfig.new(config)
|
|
10
|
+
@config.validate!
|
|
11
|
+
@adapter = create_adapter
|
|
12
|
+
@stats = {
|
|
13
|
+
cache_hits: 0,
|
|
14
|
+
cache_misses: 0,
|
|
15
|
+
conditional_requests: 0,
|
|
16
|
+
not_modified_responses: 0,
|
|
17
|
+
entries_stored: 0,
|
|
18
|
+
entries_evicted: 0
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Main cache interface - fetch with block for cache miss
|
|
23
|
+
def fetch(method, url, headers = {}, &block)
|
|
24
|
+
raise ArgumentError, "Block required for cache miss handling" unless block_given?
|
|
25
|
+
|
|
26
|
+
# Generate cache key considering vary headers
|
|
27
|
+
vary_headers = extract_request_vary_headers(headers)
|
|
28
|
+
cache_key = HttpHeaderProcessor.generate_cache_key(
|
|
29
|
+
method,
|
|
30
|
+
url,
|
|
31
|
+
vary_headers,
|
|
32
|
+
@config.ignore_query_params
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
prefixed_key = @config.cache_key_for(cache_key)
|
|
36
|
+
entry = get_entry(prefixed_key)
|
|
37
|
+
|
|
38
|
+
# Check if entry matches current request (Vary header consideration)
|
|
39
|
+
if entry && cache_entry_matches?(entry, headers)
|
|
40
|
+
if entry.fresh?
|
|
41
|
+
@stats[:cache_hits] += 1
|
|
42
|
+
return create_response_from_entry(entry)
|
|
43
|
+
elsif entry.stale? && @config.enable_conditional_requests
|
|
44
|
+
# Try conditional request
|
|
45
|
+
@stats[:conditional_requests] += 1
|
|
46
|
+
return handle_conditional_request(entry, headers, prefixed_key, &block)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Cache miss or unusable entry - make fresh request
|
|
51
|
+
@stats[:cache_misses] += 1
|
|
52
|
+
response = yield(headers)
|
|
53
|
+
cache_response(prefixed_key, method, url, headers, response)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get cached entry without making requests
|
|
57
|
+
def get(method, url, headers = {})
|
|
58
|
+
vary_headers = extract_request_vary_headers(headers)
|
|
59
|
+
cache_key = HttpHeaderProcessor.generate_cache_key(
|
|
60
|
+
method,
|
|
61
|
+
url,
|
|
62
|
+
vary_headers,
|
|
63
|
+
@config.ignore_query_params
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
prefixed_key = @config.cache_key_for(cache_key)
|
|
67
|
+
entry = get_entry(prefixed_key)
|
|
68
|
+
|
|
69
|
+
return nil unless entry
|
|
70
|
+
return nil unless cache_entry_matches?(entry, headers)
|
|
71
|
+
return nil unless entry.fresh?
|
|
72
|
+
|
|
73
|
+
create_response_from_entry(entry)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Store response in cache
|
|
77
|
+
def set(method, url, headers, response)
|
|
78
|
+
return response unless should_cache_response?(response)
|
|
79
|
+
|
|
80
|
+
vary_headers = extract_request_vary_headers(headers)
|
|
81
|
+
cache_key = HttpHeaderProcessor.generate_cache_key(
|
|
82
|
+
method,
|
|
83
|
+
url,
|
|
84
|
+
vary_headers,
|
|
85
|
+
@config.ignore_query_params
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
prefixed_key = @config.cache_key_for(cache_key)
|
|
89
|
+
cache_response(prefixed_key, method, url, headers, response)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Delete cached entry
|
|
93
|
+
def delete(method, url, headers = {})
|
|
94
|
+
vary_headers = extract_request_vary_headers(headers)
|
|
95
|
+
cache_key = HttpHeaderProcessor.generate_cache_key(
|
|
96
|
+
method,
|
|
97
|
+
url,
|
|
98
|
+
vary_headers,
|
|
99
|
+
@config.ignore_query_params
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
prefixed_key = @config.cache_key_for(cache_key)
|
|
103
|
+
@adapter.delete(prefixed_key)
|
|
104
|
+
rescue StandardError
|
|
105
|
+
# Log error but don't fail
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Clear all cache entries
|
|
110
|
+
def clear
|
|
111
|
+
@adapter.clear
|
|
112
|
+
rescue StandardError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get cache statistics
|
|
117
|
+
def stats
|
|
118
|
+
total_requests = @stats[:cache_hits] + @stats[:cache_misses]
|
|
119
|
+
hit_ratio = total_requests.positive? ? (@stats[:cache_hits].to_f / total_requests * 100) : 0
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
adapter_type: @config.adapter_type,
|
|
123
|
+
total_entries: @adapter.size,
|
|
124
|
+
cache_hits: @stats[:cache_hits],
|
|
125
|
+
cache_misses: @stats[:cache_misses],
|
|
126
|
+
conditional_requests: @stats[:conditional_requests],
|
|
127
|
+
not_modified_responses: @stats[:not_modified_responses],
|
|
128
|
+
entries_stored: @stats[:entries_stored],
|
|
129
|
+
entries_evicted: @stats[:entries_evicted],
|
|
130
|
+
hit_ratio: hit_ratio,
|
|
131
|
+
total_requests: total_requests,
|
|
132
|
+
config: {
|
|
133
|
+
default_ttl: @config.default_ttl,
|
|
134
|
+
max_entries: @config.max_entries,
|
|
135
|
+
respect_http_headers: @config.respect_http_headers,
|
|
136
|
+
enable_conditional_requests: @config.enable_conditional_requests
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Get all cache entries for inspection
|
|
142
|
+
def all_entries
|
|
143
|
+
entries = []
|
|
144
|
+
@adapter.each_key do |key|
|
|
145
|
+
data = @adapter.get(key)
|
|
146
|
+
next unless data
|
|
147
|
+
|
|
148
|
+
entry_data = data.is_a?(String) ? JSON.parse(data) : data
|
|
149
|
+
entry = HttpCacheEntry.from_hash(entry_data)
|
|
150
|
+
entries << entry
|
|
151
|
+
rescue StandardError
|
|
152
|
+
# Skip invalid entries
|
|
153
|
+
end
|
|
154
|
+
entries
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def create_adapter
|
|
160
|
+
adapter_config = @config.to_adapter_config
|
|
161
|
+
case adapter_config[:type]
|
|
162
|
+
when :memory
|
|
163
|
+
Adapter::Memory.new(adapter_config)
|
|
164
|
+
when :filesystem
|
|
165
|
+
Adapter::FileSystem.new(adapter_config)
|
|
166
|
+
when :sqlite
|
|
167
|
+
Adapter::Sqlite.new(adapter_config)
|
|
168
|
+
else
|
|
169
|
+
raise ArgumentError, "Unknown adapter type: #{adapter_config[:type]}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def get_entry(cache_key)
|
|
174
|
+
data = @adapter.get(cache_key)
|
|
175
|
+
return nil unless data
|
|
176
|
+
|
|
177
|
+
# Handle both serialized and hash data
|
|
178
|
+
entry_data = data.is_a?(String) ? JSON.parse(data) : data
|
|
179
|
+
HttpCacheEntry.from_hash(entry_data)
|
|
180
|
+
rescue StandardError
|
|
181
|
+
# Log error and continue without cache
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def store_entry(cache_key, entry)
|
|
186
|
+
# Serialize entry for storage
|
|
187
|
+
data = entry.to_hash
|
|
188
|
+
@adapter.set(cache_key, data)
|
|
189
|
+
rescue StandardError
|
|
190
|
+
# Log error but don't fail the request
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def handle_conditional_request(entry, headers, cache_key, &block)
|
|
195
|
+
# Build conditional headers
|
|
196
|
+
conditional_headers = HttpHeaderProcessor.build_conditional_headers(entry, headers)
|
|
197
|
+
response = yield(conditional_headers)
|
|
198
|
+
|
|
199
|
+
if response[:status_code] == 304
|
|
200
|
+
# Not modified - refresh cache timestamp and return cached content
|
|
201
|
+
entry.cached_at = Time.now
|
|
202
|
+
store_entry(cache_key, entry)
|
|
203
|
+
create_response_from_entry(entry)
|
|
204
|
+
else
|
|
205
|
+
# Modified - cache new response
|
|
206
|
+
cache_response(cache_key, entry.method, entry.url, headers, response)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def cache_response(cache_key, method, url, request_headers, response)
|
|
211
|
+
if should_cache_response?(response)
|
|
212
|
+
entry = create_cache_entry(cache_key, method, url, request_headers, response)
|
|
213
|
+
@stats[:entries_stored] += 1 if store_entry(cache_key, entry)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
response
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def should_cache_response?(response)
|
|
220
|
+
return false unless @config.respect_http_headers
|
|
221
|
+
return false unless response[:status_code]
|
|
222
|
+
|
|
223
|
+
HttpHeaderProcessor.should_cache_response?(response[:status_code], response[:headers] || {})
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def create_cache_entry(cache_key, method, url, request_headers, response)
|
|
227
|
+
cached_at = Time.now
|
|
228
|
+
response_headers = response[:headers] || {}
|
|
229
|
+
|
|
230
|
+
# Parse cache control and calculate expiry
|
|
231
|
+
cache_control = HttpHeaderProcessor.parse_cache_control(response_headers["cache-control"])
|
|
232
|
+
expires_at = HttpHeaderProcessor.calculate_expiry(response_headers, cached_at, @config.default_ttl)
|
|
233
|
+
|
|
234
|
+
# Parse vary headers
|
|
235
|
+
vary_headers = HttpHeaderProcessor.parse_vary_header(response_headers["vary"])
|
|
236
|
+
|
|
237
|
+
# Extract vary header values from request
|
|
238
|
+
request_vary_headers = {}
|
|
239
|
+
request_vary_headers = HttpHeaderProcessor.extract_vary_headers(request_headers, vary_headers) if vary_headers.any?
|
|
240
|
+
|
|
241
|
+
HttpCacheEntry.new(
|
|
242
|
+
cache_key: cache_key,
|
|
243
|
+
url: url,
|
|
244
|
+
method: method,
|
|
245
|
+
request_headers: request_vary_headers, # Store only vary-relevant headers
|
|
246
|
+
response_body: response[:body] || "",
|
|
247
|
+
response_headers: response_headers,
|
|
248
|
+
status_code: response[:status_code],
|
|
249
|
+
cached_at: cached_at,
|
|
250
|
+
etag: response_headers["etag"],
|
|
251
|
+
last_modified: HttpHeaderProcessor.parse_last_modified(response_headers["last-modified"]),
|
|
252
|
+
expires_at: expires_at,
|
|
253
|
+
max_age: cache_control["max-age"],
|
|
254
|
+
cache_control: cache_control,
|
|
255
|
+
vary_headers: vary_headers
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def create_response_from_entry(entry)
|
|
260
|
+
{
|
|
261
|
+
status_code: entry.status_code,
|
|
262
|
+
headers: entry.response_headers,
|
|
263
|
+
body: entry.response_body
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def extract_request_vary_headers(_headers)
|
|
268
|
+
# For now, return empty hash - will be populated when we know vary headers
|
|
269
|
+
{}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def cache_entry_matches?(entry, request_headers)
|
|
273
|
+
return true if entry.vary_headers.empty?
|
|
274
|
+
|
|
275
|
+
HttpHeaderProcessor.cache_entry_matches?(entry, request_headers, @config)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Store
|
|
7
|
+
class HttpCacheConfig
|
|
8
|
+
include Lutaml::Model::Serialize
|
|
9
|
+
|
|
10
|
+
attribute :adapter_type, :string, default: "filesystem"
|
|
11
|
+
attribute :adapter_options, :hash, default: {}
|
|
12
|
+
attribute :default_ttl, :integer, default: 3600
|
|
13
|
+
attribute :max_entries, :integer, default: 10_000
|
|
14
|
+
attribute :respect_http_headers, :boolean, default: true
|
|
15
|
+
attribute :enable_conditional_requests, :boolean, default: true
|
|
16
|
+
attribute :enable_compression, :boolean, default: false
|
|
17
|
+
attribute :cache_private_responses, :boolean, default: false
|
|
18
|
+
attribute :ignore_query_params, :string, collection: true, default: []
|
|
19
|
+
attribute :vary_ignore_headers, :string, collection: true, default: []
|
|
20
|
+
attribute :cache_key_prefix, :string, default: "http_cache"
|
|
21
|
+
|
|
22
|
+
def to_adapter_config
|
|
23
|
+
base_config = {
|
|
24
|
+
type: adapter_type.to_sym,
|
|
25
|
+
**adapter_options.transform_keys(&:to_sym)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Add compression if enabled
|
|
29
|
+
base_config[:compression] = true if enable_compression
|
|
30
|
+
|
|
31
|
+
base_config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cache_key_for(key)
|
|
35
|
+
"#{cache_key_prefix}:#{key}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def should_ignore_query_param?(param)
|
|
39
|
+
ignore_query_params.include?(param.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def should_ignore_vary_header?(header)
|
|
43
|
+
vary_ignore_headers.map(&:downcase).include?(header.to_s.downcase)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate!
|
|
47
|
+
raise ArgumentError, "default_ttl must be positive" if default_ttl <= 0
|
|
48
|
+
raise ArgumentError, "max_entries must be positive" if max_entries <= 0
|
|
49
|
+
raise ArgumentError, "adapter_type cannot be empty" if adapter_type.nil? || adapter_type.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
require "time"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Lutaml
|
|
8
|
+
module Store
|
|
9
|
+
class HttpCacheEntry
|
|
10
|
+
include Lutaml::Model::Serialize
|
|
11
|
+
|
|
12
|
+
attribute :cache_key, :string
|
|
13
|
+
attribute :url, :string
|
|
14
|
+
attribute :method, :string, default: "GET"
|
|
15
|
+
attribute :request_headers, :hash, default: {}
|
|
16
|
+
attribute :response_body, :string
|
|
17
|
+
attribute :response_headers, :hash, default: {}
|
|
18
|
+
attribute :status_code, :integer
|
|
19
|
+
attribute :cached_at, :time
|
|
20
|
+
attribute :etag, :string
|
|
21
|
+
attribute :last_modified, :time
|
|
22
|
+
attribute :expires_at, :time
|
|
23
|
+
attribute :max_age, :integer
|
|
24
|
+
attribute :cache_control, :hash, default: {}
|
|
25
|
+
attribute :vary_headers, :string, collection: true, default: []
|
|
26
|
+
|
|
27
|
+
def fresh?
|
|
28
|
+
return false if expired?
|
|
29
|
+
return false if must_revalidate?
|
|
30
|
+
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expired?
|
|
35
|
+
return true if expires_at && Time.now > expires_at
|
|
36
|
+
return true if max_age && (Time.now - cached_at) > max_age
|
|
37
|
+
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def must_revalidate?
|
|
42
|
+
!!(cache_control["must-revalidate"] || cache_control["no-cache"])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stale?
|
|
46
|
+
!fresh?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cacheable?
|
|
50
|
+
return false if status_code < 200 || status_code >= 400
|
|
51
|
+
return false if cache_control["no-store"]
|
|
52
|
+
return false if cache_control["private"]
|
|
53
|
+
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def age
|
|
58
|
+
Time.now - cached_at
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remaining_ttl
|
|
62
|
+
return 0 if expired?
|
|
63
|
+
return Float::INFINITY unless expires_at
|
|
64
|
+
|
|
65
|
+
expires_at - Time.now
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|