capsium 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Capsium
6
+ class Package
7
+ # The merged (overlay) view of a package's content, per ARCHITECTURE.md
8
+ # section 5a. Shared by Package (validation) and Reactor (serving).
9
+ #
10
+ # The content/ tree is always the implicit bottom layer; layers from
11
+ # storage.layers stack on top of it, bottom -> top in declaration
12
+ # order. Resolution scans from the TOP down and the first hit wins.
13
+ # Deletions are recorded as tombstones: a `.capsium-tombstones` JSON
14
+ # file in a writable layer listing content/-relative paths; a
15
+ # tombstoned path resolves to nil even when a lower layer (including a
16
+ # dependency's content) holds the file, while a file reappearing in a
17
+ # layer ABOVE the tombstone is served again.
18
+ #
19
+ # Composite packages (section 4a): `dependency_views` are
20
+ # [dependency GUID, MergedView] pairs acting as read-only layers below
21
+ # ALL own layers. A resource reference that is a URI of the form
22
+ # "<dependency-guid>/<path>" resolves explicitly against that
23
+ # dependency's view; plain content paths fall through to the
24
+ # dependency layers in declaration order after all own layers miss.
25
+ #
26
+ # With `exported_only: true` (the view a dependent package gets):
27
+ # layers whose visibility is "private" are hidden entirely, and a path
28
+ # resolves only when the manifest lists the resource as "exported".
29
+ class MergedView
30
+ # A single read layer: an absolute root directory mirroring the
31
+ # content/ tree, plus its parsed tombstone set.
32
+ Layer = Data.define(:root, :visibility, :tombstones) do
33
+ def file?(content_relative_path)
34
+ File.file?(absolute(content_relative_path))
35
+ end
36
+
37
+ def absolute(content_relative_path)
38
+ File.join(root, content_relative_path)
39
+ end
40
+ end
41
+
42
+ TOMBSTONE_FILE = ".capsium-tombstones"
43
+ CONTENT_PREFIX = "#{Package::CONTENT_DIR}/".freeze
44
+
45
+ attr_reader :package_path, :layers, :dependency_views
46
+
47
+ def initialize(package_path, storage:, manifest:, dependency_views: [],
48
+ exported_only: false)
49
+ @package_path = package_path
50
+ @storage = storage
51
+ @manifest = manifest
52
+ @exported_only = exported_only
53
+ @dependency_views = dependency_views
54
+ @layers = build_layers
55
+ end
56
+
57
+ # The absolute filesystem path serving a resource reference: a
58
+ # package-relative content path ("content/app.js"), or a dependency
59
+ # reference ("<dependency-guid>/content/app.js"). nil when no layer
60
+ # provides the path or it is tombstoned. Non-content paths never
61
+ # resolve through the view.
62
+ def resolve(reference)
63
+ return unless reference.is_a?(String)
64
+
65
+ if reference.include?("://")
66
+ pair = dependency_pair_for(reference)
67
+ return unless pair
68
+
69
+ return pair[1].resolve(reference.delete_prefix("#{pair[0]}/"))
70
+ end
71
+
72
+ content_relative = content_relative(reference)
73
+ return unless content_relative
74
+ return if content_relative == TOMBSTONE_FILE
75
+
76
+ result = resolve_own(content_relative)
77
+ return if result == :tombstoned
78
+ return result if result
79
+
80
+ resolve_dependencies(reference)
81
+ end
82
+
83
+ # The [GUID, view] pair whose GUID prefixes the reference (longest
84
+ # GUID first, so nested GUIDs address the innermost dependency).
85
+ def dependency_pair_for(reference)
86
+ @dependency_views
87
+ .sort_by { |guid, _view| -guid.length }
88
+ .find { |guid, _view| reference.start_with?("#{guid}/") }
89
+ end
90
+
91
+ private
92
+
93
+ # The serving path from the package's own layers, :tombstoned when
94
+ # a tombstone at or above the first hit suppresses it (tombstones
95
+ # also suppress the lower dependency layers), or nil when no own
96
+ # layer provides the path.
97
+ def resolve_own(content_relative)
98
+ tombstoned = false
99
+ @layers.reverse_each do |layer|
100
+ tombstoned ||= layer.tombstones.include?(content_relative)
101
+ next unless layer.file?(content_relative)
102
+ return :tombstoned if tombstoned
103
+
104
+ return layer.absolute(content_relative)
105
+ end
106
+ tombstoned ? :tombstoned : nil
107
+ end
108
+
109
+ def resolve_dependencies(relative_path)
110
+ @dependency_views.each do |pair|
111
+ found = pair.last.resolve(relative_path)
112
+ return found if found
113
+ end
114
+ nil
115
+ end
116
+
117
+ # Own layers, bottom -> top: the implicit content/ layer plus the
118
+ # configured storage.layers. Private layers are dropped when the view
119
+ # is exported-only (hidden from dependents, section 5a).
120
+ def build_layers
121
+ configs = [nil] + @storage.config.layers
122
+ configs = configs.reject { |config| config&.private? } if @exported_only
123
+ configs.filter_map { |config| build_layer(config) }
124
+ end
125
+
126
+ def build_layer(config)
127
+ root = config ? File.join(@package_path, config.path) : content_root
128
+ Layer.new(root: root,
129
+ visibility: config&.visibility || "exported",
130
+ tombstones: load_tombstones(root))
131
+ end
132
+
133
+ def content_root
134
+ File.join(@package_path, Package::CONTENT_DIR)
135
+ end
136
+
137
+ def load_tombstones(root)
138
+ tombstone_path = File.join(root, TOMBSTONE_FILE)
139
+ return Set.new unless File.file?(tombstone_path)
140
+
141
+ entries = JSON.parse(File.read(tombstone_path))
142
+ unless entries.is_a?(Array) && entries.all?(String)
143
+ raise Error, "Malformed #{TOMBSTONE_FILE} (expected a JSON array " \
144
+ "of paths): #{tombstone_path}"
145
+ end
146
+
147
+ Set.new(entries)
148
+ rescue JSON::ParserError => e
149
+ raise Error, "Malformed #{TOMBSTONE_FILE}: #{tombstone_path}: #{e.message}"
150
+ end
151
+
152
+ def content_relative(relative_path)
153
+ return unless relative_path.is_a?(String)
154
+ return unless relative_path.start_with?(CONTENT_PREFIX)
155
+
156
+ content_relative = relative_path.delete_prefix(CONTENT_PREFIX)
157
+ return if @exported_only && !exported?(relative_path)
158
+
159
+ content_relative
160
+ end
161
+
162
+ def exported?(relative_path)
163
+ resource = @manifest.lookup(relative_path)
164
+ resource&.visibility == "exported"
165
+ end
166
+ end
167
+ end
168
+ end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/metadata.rb
4
- require "shale"
5
3
  require "forwardable"
