capsium 0.1.1 → 0.2.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +1 -4
  3. data/.github/workflows/release.yml +1 -2
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +34 -3
  6. data/CHANGELOG.md +58 -0
  7. data/Gemfile +4 -4
  8. data/LICENSE +21 -0
  9. data/README.adoc +177 -135
  10. data/Rakefile +8 -1
  11. data/bin/console +1 -1
  12. data/capsium.gemspec +16 -23
  13. data/exe/capsium +1 -1
  14. data/lib/capsium/cli/convert.rb +18 -0
  15. data/lib/capsium/cli/package.rb +86 -0
  16. data/lib/capsium/cli/reactor.rb +25 -0
  17. data/lib/capsium/cli.rb +5 -79
  18. data/lib/capsium/converters/jekyll.rb +62 -31
  19. data/lib/capsium/converters.rb +7 -0
  20. data/lib/capsium/package/dataset.rb +76 -56
  21. data/lib/capsium/package/manifest.rb +41 -50
  22. data/lib/capsium/package/manifest_config.rb +64 -0
  23. data/lib/capsium/package/metadata.rb +11 -29
  24. data/lib/capsium/package/metadata_config.rb +105 -0
  25. data/lib/capsium/package/routes.rb +51 -111
  26. data/lib/capsium/package/routes_config.rb +150 -0
  27. data/lib/capsium/package/security.rb +123 -0
  28. data/lib/capsium/package/security_config.rb +60 -0
  29. data/lib/capsium/package/storage.rb +26 -26
  30. data/lib/capsium/package/storage_config.rb +106 -0
  31. data/lib/capsium/package/validator.rb +149 -0
  32. data/lib/capsium/package.rb +84 -70
  33. data/lib/capsium/packager.rb +60 -62
  34. data/lib/capsium/reactor/introspection.rb +88 -0
  35. data/lib/capsium/reactor.rb +108 -36
  36. data/lib/capsium/version.rb +1 -1
  37. data/lib/capsium.rb +7 -9
  38. data/sig/capsium/package/dataset.rbs +28 -0
  39. data/sig/capsium/package/manifest.rbs +34 -0
  40. data/sig/capsium/package/manifest_config.rbs +38 -0
  41. data/sig/capsium/package/metadata.rbs +26 -0
  42. data/sig/capsium/package/metadata_config.rbs +49 -0
  43. data/sig/capsium/package/routes.rbs +37 -0
  44. data/sig/capsium/package/routes_config.rbs +74 -0
  45. data/sig/capsium/package/security.rbs +65 -0
  46. data/sig/capsium/package/security_config.rbs +57 -0
  47. data/sig/capsium/package/storage.rbs +28 -0
  48. data/sig/capsium/package/storage_config.rbs +61 -0
  49. data/sig/capsium/package/validator.rbs +29 -0
  50. data/sig/capsium/package.rbs +53 -0
  51. data/sig/capsium/packager.rbs +23 -0
  52. data/sig/capsium/reactor/introspection.rbs +31 -0
  53. data/sig/capsium/reactor.rbs +30 -0
  54. data/sig/capsium.rbs +3 -1
  55. metadata +63 -146
  56. data/images/005df7415a331c466ad2d9a42efdd212b6f42fe50b5fd3b3174c86c706f58244.png +0 -0
  57. data/images/0374025b3af99b8a473282c8cbbf9fcd29573cf41e586982f328f86c0690f43d.png +0 -0
  58. data/images/0bb4da785be40ef58e219470ebccb979325928b75453dc46bac23c6ee8a7a7cb.png +0 -0
  59. data/images/6aa294dccc81af594aacbe804e7ddffdc17eacc28357338108aea5d021d831ff.png +0 -0
  60. data/images/72dd3fbf3f4b475e27a0e7fb8137c475c32c41f8d222bcf62d6a9ccf102d9532.png +0 -0
  61. data/images/8772b6961d169738d7b0fa0b669b06fc2f40632d4c62586c7634fc17b93182a3.png +0 -0
  62. data/images/a998d842405933d45723606ff3f70162ec95b4ef30db25464a366184fd08fb9b.png +0 -0
  63. data/images/aa8980547e8c003d33273ab4d80e62da7f317bd7581b293c06d67f5331f24f31.png +0 -0
  64. data/images/bb78a872b539e0e9b2d80dee58acbb688f3f2727b324a5bf8bf417a69d94a166.png +0 -0
  65. data/images/c48fc83b17725d85fbb64d971196ebfccd8c5c757fe6aa5845303f6e315879b6.png +0 -0
  66. data/images/f08ef07308d08119ac2124bb7428c8bef17ef1ca70045696604d6e83015a9b91.png +0 -0
  67. data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726-1.png +0 -0
  68. data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726.png +0 -0
  69. data/lib/capsium/protector.rb +0 -95
