api-validator 0.0.1

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.
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in api-validator.gemspec
4
+ gemspec
5
+
6
+ gem 'json-pointer', :git => 'git://github.com/tent/json-pointer-ruby.git', :branch => 'master'
data/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2013 Apollic Software, LLC. All rights reserved.
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions are
5
+ met:
6
+
7
+ * Redistributions of source code must retain the above copyright
8
+ notice, this list of conditions and the following disclaimer.
9
+ * Redistributions in binary form must reproduce the above
10
+ copyright notice, this list of conditions and the following disclaimer
11
+ in the documentation and/or other materials provided with the
12
+ distribution.
13
+ * Neither the name of Apollic Software, LLC nor the names of its
14
+ contributors may be used to endorse or promote products derived from
15
+ this software without specific prior written permission.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,80 @@
1
+ # ApiValidator [![Build Status](https://travis-ci.org/tent/api-validator.png)](https://travis-ci.org/tent/api-validator)
2
+
3
+ Framework for integration testing an API server.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'api-validator'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install api-validator
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ class MyValidation < ApiValidator::Spec
23
+
24
+ def create_resource
25
+ # ...
26
+ set(:resource, data)
27
+ end
28
+
29
+ def fudge_resource
30
+ # ...
31
+ set(:resource, fudged_data)
32
+ end
33
+
34
+ describe "GET /posts/{entity}/{id}" do
35
+ shared_examples :not_found do
36
+ expect_response(:schema => :error, :status => 404) do
37
+ # ...
38
+ response
39
+ end
40
+ end
41
+
42
+ context "when resource exists", :before => :create_resource do
43
+ context "when authorized to read all posts" do
44
+ authorize!(:server => :remote, :scopes => %w[ read_posts ], :read_types => %w[ all ])
45
+
46
+ expect_response(:schema => :post, :status => 200) do
47
+ expect_headers({ 'Content-Type' => /json\b/ })
48
+ expect_properties(:entity => get(:resource, :entity), :id => get(:resource, :id))
49
+ expect_schema(:post_content, "/content")
50
+
51
+ # ...
52
+ response
53
+ end
54
+ end
55
+
56
+ context "when authorize to write resource but not read" do
57
+ authorize!(:server => :remote, :scopes => %w[ write_posts ], :write_types => %w[ all ])
58
+ behaves_as :not_found
59
+ end
60
+
61
+ context "when not authorized" do
62
+ behaves_as :not_found
63
+ end
64
+ end
65
+
66
+ context "when resource does not exist", :before => :fudge_resource do
67
+ behaves_as :not_found
68
+ end
69
+ end
70
+
71
+ end
72
+ ```
73
+
74
+ ## Contributing
75
+
76
+ 1. Fork it
77
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
78
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
79
+ 4. Push to the branch (`git push origin my-new-feature`)
80
+ 5. Create new Pull Request
@@ -0,0 +1,8 @@
1
+ require 'bundler/setup'
2
+ require 'bundler/gem_tasks'
3
+
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new(:spec) do |spec|
6
+ spec.pattern = 'spec/**/*_spec.rb'
7
+ end
8
+ task :default => :spec
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'api-validator/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "api-validator"
8
+ gem.version = ApiValidator::VERSION
9
+ gem.authors = ["Jesse Stuart"]
10
+ gem.email = ["jesse@jessestuart.ca"]
11
+ gem.description = %q{ }
12
+ gem.summary = %q{ }
13
+ gem.homepage = ""
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ gem.add_runtime_dependency 'json-pointer'
21
+
22
+ gem.add_development_dependency 'rspec', '~> 2.11'
23
+ gem.add_development_dependency 'mocha', '~> 0.13'
24
+ gem.add_development_dependency "bundler", "~> 1.3"
25
+ gem.add_development_dependency "rake"
26
+ gem.add_development_dependency "faraday"
27
+ end
@@ -0,0 +1,19 @@
1
+ require 'api-validator/version'
2
+
3
+ module ApiValidator
4
+
5
+ require 'api-validator/mixins'
6
+
7
+ require 'api-validator/assertion'
8
+ require 'api-validator/base'
9
+ require 'api-validator/json_schemas'
10
+ require 'api-validator/json_schema'
11
+ require 'api-validator/json'
12
+ require 'api-validator/json'
13
+ require 'api-validator/header'
14
+ require 'api-validator/status'
15
+
16
+ require 'api-validator/response_expectation'
17
+ require 'api-validator/spec'
18
+
19
+ end
@@ -0,0 +1,32 @@
1
+
2
+ module ApiValidator
3
+ class Assertion
4
+
5
+ attr_reader :value, :path, :type, :format
6
+ def initialize(path, value, options = {})
7
+ @path, @value, @type, @format = path, value, options.delete(:type), options.delete(:format)
8
+ end
9
+
10
+ def to_hash(options = {})
11
+ _h = { :op => "test", :path => path, :value => stringified_value }
12
+ _h.delete(:value) if type && value.nil?
13
+ _h[:type] = "regexp" if Regexp === value
14
+ _h[:type] = type if type
15
+ _h[:format] = format if format
16
+ _h
17
+ end
18
+
19
+ def stringified_value
20
+ if Regexp === value
21
+ regex = value.to_s.
22
+ sub(%r|\(\?-mix:(.*)\)|) { $1 }.
23
+ gsub("\\A", "^").
24
+ gsub("\\Z", "$")
25
+ "/#{regex}/"
26
+ else
27
+ value
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,47 @@
1
+ require 'uri'
2
+
3
+ module ApiValidator
4
+ class Base
5
+
6
+ def initialize(expected)
7
+ @expected = expected
8
+ initialize_assertions(expected)
9
+ end
10
+
11
+ def assertions
12
+ @assertions ||= []
13
+ end
14
+
15
+ def validate(response)
16
+ {
17
+ :assertions => assertions.map(&:to_hash)
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def assertion_valid?(assertion, actual)
24
+ value = assertion.value
25
+ case value
26
+ when Regexp
27
+ value.match(actual.to_s)
28
+ when Numeric
29
+ (Numeric === actual) && (value == actual)
30
+ else
31
+ value == actual
32
+ end
33
+ end
34
+
35
+ def assertion_format_valid?(assertion, actual)
36
+ return true unless format = assertion.format
37
+ case format
38
+ when 'uri'
39
+ uri = URI(actual)
40
+ uri.scheme && uri.host
41
+ end
42
+ rescue URI::InvalidURIError, ArgumentError
43
+ false
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,77 @@
1
+ module ApiValidator
2
+ class Header < Base
3
+
4
+ def self.named_expectations
5
+ @named_expectations ||= {}
6
+ end
7
+
8
+ def self.register(name, expected)
9
+ named_expectations[name] = expected
10
+ end
11
+
12
+ def validate(response)
13
+ compiled_assertions = compile_assertions(response)
14
+ response_headers = response.env[:response_headers]
15
+ _failed_assertions = failed_assertions(compiled_assertions, response_headers)
16
+ super.merge(
17
+ :assertions => compiled_assertions.map(&:to_hash),
18
+ :key => :response_headers,
19
+ :failed_assertions => _failed_assertions.map(&:to_hash),
20
+ :diff => diff(response_headers, _failed_assertions).map(&:to_hash),
21
+ :valid => _failed_assertions.empty?
22
+ )
23
+ end
24
+
25
+ private
26
+
27
+ NoSuchExpectationError = Class.new(StandardError)
28
+ def initialize_assertions(expected)
29
+ unless Hash === expected
30
+ name = expected
31
+ unless expected = self.class.named_expectations[name]
32
+ raise NoSuchExpectationError.new("Expected #{name.inspect} to be registered with #{self.class.name}!")
33
+ end
34
+ end
35
+
36
+ @assertions = expected.inject([]) do |memo, (header, value)|
37
+ memo << Assertion.new("/#{header}", value)
38
+ end
39
+ end
40
+
41
+ def compile_assertions(response)
42
+ assertions.map do |assertion|
43
+ if Proc === assertion.value
44
+ Assertion.new(assertion.path, assertion.value.call(response))
45
+ else
46
+ assertion
47
+ end
48
+ end
49
+ end
50
+
51
+ def failed_assertions(assertions, actual)
52
+ assertions.select do |assertion|
53
+ header = key_from_path(assertion.path)
54
+ !assertion_valid?(assertion, actual[header])
55
+ end
56
+ end
57
+
58
+ def diff(actual, _failed_assertions)
59
+ _failed_assertions.map do |assertion|
60
+ header = key_from_path(assertion.path)
61
+ assertion = assertion.to_hash
62
+ if actual.has_key?(header)
63
+ assertion[:op] = "replace"
64
+ assertion[:current_value] = actual[header]
65
+ else
66
+ assertion[:op] = "add"
67
+ end
68
+ assertion
69
+ end
70
+ end
71
+
72
+ def key_from_path(path)
73
+ path.slice(1, path.length) # remove prefixed "/"
74
+ end
75
+
76
+ end
77
+ end
@@ -0,0 +1,56 @@
1
+ module ApiValidator
2
+ class Json < Base
3
+
4
+ def validate(response)
5
+ response_body = response.body.respond_to?(:to_hash) ? response.body.to_hash : response.body
6
+ _failed_assertions = failed_assertions(response_body)
7
+ super.merge(
8
+ :key => :response_body,
9
+ :failed_assertions => _failed_assertions.map(&:to_hash),
10
+ :diff => diff(response_body, _failed_assertions),
11
+ :valid => _failed_assertions.empty?
12
+ )
13
+ end
14
+
15
+ private
16
+
17
+ def initialize_assertions(expected, path = "")
18
+ case expected
19
+ when Hash
20
+ expected.each_pair do |key, val|
21
+ item_path = [path, key].join("/")
22
+ initialize_assertions(val, item_path)
23
+ end
24
+ when Array
25
+ expected.each_with_index do |val, index|
26
+ item_path = [path, index].join("/")
27
+ initialize_assertions(val, item_path)
28
+ end
29
+ else
30
+ assertions << Assertion.new(path, expected)
31
+ end
32
+ end
33
+
34
+ def failed_assertions(actual)
35
+ assertions.select do |assertion|
36
+ pointer = JsonPointer.new(actual, assertion.path)
37
+ !pointer.exists? || !assertion_valid?(assertion, pointer.value)
38
+ end
39
+ end
40
+
41
+ def diff(actual, _failed_assertions)
42
+ _failed_assertions.map do |assertion|
43
+ pointer = JsonPointer.new(actual, assertion.path)
44
+ assertion = assertion.to_hash
45
+ if pointer.exists?
46
+ assertion[:op] = "replace"
47
+ assertion[:current_value] = pointer.value
48
+ else
49
+ assertion[:op] = "add"
50
+ end
51
+ assertion
52
+ end
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,228 @@
1
+ require 'json-pointer'
2
+
3
+ module ApiValidator
4
+ class JsonSchema < Base
5
+
6
+ SchemaNotFoundError = Class.new(StandardError)
7
+
8
+ attr_reader :root_path
9
+ def initialize(expected, root_path = nil)
10
+ @expected, @root_path = expected, root_path
11
+
12
+ if Hash === expected
13
+ schema = expected
14
+ else
15
+ unless schema = JsonSchemas[expected]
16
+ raise SchemaNotFoundError.new("Unable to locate schema: #{expected.inspect}")
17
+ end
18
+ end
19
+
20
+ @schema = schema
21
+
22
+ initialize_assertions(schema)
23
+ end
24
+
25
+ def validate(response)
26
+ response_body = response.body.respond_to?(:to_hash) ? response.body.to_hash : response.body
27
+ _failed_assertions = failed_assertions(response_body)
28
+ _diff = diff(response_body, _failed_assertions)
29
+ super.merge(
30
+ :key => :response_body,
31
+ :failed_assertions => _failed_assertions.map(&:to_hash),
32
+ :diff => _diff,
33
+ :valid => _diff.empty?
34
+ )
35
+ end
36
+
37
+ def initialize_assertions(schema, parent_path = "")
38
+ parent_path = root_path if root_path && parent_path == ""
39
+ (schema["properties"] || {}).each_pair do |key, val|
40
+ next unless val["required"] == true
41
+ path = [parent_path, key].join("/")
42
+ assertions << Assertion.new(path, nil, :type => val["type"])
43
+ if val["type"] == "object"
44
+ initialize_assertions(val, path)
45
+ end
46
+ end
47
+ end
48
+
49
+ def failed_assertions(actual)
50
+ assertions.select do |assertion|
51
+ pointer = JsonPointer.new(actual, assertion.path)
52
+ !pointer.exists? || !assertion_valid?(assertion, pointer.value)
53
+ end
54
+ end
55
+
56
+ def diff(actual, _failed_assertions)
57
+ _diff = _failed_assertions.inject([]) do |memo, assertion|
58
+ pointer = JsonPointer.new(actual, assertion.path)
59
+ if !pointer.exists?
60
+ assertion = assertion.to_hash
61
+ actual_value = nil
62
+ assertion[:op] = "add"
63
+ assertion[:value] = value_for_schema_type(assertion[:type], actual_value)
64
+ assertion[:message] = wrong_type_message(assertion[:type], schema_type(actual_value))
65
+ memo << assertion
66
+ end
67
+ memo
68
+ end
69
+
70
+ _diff + schema_diff(@schema, actual)
71
+ end
72
+
73
+ def schema_diff(schema, actual, parent_path = "")
74
+ properties = schema["properties"]
75
+
76
+ return [] unless Hash === actual
77
+
78
+ if root_path && parent_path == ""
79
+ pointer = JsonPointer.new(actual, root_path)
80
+ return [] unless pointer.exists?
81
+ actual = pointer.value
82
+
83
+ parent_path = root_path
84
+ end
85
+
86
+ actual.inject([]) do |memo, (key, val)|
87
+ path = [parent_path, key].join("/")
88
+ if property = properties[key.to_s]
89
+ schema_property_diff(property, val, path) do |diff_item|
90
+ memo << diff_item
91
+ end
92
+ elsif schema['additionalProperties'] == false
93
+ memo << { :op => "remove", :path => path }
94
+ end
95
+ memo
96
+ end
97
+ end
98
+
99
+ def schema_property_diff(property, actual, path, &block)
100
+ assertion = Assertion.new(path, nil, :type => property["type"], :format => property["format"])
101
+
102
+ if !assertion_valid?(assertion, actual)
103
+ yield({
104
+ :op => "replace",
105
+ :path => path,
106
+ :value => value_for_schema_type(assertion.type, actual),
107
+ :current_value => actual,
108
+ :type => assertion.type,
109
+ :message => wrong_type_message(assertion.type, schema_type(actual))
110
+ })
111
+ elsif !assertion_format_valid?(assertion, actual)
112
+ yield({
113
+ :op => "replace",
114
+ :path => path,
115
+ :value => value_for_schema_format(assertion.format, actual),
116
+ :current_value => actual,
117
+ :type => assertion.type,
118
+ :format => assertion.format,
119
+ :message => wrong_format_message(assertion.format)
120
+ })
121
+ elsif (property["type"] == "object") && (Hash === property["properties"])
122
+ schema_diff(property, actual, path).each { |d| yield(d) }
123
+ elsif (property['type'] == 'array') && (Hash === property['items'])
124
+ array_property = property['items']
125
+ actual.each_with_index do |val, index|
126
+ schema_property_diff(array_property, val, path + "/#{index}", &block)
127
+ end
128
+ end
129
+ end
130
+
131
+ def wrong_type_message(expected_type, actual_type)
132
+ "expected type #{expected_type}, got #{actual_type}"
133
+ end
134
+
135
+ def wrong_format_message(expected_format)
136
+ "expected #{expected_format} format"
137
+ end
138
+
139
+ def assertion_valid?(assertion, actual)
140
+ type = assertion.type
141
+ case type
142
+ when "array"
143
+ Array === actual
144
+ when "boolean"
145
+ (TrueClass === actual) || (FalseClass === actual)
146
+ when "integer"
147
+ Fixnum === actual
148
+ when "number"
149
+ Numeric === actual
150
+ when "null"
151
+ NilClass === actual
152
+ when "object"
153
+ Hash === actual
154
+ when "string"
155
+ String === actual
156
+ else
157
+ super
158
+ end
159
+ end
160
+
161
+ def schema_type(value)
162
+ case value
163
+ when Array
164
+ "array"
165
+ when TrueClass, FalseClass
166
+ "boolean"
167
+ when Fixnum
168
+ "integer"
169
+ when Numeric
170
+ "number"
171
+ when NilClass
172
+ "null"
173
+ when Hash
174
+ "object"
175
+ when String
176
+ "string"
177
+ else
178
+ "unknown"
179
+ end
180
+ end
181
+
182
+ def value_for_schema_type(type, value)
183
+ klass = class_for_schema_type(type)
184
+ if klass == Array
185
+ value.respond_to?(:to_a) ? value.to_a : Array.new
186
+ elsif klass == [TrueClass, FalseClass]
187
+ !!value
188
+ elsif klass == Fixnum
189
+ value.respond_to?(:to_i) ? value.to_i : 0
190
+ elsif klass == Numeric
191
+ value.respond_to?(:to_f) ? value.to_f : 0.0
192
+ elsif klass == Hash
193
+ value.respond_to?(:to_hash) ? value.to_hash : Hash.new
194
+ elsif klass == String
195
+ value.respond_to?(:to_s) ? value.to_s : ""
196
+ else
197
+ nil
198
+ end
199
+ end
200
+
201
+ def value_for_schema_format(format, value)
202
+ case format
203
+ when 'uri'
204
+ "https://example.com"
205
+ end
206
+ end
207
+
208
+ def class_for_schema_type(type)
209
+ case type
210
+ when "array"
211
+ Array
212
+ when "boolean"
213
+ [TrueClass, FalseClass]
214
+ when "integer"
215
+ Fixnum
216
+ when "number"
217
+ Numeric
218
+ when "null"
219
+ NilClass
220
+ when "object"
221
+ Hash
222
+ when "string"
223
+ String
224
+ end
225
+ end
226
+
227
+ end
228
+ end