schema-test 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 260d4172bc31999c0e327e3ac45428d042b7977bbad68c5b5d77e9eadba6765d
4
+ data.tar.gz: ff9ff5f54b598950d61b955750473007bd66afafeaa0d81cbb937a877c460c36
5
+ SHA512:
6
+ metadata.gz: 6592e1e7f3b5d0f6ddb2fdfe5099d87de1e2674d3abb5e7a3e3268537ff86ff0bccdf933c4fdf563e581c54b5ec11025db9b16fa05477cf3c24783813e7667a2
7
+ data.tar.gz: 9d295a52695e323afe73236219694d4e51ea14754e323464f48328bf9421362626bc522ac286211e91a207858b6de25573272949c4e1917f9ae68e24b024f561
@@ -0,0 +1,35 @@
1
+ # This workflow uses actions that are not certified by GitHub.
2
+ # They are provided by a third-party and are governed by
3
+ # separate terms of service, privacy policy, and support
4
+ # documentation.
5
+ # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
6
+ # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
7
+
8
+ name: Tests
9
+
10
+ on:
11
+ push:
12
+ branches: [ main ]
13
+ pull_request:
14
+ branches: [ main ]
15
+
16
+ jobs:
17
+ test:
18
+
19
+ runs-on: ubuntu-latest
20
+ strategy:
21
+ matrix:
22
+ ruby-version: ['2.6', '2.7', '3.0']
23
+
24
+ steps:
25
+ - uses: actions/checkout@v2
26
+ - name: Set up Ruby
27
+ # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
28
+ # change this to (see https://github.com/ruby/setup-ruby#versioning):
29
+ # uses: ruby/setup-ruby@v1
30
+ uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e
31
+ with:
32
+ ruby-version: ${{ matrix.ruby-version }}
33
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
34
+ - name: Run tests
35
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at james@lazyatom.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in api-schema.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,50 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ schema-test (0.1.0)
5
+ json
6
+ json_schemer
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ byebug (11.1.3)
12
+ diff-lcs (1.4.4)
13
+ ecma-re-validator (0.3.0)
14
+ regexp_parser (~> 2.0)
15
+ hana (1.3.7)
16
+ json (2.5.1)
17
+ json_schemer (0.2.18)
18
+ ecma-re-validator (~> 0.3)
19
+ hana (~> 1.3)
20
+ regexp_parser (~> 2.0)
21
+ uri_template (~> 0.7)
22
+ rake (10.5.0)
23
+ regexp_parser (2.1.1)
24
+ rspec (3.10.0)
25
+ rspec-core (~> 3.10.0)
26
+ rspec-expectations (~> 3.10.0)
27
+ rspec-mocks (~> 3.10.0)
28
+ rspec-core (3.10.1)
29
+ rspec-support (~> 3.10.0)
30
+ rspec-expectations (3.10.1)
31
+ diff-lcs (>= 1.2.0, < 2.0)
32
+ rspec-support (~> 3.10.0)
33
+ rspec-mocks (3.10.2)
34
+ diff-lcs (>= 1.2.0, < 2.0)
35
+ rspec-support (~> 3.10.0)
36
+ rspec-support (3.10.2)
37
+ uri_template (0.7.0)
38
+
39
+ PLATFORMS
40
+ ruby
41
+
42
+ DEPENDENCIES
43
+ bundler (~> 2)
44
+ byebug
45
+ rake (~> 10.0)
46
+ rspec (~> 3.0)
47
+ schema-test!
48
+
49
+ BUNDLED WITH
50
+ 2.1.4
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 James Adam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # SchemaTest
2
+
3
+ This gem provides a convenient and flexible way for specifying and testing schema definitions for JSON API response. The mean features are:
4
+
5
+ * A simple Ruby-based DSL for capturing JSON objects, including property names, types, and metadata like descriptions
6
+ * Simple ways to _version_ those schemas, and specify new versions efficiently
7
+ * Mechanisms to evaluate a given JSON blob against a schema to verify it matches
8
+ * Mechanisms to write tests for multiple API versions that strike a good balance between efficiency of specifiation vs avoiding unintentional impacts to endpoints. I'll explain that later.
9
+
10
+ ## How & why
11
+
12
+ This gem provides a way to define JSON Schema objects using a simple Ruby DSL, in a way that allows other schemas to be easily composed of existing ones:
13
+
14
+ ``` ruby
15
+
16
+ # Define a simple user schema
17
+ SchemaTest.define :user do
18
+ id
19
+ string :name
20
+ url :avatar_url
21
+ end
22
+
23
+ # Define a new comment schema
24
+ SchemaTest.define :comment do
25
+ string :body
26
+
27
+ # re-use the user schema, but give the object a different name
28
+ object :user, as: :author
29
+ end
30
+ ```
31
+
32
+ This lets you minimise duplication between your schema definitions in a convenient way. And we can then use the generated JSON-Schema data (see below) to validate our app's generated responses against this.
33
+
34
+ But JSON-Schema already lets you compose schemas, right? What's the point of this?
35
+
36
+ ### API versioning
37
+
38
+ Let's imagine we have some test for our comments API output, something like (pseudocode):
39
+
40
+ ``` ruby
41
+ get :comment
42
+
43
+ assert_schema_matches response.body, schema_for(:comment)
44
+ ```
45
+
46
+ Then, some time later, we create a new version of our API, and some of the serialised objects have changes - new fields, removed fields, fields with different meanings, and so on.
47
+
48
+ We might change our schema definition:
49
+
50
+ ``` ruby
51
+ SchemaTest.define :user do
52
+ id
53
+ string :first_name
54
+ string :last_name
55
+ url :avatar_url
56
+ end
57
+ ```
58
+
59
+ ... and then we change our user serialiser logic, and write a test for the new API version, and everything passes. Great, right?
60
+
61
+ No. Not great. Not great because while yes, the tests pass, we've actually _changed_ the output of the previous API version endpoints accidentally, and there will be nothing in the commit that suggests it.
62
+
63
+ So instead, what we can do is _version_ our schema definitions:
64
+
65
+ ``` ruby
66
+ SchemaTest.define :user, version: 1 do
67
+ id
68
+ string :name
69
+ url :avatar_url
70
+ end
71
+
72
+ SchemaTest.define :user, version: 2 do
73
+ id
74
+ string :first_name
75
+ string :last_name
76
+ url :avatar_url
77
+ end
78
+ ```
79
+
80
+ And then use the correct version in each of our tests:
81
+
82
+ ``` ruby
83
+ # v1 comment test
84
+ get :comment
85
+
86
+ assert_schema_validates response.body, schema_for(:comment, version: 1)
87
+
88
+ # v2 comment test
89
+ get :comment
90
+
91
+ assert_schema_validates response.body, schema_for(:comment, version: 2)
92
+ ```
93
+
94
+ This way we can now be confident that our version 1 tests are actually verifying the genuine version 1 schema, and likewise for the version 2 tests.
95
+
96
+ But there's another hole that we're about to step in...
97
+
98
+ ### Nested schemas
99
+
100
+ To be written
101
+
102
+
103
+ ## Installation
104
+
105
+ Add this line to your application's Gemfile:
106
+
107
+ ```ruby
108
+ gem 'schema-test'
109
+ ```
110
+
111
+ And then execute:
112
+
113
+ $ bundle
114
+
115
+ Or install it yourself as:
116
+
117
+ $ gem install schema-test
118
+
119
+ ## Usage
120
+
121
+ Once the gem is in your bundle, add it to your tests:
122
+
123
+ ### Minitest with Rails
124
+
125
+ At or near the top of `test_helper.rb` add the following:
126
+
127
+ ``` ruby
128
+ require 'schema_test/minitest'
129
+ SchemaTest.configure do |config|
130
+ config.domain = 'mydomain.com'
131
+ config.definition_paths << Rails.root.join('test', 'schema_definitions')
132
+ end
133
+ SchemaTest.load!
134
+ ```
135
+
136
+ Within `class ActiveSupport::TestCase` in the same file, add
137
+
138
+ ``` ruby
139
+ include SchemaTest::Minitest
140
+ ```
141
+
142
+ Create the directory `test/schema_definitions`; within this directory (and any subdirectories) you can start to define your schemas. A simple one would be:
143
+
144
+ ``` ruby
145
+ SchemaTest.define :user, version: 1 do
146
+ id
147
+ string :name
148
+ url :avatar_url
149
+ integer :follower_count
150
+ end
151
+ ```
152
+
153
+ You can then make assertions using this schema in your tests. For example, if you have a controller action that responds with JSON version of a user, you might write a test like this:
154
+
155
+ ``` ruby
156
+ test 'JSON returned matches schema' do
157
+ user = User.first
158
+ get :show, params: { id: user.id }
159
+
160
+ json = JSON.parse(response.body)
161
+ assert_valid_json_for_schema(json, :user, version: 1)
162
+ end
163
+ ```
164
+
165
+ When you run this test, this gem will convert the schema definition you've given into JSON Schema, and then dynamically rewrite your test to verify against the fully-expanded schema. After you run the test, if you reload the file, you should see something like this:
166
+
167
+ ``` ruby
168
+ test 'JSON returned matches schema' do
169
+ user = User.first
170
+ get :show, params: { id: user.id }
171
+
172
+ json = JSON.parse(response.body)
173
+ assert_valid_json_for_schema( # EXPANDED
174
+ json,
175
+ {:version=>1,
176
+ :schema=>
177
+ {"$schema"=>"http://json-schema.org/draft-07/schema#",
178
+ "$id"=>"http://mydomain/v1/user.json",
179
+ "title"=>"user",
180
+ "type"=>"object",
181
+ "properties"=>
182
+ {"id"=>{"type"=>"integer"},
183
+ "name"=>{"type"=>"string"},
184
+ "avatar_url"=>{"type"=>"string", "format"=>"uri"},
185
+ "follower_count"=>{"type"=>"integer}},
186
+ "required"=>["id", "name", "avatar_url", "follower_count"],
187
+ "additionalProperties"=>false}}
188
+ ) # END EXPANDED
189
+ end
190
+ ```
191
+ 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
+ ## Development
194
+
195
+ 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.
196
+
197
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
198
+
199
+ ## Contributing
200
+
201
+ Bug reports and pull requests are welcome on GitHub at https://github.com/lazyatom/schema-test. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
202
+
203
+ ## License
204
+
205
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
206
+
207
+ ## Code of Conduct
208
+
209
+ Everyone interacting in the SchemaTest project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/lazyatom/schema-test/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "api/schema"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,23 @@
1
+ require 'schema_test/property'
2
+
3
+ module SchemaTest
4
+ class Collection < SchemaTest::Property::Object
5
+ def initialize(name, of_name, version: nil, description: nil)
6
+ super(name, version: version, description: description)
7
+ @item_type = lookup_object(of_name, version)
8
+ SchemaTest::Definition.register(self)
9
+ end
10
+
11
+ def as_json_schema(domain: SchemaTest.configuration.domain)
12
+ id_part = version ? "v#{version}/#{name}" : name
13
+ {
14
+ '$schema' => SchemaTest::SCHEMA_VERSION,
15
+ '$id' => "http://#{domain}/#{id_part}.json",
16
+ 'title' => name.to_s,
17
+ 'type' => 'array',
18
+ 'items' => @item_type.as_json_schema(false),
19
+ 'minItems' => 1
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ module SchemaTest
2
+ class Configuration
3
+ # The domain is used to contstruct the `$id` part of the generated
4
+ # JSON-Schema definitions. This can be set to anything you like really.
5
+ attr_accessor :domain
6
+
7
+ # This should be an array or one or more paths. All ruby files under these
8
+ # paths will all be loaded when `SchemaTest.setup!` is called. You can nest
9
+ # files in as many subdirectories are you like.
10
+ attr_accessor :definition_paths
11
+
12
+ def initialize
13
+ @domain = 'example.com'
14
+ @definition_paths = []
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,59 @@
1
+ require 'schema_test/property'
2
+
3
+ module SchemaTest
4
+ class Definition < SchemaTest::Property::Object
5
+ def self.reset!
6
+ @definitions = nil
7
+ end
8
+
9
+ def self.register(definition)
10
+ @definitions ||= {}
11
+ @definitions[definition.name] ||= {}
12
+ @definitions[definition.name][definition.version] = definition
13
+ end
14
+
15
+ def self.find(name, version)
16
+ (@definitions || {}).dig(name, version)
17
+ end
18
+
19
+ def self.find!(name, version)
20
+ found = find(name, version)
21
+ raise "Could not find schema for #{name.inspect} (version: #{version.inspect})" unless found
22
+ found
23
+ end
24
+
25
+ def initialize(*args)
26
+ super
27
+ self.class.register(self)
28
+ end
29
+
30
+ def type(name, version=nil)
31
+ lookup_object(name, version || @version)
32
+ end
33
+
34
+ def optional(object)
35
+ object.optional!
36
+ end
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
41
+ end
42
+
43
+ def as_json_schema(domain: SchemaTest.configuration.domain)
44
+ id_part = version ? "v#{version}/#{name}" : name
45
+ {
46
+ '$schema' => SchemaTest::SCHEMA_VERSION,
47
+ '$id' => "http://#{domain}/#{id_part}.json",
48
+ 'title' => name.to_s
49
+ }.merge(super(false))
50
+ 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
+ end
59
+ end
@@ -0,0 +1,58 @@
1
+ require 'schema_test'
2
+
3
+ module SchemaTest
4
+ module Minitest
5
+ def assert_valid_json_for_schema(json, name, version:, schema: nil)
6
+ install_assert_api_expansion_hook
7
+
8
+ definition = SchemaTest::Definition.find(name, version)
9
+ raise "Unknown definition #{name}, version: #{version}" unless definition.present?
10
+
11
+ expected_schema = definition.as_json_schema
12
+
13
+ if schema != expected_schema
14
+ if ENV['CI']
15
+ 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
+ end
19
+ end
20
+
21
+ assert_json_schema_validates_against(json, expected_schema)
22
+ end
23
+
24
+ def assert_json_schema_validates_against(json, schema)
25
+ errors = SchemaTest.validate_json(json, schema)
26
+ assert errors.empty?, "JSON did not pass schema:\n#{errors.join("\n")}"
27
+ end
28
+
29
+ private
30
+
31
+ @@__api_schema_calls_for_expansion = {}
32
+ @@__api_schema_expansion_hook_installed = false
33
+
34
+ def queue_write_expanded_assert_api_call(call_site, method, name, version, expected_schema)
35
+ file, line = call_site.split(':')
36
+ line_index = line.to_i.pred
37
+
38
+ @@__api_schema_calls_for_expansion[file] ||= []
39
+ @@__api_schema_calls_for_expansion[file] << [line_index, method, name, version, expected_schema]
40
+ end
41
+
42
+ def install_assert_api_expansion_hook
43
+ return if @@__api_schema_expansion_hook_installed
44
+ at_exit { expand_assert_api_calls }
45
+ @@__api_schema_expansion_hook_installed = true
46
+ end
47
+
48
+ def expand_assert_api_calls
49
+ @@__api_schema_calls_for_expansion.each do |file, line_indexes_with_schemas|
50
+ original_contents = File.read(file)
51
+ rewriter = SchemaTest::Rewriter.new(original_contents, line_indexes_with_schemas)
52
+ new_contents = rewriter.output
53
+ raise "Error rewriting file" if new_contents.blank?
54
+ File.open(file, 'w') { |f| f.puts new_contents }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,272 @@
1
+ module SchemaTest
2
+ class Property
3
+ attr_reader :name, :type, :description, :optional
4
+
5
+ def initialize(name, type, description=nil)
6
+ @name = name
7
+ @type = type
8
+ @description = description
9
+ @optional = false
10
+ end
11
+
12
+ def as_structure(_=nil)
13
+ if @optional
14
+ { name => nil }
15
+ else
16
+ name
17
+ end
18
+ end
19
+
20
+ def as_json_schema
21
+ json_schema = { 'type' => json_schema_type }
22
+ json_schema['description'] = description if description
23
+ json_schema['format'] = json_schema_format if json_schema_format
24
+ { name.to_s => json_schema }
25
+ end
26
+
27
+ def ==(other)
28
+ name == other.name &&
29
+ type == other.type &&
30
+ description == other.description &&
31
+ optional == other.optional
32
+ end
33
+
34
+ def optional?
35
+ optional
36
+ end
37
+
38
+ def optional!
39
+ @optional = true
40
+ end
41
+
42
+ def json_schema_type
43
+ @type.to_s
44
+ end
45
+
46
+ def json_schema_format
47
+ nil
48
+ end
49
+
50
+ class Boolean < SchemaTest::Property
51
+ def initialize(name, description=nil)
52
+ super(name, :boolean, description)
53
+ end
54
+ end
55
+
56
+ class Integer < SchemaTest::Property
57
+ def initialize(name, description=nil)
58
+ super(name, :integer, description)
59
+ end
60
+ end
61
+
62
+ class Float < SchemaTest::Property
63
+ def initialize(name, description=nil)
64
+ super(name, :float, description)
65
+ end
66
+
67
+ def json_schema_type
68
+ 'number'
69
+ end
70
+ end
71
+
72
+ class String < SchemaTest::Property
73
+ def initialize(name, description=nil)
74
+ super(name, :string, description)
75
+ end
76
+ end
77
+
78
+ class DateTime < SchemaTest::Property
79
+ def initialize(name, description=nil)
80
+ super(name, :datetime, description)
81
+ end
82
+
83
+ def json_schema_type
84
+ 'string'
85
+ end
86
+
87
+ def json_schema_format
88
+ 'date-time'
89
+ end
90
+ end
91
+
92
+ class Uri < SchemaTest::Property::String
93
+ def json_schema_format
94
+ 'uri'
95
+ end
96
+ end
97
+
98
+ class SchemaTest::Property::Object < SchemaTest::Property
99
+ attr_reader :version
100
+
101
+ def initialize(name, description: nil, version: nil, from: nil, properties: nil, &block)
102
+ @name = name
103
+ @description = description
104
+ @version = version
105
+ @specific_properties = properties
106
+ @properties = {}
107
+ @from = from
108
+ instance_eval(&block) if block_given?
109
+ end
110
+
111
+ def properties
112
+ resolve
113
+ @properties
114
+ end
115
+
116
+ def ==(other)
117
+ super && properties.all? { |name, property| property == other.properties[name] }
118
+ end
119
+
120
+ def resolve
121
+ if @from
122
+ @properties.merge!(@from.properties)
123
+ @from = nil
124
+ end
125
+ if @specific_properties
126
+ @specific_properties.each { |p| define_property(p) }
127
+ @specific_properties = nil
128
+ end
129
+ self
130
+ end
131
+
132
+ SHORTHAND_ATTRIBUTES = {
133
+ id: :integer,
134
+ slug: :string,
135
+ updated_at: :datetime,
136
+ created_at: :datetime
137
+ }
138
+
139
+ SHORTHAND_ATTRIBUTES.each do |name, type|
140
+ define_method(name) { send(type, name) }
141
+ end
142
+
143
+ TYPES = {
144
+ boolean: SchemaTest::Property::Boolean,
145
+ integer: SchemaTest::Property::Integer,
146
+ float: SchemaTest::Property::Float,
147
+ string: SchemaTest::Property::String,
148
+ datetime: SchemaTest::Property::DateTime,
149
+ url: SchemaTest::Property::Uri,
150
+ html: SchemaTest::Property::String
151
+ }
152
+
153
+ TYPES.each do |method_name, type_class|
154
+ define_method(method_name) do |name, desc: nil|
155
+ define_property(type_class.new(name, desc))
156
+ end
157
+ end
158
+
159
+ def array(name, of: nil, desc: nil, &block)
160
+ define_property(SchemaTest::Property::Array.new(name, of, desc, &block))
161
+ end
162
+
163
+ def object(name, desc: nil, as: name, version: nil, &block)
164
+ inferred_version = version || @version
165
+ if block_given?
166
+ define_property(SchemaTest::Property::Object.new(as, description: desc, version: inferred_version, &block))
167
+ else
168
+ define_property(SchemaTest::Property::Object.new(as, description: desc, version: inferred_version, from: lookup_object(name, inferred_version)))
169
+ end
170
+ end
171
+
172
+ def as_json_schema(include_root=true)
173
+ property_values = properties.values
174
+ required_property_names = property_values.reject(&:optional?).map(&:name).map(&:to_s)
175
+ schema = {
176
+ 'type' => json_schema_type,
177
+ 'properties' => property_values.inject({}) { |a,p| a.merge(p.as_json_schema) },
178
+ 'required' => required_property_names,
179
+ 'additionalProperties' => false
180
+ }
181
+ if include_root
182
+ { name.to_s => schema }
183
+ else
184
+ schema
185
+ end
186
+ end
187
+
188
+ def as_structure(include_root=true)
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
197
+ 'object'
198
+ end
199
+
200
+ private
201
+
202
+ def define_property(attribute)
203
+ @properties[attribute.name] = attribute
204
+ end
205
+
206
+ def lookup_object(name, version)
207
+ UnresolvedProperty.new(name, version: version)
208
+ end
209
+ end
210
+
211
+ class UnresolvedProperty < SchemaTest::Property::Object
212
+ def resolve
213
+ SchemaTest::Definition.find!(name, version)
214
+ end
215
+
216
+ def ==(other)
217
+ resolve == other
218
+ end
219
+
220
+ def properties
221
+ resolve.properties
222
+ end
223
+
224
+ def as_structure(*args)
225
+ resolve.as_structure(*args)
226
+ end
227
+ end
228
+
229
+ class AnonymousObject < SchemaTest::Property::Object
230
+ def initialize(properties: nil, &block)
231
+ super(nil, properties: properties, &block)
232
+ end
233
+
234
+ def as_structure(_=nil)
235
+ super(false)
236
+ end
237
+ end
238
+
239
+ class Array < SchemaTest::Property
240
+ attr_reader :item_type
241
+
242
+ def initialize(name, of=nil, description=nil, &block)
243
+ super(name, :array, description)
244
+ if block_given?
245
+ @item_type = AnonymousObject.new(&block)
246
+ else
247
+ @item_type = of
248
+ end
249
+ # @items = { type: @item_type }
250
+ end
251
+
252
+ def ==(other)
253
+ super && @item_type == other.item_type
254
+ end
255
+
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
+ def as_json_schema
265
+ super.tap do |json_schema|
266
+ item_schema = @item_type.is_a?(SchemaTest::Property) ? @item_type.as_json_schema(false) : { 'type' => @item_type.to_s }
267
+ json_schema[name.to_s]['items'] = item_schema
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,51 @@
1
+ require 'pp'
2
+
3
+ module SchemaTest
4
+ OPENING_COMMENT = '# EXPANDED'.freeze
5
+ CLOSING_COMMENT = '# END EXPANDED'.freeze
6
+
7
+ class Rewriter
8
+ def initialize(contents, line_indexes_with_schemas)
9
+ @lines = contents.split("\n")
10
+ @line_indexes_with_schemas = line_indexes_with_schemas
11
+ end
12
+
13
+ def output
14
+ current_offset = 0
15
+ line_indexes_with_schemas.sort_by { |(line,_)| line }.each do |index, method, name, version, expected_schema|
16
+ start_index = index + current_offset
17
+ if lines[start_index] =~ /#{OPENING_COMMENT}\s*\z/
18
+ end_index = start_index + lines[start_index..-1].find_index { |line| line =~ /#{CLOSING_COMMENT}\s*\z/ }
19
+ json_variable_name = lines[start_index + 1].strip.gsub(/,\z/, '')
20
+ else
21
+ end_index = start_index
22
+ json_variable_name = lines[start_index].match(/\(([^,]+)/)[1]
23
+ end
24
+ start_indent = lines[start_index].match(/\A(\s*)/)[0].length
25
+ (end_index - start_index + 1).times { |i| lines.delete_at(start_index) }
26
+
27
+ output = StringIO.new
28
+ PP.pp([name, version: version, schema: expected_schema], output)
29
+ output.rewind
30
+ expanded_schema_lines = output.read.strip.gsub(/\A\[/, '').gsub(/\]\z/, '').split("\n")
31
+ expanded_schema_lines.unshift(json_variable_name + ',')
32
+
33
+ method_string = [
34
+ (' ' * start_indent) + method.to_s + "( #{OPENING_COMMENT}",
35
+ *expanded_schema_lines.map { |line| (' ' * (start_indent + 2)) + line },
36
+ (' ' * start_indent) + ") #{CLOSING_COMMENT}"
37
+ ]
38
+
39
+ method_string.reverse.each { |line| lines.insert(start_index, line) }
40
+
41
+ current_offset += method_string.count - 1
42
+ end
43
+
44
+ lines.compact.join("\n") + "\n"
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :lines, :line_indexes_with_schemas
50
+ end
51
+ end
@@ -0,0 +1,59 @@
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
+
@@ -0,0 +1,51 @@
1
+ require 'json'
2
+ require 'json_schemer'
3
+ require 'shellwords'
4
+
5
+ module SchemaTest
6
+ class Validator
7
+ def initialize(json_or_data)
8
+ @data = case json_or_data
9
+ when Hash, Array
10
+ JSON.parse(json_or_data.to_json)
11
+ when String
12
+ JSON.parse(json_or_data)
13
+ else
14
+ json_or_data
15
+ end
16
+ end
17
+
18
+ def validate_using_definition(definition)
19
+ validate_using_json_schema(definition.as_json_schema)
20
+ end
21
+
22
+ def validate_using_json_schema(schema)
23
+ json_schema = JSONSchemer.schema(schema)
24
+ errors = json_schema.validate(@data).to_a
25
+ convert_json_schemer_errors(errors)
26
+ end
27
+
28
+ private
29
+
30
+ def convert_json_schemer_errors(errors)
31
+ errors.map { |error| convert_json_schemer_error(error) }
32
+ end
33
+
34
+ def convert_json_schemer_error(error)
35
+ if error['schema_pointer'] == '/additionalProperties'
36
+ additional_key = error['data_pointer']
37
+ "object contains the extra key: #{additional_key}"
38
+ else
39
+ message = case error['type']
40
+ when 'format'
41
+ "format should be #{error['schema']['format']}"
42
+ when 'required'
43
+ "missing some required attributes"
44
+ else
45
+ "type should be #{error['type']}"
46
+ end
47
+ "value at #{error['data_pointer']} (#{error['data'].inspect}) failed validation: #{message}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,3 @@
1
+ module SchemaTest
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,69 @@
1
+ require 'schema_test/version'
2
+ require 'schema_test/rewriter'
3
+ require 'schema_test/definition'
4
+ require 'schema_test/collection'
5
+ require 'schema_test/validator'
6
+ require 'schema_test/configuration'
7
+
8
+ module SchemaTest
9
+ class Error < StandardError; end
10
+
11
+ SCHEMA_VERSION = "http://json-schema.org/draft-07/schema#"
12
+
13
+ class << self
14
+ def reset!
15
+ @configuration = nil
16
+ SchemaTest::Definition.reset!
17
+ end
18
+
19
+ # Yields a configuration object, which can be used to set up
20
+ # various aspects of the library
21
+ def configure
22
+ yield configuration
23
+ end
24
+
25
+ def configuration
26
+ @configuration ||= SchemaTest::Configuration.new
27
+ end
28
+
29
+ # Recursively loads all files under the `definition_paths` directories
30
+ def load!
31
+ load_definitions
32
+ end
33
+
34
+ # Define a new schema
35
+ def define(name, collection: nil, **attributes, &block)
36
+ definition = SchemaTest::Definition.new(name, attributes, &block)
37
+ if collection
38
+ collection(collection, of: name, version: attributes[:version])
39
+ end
40
+ definition
41
+ end
42
+
43
+ # Explicitly define a new schema collection (an array of other schema
44
+ # objects)
45
+ def collection(name, of:, **attributes)
46
+ SchemaTest::Collection.new(name, of, attributes)
47
+ end
48
+
49
+ # Validate some JSON data against a schema or schema definition
50
+ def validate_json(json, definition_or_schema)
51
+ validator = SchemaTest::Validator.new(json)
52
+ if definition_or_schema.is_a?(SchemaTest::Property::Object)
53
+ validator.validate_using_definition(definition_or_schema)
54
+ else
55
+ validator.validate_using_json_schema(definition_or_schema)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def load_definitions
62
+ globbed_paths = configuration.definition_paths.map { |path| path.join('**', '*.rb') }
63
+ Dir[globbed_paths.join(',')].each do |schema_file|
64
+ require schema_file
65
+ end
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,45 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "schema_test/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "schema-test"
7
+ spec.version = SchemaTest::VERSION
8
+ spec.authors = ["James Adam"]
9
+ spec.email = ["james@lazyatom.com"]
10
+
11
+ spec.summary = %q{API testing against declarative schemas}
12
+ spec.description = %q{}
13
+ spec.homepage = "https://github.com/lazyatom/schema-test"
14
+ spec.license = "MIT"
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if spec.respond_to?(:metadata)
19
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/lazyatom/schema-test"
23
+ spec.metadata["changelog_uri"] = "https://github.com/lazyatom/schema-test"
24
+ else
25
+ raise "RubyGems 2.0 or newer is required to protect against " \
26
+ "public gem pushes."
27
+ end
28
+
29
+ # Specify which files should be added to the gem when it is released.
30
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
32
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ spec.add_dependency 'json'
39
+ spec.add_dependency 'json_schemer'
40
+
41
+ spec.add_development_dependency "bundler", "~> 2"
42
+ spec.add_development_dependency "rake", "~> 10.0"
43
+ spec.add_development_dependency "rspec", "~> 3.0"
44
+ spec.add_development_dependency "byebug"
45
+ end
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: schema-test
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - James Adam
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-09-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json_schemer
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: byebug
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: ''
98
+ email:
99
+ - james@lazyatom.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".github/workflows/tests.yml"
105
+ - ".gitignore"
106
+ - ".rspec"
107
+ - CODE_OF_CONDUCT.md
108
+ - Gemfile
109
+ - Gemfile.lock
110
+ - LICENSE.txt
111
+ - README.md
112
+ - Rakefile
113
+ - bin/console
114
+ - bin/setup
115
+ - lib/schema_test.rb
116
+ - lib/schema_test/collection.rb
117
+ - lib/schema_test/configuration.rb
118
+ - lib/schema_test/definition.rb
119
+ - lib/schema_test/minitest.rb
120
+ - lib/schema_test/property.rb
121
+ - lib/schema_test/rewriter.rb
122
+ - lib/schema_test/test_helper.rb
123
+ - lib/schema_test/validator.rb
124
+ - lib/schema_test/version.rb
125
+ - schema-test.gemspec
126
+ homepage: https://github.com/lazyatom/schema-test
127
+ licenses:
128
+ - MIT
129
+ metadata:
130
+ allowed_push_host: https://rubygems.org
131
+ homepage_uri: https://github.com/lazyatom/schema-test
132
+ source_code_uri: https://github.com/lazyatom/schema-test
133
+ changelog_uri: https://github.com/lazyatom/schema-test
134
+ post_install_message:
135
+ rdoc_options: []
136
+ require_paths:
137
+ - lib
138
+ required_ruby_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ required_rubygems_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ requirements: []
149
+ rubygems_version: 3.1.4
150
+ signing_key:
151
+ specification_version: 4
152
+ summary: API testing against declarative schemas
153
+ test_files: []