pacto 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +20 -0
- data/.rspec +2 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/Guardfile +8 -0
- data/LICENSE.txt +22 -0
- data/README.md +129 -0
- data/Rakefile +7 -0
- data/TODO.md +19 -0
- data/lib/pacto.rb +48 -0
- data/lib/pacto/contract.rb +22 -0
- data/lib/pacto/extensions.rb +18 -0
- data/lib/pacto/file_pre_processor.rb +12 -0
- data/lib/pacto/instantiated_contract.rb +62 -0
- data/lib/pacto/rake_task.rb +75 -0
- data/lib/pacto/request.rb +58 -0
- data/lib/pacto/response.rb +27 -0
- data/lib/pacto/response_adapter.rb +24 -0
- data/lib/pacto/version.rb +3 -0
- data/pacto.gemspec +36 -0
- data/spec/data/contract.json +25 -0
- data/spec/pacto/contract_spec.rb +50 -0
- data/spec/pacto/extensions_spec.rb +34 -0
- data/spec/pacto/file_pre_processor_spec.rb +13 -0
- data/spec/pacto/instantiated_contract_spec.rb +224 -0
- data/spec/pacto/pacto_spec.rb +87 -0
- data/spec/pacto/request_spec.rb +73 -0
- data/spec/pacto/response_adapter_spec.rb +27 -0
- data/spec/pacto/response_spec.rb +114 -0
- data/spec/spec_helper.rb +4 -0
- metadata +295 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
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 ThoughtWorks Brasil & Abril Midia
|
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,129 @@
|
|
1
|
+
# Pacto
|
2
|
+
|
3
|
+
Pacto 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
|
+
Pacto 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
|
+
Pacto 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
|
+
require 'contracts'
|
86
|
+
|
87
|
+
WebMock.allow_net_connect!
|
88
|
+
contract = Pacto.build_from_file('/path/to/contract.json', 'http://dummyprovider.com')
|
89
|
+
contract.validate
|
90
|
+
|
91
|
+
## Auto-Generated Stubs
|
92
|
+
|
93
|
+
Pacto provides an API to be used in the consumer's acceptance tests. It uses a custom JSON Schema parser and generator
|
94
|
+
to generate a valid JSON document as the response body, and relies on [WebMock](https://github.com/bblimke/webmock)
|
95
|
+
to stub any HTTP requests made by your application. Important: the JSON generator is in very early stages and does not work
|
96
|
+
with the entire JSON Schema specification.
|
97
|
+
|
98
|
+
First, register the contracts that are going to be used in the acceptance tests suite:
|
99
|
+
|
100
|
+
require 'contracts'
|
101
|
+
|
102
|
+
contract = Pacto.build_from_file('/path/to/contract.json', 'http://dummyprovider.com')
|
103
|
+
Pacto.register('my_contract', contract)
|
104
|
+
|
105
|
+
Then, in the setup phase of the test, specify which contracts will be used for that test:
|
106
|
+
|
107
|
+
Pacto.use('my_contract')
|
108
|
+
|
109
|
+
If default values are not specified in the contract's response body, a default value will be automatically generated. It is possible
|
110
|
+
to overwrite those values, however, by passing a second argument:
|
111
|
+
|
112
|
+
Pacto.use('my_contract', :value => 'new value')
|
113
|
+
|
114
|
+
The values are merged using [hash-deep-merge](https://github.com/Offirmo/hash-deep-merge).
|
115
|
+
|
116
|
+
## Code status
|
117
|
+
|
118
|
+
[![Build Status](https://travis-ci.org/thoughtworks/contracts.png)](https://travis-ci.org/thoughtworks/contracts)
|
119
|
+
[![Code Climate](https://codeclimate.com/github/thoughtworks/contracts.png)](https://codeclimate.com/github/thoughtworks/contracts)
|
120
|
+
[![Dependency Status](https://gemnasium.com/thoughtworks/contracts.png)](https://gemnasium.com/thoughtworks/contracts)
|
121
|
+
[![Coverage Status](https://coveralls.io/repos/thoughtworks/contracts/badge.png)](https://coveralls.io/r/thoughtworks/contracts)
|
122
|
+
|
123
|
+
## Contributing
|
124
|
+
|
125
|
+
1. Fork it
|
126
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
127
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
128
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
129
|
+
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/lib/pacto.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require "pacto/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
|
+
require "erb"
|
11
|
+
|
12
|
+
require "pacto/extensions"
|
13
|
+
require "pacto/request"
|
14
|
+
require "pacto/response_adapter"
|
15
|
+
require "pacto/response"
|
16
|
+
require "pacto/instantiated_contract"
|
17
|
+
require "pacto/contract"
|
18
|
+
require "pacto/file_pre_processor"
|
19
|
+
|
20
|
+
module Pacto
|
21
|
+
def self.build_from_file(contract_path, host, file_pre_processor=FilePreProcessor.new)
|
22
|
+
contract_definition_expanded = file_pre_processor.process(File.read(contract_path))
|
23
|
+
definition = JSON.parse(contract_definition_expanded)
|
24
|
+
request = Request.new(host, definition["request"])
|
25
|
+
response = Response.new(definition["response"])
|
26
|
+
Contract.new(request, response)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.register(name, contract)
|
30
|
+
raise ArgumentError, "contract \" #{name}\" has already been registered" if registered.has_key?(name)
|
31
|
+
registered[name] = contract
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.use(contract_name, values = nil)
|
35
|
+
raise ArgumentError, "contract \"#{contract_name}\" not found" unless registered.has_key?(contract_name)
|
36
|
+
instantiated_contract = registered[contract_name].instantiate(values)
|
37
|
+
instantiated_contract.stub!
|
38
|
+
instantiated_contract
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.registered
|
42
|
+
@registered ||= {}
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.unregister_all!
|
46
|
+
@registered = {}
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Pacto
|
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_gotten = @request.execute
|
16
|
+
if ENV["DEBUG_CONTRACTS"]
|
17
|
+
puts "[DEBUG] Response: #{response_gotten.inspect}"
|
18
|
+
end
|
19
|
+
@response.validate(response_gotten)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Pacto
|
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, Pacto::Extensions::HashSubsetOf)
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Pacto
|
2
|
+
class InstantiatedContract
|
3
|
+
attr_reader :response_body
|
4
|
+
|
5
|
+
def initialize(request, response)
|
6
|
+
@request = request
|
7
|
+
@response = response
|
8
|
+
@response_body = response.body
|
9
|
+
end
|
10
|
+
|
11
|
+
def request_path
|
12
|
+
@request.absolute_uri
|
13
|
+
end
|
14
|
+
|
15
|
+
def request_uri
|
16
|
+
@request.full_uri
|
17
|
+
end
|
18
|
+
|
19
|
+
def replace!(values)
|
20
|
+
if @response_body.respond_to?(:normalize_keys)
|
21
|
+
@response_body = @response_body.normalize_keys.deep_merge(values.normalize_keys)
|
22
|
+
else
|
23
|
+
@response_body = values
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def stub!
|
28
|
+
WebMock.stub_request(@request.method, "#{@request.host}#{@request.path}").
|
29
|
+
with(request_details).
|
30
|
+
to_return({
|
31
|
+
:status => @response.status,
|
32
|
+
:headers => @response.headers,
|
33
|
+
:body => format_body(@response_body)
|
34
|
+
})
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def format_body(body)
|
40
|
+
if body.is_a?(Hash) or body.is_a?(Array)
|
41
|
+
body.to_json
|
42
|
+
else
|
43
|
+
body
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def request_details
|
48
|
+
details = {}
|
49
|
+
unless @request.params.empty?
|
50
|
+
details[webmock_params_key] = @request.params
|
51
|
+
end
|
52
|
+
unless @request.headers.empty?
|
53
|
+
details[:headers] = @request.headers
|
54
|
+
end
|
55
|
+
details
|
56
|
+
end
|
57
|
+
|
58
|
+
def webmock_params_key
|
59
|
+
@request.method == :get ? :query : :body
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'pacto'
|
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 Pacto
|
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.erb,.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 = Pacto.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
|
+
Pacto::RakeTask.new.install
|