schema-test 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []