capsium 0.3.0 → 0.6.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/.rubocop.yml +7 -3
- data/CHANGELOG.md +197 -0
- data/README.adoc +323 -14
- data/capsium.gemspec +2 -0
- data/lib/capsium/cli/package.rb +62 -25
- data/lib/capsium/cli/reactor.rb +112 -6
- data/lib/capsium/cli.rb +29 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +73 -17
- data/lib/capsium/package/composition.rb +28 -12
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +39 -9
- data/lib/capsium/package/encryption_config.rb +7 -3
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +4 -2
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -0
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +18 -3
- data/lib/capsium/package/store.rb +27 -0
- data/lib/capsium/package/validator.rb +27 -2
- data/lib/capsium/package/verification.rb +7 -5
- data/lib/capsium/package.rb +24 -5
- data/lib/capsium/packager.rb +64 -22
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/introspection.rb +151 -34
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +90 -11
- data/lib/capsium/reactor.rb +96 -49
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +12 -1
- data/sig/capsium/package/composition.rbs +4 -3
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +8 -4
- data/sig/capsium/package/encryption_config.rbs +6 -2
- data/sig/capsium/package/merged_view.rbs +2 -1
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.rbs +46 -0
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +8 -1
- data/sig/capsium/package/store.rbs +6 -0
- data/sig/capsium/package/verification.rbs +5 -3
- data/sig/capsium/package.rbs +8 -3
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +28 -4
- data/sig/capsium/reactor.rbs +17 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- metadata +66 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Reactor
|
|
8
|
+
# The writable top overlay layer of a mounted package
|
|
9
|
+
# (ARCHITECTURE.md section 5a): an append-only record of writes in
|
|
10
|
+
# the reactor workdir. The immutable package on disk never changes;
|
|
11
|
+
# content writes land under content/, deletions are tombstones in
|
|
12
|
+
# .capsium-tombstones, and dataset mutations are a per-dataset JSON
|
|
13
|
+
# operation log under data/. Everything is visible on the next
|
|
14
|
+
# request (hot-swap) and reloads from disk when the reactor starts
|
|
15
|
+
# over the same workdir.
|
|
16
|
+
class Overlay
|
|
17
|
+
# Base class for the typed overlay errors the APIs map to HTTP
|
|
18
|
+
# statuses.
|
|
19
|
+
class Error < Capsium::Error; end
|
|
20
|
+
|
|
21
|
+
# The addressed item does not exist in the merged collection (404).
|
|
22
|
+
class ItemNotFoundError < Error; end
|
|
23
|
+
|
|
24
|
+
# The write conflicts with existing state, e.g. a duplicate item
|
|
25
|
+
# id on append or a body id mismatch on replace (409).
|
|
26
|
+
class ConflictError < Error; end
|
|
27
|
+
|
|
28
|
+
# The dataset cannot take item writes (not a JSON collection) or
|
|
29
|
+
# the addressed path is unsafe (422/400).
|
|
30
|
+
class UnsupportedDatasetError < Error; end
|
|
31
|
+
|
|
32
|
+
# The candidate document violates the dataset's JSON schema (422);
|
|
33
|
+
# carries the schema error messages.
|
|
34
|
+
class SchemaViolationError < Error
|
|
35
|
+
attr_reader :messages
|
|
36
|
+
|
|
37
|
+
def initialize(messages)
|
|
38
|
+
@messages = messages
|
|
39
|
+
super(messages.join("; "))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
TOMBSTONE_FILE = Package::MergedView::TOMBSTONE_FILE
|
|
44
|
+
SAFE_NAME_PATTERN = /\A[\w.-]+\z/
|
|
45
|
+
|
|
46
|
+
attr_reader :root, :tombstones
|
|
47
|
+
|
|
48
|
+
def initialize(root:)
|
|
49
|
+
@root = root
|
|
50
|
+
@tombstones = load_tombstones
|
|
51
|
+
@logs = {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def content_root = File.join(@root, "content")
|
|
55
|
+
|
|
56
|
+
def data_root = File.join(@root, "data")
|
|
57
|
+
|
|
58
|
+
# The overlay as the topmost MergedView layer. The tombstones set
|
|
59
|
+
# is live: deletions are visible on the next resolve.
|
|
60
|
+
def layer
|
|
61
|
+
Package::MergedView::Layer.new(root: content_root,
|
|
62
|
+
visibility: "private",
|
|
63
|
+
tombstones: @tombstones)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Writes a content file (create/overwrite), clearing any tombstone
|
|
67
|
+
# for the path so it is served again.
|
|
68
|
+
def write_content(content_relative, body)
|
|
69
|
+
path = safe_content_path(content_relative)
|
|
70
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
71
|
+
File.write(path, body)
|
|
72
|
+
@tombstones.delete(content_relative)
|
|
73
|
+
persist_tombstones
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Tombstones a content path: it resolves 404 even when a lower
|
|
77
|
+
# layer holds the file (ARCHITECTURE.md section 5a).
|
|
78
|
+
def delete_content(content_relative)
|
|
79
|
+
FileUtils.rm_f(safe_content_path(content_relative))
|
|
80
|
+
@tombstones.add(content_relative)
|
|
81
|
+
persist_tombstones
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The dataset as served: the base data untouched when no mutations
|
|
85
|
+
# were recorded, the merged collection otherwise.
|
|
86
|
+
def dataset_data(dataset)
|
|
87
|
+
return dataset.data if mutation_log(dataset.name).empty?
|
|
88
|
+
|
|
89
|
+
items(dataset)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The merged item collection: base data plus the replayed
|
|
93
|
+
# mutation log.
|
|
94
|
+
def items(dataset)
|
|
95
|
+
mutation_log(dataset.name).inject(base_items(dataset)) do |items, record|
|
|
96
|
+
apply_op(items, record)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def item(dataset, id)
|
|
101
|
+
current = items(dataset)
|
|
102
|
+
found = find_index(current, id)
|
|
103
|
+
raise ItemNotFoundError, "no item #{id} in dataset #{dataset.name}" unless found
|
|
104
|
+
|
|
105
|
+
current[found]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Appends an item and returns its assigned id: the "id" field
|
|
109
|
+
# convention, else the 1-based index as a string.
|
|
110
|
+
def append_item(dataset, item)
|
|
111
|
+
current = items(dataset)
|
|
112
|
+
explicit = item_id(item)
|
|
113
|
+
if explicit && find_index(current, explicit)
|
|
114
|
+
raise ConflictError, "item id #{explicit} already exists in #{dataset.name}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
validate_candidate!(dataset, current + [item])
|
|
118
|
+
record_op(dataset.name, "op" => "append", "item" => item)
|
|
119
|
+
explicit || (current.size + 1).to_s
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def replace_item(dataset, id, item)
|
|
123
|
+
current = items(dataset)
|
|
124
|
+
index = find_index(current, id)
|
|
125
|
+
raise ItemNotFoundError, "no item #{id} in dataset #{dataset.name}" unless index
|
|
126
|
+
|
|
127
|
+
explicit = item_id(item)
|
|
128
|
+
if explicit && explicit != id
|
|
129
|
+
raise ConflictError, "body id #{explicit} does not match #{id}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
validate_candidate!(dataset, replaced(current, index, item))
|
|
133
|
+
record_op(dataset.name, "op" => "replace", "id" => id, "item" => item)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def delete_item(dataset, id)
|
|
137
|
+
unless find_index(items(dataset), id)
|
|
138
|
+
raise ItemNotFoundError, "no item #{id} in dataset #{dataset.name}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
record_op(dataset.name, "op" => "delete", "id" => id)
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The item id convention: an "id" field, else nil (positional).
|
|
146
|
+
def item_id(item)
|
|
147
|
+
item["id"].to_s if item.is_a?(Hash) && item.key?("id")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Whether the dataset has recorded mutations.
|
|
151
|
+
def mutations?(name) = !mutation_log(name).empty?
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def base_items(dataset)
|
|
156
|
+
base = dataset.data || []
|
|
157
|
+
return deep_dup(base) if base.is_a?(Array)
|
|
158
|
+
|
|
159
|
+
raise UnsupportedDatasetError,
|
|
160
|
+
"dataset #{dataset.name} is not a JSON collection"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_candidate!(dataset, candidate)
|
|
164
|
+
errors = dataset.schema_errors_for(candidate)
|
|
165
|
+
raise SchemaViolationError, errors unless errors.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def apply_op(items, record)
|
|
169
|
+
return items + [record["item"]] if record["op"] == "append"
|
|
170
|
+
|
|
171
|
+
index = find_index(items, record["id"])
|
|
172
|
+
return items unless index # base changed underneath: skip the op
|
|
173
|
+
|
|
174
|
+
case record["op"]
|
|
175
|
+
when "replace" then replaced(items, index, record["item"])
|
|
176
|
+
when "delete" then items[0...index] + items[(index + 1)..]
|
|
177
|
+
else raise Error, "unknown overlay op: #{record['op']}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def replaced(items, index, item)
|
|
182
|
+
items[0...index] + [item] + items[(index + 1)..]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Positional ids are the 1-based index as a string and only match
|
|
186
|
+
# items without an explicit "id" field.
|
|
187
|
+
def find_index(items, id)
|
|
188
|
+
by_field = items.index { |item| item_id(item) == id }
|
|
189
|
+
return by_field if by_field
|
|
190
|
+
return nil unless id.to_s.match?(/\A[1-9]\d*\z/)
|
|
191
|
+
|
|
192
|
+
index = id.to_i - 1
|
|
193
|
+
index < items.size && item_id(items[index]).nil? ? index : nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def mutation_log(name)
|
|
197
|
+
@logs[name] ||= load_ops(name)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def record_op(name, record)
|
|
201
|
+
mutation_log(name) << record
|
|
202
|
+
FileUtils.mkdir_p(data_root)
|
|
203
|
+
File.write(ops_path(name), JSON.generate(mutation_log(name)))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def load_ops(name)
|
|
207
|
+
return [] unless name.match?(SAFE_NAME_PATTERN)
|
|
208
|
+
|
|
209
|
+
path = ops_path(name)
|
|
210
|
+
return [] unless File.file?(path)
|
|
211
|
+
|
|
212
|
+
JSON.parse(File.read(path))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def ops_path(name)
|
|
216
|
+
unless name.match?(SAFE_NAME_PATTERN)
|
|
217
|
+
raise Error, "unsafe dataset name for the overlay: #{name}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
File.join(data_root, "#{name}.json")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def safe_content_path(content_relative)
|
|
224
|
+
segments = content_relative.split("/")
|
|
225
|
+
if segments.empty? || segments.any? { |segment| segment.empty? || segment == ".." }
|
|
226
|
+
raise Error, "unsafe content path: #{content_relative}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
File.join(content_root, content_relative)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def deep_dup(data) = JSON.parse(JSON.generate(data))
|
|
233
|
+
|
|
234
|
+
def load_tombstones
|
|
235
|
+
path = File.join(content_root, TOMBSTONE_FILE)
|
|
236
|
+
return Set.new unless File.file?(path)
|
|
237
|
+
|
|
238
|
+
Set.new(JSON.parse(File.read(path)))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def persist_tombstones
|
|
242
|
+
FileUtils.mkdir_p(content_root)
|
|
243
|
+
File.write(File.join(content_root, TOMBSTONE_FILE),
|
|
244
|
+
JSON.generate(@tombstones.to_a))
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "yaml"
|
|
8
|
+
|
|
9
|
+
module Capsium
|
|
10
|
+
class Reactor
|
|
11
|
+
# Folds a mounted package's base plus its overlay into a NEW
|
|
12
|
+
# versioned .cap (name-version+patch.cap) in the reactor workdir:
|
|
13
|
+
# overlay content files replace, tombstones delete, dataset mutation
|
|
14
|
+
# logs replay into the dataset files (JSON/YAML per the source
|
|
15
|
+
# extension). Manifest, routes and security.json are regenerated,
|
|
16
|
+
# so the saved package passes `capsium package validate`.
|
|
17
|
+
class PackageSaver
|
|
18
|
+
def initialize(mount)
|
|
19
|
+
@mount = mount
|
|
20
|
+
@package = mount.package
|
|
21
|
+
@overlay = mount.overlay
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns {path, sha256, name, version} of the saved .cap.
|
|
25
|
+
def save(workdir)
|
|
26
|
+
staging_parent = File.join(workdir, "saved")
|
|
27
|
+
FileUtils.mkdir_p(staging_parent)
|
|
28
|
+
stage = Dir.mktmpdir("fold-", staging_parent)
|
|
29
|
+
begin
|
|
30
|
+
fold_into(stage)
|
|
31
|
+
output = Packager.new.pack(Package.new(stage), { force: true })
|
|
32
|
+
{ "path" => output,
|
|
33
|
+
"sha256" => Digest::SHA256.file(output).hexdigest,
|
|
34
|
+
"name" => @package.name,
|
|
35
|
+
"version" => bumped_version }
|
|
36
|
+
ensure
|
|
37
|
+
FileUtils.remove_entry(stage)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fold_into(stage)
|
|
44
|
+
FileUtils.cp_r("#{@package.path}/.", stage)
|
|
45
|
+
apply_content_overlay(stage)
|
|
46
|
+
apply_dataset_overlays(stage)
|
|
47
|
+
write_bumped_metadata(stage)
|
|
48
|
+
# Regenerated at pack time from the folded tree.
|
|
49
|
+
FileUtils.rm_f(File.join(stage, Package::MANIFEST_FILE))
|
|
50
|
+
FileUtils.rm_f(File.join(stage, Package::ROUTES_FILE))
|
|
51
|
+
FileUtils.rm_f(File.join(stage, Package::SECURITY_FILE))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def apply_content_overlay(stage)
|
|
55
|
+
@overlay.tombstones.each do |relative|
|
|
56
|
+
FileUtils.rm_f(File.join(stage, Package::CONTENT_DIR, relative))
|
|
57
|
+
end
|
|
58
|
+
Dir.glob(File.join(@overlay.content_root, "**", "*"), File::FNM_DOTMATCH).each do |file|
|
|
59
|
+
next unless File.file?(file)
|
|
60
|
+
|
|
61
|
+
relative = file.delete_prefix("#{@overlay.content_root}/")
|
|
62
|
+
next if relative == Overlay::TOMBSTONE_FILE
|
|
63
|
+
|
|
64
|
+
target = File.join(stage, Package::CONTENT_DIR, relative)
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
66
|
+
FileUtils.cp(file, target)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def apply_dataset_overlays(stage)
|
|
71
|
+
@package.storage.datasets.each do |dataset|
|
|
72
|
+
next unless @overlay.mutations?(dataset.name)
|
|
73
|
+
|
|
74
|
+
write_merged_dataset(stage, dataset, @overlay.items(dataset))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write_merged_dataset(stage, dataset, merged)
|
|
79
|
+
target = File.join(stage, dataset.config.backing_file)
|
|
80
|
+
case File.extname(target).downcase
|
|
81
|
+
when ".yaml", ".yml" then File.write(target, YAML.dump(merged))
|
|
82
|
+
else File.write(target, JSON.pretty_generate(merged))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def bumped_version
|
|
87
|
+
version = Package::Version.parse(@package.metadata.version)
|
|
88
|
+
"#{version.major}.#{version.minor}.#{version.patch + 1}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write_bumped_metadata(stage)
|
|
92
|
+
path = File.join(stage, Package::METADATA_FILE)
|
|
93
|
+
metadata = JSON.parse(File.read(path))
|
|
94
|
+
metadata["version"] = bumped_version
|
|
95
|
+
File.write(path, JSON.pretty_generate(metadata))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Plain HTTP response writers shared by the reactor dispatch, the
|
|
8
|
+
# route serving and the API handlers (data, GraphQL, save).
|
|
9
|
+
module Responses
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def respond_not_found(response) = respond_text(response, 404, "Not Found")
|
|
13
|
+
|
|
14
|
+
def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
|
|
15
|
+
|
|
16
|
+
def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
|
|
17
|
+
|
|
18
|
+
def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
|
|
19
|
+
|
|
20
|
+
def respond_text(response, status, body)
|
|
21
|
+
response.status = status
|
|
22
|
+
response["Content-Type"] = "text/plain"
|
|
23
|
+
response.body = body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def respond_json(response, status, body)
|
|
27
|
+
response.status = status
|
|
28
|
+
response["Content-Type"] = "application/json"
|
|
29
|
+
response.body = JSON.generate(body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A JSON error body ("error" plus optional "messages" details) —
|
|
33
|
+
# the shape the data/write APIs answer client errors with.
|
|
34
|
+
def respond_error(response, status, message, messages: nil)
|
|
35
|
+
body = { error: message }
|
|
36
|
+
body[:messages] = messages if messages
|
|
37
|
+
respond_json(response, status, body)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -9,24 +9,103 @@ module Capsium
|
|
|
9
9
|
# datasets, handler-route 501s), mixed into Reactor. Includes route
|
|
10
10
|
# inheritance processing per 05x-routing.
|
|
11
11
|
module Serving
|
|
12
|
+
include Responses
|
|
13
|
+
|
|
12
14
|
private
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
# One request metric and one log line per served request.
|
|
17
|
+
def record_request(request, response)
|
|
18
|
+
status = response.status
|
|
19
|
+
return if status.nil?
|
|
20
|
+
|
|
21
|
+
@metrics.record(status)
|
|
22
|
+
@log_buffer.add("#{request.request_method} #{request.path} -> #{status}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The mount answering a request path: longest matching prefix wins.
|
|
26
|
+
def resolve_mount(path)
|
|
27
|
+
mounts_by_length.find { |mount| mount.matches?(path) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def mounts_by_length
|
|
31
|
+
@mounts_by_length ||= @mounts.sort_by { |mount| -mount.path.length }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The root ("/") mount, or the first mount when nothing is mounted
|
|
35
|
+
# at the root: its package drives the single-package readers
|
|
36
|
+
# (package, routes, merged_view), authentication and the
|
|
37
|
+
# reactor-level introspection.
|
|
38
|
+
def root_mount
|
|
39
|
+
@mounts.find { |mount| mount.path == Mount::ROOT_PATH } || @mounts.first
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Serves a request that matched a mount: dataset CRUD and content
|
|
43
|
+
# writes go to the mount's APIs, everything else resolves against
|
|
44
|
+
# the mount's routes. Route-level access control is enforced for
|
|
45
|
+
# the addressed route in every case.
|
|
46
|
+
def serve_mounted_request(mount, identity, request, response)
|
|
47
|
+
inner = mount.inner_path(request.path)
|
|
48
|
+
return serve_data_api(mount, identity, inner, request, response) if DataApi.path?(inner)
|
|
49
|
+
if GraphqlApi.path?(inner) && mount.graphql?
|
|
50
|
+
return mount.graphql_api.handle(request, response)
|
|
51
|
+
end
|
|
52
|
+
if ContentApi.write_method?(request.request_method)
|
|
53
|
+
return serve_content_api(mount, identity, inner, request, response)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
route = mount.routes.resolve(inner)
|
|
57
|
+
return respond_not_found(response) unless route
|
|
58
|
+
return unless authorized?(identity, route, response)
|
|
59
|
+
|
|
60
|
+
serve_route(mount, route, response)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serve_data_api(mount, identity, inner, request, response)
|
|
64
|
+
route = mount.data_api.route_for(inner)
|
|
65
|
+
return respond_not_found(response) unless route
|
|
66
|
+
return unless authorized?(identity, route, response)
|
|
67
|
+
|
|
68
|
+
mount.data_api.handle(inner, request, response)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def serve_content_api(mount, identity, inner, request, response)
|
|
72
|
+
route = mount.routes.resolve(inner)
|
|
73
|
+
return unless !route || authorized?(identity, route, response)
|
|
74
|
+
|
|
75
|
+
mount.content_api.handle(inner, request, response)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Route-level access control (05x-authentication): writes the
|
|
79
|
+
# challenge/forbidden response and returns false when the
|
|
80
|
+
# identity may not proceed.
|
|
81
|
+
def authorized?(identity, route, response)
|
|
82
|
+
case @authenticator.authorize(identity, route.access_control)
|
|
83
|
+
when :unauthenticated
|
|
84
|
+
@authenticator.challenge(response)
|
|
85
|
+
false
|
|
86
|
+
when :forbidden
|
|
87
|
+
respond_forbidden(response)
|
|
88
|
+
false
|
|
89
|
+
else true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def serve_route(mount, route, response)
|
|
15
94
|
case route.kind
|
|
16
|
-
when :dataset then serve_dataset(route.dataset, response)
|
|
17
|
-
when :resource then serve_file(route, response)
|
|
95
|
+
when :dataset then serve_dataset(mount, route.dataset, response)
|
|
96
|
+
when :resource then serve_file(mount, route, response)
|
|
18
97
|
else respond_not_implemented(response)
|
|
19
98
|
end
|
|
20
99
|
end
|
|
21
100
|
|
|
22
|
-
def serve_file(route, response)
|
|
23
|
-
content_path =
|
|
101
|
+
def serve_file(mount, route, response)
|
|
102
|
+
content_path = mount.merged_view.resolve(route.resource)
|
|
24
103
|
return respond_not_found(response) unless content_path
|
|
25
104
|
|
|
26
105
|
body, headers = inherited_processing(route, File.read(content_path),
|
|
27
106
|
headers_for(route))
|
|
28
107
|
response.status = 200
|
|
29
|
-
response["Content-Type"] = content_type_for(route, content_path)
|
|
108
|
+
response["Content-Type"] = content_type_for(mount, route, content_path)
|
|
30
109
|
headers.each { |name, value| response[name] = value }
|
|
31
110
|
response.body = body
|
|
32
111
|
end
|
|
@@ -50,8 +129,8 @@ module Capsium
|
|
|
50
129
|
# The route's declared MIME type, detected from the resolved file
|
|
51
130
|
# for resources the manifest does not list (dependency content
|
|
52
131
|
# reached through inheritance, layer-only files).
|
|
53
|
-
def content_type_for(route, content_path)
|
|
54
|
-
route.mime(
|
|
132
|
+
def content_type_for(mount, route, content_path)
|
|
133
|
+
route.mime(mount.package.manifest) ||
|
|
55
134
|
Marcel::MimeType.for(Pathname.new(content_path),
|
|
56
135
|
name: File.basename(content_path))
|
|
57
136
|
end
|
|
@@ -60,12 +139,12 @@ module Capsium
|
|
|
60
139
|
route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
|
|
61
140
|
end
|
|
62
141
|
|
|
63
|
-
def serve_dataset(dataset_name, response)
|
|
64
|
-
dataset =
|
|
142
|
+
def serve_dataset(mount, dataset_name, response)
|
|
143
|
+
dataset = mount.package.storage.dataset(dataset_name)
|
|
65
144
|
if dataset
|
|
66
145
|
response.status = 200
|
|
67
146
|
response["Content-Type"] = "application/json"
|
|
68
|
-
response.body = JSON.generate(dataset
|
|
147
|
+
response.body = JSON.generate(mount.dataset_data(dataset))
|
|
69
148
|
else
|
|
70
149
|
respond_not_found(response)
|
|
71
150
|
end
|