lutaml-store 0.2.4 → 0.3.0

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.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Lutaml
8
+ module Store
9
+ # Pulls a source into a local package directory — the GCR-style shape:
10
+ #
11
+ # <into>[/<collection>]/
12
+ # manifest.json
13
+ # entries/<percent-encoded-key>
14
+ #
15
+ # The result is readable by Source::Directory, is the local-cache layout
16
+ # Repository uses, and is byte-compatible with the TypeScript
17
+ # implementation's LocalStore (conformance fixtures pin this).
18
+ #
19
+ # Pulls are incremental and digest-verified: entries whose local sha256
20
+ # already matches are not rewritten; a mismatched local entry is
21
+ # re-fetched. Digest mismatches against the source manifest are hard
22
+ # errors — never silently accepted.
23
+ module Mirror
24
+ class IntegrityError < Error; end
25
+
26
+ class << self
27
+ # @param source [Source::Base]
28
+ # @param into [String] directory to place the package in
29
+ # @param collection [String, nil] optional subdirectory of +into+
30
+ # @param force [Boolean] re-write entries even when digests match
31
+ # @return [Source::Directory] a source over the written package
32
+ def pull(source, into:, collection: nil, force: false)
33
+ manifest = source.manifest
34
+ root = collection ? ::File.join(into, collection) : into
35
+ entries_dir = ::File.join(root, "entries")
36
+ FileUtils.mkdir_p(entries_dir)
37
+
38
+ pulled = 0
39
+ entries = manifest.entries.map do |entry|
40
+ body = source.read(entry.key)
41
+ unless entry.matches?(body)
42
+ raise IntegrityError,
43
+ "digest mismatch for #{entry.key.inspect}: manifest declares " \
44
+ "#{entry.digest}, source returned #{entry.digest_for(body)}"
45
+ end
46
+
47
+ location = entry.location || "entries/#{Source.encode_key(entry.key)}"
48
+ path = ::File.join(root, location)
49
+ write_if_changed(path, body, force)
50
+ pulled += 1
51
+ Manifest::Entry.new(
52
+ key: entry.key, location: location,
53
+ digest: entry.digest_for(body), shard: entry.shard,
54
+ metadata: entry.metadata
55
+ )
56
+ end
57
+
58
+ local = Manifest.build(entries, shards: manifest.shards)
59
+ ::File.write(::File.join(root, "manifest.json"), JSON.pretty_generate(local.to_hash))
60
+
61
+ Source.for(:directory, path: root)
62
+ end
63
+
64
+ # Packs an existing package directory (as written by .pull) into a
65
+ # distributable .zip — the "downloaded official package" artifact.
66
+ # Source::Zip reads it back with no unpacking step.
67
+ #
68
+ # @param package_dir [String] a directory containing manifest.json + entries/
69
+ # @param to [String] the .zip path to write
70
+ # @return [String] the path written
71
+ def pack(package_dir, to:)
72
+ require "zip"
73
+ FileUtils.mkdir_p(::File.dirname(to))
74
+ ::Zip::File.open(to, create: true) do |zip|
75
+ Dir["#{package_dir}/**/*"].sort.each do |path|
76
+ next if ::File.directory?(path)
77
+
78
+ zip.add(path.delete_prefix("#{package_dir}/"), path)
79
+ end
80
+ end
81
+ to
82
+ end
83
+
84
+ private
85
+
86
+ def write_if_changed(path, body, force)
87
+ return if !force && ::File.file?(path) && ::File.binread(path) == body
88
+
89
+ ::File.binwrite(path, body)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ module Lutaml
9
+ module Store
10
+ # The uniform consumer facade: one read API whether the repository is a
11
+ # local package, a downloaded distribution, a temp-folder cache, or the
12
+ # cloud API. Backends are chosen by explicit composition — the
13
+ # repository never guesses, never falls back, and never hides which
14
+ # layer answered.
15
+ #
16
+ # # cloud, with an explicit local package cache
17
+ # repo = Repository.new(
18
+ # source: Source.for(:rest, base_url: "https://api.relaton.org",
19
+ # collection: "ietf"),
20
+ # cache: Source.for(:directory, path: "~/.cache/relaton/ietf"),
21
+ # )
22
+ #
23
+ # # a downloaded official package (zip) — identical read API
24
+ # repo = Repository.new(source: Source.for(:zip, path: "ietf.zip"))
25
+ #
26
+ # # a local GCR-style checkout
27
+ # repo = Repository.new(source: Source.for(:directory, path: "~/gcr/ietf"))
28
+ #
29
+ # Modes are explicit:
30
+ # :online — read cache first, then the source (default)
31
+ # :offline — never touch the source; absent keys raise NotFoundError
32
+ class Repository
33
+ MODES = %i[online offline].freeze
34
+
35
+ # @param source [Source::Base] the authoritative repository
36
+ # @param cache [Source::Directory, nil] explicit read-through local
37
+ # package (same layout as Mirror#pull writes); nil disables caching
38
+ # @param mode [Symbol] :online or :offline
39
+ def initialize(source:, cache: nil, mode: :online)
40
+ raise ConfigurationError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
41
+ raise ConfigurationError, "offline mode requires a cache source" if mode == :offline && cache.nil?
42
+
43
+ @source = source
44
+ @cache = cache
45
+ @mode = mode
46
+ end
47
+
48
+ # Read-through convenience constructors — still explicit about every
49
+ # backend, just shorter.
50
+ def self.for_cloud(base_url:, collection:, cache: nil, **source_options)
51
+ new(source: Source.for(:rest, base_url: base_url, collection: collection,
52
+ **source_options), cache: cache)
53
+ end
54
+
55
+ def self.for_package(path, type: :directory, **source_options)
56
+ new(source: Source.for(type, path: path, **source_options))
57
+ end
58
+
59
+ attr_reader :source, :cache, :mode
60
+
61
+ # @return [String] the record's bytes
62
+ def read(key)
63
+ if @cache
64
+ begin
65
+ return @cache.read(key)
66
+ rescue NotFoundError
67
+ raise if @mode == :offline
68
+ end
69
+ body = @source.read(key)
70
+ write_cache_entry(key, body)
71
+ return body
72
+ end
73
+
74
+ @source.read(key)
75
+ end
76
+
77
+ # @return the model instance built by the consumer's model class
78
+ def get(key, model_class)
79
+ format_for(key).deserialize(read(key), model_class)
80
+ end
81
+
82
+ def exist?(key)
83
+ read(key)
84
+ true
85
+ rescue NotFoundError
86
+ false
87
+ end
88
+
89
+ def keys
90
+ @source.keys
91
+ end
92
+
93
+ def each_key(&block)
94
+ keys.each(&block)
95
+ end
96
+
97
+ def manifest
98
+ @source.manifest
99
+ end
100
+
101
+ # Filters the collection's manifest entries by metadata key/values.
102
+ # The store is field-agnostic: it matches the literal metadata hash —
103
+ # the domain (relaton pubid, Glossarist concepts) narrows semantics.
104
+ #
105
+ # repo.search(doctype: "rfc", stream: "IETF")
106
+ # repo.search(docid: "RFC 7231")
107
+ #
108
+ # @param filter [Hash<String=>String>] ALL key/values must match
109
+ # @return [Array<Manifest::Entry>] matching entries (empty if none)
110
+ def search(**filter)
111
+ return [] if filter.empty?
112
+
113
+ manifest.entries.select do |entry|
114
+ filter.all? do |k, v|
115
+ mv = entry.metadata[k.to_s]
116
+ mv.is_a?(String) ? mv.casecmp?(v.to_s) : mv == v
117
+ end
118
+ end
119
+ end
120
+
121
+ # Mirror the whole source into a local package (the cache layout).
122
+ # Returns a Repository over the written package.
123
+ def pull!(into:, collection: nil, force: false)
124
+ pulled = Mirror.pull(@source, into: into, collection: collection, force: force)
125
+ Repository.new(source: pulled, mode: @mode)
126
+ end
127
+
128
+ private
129
+
130
+ # The write-through target is a Directory package (the Mirror layout);
131
+ # anything else is a configuration error, not a duck-type guess. The
132
+ # package manifest is maintained on every write so later reads —
133
+ # including reference resolution and offline mode — see the entry.
134
+ def write_cache_entry(key, body)
135
+ unless @cache.is_a?(Source::Directory)
136
+ raise ConfigurationError,
137
+ "cache must be a Source::Directory package, got #{@cache.class}"
138
+ end
139
+
140
+ location = "entries/#{Source.encode_key(key)}"
141
+ FileUtils.mkdir_p(::File.join(@cache.package_root, "entries"))
142
+ ::File.binwrite(::File.join(@cache.package_root, location), body)
143
+
144
+ manifest = begin
145
+ @cache.manifest
146
+ rescue NotFoundError
147
+ Lutaml::Store::Manifest.build([])
148
+ end
149
+ # The source manifest's metadata (e.g. the domain docid) rides
150
+ # along — offline reference resolution depends on it.
151
+ declared = begin
152
+ @source.manifest.entry_for(key)
153
+ rescue StandardError
154
+ nil
155
+ end
156
+ entry = Manifest::Entry.new(
157
+ key: key, location: location,
158
+ digest: "sha256:#{Digest::SHA256.hexdigest(body)}",
159
+ metadata: declared&.metadata || {}
160
+ )
161
+ entries = manifest.entries.reject { |e| e.key == key } + [entry]
162
+ updated = Manifest.build(entries, shards: manifest.shards)
163
+ ::File.write(
164
+ ::File.join(@cache.package_root, "manifest.json"),
165
+ JSON.pretty_generate(updated.to_hash)
166
+ )
167
+ end
168
+
169
+ def format_for(key)
170
+ fmt = Format.for_extension(::File.extname(key.to_s))
171
+ fmt ||= @source.options[:default_format] &&
172
+ Format.resolve(@source.options[:default_format])
173
+ fmt || raise(ConfigurationError,
174
+ "cannot determine the format of #{key.inspect}: pass default_format:")
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ module Source
6
+ # Contract shared by every source.
7
+ #
8
+ # Subclasses implement {#read} (bytes for one key, raising
9
+ # NotFoundError for a definitive miss and BackendError for transport
10
+ # trouble) and {#manifest_raw} (the manifest document). Everything
11
+ # else — keys, existence, typed reads — composes those two.
12
+ class Base
13
+ OPTIONS = [].freeze
14
+
15
+ attr_reader :options
16
+
17
+ def initialize(**options)
18
+ @options = options
19
+ missing = (self.class::OPTIONS - options.keys)
20
+ unless missing.empty?
21
+ raise ConfigurationError,
22
+ "#{self.class.name.split("::").last.downcase} source requires: " \
23
+ "#{missing.map(&:inspect).join(", ")}"
24
+ end
25
+ configure
26
+ end
27
+
28
+ # Bytes of one record. Raises NotFoundError (definitive) or
29
+ # BackendError (transport). Never returns nil.
30
+ #
31
+ # @param key [String]
32
+ # @return [String]
33
+ def read(_key)
34
+ raise NotImplementedError, "#{self.class}#read"
35
+ end
36
+
37
+ # The manifest document as a string.
38
+ #
39
+ # @return [String]
40
+ def manifest_raw
41
+ raise NotImplementedError, "#{self.class}#manifest_raw"
42
+ end
43
+
44
+ # The parsed manifest, memoized.
45
+ #
46
+ # @return [Manifest]
47
+ def manifest
48
+ @manifest ||= Manifest.parse(manifest_raw, format: manifest_format)
49
+ end
50
+
51
+ # All keys declared by the manifest. There is deliberately no
52
+ # fallback listing: an unenumerable source raises ConfigurationError
53
+ # instead of guessing.
54
+ #
55
+ # @return [Array<String>]
56
+ def keys
57
+ manifest.keys
58
+ end
59
+
60
+ # Manifest entry for one key.
61
+ #
62
+ # @param key [String]
63
+ # @return [Manifest::Entry, nil]
64
+ def entry_for(key)
65
+ manifest.entry_for(key)
66
+ end
67
+
68
+ def each_key(&block)
69
+ return to_enum(:each_key) unless block
70
+
71
+ keys.each(&block)
72
+ end
73
+
74
+ # Existence via a full read. A HEAD-based optimization may be
75
+ # provided per source; the default is honest and uniform.
76
+ #
77
+ # @param key [String]
78
+ # @return [Boolean]
79
+ def exist?(key)
80
+ read(key)
81
+ true
82
+ rescue NotFoundError
83
+ false
84
+ end
85
+
86
+ # Deserialized record using the consumer's model class — the model
87
+ # is declared by the caller, never inferred.
88
+ #
89
+ # @param key [String]
90
+ # @param model_class [Class] a Lutaml::Model::Serializable subclass
91
+ # @return the model instance
92
+ def get(key, model_class)
93
+ format_for(key).deserialize(read(key), model_class)
94
+ end
95
+
96
+ # Location of one key relative to the source. Manifest-declared
97
+ # locations win; otherwise the single-segment convention
98
+ # "entries/<percent-encoded key>" applies.
99
+ #
100
+ # @param key [String]
101
+ # @return [String]
102
+ def path_for(key)
103
+ entry = manifest.entry_for(key) if manifestable?
104
+ return entry.location if entry&.location
105
+
106
+ "entries/#{Source.encode_key(key)}"
107
+ end
108
+
109
+ private
110
+
111
+ def manifestable?
112
+ manifest
113
+ true
114
+ rescue NotImplementedError, BackendError, NotFoundError
115
+ false
116
+ end
117
+
118
+ def manifest_format
119
+ @options[:manifest_format] || :json
120
+ end
121
+
122
+ def format_for(key)
123
+ fmt = Format.for_extension(::File.extname(path_for(key).to_s))
124
+ fmt ||= Format.for_extension(::File.extname(key.to_s))
125
+ fmt ||= @options[:default_format] &&
126
+ Format.resolve(@options[:default_format])
127
+ fmt || raise(ConfigurationError,
128
+ "cannot determine the format of #{key.inspect}: pass default_format:")
129
+ end
130
+
131
+ def require_option(name)
132
+ @options[name] || raise(ConfigurationError, "#{name} is required")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "fileutils"
5
+
6
+ module Lutaml
7
+ module Store
8
+ module Source
9
+ # A local package directory: `manifest.json` + `entries/`. This is the
10
+ # layout {Mirror}#pull writes and what a downloaded official package
11
+ # (GCR-style) ships — one reader for caches, distributions and
12
+ # checkouts.
13
+ class Directory < Base
14
+ OPTIONS = %i[path].freeze
15
+
16
+ attr_reader :package_root
17
+
18
+ private
19
+
20
+ def configure
21
+ @path = require_option(:path)
22
+ @package_root = @path
23
+ # A read-only source never writes - not even directory creation.
24
+ # A missing path simply holds no entries and no manifest.
25
+ end
26
+
27
+ def entry_path(key)
28
+ File.join(@path, path_for(key))
29
+ end
30
+
31
+ public
32
+
33
+ def read(key)
34
+ File.binread(entry_path(key))
35
+ rescue Errno::ENOENT
36
+ raise NotFoundError, "no entry #{key.inspect} in #{@path}"
37
+ rescue SystemCallError => e
38
+ raise BackendError, "cannot read #{key.inspect} in #{@path}: #{e.message}"
39
+ end
40
+
41
+ def manifest_raw
42
+ File.binread(File.join(@path, "manifest.json"))
43
+ rescue Errno::ENOENT
44
+ raise NotFoundError, "no manifest.json in #{@path}"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Lutaml
9
+ module Store
10
+ module Source
11
+ # Any static HTTP host — GitHub Pages, raw.githubusercontent, an
12
+ # object-storage bucket. The consumer states the base URL; the source
13
+ # appends one percent-encoded path segment per key.
14
+ #
15
+ # Every read may be wrapped in an explicit `cache:` (an
16
+ # HttpCacheConfig hash) so ETag/304 revalidation is handled by the
17
+ # store's HttpCache. With no cache configured, reads go straight to
18
+ # the network — nothing is implied.
19
+ #
20
+ # `transport:` accepts a callable for tests and alternative HTTP
21
+ # stacks: `->(uri, headers) { {status_code:, headers:, body:} }`.
22
+ class Https < Base
23
+ OPTIONS = %i[base_url].freeze
24
+
25
+ RETRIED_ERRORS = [
26
+ SocketError, Timeout::Error, IOError, SystemCallError,
27
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
28
+ Net::ProtocolError
29
+ ].freeze
30
+
31
+ private
32
+
33
+ def configure
34
+ base = require_option(:base_url)
35
+ @base = URI.parse(base.to_s)
36
+ raise ConfigurationError, "base_url must be http(s)" unless @base.is_a?(URI::HTTP) ||
37
+ @base.is_a?(URI::HTTPS)
38
+
39
+ @headers = @options.fetch(:headers, {})
40
+ @timeout = @options.fetch(:timeout, 60)
41
+ @open_timeout = @options.fetch(:open_timeout, 60)
42
+ @max_redirects = @options.fetch(:max_redirects, 3)
43
+ @transport = @options[:transport]
44
+ @cache = @options[:cache] && HttpCache.new(@options[:cache])
45
+ end
46
+
47
+ def fetch(uri, headers = {}, redirects = @max_redirects)
48
+ response = raw_fetch(uri, headers)
49
+ case response[:status_code]
50
+ when 200..299 then response
51
+ when 301, 302, 307, 308
52
+ raise BackendError, "too many redirects fetching #{uri}" if redirects.zero?
53
+
54
+ fetch(URI.parse(response[:headers]["location"]), headers, redirects - 1)
55
+ when 404 then raise NotFoundError, "no entry at #{uri}"
56
+ else raise BackendError, "HTTP #{response[:status_code]} fetching #{uri}"
57
+ end
58
+ end
59
+
60
+ def raw_fetch(uri, headers)
61
+ if @transport
62
+ begin
63
+ return @transport.call(uri, headers)
64
+ rescue *RETRIED_ERRORS => e
65
+ raise BackendError, "cannot fetch #{uri}: #{e.class}: #{e.message}"
66
+ end
67
+ end
68
+
69
+ if @cache
70
+ return @cache.fetch(:get, uri.to_s, headers) do |h|
71
+ http_get(uri, h)
72
+ end
73
+ end
74
+
75
+ http_get(uri, headers)
76
+ end
77
+
78
+ def http_get(uri, headers)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.is_a?(URI::HTTPS)
81
+ http.open_timeout = @open_timeout
82
+ http.read_timeout = @timeout
83
+ begin
84
+ resp = http.get(uri, @headers.merge(headers))
85
+ { status_code: resp.code.to_i, headers: resp.each_header.to_h, body: resp.body }
86
+ rescue *RETRIED_ERRORS => e
87
+ raise BackendError, "cannot fetch #{uri}: #{e.class}: #{e.message}"
88
+ end
89
+ end
90
+
91
+ def url_for(relative_path)
92
+ base = @base.to_s.sub(%r{/+\z}, "")
93
+ path = relative_path.sub(%r{\A/+}, "")
94
+ URI.parse("#{base}/#{path}")
95
+ end
96
+
97
+ public
98
+
99
+ def read(key)
100
+ fetch(url_for(Source.encode_key(key)))[:body]
101
+ end
102
+
103
+ def manifest_raw
104
+ fetch(url_for(@options.fetch(:manifest_path, "manifest.json")))[:body]
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module Store
7
+ module Source
8
+ # A repository served by the lutaml cloud store API (the contract
9
+ # distilled from api.relaton.org):
10
+ #
11
+ # GET {base}/collections → collection list
12
+ # GET {base}/collections/{c}/manifest → Manifest
13
+ # GET {base}/collections/{c}/entries/{key} → record
14
+ #
15
+ # 404 is a definitive "no such key/collection" (NotFoundError); every
16
+ # other non-2xx is a BackendError. Path shapes are fixed here so
17
+ # server implementers have one contract — not per-client guesswork.
18
+ class Rest < Https
19
+ OPTIONS = %i[base_url collection].freeze
20
+
21
+ private
22
+
23
+ def configure
24
+ super
25
+ @collection = require_option(:collection)
26
+ end
27
+
28
+ def collection_path(suffix)
29
+ "collections/#{Source.encode_key(@collection)}#{suffix}"
30
+ end
31
+
32
+ public
33
+
34
+ def read(key)
35
+ fetch(url_for(collection_path("/entries/#{Source.encode_key(key)}")))[:body]
36
+ end
37
+
38
+ def manifest_raw
39
+ fetch(url_for(collection_path("/manifest")))[:body]
40
+ end
41
+
42
+ # All collections the API publishes.
43
+ #
44
+ # @return [Array<Hash>]
45
+ def collections
46
+ JSON.parse(fetch(url_for("collections"))[:body])["collections"]
47
+ end
48
+
49
+ # The keys of one declared shard, when the collection's manifest
50
+ # advertises sharding. Shard-number semantics belong to the domain;
51
+ # this only fetches the named part.
52
+ #
53
+ # @param number [Integer]
54
+ # @return [Array<String>]
55
+ def shard(number)
56
+ JSON.parse(fetch(url_for(collection_path("/shards/#{Integer(number)}")))[:body])["keys"]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ module Lutaml
6
+ module Store
7
+ module Source
8
+ # A packaged repository read in place from a .zip — the shape of a
9
+ # downloaded official package. Same internal layout as
10
+ # {Directory}: manifest.json + entries/.
11
+ class Zip < Base
12
+ OPTIONS = %i[path].freeze
13
+
14
+ private
15
+
16
+ def configure
17
+ @path = require_option(:path)
18
+ return if File.file?(@path)
19
+
20
+ raise ConfigurationError, "no such package file: #{@path}"
21
+ end
22
+
23
+ def with_zip(&block)
24
+ ::Zip::File.open(@path, &block)
25
+ rescue ::Zip::Error => e
26
+ raise BackendError, "cannot open package #{@path}: #{e.message}"
27
+ end
28
+
29
+ public
30
+
31
+ def read(key)
32
+ inner = path_for(key)
33
+ with_zip do |zip|
34
+ entry = zip.find_entry(inner)
35
+ raise NotFoundError, "no entry #{key.inspect} in #{::File.basename(@path)}" unless entry
36
+
37
+ entry.get_input_stream.read
38
+ end
39
+ end
40
+
41
+ def manifest_raw
42
+ with_zip do |zip|
43
+ entry = zip.find_entry("manifest.json")
44
+ raise NotFoundError, "no manifest.json in #{::File.basename(@path)}" unless entry
45
+
46
+ entry.get_input_stream.read
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end