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
@@ -26,6 +26,7 @@ module Capsium
26
26
 
27
27
  def run
28
28
  Dir.mktmpdir do |dir|
29
+ @workdir = dir
29
30
  @package_path = prepare(dir)
30
31
  [metadata_check, manifest_check, routes_check,
31
32
  storage_check, security_check, content_check]
@@ -113,15 +114,39 @@ module Capsium
113
114
 
114
115
  # The merged content view (ARCHITECTURE.md section 5a) over the
115
116
  # package being validated; layered and tombstoned resources resolve
116
- # the same way the reactor resolves them.
117
+ # the same way the reactor resolves them. For encapsulated
118
+ # packages the bundled dependencies (packages/, recursively) act
119
+ # as read-only exported layers exactly as at activation, so
120
+ # fallthrough routes referencing dependency content validate.
117
121
  def merged_view
118
122
  @merged_view ||= MergedView.new(
119
123
  @package_path,
120
124
  storage: Storage.new(metadata_path(STORAGE_FILE)),
121
- manifest: Manifest.new(metadata_path(MANIFEST_FILE))
125
+ manifest: Manifest.new(metadata_path(MANIFEST_FILE)),
126
+ dependency_views: dependency_views(@package_path)
122
127
  )
123
128
  end
124
129
 
130
+ # The [guid, exported MergedView] layers of the dependencies
131
+ # bundled under the package directory's packages/ (empty when the
132
+ # package embeds no bundle).
133
+ def dependency_views(package_path)
134
+ bundle = Bundle.new(package_path)
135
+ return [] unless bundle.present?
136
+
137
+ bundle.entries.map do |guid, entry|
138
+ dependency_dir = File.join(@workdir, "bundles", guid.gsub(/[^A-Za-z0-9.-]/, "_"))
139
+ Packager.new.unpack(entry.file, dependency_dir)
140
+ [guid, MergedView.new(
141
+ dependency_dir,
142
+ storage: Storage.new(File.join(dependency_dir, STORAGE_FILE)),
143
+ manifest: Manifest.new(File.join(dependency_dir, MANIFEST_FILE)),
144
+ dependency_views: dependency_views(dependency_dir),
145
+ exported_only: true
146
+ )]
147
+ end
148
+ end
149
+
125
150
  def security_check
126
151
  security = Security.new(metadata_path(SECURITY_FILE))
127
152
  return result("security", []) unless security.present?
@@ -19,13 +19,15 @@ module Capsium
19
19
  # Whether security.json declares a digital signature for this package.
20
20
  def signed? = @security.signed?
21
21
 
22
- # Verifies the declared digital signature (RSA-SHA256) against the
23
- # checksum-covered payload. True when the package is unsigned (nothing
24
- # declared) or the signature verifies; false on mismatch.
25
- def verify_signature = !signed? || Signer.new(@path).verify
22
+ # Verifies the declared digital signature against the
23
+ # checksum-covered payload, through the signer matching the
24
+ # declared certificateType (OpenPgpSigner for OpenPGP, the
25
+ # RSA-SHA256 Signer otherwise). True when the package is unsigned
26
+ # (nothing declared) or the signature verifies; false on mismatch.
27
+ def verify_signature = !signed? || Signer.signer_class_for(@path).new(@path).verify
26
28
 
27
29
  def verify_signature!
28
- Signer.new(@path).verify! if signed?
30
+ Signer.signer_class_for(@path).new(@path).verify! if signed?
29
31
  end
30
32
  end
31
33
  end
@@ -13,6 +13,7 @@ module Capsium
13
13
  autoload :AuthenticationConfig, "capsium/package/authentication_config"
14
14
  autoload :AuthenticationData, "capsium/package/authentication_config"
15
15
  autoload :BasicAuthConfig, "capsium/package/authentication_config"
16
+ autoload :Bundle, "capsium/package/bundle"
16
17
  autoload :Dataset, "capsium/package/dataset"
17
18
  autoload :DatasetConfig, "capsium/package/storage_config"
