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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +46 -7
- data/CHANGELOG.md +183 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +496 -127
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +12 -19
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +148 -0
- data/lib/capsium/cli/reactor.rb +33 -0
- data/lib/capsium/cli.rb +4 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/cipher.rb +197 -0
- data/lib/capsium/package/composition.rb +77 -0
- data/lib/capsium/package/dataset.rb +67 -34
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +174 -44
- data/lib/capsium/package/security.rb +140 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +128 -3
- data/lib/capsium/package/store.rb +109 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +160 -0
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +92 -106
- data/lib/capsium/packager.rb +75 -6
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +91 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/serving.rb +75 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +93 -43
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/cipher.rbs +51 -0
- data/sig/capsium/package/composition.rbs +20 -0
- data/sig/capsium/package/dataset.rbs +28 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +107 -0
- data/sig/capsium/package/security.rbs +72 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +85 -0
- data/sig/capsium/package/store.rbs +37 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/validator.rbs +29 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +61 -0
- data/sig/capsium/packager.rbs +32 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +31 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/serving.rbs +22 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +35 -0
- data/sig/capsium.rbs +3 -1
- metadata +108 -170
- data/lib/capsium/package/dataset_config.rb +0 -28
- data/lib/capsium/protector.rb +0 -103
|
@@ -1,76 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
5
|
-
require "shale"
|
|
6
|
-
require_relative "dataset"
|
|
7
|
-
require_relative "storage_config"
|
|
3
|
+
require "forwardable"
|
|
8
4
|
|
|
9
5
|
module Capsium
|
|
10
6
|
class Package
|
|
11
7
|
class Storage
|
|
12
8
|
extend Forwardable
|
|
13
|
-
attr_reader :config
|
|
14
9
|
|
|
15
|
-
|
|
10
|
+
attr_reader :path, :config, :datasets
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
def_delegators :@config, :to_json, :to_hash
|
|
18
13
|
|
|
19
14
|
def initialize(path)
|
|
20
15
|
@path = path
|
|
21
16
|
@dir = File.dirname(path)
|
|
22
|
-
@datasets_path = File.join(@dir, DATA_DIR)
|
|
23
17
|
@config = if File.exist?(path)
|
|
24
18
|
StorageConfig.from_json(File.read(path))
|
|
25
19
|
else
|
|
26
|
-
StorageConfig.new
|
|
20
|
+
StorageConfig.new
|
|
27
21
|
end
|
|
28
|
-
@datasets = load_datasets
|
|
22
|
+
@datasets = load_datasets
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def data_sets
|
|
26
|
+
@config.data_sets
|
|
29
27
|
end
|
|
30
28
|
|
|
31
29
|
def load_datasets
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
storage_data.datasets.map do |dataset_config|
|
|
35
|
-
dataset_config.to_dataset(@datasets_path)
|
|
36
|
-
end
|
|
30
|
+
data_sets.map do |name, dataset_config|
|
|
31
|
+
dataset_config.to_dataset(name, @dir)
|
|
37
32
|
end
|
|
38
33
|
end
|
|
39
34
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
DatasetConfig.from_dataset(dataset)
|
|
43
|
-
end)
|
|
44
|
-
File.write(output_path, storage_config.to_json)
|
|
35
|
+
def dataset(name)
|
|
36
|
+
@datasets.find { |dataset| dataset.name == name }
|
|
45
37
|
end
|
|
46
38
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
Dir.glob(paths).map do |file_path|
|
|
50
|
-
Dataset.new(config: DatasetConfig.new(
|
|
51
|
-
name: File.basename(file_path,
|
|
52
|
-
".*"), source: file_path, format: detect_format(file_path)
|
|
53
|
-
))
|
|
54
|
-
end
|
|
39
|
+
def dataset_names
|
|
40
|
+
data_sets.keys.sort
|
|
55
41
|
end
|
|
56
42
|
|
|
57
|
-
|
|
43
|
+
def empty?
|
|
44
|
+
data_sets.empty?
|
|
45
|
+
end
|
|
58
46
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
when ".yaml", ".yml"
|
|
62
|
-
"yaml"
|
|
63
|
-
when ".json"
|
|
64
|
-
"json"
|
|
65
|
-
when ".csv"
|
|
66
|
-
"csv"
|
|
67
|
-
when ".tsv"
|
|
68
|
-
"tsv"
|
|
69
|
-
when ".sqlite", ".db"
|
|
70
|
-
"sqlite"
|
|
71
|
-
else
|
|
72
|
-
raise "Unsupported data file type: #{File.extname(file_path)}"
|
|
73
|
-
end
|
|
47
|
+
def save_to_file(output_path = @path)
|
|
48
|
+
File.write(output_path, to_json)
|
|
74
49
|
end
|
|
75
50
|
end
|
|
76
51
|
end
|
|
@@ -1,9 +1,134 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
2
5
|
|
|
3
6
|
module Capsium
|
|
4
7
|
class Package
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
# A single dataset entry (ARCHITECTURE.md section 5). Paths are
|
|
9
|
+
# package-relative POSIX paths. Kinds: schema-backed file (via
|
|
10
|
+
# "source") or SQLite ("databaseFile" + "table").
|
|
11
|
+
class DatasetConfig < Lutaml::Model::Serializable
|
|
12
|
+
FORMATS = {
|
|
13
|
+
".yaml" => "yaml", ".yml" => "yaml", ".json" => "json",
|
|
14
|
+
".csv" => "csv", ".tsv" => "tsv",
|
|
15
|
+
".sqlite" => "sqlite", ".db" => "sqlite"
|
|
16
|
+
}.freeze
|
|
17
|
+
SCHEMA_TYPES = %w[json-schema].freeze
|
|
18
|
+
|
|
19
|
+
attribute :source, :string
|
|
20
|
+
attribute :schema_file, :string
|
|
21
|
+
attribute :schema_type, :string, values: SCHEMA_TYPES
|
|
22
|
+
attribute :database_file, :string
|
|
23
|
+
attribute :table, :string
|
|
24
|
+
|
|
25
|
+
json do
|
|
26
|
+
map :source, to: :source
|
|
27
|
+
map "schemaFile", to: :schema_file
|
|
28
|
+
map "schemaType", to: :schema_type
|
|
29
|
+
map "databaseFile", to: :database_file
|
|
30
|
+
map :table, to: :table
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format
|
|
34
|
+
FORMATS.fetch(File.extname(backing_file).downcase) do
|
|
35
|
+
raise Error, "Unsupported data file type: #{File.extname(backing_file)}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sqlite?
|
|
40
|
+
!database_file.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def backing_file
|
|
44
|
+
database_file || source.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_dataset(name, package_path)
|
|
48
|
+
Dataset.new(name: name, config: self, package_path: package_path)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A single storage layer entry (ARCHITECTURE.md section 5a). "path" is
|
|
53
|
+
# a package-relative directory mirroring the content/ tree; layers are
|
|
54
|
+
# stacked bottom -> top in declaration order. "visibility: private"
|
|
55
|
+
# layers are hidden from dependent packages.
|
|
56
|
+
class LayerConfig < Lutaml::Model::Serializable
|
|
57
|
+
attribute :path, :string
|
|
58
|
+
attribute :writable, :boolean, default: false
|
|
59
|
+
attribute :visibility, :string, values: Resource::VISIBILITIES, default: "exported"
|
|
60
|
+
|
|
61
|
+
json do
|
|
62
|
+
map :path, to: :path
|
|
63
|
+
map :writable, to: :writable
|
|
64
|
+
map :visibility, to: :visibility
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def private?
|
|
68
|
+
visibility == "private"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The "storage" object holding the dataSets map and the layers stack.
|
|
73
|
+
class StorageData < Lutaml::Model::Serializable
|
|
74
|
+
attribute :data_sets, :hash, default: {}
|
|
75
|
+
attribute :layers, LayerConfig, collection: true, default: []
|
|
76
|
+
|
|
77
|
+
json do
|
|
78
|
+
map "dataSets", with: { from: :data_sets_from_json, to: :data_sets_to_json }
|
|
79
|
+
map :layers, to: :layers
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def data_sets_from_json(model, value)
|
|
83
|
+
model.data_sets = (value || {}).to_h do |name, attributes|
|
|
84
|
+
[name, DatasetConfig.from_json(JSON.generate(attributes))]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def data_sets_to_json(model, doc)
|
|
89
|
+
doc["dataSets"] = model.data_sets.sort.to_h do |name, dataset|
|
|
90
|
+
[name, JSON.parse(dataset.to_json)]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Canonical storage.json model. The legacy gem form
|
|
96
|
+
# ({"datasets": [{name, source, format, schema}]}) is accepted on read
|
|
97
|
+
# and normalized; writers emit only the canonical form.
|
|
98
|
+
class StorageConfig < Lutaml::Model::Serializable
|
|
99
|
+
attribute :storage, StorageData
|
|
100
|
+
|
|
101
|
+
json do
|
|
102
|
+
map :storage, to: :storage
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.from_json(json)
|
|
106
|
+
doc = JSON.parse(json)
|
|
107
|
+
doc["storage"] ||= { "dataSets" => legacy_data_sets(doc.delete("datasets")) }
|
|
108
|
+
super(JSON.generate(doc))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.legacy_data_sets(datasets)
|
|
112
|
+
(datasets || []).to_h do |item|
|
|
113
|
+
entry = { "source" => item["source"] }
|
|
114
|
+
if item["schema"]
|
|
115
|
+
entry["schemaFile"] = item["schema"]
|
|
116
|
+
entry["schemaType"] = "json-schema"
|
|
117
|
+
end
|
|
118
|
+
[item["name"], entry]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
private_class_method :legacy_data_sets
|
|
122
|
+
|
|
123
|
+
def data_sets
|
|
124
|
+
storage ? storage.data_sets : {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The configured storage layers, bottom -> top (empty when the
|
|
128
|
+
# package uses the single implicit content/ layer).
|
|
129
|
+
def layers
|
|
130
|
+
storage ? storage.layers : []
|
|
131
|
+
end
|
|
7
132
|
end
|
|
8
133
|
end
|
|
9
134
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "zip"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
# A package store directory (ARCHITECTURE.md section 4a): a directory
|
|
9
|
+
# of "<name>-<version>.cap" files plus an optional index.json mapping
|
|
10
|
+
# dependency GUID -> file name. Dependencies resolve to the newest
|
|
11
|
+
# version satisfying their semver range; an index.json entry pins the
|
|
12
|
+
# GUID to a specific file (still range-checked).
|
|
13
|
+
class Store
|
|
14
|
+
INDEX_FILE = "index.json"
|
|
15
|
+
CAP_GLOB = "*.cap"
|
|
16
|
+
|
|
17
|
+
# One store .cap with its identity per its metadata.json.
|
|
18
|
+
CatalogEntry = Data.define(:path, :guid, :version)
|
|
19
|
+
|
|
20
|
+
attr_reader :dir
|
|
21
|
+
|
|
22
|
+
# The store configured via the CAPSIUM_STORE environment variable,
|
|
23
|
+
# or nil when unset.
|
|
24
|
+
def self.default
|
|
25
|
+
dir = ENV.fetch("CAPSIUM_STORE", nil)
|
|
26
|
+
dir.nil? || dir.empty? ? nil : new(dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(dir)
|
|
30
|
+
unless File.directory?(dir)
|
|
31
|
+
raise DependencyError, "Package store directory not found: #{dir}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@dir = dir
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The newest .cap providing the dependency GUID whose version
|
|
38
|
+
# satisfies the range string.
|
|
39
|
+
def find(guid, range_string)
|
|
40
|
+
range = VersionRange.parse(range_string)
|
|
41
|
+
indexed = indexed_path(guid)
|
|
42
|
+
return indexed_satisfying(indexed, guid, range_string, range) if indexed
|
|
43
|
+
|
|
44
|
+
candidates = catalog.select { |entry| entry.guid == guid }
|
|
45
|
+
if candidates.empty?
|
|
46
|
+
raise DependencyNotFoundError,
|
|
47
|
+
"no package for dependency #{guid} in store #{@dir}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
satisfying = candidates.select { |entry| range.satisfied_by?(entry.version) }
|
|
51
|
+
return satisfying.max_by(&:version).path unless satisfying.empty?
|
|
52
|
+
|
|
53
|
+
versions = candidates.map { |entry| entry.version.to_s }.sort.join(", ")
|
|
54
|
+
raise UnsatisfiableDependencyError,
|
|
55
|
+
"no version of #{guid} satisfies '#{range_string}' " \
|
|
56
|
+
"(store has: #{versions})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every .cap in the store with its metadata identity (memoized).
|
|
60
|
+
def catalog
|
|
61
|
+
@catalog ||= Dir.glob(File.join(@dir, CAP_GLOB)).map do |path|
|
|
62
|
+
catalog_entry(path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def indexed_path(guid)
|
|
69
|
+
file = load_index[guid]
|
|
70
|
+
file && File.join(@dir, file)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def indexed_satisfying(path, guid, range_string, range)
|
|
74
|
+
unless File.file?(path)
|
|
75
|
+
raise DependencyNotFoundError,
|
|
76
|
+
"#{INDEX_FILE} maps #{guid} to a missing file: #{path}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
entry = catalog_entry(path)
|
|
80
|
+
return entry.path if range.satisfied_by?(entry.version)
|
|
81
|
+
|
|
82
|
+
raise UnsatisfiableDependencyError,
|
|
83
|
+
"indexed #{guid} #{entry.version} does not satisfy '#{range_string}'"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def catalog_entry(cap_path)
|
|
87
|
+
metadata = read_metadata(cap_path)
|
|
88
|
+
CatalogEntry.new(path: cap_path, guid: metadata["guid"],
|
|
89
|
+
version: Version.parse(metadata["version"].to_s))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_metadata(cap_path)
|
|
93
|
+
Zip::File.open(cap_path) do |zip|
|
|
94
|
+
entry = zip.find_entry(Package::METADATA_FILE)
|
|
95
|
+
raise DependencyError, "no #{Package::METADATA_FILE} in #{cap_path}" unless entry
|
|
96
|
+
|
|
97
|
+
JSON.parse(entry.get_input_stream.read)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def load_index
|
|
102
|
+
@load_index ||= begin
|
|
103
|
+
path = File.join(@dir, INDEX_FILE)
|
|
104
|
+
File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
module Testing
|
|
9
|
+
# A "config" test (05x-testing): the configuration file must exist
|
|
10
|
+
# in the package and parse as the declared format (json or yaml).
|
|
11
|
+
# The known Capsium package configs (metadata.json, manifest.json,
|
|
12
|
+
# routes.json, storage.json, security.json) are additionally
|
|
13
|
+
# validated against their canonical models.
|
|
14
|
+
class ConfigTest < TestCase
|
|
15
|
+
FORMATS = %w[json yaml].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :format, :config_file
|
|
18
|
+
|
|
19
|
+
def initialize(name:, format:, config_file:)
|
|
20
|
+
super(name: name)
|
|
21
|
+
@format = format
|
|
22
|
+
@config_file = config_file
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(context)
|
|
26
|
+
problems = config_problems(context)
|
|
27
|
+
Result.new(name: name, ok: problems.empty?, messages: problems)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def config_problems(context)
|
|
33
|
+
return ["unsupported config format: #{format}"] unless FORMATS.include?(format)
|
|
34
|
+
|
|
35
|
+
full_path = File.join(context.package_path, config_file.delete_prefix("/"))
|
|
36
|
+
return ["config file missing in package: #{config_file}"] unless File.file?(full_path)
|
|
37
|
+
|
|
38
|
+
parse(full_path)
|
|
39
|
+
model_problems(full_path)
|
|
40
|
+
rescue JSON::ParserError, Psych::SyntaxError => e
|
|
41
|
+
["cannot parse #{config_file}: #{e.message}"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(full_path)
|
|
45
|
+
format == "yaml" ? YAML.load_file(full_path) : JSON.parse(File.read(full_path))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def model_problems(full_path)
|
|
49
|
+
case File.basename(config_file)
|
|
50
|
+
when Package::METADATA_FILE then Metadata.new(full_path).config.format_errors
|
|
51
|
+
when Package::MANIFEST_FILE then probe { ManifestConfig.from_json(File.read(full_path)) }
|
|
52
|
+
when Package::ROUTES_FILE then probe { RoutesConfig.from_json(File.read(full_path)) }
|
|
53
|
+
when Package::STORAGE_FILE then probe { StorageConfig.from_json(File.read(full_path)) }
|
|
54
|
+
when Package::SECURITY_FILE then probe { SecurityConfig.from_json(File.read(full_path)) }
|
|
55
|
+
else []
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def probe
|
|
60
|
+
yield
|
|
61
|
+
[]
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
[e.message]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
TestCase.register("config", ConfigTest)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
module Testing
|
|
6
|
+
# What a test run needs: the extracted package directory and, when
|
|
7
|
+
# route tests run, the base URL of the reactor serving the package.
|
|
8
|
+
Context = Data.define(:package_path, :base_url)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json-schema"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Capsium
|
|
8
|
+
class Package
|
|
9
|
+
module Testing
|
|
10
|
+
# A "data_validation" test (05x-testing): the rows of the data
|
|
11
|
+
# file (format: json or yaml) must validate against the JSON
|
|
12
|
+
# schema file. Array data validates row by row; a single document
|
|
13
|
+
# validates as one row.
|
|
14
|
+
class DataValidationTest < TestCase
|
|
15
|
+
FORMATS = %w[json yaml].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :format, :data_file, :schema_file
|
|
18
|
+
|
|
19
|
+
def initialize(name:, format:, data_file:, schema_file:)
|
|
20
|
+
super(name: name)
|
|
21
|
+
@format = format
|
|
22
|
+
@data_file = data_file
|
|
23
|
+
@schema_file = schema_file
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(context)
|
|
27
|
+
problems = validation_problems(context)
|
|
28
|
+
Result.new(name: name, ok: problems.empty?, messages: problems)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validation_problems(context)
|
|
34
|
+
return ["unsupported data format: #{format}"] unless FORMATS.include?(format)
|
|
35
|
+
|
|
36
|
+
schema = load_structured(context, schema_file)
|
|
37
|
+
rows = load_structured(context, data_file)
|
|
38
|
+
rows = [rows] unless rows.is_a?(Array)
|
|
39
|
+
rows.each_with_index.flat_map { |row, index| row_problems(schema, row, index) }
|
|
40
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::SyntaxError => e
|
|
41
|
+
["cannot load data or schema: #{e.message}"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def load_structured(context, relative_path)
|
|
45
|
+
full_path = File.join(context.package_path, relative_path.delete_prefix("/"))
|
|
46
|
+
if File.extname(full_path).match?(/\A\.ya?ml\z/i)
|
|
47
|
+
YAML.load_file(full_path)
|
|
48
|
+
else
|
|
49
|
+
JSON.parse(File.read(full_path))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def row_problems(schema, row, index)
|
|
54
|
+
JSON::Validator.fully_validate(schema, row).map do |message|
|
|
55
|
+
"row #{index}: #{message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
TestCase.register("data_validation", DataValidationTest)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
module Testing
|
|
6
|
+
# A "file" test (05x-testing): the file must exist in the package;
|
|
7
|
+
# optionally its content must contain a string.
|
|
8
|
+
class FileTest < TestCase
|
|
9
|
+
attr_reader :path, :contains
|
|
10
|
+
|
|
11
|
+
def initialize(name:, path:, contains: nil)
|
|
12
|
+
super(name: name)
|
|
13
|
+
@path = path
|
|
14
|
+
@contains = contains
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(context)
|
|
18
|
+
problems = existence_problems(context) + contains_problems(context)
|
|
19
|
+
Result.new(name: name, ok: problems.empty?, messages: problems)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def full_path(context)
|
|
25
|
+
File.join(context.package_path, path.delete_prefix("/"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def existence_problems(context)
|
|
29
|
+
return [] if File.file?(full_path(context))
|
|
30
|
+
|
|
31
|
+
["file missing in package: #{path}"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def contains_problems(context)
|
|
35
|
+
return [] unless contains && File.file?(full_path(context))
|
|
36
|
+
return [] if File.read(full_path(context)).include?(contains)
|
|
37
|
+
|
|
38
|
+
["file #{path} does not contain #{contains.inspect}"]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
TestCase.register("file", FileTest)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
module Testing
|
|
6
|
+
# The outcome of a test suite run: one Result per test case.
|
|
7
|
+
class Report
|
|
8
|
+
attr_reader :results
|
|
9
|
+
|
|
10
|
+
def initialize(results = [])
|
|
11
|
+
@results = results
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def <<(result)
|
|
15
|
+
@results << result
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ok?
|
|
20
|
+
@results.all?(&:ok?)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failures
|
|
24
|
+
@results.reject(&:ok?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def summary
|
|
28
|
+
"#{@results.size} tests, #{failures.size} failures"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
module Testing
|
|
9
|
+
# A "route" test (05x-testing): requests the URL path from the
|
|
10
|
+
# reactor serving the package and checks the expected status, and
|
|
11
|
+
# optionally the content type and a response body substring.
|
|
12
|
+
class RouteTest < TestCase
|
|
13
|
+
attr_reader :url, :expected_status, :response_contains, :expected_content_type
|
|
14
|
+
|
|
15
|
+
def initialize(name:, url:, expected_status:, response_contains: nil,
|
|
16
|
+
expected_content_type: nil)
|
|
17
|
+
super(name: name)
|
|
18
|
+
@url = url
|
|
19
|
+
@expected_status = expected_status
|
|
20
|
+
@response_contains = response_contains
|
|
21
|
+
@expected_content_type = expected_content_type
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(context)
|
|
25
|
+
response = Net::HTTP.get_response(URI.join(context.base_url, request_path))
|
|
26
|
+
problems = status_problems(response) + content_type_problems(response) +
|
|
27
|
+
body_problems(response)
|
|
28
|
+
Result.new(name: name, ok: problems.empty?, messages: problems)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# The DSL allows absolute URLs; only their path (and query) is
|
|
34
|
+
# used — the runner always targets its own reactor.
|
|
35
|
+
def request_path
|
|
36
|
+
uri = URI.parse(url)
|
|
37
|
+
uri.host ? uri.request_uri : url
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def status_problems(response)
|
|
41
|
+
return [] if response.code.to_i == expected_status
|
|
42
|
+
|
|
43
|
+
["expected status #{expected_status}, got #{response.code}"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def content_type_problems(response)
|
|
47
|
+
return [] unless expected_content_type
|
|
48
|
+
return [] if response.content_type == expected_content_type
|
|
49
|
+
|
|
50
|
+
["expected content type #{expected_content_type}, got #{response.content_type}"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def body_problems(response)
|
|
54
|
+
return [] unless response_contains
|
|
55
|
+
return [] if response.body.to_s.include?(response_contains)
|
|
56
|
+
|
|
57
|
+
["response does not contain #{response_contains.inspect}"]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
TestCase.register("route", RouteTest)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
module Testing
|
|
6
|
+
# Base class for one test of the Capsium testing YAML DSL
|
|
7
|
+
# (05x-testing). Subclasses implement a test kind and register
|
|
8
|
+
# themselves under their DSL type name (open/closed registry).
|
|
9
|
+
class TestCase
|
|
10
|
+
# The outcome of running one test.
|
|
11
|
+
Result = Data.define(:name, :ok, :messages) do
|
|
12
|
+
def ok? = ok
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised for invalid test definitions (unknown type,
|
|
16
|
+
# missing/unknown attributes, non-hash entries).
|
|
17
|
+
class DefinitionError < Capsium::Error; end
|
|
18
|
+
|
|
19
|
+
attr_reader :name
|
|
20
|
+
|
|
21
|
+
def initialize(name:)
|
|
22
|
+
@name = name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Runs the test against the context and returns a Result.
|
|
26
|
+
def run(context)
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.register(type, klass)
|
|
31
|
+
types[type] = klass
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.types
|
|
35
|
+
@types ||= {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Builds a test case from a YAML definition hash (string keys).
|
|
39
|
+
def self.build(definition)
|
|
40
|
+
unless definition.is_a?(Hash)
|
|
41
|
+
raise DefinitionError, "test definition is not a mapping: #{definition.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
type = definition["type"] ||
|
|
45
|
+
raise(DefinitionError, "test #{definition['name'].inspect} has no type")
|
|
46
|
+
klass = types[type] || raise(DefinitionError, "unknown test type: #{type}")
|
|
47
|
+
klass.from_h(definition)
|
|
48
|
+
rescue ArgumentError => e
|
|
49
|
+
raise DefinitionError, "invalid #{type} test #{definition['name'].inspect}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_h(definition)
|
|
53
|
+
attributes = definition.transform_keys(&:to_sym)
|
|
54
|
+
attributes.delete(:type)
|
|
55
|
+
new(**attributes)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|