schema-test 0.1.0 → 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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/tests.yml +3 -6
- data/Gemfile.lock +23 -24
- data/README.md +59 -0
- data/lib/schema_test/collapser.rb +58 -0
- data/lib/schema_test/collection.rb +4 -1
- data/lib/schema_test/configuration.rb +10 -0
- data/lib/schema_test/definition.rb +10 -23
- data/lib/schema_test/fingerprint_rewriter.rb +93 -0
- data/lib/schema_test/minitest.rb +62 -14
- data/lib/schema_test/property.rb +104 -56
- data/lib/schema_test/rewriter.rb +24 -7
- data/lib/schema_test/validator.rb +6 -2
- data/lib/schema_test/version.rb +1 -1
- data/lib/schema_test.rb +135 -4
- data/schema-test.gemspec +1 -1
- metadata +8 -6
- data/lib/schema_test/test_helper.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63c3ad67322093fabc506bf99f0f8677eb24b929d7f17bb569656d1951bc6e71
|
|
4
|
+
data.tar.gz: 3ef25c82500f915aeab5b522514778742a5235e4ae9289b1178f07be4faf998d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a3cc4befe4ecd6fec9f948ed6c9048306948a530b815a1dab4c2d127d4557b1a0e6d62ae61bd7ffcf4e7ea622a27fc3151ad4c0209a6cd8d1d15abe7a470586
|
|
7
|
+
data.tar.gz: f2dd3828fc7ce1ae14df985408d08e64444bd4efd406b71275daa49889c21caf7d78703f2490cfe8b4e6f1f1d3f9bb737f5d6c1cf54e6524ef1cd831a953a6f4
|
data/.github/workflows/tests.yml
CHANGED
|
@@ -19,15 +19,12 @@ jobs:
|
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
strategy:
|
|
21
21
|
matrix:
|
|
22
|
-
ruby-version: ['
|
|
22
|
+
ruby-version: ['3.1', '3.2', '3.3']
|
|
23
23
|
|
|
24
24
|
steps:
|
|
25
|
-
- uses: actions/checkout@
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
26
|
- name: Set up Ruby
|
|
27
|
-
|
|
28
|
-
# change this to (see https://github.com/ruby/setup-ruby#versioning):
|
|
29
|
-
# uses: ruby/setup-ruby@v1
|
|
30
|
-
uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e
|
|
27
|
+
uses: ruby/setup-ruby@v1
|
|
31
28
|
with:
|
|
32
29
|
ruby-version: ${{ matrix.ruby-version }}
|
|
33
30
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
data/Gemfile.lock
CHANGED
|
@@ -1,40 +1,39 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
schema-test (0.
|
|
4
|
+
schema-test (0.2.0)
|
|
5
5
|
json
|
|
6
6
|
json_schemer
|
|
7
7
|
|
|
8
8
|
GEM
|
|
9
9
|
remote: https://rubygems.org/
|
|
10
10
|
specs:
|
|
11
|
+
bigdecimal (3.1.8)
|
|
11
12
|
byebug (11.1.3)
|
|
12
|
-
diff-lcs (1.
|
|
13
|
-
ecma-re-validator (0.3.0)
|
|
14
|
-
regexp_parser (~> 2.0)
|
|
13
|
+
diff-lcs (1.5.1)
|
|
15
14
|
hana (1.3.7)
|
|
16
|
-
json (2.
|
|
17
|
-
json_schemer (
|
|
18
|
-
|
|
15
|
+
json (2.7.2)
|
|
16
|
+
json_schemer (2.3.0)
|
|
17
|
+
bigdecimal
|
|
19
18
|
hana (~> 1.3)
|
|
20
19
|
regexp_parser (~> 2.0)
|
|
21
|
-
|
|
22
|
-
rake (
|
|
23
|
-
regexp_parser (2.
|
|
24
|
-
rspec (3.
|
|
25
|
-
rspec-core (~> 3.
|
|
26
|
-
rspec-expectations (~> 3.
|
|
27
|
-
rspec-mocks (~> 3.
|
|
28
|
-
rspec-core (3.
|
|
29
|
-
rspec-support (~> 3.
|
|
30
|
-
rspec-expectations (3.
|
|
20
|
+
simpleidn (~> 0.2)
|
|
21
|
+
rake (13.2.1)
|
|
22
|
+
regexp_parser (2.9.2)
|
|
23
|
+
rspec (3.13.0)
|
|
24
|
+
rspec-core (~> 3.13.0)
|
|
25
|
+
rspec-expectations (~> 3.13.0)
|
|
26
|
+
rspec-mocks (~> 3.13.0)
|
|
27
|
+
rspec-core (3.13.0)
|
|
28
|
+
rspec-support (~> 3.13.0)
|
|
29
|
+
rspec-expectations (3.13.0)
|
|
31
30
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
32
|
-
rspec-support (~> 3.
|
|
33
|
-
rspec-mocks (3.
|
|
31
|
+
rspec-support (~> 3.13.0)
|
|
32
|
+
rspec-mocks (3.13.0)
|
|
34
33
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
35
|
-
rspec-support (~> 3.
|
|
36
|
-
rspec-support (3.
|
|
37
|
-
|
|
34
|
+
rspec-support (~> 3.13.0)
|
|
35
|
+
rspec-support (3.13.1)
|
|
36
|
+
simpleidn (0.2.3)
|
|
38
37
|
|
|
39
38
|
PLATFORMS
|
|
40
39
|
ruby
|
|
@@ -42,9 +41,9 @@ PLATFORMS
|
|
|
42
41
|
DEPENDENCIES
|
|
43
42
|
bundler (~> 2)
|
|
44
43
|
byebug
|
|
45
|
-
rake (~>
|
|
44
|
+
rake (~> 13.0)
|
|
46
45
|
rspec (~> 3.0)
|
|
47
46
|
schema-test!
|
|
48
47
|
|
|
49
48
|
BUNDLED WITH
|
|
50
|
-
2.
|
|
49
|
+
2.5.9
|
data/README.md
CHANGED
|
@@ -129,6 +129,7 @@ require 'schema_test/minitest'
|
|
|
129
129
|
SchemaTest.configure do |config|
|
|
130
130
|
config.domain = 'mydomain.com'
|
|
131
131
|
config.definition_paths << Rails.root.join('test', 'schema_definitions')
|
|
132
|
+
config.disable_rubocop = true # optional, set to true if using rubocop to disable it in the generated code
|
|
132
133
|
end
|
|
133
134
|
SchemaTest.load!
|
|
134
135
|
```
|
|
@@ -190,6 +191,64 @@ end
|
|
|
190
191
|
```
|
|
191
192
|
Keeping the full schema directly in the tests means that it is **impossible** for us to accidentally impact any API endpoints with a distant schema change without also producing some change in the test files for those endpoints. That is the main benefit that this library tries to acheive.
|
|
192
193
|
|
|
194
|
+
### Compiled mode
|
|
195
|
+
|
|
196
|
+
As an alternative to inlining expanded schemas into your test files, you can use **compiled mode**. In this mode, all schema definitions are compiled to standalone JSON Schema files on disk, and test assertions validate against those files directly. No test file rewriting happens.
|
|
197
|
+
|
|
198
|
+
To enable it, update your `test_helper.rb`:
|
|
199
|
+
|
|
200
|
+
``` ruby
|
|
201
|
+
require 'schema_test/minitest'
|
|
202
|
+
SchemaTest.configure do |config|
|
|
203
|
+
config.domain = 'mydomain.com'
|
|
204
|
+
config.definition_paths << Rails.root.join('test', 'schema_definitions')
|
|
205
|
+
config.compiled = true
|
|
206
|
+
end
|
|
207
|
+
SchemaTest.compile!
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This will create a `compiled` directory inside your schema definitions directory containing one JSON file per definition. The compiled files mirror the layout of your original definition files, so a definition declared in `api/v3/film.rb` is compiled to `compiled/api/v3/film.json`:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
test/schema_definitions/
|
|
214
|
+
├── api/
|
|
215
|
+
│ └── v3/
|
|
216
|
+
│ ├── film.rb
|
|
217
|
+
│ └── user.rb
|
|
218
|
+
└── compiled/
|
|
219
|
+
└── api/
|
|
220
|
+
└── v3/
|
|
221
|
+
├── film.json
|
|
222
|
+
├── user.v1.json
|
|
223
|
+
└── user.v2.json
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Versioned definitions are named `<name>.v<version>.json`; unversioned ones are simply `<name>.json`. Definitions without a known source location are written to the root of the `compiled` directory.
|
|
227
|
+
|
|
228
|
+
Your test assertions stay the same — `assert_valid_json_for_schema` will load the pre-compiled JSON schema file and validate against it:
|
|
229
|
+
|
|
230
|
+
``` ruby
|
|
231
|
+
test 'JSON returned matches schema' do
|
|
232
|
+
json = JSON.parse(response.body)
|
|
233
|
+
assert_valid_json_for_schema(json, :user, version: 1)
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
You can commit the compiled JSON files to your repository so that the schemas used in tests are visible in diffs, or add `compiled/` to your `.gitignore` and regenerate them as part of your test setup.
|
|
238
|
+
|
|
239
|
+
#### Schema fingerprints
|
|
240
|
+
|
|
241
|
+
In compiled mode the test files no longer contain the expanded schema, so a schema change wouldn't otherwise show up as a diff in the tests themselves. To keep that feedback, each assertion records a `fingerprint:` argument — a SHA-256 of the compiled schema:
|
|
242
|
+
|
|
243
|
+
``` ruby
|
|
244
|
+
test 'JSON returned matches schema' do
|
|
245
|
+
json = JSON.parse(response.body)
|
|
246
|
+
assert_valid_json_for_schema(json, :user, version: 1, fingerprint: '9f2c…')
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
You don't write the fingerprint by hand. When you run the tests locally, the assertion compares the fingerprint argument against the compiled schema and, if it is missing or stale, rewrites it in place. The resulting diff points at exactly which API endpoints changed. In CI (when `ENV['CI']` is set) a mismatched or missing fingerprint fails the test instead of rewriting it, so out-of-date assertions can't be merged.
|
|
251
|
+
|
|
193
252
|
## Development
|
|
194
253
|
|
|
195
254
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module SchemaTest
|
|
2
|
+
class Collapser
|
|
3
|
+
def initialize(contents)
|
|
4
|
+
@lines = contents.split("\n")
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def output
|
|
8
|
+
result = []
|
|
9
|
+
i = 0
|
|
10
|
+
while i < @lines.length
|
|
11
|
+
line = @lines[i]
|
|
12
|
+
|
|
13
|
+
# Skip rubocop:disable line immediately before an EXPANDED block
|
|
14
|
+
if line.match?(/#{DISABLE_RUBOCOP_COMMENT}/) &&
|
|
15
|
+
i + 1 < @lines.length && @lines[i + 1].match?(/#{OPENING_COMMENT}/)
|
|
16
|
+
i += 1
|
|
17
|
+
next
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if line =~ /\A(\s*)(\w+)\(\s*#{OPENING_COMMENT}/
|
|
21
|
+
indent = $1
|
|
22
|
+
method_name = $2
|
|
23
|
+
|
|
24
|
+
# Next line is the json variable
|
|
25
|
+
json_var = @lines[i + 1].strip.sub(/,\z/, '')
|
|
26
|
+
|
|
27
|
+
# Gather remaining content lines until closing marker
|
|
28
|
+
content_lines = []
|
|
29
|
+
j = i + 2
|
|
30
|
+
while j < @lines.length && !@lines[j].match?(/#{CLOSING_COMMENT}/)
|
|
31
|
+
content_lines << @lines[j]
|
|
32
|
+
j += 1
|
|
33
|
+
end
|
|
34
|
+
end_index = j
|
|
35
|
+
|
|
36
|
+
# Extract name and version from the content
|
|
37
|
+
joined = content_lines.map(&:strip).join(' ')
|
|
38
|
+
name = joined.match(/:(\w+),/)[1]
|
|
39
|
+
version_match = joined.match(/:version\s*=>\s*([^,}]+)/)
|
|
40
|
+
version = version_match[1].strip
|
|
41
|
+
|
|
42
|
+
result << "#{indent}#{method_name}(#{json_var}, :#{name}, version: #{version})"
|
|
43
|
+
|
|
44
|
+
# Skip rubocop:enable line immediately after END EXPANDED
|
|
45
|
+
i = end_index + 1
|
|
46
|
+
if i < @lines.length && @lines[i].match?(/#{ENABLE_RUBOCOP_COMMENT}/)
|
|
47
|
+
i += 1
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
result << line
|
|
51
|
+
i += 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result.join("\n") + "\n"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -2,9 +2,12 @@ require 'schema_test/property'
|
|
|
2
2
|
|
|
3
3
|
module SchemaTest
|
|
4
4
|
class Collection < SchemaTest::Property::Object
|
|
5
|
-
|
|
5
|
+
attr_reader :location
|
|
6
|
+
|
|
7
|
+
def initialize(name, of_name, location: nil, version: nil, description: nil)
|
|
6
8
|
super(name, version: version, description: description)
|
|
7
9
|
@item_type = lookup_object(of_name, version)
|
|
10
|
+
@location = location
|
|
8
11
|
SchemaTest::Definition.register(self)
|
|
9
12
|
end
|
|
10
13
|
|
|
@@ -9,9 +9,19 @@ module SchemaTest
|
|
|
9
9
|
# files in as many subdirectories are you like.
|
|
10
10
|
attr_accessor :definition_paths
|
|
11
11
|
|
|
12
|
+
# Set to true in order to disable running rubocop on the expanded schemas,
|
|
13
|
+
# as the Ruby PP output does not conform to rubocop's standards.
|
|
14
|
+
attr_accessor :disable_rubocop
|
|
15
|
+
|
|
16
|
+
# Set to true to use pre-compiled JSON schema files for validation
|
|
17
|
+
# instead of inlining expanded schemas into test files.
|
|
18
|
+
attr_accessor :compiled
|
|
19
|
+
|
|
12
20
|
def initialize
|
|
13
21
|
@domain = 'example.com'
|
|
14
22
|
@definition_paths = []
|
|
23
|
+
@disable_rubocop = false
|
|
24
|
+
@compiled = false
|
|
15
25
|
end
|
|
16
26
|
end
|
|
17
27
|
end
|
|
@@ -16,28 +16,22 @@ module SchemaTest
|
|
|
16
16
|
(@definitions || {}).dig(name, version)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def self.all
|
|
20
|
+
(@definitions || {}).values.flat_map(&:values)
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def self.find!(name, version)
|
|
20
24
|
found = find(name, version)
|
|
21
|
-
raise "Could not find schema for #{name.inspect} (version: #{version.inspect})" unless found
|
|
25
|
+
raise SchemaTest::Error, "Could not find schema for #{name.inspect} (version: #{version.inspect})" unless found
|
|
22
26
|
found
|
|
23
27
|
end
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
super
|
|
27
|
-
self.class.register(self)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def type(name, version=nil)
|
|
31
|
-
lookup_object(name, version || @version)
|
|
32
|
-
end
|
|
29
|
+
attr_reader :location
|
|
33
30
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def as_structure(_=nil)
|
|
39
|
-
hashes, others = @properties.values.map(&:as_structure).partition { |x| x.is_a?(Hash) }
|
|
40
|
-
others + [hashes.inject(&:merge)].compact
|
|
31
|
+
def initialize(name, location: nil, **attributes)
|
|
32
|
+
super(name, **attributes)
|
|
33
|
+
self.class.register(self)
|
|
34
|
+
@location = location
|
|
41
35
|
end
|
|
42
36
|
|
|
43
37
|
def as_json_schema(domain: SchemaTest.configuration.domain)
|
|
@@ -48,12 +42,5 @@ module SchemaTest
|
|
|
48
42
|
'title' => name.to_s
|
|
49
43
|
}.merge(super(false))
|
|
50
44
|
end
|
|
51
|
-
|
|
52
|
-
def based_on(name, version: self.version)
|
|
53
|
-
other_version = self.class.find(name, version)
|
|
54
|
-
other_version.properties.values.each do |property|
|
|
55
|
-
define_property(property.dup)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
45
|
end
|
|
59
46
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module SchemaTest
|
|
2
|
+
# Rewrites `assert_valid_json_for_schema` calls in test files so that
|
|
3
|
+
# they carry an up-to-date `fingerprint:` argument matching the
|
|
4
|
+
# compiled schema. This means a schema change shows up as a diff in
|
|
5
|
+
# the test files themselves, pointing at exactly which API endpoints
|
|
6
|
+
# changed, and lets the assertion verify the fingerprint at runtime.
|
|
7
|
+
#
|
|
8
|
+
# The call site reported at runtime is the line the call *starts* on,
|
|
9
|
+
# which for a multi-line call is the line with the opening paren. The
|
|
10
|
+
# rewriter therefore scans forward to find the matching closing paren
|
|
11
|
+
# before inserting or replacing the argument.
|
|
12
|
+
class FingerprintRewriter
|
|
13
|
+
FINGERPRINT_ARGUMENT = /fingerprint:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,)]+)/
|
|
14
|
+
|
|
15
|
+
def initialize(contents, line_indexes_with_fingerprints)
|
|
16
|
+
@lines = contents.split("\n")
|
|
17
|
+
@line_indexes_with_fingerprints = line_indexes_with_fingerprints
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def output
|
|
21
|
+
@line_indexes_with_fingerprints.each do |start_index, fingerprint|
|
|
22
|
+
next unless @lines[start_index]
|
|
23
|
+
annotate_call(start_index, fingerprint)
|
|
24
|
+
end
|
|
25
|
+
@lines.join("\n") + "\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def annotate_call(start_index, fingerprint)
|
|
31
|
+
close = find_closing_paren(start_index)
|
|
32
|
+
return unless close
|
|
33
|
+
end_index, paren_column = close
|
|
34
|
+
|
|
35
|
+
# If the call already carries a fingerprint argument, replace it.
|
|
36
|
+
(start_index..end_index).each do |index|
|
|
37
|
+
if @lines[index] =~ FINGERPRINT_ARGUMENT
|
|
38
|
+
@lines[index] = @lines[index].sub(FINGERPRINT_ARGUMENT, "fingerprint: '#{fingerprint}'")
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
argument = "fingerprint: '#{fingerprint}'"
|
|
44
|
+
close_line = @lines[end_index]
|
|
45
|
+
before_paren = close_line[0...paren_column]
|
|
46
|
+
|
|
47
|
+
if before_paren.strip.empty?
|
|
48
|
+
# The closing paren is on its own line; append the argument to the
|
|
49
|
+
# last argument line so we don't leave a leading comma dangling.
|
|
50
|
+
insert_index = (start_index...end_index).to_a.reverse.find { |index| !@lines[index].strip.empty? } || start_index
|
|
51
|
+
@lines[insert_index] = "#{@lines[insert_index].sub(/,\s*\z/, '')}, #{argument}"
|
|
52
|
+
else
|
|
53
|
+
@lines[end_index] = "#{before_paren.sub(/,\s*\z/, '')}, #{argument}#{close_line[paren_column..-1]}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Finds the closing paren matching the first opening paren at or after
|
|
58
|
+
# the start line, returning [line_index, column] or nil. Skips parens
|
|
59
|
+
# inside string literals and trailing comments.
|
|
60
|
+
def find_closing_paren(start_index)
|
|
61
|
+
depth = 0
|
|
62
|
+
started = false
|
|
63
|
+
(start_index...@lines.length).each do |line_index|
|
|
64
|
+
line = @lines[line_index]
|
|
65
|
+
string_delimiter = nil
|
|
66
|
+
column = 0
|
|
67
|
+
while column < line.length
|
|
68
|
+
char = line[column]
|
|
69
|
+
if string_delimiter
|
|
70
|
+
if char == '\\'
|
|
71
|
+
column += 2
|
|
72
|
+
next
|
|
73
|
+
elsif char == string_delimiter
|
|
74
|
+
string_delimiter = nil
|
|
75
|
+
end
|
|
76
|
+
elsif char == '"' || char == "'"
|
|
77
|
+
string_delimiter = char
|
|
78
|
+
elsif char == '#'
|
|
79
|
+
break
|
|
80
|
+
elsif char == '('
|
|
81
|
+
depth += 1
|
|
82
|
+
started = true
|
|
83
|
+
elsif char == ')'
|
|
84
|
+
depth -= 1
|
|
85
|
+
return [line_index, column] if started && depth.zero?
|
|
86
|
+
end
|
|
87
|
+
column += 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/schema_test/minitest.rb
CHANGED
|
@@ -2,23 +2,41 @@ require 'schema_test'
|
|
|
2
2
|
|
|
3
3
|
module SchemaTest
|
|
4
4
|
module Minitest
|
|
5
|
-
def assert_valid_json_for_schema(json, name,
|
|
6
|
-
|
|
5
|
+
def assert_valid_json_for_schema(json, name, arguments)
|
|
6
|
+
version = arguments[:version]
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
if SchemaTest.configuration.compiled
|
|
9
|
+
schema = SchemaTest.load_compiled_schema(name, version: version)
|
|
10
|
+
actual_fingerprint = SchemaTest.schema_fingerprint(schema)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
if arguments[:fingerprint] != actual_fingerprint
|
|
13
|
+
if ENV['CI']
|
|
14
|
+
flunk "Schema fingerprint mismatch for #{name.inspect} (version: #{version.inspect}) at #{caller[0]}. The compiled schema has changed; run the tests locally to update the fingerprint."
|
|
15
|
+
else
|
|
16
|
+
install_fingerprint_rewrite_hook
|
|
17
|
+
queue_write_schema_fingerprint(caller[0], actual_fingerprint)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
assert_json_schema_validates_against(json, schema)
|
|
22
|
+
else
|
|
23
|
+
install_assert_api_expansion_hook
|
|
24
|
+
|
|
25
|
+
schema = arguments[:schema]
|
|
26
|
+
|
|
27
|
+
definition = SchemaTest::Definition.find(name, version)
|
|
28
|
+
raise "Unknown definition #{name}, version: #{version}" unless definition.present?
|
|
29
|
+
|
|
30
|
+
expected_schema = definition.as_json_schema
|
|
12
31
|
|
|
13
|
-
|
|
14
|
-
if ENV['CI']
|
|
32
|
+
if schema != expected_schema && ENV['CI']
|
|
15
33
|
flunk "Outdated API schema assertion at #{caller[0]}"
|
|
16
|
-
else
|
|
17
|
-
queue_write_expanded_assert_api_call(caller[0], __method__, name, version, expected_schema)
|
|
18
34
|
end
|
|
19
|
-
end
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
queue_write_expanded_assert_api_call(caller[0], __method__, name, version, definition.location, expected_schema)
|
|
37
|
+
|
|
38
|
+
assert_json_schema_validates_against(json, expected_schema)
|
|
39
|
+
end
|
|
22
40
|
end
|
|
23
41
|
|
|
24
42
|
def assert_json_schema_validates_against(json, schema)
|
|
@@ -31,12 +49,17 @@ module SchemaTest
|
|
|
31
49
|
@@__api_schema_calls_for_expansion = {}
|
|
32
50
|
@@__api_schema_expansion_hook_installed = false
|
|
33
51
|
|
|
34
|
-
def queue_write_expanded_assert_api_call(call_site, method, name, version, expected_schema)
|
|
52
|
+
def queue_write_expanded_assert_api_call(call_site, method, name, version, location, expected_schema)
|
|
35
53
|
file, line = call_site.split(':')
|
|
36
54
|
line_index = line.to_i.pred
|
|
55
|
+
schema_call = [line_index, method, name, version, location, expected_schema]
|
|
37
56
|
|
|
38
57
|
@@__api_schema_calls_for_expansion[file] ||= []
|
|
39
|
-
@@__api_schema_calls_for_expansion[file]
|
|
58
|
+
if (existing_call = @@__api_schema_calls_for_expansion[file].find { |call| line_index == call[0] })
|
|
59
|
+
return if existing_call == schema_call
|
|
60
|
+
raise "Expected schema does not match for duplicate API schema assertion at #{call_site}"
|
|
61
|
+
end
|
|
62
|
+
@@__api_schema_calls_for_expansion[file] << [line_index, method, name, version, location, expected_schema]
|
|
40
63
|
end
|
|
41
64
|
|
|
42
65
|
def install_assert_api_expansion_hook
|
|
@@ -48,11 +71,36 @@ module SchemaTest
|
|
|
48
71
|
def expand_assert_api_calls
|
|
49
72
|
@@__api_schema_calls_for_expansion.each do |file, line_indexes_with_schemas|
|
|
50
73
|
original_contents = File.read(file)
|
|
51
|
-
|
|
74
|
+
rewriter_options = { disable_rubocop: SchemaTest.configuration.disable_rubocop }
|
|
75
|
+
rewriter = SchemaTest::Rewriter.new(original_contents, line_indexes_with_schemas, options: rewriter_options)
|
|
52
76
|
new_contents = rewriter.output
|
|
53
77
|
raise "Error rewriting file" if new_contents.blank?
|
|
54
78
|
File.open(file, 'w') { |f| f.puts new_contents }
|
|
55
79
|
end
|
|
56
80
|
end
|
|
81
|
+
|
|
82
|
+
@@__schema_fingerprints = {}
|
|
83
|
+
@@__schema_fingerprint_hook_installed = false
|
|
84
|
+
|
|
85
|
+
def queue_write_schema_fingerprint(call_site, fingerprint)
|
|
86
|
+
file, line = call_site.split(':')
|
|
87
|
+
line_index = line.to_i.pred
|
|
88
|
+
@@__schema_fingerprints[file] ||= {}
|
|
89
|
+
@@__schema_fingerprints[file][line_index] = fingerprint
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def install_fingerprint_rewrite_hook
|
|
93
|
+
return if @@__schema_fingerprint_hook_installed
|
|
94
|
+
at_exit { write_schema_fingerprints }
|
|
95
|
+
@@__schema_fingerprint_hook_installed = true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def write_schema_fingerprints
|
|
99
|
+
@@__schema_fingerprints.each do |file, line_indexes_with_fingerprints|
|
|
100
|
+
original_contents = File.read(file)
|
|
101
|
+
rewriter = SchemaTest::FingerprintRewriter.new(original_contents, line_indexes_with_fingerprints)
|
|
102
|
+
File.open(file, 'w') { |f| f.print rewriter.output }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
57
105
|
end
|
|
58
106
|
end
|
data/lib/schema_test/property.rb
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
module SchemaTest
|
|
2
2
|
class Property
|
|
3
|
-
|
|
3
|
+
NULL_TYPE = 'null'.freeze
|
|
4
|
+
|
|
5
|
+
attr_reader :name, :_type, :description
|
|
4
6
|
|
|
5
7
|
def initialize(name, type, description=nil)
|
|
6
8
|
@name = name
|
|
7
|
-
@
|
|
9
|
+
@_type = type
|
|
8
10
|
@description = description
|
|
9
11
|
@optional = false
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def as_structure(_=nil)
|
|
13
|
-
if @optional
|
|
14
|
-
{ name => nil }
|
|
15
|
-
else
|
|
16
|
-
name
|
|
17
|
-
end
|
|
12
|
+
@nullable = false
|
|
18
13
|
end
|
|
19
14
|
|
|
20
15
|
def as_json_schema
|
|
@@ -25,28 +20,69 @@ module SchemaTest
|
|
|
25
20
|
end
|
|
26
21
|
|
|
27
22
|
def ==(other)
|
|
23
|
+
return false unless other.is_a?(SchemaTest::Property)
|
|
24
|
+
|
|
28
25
|
name == other.name &&
|
|
29
|
-
|
|
26
|
+
_type == other._type &&
|
|
30
27
|
description == other.description &&
|
|
31
|
-
optional == other.optional
|
|
28
|
+
optional? == other.optional? &&
|
|
29
|
+
nullable? == other.nullable?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def optional(object)
|
|
33
|
+
object.tap(&:optional!)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def nullable(object)
|
|
37
|
+
object.tap(&:nullable!)
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def optional?
|
|
35
|
-
optional
|
|
41
|
+
@optional
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def optional!
|
|
39
45
|
@optional = true
|
|
40
46
|
end
|
|
41
47
|
|
|
48
|
+
def nullable?
|
|
49
|
+
@nullable
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def nullable!
|
|
53
|
+
@nullable = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def lookup_object(name, *versions)
|
|
57
|
+
UnresolvedProperty.new(name, versions: versions)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def type(name, version: nil)
|
|
61
|
+
lookup_object(name, version || @version)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def base_json_schema_type
|
|
65
|
+
@_type.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
42
68
|
def json_schema_type
|
|
43
|
-
|
|
69
|
+
if nullable?
|
|
70
|
+
[base_json_schema_type, NULL_TYPE]
|
|
71
|
+
else
|
|
72
|
+
base_json_schema_type
|
|
73
|
+
end
|
|
44
74
|
end
|
|
45
75
|
|
|
46
76
|
def json_schema_format
|
|
47
77
|
nil
|
|
48
78
|
end
|
|
49
79
|
|
|
80
|
+
class Nil < SchemaTest::Property
|
|
81
|
+
def initialize(name, description=nil)
|
|
82
|
+
super(name, :null, description)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
50
86
|
class Boolean < SchemaTest::Property
|
|
51
87
|
def initialize(name, description=nil)
|
|
52
88
|
super(name, :boolean, description)
|
|
@@ -64,7 +100,7 @@ module SchemaTest
|
|
|
64
100
|
super(name, :float, description)
|
|
65
101
|
end
|
|
66
102
|
|
|
67
|
-
def
|
|
103
|
+
def base_json_schema_type
|
|
68
104
|
'number'
|
|
69
105
|
end
|
|
70
106
|
end
|
|
@@ -75,12 +111,26 @@ module SchemaTest
|
|
|
75
111
|
end
|
|
76
112
|
end
|
|
77
113
|
|
|
114
|
+
class Date < SchemaTest::Property
|
|
115
|
+
def initialize(name, description=nil)
|
|
116
|
+
super(name, :date, description)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def base_json_schema_type
|
|
120
|
+
'string'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def json_schema_format
|
|
124
|
+
'date'
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
78
128
|
class DateTime < SchemaTest::Property
|
|
79
129
|
def initialize(name, description=nil)
|
|
80
130
|
super(name, :datetime, description)
|
|
81
131
|
end
|
|
82
132
|
|
|
83
|
-
def
|
|
133
|
+
def base_json_schema_type
|
|
84
134
|
'string'
|
|
85
135
|
end
|
|
86
136
|
|
|
@@ -96,30 +146,37 @@ module SchemaTest
|
|
|
96
146
|
end
|
|
97
147
|
|
|
98
148
|
class SchemaTest::Property::Object < SchemaTest::Property
|
|
99
|
-
attr_reader :version
|
|
149
|
+
attr_reader :version, :excluded_property_names
|
|
100
150
|
|
|
101
|
-
def initialize(name, description: nil, version: nil, from: nil, properties: nil, &block)
|
|
102
|
-
|
|
103
|
-
@description = description
|
|
151
|
+
def initialize(name, description: nil, version: nil, from: nil, properties: nil, except: [], &block)
|
|
152
|
+
super(name, :object, description)
|
|
104
153
|
@version = version
|
|
105
154
|
@specific_properties = properties
|
|
106
155
|
@properties = {}
|
|
156
|
+
@excluded_property_names = except
|
|
107
157
|
@from = from
|
|
108
158
|
instance_eval(&block) if block_given?
|
|
109
159
|
end
|
|
110
160
|
|
|
111
161
|
def properties
|
|
112
162
|
resolve
|
|
113
|
-
@properties
|
|
163
|
+
@properties.reject { |p| excluded_property_names.include?(p) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def based_on(name, version: self.version, except: [])
|
|
167
|
+
@from = lookup_object(name, version)
|
|
168
|
+
@excluded_property_names = except
|
|
114
169
|
end
|
|
115
170
|
|
|
116
171
|
def ==(other)
|
|
117
|
-
super &&
|
|
172
|
+
super &&
|
|
173
|
+
properties.all? { |name, property| property == other.properties[name] } &&
|
|
174
|
+
excluded_property_names == other.excluded_property_names
|
|
118
175
|
end
|
|
119
176
|
|
|
120
177
|
def resolve
|
|
121
178
|
if @from
|
|
122
|
-
@properties.merge
|
|
179
|
+
@properties = @from.properties.merge(@properties)
|
|
123
180
|
@from = nil
|
|
124
181
|
end
|
|
125
182
|
if @specific_properties
|
|
@@ -146,8 +203,10 @@ module SchemaTest
|
|
|
146
203
|
float: SchemaTest::Property::Float,
|
|
147
204
|
string: SchemaTest::Property::String,
|
|
148
205
|
datetime: SchemaTest::Property::DateTime,
|
|
206
|
+
date: SchemaTest::Property::Date,
|
|
149
207
|
url: SchemaTest::Property::Uri,
|
|
150
|
-
html: SchemaTest::Property::String
|
|
208
|
+
html: SchemaTest::Property::String,
|
|
209
|
+
null: SchemaTest::Property::Nil
|
|
151
210
|
}
|
|
152
211
|
|
|
153
212
|
TYPES.each do |method_name, type_class|
|
|
@@ -160,12 +219,20 @@ module SchemaTest
|
|
|
160
219
|
define_property(SchemaTest::Property::Array.new(name, of, desc, &block))
|
|
161
220
|
end
|
|
162
221
|
|
|
163
|
-
def object(name, desc: nil, as: name, version: nil, &block)
|
|
222
|
+
def object(name, desc: nil, as: name, version: nil, except: [], &block)
|
|
164
223
|
inferred_version = version || @version
|
|
165
224
|
if block_given?
|
|
166
225
|
define_property(SchemaTest::Property::Object.new(as, description: desc, version: inferred_version, &block))
|
|
167
226
|
else
|
|
168
|
-
define_property(
|
|
227
|
+
define_property(
|
|
228
|
+
SchemaTest::Property::Object.new(
|
|
229
|
+
as,
|
|
230
|
+
description: desc,
|
|
231
|
+
version: inferred_version,
|
|
232
|
+
from: lookup_object(name, inferred_version, nil),
|
|
233
|
+
except: except
|
|
234
|
+
)
|
|
235
|
+
)
|
|
169
236
|
end
|
|
170
237
|
end
|
|
171
238
|
|
|
@@ -185,15 +252,7 @@ module SchemaTest
|
|
|
185
252
|
end
|
|
186
253
|
end
|
|
187
254
|
|
|
188
|
-
def
|
|
189
|
-
if include_root
|
|
190
|
-
{ name => properties.values.map(&:as_structure) }
|
|
191
|
-
else
|
|
192
|
-
properties.values.map(&:as_structure)
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def json_schema_type
|
|
255
|
+
def base_json_schema_type
|
|
197
256
|
'object'
|
|
198
257
|
end
|
|
199
258
|
|
|
@@ -202,15 +261,20 @@ module SchemaTest
|
|
|
202
261
|
def define_property(attribute)
|
|
203
262
|
@properties[attribute.name] = attribute
|
|
204
263
|
end
|
|
205
|
-
|
|
206
|
-
def lookup_object(name, version)
|
|
207
|
-
UnresolvedProperty.new(name, version: version)
|
|
208
|
-
end
|
|
209
264
|
end
|
|
210
265
|
|
|
211
266
|
class UnresolvedProperty < SchemaTest::Property::Object
|
|
267
|
+
def initialize(name, versions:)
|
|
268
|
+
@name = name
|
|
269
|
+
@versions = versions
|
|
270
|
+
end
|
|
271
|
+
|
|
212
272
|
def resolve
|
|
213
|
-
|
|
273
|
+
@versions.each do |v|
|
|
274
|
+
definition = SchemaTest::Definition.find(@name, v)
|
|
275
|
+
return definition if definition
|
|
276
|
+
end
|
|
277
|
+
raise SchemaTest::Error, "could not resolve schema #{@name.inspect}; tried versions: #{@versions.inspect}"
|
|
214
278
|
end
|
|
215
279
|
|
|
216
280
|
def ==(other)
|
|
@@ -220,20 +284,12 @@ module SchemaTest
|
|
|
220
284
|
def properties
|
|
221
285
|
resolve.properties
|
|
222
286
|
end
|
|
223
|
-
|
|
224
|
-
def as_structure(*args)
|
|
225
|
-
resolve.as_structure(*args)
|
|
226
|
-
end
|
|
227
287
|
end
|
|
228
288
|
|
|
229
289
|
class AnonymousObject < SchemaTest::Property::Object
|
|
230
290
|
def initialize(properties: nil, &block)
|
|
231
291
|
super(nil, properties: properties, &block)
|
|
232
292
|
end
|
|
233
|
-
|
|
234
|
-
def as_structure(_=nil)
|
|
235
|
-
super(false)
|
|
236
|
-
end
|
|
237
293
|
end
|
|
238
294
|
|
|
239
295
|
class Array < SchemaTest::Property
|
|
@@ -253,14 +309,6 @@ module SchemaTest
|
|
|
253
309
|
super && @item_type == other.item_type
|
|
254
310
|
end
|
|
255
311
|
|
|
256
|
-
def as_structure(_=nil)
|
|
257
|
-
if @item_type.is_a?(SchemaTest::Property)
|
|
258
|
-
{ name => @item_type.as_structure(false) }
|
|
259
|
-
else
|
|
260
|
-
{ name => [] }
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
312
|
def as_json_schema
|
|
265
313
|
super.tap do |json_schema|
|
|
266
314
|
item_schema = @item_type.is_a?(SchemaTest::Property) ? @item_type.as_json_schema(false) : { 'type' => @item_type.to_s }
|
data/lib/schema_test/rewriter.rb
CHANGED
|
@@ -4,23 +4,37 @@ module SchemaTest
|
|
|
4
4
|
OPENING_COMMENT = '# EXPANDED'.freeze
|
|
5
5
|
CLOSING_COMMENT = '# END EXPANDED'.freeze
|
|
6
6
|
|
|
7
|
+
DISABLE_RUBOCOP_COMMENT = '# rubocop:disable all'.freeze
|
|
8
|
+
ENABLE_RUBOCOP_COMMENT = '# rubocop:enable all'.freeze
|
|
9
|
+
|
|
7
10
|
class Rewriter
|
|
8
|
-
def initialize(contents, line_indexes_with_schemas)
|
|
11
|
+
def initialize(contents, line_indexes_with_schemas, options: {})
|
|
9
12
|
@lines = contents.split("\n")
|
|
10
13
|
@line_indexes_with_schemas = line_indexes_with_schemas
|
|
14
|
+
|
|
15
|
+
@disable_rubocop = options.fetch(:disable_rubocop, false)
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
def output
|
|
14
19
|
current_offset = 0
|
|
15
|
-
line_indexes_with_schemas.sort_by { |(line,_)| line }.each do |index, method, name, version, expected_schema|
|
|
20
|
+
line_indexes_with_schemas.sort_by { |(line,_)| line }.each do |index, method, name, version, location, expected_schema|
|
|
16
21
|
start_index = index + current_offset
|
|
17
|
-
if lines[start_index
|
|
22
|
+
if lines[start_index - 1].match?(/#{DISABLE_RUBOCOP_COMMENT}/)
|
|
23
|
+
lines.delete_at(start_index - 1)
|
|
24
|
+
start_index -= 1
|
|
25
|
+
current_offset -= 2
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if lines[start_index] =~ /#{OPENING_COMMENT}/
|
|
18
29
|
end_index = start_index + lines[start_index..-1].find_index { |line| line =~ /#{CLOSING_COMMENT}\s*\z/ }
|
|
30
|
+
lines.delete_at(end_index + 1) if lines[end_index + 1]&.match?(/#{ENABLE_RUBOCOP_COMMENT}/)
|
|
19
31
|
json_variable_name = lines[start_index + 1].strip.gsub(/,\z/, '')
|
|
20
32
|
else
|
|
21
33
|
end_index = start_index
|
|
22
34
|
json_variable_name = lines[start_index].match(/\(([^,]+)/)[1]
|
|
23
35
|
end
|
|
36
|
+
|
|
37
|
+
original_method_definition_length = end_index - start_index
|
|
24
38
|
start_indent = lines[start_index].match(/\A(\s*)/)[0].length
|
|
25
39
|
(end_index - start_index + 1).times { |i| lines.delete_at(start_index) }
|
|
26
40
|
|
|
@@ -31,14 +45,16 @@ module SchemaTest
|
|
|
31
45
|
expanded_schema_lines.unshift(json_variable_name + ',')
|
|
32
46
|
|
|
33
47
|
method_string = [
|
|
34
|
-
(' ' * start_indent) +
|
|
48
|
+
disable_rubocop ? (' ' * start_indent) + DISABLE_RUBOCOP_COMMENT : nil,
|
|
49
|
+
(' ' * start_indent) + method.to_s + "( #{OPENING_COMMENT} from #{location}",
|
|
35
50
|
*expanded_schema_lines.map { |line| (' ' * (start_indent + 2)) + line },
|
|
36
|
-
(' ' * start_indent) + ") #{CLOSING_COMMENT}"
|
|
37
|
-
|
|
51
|
+
(' ' * start_indent) + ") #{CLOSING_COMMENT}",
|
|
52
|
+
disable_rubocop ? (' ' * start_indent) + ENABLE_RUBOCOP_COMMENT: nil,
|
|
53
|
+
].compact
|
|
38
54
|
|
|
39
55
|
method_string.reverse.each { |line| lines.insert(start_index, line) }
|
|
40
56
|
|
|
41
|
-
current_offset += method_string.count - 1
|
|
57
|
+
current_offset += method_string.count - original_method_definition_length - 1
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
lines.compact.join("\n") + "\n"
|
|
@@ -47,5 +63,6 @@ module SchemaTest
|
|
|
47
63
|
private
|
|
48
64
|
|
|
49
65
|
attr_reader :lines, :line_indexes_with_schemas
|
|
66
|
+
attr_reader :disable_rubocop
|
|
50
67
|
end
|
|
51
68
|
end
|
|
@@ -40,9 +40,13 @@ module SchemaTest
|
|
|
40
40
|
when 'format'
|
|
41
41
|
"format should be #{error['schema']['format']}"
|
|
42
42
|
when 'required'
|
|
43
|
-
"missing some required attributes"
|
|
43
|
+
"missing some required attributes: #{error['details'].inspect}"
|
|
44
44
|
else
|
|
45
|
-
|
|
45
|
+
if error['type'] == 'type'
|
|
46
|
+
"type should be one of #{error['schema']['type'].inspect}"
|
|
47
|
+
else
|
|
48
|
+
"type should be #{error['type']}"
|
|
49
|
+
end
|
|
46
50
|
end
|
|
47
51
|
"value at #{error['data_pointer']} (#{error['data'].inspect}) failed validation: #{message}"
|
|
48
52
|
end
|
data/lib/schema_test/version.rb
CHANGED
data/lib/schema_test.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'digest'
|
|
1
3
|
require 'schema_test/version'
|
|
2
4
|
require 'schema_test/rewriter'
|
|
5
|
+
require 'schema_test/fingerprint_rewriter'
|
|
6
|
+
require 'schema_test/collapser'
|
|
3
7
|
require 'schema_test/definition'
|
|
4
8
|
require 'schema_test/collection'
|
|
5
9
|
require 'schema_test/validator'
|
|
@@ -10,6 +14,13 @@ module SchemaTest
|
|
|
10
14
|
|
|
11
15
|
SCHEMA_VERSION = "http://json-schema.org/draft-07/schema#"
|
|
12
16
|
|
|
17
|
+
# The number of leading hex characters of the schema digest kept as a
|
|
18
|
+
# fingerprint. The fingerprint only needs to change when the schema
|
|
19
|
+
# changes (it is compared against a single expected value, never
|
|
20
|
+
# searched for collisions), so a short prefix keeps the assertion lines
|
|
21
|
+
# readable while remaining collision-free in practice.
|
|
22
|
+
FINGERPRINT_LENGTH = 12
|
|
23
|
+
|
|
13
24
|
class << self
|
|
14
25
|
def reset!
|
|
15
26
|
@configuration = nil
|
|
@@ -33,7 +44,7 @@ module SchemaTest
|
|
|
33
44
|
|
|
34
45
|
# Define a new schema
|
|
35
46
|
def define(name, collection: nil, **attributes, &block)
|
|
36
|
-
definition = SchemaTest::Definition.new(name, attributes, &block)
|
|
47
|
+
definition = SchemaTest::Definition.new(name, location: definition_location(caller[0]), **attributes, &block)
|
|
37
48
|
if collection
|
|
38
49
|
collection(collection, of: name, version: attributes[:version])
|
|
39
50
|
end
|
|
@@ -43,7 +54,67 @@ module SchemaTest
|
|
|
43
54
|
# Explicitly define a new schema collection (an array of other schema
|
|
44
55
|
# objects)
|
|
45
56
|
def collection(name, of:, **attributes)
|
|
46
|
-
SchemaTest::Collection.new(name, of, attributes)
|
|
57
|
+
SchemaTest::Collection.new(name, of, location: definition_location(caller[1]), **attributes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Compile all definitions to JSON Schema files in a `compiled`
|
|
61
|
+
# directory within each definition path. The compiled files mirror
|
|
62
|
+
# the layout of the original definition files, so a definition
|
|
63
|
+
# declared in `api/v3/film.rb` is written to
|
|
64
|
+
# `compiled/api/v3/film.json`.
|
|
65
|
+
def compile!
|
|
66
|
+
load_definitions
|
|
67
|
+
SchemaTest::Definition.all.each do |definition|
|
|
68
|
+
begin
|
|
69
|
+
definition_path = owning_definition_path(definition)
|
|
70
|
+
next unless definition_path
|
|
71
|
+
path = Pathname.new(definition_path).join('compiled', compiled_relative_path(definition))
|
|
72
|
+
path.dirname.mkpath
|
|
73
|
+
File.write(path, JSON.pretty_generate(definition.as_json_schema) + "\n")
|
|
74
|
+
rescue => e
|
|
75
|
+
warn "SchemaTest: failed to compile #{definition.name} (version: #{definition.version}): #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Load a pre-compiled JSON schema from the compiled directory.
|
|
81
|
+
# Because compiled files mirror the original definition layout, the
|
|
82
|
+
# file may live in a nested subdirectory, so the compiled tree is
|
|
83
|
+
# searched recursively for the expected filename.
|
|
84
|
+
def load_compiled_schema(name, version: nil)
|
|
85
|
+
filename = compiled_filename(name, version)
|
|
86
|
+
configuration.definition_paths.each do |definition_path|
|
|
87
|
+
compiled_root = Pathname.new(definition_path).join('compiled')
|
|
88
|
+
match = Pathname.glob(compiled_root.join('**', filename)).first
|
|
89
|
+
return JSON.parse(match.read) if match
|
|
90
|
+
end
|
|
91
|
+
raise SchemaTest::Error, "Could not find compiled schema for #{name.inspect} (version: #{version.inspect})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A stable fingerprint of a compiled schema. The fingerprint is
|
|
95
|
+
# derived from the schema's semantic content, so it changes whenever
|
|
96
|
+
# the schema changes but is unaffected by pretty-print formatting.
|
|
97
|
+
def schema_fingerprint(schema)
|
|
98
|
+
Digest::SHA256.hexdigest(JSON.generate(schema))[0, FINGERPRINT_LENGTH]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Collapse expanded schema assertions in test files back to
|
|
102
|
+
# simple one-line calls. Pass file paths or directory paths.
|
|
103
|
+
# Directories are globbed for **/*.rb files.
|
|
104
|
+
def collapse!(*paths)
|
|
105
|
+
files = paths.flat_map do |path|
|
|
106
|
+
if File.directory?(path)
|
|
107
|
+
Dir[File.join(path, '**', '*.rb')]
|
|
108
|
+
else
|
|
109
|
+
[path]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
files.each do |file|
|
|
113
|
+
contents = File.read(file)
|
|
114
|
+
next unless contents.include?(OPENING_COMMENT)
|
|
115
|
+
collapser = SchemaTest::Collapser.new(contents)
|
|
116
|
+
File.write(file, collapser.output)
|
|
117
|
+
end
|
|
47
118
|
end
|
|
48
119
|
|
|
49
120
|
# Validate some JSON data against a schema or schema definition
|
|
@@ -58,9 +129,69 @@ module SchemaTest
|
|
|
58
129
|
|
|
59
130
|
private
|
|
60
131
|
|
|
132
|
+
# The filename a definition compiles to, e.g. `film.json` or
|
|
133
|
+
# `film.v2.json` for versioned definitions.
|
|
134
|
+
def compiled_filename(name, version)
|
|
135
|
+
if version
|
|
136
|
+
"#{name}.v#{version}.json"
|
|
137
|
+
else
|
|
138
|
+
"#{name}.json"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The relative source file a definition was declared in (without the
|
|
143
|
+
# trailing line number), or nil if it has no known location.
|
|
144
|
+
def definition_source_file(definition)
|
|
145
|
+
return nil unless definition.location
|
|
146
|
+
source = definition.location.rpartition(':').first
|
|
147
|
+
source = definition.location if source.empty?
|
|
148
|
+
source.empty? ? nil : source
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The definition path a definition's source file lives under, so
|
|
152
|
+
# that each schema is compiled exactly once into the `compiled`
|
|
153
|
+
# directory of its owning path rather than duplicated into every
|
|
154
|
+
# definition path. Definitions whose source cannot be located (for
|
|
155
|
+
# example, those constructed directly without a location) fall back
|
|
156
|
+
# to the first configured definition path.
|
|
157
|
+
def owning_definition_path(definition)
|
|
158
|
+
source = definition_source_file(definition)
|
|
159
|
+
if source
|
|
160
|
+
owning = configuration.definition_paths.find do |definition_path|
|
|
161
|
+
Pathname.new(definition_path).join(source).exist?
|
|
162
|
+
end
|
|
163
|
+
return owning if owning
|
|
164
|
+
end
|
|
165
|
+
configuration.definition_paths.first
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The path a definition compiles to, relative to the `compiled`
|
|
169
|
+
# directory. The directory mirrors the location of the source
|
|
170
|
+
# definition file (e.g. `api/v3/film.rb` -> `api/v3/film.json`).
|
|
171
|
+
# Definitions without a known location are written to the root of
|
|
172
|
+
# the compiled directory.
|
|
173
|
+
def compiled_relative_path(definition)
|
|
174
|
+
filename = compiled_filename(definition.name, definition.version)
|
|
175
|
+
source = definition_source_file(definition)
|
|
176
|
+
return Pathname.new(filename) unless source
|
|
177
|
+
Pathname.new(source).dirname.join(filename)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def definition_location(caller_line)
|
|
181
|
+
path, line = caller_line.split(':').take(2)
|
|
182
|
+
configuration.definition_paths.each do |definition_path|
|
|
183
|
+
if path.start_with?(definition_path.to_s)
|
|
184
|
+
path = Pathname.new(path).relative_path_from(definition_path)
|
|
185
|
+
break
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
[path, line].join(':')
|
|
189
|
+
end
|
|
190
|
+
|
|
61
191
|
def load_definitions
|
|
62
|
-
|
|
63
|
-
|
|
192
|
+
configuration.definition_paths.map! { |p| Pathname.new(p) }
|
|
193
|
+
globbed_paths = configuration.definition_paths.map { |path| path.join('**', '*.rb').to_s }
|
|
194
|
+
Dir[*globbed_paths].each do |schema_file|
|
|
64
195
|
require schema_file
|
|
65
196
|
end
|
|
66
197
|
end
|
data/schema-test.gemspec
CHANGED
|
@@ -39,7 +39,7 @@ Gem::Specification.new do |spec|
|
|
|
39
39
|
spec.add_dependency 'json_schemer'
|
|
40
40
|
|
|
41
41
|
spec.add_development_dependency "bundler", "~> 2"
|
|
42
|
-
spec.add_development_dependency "rake", "~>
|
|
42
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
43
43
|
spec.add_development_dependency "rspec", "~> 3.0"
|
|
44
44
|
spec.add_development_dependency "byebug"
|
|
45
45
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: schema-test
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Adam
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-06-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -58,14 +58,14 @@ dependencies:
|
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '
|
|
61
|
+
version: '13.0'
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '
|
|
68
|
+
version: '13.0'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: rspec
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -101,6 +101,7 @@ executables: []
|
|
|
101
101
|
extensions: []
|
|
102
102
|
extra_rdoc_files: []
|
|
103
103
|
files:
|
|
104
|
+
- ".github/dependabot.yml"
|
|
104
105
|
- ".github/workflows/tests.yml"
|
|
105
106
|
- ".gitignore"
|
|
106
107
|
- ".rspec"
|
|
@@ -113,13 +114,14 @@ files:
|
|
|
113
114
|
- bin/console
|
|
114
115
|
- bin/setup
|
|
115
116
|
- lib/schema_test.rb
|
|
117
|
+
- lib/schema_test/collapser.rb
|
|
116
118
|
- lib/schema_test/collection.rb
|
|
117
119
|
- lib/schema_test/configuration.rb
|
|
118
120
|
- lib/schema_test/definition.rb
|
|
121
|
+
- lib/schema_test/fingerprint_rewriter.rb
|
|
119
122
|
- lib/schema_test/minitest.rb
|
|
120
123
|
- lib/schema_test/property.rb
|
|
121
124
|
- lib/schema_test/rewriter.rb
|
|
122
|
-
- lib/schema_test/test_helper.rb
|
|
123
125
|
- lib/schema_test/validator.rb
|
|
124
126
|
- lib/schema_test/version.rb
|
|
125
127
|
- schema-test.gemspec
|
|
@@ -146,7 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
146
148
|
- !ruby/object:Gem::Version
|
|
147
149
|
version: '0'
|
|
148
150
|
requirements: []
|
|
149
|
-
rubygems_version: 3.
|
|
151
|
+
rubygems_version: 3.5.22
|
|
150
152
|
signing_key:
|
|
151
153
|
specification_version: 4
|
|
152
154
|
summary: API testing against declarative schemas
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
module SchemaTest
|
|
2
|
-
class TestHelper
|
|
3
|
-
def assert_api_schema(name, version:, structure: nil)
|
|
4
|
-
install_asset_api_expansion_hook
|
|
5
|
-
|
|
6
|
-
definition = ApiSchema::Definition.find(name, version)
|
|
7
|
-
raise "Unknown definition #{name}, version: #{version}" unless definition.present?
|
|
8
|
-
|
|
9
|
-
expected_structure = definition.as_structure
|
|
10
|
-
|
|
11
|
-
if structure != expected_structure
|
|
12
|
-
if ENV['CI']
|
|
13
|
-
flunk "Outdated API schema assertion at #{caller[0]}"
|
|
14
|
-
else
|
|
15
|
-
queue_write_expanded_assert_api_call(caller[0], __method__, name, version, expected_structure)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
assert_json_response_structure(*expected_structure)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
@@__api_schema_calls_for_expansion = {}
|
|
25
|
-
@@__api_schema_expansion_hook_installed = false
|
|
26
|
-
|
|
27
|
-
def queue_write_expanded_assert_api_call(call_site, method, name, version, expected_structure)
|
|
28
|
-
file, line = call_site.split(':')
|
|
29
|
-
line_index = line.to_i.pred
|
|
30
|
-
|
|
31
|
-
@@__api_schema_calls_for_expansion[file] ||= []
|
|
32
|
-
@@__api_schema_calls_for_expansion[file] << [line_index, method, name, version, expected_structure]
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def install_asset_api_expansion_hook
|
|
36
|
-
return if @@__api_schema_expansion_hook_installed
|
|
37
|
-
at_exit { expand_assert_api_calls }
|
|
38
|
-
@@__api_schema_expansion_hook_installed = true
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def expand_assert_api_calls
|
|
42
|
-
@@__api_schema_calls_for_expansion.each do |file, line_indexes_with_structures|
|
|
43
|
-
rewriter = SchemaTest::Rewriter.new(File.read(file), line_indexes_with_structures)
|
|
44
|
-
File.open(file, 'w') { f.puts rewriter.output }
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
if const_defined?(Rails)
|
|
51
|
-
ActionController::TestCase.send(:include, SchemaTest::TestHelper)
|
|
52
|
-
ActionDispatch::IntegrationTest.send(:include, SchemaTest::TestHelper)
|
|
53
|
-
|
|
54
|
-
SchemaTest.definition_paths << Rails.root.join('test', 'schema_definitions', '**', '*.rb')
|
|
55
|
-
SchemaTest.definition_paths << Rails.root.join('spec', 'schema_definitions', '**', '*.rb')
|
|
56
|
-
|
|
57
|
-
SchemaTest.load_definitions
|
|
58
|
-
end
|
|
59
|
-
|