18
19
  autoload :DependencyError, "capsium/package/dependency_resolver"
@@ -30,6 +31,9 @@ module Capsium
30
31
  autoload :Metadata, "capsium/package/metadata"
31
32
  autoload :MetadataData, "capsium/package/metadata_config"
32
33
  autoload :OAuth2Config, "capsium/package/authentication_config"
34
+ autoload :OpenPgp, "capsium/package/open_pgp"
35
+ autoload :OpenPgpCipher, "capsium/package/open_pgp_cipher"
36
+ autoload :OpenPgpSigner, "capsium/package/open_pgp_signer"
33
37
  autoload :Preparation, "capsium/package/preparation"
34
38
  autoload :Repository, "capsium/package/metadata_config"
35
39
  autoload :ResolvedDependency, "capsium/package/dependency_resolver"
@@ -73,10 +77,16 @@ module Capsium
73
77
  # Loads a package directory or .cap file. Composite packages
74
78
  # (metadata.dependencies, ARCHITECTURE.md section 4a) resolve against
75
79
  # the package store given as `store` (directory path or Store) or via
76
- # CAPSIUM_STORE. `dependency_chain` is internal: the ancestor GUIDs
77
- # used for circular-dependency detection during recursive resolution.
80
+ # CAPSIUM_STORE, falling back to the registry given as `registry`
81
+ # (Capsium::Registry or reference) or via CAPSIUM_REGISTRY when the
82
+ # store has no package for a dependency GUID. Dependencies bundled
83
+ # under packages/ (encapsulated packages, Capsium::Package::Bundle)
84
+ # resolve first, without any store or registry. `dependency_chain`
85
+ # is internal: the ancestor GUIDs used for circular-dependency
86
+ # detection during recursive resolution. `bundle` is internal too:
87
+ # the ancestor bundle serving re-declared transitive dependencies.
78
88
  def initialize(path, load_type: nil, decryption_key: nil, store: nil,
79
- dependency_chain: [])
89
+ registry: nil, dependency_chain: [], bundle: nil)
80
90
  @decryption_key = decryption_key
81
91
  @original_path = Pathname.new(path).expand_path
82
92
  @path = prepare_package(@original_path).to_s
@@ -84,7 +94,7 @@ module Capsium
84
94
  create_package_structure
85
95
  load_package
86
96
  @name = metadata.name
87
- @resolved_dependencies = resolve_dependencies(store, dependency_chain)
97
+ @resolved_dependencies = resolve_dependencies(store, registry, dependency_chain, bundle)
88
98
  validate_dependency_references!
89
99
  verify_integrity!
90
100
  verify_signature!
@@ -119,7 +129,16 @@ module Capsium
119
129
  # The merged content view across the storage layers (ARCHITECTURE.md
120
130
  # section 5a); exported_only gives the dependent-package view (section 4a).
121
131
  # Resolved dependencies act as read-only layers below all own layers.
122
- def merged_view(exported_only: false)
132
+ # extra_layers stack above everything (the reactor's writable overlay);
133
+ # views with extra layers are built fresh, not memoized.
134
+ def merged_view(exported_only: false, extra_layers: [])
135
+ unless extra_layers.empty?
136
+ return MergedView.new(@path, storage: @storage, manifest: @manifest,
137
+ dependency_views: dependency_views,
138
+ exported_only: exported_only,
139
+ extra_layers: extra_layers)
140
+ end
141
+
123
142
  @merged_views ||= {}
124
143
  @merged_views[exported_only] ||=
