contracts_api_test 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.
- data/.gitignore +20 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/Guardfile +8 -0
- data/LICENSE.txt +22 -0
- data/README.md +117 -0
- data/Rakefile +7 -0
- data/TODO.md +19 -0
- data/contracts.gemspec +29 -0
- data/lib/contracts.rb +46 -0
- data/lib/contracts/contract.rb +18 -0
- data/lib/contracts/extensions.rb +18 -0
- data/lib/contracts/instantiated_contract.rb +63 -0
- data/lib/contracts/rake_task.rb +75 -0
- data/lib/contracts/request.rb +62 -0
- data/lib/contracts/response.rb +27 -0
- data/lib/contracts/response_adapter.rb +24 -0
- data/lib/contracts/version.rb +3 -0
- data/lib/json-generator.rb +1 -0
- data/lib/json/generator.rb +18 -0
- data/lib/json/generator/array_attribute.rb +11 -0
- data/lib/json/generator/attribute_factory.rb +18 -0
- data/lib/json/generator/basic_attribute.rb +17 -0
- data/lib/json/generator/boolean_attribute.rb +7 -0
- data/lib/json/generator/dereferencer.rb +22 -0
- data/lib/json/generator/empty_attribute.rb +7 -0
- data/lib/json/generator/integer_attribute.rb +7 -0
- data/lib/json/generator/object_attribute.rb +18 -0
- data/lib/json/generator/string_attribute.rb +7 -0
- data/spec/contracts/contract_spec.rb +50 -0
- data/spec/contracts/contracts_spec.rb +77 -0
- data/spec/contracts/extensions_spec.rb +34 -0
- data/spec/contracts/instantiated_contract_spec.rb +224 -0
- data/spec/contracts/request_spec.rb +73 -0
- data/spec/contracts/response_adapter_spec.rb +27 -0
- data/spec/contracts/response_spec.rb +114 -0
- data/spec/data/contract.json +25 -0
- data/spec/json/generator/array_attribute_spec.rb +42 -0
- data/spec/json/generator/attribute_factory_spec.rb +72 -0
- data/spec/json/generator/basic_attribute_spec.rb +41 -0
- data/spec/json/generator/boolean_attribute_spec.rb +17 -0
- data/spec/json/generator/dereferencer_spec.rb +72 -0
- data/spec/json/generator/empty_attribute_spec.rb +17 -0
- data/spec/json/generator/integer_attribute_spec.rb +17 -0
- data/spec/json/generator/object_attribute_spec.rb +100 -0
- data/spec/json/generator/string_attribute_spec.rb +17 -0
- data/spec/json/generator_spec.rb +20 -0
- data/spec/spec_helper.rb +1 -0
- metadata +259 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/Guardfile
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
guard 'rspec', :cli => '--color --require spec_helper', :version => 2 do
|
2
|
+
watch(%r{^spec/contracts/.+_spec\.rb$})
|
3
|
+
watch(%r{^spec/json/.+_spec\.rb$})
|
4
|
+
watch(%r{^lib/contracts\.rb$}) { |m| "spec" }
|
5
|
+
watch(%r{^lib/json-generator\.rb$}) { |m| "spec" }
|
6
|
+
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
7
|
+
watch('spec/spec_helper.rb') { "spec" }
|
8
|
+
end
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 TODO: Write your name
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
# Contracts
|
2
|
+
|
3
|
+
Contracts is a Ruby implementation of the [Consumer-Driven Contracts](http://martinfowler.com/articles/consumerDrivenContracts.html)
|
4
|
+
pattern for evolving services. It's main features are:
|
5
|
+
|
6
|
+
- A simple language for specifying a contract;
|
7
|
+
- An automated way to validate that a producer meets it's consumers requirements;
|
8
|
+
- An auto-generated stub to be used in the consumer's acceptance tests.
|
9
|
+
|
10
|
+
It was developed in a micro-services environment, specifically a RESTful one, so expect it to be opinionated. Although
|
11
|
+
there is enough functionality implemented to motivate us to open-source this, it is still a work in progress and under active
|
12
|
+
development. Check the Constraints session for further information on what works and what doesn't.
|
13
|
+
|
14
|
+
## Specifying Contracts
|
15
|
+
|
16
|
+
A contract specifies a single message exchange between a consumer and a provider. In a RESTful world, this means
|
17
|
+
an HTTP interaction, which is composed of two main parts: a request and a response.
|
18
|
+
|
19
|
+
A request has the following attributes:
|
20
|
+
|
21
|
+
- Method: the method of the HTTP request (e.g. GET, POST, PUT, DELETE);
|
22
|
+
- Path: the relative path (without host) of the provider's endpoint;
|
23
|
+
- Headers: headers sent in the HTTP request;
|
24
|
+
- Params: any data or parameters of the HTTP request (e.g. query string for GET, body for POST)
|
25
|
+
|
26
|
+
A response has the following attributes:
|
27
|
+
|
28
|
+
- Status: the HTTP response status code (e.g. 200, 404, 500);
|
29
|
+
- Headers: the HTTP response headers;
|
30
|
+
- Body: a JSON Schema defining the expected structure of the HTTP response body.
|
31
|
+
|
32
|
+
Contracts relies on a simple, JSON based language for defining contracts. Below is an example contract for a GET request
|
33
|
+
to the /hello_world endpoint of a provider:
|
34
|
+
|
35
|
+
{
|
36
|
+
"request": {
|
37
|
+
"method": "GET",
|
38
|
+
"path": "/hello_world",
|
39
|
+
"headers": {
|
40
|
+
"Accept": "application/json"
|
41
|
+
},
|
42
|
+
"params": {}
|
43
|
+
},
|
44
|
+
|
45
|
+
"response": {
|
46
|
+
"status": 200,
|
47
|
+
"headers": {
|
48
|
+
"Content-Type": "application/json"
|
49
|
+
},
|
50
|
+
"body": {
|
51
|
+
"description": "A simple response",
|
52
|
+
"type": "object",
|
53
|
+
"properties": {
|
54
|
+
"message": {
|
55
|
+
"type": "string"
|
56
|
+
}
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
}
|
61
|
+
|
62
|
+
The host address is intentionally left out of the request specification so that we can validate a contract against any provider.
|
63
|
+
It also reinforces the fact that a contract defines the expectation of a consumer, and not the implementation of any specific provider.
|
64
|
+
|
65
|
+
## Validating Contracts
|
66
|
+
|
67
|
+
There are two ways to validate a contract against a provider: through a Rake task or programatically.
|
68
|
+
|
69
|
+
### Rake Task
|
70
|
+
|
71
|
+
Contracts includes a default Rake task. To use it, include it in your Rakefile:
|
72
|
+
|
73
|
+
require 'contracts/rake_task'
|
74
|
+
|
75
|
+
Validating a contract against a provider is as simple as running:
|
76
|
+
|
77
|
+
$ rake contracts:validate[host,dir] # Validates all contracts in a given directory against a given host
|
78
|
+
|
79
|
+
It is recommended that you also include [colorize](https://github.com/fazibear/colorize) to get prettier, colorful output.
|
80
|
+
|
81
|
+
### Programatically
|
82
|
+
|
83
|
+
The easiest way to load a contract from a file and validate it against a host is by using the builder interface:
|
84
|
+
|
85
|
+
> contract = Contracts.build_from_file('/path/to/contract.json', 'http://dummyprovider.com')
|
86
|
+
> contract.validate
|
87
|
+
|
88
|
+
## Auto-Generated Stubs
|
89
|
+
|
90
|
+
Contracts provides an API to be used in the consumer's acceptance tests. It uses a custom JSON Schema parser and generator
|
91
|
+
to generate a valid JSON document as the response body, and relies on [WebMock](https://github.com/bblimke/webmock)
|
92
|
+
to stub any HTTP requests made by your application. Important: the JSON generator is in very early stages and does not work
|
93
|
+
with the entire JSON Schema specification.
|
94
|
+
|
95
|
+
First, register the contracts that are going to be used in the acceptance tests suite:
|
96
|
+
|
97
|
+
contract = Contracts.build_from_file('/path/to/contract.json', 'http://dummyprovider.com')
|
98
|
+
Contracts.register('my_contract', contract)
|
99
|
+
|
100
|
+
Then, in the setup phase of the test, specify which contracts will be used for that test:
|
101
|
+
|
102
|
+
Contracts.use('my_contract')
|
103
|
+
|
104
|
+
If default values are not specified in the contract's response body, a default value will be automatically generated. It is possible
|
105
|
+
to overwrite those values, however, by passing a second argument:
|
106
|
+
|
107
|
+
Contracts.use('my_contract', :value => 'new value')
|
108
|
+
|
109
|
+
The values are merged using [hash-deep-merge](https://github.com/Offirmo/hash-deep-merge).
|
110
|
+
|
111
|
+
## Contributing
|
112
|
+
|
113
|
+
1. Fork it
|
114
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
115
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
116
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
117
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/TODO.md
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# TODO
|
2
|
+
|
3
|
+
## Nice to have
|
4
|
+
|
5
|
+
- Cucumber Tests as docs (see https://relishapp.com/cucumber/cucumber/docs/);
|
6
|
+
- Fake Server (sinatra app generating fake responses based on the contracts);
|
7
|
+
- Optional "require" format for JSON Schema: # 'required': ['id', 'categorias', 'titulo', ...];
|
8
|
+
- Contract variables for easy writing. Such as: 'path': '/member/{id}';
|
9
|
+
- Add JSHint rake task to validate contracts syntax;
|
10
|
+
- Pretty output for hash difference (using something like [hashdiff](https://github.com/liufengyun/hashdiff)).
|
11
|
+
- A default header in the response marking the response as "mocked"
|
12
|
+
- Parameter matcher should use an idea of "subset" instead of matching all the parameters
|
13
|
+
- 'default' value to be used when it is present with an array of types
|
14
|
+
- Support 'null' attribute type
|
15
|
+
- Validate contract structure in a rake task. Then assume all contracts are valid.
|
16
|
+
|
17
|
+
## Assumptions
|
18
|
+
|
19
|
+
- JSON Schema references are stored in the 'definitions' attribute, in the schema's root element.
|
data/contracts.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'contracts/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "contracts_api_test"
|
8
|
+
gem.version = Contracts::VERSION
|
9
|
+
gem.authors = ["Abril Midia", "ThoughtWorks"]
|
10
|
+
gem.email = ["vejasp-dev@abril.com.br", "abril_vejasp_dev@thoughtworks.com"]
|
11
|
+
gem.description = %q{Consumer-Driven Contracts}
|
12
|
+
gem.summary = %q{Consumer-Driven Contracts Gem}
|
13
|
+
|
14
|
+
gem.files = `git ls-files`.split($/)
|
15
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
16
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
17
|
+
gem.require_paths = ["lib"]
|
18
|
+
|
19
|
+
gem.add_dependency "webmock"
|
20
|
+
gem.add_dependency "json"
|
21
|
+
gem.add_dependency "json-schema"
|
22
|
+
gem.add_dependency "hash-deep-merge"
|
23
|
+
gem.add_dependency "httparty"
|
24
|
+
gem.add_dependency "addressable"
|
25
|
+
|
26
|
+
gem.add_development_dependency "rake"
|
27
|
+
gem.add_development_dependency "rspec"
|
28
|
+
gem.add_development_dependency "guard-rspec"
|
29
|
+
end
|
data/lib/contracts.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require "contracts/version"
|
2
|
+
|
3
|
+
require "httparty"
|
4
|
+
require "hash_deep_merge"
|
5
|
+
require "json"
|
6
|
+
require "json-schema"
|
7
|
+
require "json-generator"
|
8
|
+
require "webmock"
|
9
|
+
require "ostruct"
|
10
|
+
|
11
|
+
require "contracts/extensions"
|
12
|
+
require "contracts/request"
|
13
|
+
require "contracts/response_adapter"
|
14
|
+
require "contracts/response"
|
15
|
+
require "contracts/instantiated_contract"
|
16
|
+
require "contracts/contract"
|
17
|
+
|
18
|
+
module Contracts
|
19
|
+
def self.build_from_file(contract_path, host)
|
20
|
+
definition = JSON.parse(File.read(contract_path))
|
21
|
+
request = Request.new(host, definition["request"])
|
22
|
+
response = Response.new(definition["response"])
|
23
|
+
Contract.new(request, response)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.register(name, contract)
|
27
|
+
raise ArgumentError, "contract \" #{name}\" has already been registered" if registered.has_key?(name)
|
28
|
+
registered[name] = contract
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.use(contract_name, values = nil, path = nil)
|
32
|
+
raise ArgumentError, "contract \"#{contract_name}\" not found" unless registered.has_key?(contract_name)
|
33
|
+
instantiated_contract = registered[contract_name].instantiate(values)
|
34
|
+
instantiated_contract.request.path = path unless path.nil?
|
35
|
+
instantiated_contract.stub!
|
36
|
+
instantiated_contract
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.registered
|
40
|
+
@registered ||= {}
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.unregister_all!
|
44
|
+
@registered = {}
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Contracts
|
2
|
+
class Contract
|
3
|
+
def initialize(request, response)
|
4
|
+
@request = request
|
5
|
+
@response = response
|
6
|
+
end
|
7
|
+
|
8
|
+
def instantiate(values = nil)
|
9
|
+
instantiated_contract = InstantiatedContract.new(@request, @response.instantiate)
|
10
|
+
instantiated_contract.replace!(values) unless values.nil?
|
11
|
+
instantiated_contract
|
12
|
+
end
|
13
|
+
|
14
|
+
def validate
|
15
|
+
@response.validate(@request.execute)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Contracts
|
2
|
+
module Extensions
|
3
|
+
module HashSubsetOf
|
4
|
+
def subset_of?(other)
|
5
|
+
(self.to_a - other.to_a).empty?
|
6
|
+
end
|
7
|
+
|
8
|
+
def normalize_keys
|
9
|
+
self.inject({}) do |normalized, (key, value)|
|
10
|
+
normalized[key.to_s.downcase] = value
|
11
|
+
normalized
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
Hash.send(:include, Contracts::Extensions::HashSubsetOf)
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Contracts
|
2
|
+
class InstantiatedContract
|
3
|
+
attr_accessor :request
|
4
|
+
attr_reader :response_body
|
5
|
+
|
6
|
+
def initialize(request, response)
|
7
|
+
@request = request
|
8
|
+
@response = response
|
9
|
+
@response_body = response.body
|
10
|
+
end
|
11
|
+
|
12
|
+
def request_path
|
13
|
+
@request.absolute_uri
|
14
|
+
end
|
15
|
+
|
16
|
+
def request_uri
|
17
|
+
@request.full_uri
|
18
|
+
end
|
19
|
+
|
20
|
+
def replace!(values)
|
21
|
+
if @response_body.respond_to?(:normalize_keys)
|
22
|
+
@response_body = @response_body.normalize_keys.deep_merge(values.normalize_keys)
|
23
|
+
else
|
24
|
+
@response_body = values
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def stub!
|
29
|
+
WebMock.stub_request(@request.method, "#{@request.host}#{@request.path}").
|
30
|
+
with(request_details).
|
31
|
+
to_return({
|
32
|
+
:status => @response.status,
|
33
|
+
:headers => @response.headers,
|
34
|
+
:body => format_body(@response_body)
|
35
|
+
})
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def format_body(body)
|
41
|
+
if body.is_a?(Hash) or body.is_a?(Array)
|
42
|
+
body.to_json
|
43
|
+
else
|
44
|
+
body
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def request_details
|
49
|
+
details = {}
|
50
|
+
unless @request.params.empty?
|
51
|
+
details[webmock_params_key] = @request.params
|
52
|
+
end
|
53
|
+
unless @request.headers.empty?
|
54
|
+
details[:headers] = @request.headers
|
55
|
+
end
|
56
|
+
details
|
57
|
+
end
|
58
|
+
|
59
|
+
def webmock_params_key
|
60
|
+
@request.method == :get ? :query : :body
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'contracts'
|
2
|
+
|
3
|
+
unless String.respond_to?(:colors)
|
4
|
+
class String
|
5
|
+
def colorize(*args)
|
6
|
+
self
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
module Contracts
|
12
|
+
class RakeTask
|
13
|
+
include Rake::DSL
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@exit_with_error = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def install
|
20
|
+
desc "Tasks for contracts gem"
|
21
|
+
namespace :contracts do
|
22
|
+
validate_task
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate_task
|
27
|
+
desc "Validates all contracts in a given directory against a given host"
|
28
|
+
task :validate, :host, :dir do |t, args|
|
29
|
+
if args.to_a.size < 2
|
30
|
+
fail "USAGE: rake contracts:validate[<host>, <contract_dir>]".colorize(:yellow)
|
31
|
+
end
|
32
|
+
|
33
|
+
validate_contracts(args[:host], args[:dir])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def validate_contracts(host, dir)
|
38
|
+
WebMock.allow_net_connect!
|
39
|
+
|
40
|
+
contracts = Dir[File.join(dir, '*.json')]
|
41
|
+
if contracts.empty?
|
42
|
+
fail "No contracts found in directory #{dir}".colorize(:yellow)
|
43
|
+
end
|
44
|
+
|
45
|
+
puts "Validating contracts in directory #{dir} against host #{host}\n\n"
|
46
|
+
|
47
|
+
total_failed = 0
|
48
|
+
contracts.each do |contract_file|
|
49
|
+
print "#{contract_file.split('/').last}:"
|
50
|
+
contract = Contracts.build_from_file(contract_file, host)
|
51
|
+
errors = contract.validate
|
52
|
+
|
53
|
+
if errors.empty?
|
54
|
+
puts " OK!".colorize(:green)
|
55
|
+
else
|
56
|
+
@exit_with_error = true
|
57
|
+
total_failed += 1
|
58
|
+
puts " FAILED!".colorize(:red)
|
59
|
+
errors.each do |error|
|
60
|
+
puts "\t* #{error}".colorize(:light_red)
|
61
|
+
end
|
62
|
+
puts ""
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
if @exit_with_error
|
67
|
+
fail "#{total_failed} of #{contracts.size} failed. Check output for detailed error messages.".colorize(:red)
|
68
|
+
else
|
69
|
+
puts "#{contracts.size} valid contract#{contracts.size > 1 ? 's' : nil}".colorize(:green)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
Contracts::RakeTask.new.install
|