@@ -1,60 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/manifest.rb
4
- require "json"
3
+ require "forwardable"
5
4
  require "marcel"
6
- require "shale"
5
+ require "pathname"
7
6
 
8
7
  module Capsium
9
8
  class Package
10
9
  class Manifest
11
- attr_accessor :path, :content_path, :data
10
+ extend Forwardable
11
+
12
+ attr_reader :path, :content_path, :config
13
+
14
+ def_delegators :@config, :to_hash
12
15
 
13
16
  def initialize(path)
14
17
  @path = path
15
- # This is {package-name}/content
16
- # or
17
- # /tmp/../content
18
- @content_path = File.join(File.dirname(@path), "content")
19
-
20
- @data = if File.exist?(path)
21
- ManifestData.from_json(File.read(path))
22
- else
23
- ManifestData.new(content: generate_manifest)
24
- end
18
+ @content_path = File.join(File.dirname(@path), Package::CONTENT_DIR)
19
+ @config = if File.exist?(path)
20
+ ManifestConfig.from_json(File.read(path))
21
+ else
22
+ ManifestConfig.new(resources: generate_manifest)
23
+ end
25
24
  end
26
25
 
26
+ # Auto-generation (ARCHITECTURE.md section 3): scan content/
27
+ # recursively, detect MIME types, default visibility "exported",
28
+ # deterministic (sorted) output.
27
29
  def generate_manifest
28
- files = Dir[File.join(@content_path, "**", "*")].reject do |f|
29
- File.directory?(f)
30
+ content_files.sort.to_h do |file_path|
31
+ [relative_path(file_path),
32
+ Resource.new(type: mime_from_path(file_path), visibility: "exported")]
30
33
  end
34
+ end
31
35
 
32
- files.sort.map do |file_path|
33
- ManifestDataItem.new(
34
- file: relative_path(file_path),
35
- mime: mime_from_path(file_path)
36
- )
37
- end
36
+ def resources
37
+ @config.resources
38
38
  end
39
39
 
40
- def lookup(filename)
41
- @data.content.detect do |data_item|
42
- data_item.file == filename
43
- end
40
+ def lookup(path)
41
+ resources[path]
42
+ end
43
+
44
+ def type_for(path)
45
+ resource = lookup(path)
46
+ resource&.type
44
47
  end
45
48
 
46
49
  def to_json(*_args)
47
- @data.to_json
50
+ @config.to_json
48
51
  end
49
52
 
50
53
  def save_to_file(output_path = @path)
51
- File.open(output_path, "w") do |file|
52
- file.write(to_json)
53
- end
54
+ File.write(output_path, to_json)
54
55
  end
55
56
 
56
57
  def path_to_content_file(path)
57
- Pathname.new(@content_path).join(path)
58
+ raise TypeError, "Path cannot be nil" if path.nil?
59
+
60
+ Pathname.new(File.dirname(@path)).join(path)
58
61
  end
59
62
 
60
63
  def content_file_exists?(path)