6
- require_relative "metadata_config"
7
4
 
8
5
  module Capsium
9
6
  class Package
@@ -11,11 +8,10 @@ module Capsium
11
8
  attr_reader :path, :config
12
9
 
13
10
  extend Forwardable
14
- def_delegator :@config, :to_json
15
- def_delegator :@config, :name
16
- def_delegator :@config, :version
17
- def_delegator :@config, :description # Delegate description method
18
- def_delegator :@config, :dependencies
11
+
12
+ def_delegators :@config, :to_json, :to_hash, :name, :version,
13
+ :description, :guid, :uuid, :author, :license,
14
+ :repository, :dependencies
19
15
 
20
16
  def initialize(path)
21
17
  @path = path
@@ -1,17 +1,105 @@
1
- require "shale"
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+ require "uri"
2
6
 
3
7
  module Capsium
4
8
  class Package
5
- class Dependency < Shale::Mapper
6
- attribute :name, Shale::Type::String
7
- attribute :version, Shale::Type::String
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
8
17
  end
9
18
 
10
- class MetadataData < Shale::Mapper
11
- attribute :name, Shale::Type::String
12
- attribute :version, Shale::Type::String
13
- attribute :description, Shale::Type::String # Add description attribute
14
- attribute :dependencies, Dependency, collection: true
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
15
103
  end
16
104
  end
17
105
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Capsium
7
+ class Package
8
+ # Package-source preparation (ARCHITECTURE.md section 1), mixed into
9
+ # Package: directory/.cap/encrypted-cap detection, extraction and
10
+ # decryption into a readable package directory.
11
+ module Preparation
12
+ def prepare_package(path)
13
+ return decrypt_cap_file(path) if Cipher.encrypted?(path)
14
+ return path if File.directory?(path)
15
+ raise Error, "Invalid package path: #{path}" unless File.file?(path)
16
+ raise Error, "The package must have a .cap extension" unless File.extname(path) == ".cap"
17
+
18
+ decompress_cap_file(path)
19
+ end
20
+
21
+ def decompress_cap_file(file_path)
22
+ package_path = File.join(Dir.mktmpdir, package_stem(file_path))
23
+ FileUtils.mkdir_p(package_path)
24
+ Packager.new.unpack(file_path, package_path)
25
+ package_path
26
+ end
27
+
28
+ # Decrypts an encrypted package (.cap file or uncompressed directory)
29
+ # into a temporary directory and returns its path. The metadata.json
30
+ # of an encrypted package stays cleartext, but everything else is
31
+ # only readable with the recipient's private key.
32
+ def decrypt_cap_file(source_path)
33
+ unless @decryption_key
34
+ raise Cipher::KeyRequiredError,
35
+ "Package is encrypted; provide the private key via decryption_key:"
36
+ end
37
+
38
+ Cipher.decrypt_to_directory(source_path, @decryption_key)
39
+ end
40
+
41
+ def determine_load_type(path)
42
+ return :directory if File.directory?(path)
43
+
44
+ File.extname(path) == ".cap" ? :cap_file : :unsaved
45
+ end
46
+
47
+ # Whether the package was loaded from an encrypted source
48
+ # (ARCHITECTURE.md section 6b layout).
49
+ def encrypted?
50
+ Cipher.encrypted?(@original_path)
51
+ end
52
+
53
+ private
54
+
55
+ def package_stem(file_path)
56
+ File.basename(file_path, ".cap")
57
+ end
58
+
59
+ def create_package_structure
60
+ FileUtils.mkdir_p(content_path)
61
+ FileUtils.mkdir_p(data_path)
62
+ end
63
+
64
+ def content_path = File.join(@path, CONTENT_DIR)
65
+
66
+ def data_path = File.join(@path, DATA_DIR)
67
+
68
+ def routes_path = File.join(@path, ROUTES_FILE)
69
+
70
+ def storage_path = File.join(@path, STORAGE_FILE)
71
+
72
+ def metadata_path = File.join(@path, METADATA_FILE)
73
+
74
+ def manifest_path = File.join(@path, MANIFEST_FILE)
75
+
76
+ def security_path = File.join(@path, SECURITY_FILE)
77
+
78
+ def authentication_path = File.join(@path, AUTHENTICATION_FILE)
79
+ end
80
+ end
81
+ end
@@ -1,27 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/routes.rb
4
- require "json"
5
- require "fileutils"
6
- require_relative "routes_config"
3
+ require "forwardable"
7
4
 
