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.
- checksums.yaml +4 -4
- data/README.adoc +56 -0
- data/docs/astro.config.mjs +12 -0
- data/docs/package-lock.json +6098 -0
- data/docs/package.json +18 -0
- data/docs/public/favicon.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
- data/docs/src/layouts/Base.astro +44 -0
- data/docs/src/layouts/Docs.astro +41 -0
- data/docs/src/pages/adapters.md +46 -0
- data/docs/src/pages/architecture.md +68 -0
- data/docs/src/pages/cloud-contract.md +48 -0
- data/docs/src/pages/formats.md +54 -0
- data/docs/src/pages/http-cache.md +43 -0
- data/docs/src/pages/index.md +82 -0
- data/docs/src/pages/quick-start.md +109 -0
- data/docs/src/pages/sources.md +83 -0
- data/docs/src/pages/stores.md +110 -0
- data/docs/src/styles/global.css +10 -0
- data/lib/lutaml/store/format.rb +13 -0
- data/lib/lutaml/store/manifest.rb +85 -0
- data/lib/lutaml/store/mirror.rb +94 -0
- data/lib/lutaml/store/repository.rb +178 -0
- data/lib/lutaml/store/source/base.rb +137 -0
- data/lib/lutaml/store/source/directory.rb +49 -0
- data/lib/lutaml/store/source/https.rb +109 -0
- data/lib/lutaml/store/source/rest.rb +61 -0
- data/lib/lutaml/store/source/zip.rb +52 -0
- data/lib/lutaml/store/source.rb +55 -0
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +5 -0
- metadata +30 -6
|
@@ -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
|