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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -3
  3. data/CHANGELOG.md +197 -0
  4. data/README.adoc +323 -14
  5. data/capsium.gemspec +2 -0
  6. data/lib/capsium/cli/package.rb +62 -25
  7. data/lib/capsium/cli/reactor.rb +112 -6
  8. data/lib/capsium/cli.rb +29 -0
  9. data/lib/capsium/log_buffer.rb +47 -0
  10. data/lib/capsium/package/bundle.rb +136 -0
  11. data/lib/capsium/package/cipher.rb +73 -17
  12. data/lib/capsium/package/composition.rb +28 -12
  13. data/lib/capsium/package/dataset.rb +20 -0
  14. data/lib/capsium/package/dependency_resolver.rb +39 -9
  15. data/lib/capsium/package/encryption_config.rb +7 -3
  16. data/lib/capsium/package/manifest.rb +1 -1
  17. data/lib/capsium/package/merged_view.rb +4 -2
  18. data/lib/capsium/package/metadata.rb +1 -1
  19. data/lib/capsium/package/metadata_config.rb +9 -1
  20. data/lib/capsium/package/open_pgp.rb +65 -0
  21. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  22. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  23. data/lib/capsium/package/security_config.rb +6 -2
  24. data/lib/capsium/package/signer.rb +18 -3
  25. data/lib/capsium/package/store.rb +27 -0
  26. data/lib/capsium/package/validator.rb +27 -2
  27. data/lib/capsium/package/verification.rb +7 -5
  28. data/lib/capsium/package.rb +24 -5
  29. data/lib/capsium/packager.rb +64 -22
  30. data/lib/capsium/reactor/content_api.rb +110 -0
  31. data/lib/capsium/reactor/data_api.rb +154 -0
  32. data/lib/capsium/reactor/endpoints.rb +37 -0
  33. data/lib/capsium/reactor/graphql_api.rb +123 -0
  34. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  35. data/lib/capsium/reactor/introspection.rb +151 -34
  36. data/lib/capsium/reactor/metrics.rb +30 -0
  37. data/lib/capsium/reactor/mount.rb +206 -0
  38. data/lib/capsium/reactor/overlay.rb +248 -0
  39. data/lib/capsium/reactor/package_saver.rb +99 -0
  40. data/lib/capsium/reactor/responses.rb +41 -0
  41. data/lib/capsium/reactor/serving.rb +90 -11
  42. data/lib/capsium/reactor.rb +96 -49
  43. data/lib/capsium/registry/local.rb +120 -0
  44. data/lib/capsium/registry/remote.rb +105 -0
  45. data/lib/capsium/registry.rb +190 -0
  46. data/lib/capsium/version.rb +1 -1
  47. data/lib/capsium.rb +2 -0
  48. data/sig/capsium/log_buffer.rbs +30 -0
  49. data/sig/capsium/package/bundle.rbs +43 -0
  50. data/sig/capsium/package/cipher.rbs +12 -1
  51. data/sig/capsium/package/composition.rbs +4 -3
  52. data/sig/capsium/package/dataset.rbs +8 -0
  53. data/sig/capsium/package/dependency_resolver.rbs +8 -4
  54. data/sig/capsium/package/encryption_config.rbs +6 -2
  55. data/sig/capsium/package/merged_view.rbs +2 -1
  56. data/sig/capsium/package/metadata.rbs +1 -0
  57. data/sig/capsium/package/open_pgp.rbs +38 -0
  58. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  59. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  60. data/sig/capsium/package/security_config.rbs +6 -3
  61. data/sig/capsium/package/signer.rbs +8 -1
  62. data/sig/capsium/package/store.rbs +6 -0
  63. data/sig/capsium/package/verification.rbs +5 -3
  64. data/sig/capsium/package.rbs +8 -3
  65. data/sig/capsium/reactor/content_api.rbs +19 -0
  66. data/sig/capsium/reactor/data_api.rbs +23 -0
  67. data/sig/capsium/reactor/endpoints.rbs +16 -0
  68. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  69. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  70. data/sig/capsium/reactor/introspection.rbs +27 -7
  71. data/sig/capsium/reactor/metrics.rbs +13 -0
  72. data/sig/capsium/reactor/mount.rbs +83 -0
  73. data/sig/capsium/reactor/overlay.rbs +72 -0
  74. data/sig/capsium/reactor/package_saver.rbs +12 -0
  75. data/sig/capsium/reactor/responses.rbs +23 -0
  76. data/sig/capsium/reactor/serving.rbs +28 -4
  77. data/sig/capsium/reactor.rbs +17 -2
  78. data/sig/capsium/registry/local.rbs +16 -0
  79. data/sig/capsium/registry/remote.rbs +18 -0
  80. data/sig/capsium/registry.rbs +83 -0
  81. data/sig/graphql.rbs +12 -0
  82. 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
- def serve_route(route, response)
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 = @merged_view.resolve(route.resource)
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(@package.manifest) ||
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 = @package.storage.dataset(dataset_name)
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.data)
147
+ response.body = JSON.generate(mount.dataset_data(dataset))
69
148
  else
70
149
  respond_not_found(response)
71
150
  end