@@ -62,11 +65,17 @@ module Capsium
62
65
  end
63
66
 
64
67
  def relative_path(path)
65
- Pathname.new(path).relative_path_from(@content_path).to_s
68
+ Pathname.new(path).relative_path_from(File.dirname(@path)).to_s
66
69
  end
67
70
 
68
71
  private
69
72
 
73
+ def content_files
74
+ Dir.glob(File.join(@content_path, "**", "*")).select do |file|
75
+ File.file?(file)
76
+ end
77
+ end
78
+
70
79
  def mime_from_path(path)
71
80
  Marcel::MimeType.for(
72
81
  Pathname.new(path),
@@ -75,23 +84,5 @@ module Capsium
75
84
  )
76
85
  end
77
86
  end
78
-
79
- class ManifestDataItem < Shale::Mapper
80
- attribute :file, Shale::Type::String
81
- attribute :mime, Shale::Type::String
82
-
83
- # json do
84
- # map "file", to: :file
85
- # map "mime", to: :mime
86
- # end
87
- end
88
-
89
- class ManifestData < Shale::Mapper
90
- attribute :content, ManifestDataItem, collection: true
91
-
92
- # json do
93
- # map "content", to: :content
94
- # end
95
- end
96
87
  end
97
88
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+
6
+ module Capsium
7
+ class Package
8
+ # A single manifest resource entry (ARCHITECTURE.md section 3).
9
+ class Resource < Lutaml::Model::Serializable
10
+ VISIBILITIES = %w[exported private].freeze
11
+
12
+ attribute :type, :string
13
+ attribute :visibility, :string, values: VISIBILITIES, default: "exported"
14
+ attribute :version, :string
15
+
16
+ json do
17
+ map :type, to: :type
18
+ map :visibility, to: :visibility
19
+ map :version, to: :version
20
+ end
21
+ end
22
+
23
+ # Canonical manifest.json model: an object keyed by package-relative
24
+ # resource path. The legacy gem form ({"content": [{file, mime}]}) is
25
+ # accepted on read and normalized; writers emit only the object form.
26
+ class ManifestConfig < Lutaml::Model::Serializable
27
+ attribute :resources, :hash, default: {}
28
+
29
+ json do
30
+ map :resources, with: { from: :resources_from_json, to: :resources_to_json }
31
+ end
32
+
33
+ def self.from_json(json)
34
+ doc = JSON.parse(json)
35
+ doc["resources"] ||= legacy_resources(doc.delete("content")) if doc.key?("content")
36
+ super(JSON.generate(doc))
37
+ end
38
+
39
+ def self.legacy_resources(content)
40
+ (content || []).to_h do |item|
41
+ [item["file"], { "type" => item["mime"], "visibility" => "exported" }]
42
+ end
43
+ end
44
+ private_class_method :legacy_resources
45
+
46
+ def resources_from_json(model, value)
47
+ model.resources = (value || {}).to_h do |path, attributes|
48
+ [path, Resource.from_json(JSON.generate(attributes))]
49
+ end
50
+ end
51
+
52
+ def resources_to_json(model, doc)
53
+ doc["resources"] = model.resources.sort.to_h do |path, resource|
54
+ [path, JSON.parse(resource.to_json)]
55
+ end
56
+ end
57
+
58
+ def sort!
59
+ self.resources = resources.sort.to_h
60
+ self
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,47 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/metadata.rb
4
- require "shale"
5
3
  require "forwardable"
6
4
 
7
5
  module Capsium
8
6
  class Package
9
- class Dependency < Shale::Mapper
10
- attribute :name, Shale::Type::String
11
- attribute :version, Shale::Type::String
12
- end
13
-
14
- class MetadataData < Shale::Mapper
15
- attribute :name, Shale::Type::String
16
- attribute :version, Shale::Type::String
17
- attribute :dependencies, Dependency, collection: true
18
- end
19
-
20
7
  class Metadata
21
- attr_reader :path, :data
8
+ attr_reader :path, :config
22
9
 
23
10
  extend Forwardable
24
- def_delegator :@data, :name
25
- def_delegator :@data, :version
26
- def_delegator :@data, :dependencies
11
+
12
+ def_delegators :@config, :to_json, :to_hash, :name, :version,
13
+ :description, :guid, :uuid, :author, :license,
14
+ :repository, :dependencies
27
15
 
28
16
  def initialize(path)
29
17
  @path = path
30
- @data = if File.exist?(path)
31
- MetadataData.from_json(File.read(path))
32
- else
33
- MetadataData.new
34
- end
35
- end
36
-
37
- def to_json(*_args)
38
- @data.to_json
18
+ @config = if File.exist?(path)
19
+ MetadataData.from_json(File.read(path))
20
+ else
21
+ MetadataData.new
22
+ end
39
23
  end
40
24
 
41
25
  def save_to_file(output_path = @path)
42
- File.open(output_path, "w") do |file|
43
- file.write(to_json)
44
- end
26
+ File.write(output_path, to_json)
45
27
  end
46
28
  end
47
29
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+ require "uri"
6
+
7
+ module Capsium
8
+ class Package
9
+ class Repository < Lutaml::Model::Serializable
10
+ attribute :type, :string
11
+ attribute :url, :string
12
+
13
+ json do
14
+ map :type, to: :type
15
+ map :url, to: :url
16
+ end
17
+ end
18
+
19
+ # Canonical metadata.json model (ARCHITECTURE.md section 2).
20
+ #
21
+ # The legacy gem form of "dependencies" (an array of {name, version}
22
+ # objects) is accepted on read and normalized to the canonical object
23
+ # form; writers emit only the object form.
24
+ class MetadataData < Lutaml::Model::Serializable
25
+ KEBAB_CASE_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
26
+ SEMVER_PATTERN = /\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
27
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
28
+
29
+ attribute :name, :string
30
+ attribute :version, :string
31
+ attribute :description, :string
32
+ attribute :guid, :string
33
+ attribute :uuid, :string
34
+ attribute :author, :string
35
+ attribute :license, :string
36
+ attribute :repository, Repository
37
+ attribute :dependencies, :hash, default: {}
38
+ attribute :read_only, :boolean
39
+
40
+ json do
41
+ map :name, to: :name
42
+ map :version, to: :version
43
+ map :description, to: :description
44
+ map :guid, to: :guid
45
+ map :uuid, to: :uuid
46
+ map :author, to: :author
47
+ map :license, to: :license
48
+ map :repository, to: :repository
49
+ map :dependencies, to: :dependencies
50
+ map "readOnly", to: :read_only
51
+ end
52
+
53
+ def self.from_json(json)
54
+ doc = JSON.parse(json)
55
+ doc["dependencies"] = normalize_dependencies(doc["dependencies"])
56
+ super(JSON.generate(doc))
57
+ end
58
+
59
+ def self.normalize_dependencies(dependencies)
60
+ return {} if dependencies.nil?
61
+ return dependencies unless dependencies.is_a?(Array)
62
+
63
+ dependencies.to_h { |dep| [dep["name"], dep["version"]] }
64
+ end
65
+ private_class_method :normalize_dependencies
66
+
67
+ # Field-level format validations (ARCHITECTURE.md section 2).
68
+ # Returns a list of human-readable problems; empty when valid.
69
+ def format_errors
70
+ presence_errors + format_field_errors
71
+ end
72
+
73
+ private
74
+
75
+ def presence_errors
76
+ problems = []
77
+ problems << "name is missing" if name.to_s.empty?
78
+ problems << "version is missing" if version.to_s.empty?
79
+ problems << "description is missing" if description.to_s.empty?
80
+ problems << "guid is missing" if guid.to_s.empty?
81
+ problems << "uuid is missing" if uuid.to_s.empty?
82
+ problems
83
+ end
84
+
85
+ def format_field_errors
86
+ problems = []
87
+ problems << "name must be kebab-case" if invalid?(name, KEBAB_CASE_PATTERN)
88
+ problems << "version must be semver" if invalid?(version, SEMVER_PATTERN)
89
+ problems << "guid must be a URI" if guid && !uri?(guid)
90
+ problems << "uuid is not a valid UUID" if invalid?(uuid, UUID_PATTERN)
91
+ problems
92
+ end
93
+
94
+ def invalid?(value, pattern)
95
+ !value.nil? && !value.match?(pattern)
96
+ end
97
+
98
+ def uri?(value)
99
+ URI.parse(value).is_a?(URI::Generic)
100
+ rescue URI::InvalidURIError
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,156 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/routes.rb
4
- require "json"
5
- require "fileutils"
3
+ require "forwardable"
6
4
 