125
144
  MergedView.new(@path, storage: @storage, manifest: @manifest,
@@ -16,31 +16,16 @@ module Capsium
16
16
  class UnsafeEntryError < Capsium::Error; end
17
17
 
18
18
  DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
19
+ DOT_ENTRIES = [".", ".."].freeze
19
20
 
20
21
  def pack(package, options = {})
21
- directory = package.path
22
22
  output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
23
- output_directory = File.dirname(directory)
24
- cap_file_path = File.join(output_directory, output_file_name)
25
-
26
- if File.exist?(cap_file_path) && !options[:force]
27
- raise FileAlreadyExistsError,
28
- "Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
29
- elsif File.exist?(cap_file_path)
30
- puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
31
- FileUtils.rm_f(cap_file_path)
32
- end
23
+ cap_file_path = File.join(File.dirname(package.path), output_file_name)
24
+ check_target(cap_file_path, options)
33
25
 
34
26
  Dir.mktmpdir do |dir|
35
- FileUtils.cp_r("#{directory}/.", dir)
36
- strip_security_artifacts(dir)
37
- new_package = Package.new(dir)
38
- new_package.solidify
39
- generate_security(new_package)
40
- new_cap_file_path = File.join(dir, output_file_name)
41
- compress_package(new_package, new_cap_file_path)
42
- puts "Package built at: #{new_cap_file_path}"
43
- FileUtils.mv(new_cap_file_path, cap_file_path)
27
+ build_cap(package, dir, output_file_name, options)
28
+ FileUtils.mv(File.join(dir, output_file_name), cap_file_path)
44
29
  puts "Package created: #{relative_path_current(cap_file_path)}"
45
30
  return cap_file_path
46
31
  end
@@ -58,8 +43,9 @@ module Capsium
58
43
  end
59
44
 
60
45
  def compress_package(package, cap_file)
61
- entries = Dir[File.join(package.path, "**", "**")].reject do |file|
62
- File.expand_path(file) == File.expand_path(cap_file)
46
+ entries = Dir.glob(File.join(package.path, "**", "**"), File::FNM_DOTMATCH).reject do |file|
47
+ File.expand_path(file) == File.expand_path(cap_file) ||
48
+ DOT_ENTRIES.include?(File.basename(file))
63
49
  end
64
50
  Zip::File.open(cap_file, create: true) do |zipfile|
65
51
  entries.each do |file|
@@ -99,6 +85,62 @@ module Capsium
99
85
 
100
86
  private
101
87
 
88
+ # Guards the pack target: without :force an existing .cap aborts,
89
+ # with :force it is removed first.
90
+ def check_target(cap_file_path, options)
91
+ if File.exist?(cap_file_path) && !options[:force]
92
+ raise FileAlreadyExistsError,
93
+ "Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
94
+ end
95
+ return unless File.exist?(cap_file_path)
96
+
97
+ puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
98
+ FileUtils.rm_f(cap_file_path)
99
+ end
100
+
101
+ # Builds the .cap inside the temporary directory: copy, optional
102
+ # dependency bundling, solidify, security.json, compress.
103
+ def build_cap(package, directory, output_file_name, options)
104
+ FileUtils.cp_r("#{package.path}/.", directory)
105
+ strip_security_artifacts(directory)
106
+ bundle_dependencies(directory, options) if options[:bundle_deps]
107
+ new_package = load_packed_package(directory, options)
108
+ new_package.solidify
109
+ generate_security(new_package)
110
+ built_path = File.join(directory, output_file_name)
111
+ compress_package(new_package, built_path)
112
+ puts "Package built at: #{built_path}"
113
+ end
114
+
115
+ # Loads the copied package directory for solidification, with the
116
+ # pack-time store/registry available for dependency resolution.
117
+ def load_packed_package(directory, options)
118
+ Package.new(directory, store: options[:store], registry: options[:registry])
119
+ end
120
+
121
+ # Encapsulated packing (`pack --bundle-deps`): resolves every
122
+ # declared dependency through the store -> registry chain and embeds
123
+ # the resolved .cap files under packages/ (Capsium::Package::Bundle),
124
+ # so the packed package activates with no store or registry. Only
125
+ # the declared dependencies are bundled — the one-level policy: the
126
+ # parent's metadata.dependencies must list the transitive closure.
127
+ # Typed dependency errors (DependencyNotFoundError,
128
+ # UnsatisfiableDependencyError) propagate for unresolvable
129
+ # dependencies.
130
+ def bundle_dependencies(directory, options)
131
+ metadata = Package::Metadata.new(File.join(directory, Package::METADATA_FILE))
132
+ declared = metadata.dependencies
133
+ return if declared.empty?
134
+
135
+ resolver = Package::DependencyResolver.new(
136
+ options[:store] || Package::Store.default, registry: options[:registry]
137
+ )
138
+ resolutions = declared.map do |guid, range|
139
+ [guid, resolver.resolve_path(guid, range, chain: [metadata.guid])]
140
+ end
141
+ Package::Bundle.write(directory, resolutions)
142
+ end
143
+
102
144
  # Resolves an entry name against the destination and returns the
103
145
  # absolute target path, raising UnsafeEntryError when the entry would
104
146
  # escape the destination (zip-slip).
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Capsium
6
+ class Reactor
7
+ # Content writes over the mount's overlay (ARCHITECTURE.md section
8
+ # 5a): PUT creates/overwrites a content file (creating its route on
9
+ # demand), DELETE records a tombstone so the path 404s even when a
10
+ # lower layer holds the file. The immutable package never changes
11
+ # on disk. Text bodies only for v1.
12
+ class ContentApi
13
+ include Responses
14
+
15
+ WRITE_METHODS = %w[PUT DELETE].freeze
16
+ CONTENT_PREFIX = "#{Package::CONTENT_DIR}/".freeze
17
+
18
+ def self.write_method?(method)
19
+ WRITE_METHODS.include?(method)
20
+ end
21
+
22
+ def initialize(mount)
23
+ @mount = mount
24
+ end
25
+
26
+ def handle(inner_path, request, response)
27
+ return read_only(response) unless @mount.writable?
28
+
29
+ route = @mount.routes.resolve(inner_path)
30
+ return guard_route(route, inner_path, response) if route && !writable_route?(route)
31
+
32
+ case request.request_method
33
+ when "PUT" then put_content(inner_path, route, request, response)
34
+ when "DELETE" then delete_content(inner_path, route, response)
35
+ else respond_method_not_allowed(response)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # A route is writable when it is a plain resource route (or no
42
+ # route at all): dataset paths are the DataApi's, handler routes
43
+ # are not writable, dependency content stays immutable.
44
+ def writable_route?(route)
45
+ route.kind == :resource && !route.dependency_reference?
46
+ end
47
+
48
+ def guard_route(route, inner_path, response)
49
+ return respond_method_not_allowed(response) if route.kind == :dataset
50
+
51
+ respond_error(response, 400, "cannot write #{route.kind} content: #{inner_path}")
52
+ end
53
+
54
+ def put_content(inner_path, route, request, response)
55
+ relative = content_relative(resource_path(inner_path, route))
56
+ return unsafe_path(inner_path, response) if relative.nil?
57
+
58
+ @mount.overlay.write_content(relative, request.body.to_s)
59
+ resource = "#{Package::CONTENT_DIR}/#{relative}"
60
+ @mount.package.routes.add_route(inner_path, resource) unless route
61
+ respond_json(response, 200, { path: inner_path, resource: resource })
62
+ rescue Overlay::Error => e
63
+ respond_error(response, 400, e.message)
64
+ end
65
+
66
+ # Tombstones the served resource: 404 when the path is already
67
+ # tombstoned or nothing (here or in a lower layer) serves it.
68
+ def delete_content(inner_path, route, response)
69
+ relative = content_relative(resource_path(inner_path, route))
70
+ return unsafe_path(inner_path, response) if relative.nil?
71
+ return respond_not_found(response) if @mount.overlay.tombstones.include?(relative)
72
+ unless @mount.merged_view.resolve("#{Package::CONTENT_DIR}/#{relative}")
73
+ return respond_not_found(response)
74
+ end
75
+
76
+ @mount.overlay.delete_content(relative)
77
+ response.status = 204
78
+ rescue Overlay::Error => e
79
+ respond_error(response, 400, e.message)
80
+ end
81
+
82
+ def unsafe_path(inner_path, response)
83
+ respond_error(response, 400, "unsafe content path: #{inner_path}")
84
+ end
85
+
86
+ def read_only(response)
87
+ respond_error(response, 403, "package #{@mount.package.name} is read-only")
88
+ end
89
+
90
+ # The package resource a write addresses: the existing route's
91
+ # resource (overwrite what GET serves), else a content file
92
+ # derived from the URL path (created on demand).
93
+ def resource_path(inner_path, route)
94
+ route ? route.resource : "#{Package::CONTENT_DIR}/#{inner_path.delete_prefix('/')}"
95
+ end
96
+
97
+ # The content/-relative path for a package resource, nil for
98
+ # unsafe paths (outside content/, dot segments).
99
+ def content_relative(resource_path)
100
+ return nil unless resource_path.start_with?(CONTENT_PREFIX)
101
+
102
+ relative = resource_path.delete_prefix(CONTENT_PREFIX)
103
+ segments = relative.split("/")
104
+ return nil if segments.empty? || segments.any? { |s| s.empty? || s == ".." }
105
+
106
+ relative
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Capsium
6
+ class Reactor
7
+ # REST CRUD over a mount's datasets (ARCHITECTURE.md sections 5 and
8
+ # 5a): JSON item append/read/replace/delete against the overlay-
9
+ # merged collection. Writes go only to the mount's writable overlay;
10
+ # the immutable package never changes on disk.
11
+ class DataApi
12
+ include Responses
13
+
14
+ PREFIX = "/api/v1/data/"
15
+ COLLECTION_PATTERN = %r{\A/api/v1/data/(?<dataset>[^/]+)\z}
16
+ ITEM_PATTERN = %r{\A/api/v1/data/(?<dataset>[^/]+)/(?<id>[^/]+)\z}
17
+
18
+ def self.path?(inner_path)
19
+ inner_path.start_with?(PREFIX)
20
+ end
21
+
22
+ def initialize(mount)
23
+ @mount = mount
24
+ end
25
+
26
+ # The declared dataset route a data path addresses (the collection
27
+ # route, also for item paths), or nil when the package does not
28
+ # route this dataset — undeclared datasets are not served.
29
+ def route_for(inner_path)
30
+ match = COLLECTION_PATTERN.match(inner_path) || ITEM_PATTERN.match(inner_path)
31
+ return nil unless match
32
+
33
+ route = @mount.routes.resolve("#{PREFIX}#{match[:dataset]}")
34
+ route if route&.dataset == match[:dataset]
35
+ end
36
+
37
+ def handle(inner_path, request, response)
38
+ route = route_for(inner_path)
39
+ return respond_not_found(response) unless route
40
+
41
+ dataset = @mount.package.storage.dataset(route.dataset)
42
+ return respond_not_found(response) unless dataset
43
+
44
+ collection = COLLECTION_PATTERN.match(inner_path)
45
+ if collection
46
+ handle_collection(dataset, request, response)
47
+ else
48
+ handle_item(dataset, ITEM_PATTERN.match(inner_path)[:id], request, response)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def handle_collection(dataset, request, response)
55
+ case request.request_method
56
+ when "GET" then respond_json(response, 200, @mount.dataset_data(dataset))
57
+ when "POST" then append_item(dataset, request, response)
58
+ else respond_method_not_allowed(response)
59
+ end
60
+ end
61
+
62
+ def handle_item(dataset, id, request, response)
63
+ case request.request_method
64
+ when "GET" then read_item(dataset, id, response)
65
+ when "PUT" then replace_item(dataset, id, request, response)
66
+ when "DELETE" then delete_item(dataset, id, response)
67
+ else respond_method_not_allowed(response)
68
+ end
69
+ end
70
+
71
+ def append_item(dataset, request, response)
72
+ return unless writable?(dataset, response)
73
+
74
+ item = parse_body(request, response)
75
+ return if item.nil?
76
+
77
+ id = @mount.overlay.append_item(dataset, item)
78
+ response["Location"] = "#{request.path}/#{id}"
79
+ respond_json(response, 201, item)
80
+ rescue Overlay::Error => e
81
+ respond_overlay_error(response, e)
82
+ end
83
+
84
+ def read_item(dataset, id, response)
85
+ respond_json(response, 200, @mount.overlay.item(dataset, id))
86
+ rescue Overlay::ItemNotFoundError
87
+ respond_not_found(response)
88
+ end
89
+
90
+ def replace_item(dataset, id, request, response)
91
+ return unless writable?(dataset, response)
92
+
93
+ item = parse_body(request, response)
94
+ return if item.nil?
95
+
96
+ @mount.overlay.replace_item(dataset, id, item)
97
+ respond_json(response, 200, item)
98
+ rescue Overlay::ItemNotFoundError
99
+ respond_not_found(response)
100
+ rescue Overlay::Error => e
101
+ respond_overlay_error(response, e)
102
+ end
103
+
104
+ def delete_item(dataset, id, response)
105
+ return unless writable?(dataset, response)
106
+
107
+ @mount.overlay.delete_item(dataset, id)
108
+ response.status = 204
109
+ rescue Overlay::ItemNotFoundError
110
+ respond_not_found(response)
111
+ end
112
+
113
+ # Write guards: 403 for a read-only mount, 501 for a SQLite
114
+ # dataset (not file-backed JSON/YAML).
115
+ def writable?(dataset, response)
116
+ unless @mount.writable?
117
+ respond_error(response, 403, "package #{@mount.package.name} is read-only")
118
+ return false
119
+ end
120
+ if dataset.config.sqlite?
121
+ respond_error(response, 501,
122
+ "dataset #{dataset.name} is SQLite; writes are not supported")
123
+ return false
124
+ end
125
+ true
126
+ end
127
+
128
+ # The JSON request body, or nil after answering 400 (invalid JSON)
129
+ # or 422 (a null body cannot be an item).
130
+ def parse_body(request, response)
131
+ body = JSON.parse(request.body.to_s)
132
+ if body.nil?
133
+ respond_error(response, 422, "request body must not be null")
134
+ return nil
135
+ end
136
+
137
+ body
138
+ rescue JSON::ParserError => e
139
+ respond_error(response, 400, "invalid JSON body: #{e.message}")
140
+ nil
141
+ end
142
+
143
+ def respond_overlay_error(response, error)
144
+ case error
145
+ when Overlay::ConflictError then respond_error(response, 409, error.message)
146
+ when Overlay::SchemaViolationError
147
+ respond_error(response, 422, "schema validation failed",
148
+ messages: error.messages)
149
+ else respond_error(response, 422, error.message)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Capsium
6
+ class Reactor
7
+ # Serving of the reactor-level endpoints (introspection reports,
8
+ # per-package save), mixed into Reactor.
9
+ module Endpoints
10
+ include Responses
11
+
12
+ private
13
+
14
+ def serve_introspection(request, response)
15
+ return respond_method_not_allowed(response) unless request.request_method == "GET"
16
+
17
+ report = @introspection.report_for(request.path, params: request.query)
18
+ return respond_not_found(response) if report.nil?
19
+
20
+ respond_json(response, 200, report)
21
+ end
22
+
23
+ # POST /package/<name>/save: folds the mounted package's base plus
24
+ # its overlay into a new versioned .cap in the workdir.
25
+ def serve_package_save(request, response)
26
+ return respond_method_not_allowed(response) unless request.request_method == "POST"
27
+
28
+ name = PACKAGE_SAVE_PATTERN.match(request.path)[:name]
29
+ mount = @mounts.find { |candidate| candidate.package.name == name }
30
+ return respond_not_found(response) unless mount
31
+ return respond_error(response, 403, "package #{name} is read-only") unless mount.writable?
32
+
33
+ respond_json(response, 200, PackageSaver.new(mount).save(@workdir))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql"
4
+ require "json"
5
+
6
+ module Capsium
7
+ class Reactor
8
+ # GraphQL over a mount's datasets (POST and GET <mount>/graphql):
9
+ # executes the schema GraphqlSchema derives from the package's
10
+ # storage and maps the overlay's typed errors into the "errors"
11
+ # array (never a 500 on user error). Query fields read the merged
12
+ # collection; mutations match the REST semantics, schema validation
13
+ # included.
14
+ class GraphqlApi
15
+ include Responses
16
+
17
+ PATH = "/graphql"
18
+
19
+ def self.path?(inner_path)
20
+ inner_path == PATH
21
+ end
22
+
23
+ def initialize(mount)
24
+ @mount = mount
25
+ @schema = GraphqlSchema.new(mount, self).build
26
+ end
27
+
28
+ def handle(request, response)
29
+ case request.request_method
30
+ when "POST" then handle_post(request, response)
31
+ when "GET" then handle_get(request, response)
32
+ else respond_method_not_allowed(response)
33
+ end
34
+ end
35
+
36
+ # Query/mutation resolvers (public; called from the derived
37
+ # schema's fields).
38
+ def collection(dataset, id: nil)
39
+ return @mount.overlay.items(dataset) if id.nil?
40
+
41
+ [@mount.overlay.item(dataset, id)]
42
+ rescue Overlay::Error => e
43
+ raise execution_error(e)
44
+ end
45
+
46
+ def create_item(dataset, item:)
47
+ ensure_writable!
48
+ @mount.overlay.append_item(dataset, item)
49
+ item
50
+ rescue Overlay::Error => e
51
+ raise execution_error(e)
52
+ end
53
+
54
+ def update_item(dataset, id:, item:)
55
+ ensure_writable!
56
+ @mount.overlay.replace_item(dataset, id, item)
57
+ item
58
+ rescue Overlay::Error => e
59
+ raise execution_error(e)
60
+ end
61
+
62
+ def remove_item(dataset, id:)
63
+ ensure_writable!
64
+ @mount.overlay.delete_item(dataset, id)
65
+ true
66
+ rescue Overlay::Error => e
67
+ raise execution_error(e)
68
+ end
69
+
70
+ private
71
+
72
+ def handle_post(request, response)
73
+ payload = JSON.parse(request.body.to_s)
74
+ unless payload.is_a?(Hash) && payload["query"].is_a?(String)
75
+ return respond_error(response, 400, "GraphQL POST body must be " \
76
+ '{"query": "...", "variables": {...}}')
77
+ end
78
+
79
+ execute(response, payload["query"], variables: payload["variables"],
80
+ operation_name: payload["operationName"])
81
+ rescue JSON::ParserError => e
82
+ respond_error(response, 400, "invalid JSON body: #{e.message}")
83
+ end
84
+
85
+ def handle_get(request, response)
86
+ query = request.query["query"]
87
+ return respond_error(response, 400, "missing GraphQL query") if query.nil?
88
+
89
+ execute(response, query, variables: parse_variables(request.query["variables"]),
90
+ operation_name: request.query["operationName"])
91
+ end
92
+
93
+ def parse_variables(raw)
94
+ return nil if raw.nil?
95
+
96
+ JSON.parse(raw)
97
+ rescue JSON::ParserError
98
+ nil
99
+ end
100
+
101
+ def execute(response, query, variables:, operation_name:)
102
+ result = @schema.execute(query, variables: variables || {},
103
+ operation_name: operation_name)
104
+ respond_json(response, 200, result.to_h)
105
+ end
106
+
107
+ def ensure_writable!
108
+ return if @mount.writable?
109
+
110
+ raise GraphQL::ExecutionError, "package #{@mount.package.name} is read-only"
111
+ end
112
+
113
+ def execution_error(error)
114
+ message = if error.is_a?(Overlay::SchemaViolationError)
115
+ error.messages.join("; ")
116
+ else
117
+ error.message
118
+ end
119
+ GraphQL::ExecutionError.new(message)
120
+ end
121
+ end
122
+ end
123
+ end