8
5
  module Capsium
9
6
  class Package
10
7
  class Routes
11
8
  extend Forwardable
12
- attr_reader :config
13
9
 
14
- def_delegators :@config, :to_json
10
+ attr_reader :path, :config, :manifest, :storage
15
11
 
16
- attr_reader :path, :config, :index, :manifest, :storage
12
+ def_delegators :@config, :to_hash
17
13
 
18
- ROUTES_FILE = "routes.json"
19
- DEFAULT_INDEX_TARGET = "content/index.html"
20
14
  INDEX_ROUTE = "/"
15
+ INDEX_RESOURCE = "content/index.html"
16
+ DATASET_ROUTE_PREFIX = "/api/v1/data/"
21
17
 
22
18
  def initialize(path, manifest, storage)
23
19
  @path = path
24
- @dir = File.dirname(path)
25
20
  @manifest = manifest
26
21
  @storage = storage
27
22
  @config = if File.exist?(path)
@@ -29,8 +24,6 @@ module Capsium
29
24
  else
30
25
  generate_routes
31
26
  end
32
- validate_index_path(@config.resolve(INDEX_ROUTE)&.target&.file)
33
- validate
34
27
  end
35
28
 
36
29
  def resolve(url_path)
@@ -38,12 +31,10 @@ module Capsium
38
31
  end
39
32
 
40
33
  def add_route(route, target)
41
- validate_route_target(route, target)
42
34
  @config.add(route, target)
43
35
  end
44
36
 
45
37
  def update_route(route, updated_route, updated_target)
46
- validate_route_target(updated_route, updated_target)
47
38
  @config.update(route, updated_route, updated_target)
48
39
  end
49
40
 
@@ -51,48 +42,55 @@ module Capsium
51
42
  @config.remove(route)
52
43
  end
53
44
 
54
- def validate
55
- @config.routes.each do |route|
56
- route.target.validate(@manifest, @storage)
57
- end
58
- end
59
-
60
45
  def to_json(*_args)
61
- @config.sort!.to_json
46
+ @config.to_json
62
47
  end
63
48
 
64
49
  def save_to_file(output_path = @path)
65
50
  File.write(output_path, to_json)
66
51
  end
67
52
 
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
62
+
68
63
  private
69
64
 
70
- def generate_routes
71
- r = RoutesConfig.new
72
- manifest.config.sort!.content.each_with_object({}) do |data_item, _hash|
73
- relative_path = data_item.file.sub(/^#{Package::CONTENT_DIR}/o, "")
74
- r.add(relative_path, data_item.file)
75
-
76
- # Ensure the index route is included
77
- if File.basename(relative_path, ".*") == "index"
78
- r.add("/index", data_item.file)
79
- r.add("/", data_item.file)
80
- end
81
- end
65
+ def index_resource
66
+ INDEX_RESOURCE if @manifest.resources.key?(INDEX_RESOURCE)
67
+ end
82
68
 
83
- r
69
+ def resource_routes
70
+ @manifest.resources.keys.flat_map do |resource_path|
71
+ routes_for_resource(resource_path)
72
+ end.uniq(&:path)
84
73
  end
85
74
 
86
- def validate_index_path(index_path)
87
- return unless index_path
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"
88
79
 
89
- target_path = @manifest.path_to_content_file(index_path)
90
- raise "Index file does not exist: #{target_path}" unless File.exist?(target_path)
91
- raise "Index file is not an HTML file: #{target_path}" unless File.extname(target_path).downcase == ".html"
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)
83
+ routes
92
84
  end
93
85
 
94
- def validate_route_target(route, target)
95
- # Add any necessary validation logic for the route and target
86
+ def index?(resource_path)
87
+ resource_path == INDEX_RESOURCE
88
+ end
89
+
90
+ def dataset_routes
91
+ @storage.dataset_names.map do |name|
92
+ Route.new(path: "#{DATASET_ROUTE_PREFIX}#{name}", dataset: name)
93
+ end
96
94
  end
97
95
  end
98
96
  end