7
5
  module Capsium
8
6
  class Package
9
- class RouteTarget < Shale::Mapper
10
- attribute :file, Shale::Type::String
11
-
12
- def fs_path(manifest)
13
- manifest.path_to_content_file(manifest.lookup(file).file)
14
- end
15
-
16
- def mime(manifest)
17
- manifest.lookup(file).mime
18
- end
19
-
20
- def validate(manifest)
21
- target_path = fs_path(manifest)
22
- return if File.exist?(target_path)
23
-
24
- raise "Route target does not exist: #{target_path}"
25
- end
26
- end
27
-
28
- class Route < Shale::Mapper
29
- attribute :path, Shale::Type::String
30
- attribute :target, RouteTarget
31
- end
32
-
33
- class RoutesData < Shale::Mapper
34
- attribute :routes, Route, collection: true
35
-
36
- def resolve(route)
37
- routes.detect do |r|
38
- r.path == route
39
- end
40
- end
41
-
42
- def add(route, target)
43
- target = RouteTarget.new(file: target) if target.is_a?(String)
44
-
45
- @routes << Route.new(path: route, target: target)
46
- end
47
-
48
- def update(route, updated_route, _updated_target)
49
- r = @routes.resolve(route)
50
- r.path = updated_route
51
- r.target = target
52
- r
53
- end
54
-
55
- def remove(route)
56
- r = @routes.resolve(route)
57
- @routes.remove(r)
58
- end
7
+ class Routes
8
+ extend Forwardable
59
9
 
60
- def sort!
61
- @routes.sort_by!(&:path)
62
- self
63
- end
64
- end
10
+ attr_reader :path, :config, :manifest, :storage
65
11
 
66
- class Routes
67
- attr_reader :path, :data, :index, :manifest
12
+ def_delegators :@config, :to_hash
68
13
 
69
- ROUTES_FILE = "routes.json"
70
- DEFAULT_INDEX_TARGET = "index.html"
71
14
  INDEX_ROUTE = "/"
15
+ INDEX_RESOURCE = "content/index.html"
16
+ DATASET_ROUTE_PREFIX = "/api/v1/data/"
72
17
 
73
- def initialize(path, manifest)
18
+ def initialize(path, manifest, storage)
74
19
  @path = path
75
- @dir = File.dirname(path)
76
20
  @manifest = manifest
77
- @data = if File.exist?(path)
78
- RoutesData.from_json(File.read(path))
79
- else
80
- generate_routes_from_manifest
81
- end
82
- validate_index_path(@data.resolve(INDEX_ROUTE).target.file)
83
- validate
21
+ @storage = storage
22
+ @config = if File.exist?(path)
23
+ RoutesConfig.from_json(File.read(path))
24
+ else
25
+ generate_routes
26
+ end
84
27
  end
85
28
 
86
29
  def resolve(url_path)
87
- @data.resolve(url_path)
30
+ @config.resolve(url_path)
88
31
  end
89
32
 
90
33
  def add_route(route, target)
91
- validate_route_target(route, target)
92
- @data.add(route, target)
34
+ @config.add(route, target)
93
35
  end
94
36
 
95
37
  def update_route(route, updated_route, updated_target)
96
- validate_route_target(updated_route, updated_target)
97
- @data.update(route, updated_route, updated_target)
38
+ @config.update(route, updated_route, updated_target)
98
39
  end
99
40
 
100
41
  def remove_route(route)
101
- @data._removed(route)
102
- end
103
-
104
- def validate
105
- @data.routes.each do |route|
106
- route.target.validate(@manifest)
107
- end
42
+ @config.remove(route)
108
43
  end
109
44
 
110
45
  def to_json(*_args)
111
- @data.sort!.to_json
46
+ @config.to_json
112
47
  end
113
48
 
114
49
  def save_to_file(output_path = @path)
115
- File.open(output_path, "w") do |file|
116
- file.write(to_json)
117
- end
50
+ File.write(output_path, to_json)
118
51
  end
119
52
 
120
- private
53
+ # Auto-generation (ARCHITECTURE.md section 4): every manifest
54
+ # resource gets a route at its path relative to content/; HTML files
55
+ # get two routes (basename without extension and full filename); the
56
+ # index HTML additionally gets "/"; every dataset in storage gets
57
+ # /api/v1/data/<id>. Output is deterministic (sorted by path).
58
+ def generate_routes
59
+ routes = resource_routes + dataset_routes
60
+ RoutesConfig.new(index: index_resource, routes: routes.sort_by(&:path))
61
+ end
121
62
 
122
- def generate_routes_from_manifest
123
- routes = RoutesData.new
124
- @manifest.data.content.each do |data_item|
125
- file_path = data_item.file
126
- # mime_type = data_item.mime
127
- content_path = @manifest.path_to_content_file(file_path).to_s
63
+ private
128
64
 
129
- if file_path == DEFAULT_INDEX_TARGET
130
- routes.add(INDEX_ROUTE, DEFAULT_INDEX_TARGET)
131
- end
65
+ def index_resource
66
+ INDEX_RESOURCE if @manifest.resources.key?(INDEX_RESOURCE)
67
+ end
132
68
 
133
- routes.add("/#{clean_target_html_path(file_path)}", file_path) if file_path =~ /\.html$/
69
+ def resource_routes
70
+ @manifest.resources.keys.flat_map do |resource_path|
71
+ routes_for_resource(resource_path)
72
+ end.uniq(&:path)
73
+ end
134
74
 
135
- routes.add("/#{file_path}", file_path)
136
- end
75
+ def routes_for_resource(resource_path)
76
+ url_path = resource_path.sub(%r{\A#{Package::CONTENT_DIR}/}o, "")
77
+ routes = [Route.new(path: "/#{url_path}", resource: resource_path)]
78
+ return routes unless File.extname(resource_path) == ".html"
137
79
 
80
+ basename = File.basename(url_path, ".html")
81
+ routes << Route.new(path: "/#{basename}", resource: resource_path)
82
+ routes << Route.new(path: INDEX_ROUTE, resource: resource_path) if index?(resource_path)
138
83
  routes
139
84
  end
140
85
 
141
- def clean_target_html_path(path)
142
- File.dirname(path) + File.basename(path, ".html")
86
+ def index?(resource_path)
87
+ resource_path == INDEX_RESOURCE
143
88
  end
144
89
 
145
-
146
- def validate_index_path(index_path)
147
- target_path = @manifest.path_to_content_file(index_path)
148
-
149
- raise "Index file does not exist: #{target_path}" unless File.exist?(target_path)
150
-
151
- return if File.extname(target_path).downcase == ".html"
152
-
153
- raise "Index file is not an HTML file: #{target_path}"
90
+ def dataset_routes
91
+ @storage.dataset_names.map do |name|
92
+ Route.new(path: "#{DATASET_ROUTE_PREFIX}#{name}", dataset: name)
93
+ end
154
94
  end
155
95
  end
156
96
  end