Repository: thoughtworks/pacto
Branch: master
Commit: a1bd9668deca
Files: 200
Total size: 283.1 KB
Directory structure:
gitextract__ymcm0w_/
├── .gitignore
├── .rspec
├── .rubocop.yml
├── .travis.yml
├── CONTRIBUTING.md
├── Gemfile
├── Guardfile
├── LICENSE.txt
├── Procfile
├── README.md
├── Rakefile
├── TODO.md
├── appveyor.yml
├── bin/
│ ├── pacto
│ └── pacto-server
├── changelog.md
├── docs/
│ ├── configuration.md
│ ├── consumer.md
│ ├── cops.md
│ ├── forensics.md
│ ├── generation.md
│ ├── rake_tasks.md
│ ├── rspec.md
│ ├── samples.md
│ ├── server.md
│ ├── server_cli.md
│ └── stenographer.md
├── features/
│ ├── configuration/
│ │ └── strict_matchers.feature
│ ├── evolve/
│ │ ├── README.md
│ │ └── existing_services.feature
│ ├── generate/
│ │ ├── README.md
│ │ └── generation.feature
│ ├── steps/
│ │ └── pacto_steps.rb
│ ├── stub/
│ │ ├── README.md
│ │ └── templates.feature
│ ├── support/
│ │ └── env.rb
│ └── validate/
│ ├── README.md
│ ├── meta_validation.feature
│ └── validation.feature
├── lib/
│ ├── pacto/
│ │ ├── actor.rb
│ │ ├── actors/
│ │ │ ├── from_examples.rb
│ │ │ └── json_generator.rb
│ │ ├── body_parsing.rb
│ │ ├── cli/
│ │ │ └── helpers.rb
│ │ ├── cli.rb
│ │ ├── consumer/
│ │ │ └── faraday_driver.rb
│ │ ├── consumer.rb
│ │ ├── contract.rb
│ │ ├── contract_factory.rb
│ │ ├── contract_files.rb
│ │ ├── contract_set.rb
│ │ ├── cops/
│ │ │ ├── body_cop.rb
│ │ │ ├── request_body_cop.rb
│ │ │ ├── response_body_cop.rb
│ │ │ ├── response_header_cop.rb
│ │ │ └── response_status_cop.rb
│ │ ├── cops.rb
│ │ ├── core/
│ │ │ ├── configuration.rb
│ │ │ ├── contract_registry.rb
│ │ │ ├── hook.rb
│ │ │ ├── http_middleware.rb
│ │ │ ├── investigation_registry.rb
│ │ │ ├── modes.rb
│ │ │ ├── pacto_request.rb
│ │ │ └── pacto_response.rb
│ │ ├── dash.rb
│ │ ├── erb_processor.rb
│ │ ├── errors.rb
│ │ ├── extensions.rb
│ │ ├── forensics/
│ │ │ ├── investigation_filter.rb
│ │ │ └── investigation_matcher.rb
│ │ ├── formats/
│ │ │ ├── legacy/
│ │ │ │ ├── contract.rb
│ │ │ │ ├── contract_builder.rb
│ │ │ │ ├── contract_factory.rb
│ │ │ │ ├── contract_generator.rb
│ │ │ │ ├── generator/
│ │ │ │ │ └── filters.rb
│ │ │ │ ├── generator_hint.rb
│ │ │ │ ├── request_clause.rb
│ │ │ │ └── response_clause.rb
│ │ │ └── swagger/
│ │ │ ├── contract.rb
│ │ │ ├── contract_factory.rb
│ │ │ ├── request_clause.rb
│ │ │ └── response_clause.rb
│ │ ├── generator.rb
│ │ ├── handlers/
│ │ │ ├── json_handler.rb
│ │ │ └── text_handler.rb
│ │ ├── hooks/
│ │ │ └── erb_hook.rb
│ │ ├── investigation.rb
│ │ ├── logger.rb
│ │ ├── meta_schema.rb
│ │ ├── observers/
│ │ │ └── stenographer.rb
│ │ ├── provider.rb
│ │ ├── rake_task.rb
│ │ ├── request_clause.rb
│ │ ├── request_pattern.rb
│ │ ├── resettable.rb
│ │ ├── response_clause.rb
│ │ ├── rspec.rb
│ │ ├── server/
│ │ │ ├── cli.rb
│ │ │ ├── config.rb
│ │ │ ├── proxy.rb
│ │ │ └── settings.rb
│ │ ├── server.rb
│ │ ├── stubs/
│ │ │ ├── uri_pattern.rb
│ │ │ └── webmock_adapter.rb
│ │ ├── test_helper.rb
│ │ ├── ui.rb
│ │ ├── uri.rb
│ │ └── version.rb
│ └── pacto.rb
├── pacto-server.gemspec
├── pacto.gemspec
├── resources/
│ ├── contract_schema.json
│ ├── draft-03.json
│ └── draft-04.json
├── sample_apis/
│ ├── album/
│ │ └── cover_api.rb
│ ├── config.ru
│ ├── echo_api.rb
│ ├── files_api.rb
│ ├── hello_api.rb
│ ├── ping_api.rb
│ ├── reverse_api.rb
│ └── user_api.rb
├── samples/
│ ├── README.md
│ ├── Rakefile
│ ├── configuration.rb
│ ├── consumer.rb
│ ├── contracts/
│ │ ├── README.md
│ │ ├── contract.js
│ │ ├── get_album_cover.json
│ │ ├── localhost/
│ │ │ └── api/
│ │ │ ├── echo.json
│ │ │ └── ping.json
│ │ └── user.json
│ ├── cops.rb
│ ├── forensics.rb
│ ├── generation.rb
│ ├── rake_tasks.sh
│ ├── rspec.rb
│ ├── samples.rb
│ ├── scripts/
│ │ ├── bootstrap
│ │ └── wrapper
│ ├── server.rb
│ ├── server_cli.sh
│ └── stenographer.rb
├── spec/
│ ├── coveralls_helper.rb
│ ├── fabricators/
│ │ ├── contract_fabricator.rb
│ │ ├── http_fabricator.rb
│ │ └── webmock_fabricator.rb
│ ├── fixtures/
│ │ └── contracts/
│ │ ├── deprecated/
│ │ │ └── deprecated_contract.json
│ │ ├── legacy/
│ │ │ ├── contract.json
│ │ │ ├── contract_with_examples.json
│ │ │ ├── simple_contract.json
│ │ │ ├── strict_contract.json
│ │ │ └── templating_contract.json
│ │ └── swagger/
│ │ └── petstore.yaml
│ ├── integration/
│ │ ├── e2e_spec.rb
│ │ ├── forensics/
│ │ │ └── integration_matcher_spec.rb
│ │ ├── rspec_spec.rb
│ │ └── templating_spec.rb
│ ├── spec_helper.rb
│ └── unit/
│ ├── actors/
│ │ ├── from_examples_spec.rb
│ │ └── json_generator_spec.rb
│ └── pacto/
│ ├── actor_spec.rb
│ ├── configuration_spec.rb
│ ├── consumer/
│ │ └── faraday_driver_spec.rb
│ ├── contract_factory_spec.rb
│ ├── contract_files_spec.rb
│ ├── contract_set_spec.rb
│ ├── contract_spec.rb
│ ├── cops/
│ │ ├── body_cop_spec.rb
│ │ ├── response_header_cop_spec.rb
│ │ └── response_status_cop_spec.rb
│ ├── cops_spec.rb
│ ├── core/
│ │ ├── configuration_spec.rb
│ │ ├── contract_registry_spec.rb
│ │ ├── http_middleware_spec.rb
│ │ ├── investigation_spec.rb
│ │ └── modes_spec.rb
│ ├── erb_processor_spec.rb
│ ├── extensions_spec.rb
│ ├── formats/
│ │ ├── legacy/
│ │ │ ├── contract_builder_spec.rb
│ │ │ ├── contract_factory_spec.rb
│ │ │ ├── contract_generator_spec.rb
│ │ │ ├── contract_spec.rb
│ │ │ ├── generator/
│ │ │ │ └── filters_spec.rb
│ │ │ ├── request_clause_spec.rb
│ │ │ └── response_clause_spec.rb
│ │ └── swagger/
│ │ ├── contract_factory_spec.rb
│ │ └── contract_spec.rb
│ ├── hooks/
│ │ └── erb_hook_spec.rb
│ ├── investigation_registry_spec.rb
│ ├── logger_spec.rb
│ ├── meta_schema_spec.rb
│ ├── pacto_spec.rb
│ ├── request_pattern_spec.rb
│ ├── stubs/
│ │ ├── observers/
│ │ │ └── stenographer_spec.rb
│ │ ├── uri_pattern_spec.rb
│ │ └── webmock_adapter_spec.rb
│ └── uri_spec.rb
└── tasks/
└── release.rake
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
*.gem
*.log
*.pid
*.rbc
.bundle
.config
.yardoc
Gemfile.lock
InstalledFiles
_yardoc
coverage
doc/
lib/bundler/man
pkg
rdoc
spec/reports
test/tmp
test/version_tmp
tmp
*.swp
*.swo
.idea/
.floo*
.sublime*
tags
pacto.log
.polytrix/
================================================
FILE: .rspec
================================================
--colour
--require spec_helper
================================================
FILE: .rubocop.yml
================================================
require: rubocop-rspec
Documentation:
Enabled: false
DotPosition:
Enabled: false
LineLength:
Enabled: false
MethodLength:
Max: 20
Style/Encoding:
EnforcedStyle: when_needed
AllCops:
Include:
- Guardfile
- '**/Rakefile'
- pacto*.gemspec
Exclude:
- bin/**/*
- tmp/**/*
RSpec/DescribeClass:
Exclude:
- samples/**/*
- spec/integration/**/*
RSpec/MultipleDescribes:
Exclude:
- samples/**/*
================================================
FILE: .travis.yml
================================================
language: ruby
rvm:
- 2.1.0
- 2.0.0
- 1.9.3
# There is a bug in jruby-1.7.15
# that is blocking testing
- jruby
before_script:
- gem install foreman
- foreman start &
matrix:
allow_failures:
- rvm: jruby
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
You are welcome to contribute to Pacto and this guide will help you to:
- [Setup](#setup) all the needed dependencies in order to start hacking.
- Follow [conventions](#code-conventions) agreed among the project
contributors.
- Follow Pacto's suggested [workflow](#workflow).
- [Submit](#submit-code) new code to the project.
- Run the automated suite of [tests](#run-tests) that is bundled with Pacto.
- Find easily code annotations for [technical debt](#technical-debt) (TODOs,
FIXMEs, etc)
- Be aware of some [troubleshooting tips](#troubleshooting) when running issues
with Pacto.
## Development (suggested) workflow
Pacto comes with [`guard`](https://github.com/guard/guard) enabled, this means
that guard will trigger the tests after any change is made on the source code.
We try to keep the feedback loop as fast as we can, so you can be able to run
all the tests everytime you make a change on the project. If you want to follow
this workflow just run:
`bundle exec guard`
Guard will run first the static analysis and then will run the unit test related
with the file that was changed, later the integration test and last the user
journey tests.
## Submit code
Any contribution has to come from a Pull Request via GitHub, to do it just
follow these steps:
1. Fork it (`git clone git@github.com:thoughtworks/pacto.git`).
2. Create your feature branch (`git checkout -b my-new-feature`).
3. Commit your changes (`git commit -am 'Add some feature'`).
4. Verify that the tests are passing (`bundle exec rake`).
5. Push to the branch (`git push origin my-new-feature`).
6. Create new Pull Request.
## Setting up
You will need to have installed:
- Ruby 1.9.3 or greater installed.
- Bundler gem installed (`gem install bundler`).
- Install all the dependencies (`bundle install`).
## Coding conventions
### Style guide
Contributing in a project among several authors could lead to different styles
of writting code. In order to create some basic baseline on the source code
Pacto comes with an static code analyzer that will enforce the code to follow
the [Ruby Style Guide](https://github.com/bbatsov/ruby-style-guide). To execute
the analyzer just run:
`bundle exec rubocop`
### Writing tests
Pacto unit tests and integration test are written in RSpec and the user journey
tests are written in Cucumber. For the RSpec tests we suggest to follow the
[Better Specs](http://betterspecs.org/) guideline.
## Running tests
Pacto comes with a set of automated tests. All the tests are runnable via rake
tasks:
- Unit tests (`bundle exec rake unit`).
- Integration tests (`bundle exec rake integration`).
- User journey tests (`bundle exec rake journeys`).
It is also possible run specific tests:
- Unit tests (`bundle exec rspec spec/unit/[file_path]`
- Integration tests (`bundle exec rspec spec/integration/[file_path]`)
- User journey tests (`bundle exec cucumber features/[file_path] -r features/support/env.rb`)
### Checking that all is green
To know that both tests and static analysis is working fine you just have to
run:
`bundle exec rake`
## Technical Debt
Some of the code in Pacto is commented with the anotations TODO or
FIXME that might point to some potencial technical debt on the source code. If
you are interested to list where are all these, just run:
`bundle exec notes`
## Troubleshooting
### Debugging pacto
If you run into some strange behaviour that Pacto might have, you can take
advantage of the debugging capabilities of Pacto. Running the tests with the
environment variable PACTO_DEBUG=true will show (on the standard output) more
details what Pacto is doing behind the scenes.
### Gemfile.lock
Because Pacto is a gem we don't include the Gemfile.lock into the repository
([here is the reason](http://yehudakatz.com/2010/12/16/clarifying-the-roles-of-the-gemspec-and-gemfile/)).
This could lead to some problems in your daily job as contributor specially
when there is an upgrade in any of the gems that Pacto depends upon. That is
why we recomend you to remove the Gemfile.lock and generate it
(`bundle install`) everytime there are changes on the dependencies.
================================================
FILE: Gemfile
================================================
source 'https://rubygems.org'
# Specify your gem's dependencies in pacto.gemspec
gemspec name: 'pacto'
gemspec name: 'pacto-server'
# This is only used by Relish tests. Putting it here let's travis
# pre-install so we can speed up the test with `bundle install --local`,
# avoiding Aruba timeouts.
gem 'excon'
gem 'octokit'
group :samples do
gem 'grape'
gem 'grape-swagger'
gem 'puma'
gem 'rake'
gem 'pry'
gem 'rack'
end
================================================
FILE: Guardfile
================================================
# vim: syntax=ruby filetype=ruby
guard :rubocop, all_on_start: false do
watch(/.+\.rb$/)
watch(/\.gemspec$/)
watch('Guardfile')
watch('Rakefile')
watch('.rubocop.yml') { '.' }
watch('.rubocop-todo.yml') { '.' }
end
group :tests, halt_on_fail: true do
guard :rspec, cmd: 'bundle exec rspec' do
# Unit tests
watch(%r{^spec/unit/.+_spec\.rb$})
watch(/^lib\/(.+)\.rb$/) { |_m| 'spec/unit/#{m[1]}_spec.rb' }
watch('spec/spec_helper.rb') { 'spec/unit' }
watch('spec/unit/spec_helper.rb') { 'spec/unit' }
watch(%r{^spec/unit/data/.+\.json$}) { 'spec/unit' }
# Integration tests
watch(%r{^spec/integration/.+_spec\.rb$})
watch(%r{^spec/integration/utils/.+\.rb$}) { 'spec/integration' }
watch(/^lib\/.+\.rb$/) { 'spec/integration' }
watch('spec/spec_helper.rb') { 'spec/integration' }
watch('spec/integration/spec_helper.rb') { 'spec/integration' }
watch(%r{^spec/integration/data/.+\.json$}) { 'spec/integration' }
end
guard :cucumber, cmd: 'bundle exec cucumber', all_on_start: false do
watch(/^features\/.+\.feature$/)
watch(%r{^features/support/.+$}) { 'features' }
watch(%r{^features/step_definitions/(.+)_steps\.rb$}) { |_m| Dir[File.join('**/#{m[1]}.feature')][0] || 'features' }
end
end
================================================
FILE: LICENSE.txt
================================================
Copyright (c) 2013 ThoughtWorks Brasil & Abril Midia
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: Procfile
================================================
sample_apis: sh -c 'bundle exec rackup -s puma -o localhost -p $PORT sample_apis/config.ru'
================================================
FILE: README.md
================================================
[](http://badge.fury.io/rb/pacto)
[](https://travis-ci.org/thoughtworks/pacto)
[](https://codeclimate.com/github/thoughtworks/pacto)
[](https://gemnasium.com/thoughtworks/pacto)
[](https://coveralls.io/r/thoughtworks/pacto)
**Pacto is currently INACTIVE. Unfortunately none of the maintainers have had enough time to maintain it. While we feel that Pacto had good ideas, we also feel that a lot has changed since Pacto was first conceived (including the [OpenAPIs initiative](https://openapis.org/)) and that too much work would be required to maintain & update Pacto. Instead, we encourage others to use other projects that have focused on Consumer-Driven Contracts, like [Pact](https://github.com/realestate-com-au/pact), or to write their own Pacto-inspired project.**
# [INACTIVE] Pacto
Pacto is a judge that arbitrates contract disputes between a **service provider** and one or more **consumers**. In other words, it is a framework for [Integration Contract Testing](http://martinfowler.com/bliki/IntegrationContractTest.html), and helps guide service evolution patterns like [Consumer-Driven Contracts](http://thoughtworks.github.io/pacto/patterns/cdc/) or [Documentation-Driven Contracts](http://thoughtworks.github.io/pacto/patterns/documentation_driven/).
Pacto considers two major terms in order decide if there has been a breach of contract: the **request clause** and the **response clause**.
The **request clause** defines what information must be sent by the **consumer** to the **provider** in order to compel them to render a service. The request clause often describes the required HTTP request headers like `Content-Type`, the required parameters, and the required request body (defined in [json-schema](http://json-schema.org/)) when applicable. Providers are not held liable for failing to deliver services for invalid requests.
The **response clause** defines what information must be returned by the **provider** to the **consumer** in order to successfully complete the transaction. This commonly includes HTTP response headers like `Location` as well as the required response body (also defined in [json-schema](http://json-schema.org/)).
## Test Doubles
The consumer may also enter into an agreement with **test doubles** like [WebMock](http://robots.thoughtbot.com/how-to-stub-external-services-in-tests), [vcr](https://github.com/vcr/vcr) or [mountebank](http://www.mbtest.org/). The services delivered by the **test doubles** for the purposes of development and testing will be held to the same conditions as the service the final services rendered by the parent **provider**. This prevents misrepresentation of sample services as realistic, leading to damages during late integration.
Pacto can provide a [**test double**](#stubbing) if you cannot afford your own.
## Due Diligence
Pacto usually makes it clear if the **consumer** or **provider** is at fault, but if a contract is too vague Pacto cannot assign blame, and if it is too specific the matter may become litigious.
Pacto can provide a [**contract writer**](#generating) that tries to strike a balance, but you may still need to adjust the terms.
## Implied Terms
- Pacto only arbitrates contracts for JSON services.
- Pacto requires Ruby 1.9.3 or newer, though you can use [Polyglot Testing](http://thoughtworks.github.io/pacto/patterns/polyglot/) techniques to support older Rubies and non-Ruby projects.
## Roadmap
- The **test double** provided by Pacto is only semi-competent. It handles simple cases, but we expect to find a more capable replacement in the near future.
- Pacto will provide additional Contract Writers for converting from apiblueprint, WADL, or other documentation formats in the future. It's part of our goal to support [Documentation-Driven Contracts](http://thoughtworks.github.io/pacto/patterns/documentation_driven/)
- Pacto reserves the right to consider other clauses in the future, like security and compliance to industry specifications.
## Usage
**See also: http://thoughtworks.github.io/pacto/usage/**
Pacto can perform three activities: generating, validating, or stubbing services. You can do each of these activities against either live or stubbed services.
You can also use [Pacto Server](#pacto-server-non-ruby-usage) if you are working with non-Ruby projects.
### Configuration
In order to start with Pacto, you just need to require it and optionally customize the default [Configuration](docs/configuration.md). For example:
```ruby
require 'pacto'
Pacto.configure do |config|
config.contracts_path = 'contracts'
end
```
### Generating
The easiest way to get started with Pacto is to run a suite of live tests and tell Pacto to generate the contracts:
```ruby
Pacto.generate!
# run your tests
```
If you're using the same configuration as above, this will produce Contracts in the contracts/ folder.
We know we cannot generate perfect Contracts, especially if we only have one sample request. So we do our best to give you a good starting point, but you will likely want to customize the contract so the validation is more strict in some places and less strict in others.
### Contract Lists
In order to stub or validate a group of contracts you need to create a ContractList.
A ContractList represent a collection of endpoints attached to the same host.
```ruby
require 'pacto'
default_contracts = Pacto.load_contracts('contracts/services', 'http://example.com')
authentication_contracts = Pacto.load_contracts('contracts/auth', 'http://example.com')
legacy_contracts = Pacto.load_contracts('contracts/legacy', 'http://example.com')
```
### Validating
Once you have a ContractList, you can validate all the contracts against the live host.
```ruby
contracts = Pacto.load_contracts('contracts/services', 'http://example.com')
contracts.simulate_consumers
```
This method will hit the real endpoint, with a request created based on the request part of the contract.
The response will be compared against the response part of the contract and any structural difference will
generate a validation error.
Running this in your build pipeline will ensure that your contracts actually match the real world, and that
you can run your acceptance tests against the contract stubs without worries.
### Stubbing
To generate stubs based on a ContractList you can run:
```ruby
contracts = Pacto.load_contracts('contracts/services', 'http://example.com')
contracts.stub_providers
```
This method uses webmock to ensure that whenever you make a request against one of contracts you actually get a stubbed response,
based on the default values specified on the contract, instead of hitting the real provider.
You can override any default value on the contracts by providing a hash of optional values to the stub_providers method. This
will override the keys for every contract in the list
```ruby
contracts = Pacto.load_contracts('contracts/services', 'http://example.com')
contracts.stub_providers(request_id: 14, name: "Marcos")
```
## Pacto Server (non-Ruby usage)
**See also: http://thoughtworks.github.io/pacto/patterns/polyglot/**
We've bundled a small server that embeds pacto so you can use it for non-Ruby projects. If you want to take advantage of the full features, you'll still need to use Ruby (usually rspec) to drive your API testing. You can run just the server in order to stub or to write validation issues to a log, but you won't have access to the full API fail your tests if there were validation problems.
### Command-line
The command-line version of the server will show you all the options. These same options are used when you launch the server from within rspec. In order to see the options:
`bundle exec pacto-server --help`
Some examples:
```sh
$ bundle exec pacto-server -sv --generate
# Runs a server that will generate Contracts for each request received
$ bundle exec pacto-server -sv --stub --validate
# Runs the server that provides stubs and checks them against Contracts
$ bundle exec pacto-server -sv --live --validate --host
# Runs the server that acts as a proxy to http://example.com, validating each request/response against a Contract
```
### RSpec test helper
You can also launch a server from within an rspec test. The server does start up an external port and runs asynchronously so it doens't block your main test thread from kicking off your consumer code. This gives you an externally accessable server that non-Ruby clients can hit, but still gives you the full Pacto API in order to validate and spy on HTTP interactions.
Example usage of the rspec test helper:
```ruby
require 'rspec'
require 'pacto/rspec'
require 'pacto/test_helper'
describe 'my consumer' do
include Pacto::TestHelper
it 'calls a service' do
with_pacto(
:port => 5000,
:directory => '../spec/integration/data',
:stub => true) do |pacto_endpoint|
# call your code
system "curl #{pacto_endpoint}/echo"
# check results
expect(Pacto).to have_validated(:get, 'https://localhost/echo')
end
end
end
```
## Rake Tasks
Pacto includes a few Rake tasks to help with common tasks. If you want to use these tasks, just add this top your Rakefile:
```ruby
require 'pacto/rake_task'
```
This should add several new tasks to you project:
```sh
rake pacto:generate[input_dir,output_dir,host] # Generates contracts from partial contracts
rake pacto:meta_validate[dir] # Validates a directory of contract definitions
rake pacto:validate[host,dir] # Validates all contracts in a given directory against a given host
```
The pacto:generate task will take partially defined Contracts and generate the missing pieces. See [Generate](docs/generation.md) for more details.
The pacto:meta_validate task makes sure that your Contracts are valid. It only checks the Contracts, not the services that implement them.
The pacto:validate task sends a request to an actual provider and ensures their response complies with the Contract.
## Contracts
Pacto works by associating a service with a Contract. The Contract is a JSON description of the service that uses json-schema to describe the response body. You don't need to write your contracts by hand. In fact, we recommend generating a Contract from your documentation or a service.
A contract is composed of a request that has:
- Method: the method of the HTTP request (e.g. GET, POST, PUT, DELETE);
- Path: the relative path (without host) of the provider's endpoint;
- Headers: headers sent in the HTTP request;
- Params: any data or parameters of the HTTP request (e.g. query string for GET, body for POST).
And a response has that has:
- Status: the HTTP response status code (e.g. 200, 404, 500);
- Headers: the HTTP response headers;
- Body: a JSON Schema defining the expected structure of the HTTP response body.
Below is an example contract for a GET request
to the /hello_world endpoint of a provider:
```json
{
"request": {
"method": "GET",
"path": "/hello_world",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"description": "A simple response",
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
```
## Constraints
- Pacto only works with JSON services
- Pacto requires Ruby 1.9.3 or newer (though you can older Rubies or non-Ruby projects with a [Pacto Server](#pacto-server-non-ruby-usage))
- Pacto cannot currently specify multiple acceptable status codes (e.g. 200 or 201)
## Contributing
Read the [CONTRIBUTING.md](CONTRIBUTING.md) file.
================================================
FILE: Rakefile
================================================
require 'rspec/core/rake_task'
require 'cucumber'
require 'cucumber/rake/task'
require 'coveralls/rake/task'
require 'rubocop/rake_task'
require 'rake/notes/rake_task'
require 'rake/packagetask'
Dir.glob('tasks/*.rake').each { |r| import r }
Coveralls::RakeTask.new
require 'pacto/rake_task' # FIXME: This require turns on WebMock
WebMock.allow_net_connect!
RuboCop::RakeTask.new(:rubocop) do |task|
task.fail_on_error = true
end
Cucumber::Rake::Task.new(:journeys) do |t|
t.cucumber_opts = 'features --format progress'
end
RSpec::Core::RakeTask.new(:unit) do |t|
t.pattern = 'spec/unit/**/*_spec.rb'
end
RSpec::Core::RakeTask.new(:integration) do |t|
t.pattern = 'spec/integration/**/*_spec.rb'
end
task default: [:unit, :integration, :journeys, :samples, :rubocop, 'coveralls:push']
%w(unit integration journeys samples).each do |taskname|
task taskname => 'smoke_test_services'
end
desc 'Run the samples'
task :samples do
FileUtils.rm_rf('samples/tmp')
sh 'bundle exec polytrix exec --solo=samples --solo-glob="*.{rb,sh}"'
sh 'bundle exec polytrix generate code2doc --solo=samples --solo-glob="*.{rb,sh}"'
end
desc 'Build gems into the pkg directory'
task :build do
FileUtils.rm_rf('pkg')
Dir['*.gemspec'].each do |gemspec|
system "gem build #{gemspec}"
end
FileUtils.mkdir_p('pkg')
FileUtils.mv(Dir['*.gem'], 'pkg')
end
Rake::PackageTask.new('pacto_docs', Pacto::VERSION) do |p|
p.need_zip = true
p.need_tar = true
p.package_files.include('docs/**/*')
end
def changelog
changelog = File.read('CHANGELOG').split("\n\n\n", 2).first
confirm 'Does the CHANGELOG look correct? ', changelog
end
def confirm(question, data)
puts 'Please confirm...'
puts data
print question
abort 'Aborted' unless $stdin.gets.strip == 'y'
puts 'Confirmed'
data
end
desc 'Make sure the sample services are running'
task :smoke_test_services do
require 'faraday'
begin
retryable(tries: 5, sleep: 1) do
Faraday.get('http://localhost:5000/api/ping')
end
rescue
abort 'Could not connect to the demo services, please start them with `foreman start`'
end
end
# Retries a given block a specified number of times in the
# event the specified exception is raised. If the retries
# run out, the final exception is raised.
#
# This code is slightly adapted from https://github.com/mitchellh/vagrant/blob/master/lib/vagrant/util/retryable.rb,
# which is in turn adapted slightly from the following blog post:
# http://blog.codefront.net/2008/01/14/retrying-code-blocks-in-ruby-on-exceptions-whatever/
def retryable(opts = nil)
opts = { tries: 1, on: Exception }.merge(opts || {})
begin
return yield
rescue *opts[:on] => e
if (opts[:tries] -= 1) > 0
$stderr.puts("Retryable exception raised: #{e.inspect}")
sleep opts[:sleep].to_f if opts[:sleep]
retry
end
raise
end
end
================================================
FILE: TODO.md
================================================
# TODO
# v0.4
## Thor
- Kill rake tasks, replace w/ pacto binary
- Split Pacto server to separate repo??
## Swagger converter
- Legacy contracts -> Swagger
## Swagger concepts not yet supported by Pacto
- Support schemes (multiple)
- Support multiple report types
- Validate parameters
- Support Swagger formats/serializations
- Support Swagger examples, or extension for examples
## Documentation
- Polytrix samples -> docs
# v0.5
## Swagger
- Support multiple media types (not just JSON)
- Extension: templates for more advanced stubbing
- Patterns: detect creation, auto-delete
- Configure multiple producers: pacto server w/ multiple ports
# v0.6
## Nice to have
# Someday
- Pretty output for hash difference (using something like [hashdiff](https://github.com/liufengyun/hashdiff)).
- A default header in the response marking the response as "mocked"
## Assumptions
- JSON Schema references are stored in the 'definitions' attribute, in the schema's root element.
================================================
FILE: appveyor.yml
================================================
version: 0.0.{build}
init:
- choco install openssl.light
- gem install bundler --quiet --no-ri --no-rdoc
- gem install foreman --quiet --no-ri --no-rdoc
install:
- bundle install
build: off
test_script:
- START /B foreman start
- bundle exec rake
================================================
FILE: bin/pacto
================================================
#!/usr/bin/env ruby
require 'pacto/cli'
Pacto::CLI::Main.start
================================================
FILE: bin/pacto-server
================================================
#!/usr/bin/env ruby
require 'pacto/server/cli'
Pacto::Server::CLI.start
================================================
FILE: changelog.md
================================================
## 0.3.2
*New Features:*
- #105: Add pacto-server for non-ruby tests. Use the pacto-server gem.
*Breaking Changes:*
- #107: Change default URI pattern to be less greedy.
/magazine will now not match also /magazine/last_edition.
query parameters after ? are still a match (ie /magazine?lastest=true)
*Bug Fixes:*
- #106: Remove dead, undocumented tag feature
## 0.3.1
*Enhancements:*
- #103: Display file URI instead of meaningless schema identifier in messages
*Bug Fixes:*
- #102: - Fix rake pacto:generate task
## 0.3.0
First stable release
================================================
FILE: docs/configuration.md
================================================
Just require pacto to add it to your project.
```rb
require 'pacto'
```
Pacto will disable live connections, so you will get an error if
your code unexpectedly calls an service that was not stubbed. If you
want to re-enable connections, run `WebMock.allow_net_connect!`
```rb
WebMock.allow_net_connect!
```
Pacto can be configured via a block:
```rb
Pacto.configure do |c|
```
Path for loading/storing contracts.
```rb
c.contracts_path = 'contracts'
```
If the request matching should be strict (especially regarding HTTP Headers).
```rb
c.strict_matchers = true
```
You can set the Ruby Logger used by Pacto.
```rb
c.logger = Pacto::Logger::SimpleLogger.instance
```
(Deprecated) You can specify a callback for post-processing responses. Note that only one hook
can be active, and specifying your own will disable ERB post-processing.
```rb
c.register_hook do |_contracts, request, _response|
puts "Received #{request}"
end
```
Options to pass to the [json-schema-generator](https://github.com/maxlinc/json-schema-generator) while generating contracts.
```rb
c.generator_options = { schema_version: 'draft3' }
end
```
You can also do inline configuration. This example tells the json-schema-generator to store default values in the schema.
```rb
Pacto.configuration.generator_options = { defaults: true }
```
If you're using Pacto's rspec matchers you might want to configure a reset between each scenario
```rb
require 'pacto/rspec'
RSpec.configure do |c|
c.after(:each) { Pacto.clear! }
end
```
================================================
FILE: docs/consumer.md
================================================
```rb
require 'pacto'
Pacto.load_contracts 'contracts', 'http://localhost:5000'
WebMock.allow_net_connect!
interactions = Pacto.simulate_consumer :my_client do
request 'Ping'
request 'Echo', body: ->(body) { body.reverse },
headers: (proc do |headers|
headers['Content-Type'] = 'text/json'
headers['Accept'] = 'none'
headers
end)
end
puts interactions
```
================================================
FILE: docs/cops.md
================================================
```rb
require 'pacto'
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
Pacto.validate!
```
You can create a custom cop that investigates the request/response and sees if it complies with a
contract. The cop should return a list of citations if it finds any problems.
```rb
class MyCustomCop
def investigate(_request, _response, contract)
citations = []
citations << 'Contract must have a request schema' if contract.request.schema.empty?
citations << 'Contract must have a response schema' if contract.response.schema.empty?
citations
end
end
Pacto::Cops.active_cops << MyCustomCop.new
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
contracts.stub_providers
puts contracts.simulate_consumers
```
Or you can completely replace the default set of validators
```rb
Pacto::Cops.registered_cops.clear
Pacto::Cops.register_cop Pacto::Cops::ResponseBodyCop
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
puts contracts.simulate_consumers
```
================================================
FILE: docs/forensics.md
================================================
Pacto has a few RSpec matchers to help you ensure a **consumer** and **producer** are
interacting properly. First, let's setup the rspec suite.
```rb
require 'rspec/autorun' # Not generally needed
require 'pacto/rspec'
WebMock.allow_net_connect!
Pacto.validate!
Pacto.load_contracts('contracts', 'http://localhost:5000').stub_providers
```
It's usually a good idea to reset Pacto between each scenario. `Pacto.reset` just clears the
data and metrics about which services were called. `Pacto.clear!` also resets all configuration
and plugins.
```rb
RSpec.configure do |c|
c.after(:each) { Pacto.reset }
end
```
Pacto provides some RSpec matchers related to contract testing, like making sure
Pacto didn't received any unrecognized requests (`have_unmatched_requests`) and that
the HTTP requests matched up with the terms of the contract (`have_failed_investigations`).
```rb
describe Faraday do
let(:connection) { described_class.new(url: 'http://localhost:5000') }
it 'passes contract tests' do
connection.get '/api/ping'
expect(Pacto).to_not have_failed_investigations
expect(Pacto).to_not have_unmatched_requests
end
end
```
There are also some matchers for collaboration testing, so you can make sure each scenario is
calling the expected services and sending the right type of data.
```rb
describe Faraday do
let(:connection) { described_class.new(url: 'http://localhost:5000') }
before(:each) do
connection.get '/api/ping'
connection.post do |req|
req.url '/api/echo'
req.headers['Content-Type'] = 'application/json'
req.body = '{"foo": "bar"}'
end
end
it 'calls the ping service' do
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping').against_contract('Ping')
end
it 'sends data to the echo service' do
expect(Pacto).to have_investigated('Ping').with_response(body: hash_including('ping' => 'pong - from the example!'))
expect(Pacto).to have_investigated('Echo').with_request(body: hash_including('foo' => 'bar'))
echoed_body = { 'foo' => 'bar' }
expect(Pacto).to have_investigated('Echo').with_request(body: echoed_body).with_response(body: echoed_body)
end
end
```
================================================
FILE: docs/generation.md
================================================
Some generation related [configuration](configuration.rb).
```rb
require 'pacto'
WebMock.allow_net_connect!
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
WebMock.allow_net_connect!
```
Once we call `Pacto.generate!`, Pacto will record contracts for all requests it detects.
```rb
Pacto.generate!
```
Now, if we run any code that makes an HTTP call (using an
[HTTP library supported by WebMock](https://github.com/bblimke/webmock#supported-http-libraries))
then Pacto will generate a Contract based on the HTTP request/response.
This code snippet will generate a Contract and save it to `contracts/samples/contracts/localhost/api/ping.json`.
```rb
require 'faraday'
conn = Faraday.new(url: 'http://localhost:5000')
response = conn.get '/api/ping'
```
We're getting back real data from GitHub, so this should be the actual file encoding.
```rb
puts response.body
```
The generated contract will contain expectations based on the request/response we observed,
including a best-guess at an appropriate json-schema. Our heuristics certainly aren't foolproof,
so you might want to customize schema!
Here's another sample that sends a post request.
```rb
conn.post do |req|
req.url '/api/echo'
req.headers['Content-Type'] = 'application/json'
req.body = '{"red fish": "blue fish"}'
end
```
You can provide hints to Pacto to help it generate contracts. For example, Pacto doesn't have
a good way to know a good name and correct URI template for the service. That means that Pacto
will not know if two similar requests are for the same service or two different services, and
will be forced to give names based on the URI that are not good display names.
The hint below tells Pacto that requests to http://localhost:5000/album/1/cover and http://localhost:5000/album/2/cover
are both going to the same service, which is known as "Get Album Cover". This hint will cause Pacto to
generate a Contract for "Get Album Cover" and save it to `contracts/get_album_cover.json`, rather than two
contracts that are stored at `contracts/localhost/album/1/cover.json` and `contracts/localhost/album/2/cover.json`.
```rb
Pacto::Generator.configure do |c|
c.hint 'Get Album Cover', http_method: :get, host: 'http://localhost:5000', path: '/api/album/{id}/cover'
end
conn.get '/api/album/1/cover'
conn.get '/api/album/2/cover'
```
================================================
FILE: docs/rake_tasks.md
================================================
# Rake tasks
## This is a test!
[That](www.google.com) markdown works
```sh
bundle exec rake pacto:meta_validate['contracts']
bundle exec rake pacto:validate['http://localhost:5000','contracts']
```
================================================
FILE: docs/rspec.md
================================================
================================================
FILE: docs/samples.md
================================================
# Overview
Welcome to the Pacto usage samples!
This document gives a quick overview of the main features.
You can browse the Table of Contents (upper right corner) to view additional samples.
In addition to this document, here are some highlighted samples:
You can also find other samples using the Table of Content (upper right corner), including sample contracts.
# Getting started
Once you've installed the Pacto gem, you just require it. If you want, you can also require the Pacto rspec expectations.
```rb
require 'pacto'
require 'pacto/rspec'
```
Pacto will disable live connections, so you will get an error if
your code unexpectedly calls an service that was not stubbed. If you
want to re-enable connections, run `WebMock.allow_net_connect!`
```rb
WebMock.allow_net_connect!
```
Pacto can be configured via a block. The `contracts_path` option tells Pacto where it should load or save contracts. See the [Configuration](configuration.html) for all the available options.
```rb
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
```
# Generating a Contract
Calling `Pacto.generate!` enables contract generation.
Pacto.generate!
Now, if we run any code that makes an HTTP call (using an
[HTTP library supported by WebMock](https://github.com/bblimke/webmock#supported-http-libraries))
then Pacto will generate a Contract based on the HTTP request/response.
We're using the sample APIs in the sample_apis directory.
```rb
require 'faraday'
conn = Faraday.new(url: 'http://localhost:5000')
response = conn.get '/api/ping'
```
This is the real request, so you should see {"ping":"pong"}
```rb
puts response.body
```
# Testing providers by simulating consumers
The generated contract will contain expectations based on the request/response we observed,
including a best-guess at an appropriate json-schema. Our heuristics certainly aren't foolproof,
so you might want to modify the output!
We can load the contract and validate it, by sending a new request and making sure
the response matches the JSON schema. Obviously it will pass since we just recorded it,
but if the service has made a change, or if you alter the contract with new expectations,
then you will see a contract investigation message.
```rb
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
contracts.simulate_consumers
```
# Stubbing providers for consumer testing
We can also use Pacto to stub the service based on the contract.
```rb
contracts.stub_providers
```
The stubbed data won't be very realistic, the default behavior is to return the simplest data
that complies with the schema. That basically means that you'll have "bar" for every string.
```rb
response = conn.get '/api/ping'
```
You're now getting stubbed data. You should see {"ping":"bar"} unless you recorded with
the `defaults` option enabled, in which case you will still seee {"ping":"pong"}.
```rb
puts response.body
```
# Collaboration tests with RSpec
Pacto comes with rspec matchers
```rb
require 'pacto/rspec'
```
It's probably a good idea to reset Pacto between each rspec scenario
```rb
RSpec.configure do |c|
c.after(:each) { Pacto.clear! }
end
```
Load your contracts, and stub them if you'd like.
```rb
Pacto.load_contracts('contracts', 'http://localhost:5000').stub_providers
```
You can turn on investigation mode so Pacto will detect and validate HTTP requests.
```rb
Pacto.validate!
describe 'my_code' do
it 'calls a service' do
conn = Faraday.new(url: 'http://localhost:5000')
response = conn.get '/api/ping'
```
The have_validated matcher makes sure that Pacto received and successfully validated a request
```rb
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping')
end
end
```
================================================
FILE: docs/server.md
================================================
```rb
require 'pacto/rspec'
require 'pacto/test_helper'
describe 'ping service' do
include Pacto::TestHelper
it 'pongs' do
with_pacto(
port: 6000,
backend_host: 'http://localhost:5000',
live: true,
stub: false,
generate: false,
directory: 'contracts'
) do |pacto_endpoint|
```
call your code
```rb
system "curl #{pacto_endpoint}/api/ping"
end
```
check citations
```rb
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping')
end
end
```
================================================
FILE: docs/server_cli.md
================================================
# Standalone server
You can run Pacto as a server in order to test non-Ruby projects. In order to get the full set
of options, run:
```sh
bundle exec pacto-server -h
```
You probably want to run with the -sv option, which will display verbose output to stdout. You can
run server that proxies to a live endpoint:
```sh
bundle exec pacto-server -sv --port 9000 --live http://example.com &
bundle exec pacto-server -sv --port 9001 --stub &
pkill -f pacto-server
```
================================================
FILE: docs/stenographer.md
================================================
```rb
require 'pacto'
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
contracts.stub_providers
Pacto.simulate_consumer do
request 'Echo', values: nil, response: { status: 200 } # 0 contract violations
request 'Ping', values: nil, response: { status: 200 } # 0 contract violations
request 'Unknown (http://localhost:8000/404)', values: nil, response: { status: 500 } # 0 contract violations
end
Pacto.simulate_consumer :my_consumer do
playback 'pacto_stenographer.log'
end
```
================================================
FILE: features/configuration/strict_matchers.feature
================================================
Feature: Strict Matching
By default, Pacto matches requests to contracts (and stubs) using exact request paths, parameters, and headers. This strict behavior is useful for Consumer-Driven Contracts.
You can use less strict matching so the same contract can match multiple similar requests. This is useful for matching contracts with resource identifiers in the path. Any placeholder in the path (like :id in /beers/:id) is considered a resource identifier and will match any alphanumeric combination.
You can change the default behavior to the behavior that allows placeholders and ignores headers or request parameters by setting the strict_matchers configuration option:
```ruby
Pacto.configure do |config|
config.strict_matchers = false
end
```
Background:
Given a file named "contracts/hello_contract.json" with:
"""json
{
"request": {
"http_method": "GET",
"path": "/hello/{id}",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"schema": {
"type": "object",
"required": true,
"properties": {
"message": { "type": "string", "required": true, "default": "Hello, world!" }
}
}
}
}
"""
Given a file named "requests.rb" with:
"""ruby
require 'pacto'
strict = ARGV[0] == "true"
puts "Pacto.configuration.strict_matchers = #{strict}"
puts
Pacto.configure do |config|
config.strict_matchers = strict
end
Pacto.load_contracts('contracts', 'http://dummyprovider.com').stub_providers
def response url, headers
begin
response = Faraday.get(url) do |req|
req.headers = headers[:headers]
end
response.body
rescue WebMock::NetConnectNotAllowedError => e
e.class
end
end
print 'Exact: '
puts response URI.encode('http://dummyprovider.com/hello/{id}'), headers: {'Accept' => 'application/json' }
print 'Wrong headers: '
puts response 'http://dummyprovider.com/hello/123', headers: {'Content-Type' => 'application/json' }
print 'ID placeholder: '
puts response 'http://dummyprovider.com/hello/123', headers: {'Accept' => 'application/json' }
"""
Scenario: Default (strict) behavior
When I run `bundle exec ruby requests.rb true`
Then the stdout should contain:
"""
Pacto.configuration.strict_matchers = true
Exact: {"message":"Hello, world!"}
Wrong headers: WebMock::NetConnectNotAllowedError
ID placeholder: {"message":"Hello, world!"}
"""
Scenario: Non-strict matching
When I run `bundle exec ruby requests.rb false`
Then the stdout should contain:
"""
Pacto.configuration.strict_matchers = false
Exact: {"message":"Hello, world!"}
Wrong headers: {"message":"Hello, world!"}
ID placeholder: {"message":"Hello, world!"}
"""
================================================
FILE: features/evolve/README.md
================================================
## Consumer-Driven Contract Recommendations
If you are using Pacto for Consumer-Driven Contracts, we recommend avoiding the advanced features so you'll test with the strictest Contract possible. We recommend:
- Do not use templating, let Pacto use the json-generator
- Use strict request matching
- Use multiple contracts for the same service to capture attributes that are required in some situations but not others
The host address is intentionally left out of the request specification so that we can validate a contract against any provider.
It also reinforces the fact that a contract defines the expectation of a consumer, and not the implementation of any specific provider.
================================================
FILE: features/evolve/existing_services.feature
================================================
Feature: Existing services journey
Scenario: Generating the contracts
Given I have a Rakefile
Given an existing set of services
When I execute:
"""ruby
require 'pacto'
Pacto.configure do | c |
c.contracts_path = 'contracts'
end
Pacto.generate!
Faraday.get 'http://www.example.com/service1'
Faraday.get 'http://www.example.com/service2'
"""
Then the following files should exist:
| contracts/www.example.com/service1.json |
| contracts/www.example.com/service2.json |
@no-clobber
Scenario: Ensuring all contracts are valid
When I successfully run `bundle exec rake pacto:meta_validate['contracts']`
Then the stdout should contain exactly:
"""
validated contracts/www.example.com/service1.json
validated contracts/www.example.com/service2.json
All contracts successfully meta-validated
"""
# TODO: find where Webmock is being called with and an empty :with
# and update it, to not use with so we can upgrade Webmock past 1.20.2
@no-clobber
Scenario: Stubbing with the contracts
Given a file named "test.rb" with:
"""ruby
require 'pacto'
Pacto.configure do | c |
c.contracts_path = 'contracts'
end
contracts = Pacto.load_contracts('contracts/www.example.com/', 'http://www.example.com')
contracts.stub_providers
puts Faraday.get('http://www.example.com/service1').body
puts Faraday.get('http://www.example.com/service2').body
"""
When I successfully run `bundle exec ruby test.rb`
Then the stdout should contain exactly:
"""
{"thoughtworks":"pacto"}
{"service2":["thoughtworks","pacto"]}
"""
@no-clobber
Scenario: Expecting a change
When I make replacements in "contracts/www.example.com/service1.json":
| pattern | replacement |
| string | integer |
When I execute:
"""ruby
require 'pacto'
Pacto.stop_generating!
Pacto.configure do | c |
c.contracts_path = 'contracts'
end
Pacto.load_contracts('contracts', 'http://www.example.com').stub_providers
Pacto.validate!
Faraday.get 'http://www.example.com/service1'
Faraday.get 'http://www.example.com/service2'
"""
Then the stdout should contain exactly:
"""
"""
================================================
FILE: features/generate/README.md
================================================
We know - json-schema can get pretty verbose! It's a powerful tool, but writing the entire Contract by hand for a complex service is a painstaking task. We've created a simple generator to speed this process up. You can invoke it programmatically, or with the provided rake task.
The basic generate we've bundled with Pacto completes partially defined Contracts - Contracts that have a request defined but no response. We haven't bundled any other generates, but you could use the API to generate from other sources, like existing [VCR](https://github.com/vcr/vcr) cassettes, [apiblueprint](http://apiblueprint.org/), or [WADL](https://wadl.java.net/). If you want some help or ideas, try the [pacto mailing-list](https://groups.google.com/forum/#!forum/pacto-gem).
Note: Request headers are only recorded if they are in the response's [Vary header](http://www.subbu.org/blog/2007/12/vary-header-for-restful-applications), so make sure your services return a proper Vary for best results!
================================================
FILE: features/generate/generation.feature
================================================
@needs_server
Feature: Contract Generation
We know - json-schema can get pretty verbose! It's a powerful tool, but writing the entire Contract by hand for a complex service is a painstaking task. We've created a simple generator to speed this process up. You can invoke it programmatically, or with the provided rake task.
You just need to create a partial Contract that only describes the request. The generator will then execute the request, and use the response to generate a full Contract.
Remember, we only record request headers if they are in the response's [Vary header](http://www.subbu.org/blog/2007/12/vary-header-for-restful-applications), so make sure your services return a proper Vary for best results!
Background:
Given a file named "requests/my_contract.json" with:
"""
{
"request": {
"http_method": "GET",
"path": "/api/ping",
"headers": {
"Accept": "application/json"
}
},
"response": {
"status": 200,
"schema": {
"required": true
}
}
}
"""
Scenario: Generating a contract using the rake task
Given a directory named "contracts"
When I successfully run `bundle exec rake pacto:generate['tmp/aruba/requests','tmp/aruba/contracts','http://localhost:5000']`
Then the stdout should contain "Successfully generated all contracts"
Scenario: Generating a contract programmatically
Given a file named "generate.rb" with:
"""ruby
require 'pacto'
Pacto.configuration.generator_options[:no_examples] = true
WebMock.allow_net_connect!
generator = Pacto::Generator.contract_generator
contract = generator.generate_from_partial_contract('requests/my_contract.json', 'http://localhost:5000')
puts contract
"""
When I successfully run `bundle exec ruby generate.rb`
Then the stdout should match this contract:
"""json
{
"name": "/api/ping",
"request": {
"headers": {
},
"http_method": "get",
"path": "/api/ping"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"description": "Generated from requests/my_contract.json with shasum 210fa3b144ef2db8d1c160c4d9e8d8bf738ed851",
"type": "object",
"required": true,
"properties": {
"ping": {
"type": "string",
"required": true
}
}
}
}
}
"""
================================================
FILE: features/steps/pacto_steps.rb
================================================
# -*- encoding : utf-8 -*-
Given(/^Pacto is configured with:$/) do |string|
steps %(
Given a file named "pacto_config.rb" with:
"""ruby
#{string}
"""
)
end
Given(/^I have a Rakefile$/) do
steps %(
Given a file named "Rakefile" with:
"""ruby
require 'pacto/rake_task'
"""
)
end
When(/^I request "(.*?)"$/) do |url|
steps %{
Given a file named "request.rb" with:
"""ruby
require 'pacto'
require_relative 'pacto_config'
require 'faraday'
resp = Faraday.get('#{url}') do |req|
req.headers = { 'Accept' => 'application/json' }
end
puts resp.body
"""
When I run `bundle exec ruby request.rb`
}
end
Given(/^an existing set of services$/) do
WebMock.stub_request(:get, 'www.example.com/service1').to_return(body: { 'thoughtworks' => 'pacto' }.to_json)
WebMock.stub_request(:post, 'www.example.com/service1').with(body: 'thoughtworks').to_return(body: 'pacto')
WebMock.stub_request(:get, 'www.example.com/service2').to_return(body: { 'service2' => %w(thoughtworks pacto) }.to_json)
WebMock.stub_request(:post, 'www.example.com/service2').with(body: 'thoughtworks').to_return(body: 'pacto')
end
When(/^I execute:$/) do |script|
FileUtils.mkdir_p 'tmp/aruba'
Dir.chdir 'tmp/aruba' do
begin
script = <<-eof
require 'stringio'
begin $stdout = StringIO.new
#{ script }
$stdout.string
ensure
$stdout = STDOUT
end
eof
eval(script) # rubocop:disable Eval
# It's just for testing...
rescue SyntaxError => e
raise e
end
end
end
When(/^I make replacements in "([^"]*)":$/) do |file_name, replacements|
Dir.chdir 'tmp/aruba' do
content = File.read file_name
replacements.rows.each do | pattern, replacement |
content.gsub! Regexp.new(pattern), replacement
end
File.open(file_name, 'w') { |file| file.write content }
end
end
Then(/^the stdout should match this contract:$/) do |expected_contract|
actual_contract = all_stdout
expect(actual_contract).to be_json_eql(expected_contract).excluding('description')
end
================================================
FILE: features/stub/README.md
================================================
You can write your own stubs and use Pacto to [validate](https://www.relishapp.com/maxlinc/pacto/docs/validate) them, or you can just let Pacto create stubs for you.
================================================
FILE: features/stub/templates.feature
================================================
Feature: Templating
If you want to create more dynamic stubs, you can use Pacto templating. Currently the only supported templating mechanism is to use ERB in the "default" attributes of the json-schema.
Background:
Given Pacto is configured with:
"""ruby
Pacto.configure do |c|
c.register_hook Pacto::Hooks::ERBHook.new
end
Pacto.load_contracts('contracts', 'http://example.com').stub_providers
"""
Given a file named "contracts/template.json" with:
"""json
{
"request": {
"http_method": "GET",
"path": "/hello",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"schema": {
"type": "object",
"required": true,
"properties": {
"message": { "type": "string", "required": true,
"default": "<%= 'Hello, world!'.reverse %>"
}
}
}
}
}
"""
Scenario: ERB Template
When I request "http://example.com/hello"
Then the stdout should contain:
"""
{"message":"!dlrow ,olleH"}
"""
================================================
FILE: features/support/env.rb
================================================
# -*- encoding : utf-8 -*-
require_relative '../../spec/coveralls_helper'
require 'aruba'
require 'aruba/cucumber'
require 'json_spec/cucumber'
require 'aruba/jruby' if RUBY_PLATFORM == 'java'
require 'pacto/test_helper'
Pacto.configuration.hide_deprecations = true
Before do
# Given I successfully run `bundle install` can take a while.
@aruba_timeout_seconds = RUBY_PLATFORM == 'java' ? 60 : 10
end
class PactoWorld
include Pacto::TestHelper
end
World do
PactoWorld.new
end
Around do | _scenario, block |
WebMock.allow_net_connect!
world = self || PactoWorld.new
world.with_pacto(port: 8000, live: true, backend_host: 'http://localhost:5000') do
block.call
end
end
================================================
FILE: features/validate/README.md
================================================
You can use Pacto to do Integration Contract Testing - making sure your service and any test double that simulates the service are similar. If you generate your Contracts from documentation, you can be fairly confident that all three - live services, test doubles, and documentation - are in sync.
================================================
FILE: features/validate/meta_validation.feature
================================================
Feature: Meta-validation
Meta-validation ensures that a Contract file matches the Contract format. It does not validation actual responses, just the Contract itself.
You can easily do meta-validation with the Rake task pacto:meta_validate, or programmatically.
Background:
Given a file named "contracts/my_contract.json" with:
"""
{
"request": {
"http_method": "GET",
"path": "/hello_world",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"schema": {
"description": "A simple response",
"type": "object",
"required": ["message"],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
"""
Scenario: Meta-validation via a rake task
When I successfully run `bundle exec rake pacto:meta_validate['tmp/aruba/contracts/my_contract.json']`
Then the stdout should contain "All contracts successfully meta-validated"
# The tests from here down should probably be specs instead of relish
Scenario: Meta-validation of an invalid contract
Given a file named "contracts/my_contract.json" with:
"""
{"request": "yes"}
"""
When I run `bundle exec rake pacto:meta_validate['tmp/aruba/contracts/my_contract.json']`
Then the exit status should be 1
And the stdout should contain "did not match the following type"
Scenario: Meta-validation of a contract with empty request and response
Given a file named "contracts/my_contract.json" with:
"""
{"request": {}, "response": {}}
"""
When I run `bundle exec rake pacto:meta_validate['tmp/aruba/contracts/my_contract.json']`
Then the exit status should be 1
And the stdout should contain "did not contain a required property"
Scenario: Meta-validation of a contracts response body
Given a file named "contracts/my_contract.json" with:
"""
{
"request": {
"http_method": "GET",
"path": "/hello_world"
},
"response": {
"status": 200,
"schema": {
"required": "anystring"
}
}
}
"""
When I run `bundle exec rake pacto:meta_validate['tmp/aruba/contracts/my_contract.json']`
Then the exit status should be 1
And the stdout should contain "did not match the following type"
================================================
FILE: features/validate/validation.feature
================================================
Feature: Validation
Validation ensures that a external service conform to a Contract.
Scenario: Validation via a rake task
Given a file named "contracts/simple_contract.json" with:
"""
{
"request": {
"http_method": "GET",
"path": "/api/hello",
"headers": { "Accept": "application/json" },
"params": {}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"schema": {
"description": "A simple response",
"type": "object",
"properties": {
"message": { "type": "string" }
}
}
}
}
"""
When I successfully run `bundle exec rake pacto:validate['http://localhost:5000','tmp/aruba/contracts/simple_contract.json']`
Then the stdout should contain:
""""
Validating contracts against host http://localhost:5000
OK! simple_contract.json
1 valid contract
"""
================================================
FILE: lib/pacto/actor.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class Actor
end
end
================================================
FILE: lib/pacto/actors/from_examples.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Actors
class FirstExampleSelector
def self.select(examples, _values)
Hashie::Mash.new examples.values.first
end
end
class RandomExampleSelector
def self.select(examples, _values)
Hashie::Mash.new examples.values.sample
end
end
class NamedExampleSelector
def self.select(examples, values)
name = values[:example_name]
if name.nil?
RandomExampleSelector.select(examples, values)
else
Hashie::Mash.new examples[name]
end
end
end
class FromExamples < Actor
def initialize(fallback_actor = JSONGenerator.new, selector = Pacto::Actors::FirstExampleSelector)
@fallback_actor = fallback_actor
@selector = selector
end
def build_request(contract, values = {})
request_values = (values || {}).dup
if contract.examples?
example = @selector.select(contract.examples, values)
data = contract.request.to_hash
request_values.merge! example_uri_values(contract)
data['uri'] = contract.request.uri(request_values)
data['body'] = example.request.body
data['method'] = contract.request.http_method
Pacto::PactoRequest.new(data)
else
@fallback_actor.build_request contract, values
end
end
def build_response(contract, values = {})
if contract.examples?
example = @selector.select(contract.examples, values)
data = contract.response.to_hash
data['body'] = example.response.body
Pacto::PactoResponse.new(data)
else
@fallback_actor.build_response contract, values
end
end
def example_uri_values(contract)
uri_template = contract.request.pattern.uri_template
if contract.examples && contract.examples.values.first[:request][:uri]
example_uri = contract.examples.values.first[:request][:uri]
uri_template.extract example_uri
else
{}
end
end
end
end
end
================================================
FILE: lib/pacto/actors/json_generator.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Actors
class JSONGenerator < Actor
def build_request(contract, values = {})
data = contract.request.to_hash
data['uri'] = contract.request.uri(values)
data['body'] = JSON::Generator.generate(data['schema']) if data['schema']
data['method'] = contract.request.http_method
Pacto::PactoRequest.new(data)
end
def build_response(contract, _values = {})
data = contract.response.to_hash
data['body'] = JSON::Generator.generate(data['schema'])
Pacto::PactoResponse.new(data)
end
end
end
end
================================================
FILE: lib/pacto/body_parsing.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Handlers
autoload :JSONHandler, 'pacto/handlers/json_handler'
autoload :TextHandler, 'pacto/handlers/text_handler'
autoload :XMLHandler, 'pacto/handlers/xml_handler'
end
module BodyParsing
def raw_body
return nil if body.nil?
return body if body.respond_to? :to_str
body_handler.raw(body)
end
def parsed_body
return nil if body.nil?
body_handler.parse(body)
end
def content_type
headers['Content-Type']
end
def body_handler
case content_type
when /\bjson$/
Pacto::Handlers::JSONHandler
when /\btext$/
Pacto::Handlers::TextHandler
# No XML support - yet
# when /\bxml$/
# XMLHandler
else
# JSON is still the default
Pacto::Handlers::JSONHandler
end
end
end
end
================================================
FILE: lib/pacto/cli/helpers.rb
================================================
module Pacto
module CLI
module Helpers
def each_contract(*contracts)
[*contracts].each do |contract|
if File.file? contract
yield contract
else # Should we assume it's a dir, or also support glob patterns?
contracts = Dir[File.join(contract, '**/*{.json.erb,.json}')]
fail Pacto::UI.colorize("No contracts found in directory #{contract}", :yellow) if contracts.empty?
contracts.sort.each do |contract_file|
yield contract_file
end
end
end
end
end
end
end
================================================
FILE: lib/pacto/cli.rb
================================================
require 'thor'
require 'pacto'
require 'pacto/cli/helpers'
module Pacto
module CLI
class Main < Thor
include Pacto::CLI::Helpers
desc 'meta_validate [CONTRACTS...]', 'Validates a directory of contract definitions'
def meta_validate(*contracts)
invalid = []
each_contract(*contracts) do |contract_file|
begin
Pacto.validate_contract(contract_file)
say_status :validated, contract_file
rescue InvalidContract => exception
invalid << contract_file
shell.say_status :invalid, contract_file, :red
exception.errors.each do |error|
say set_color(" Error: #{error}", :red)
end
end
end
abort "The following contracts were invalid: #{invalid.join(',')}" unless invalid.empty?
say 'All contracts successfully meta-validated'
end
desc 'validate [CONTRACTS...]', 'Validates all contracts in a given directory against a given host'
method_option :host, type: :string, desc: 'Override host in contracts for validation'
def validate(*contracts)
host = options[:host]
WebMock.allow_net_connect!
banner = 'Validating contracts'
banner << " against host #{host}" unless host.nil?
say banner
invalid_contracts = []
tested_contracts = []
each_contract(*contracts) do |contract_file|
tested_contracts << contract_file
invalid_contracts << contract_file unless contract_is_valid?(contract_file, host)
end
validation_summary(tested_contracts, invalid_contracts)
end
private
def validation_summary(contracts, invalid_contracts)
if invalid_contracts.empty?
say set_color("#{contracts.size} valid contract#{contracts.size > 1 ? 's' : nil}", :green)
else
abort set_color("#{invalid_contracts.size} of #{contracts.size} failed. Check output for detailed error messages.", :red)
end
end
def contract_is_valid?(contract_file, host)
name = File.split(contract_file).last
contract = Pacto.load_contract(contract_file, host)
investigation = contract.simulate_request
if investigation.successful?
say_status 'OK!', name
true
else
say_status 'FAILED!', name, :red
say set_color(investigation.summary, :red)
say set_color(investigation.to_s, :red)
false
end
end
end
end
end
================================================
FILE: lib/pacto/consumer/faraday_driver.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class Consumer
class FaradayDriver
include Pacto::Logger
# Sends a Pacto::PactoRequest
def execute(req)
conn_options = { url: req.uri.site }
conn_options[:proxy] = Pacto.configuration.proxy if Pacto.configuration.proxy
conn = Faraday.new(conn_options) do |faraday|
faraday.response :logger if Pacto.configuration.logger.level == :debug
faraday.adapter Faraday.default_adapter
end
response = conn.send(req.method) do |faraday_request|
faraday_request.url(req.uri.path, req.uri.query_values)
faraday_request.headers = req.headers
faraday_request.body = req.raw_body
end
faraday_to_pacto_response response
end
private
# This belongs in an adapter
def faraday_to_pacto_response(faraday_response)
data = {
status: faraday_response.status,
headers: faraday_response.headers,
body: faraday_response.body
}
Pacto::PactoResponse.new(data)
end
end
end
end
================================================
FILE: lib/pacto/consumer.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
def self.consumers
@consumers ||= {}
end
def self.simulate_consumer(consumer_name = :consumer, &block)
consumers[consumer_name] ||= Consumer.new(consumer_name)
consumers[consumer_name].simulate(&block)
end
class Consumer
include Logger
include Resettable
def initialize(name = :consumer)
@name = name
end
def simulate(&block)
instance_eval(&block)
end
def playback(stenographer_script)
script = File.read(stenographer_script)
instance_eval script, stenographer_script
end
def self.reset!
Pacto.consumers.clear
end
def actor
@actor ||= Pacto::Actors::FromExamples.new
end
def actor=(actor)
fail ArgumentError, 'The actor must respond to :build_request' unless actor.respond_to? :build_request
@actor = actor
end
def request(contract, data = {})
contract = Pacto.contract_registry.find_by_name(contract) if contract.is_a? String
logger.info "Sending request to #{contract.name.inspect}"
logger.info " with #{data.inspect}"
reenact(contract, data)
rescue ContractNotFound => e
logger.warn "Ignoring request: #{e.message}"
end
def reenact(contract, data = {})
request = build_request contract, data
response = driver.execute request
[request, response]
end
# Returns the current driver
def driver
@driver ||= Pacto::Consumer::FaradayDriver.new
end
# Changes the driver
def driver=(driver)
fail ArgumentError, 'The driver must respond to :execute' unless driver.respond_to? :execute
@driver = driver
end
# @api private
def build_request(contract, data = {})
actor.build_request(contract, data[:values]).tap do |request|
if data[:headers] && data[:headers].respond_to?(:call)
request.headers = data[:headers].call(request.headers)
end
if data[:body] && data[:body].respond_to?(:call)
request.body = data[:body].call(request.body)
end
end
end
end
end
================================================
FILE: lib/pacto/contract.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Contract
include Logger
attr_reader :id
attr_reader :file
attr_reader :request
attr_reader :response
attr_reader :values
attr_reader :examples
attr_reader :name
attr_writer :adapter
attr_writer :consumer
attr_writer :provider
def adapter
@adapter ||= Pacto.configuration.adapter
end
def consumer
@consumer ||= Pacto.configuration.default_consumer
end
def provider
@provider ||= Pacto.configuration.default_provider
end
def examples?
examples && !examples.empty?
end
def stub_contract!(values = {})
self.values = values
adapter.stub_request!(self)
end
def simulate_request
pacto_request, pacto_response = execute
validate_response pacto_request, pacto_response
end
# Should this be deprecated?
def validate_response(request, response)
Pacto::Cops.perform_investigation request, response, self
end
def matches?(request_signature)
request_pattern.matches? request_signature
end
def request_pattern
request.pattern
end
def response_for(pacto_request)
provider.response_for self, pacto_request
end
def execute(additional_values = {})
# FIXME: Do we really need to store on the Contract, or just as a param for #stub_contact! and #execute?
full_values = values.merge(additional_values)
consumer.reenact(self, full_values)
end
end
end
================================================
FILE: lib/pacto/contract_factory.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class ContractFactory
include Singleton
include Logger
def initialize
@factories = {}
end
def add_factory(format, factory)
@factories[format.to_sym] = factory
end
def remove_factory(format)
@factories.delete format
end
def build(contract_files, host, format = :legacy)
factory = @factories[format.to_sym]
fail "No Contract factory registered for #{format}" if factory.nil?
contract_files.map { |file| factory.build_from_file(file, host) }.flatten
end
def load_contracts(contracts_path, host, format = :legacy)
factory = @factories[format.to_sym]
files = factory.files_for(contracts_path)
contracts = ContractFactory.build(files, host, format)
contracts
end
class << self
extend Forwardable
def_delegators :instance, *ContractFactory.instance_methods(false)
end
end
end
require 'pacto/formats/legacy/contract_factory'
require 'pacto/formats/swagger/contract_factory'
================================================
FILE: lib/pacto/contract_files.rb
================================================
# -*- encoding : utf-8 -*-
require 'pathname'
module Pacto
class ContractFiles
def self.for(path)
full_path = Pathname.new(path).realpath
if full_path.directory?
all_json_files = "#{full_path}/**/*.json"
Dir.glob(all_json_files).map do |f|
Pathname.new(f)
end
else
[full_path]
end
end
end
end
================================================
FILE: lib/pacto/contract_set.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class ContractSet < Set
def stub_providers(values = {})
each { |contract| contract.stub_contract!(values) }
end
def simulate_consumers
map(&:simulate_request)
end
end
end
================================================
FILE: lib/pacto/cops/body_cop.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
class BodyCop
KNOWN_CLAUSES = [:request, :response]
def self.validates(clause)
fail ArgumentError, "Unknown clause: #{clause}" unless KNOWN_CLAUSES.include? clause
@clause = clause
end
def self.investigate(request, response, contract)
# eval "is a security risk" and local_variable_get is ruby 2.1+ only, so...
body = { request: request, response: response }[@clause].body
schema = contract.send(@clause).schema
if schema && !schema.empty?
schema['id'] = contract.file unless schema.key? 'id'
JSON::Validator.fully_validate(schema, body, version: :draft3)
end || []
end
end
end
end
================================================
FILE: lib/pacto/cops/request_body_cop.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
class RequestBodyCop < BodyCop
validates :request
end
end
end
Pacto::Cops.register_cop Pacto::Cops::RequestBodyCop
================================================
FILE: lib/pacto/cops/response_body_cop.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
class ResponseBodyCop < BodyCop
validates :response
end
end
end
Pacto::Cops.register_cop Pacto::Cops::ResponseBodyCop
================================================
FILE: lib/pacto/cops/response_header_cop.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
class ResponseHeaderCop
def self.investigate(_request, response, contract)
expected_headers = contract.response.headers
actual_headers = response.headers
actual_headers = Pacto::Extensions.normalize_header_keys actual_headers
headers_to_validate = Pacto::Extensions.normalize_header_keys expected_headers
headers_to_validate.map do |expected_header, expected_value|
if actual_headers.key? expected_header
actual_value = actual_headers[expected_header]
HeaderValidatorMap[expected_header].call(expected_value, actual_value)
else
"Missing expected response header: #{expected_header}"
end
end.compact
end
private
HeaderValidatorMap = Hash.new do |_map, key|
proc do |expected_value, actual_value|
unless expected_value.eql? actual_value
"Invalid response header #{key}: expected #{expected_value.inspect} but received #{actual_value.inspect}"
end
end
end
HeaderValidatorMap['Location'] = proc do |expected_value, actual_value|
location_template = Addressable::Template.new(expected_value)
if location_template.match(Addressable::URI.parse(actual_value))
nil
else
"Invalid response header Location: expected URI #{actual_value} to match URI Template #{location_template.pattern}"
end
end
end
end
end
Pacto::Cops.register_cop Pacto::Cops::ResponseHeaderCop
================================================
FILE: lib/pacto/cops/response_status_cop.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
class ResponseStatusCop
def self.investigate(_request, response, contract)
expected_status = contract.response.status
actual_status = response.status
errors = []
if expected_status != actual_status
errors << "Invalid status: expected #{expected_status} but got #{actual_status}"
end
errors
end
end
end
end
Pacto::Cops.register_cop Pacto::Cops::ResponseStatusCop
================================================
FILE: lib/pacto/cops.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
extend Pacto::Resettable
class << self
def reset!
@active_cops = nil
end
def register_cop(cop)
fail TypeError "#{cop} does not respond to investigate" unless cop.respond_to? :investigate
registered_cops << cop
end
def registered_cops
@registered_cops ||= Set.new
end
def active_cops
@active_cops ||= registered_cops.dup
end
def investigate(request_signature, pacto_response)
return unless Pacto.validating?
contract = Pacto.contracts_for(request_signature).first
if contract
investigation = perform_investigation request_signature, pacto_response, contract
else
investigation = Investigation.new request_signature, pacto_response
end
Pacto::InvestigationRegistry.instance.register_investigation investigation
end
def perform_investigation(request, response, contract)
citations = []
active_cops.map do | cop |
citations.concat cop.investigate(request, response, contract)
end
Investigation.new(request, response, contract, citations.compact)
end
end
end
end
================================================
FILE: lib/pacto/core/configuration.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class Configuration
attr_accessor :adapter, :strict_matchers,
:contracts_path, :logger, :generator_options,
:hide_deprecations, :default_consumer, :default_provider,
:stenographer_log_file, :color, :proxy
attr_reader :hook
def initialize # rubocop:disable Metrics/MethodLength
@middleware = Pacto::Core::HTTPMiddleware.new
@middleware.add_observer Pacto::Cops, :investigate
@generator = Pacto::Generator.contract_generator
@middleware.add_observer @generator, :generate
@default_consumer = Pacto::Consumer.new
@default_provider = Pacto::Provider.new
@adapter = Stubs::WebMockAdapter.new(@middleware)
@strict_matchers = true
@contracts_path = '.'
@hook = Hook.new {}
@generator_options = { schema_version: 'draft3' }
@color = $stdout.tty?
@proxy = ENV['PACTO_PROXY']
end
def logger
@logger ||= new_simple_logger
end
def stenographer_log_file
@stenographer_log_file ||= File.expand_path('pacto_stenographer.log')
end
def register_hook(hook = nil, &block)
if block_given?
@hook = Hook.new(&block)
else
fail 'Expected a Pacto::Hook' unless hook.is_a? Hook
@hook = hook
end
end
private
def new_simple_logger
Logger::SimpleLogger.instance.tap do | logger |
if ENV['PACTO_DEBUG']
logger.level = :debug
else
logger.level = :default
end
end
end
end
end
================================================
FILE: lib/pacto/core/contract_registry.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class ContractNotFound < StandardError; end
class ContractRegistry < Set
include Logger
def register(contract)
fail ArgumentError, 'expected a Pacto::Contract' unless contract.is_a? Contract
logger.debug "Registering #{contract.request_pattern} as #{contract.name}"
add contract
end
def find_by_name(name)
contract = select { |c| c.name == name }.first
fail ContractNotFound, "No contract found for #{name}" unless contract
contract
end
def contracts_for(request_signature)
select { |c| c.matches? request_signature }
end
end
end
================================================
FILE: lib/pacto/core/hook.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class Hook
def initialize(&block)
@hook = block
end
def process(contracts, request_signature, response)
@hook.call contracts, request_signature, response
end
end
end
================================================
FILE: lib/pacto/core/http_middleware.rb
================================================
# -*- encoding : utf-8 -*-
require 'observer'
module Pacto
module Core
class HTTPMiddleware
include Logger
include Observable
def process(request, response)
contracts = Pacto.contracts_for request
Pacto.configuration.hook.process contracts, request, response
changed
begin
notify_observers request, response
rescue StandardError => e
logger.error Pacto::Errors.formatted_trace(e)
end
end
end
end
end
================================================
FILE: lib/pacto/core/investigation_registry.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class InvestigationRegistry
include Singleton
include Logger
include Resettable
attr_reader :investigations
def initialize
@investigations = []
end
def self.reset!
instance.reset!
end
def reset!
@investigations.clear
@stenographer = nil
end
def validated?(request_pattern)
matched_investigations = @investigations.select do |investigation|
request_pattern.matches? investigation.request
end
matched_investigations unless matched_investigations.empty?
end
def register_investigation(investigation)
@investigations << investigation
stenographer.log_investigation investigation
logger.info "Detected #{investigation.summary}"
logger.debug(investigation.to_s) unless investigation.successful?
investigation
end
def unmatched_investigations
@investigations.select do |investigation|
investigation.contract.nil?
end
end
def failed_investigations
@investigations.select do |investigation|
!investigation.successful?
end
end
protected
def stenographer
@stenographer ||= create_stenographer
end
def create_stenographer
stenographer_log = File.open(Pacto.configuration.stenographer_log_file, 'a+')
Pacto::Observers::Stenographer.new stenographer_log
end
end
end
================================================
FILE: lib/pacto/core/modes.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class << self
def generate!
modes << :generate
end
def stop_generating!
modes.delete :generate
end
def generating?
modes.include? :generate
end
def validate!
modes << :validate
end
def stop_validating!
modes.delete :validate
end
def validating?
modes.include? :validate
end
private
def modes
@modes ||= []
end
end
end
================================================
FILE: lib/pacto/core/pacto_request.rb
================================================
# -*- encoding : utf-8 -*-
require 'hashie/mash'
module Pacto
class PactoRequest
# FIXME: Need case insensitive header lookup, but case-sensitive storage
attr_accessor :headers, :body, :method, :uri
include BodyParsing
def initialize(data)
mash = Hashie::Mash.new data
@headers = mash.headers.nil? ? {} : mash.headers
@body = mash.body
@method = mash[:method]
@uri = mash.uri
normalize
end
def to_hash
{
method: method,
uri: uri,
headers: headers,
body: body
}
end
def to_s
string = Pacto::UI.colorize_method(method)
string << " #{relative_uri}"
string << " with body (#{raw_body.bytesize} bytes)" if raw_body
string
end
def relative_uri
uri.to_s.tap do |s|
s.slice!(uri.normalized_site)
end
end
def normalize
@method = @method.to_s.downcase.to_sym
@uri = @uri.normalize if @uri
end
end
end
================================================
FILE: lib/pacto/core/pacto_response.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class PactoResponse
# FIXME: Need case insensitive header lookup, but case-sensitive storage
attr_accessor :headers, :body, :status, :parsed_body
attr_reader :parsed_body
include BodyParsing
def initialize(data)
mash = Hashie::Mash.new data
@headers = mash.headers.nil? ? {} : mash.headers
@body = mash.body
@status = mash.status.to_i
end
def to_hash
{
status: status,
headers: headers,
body: body
}
end
def to_s
string = "STATUS: #{status}"
string << " with body (#{raw_body.bytesize} bytes)" if raw_body
string
end
end
end
================================================
FILE: lib/pacto/dash.rb
================================================
# -*- encoding : utf-8 -*-
require 'hashie'
module Pacto
class Dash < Hashie::Dash
include Hashie::Extensions::Coercion
include Hashie::Extensions::Dash::IndifferentAccess
end
end
================================================
FILE: lib/pacto/erb_processor.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class ERBProcessor
include Logger
def process(contract, values = {})
erb = ERB.new(contract)
erb_result = erb.result hash_binding(values)
logger.debug "Processed contract: #{erb_result.inspect}"
erb_result
end
private
def hash_binding(values)
namespace = OpenStruct.new(values)
namespace.instance_eval { binding }
end
end
end
================================================
FILE: lib/pacto/errors.rb
================================================
module Pacto
class InvalidContract < ArgumentError
attr_reader :errors
def initialize(errors)
@errors = errors
end
def message
@errors.join "\n"
end
end
module Errors
# Creates an array of strings, representing a formatted exception,
# containing backtrace and nested exception info as necessary, that can
# be viewed by a human.
#
# For example:
#
# ------Exception-------
# Class: Crosstest::StandardError
# Message: Failure starting the party
# ---Nested Exception---
# Class: IOError
# Message: not enough directories for a party
# ------Backtrace-------
# nil
# ----------------------
#
# @param exception [::StandardError] an exception
# @return [Array] a formatted message
def self.formatted_trace(exception)
arr = formatted_exception(exception).dup
last = arr.pop
if exception.respond_to?(:original) && exception.original
arr += formatted_exception(exception.original, 'Nested Exception')
last = arr.pop
end
arr += ['Backtrace'.center(22, '-'), exception.backtrace, last].flatten
arr
end
# Creates an array of strings, representing a formatted exception that
# can be viewed by a human. Thanks to MiniTest for the inspiration
# upon which this output has been designed.
#
# For example:
#
# ------Exception-------
# Class: Crosstest::StandardError
# Message: I have failed you
# ----------------------
#
# @param exception [::StandardError] an exception
# @param title [String] a custom title for the message
# (default: `"Exception"`)
# @return [Array] a formatted message
def self.formatted_exception(exception, title = 'Exception')
[
title.center(22, '-'),
"Class: #{exception.class}",
"Message: #{exception.message}",
''.center(22, '-')
]
end
end
end
================================================
FILE: lib/pacto/extensions.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Extensions
# Adapted from Faraday
HeaderKeyMap = Hash.new do |map, key|
split_char = key.to_s.include?('-') ? '-' : '_'
map[key] = key.to_s.split(split_char). # :user_agent => %w(user agent)
each(&:capitalize!). # => %w(User Agent)
join('-') # => "User-Agent"
end
HeaderKeyMap[:etag] = 'ETag'
def self.normalize_header_keys(headers)
headers.each_with_object({}) do |(key, value), normalized|
normalized[HeaderKeyMap[key]] = value
end
end
end
end
================================================
FILE: lib/pacto/forensics/investigation_filter.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Forensics
class FilterExhaustedError < StandardError
attr_reader :suspects
def initialize(msg, filter, suspects = [])
@suspects = suspects
if filter.respond_to? :description
msg = "#{msg} #{filter.description}"
else
msg = "#{msg} #{filter}"
end
super(msg)
end
end
class InvestigationFilter
# CaseEquality makes sense for some of the rspec matchers and compound matching behavior
# rubocop:disable Style/CaseEquality
attr_reader :investigations, :filtered_investigations
def initialize(investigations, track_suspects = true)
investigations ||= []
@investigations = investigations.dup
@filtered_investigations = @investigations.dup
@track_suspects = track_suspects
end
def with_name(contract_name)
@filtered_investigations.keep_if do |investigation|
return false if investigation.contract.nil?
contract_name === investigation.contract.name
end
self
end
def with_request(request_constraints)
return self if request_constraints.nil?
[:headers, :body].each do |section|
filter_request_section(section, request_constraints[section])
end
self
end
def with_response(response_constraints)
return self if response_constraints.nil?
[:headers, :body].each do |section|
filter_response_section(section, response_constraints[section])
end
self
end
def successful_investigations
@filtered_investigations.select(&:successful?)
end
def unsuccessful_investigations
@filtered_investigations - successful_investigations
end
protected
def filter_request_section(section, filter)
suspects = []
section = :parsed_body if section == :body
@filtered_investigations.keep_if do |investigation|
candidate = investigation.request.send(section)
suspects << candidate if @track_suspects
filter === candidate
end if filter
fail FilterExhaustedError.new("no requests matched #{section}", filter, suspects) if @filtered_investigations.empty?
end
def filter_response_section(section, filter)
section = :parsed_body if section == :body
suspects = []
@filtered_investigations.keep_if do |investigation|
candidate = investigation.response.send(section)
suspects << candidate if @track_suspects
filter === candidate
end if filter
fail FilterExhaustedError.new("no responses matched #{section}", filter, suspects) if @filtered_investigations.empty?
end
# rubocop:enable Style/CaseEquality
end
end
end
================================================
FILE: lib/pacto/forensics/investigation_matcher.rb
================================================
# -*- encoding : utf-8 -*-
RSpec::Matchers.define :have_investigated do |service_name|
match do
investigations = Pacto::InvestigationRegistry.instance.investigations
@service_name = service_name
begin
@investigation_filter = Pacto::Forensics::InvestigationFilter.new(investigations)
@investigation_filter.with_name(@service_name)
.with_request(@request_constraints)
.with_response(@response_constraints)
@matched_investigations = @investigation_filter.filtered_investigations
@unsuccessful_investigations = @investigation_filter.unsuccessful_investigations
!@matched_investigations.empty? && (@allow_citations || @unsuccessful_investigations.empty?)
rescue Pacto::Forensics::FilterExhaustedError => e
@filter_error = e
false
end
end
def describe(obj)
obj.respond_to?(:description) ? obj.description : obj.to_s
end
description do
buffer = StringIO.new
buffer.puts "to have investigated #{@service_name}"
if @request_constraints
buffer.puts ' with request matching'
@request_constraints.each do |k, v|
buffer.puts " #{k}: #{describe(v)}"
end
end
buffer.puts ' and' if @request_constraints && @response_constraints
if @response_constraint
buffer.puts ' with response matching'
@request_constraints.each do |k, v|
buffer.puts " #{k}: #{describe(v)}"
end
end
buffer.string
end
chain :with_request do |request_constraints|
@request_constraints = request_constraints
end
chain :with_response do |response_constraints|
@response_constraints = response_constraints
end
chain :allow_citations do
@allow_citations = true
end
failure_message do | group |
buffer = StringIO.new
buffer.puts "expected #{group} " + description
if @filter_error
buffer.puts "but #{@filter_error.message}"
unless @filter_error.suspects.empty?
buffer.puts ' suspects:'
@filter_error.suspects.each do |suspect|
buffer.puts " #{suspect}"
end
end
elsif @matched_investigations.empty?
investigated_services = @investigation_filter.investigations.map(&:contract).compact.map(&:name).uniq
buffer.puts "but it was not among the services investigated: #{investigated_services}"
elsif @unsuccessful_investigations
buffer.puts 'but investigation errors were found:'
@unsuccessful_investigations.each do |investigation|
buffer.puts " #{investigation}"
end
end
buffer.string
end
end
================================================
FILE: lib/pacto/formats/legacy/contract.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/formats/legacy/request_clause'
require 'pacto/formats/legacy/response_clause'
module Pacto
module Formats
module Legacy
class Contract < Pacto::Dash
include Pacto::Contract
property :id
property :file
property :request, required: true
# Although I'd like response to be required, it complicates
# the partial contracts used the rake generation task...
# yet another reason I'd like to deprecate that feature
property :response # , required: true
property :values, default: {}
# Gotta figure out how to use test doubles w/ coercion
coerce_key :request, RequestClause
coerce_key :response, ResponseClause
property :examples
property :name, required: true
property :adapter, default: proc { Pacto.configuration.adapter }
property :consumer, default: proc { Pacto.configuration.default_consumer }
property :provider, default: proc { Pacto.configuration.default_provider }
def initialize(opts)
skip_freeze = opts.delete(:skip_freeze)
if opts[:file]
opts[:file] = Addressable::URI.convert_path(File.expand_path(opts[:file])).to_s
opts[:name] ||= opts[:file]
end
opts[:id] ||= (opts[:summary] || opts[:file])
super
freeze unless skip_freeze
end
def freeze
(keys.map(&:to_sym) - [:values, :adapter, :consumer, :provider]).each do | key |
send(key).freeze
end
self
end
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/contract_builder.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
class ContractBuilder < Hashie::Dash # rubocop:disable Metrics/ClassLength
extend Forwardable
attr_accessor :source
def initialize(options = {})
@schema_generator = options[:schema_generator] ||= JSON::SchemaGenerator
@filters = options[:filters] ||= Generator::Filters.new
@data = { request: {}, response: {}, examples: {} }
@source = 'Pacto' # Currently used by JSONSchemaGeneator, but not really useful
end
def name=(name)
@data[:name] = name
end
def add_example(name, pacto_request, pacto_response)
@data[:examples][name] ||= {}
@data[:examples][name][:request] = clean(pacto_request.to_hash)
@data[:examples][name][:response] = clean(pacto_response.to_hash)
self
end
def infer_all
# infer_file # The target file is being chosen inferred by the Generator
infer_name
infer_schemas
end
def infer_name
if @data[:examples].empty?
@data[:name] = @data[:request][:path] if @data[:request]
return self
end
example, hint = example_and_hint
@data[:name] = hint.nil? ? PactoRequest.new(example[:request]).uri.path : hint.service_name
self
end
def infer_schemas
return self if @data[:examples].empty?
# TODO: It'd be awesome if we could infer across all examples
example, _hint = example_and_hint
sample_request_body = example[:request][:body]
sample_response_body = example[:response][:body]
@data[:request][:schema] = generate_schema(sample_request_body) if sample_request_body && !sample_request_body.empty?
@data[:response][:schema] = generate_schema(sample_response_body) if sample_response_body && !sample_response_body.empty?
self
end
def without_examples
@export_examples = false
self
end
def generate_contract(request, response)
generate_request(request, response)
generate_response(request, response)
infer_all
self
end
def generate_request(request, response)
hint = hint_for(request)
request = clean(
headers: @filters.filter_request_headers(request, response),
http_method: request.method,
params: request.uri.query_values,
path: hint.nil? ? request.uri.path : hint.path
)
@data[:request] = request
self
end
def generate_response(request, response)
response = clean(
headers: @filters.filter_response_headers(request, response),
status: response.status
)
@data[:response] = response
self
end
def build_hash
instance_eval(&block) if block_given?
@final_data = @data.dup
@final_data.delete(:examples) if exclude_examples?
clean(@final_data)
end
def build(&block)
Contract.new build_hash(&block)
end
protected
def example_and_hint
example = @data[:examples].values.first
example_request = PactoRequest.new example[:request]
[example, Pacto::Generator.hint_for(example_request)]
end
def exclude_examples?
@export_examples == false
end
def generate_schema(body, generator_options = Pacto.configuration.generator_options)
return if body.nil? || body.empty?
body_schema = @schema_generator.generate @source, body, generator_options
MultiJson.load(body_schema)
end
def clean(data)
data.delete_if { |_k, v| v.nil? }
end
def hint_for(pacto_request)
Pacto::Generator.hint_for(pacto_request)
end
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/contract_factory.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/formats/legacy/contract'
module Pacto
module Formats
module Legacy
# Builds {Pacto::Formats::Legacy::Contract} instances from Pacto's legacy Contract format.
class ContractFactory
attr_reader :schema
def initialize(options = {})
@schema = options[:schema] || MetaSchema.new
end
def build_from_file(contract_path, host)
contract_definition = File.read(contract_path)
definition = JSON.parse(contract_definition)
schema.validate definition
definition['request'].merge!('host' => host)
body_to_schema(definition, 'request', contract_path)
body_to_schema(definition, 'response', contract_path)
method_to_http_method(definition, contract_path)
request = RequestClause.new(definition['request'])
response = ResponseClause.new(definition['response'])
Contract.new(request: request, response: response, file: contract_path, name: definition['name'], examples: definition['examples'])
end
def files_for(contracts_dir)
full_path = Pathname.new(contracts_dir).realpath
if full_path.directory?
all_json_files = "#{full_path}/**/*.json"
Dir.glob(all_json_files).map do |f|
Pathname.new(f)
end
else
[full_path]
end
end
private
def body_to_schema(definition, section, file)
schema = definition[section].delete 'body'
return nil unless schema
Pacto::UI.deprecation "Contract format deprecation: #{section}:body will be moved to #{section}:schema (#{file})"
definition[section]['schema'] = schema
end
def method_to_http_method(definition, file)
method = definition['request'].delete 'method'
return nil unless method
Pacto::UI.deprecation "Contract format deprecation: request:method will be moved to request:http_method (#{file})"
definition['request']['http_method'] = method
end
Pacto::ContractFactory.add_factory(:legacy, ContractFactory.new)
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/contract_generator.rb
================================================
# -*- encoding : utf-8 -*-
require 'json/schema_generator'
require 'pacto/formats/legacy/contract_builder'
require 'pacto/formats/legacy/generator/filters'
module Pacto
module Formats
module Legacy
class ContractGenerator
include Logger
def initialize(_schema_version = 'draft3',
schema_generator = JSON::SchemaGenerator,
validator = Pacto::MetaSchema.new,
filters = Generator::Filters.new,
consumer = Pacto::Consumer.new)
@contract_builder = ContractBuilder.new(schema_generator: schema_generator, filters: filters)
@consumer = consumer
@validator = validator
end
def generate(pacto_request, pacto_response)
return unless Pacto.generating?
logger.debug("Generating Contract for #{pacto_request}, #{pacto_response}")
begin
contract_file = load_contract_file(pacto_request)
unless File.exist? contract_file
uri = URI(pacto_request.uri)
FileUtils.mkdir_p(File.dirname contract_file)
raw_contract = save(uri, pacto_request, pacto_response)
File.write(contract_file, raw_contract)
logger.debug("Generating #{contract_file}")
Pacto.load_contract contract_file, uri.host
end
rescue => e
raise StandardError, "Error while generating Contract #{contract_file}: #{e.message}", e.backtrace
end
end
def generate_from_partial_contract(request_file, host)
contract = Pacto.load_contract request_file, host
request, response = @consumer.request(contract)
save(request_file, request, response)
end
def save(source, request, response)
@contract_builder.source = source
# TODO: Get rid of the generate_contract call, just use add_example/infer_all
@contract_builder.add_example('default', request, response).generate_contract(request, response) # .infer_all
@contract_builder.without_examples if Pacto.configuration.generator_options[:no_examples]
contract = @contract_builder.build_hash
pretty_contract = MultiJson.encode(contract, pretty: true)
# This is because of a discrepency w/ jruby vs MRI pretty json
pretty_contract.gsub!(/^$\n/, '')
@validator.validate pretty_contract
pretty_contract
end
private
def load_contract_file(pacto_request)
hint = Pacto::Generator.hint_for(pacto_request)
if hint.nil?
uri = URI(pacto_request.uri)
path = uri.path
basename = File.basename(path, '.json') + '.json'
File.join(Pacto.configuration.contracts_path, uri.host, File.dirname(path), basename)
else
File.expand_path(hint.target_file, Pacto.configuration.contracts_path)
end
end
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/generator/filters.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
module Generator
class Filters
CONNECTION_CONTROL_HEADERS = %w(
Via
Server
Connection
Transfer-Encoding
Content-Length
)
FRESHNESS_HEADERS =
%w(
Date
Last-Modified
ETag
)
HEADERS_TO_FILTER = CONNECTION_CONTROL_HEADERS + FRESHNESS_HEADERS
def filter_request_headers(request, response)
# FIXME: Do we need to handle all these cases in real situations, or just because of stubbing?
vary_headers = response.headers['vary'] || response.headers['Vary'] || []
vary_headers = [vary_headers] if vary_headers.is_a? String
vary_headers = vary_headers.map do |h|
h.split(',').map(&:strip)
end.flatten
request.headers.select do |header|
vary_headers.map(&:downcase).include? header.downcase
end
end
def filter_response_headers(_request, response)
Pacto::Extensions.normalize_header_keys(response.headers).reject do |header|
(HEADERS_TO_FILTER.include? header) || (header.start_with?('X-'))
end
end
end
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/generator_hint.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
class GeneratorHint < Pacto::Dash
extend Forwardable
property :request_clause
coerce_key :request_clause, RequestClause
property :service_name, required: true
property :target_file
def_delegators :request_clause, *RequestClause::Data.properties.map(&:to_sym)
def initialize(data)
data[:request_clause] = RequestClause::Data.properties.each_with_object({}) do | prop, hash |
hash[prop] = data.delete prop
end
super
self.target_file ||= "#{slugify(service_name)}.json"
end
def matches?(pacto_request)
return false if pacto_request.nil?
Pacto::RequestPattern.for(request_clause).matches?(pacto_request)
end
private
def slugify(path)
path.downcase.gsub(' ', '_')
end
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/request_clause.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
class RequestClause < Pacto::Dash
include Pacto::RequestClause
extend Forwardable
attr_reader :data
def_delegators :data, :to_hash
def_delegators :data, :host, :http_method, :schema, :path, :headers, :params
def_delegators :data, :host=, :http_method=, :schema=, :path=, :headers=, :params=
class Data < Pacto::Dash
property :host # required?
property :http_method, required: true
property :schema, default: {}
property :path, default: '/'
property :headers, default: {}
property :params, default: {}
end
def initialize(data)
skip_freeze = data.delete(:skip_freeze)
mash = Hashie::Mash.new data
mash['http_method'] = normalize(mash['http_method'])
@data = Data.new(mash)
freeze unless skip_freeze
super({})
@pattern = Pacto::RequestPattern.for(self)
end
def freeze
@data.freeze
self
end
end
end
end
end
================================================
FILE: lib/pacto/formats/legacy/response_clause.rb
================================================
module Pacto
module Formats
module Legacy
class ResponseClause
include Pacto::ResponseClause
extend Forwardable
attr_reader :data
def_delegators :data, :to_hash
def_delegators :data, :status, :headers, :schema
def_delegators :data, :status=, :headers=, :schema=
class Data < Pacto::Dash
property :status
property :headers, default: {}
property :schema, default: {}
end
def initialize(data)
skip_freeze = data.delete(:skip_freeze)
@data = Data.new(data)
freeze unless skip_freeze
end
def freeze
@data.freeze
self
end
end
end
end
end
================================================
FILE: lib/pacto/formats/swagger/contract.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/formats/swagger/request_clause'
require 'pacto/formats/swagger/response_clause'
module Pacto
module Formats
module Swagger
class Contract < Pacto::Dash
include Pacto::Contract
attr_reader :swagger_api_operation
property :id
property :file
property :request, required: true
# Although I'd like response to be required, it complicates
# the partial contracts used the rake generation task...
# yet another reason I'd like to deprecate that feature
property :response # , required: true
property :values, default: {}
# Gotta figure out how to use test doubles w/ coercion
coerce_key :request, RequestClause
coerce_key :response, ResponseClause
property :examples
property :name, required: true
property :adapter, default: proc { Pacto.configuration.adapter }
property :consumer, default: proc { Pacto.configuration.default_consumer }
property :provider, default: proc { Pacto.configuration.default_provider }
def initialize(swagger_api_operation, base_data = {}) # rubocop:disable Metrics/MethodLength
if base_data[:file]
base_data[:file] = Addressable::URI.convert_path(File.expand_path(base_data[:file])).to_s
base_data[:name] ||= base_data[:file]
end
base_data[:id] ||= (base_data[:summary] || base_data[:file])
@swagger_api_operation = swagger_api_operation
host = base_data.delete(:host) || swagger_api_operation.host
default_response = swagger_api_operation.default_response
request_clause = Pacto::Formats::Swagger::RequestClause.new(swagger_api_operation, host: host)
if default_response.nil?
logger.warn("No response defined for #{swagger_api_operation.full_name}")
response_clause = ResponseClause.new(status: 200)
else
response_clause = ResponseClause.new(default_response)
end
examples = build_examples(default_response)
super base_data.merge(
id: swagger_api_operation.operationId,
name: swagger_api_operation.full_name,
request: request_clause, response: response_clause,
examples: examples
)
end
private
def build_examples(response)
return nil if response.nil? || response.examples.nil? || response.examples.empty?
if response.examples.empty?
response_body = nil
else
response_body = response.examples.values.first
end
{
default: {
request: {}, # Swagger doesn't have a clear way to capture request examples
response: {
body: response_body
}
}
}
rescue => e # FIXME: Only parsing errors?
logger.warn("Error while trying to parse response example for #{swagger_api_operation.full_name}")
logger.debug(" Error details: #{e.inspect}")
nil
end
end
end
end
end
================================================
FILE: lib/pacto/formats/swagger/contract_factory.rb
================================================
# -*- encoding : utf-8 -*-
require 'swagger'
require 'pacto/formats/swagger/contract'
module Pacto
module Formats
module Swagger
# Builds {Pacto::Formats::Swagger::Contract} instances from Swagger documents
class ContractFactory
include Logger
def load_hints(_contract_path, _host = nil)
fail NotImplementedError, 'Contract generation from hints is not currently supported for Swagger'
end
def build_from_file(contract_path, host = nil)
app = ::Swagger.load(contract_path)
app.operations.map do |op|
Contract.new(op,
file: contract_path,
host: host
)
end
rescue ArgumentError => e
logger.error(e)
raise "Could not load #{contract_path}: #{e.message}"
end
def files_for(contracts_dir)
full_path = Pathname.new(contracts_dir).realpath
if full_path.directory?
all_json_files = "#{full_path}/**/*.{json,yaml,yml}"
Dir.glob(all_json_files).map do |f|
Pathname.new(f)
end
else
[full_path]
end
end
end
Pacto::ContractFactory.add_factory(:swagger, ContractFactory.new)
end
end
end
================================================
FILE: lib/pacto/formats/swagger/request_clause.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Swagger
class RequestClause
include Pacto::RequestClause
extend Forwardable
attr_writer :host
attr_reader :swagger_api_operation
def_delegator :swagger_api_operation, :verb, :http_method
def_delegators :swagger_api_operation, :path
def initialize(swagger_api_operation, base_data = {})
@swagger_api_operation = swagger_api_operation
@host = base_data[:host] || swagger_api_operation.host
@pattern = Pacto::RequestPattern.for(self)
end
def schema
return nil if body_parameter.nil?
return nil if body_parameter.schema.nil?
body_parameter.schema.parse
end
def params
return {} if swagger_api_operation.parameters.nil?
swagger_api_operation.parameters.select { |p| p.in == 'query' }
end
def headers
return {} if swagger_api_operation.parameters.nil?
swagger_api_operation.parameters.select { |p| p.in == 'header' }
end
def to_hash
[:http_method, :schema, :path, :headers, :params].each_with_object({}) do | key, hash |
hash[key.to_s] = send key
end
end
private
def body_parameter
return nil if swagger_api_operation.parameters.nil?
swagger_api_operation.parameters.find { |p| p.in == 'body' }
end
end
end
end
end
================================================
FILE: lib/pacto/formats/swagger/response_clause.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Swagger
class ResponseClause
extend Forwardable
include Pacto::ResponseClause
attr_reader :swagger_response
def_delegators :swagger_response, :schema
def initialize(swagger_response, _base_data = {})
@swagger_response = swagger_response
end
def status
swagger_response.status_code || 200
end
def headers
swagger_response.headers || {}
end
def schema
return nil unless swagger_response.schema
swagger_response.schema.parse
end
end
end
end
end
================================================
FILE: lib/pacto/generator.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/formats/legacy/contract_generator'
require 'pacto/formats/legacy/generator_hint'
module Pacto
module Generator
include Logger
class << self
# Factory method to return the active contract generator implementation
def contract_generator
Pacto::Formats::Legacy::ContractGenerator.new
end
# Factory method to return the active contract generator implementation
def schema_generator
JSON::SchemaGenerator
end
def configuration
@configuration ||= Configuration.new
end
def configure
yield(configuration)
end
def hint_for(pacto_request)
configuration.hints.find { |hint| hint.matches? pacto_request }
end
end
class Configuration
attr_reader :hints
def initialize
@hints = Set.new
end
def hint(name, hint_data)
@hints << Formats::Legacy::GeneratorHint.new(hint_data.merge(service_name: name))
end
end
end
end
================================================
FILE: lib/pacto/handlers/json_handler.rb
================================================
require 'json'
module Pacto
module Handlers
module JSONHandler
class << self
def raw(body)
JSON.dump(body)
end
def parse(body)
JSON.parse(body)
end
# TODO: Something like validate(contract, body)
end
end
end
end
================================================
FILE: lib/pacto/handlers/text_handler.rb
================================================
module Pacto
module Handlers
module TextHandler
class << self
def raw(body)
body.to_s
end
def parse(body)
body.to_s
end
# TODO: Something like validate(contract, body)
end
end
end
end
================================================
FILE: lib/pacto/hooks/erb_hook.rb
================================================
# -*- encoding : utf-8 -*-
require_relative '../erb_processor'
module Pacto
module Hooks
class ERBHook < Pacto::Hook
def initialize
@processor = ERBProcessor.new
end
def process(contracts, request_signature, response)
bound_values = contracts.empty? ? {} : contracts.first.values
bound_values.merge!(req: { 'HEADERS' => request_signature.headers })
response.body = @processor.process response.body, bound_values
response.body
end
end
end
end
================================================
FILE: lib/pacto/investigation.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class Investigation
include Logger
attr_reader :request, :response, :contract, :citations
def initialize(request, response, contract = nil, citations = nil)
@request = request
@response = response
@contract = contract
@citations = citations || []
end
def successful?
@citations.empty?
end
def against_contract?(contract_pattern)
return nil if @contract.nil?
case contract_pattern
when String
@contract if @contract.file.eql? contract_pattern
when Regexp
@contract if @contract.file =~ contract_pattern
end
end
def to_s
contract_name = @contract.nil? ? 'nil' : contract.name
citation_string = Pacto::UI.colorize(@citations.join("\n\t\t"), :red)
''"
Investigation:
\tContract: #{contract_name}
\tRequest: #{@request}
\tCitations: \n\t\t#{citation_string}
"''
end
def summary
if @contract.nil?
"Missing contract for services provided by #{@request.uri.host}"
else
status = successful? ? 'successful' : 'unsuccessful'
"#{status} investigation of #{@contract.name}"
end
end
end
end
================================================
FILE: lib/pacto/logger.rb
================================================
# -*- encoding : utf-8 -*-
require 'forwardable'
module Pacto
module Logger
def logger
Pacto.configuration.logger
end
class SimpleLogger
include Singleton
extend Forwardable
def_delegators :@log, :debug, :info, :warn, :error, :fatal
def initialize
log ::Logger.new STDOUT
end
def log(log)
@log = log
@log.level = default_level
@log.progname = 'Pacto'
end
def level=(level)
@log.level = log_levels.fetch(level, default_level)
end
def level
log_levels.key @log.level
end
private
def default_level
::Logger::ERROR
end
def log_levels
{
debug: ::Logger::DEBUG,
info: ::Logger::INFO,
warn: ::Logger::WARN,
error: ::Logger::ERROR,
fatal: ::Logger::FATAL
}
end
end
end
end
================================================
FILE: lib/pacto/meta_schema.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class MetaSchema
attr_accessor :schema, :engine
def initialize(engine = JSON::Validator)
@schema = File.join(File.dirname(File.expand_path(__FILE__)), '../../resources/contract_schema.json')
base_schemas = ['../../resources/draft-03.json', '../../resources/draft-04.json']
validatable = false
base_schemas.each do |base_schema|
base_schema_file = File.join(File.dirname(File.expand_path(__FILE__)), base_schema)
# This has a side-effect of caching local schemas, so we don't
# look up json-schemas over HTTP.
validatable ||= JSON::Validator.validate(base_schema_file, @schema)
end
fail 'Could not validate metaschema against any known version of json-schema' unless validatable
@engine = engine
end
def validate(definition)
errors = engine.fully_validate(schema, definition)
fail InvalidContract, errors unless errors.empty?
end
end
end
================================================
FILE: lib/pacto/observers/stenographer.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Observers
class Stenographer
def initialize(output)
@output = output
end
def log_investigation(investigation)
return if @output.nil?
contract = investigation.contract
request = investigation.request
response = investigation.response
name = name_for(contract, request)
values = values_for(contract, request)
msg = "request #{name.inspect}, values: #{values.inspect}, response: {status: #{response.status}} # #{number_of_citations(investigation)} contract violations"
@output.puts msg
@output.flush
end
protected
def name_for(contract, request)
return "Unknown (#{request.uri})" if contract.nil?
contract.name
end
def number_of_citations(investigation)
return 0 if investigation.nil?
return 0 if investigation.citations.nil?
investigation.citations.size.to_s
end
def values_for(_contract, request)
# FIXME: Extract vars w/ URI::Template
request.uri.query_values
end
end
end
end
================================================
FILE: lib/pacto/provider.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
def self.providers
@providers ||= {}
end
class Provider
include Resettable
def self.reset!
Pacto.providers.clear
end
def actor
@actor ||= Pacto::Actors::FromExamples.new
end
def actor=(actor)
fail ArgumentError, 'The actor must respond to :build_response' unless actor.respond_to? :build_response
@actor = actor
end
def response_for(contract, data = {})
actor.build_response contract, data
end
end
end
================================================
FILE: lib/pacto/rake_task.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto'
require 'thor'
require 'pacto/cli'
require 'pacto/cli/helpers'
# FIXME: RakeTask is a huge class, refactor this please
# rubocop:disable ClassLength
module Pacto
class RakeTask
extend Forwardable
include Thor::Actions
include Rake::DSL
include Pacto::CLI::Helpers
def initialize
@exit_with_error = false
@cli = Pacto::CLI::Main.new
end
def run(task, args, opts = {})
Pacto::CLI::Main.new([], opts).public_send(task, *args)
end
def install
desc 'Tasks for Pacto gem'
namespace :pacto do
validate_task
generate_task
meta_validate
end
end
def validate_task
desc 'Validates all contracts in a given directory against a given host'
task :validate, :host, :dir do |_t, args|
opts = args.to_hash
dir = opts.delete :dir
run(:validate, dir, opts)
end
end
def generate_task
desc 'Generates contracts from partial contracts'
task :generate, :input_dir, :output_dir, :host do |_t, args|
if args.to_a.size < 3
fail Pacto::UI.colorize('USAGE: rake pacto:generate[, , ]', :yellow)
end
generate_contracts(args[:input_dir], args[:output_dir], args[:host])
end
end
def meta_validate
desc 'Validates a directory of contract definitions'
task :meta_validate, :dir do |_t, args|
run(:meta_validate, *args)
end
end
# rubocop:enable MethodLength
# FIXME: generate_contracts is a big method =(. Needs refactoring
# rubocop:disable MethodLength
def generate_contracts(input_dir, output_dir, host)
WebMock.allow_net_connect!
generator = Pacto::Generator.contract_generator
puts "Generating contracts from partial contracts in #{input_dir} and recording to #{output_dir}\n\n"
failed_contracts = []
each_contract(input_dir) do |contract_file|
begin
contract = generator.generate_from_partial_contract(contract_file, host)
output_file = File.expand_path(File.basename(contract_file), output_dir)
output_file = File.open(output_file, 'wb')
output_file.write contract
output_file.flush
output_file.close
rescue InvalidContract => e
failed_contracts << contract_file
puts Pacto::UI.colorize(e.message, :red)
end
end
if failed_contracts.empty?
puts Pacto::UI.colorize('Successfully generated all contracts', :green)
else
fail Pacto::UI.colorize("The following contracts could not be generated: #{failed_contracts.join ','}", :red)
end
end
# rubocop:enable MethodLength
end
end
# rubocop:enable ClassLength
Pacto::RakeTask.new.install
================================================
FILE: lib/pacto/request_clause.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module RequestClause
include Logger
attr_reader :host
attr_reader :http_method
attr_reader :schema
attr_reader :path
attr_reader :headers
attr_reader :params
attr_reader :pattern
def http_method=(method)
normalize(method)
end
def uri(values = {})
values ||= {}
uri_template = pattern.uri_template
missing_keys = uri_template.keys.map(&:to_sym) - values.keys.map(&:to_sym)
values[:scheme] = 'http' if missing_keys.delete(:scheme)
values[:server] = 'localhost' if missing_keys.delete(:server)
logger.warn "Missing keys for building a complete URL: #{missing_keys.inspect}" unless missing_keys.empty?
Addressable::URI.heuristic_parse(uri_template.expand(values)).tap do |uri|
uri.query_values = params unless params.nil? || params.empty?
end
end
private
def normalize(method)
method.to_s.downcase.to_sym
end
end
end
================================================
FILE: lib/pacto/request_pattern.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class RequestPattern < WebMock::RequestPattern
attr_accessor :uri_template
def self.for(base_request)
new(base_request.http_method, UriPattern.for(base_request))
end
def initialize(http_method, uri_template)
@uri_template = uri_template
super
end
def to_s
string = Pacto::UI.colorize_method(@method_pattern.to_s)
string << " #{@uri_pattern}"
# WebMock includes this info, but I don't think we should. Pacto should match on URIs only and then validate the rest...
# string << " with body #{@body_pattern.to_s}" if @body_pattern
# string << " with headers #{@headers_pattern.to_s}" if @headers_pattern
# string << " with given block" if @with_block
string
end
end
end
================================================
FILE: lib/pacto/resettable.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
# Included this module so that Pacto::Resettable.reset_all will call your class/module's self.reset! method.
module Resettable
def self.resettables
@resettables ||= []
end
def self.extended(base)
resettables << base
end
def self.included(base)
resettables << base
end
def self.reset_all
resettables.each(&:reset!)
true
end
end
end
================================================
FILE: lib/pacto/response_clause.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module ResponseClause
attr_reader :status
attr_reader :headers
attr_reader :schema
end
end
================================================
FILE: lib/pacto/rspec.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto'
begin
require 'rspec/core'
require 'rspec/expectations'
rescue LoadError
raise 'pacto/rspec requires rspec 2 or later'
end
require 'pacto/forensics/investigation_filter'
require 'pacto/forensics/investigation_matcher'
RSpec::Matchers.define :have_unmatched_requests do |_method, _uri|
match do
@unmatched_investigations = Pacto::InvestigationRegistry.instance.unmatched_investigations
!@unmatched_investigations.empty?
end
failure_message do
'Expected Pacto to have not matched all requests to a Contract, but all requests were matched.'
end
failure_message_when_negated do
unmatched_requests = @unmatched_investigations.map(&:request).join("\n ")
"Expected Pacto to have matched all requests to a Contract, but the following requests were not matched: \n #{unmatched_requests}"
end
end
RSpec::Matchers.define :have_failed_investigations do |_method, _uri|
match do
@failed_investigations = Pacto::InvestigationRegistry.instance.failed_investigations
!@failed_investigations.empty?
end
failure_message do
'Expected Pacto to have found investigation problems, but none were found.'
end
failure_message_when_negated do
"Expected Pacto to have successfully validated all requests, but the following issues were found: #{@failed_investigations}"
end
end
RSpec::Matchers.define :have_validated do |method, uri|
match do
@request_pattern = Pacto::RequestPattern.new(method, uri)
@request_pattern.with(@options) if @options
validated? @request_pattern
end
chain :against_contract do |contract|
@contract = contract
end
chain :with do |options|
@options = options
end
def validated?(_request_pattern)
@matching_investigations = Pacto::InvestigationRegistry.instance.validated? @request_pattern
validated = !@matching_investigations.nil?
validated && successfully? && contract_matches?
end
def investigation_citations
@investigation_citations ||= @matching_investigations.map(&:citations).flatten.compact
end
def successfully?
@matching_investigations.map(&:successful?).uniq.eql? [true]
end
def contract_matches?
if @contract
validated_contracts = @matching_investigations.map(&:contract).compact
# Is there a better option than case equality for string & regex support?
validated_contracts.any? do |contract|
@contract === contract.file || @contract === contract.name # rubocop:disable CaseEquality
end
else
true
end
end
failure_message do
buffer = StringIO.new
buffer.puts "expected Pacto to have validated #{@request_pattern}"
if @matching_investigations.nil? || @matching_investigations.empty?
buffer.puts ' but no matching request was received'
buffer.puts ' received:'
buffer.puts "#{WebMock::RequestRegistry.instance}"
elsif @matching_investigations.map(&:contract).compact.empty?
buffer.puts ' but a matching Contract was not found'
elsif !successfully?
buffer.puts ' but investigation errors were found:'
buffer.print ' '
buffer.puts investigation_citations.join "\n "
# investigation_citations.each do |investigation_result|
# buffer.puts " #{investigation_result}"
# end
elsif @contract
validated_against = @matching_investigations.map { |v| v.against_contract? @contract }.compact.join ','
buffer.puts " against Contract #{@contract}"
buffer.puts " but it was validated against #{validated_against}"
end
buffer.string
end
end
================================================
FILE: lib/pacto/server/cli.rb
================================================
require 'thor'
require 'pacto/server'
module Pacto
module Server
class CLI < Thor
class << self
DEFAULTS = {
stdout: true,
log_file: 'pacto.log',
# :config => 'pacto/config/pacto_server.rb',
strict: false,
stub: true,
live: false,
generate: false,
verbose: true,
validate: true,
directory: File.join(Dir.pwd, 'contracts'),
port: 9000,
format: :legacy,
stenographer_log_file: File.expand_path('pacto_stenographer.log', Dir.pwd),
strip_port: true
}
def server_options
method_option :port, default: 4567, desc: 'The port to run the server on'
method_option :directory, default: DEFAULTS[:directory], desc: 'The directory containing contracts'
method_option :strict, default: DEFAULTS[:strict], desc: 'Whether Pacto should use strict matching or not'
method_option :format, default: DEFAULTS[:format], desc: 'The contract format to use'
method_option :strip_port, default: DEFAULTS[:strip_port], desc: 'If pacto should remove the port from URLs before forwarding'
end
end
desc 'stub [CONTRACTS...]', 'Launches a stub server for a set of contracts'
method_option :port, type: :numeric, desc: 'The port to listen on', default: 3000
method_option :spy, type: :boolean, desc: 'Display traffic received by Pacto'
server_options
def stub(*_contracts)
setup_interrupt
server_options = @options.dup
server_options[:stub] = true
Pacto::Server::HTTP.run('0.0.0.0', options.port, server_options)
end
desc 'proxy [CONTRACTS...]', 'Launches an intercepting proxy server for a set of contracts'
method_option :to, type: :string, desc: 'The target host for forwarded requests'
method_option :port, type: :numeric, desc: 'The port to listen on', default: 3000
method_option :spy, type: :boolean, desc: 'Display traffic received by Pacto'
def proxy(*_contracts)
setup_interrupt
server_options = @options.dup
server_options[:live] = true
Pacto::Server::HTTP.run('0.0.0.0', options.port, server_options)
end
private
def setup_interrupt
trap('INT') do
say 'Exiting...'
exit
end
end
end
end
end
================================================
FILE: lib/pacto/server/config.rb
================================================
# -*- encoding : utf-8 -*-
Pacto::Server::Settings::OptionHandler.new(port, logger, config).handle(options)
================================================
FILE: lib/pacto/server/proxy.rb
================================================
module Pacto
module Server
module Proxy
def proxy_request(pacto_request)
prepare_to_forward(pacto_request)
pacto_response = forward(pacto_request)
prepare_to_respond(pacto_response)
pacto_response.body = rewrite(pacto_response.body)
pacto_response
end
def prepare_to_forward(pacto_request)
host = host_for(pacto_request)
fail 'Could not determine request host' if host.nil?
host.gsub!('.dev', '.com') if settings[:strip_dev]
scheme, host = host.split('://')
host, scheme = scheme, host if host.nil?
host, _port = host.split(':')
scheme ||= 'https'
pacto_request.uri = Addressable::URI.heuristic_parse("#{scheme}://#{host}#{pacto_request.uri}")
# FIXME: We're stripping accept-encoding and transfer-encoding rather than dealing with the encodings
pacto_request.headers.delete_if { |k, _v| %w(host content-length accept-encoding transfer-encoding).include? k.downcase }
end
def rewrite(body)
return body unless settings[:strip_dev]
# FIXME: This is pretty hacky and needs to be rethought, but here to support hypermedia APIs
# This rewrites the response body so that URLs that may link to other services are rewritten
# to also passs through the Pacto server.
body.gsub('.com', ".dev:#{settings[:port]}").gsub(/https\:([\w\-\.\\\/]+).dev/, 'http:\1.dev')
end
def forward(pacto_request)
Pacto::Consumer::FaradayDriver.new.execute(pacto_request)
end
def prepare_to_respond(pacto_response)
pacto_response.headers.delete_if { |k, _v| %w(connection content-encoding content-length transfer-encoding).include? k.downcase }
end
private
def host_for(pacto_request)
# FIXME: Need case insensitive fetch for headers
pacto_request.uri.site || pacto_request.headers.find { |key, _| key.downcase == 'host' }[1]
end
end
end
end
================================================
FILE: lib/pacto/server/settings.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Server
module Settings
def options_parser(opts, options) # rubocop:disable MethodLength
options[:format] ||= :legacy
options[:strict] ||= false
options[:directory] ||= File.expand_path('contracts', @original_pwd)
options[:config] ||= File.expand_path('../config.rb', __FILE__)
options[:stenographer_log_file] ||= File.expand_path('pacto_stenographer.log', @original_pwd)
options[:strip_port] ||= true
opts.on('-l', '--live', 'Send requests to live services (instead of stubs)') { |_val| options[:live] = true }
opts.on('-f', '--format FORMAT', 'Contract format') { |val| options[:format] = val }
opts.on('--stub', 'Stub responses based on contracts') { |_val| options[:stub] = true }
opts.on('-g', '--generate', 'Generate Contracts from requests') { |_val| options[:generate] = true }
opts.on('-V', '--validate', 'Validate requests/responses against Contracts') { |_val| options[:validate] = true }
opts.on('-m', '--match-strict', 'Enforce strict request matching rules') { |_val| options[:strict] = true }
opts.on('-x', '--contracts_dir DIR', 'Directory that contains the contracts to be registered') { |val| options[:directory] = File.expand_path(val, @original_pwd) }
opts.on('-H', '--host HOST', 'Host of the real service, for generating or validating live requests') { |val| options[:backend_host] = val }
opts.on('-r', '--recursive-loading', 'Load contracts from folders named after the host to be stubbed') { |_val| options[:recursive_loading] = true }
opts.on('--strip-port', 'Strip the port from the request URI to build the proxied URI') { |_val| options[:strip_port] = true }
opts.on('--strip-dev', 'Strip .dev from the request domain to build the proxied URI') { |_val| options[:strip_dev] = true }
opts.on('--stenographer-log-file', 'Location for the stenographer log file') { |val| options[:stenographer_log_file] = val }
opts.on('--log-level [LEVEL]', [:debug, :info, :warn, :error, :fatal], 'Pacto log level ( debug, info, warn, error or fatal)') { |val| options[:pacto_log_level] = val }
end
class OptionHandler
attr_reader :port, :logger, :config, :options
def initialize(port, logger, config = {})
@port, @logger, @config = port, logger, config
end
def token_map
if File.readable? '.tokens.json'
MultiJson.load(File.read '.tokens.json')
else
{}
end
end
def prepare_contracts(contracts)
contracts.stub_providers if options[:stub]
end
def handle(options) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@options = options
config[:backend_host] = options[:backend_host]
config[:strip_port] = options[:strip_port]
config[:strip_dev] = options[:strip_dev]
config[:port] = port
contracts_path = options[:directory] || File.expand_path('contracts', Dir.pwd)
Pacto.configure do |pacto_config|
pacto_config.logger = options[:pacto_logger] || logger
pacto_config.loggerl.log_level = config[:pacto_log_level] if config[:pacto_log_level]
pacto_config.contracts_path = contracts_path
pacto_config.strict_matchers = options[:strict]
pacto_config.generator_options = {
schema_version: :draft3,
token_map: token_map
}
pacto_config.stenographer_log_file = options[:stenographer_log_file]
end
if options[:generate]
Pacto.generate!
logger.info 'Pacto generation mode enabled'
end
if options[:recursive_loading]
Dir["#{contracts_path}/*"].each do |host_dir|
host = File.basename host_dir
prepare_contracts Pacto.load_contracts(host_dir, "https://#{host}", options[:format])
end
else
host_pattern = options[:backend_host] || '{scheme}://{server}'
if File.exist? contracts_path
prepare_contracts Pacto.load_contracts(contracts_path, host_pattern, options[:format])
end
end
Pacto.validate! if options[:validate]
if options[:live]
# WebMock.reset!
WebMock.allow_net_connect!
end
config
end
end
end
end
end
================================================
FILE: lib/pacto/server.rb
================================================
# -*- encoding : utf-8 -*-
require 'reel'
require 'pacto'
require_relative 'server/settings'
require_relative 'server/proxy'
module Pacto
module Server
class HTTP < Reel::Server::HTTP
attr_reader :settings, :logger
include Proxy
def initialize(host = '127.0.0.1', port = 3000, options = {})
@logger = options[:pacto_logger] || Pacto.configuration.logger
@settings = Settings::OptionHandler.new(port, @logger).handle(options)
logger.info "Pacto Server starting on #{host}:#{port}"
super(host, port, spy: options[:spy], &method(:on_connection))
end
def on_connection(connection)
# Support multiple keep-alive requests per connection
connection.each_request do |reel_request|
begin
pacto_request = # exclusive do
Pacto::PactoRequest.new(
headers: reel_request.headers, body: reel_request.read,
method: reel_request.method, uri: Addressable::URI.heuristic_parse(reel_request.uri)
)
# end
pacto_response = proxy_request(pacto_request)
reel_response = ::Reel::Response.new(pacto_response.status, pacto_response.headers, pacto_response.body)
reel_request.respond(reel_response)
rescue WebMock::NetConnectNotAllowedError, Faraday::ConnectionFailed => e
reel_request.respond 502, e.message
rescue => e
reel_request.respond 500, Pacto::Errors.formatted_trace(e)
end
end
end
end
end
end
================================================
FILE: lib/pacto/stubs/uri_pattern.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class UriPattern
class << self
def for(request, strict = Pacto.configuration.strict_matchers)
fail_deprecations(request)
build_template_uri_pattern(request, strict)
end
def build_template_uri_pattern(request, strict)
path = request.path.respond_to?(:pattern) ? request.path.pattern : request.path
host = request.host
host ||= '{server}'
scheme, host = host.split('://') if host.include?('://')
scheme ||= '{scheme}'
if strict
Addressable::Template.new("#{scheme}://#{host}#{path}")
else
Addressable::Template.new("#{scheme}://#{host}#{path}{?anyvars*}")
end
end
def fail_deprecations(request)
return if request.path.is_a? Addressable::Template
return if request.path == (corrected_path = request.path.gsub(/\/:(\w+)/, '/{\\1}'))
fail "please change path #{request.path} to uri template: #{corrected_path} - old syntax no longer supported"
end
end
end
end
================================================
FILE: lib/pacto/stubs/webmock_adapter.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Adapters
module WebMock
class PactoRequest < Pacto::PactoRequest
extend Forwardable
def_delegators :@webmock_request_signature, :headers, :method, :body, :uri, :to_s, :inspect
def initialize(webmock_request_signature)
@webmock_request_signature = webmock_request_signature
end
def params
@webmock_request_signature.uri.query_values
end
def path
@webmock_request_signature.uri.path
end
end
class PactoResponse < Pacto::PactoResponse
extend Forwardable
def_delegators :@webmock_response, :body, :body=, :headers=, :status=, :to_s, :inspect
def initialize(webmock_response)
@webmock_response = webmock_response
end
def headers
@webmock_response.headers || {}
end
def status
status, _ = @webmock_response.status
status
end
end
end
end
module Stubs
class WebMockAdapter
include Resettable
def initialize(middleware)
@middleware = middleware
WebMock.after_request do |webmock_request_signature, webmock_response|
process_hooks webmock_request_signature, webmock_response
end
end
def stub_request!(contract)
request_clause = contract.request
uri_pattern = UriPattern.for(request_clause)
stub = WebMock.stub_request(request_clause.http_method, uri_pattern)
if Pacto.configuration.strict_matchers
with_opts = strict_details(request_clause)
stub.request_pattern.with(with_opts) unless with_opts.empty?
end
stub.to_return do |request|
pacto_request = Pacto::Adapters::WebMock::PactoRequest.new request
response = contract.response_for pacto_request
{
status: response.status,
headers: response.headers,
body: format_body(response.body)
}
end
end
def self.reset!
WebMock.reset!
WebMock.reset_callbacks
end
def process_hooks(webmock_request_signature, webmock_response)
pacto_request = Pacto::Adapters::WebMock::PactoRequest.new webmock_request_signature
pacto_response = Pacto::Adapters::WebMock::PactoResponse.new webmock_response
@middleware.process pacto_request, pacto_response
end
private
def format_body(body)
if body.is_a?(Hash) || body.is_a?(Array)
body.to_json
else
body
end
end
def strict_details(request)
{}.tap do |details|
details[webmock_params_key(request)] = request.params unless request.params.empty?
details[:headers] = request.headers unless request.headers.empty?
end
end
def webmock_params_key(request)
request.http_method == :get ? :query : :body
end
end
end
end
================================================
FILE: lib/pacto/test_helper.rb
================================================
# -*- encoding : utf-8 -*-
begin
require 'pacto'
require 'pacto/server'
rescue LoadError
raise 'pacto/test_helper requires the pacto-server gem'
end
module Pacto
module TestHelper
DEFAULT_ARGS = {
stdout: true,
log_file: 'pacto.log',
# :config => 'pacto/config/pacto_server.rb',
strict: false,
stub: true,
live: false,
generate: false,
verbose: true,
validate: true,
directory: File.join(Dir.pwd, 'contracts'),
port: 9000,
format: :legacy,
stenographer_log_file: File.expand_path('pacto_stenographer.log', Dir.pwd),
strip_port: true
}
def with_pacto(args = {})
start_index = ::Pacto::InvestigationRegistry.instance.investigations.size
::Pacto::InvestigationRegistry.instance.investigations.clear
args = DEFAULT_ARGS.merge(args)
args[:spy] = args[:verbose]
server = Pacto::Server::HTTP.supervise('0.0.0.0', args[:port], args)
yield "http://localhost:#{args[:port]}"
::Pacto::InvestigationRegistry.instance.investigations[start_index, -1]
ensure
server.terminate unless server.nil?
end
end
end
================================================
FILE: lib/pacto/ui.rb
================================================
# -*- encoding : utf-8 -*-
require 'thor'
module Pacto
module UI
# Colors for HTTP Methods, intended to match colors of Swagger-UI (as close as possible with ANSI Colors)
METHOD_COLORS = {
'POST' => :green,
'PUT' => :yellow,
'DELETE' => :red,
'GET' => :blue,
'PATCH' => :yellow,
'HEAD' => :green
}
def self.shell
@shell ||= Thor::Shell::Color.new
end
def self.deprecation(msg)
$stderr.puts colorize(msg, :yellow) unless Pacto.configuration.hide_deprecations
end
def self.colorize(msg, color)
return msg unless Pacto.configuration.color
shell.set_color(msg, color)
end
def self.colorize_method(method)
method_string = method.to_s.upcase
color = METHOD_COLORS[method_string] || :red # red for unknown methods
colorize(method_string, color)
end
end
end
================================================
FILE: lib/pacto/uri.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class URI
def self.for(host, path, params = {})
Addressable::URI.heuristic_parse("#{host}#{path}").tap do |uri|
uri.query_values = params unless params.nil? || params.empty?
end
end
end
end
================================================
FILE: lib/pacto/version.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
VERSION = '0.4.0.rc3'
end
================================================
FILE: lib/pacto.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/version'
require 'addressable/template'
require 'swagger'
require 'middleware'
require 'faraday'
require 'multi_json'
require 'json-schema'
require 'json-generator'
require 'webmock'
require 'ostruct'
require 'erb'
require 'logger'
# FIXME: There's soo much stuff here! I'd both like to re-roganize and to use autoloading.
require 'pacto/errors'
require 'pacto/dash'
require 'pacto/resettable'
require 'pacto/logger'
require 'pacto/ui'
require 'pacto/request_pattern'
require 'pacto/core/http_middleware'
require 'pacto/consumer/faraday_driver'
require 'pacto/actor'
require 'pacto/consumer'
require 'pacto/provider'
require 'pacto/actors/json_generator'
require 'pacto/actors/from_examples'
require 'pacto/body_parsing'
require 'pacto/core/pacto_request'
require 'pacto/core/pacto_response'
require 'pacto/core/contract_registry'
require 'pacto/core/investigation_registry'
require 'pacto/core/configuration'
require 'pacto/core/modes'
require 'pacto/core/hook'
require 'pacto/extensions'
require 'pacto/request_clause'
require 'pacto/response_clause'
require 'pacto/stubs/webmock_adapter'
require 'pacto/stubs/uri_pattern'
require 'pacto/contract'
require 'pacto/cops'
require 'pacto/meta_schema'
require 'pacto/contract_factory'
require 'pacto/investigation'
require 'pacto/hooks/erb_hook'
require 'pacto/observers/stenographer'
require 'pacto/generator'
require 'pacto/contract_files'
require 'pacto/contract_set'
require 'pacto/uri'
# Cops
require 'pacto/cops/body_cop'
require 'pacto/cops/request_body_cop'
require 'pacto/cops/response_body_cop'
require 'pacto/cops/response_status_cop'
require 'pacto/cops/response_header_cop'
module Pacto
class << self
def configuration
@configuration ||= Configuration.new
end
def contract_registry
@registry ||= ContractRegistry.new
end
# Resets data and metrics only. It usually makes sense to call this between test scenarios.
def reset
Pacto::InvestigationRegistry.instance.reset!
# Pacto::Resettable.reset_all
end
# Resets but also clears configuration, loaded contracts, and plugins.
def clear!
Pacto::Resettable.reset_all
@modes = nil
@configuration = nil
@registry = nil
end
def configure
yield(configuration)
end
def contracts_for(request_signature)
contract_registry.contracts_for(request_signature)
end
# @throws Pacto::InvalidContract
def validate_contract(contract)
Pacto::MetaSchema.new.validate contract
true
end
def load_contract(contract_path, host, format = :legacy)
load_contracts(contract_path, host, format).first
end
def load_contracts(contracts_path, host, format = :legacy)
contracts = ContractFactory.load_contracts(contracts_path, host, format)
contracts.each do |contract|
contract_registry.register(contract)
end
ContractSet.new(contracts)
end
end
end
================================================
FILE: pacto-server.gemspec
================================================
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'pacto/version'
Gem::Specification.new do |gem|
gem.name = 'pacto-server'
gem.version = Pacto::VERSION
gem.authors = ['ThoughtWorks']
gem.email = ['pacto-gem@googlegroups.com']
gem.description = "Pacto Server let's you run Pacto as a standalone server to arbitrate contract disputes between a service provider and one or more consumers in any programming language. It's Pacto beyond Ruby"
gem.summary = 'Polyglot Integration Contract Testing server'
gem.homepage = 'http://thoughtworks.github.io/pacto/'
gem.license = 'MIT'
gem.files = `git ls-files -- bin/pacto-server lib/pacto/server.rb lib/pacto/server`.split($/) # rubocop:disable SpecialGlobalVars
gem.executables = gem.files.grep(/^bin\//).map { |f| File.basename(f) }
gem.test_files = gem.files.grep(/^(test|spec|features)\//)
gem.require_paths = ['lib']
gem.add_dependency 'pacto', Pacto::VERSION
gem.add_dependency 'reel', '~> 0.5'
end
================================================
FILE: pacto.gemspec
================================================
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'pacto/version'
plugin_files = Dir['pacto-*.gemspec'].map do |gemspec|
eval(File.read(gemspec)).files # rubocop:disable Eval
end.flatten.uniq
Gem::Specification.new do |gem|
gem.name = 'pacto'
gem.version = Pacto::VERSION
gem.authors = ['ThoughtWorks & Abril']
gem.email = ['pacto-gem@googlegroups.com']
gem.description = 'Pacto is a judge that arbitrates contract disputes between a service provider and one or more consumers. In other words, it is a framework for Integration Contract Testing, and helps guide service evolution patterns like Consumer-Driven Contracts or Documentation-Driven Contracts.'
gem.summary = 'Integration Contract Testing framework'
gem.homepage = 'http://thoughtworks.github.io/pacto/'
gem.license = 'MIT'
gem.files = `git ls-files`.split($/) - plugin_files # rubocop:disable SpecialGlobalVars
gem.executables = gem.files.grep(/^bin\//).map { |f| File.basename(f) }
gem.test_files = gem.files.grep(/^(test|spec|features)\//)
gem.require_paths = ['lib']
gem.add_dependency 'webmock', '~> 1.18'
gem.add_dependency 'swagger-core', '~> 0.2', '>= 0.2.1'
gem.add_dependency 'middleware', '~> 0.1'
gem.add_dependency 'multi_json', '~> 1.8'
gem.add_dependency 'json-schema', '~> 2.0'
gem.add_dependency 'json-generator', '~> 0.0', '>= 0.0.5'
gem.add_dependency 'hashie', '~> 3.0'
gem.add_dependency 'faraday', '~> 0.9'
gem.add_dependency 'addressable', '~> 2.3'
gem.add_dependency 'json-schema-generator', '~> 0.0', '>= 0.0.7'
gem.add_dependency 'thor', '~> 0.19'
gem.add_development_dependency 'polytrix', '~> 0.1', '>= 0.1.4'
gem.add_development_dependency 'coveralls', '~> 0'
gem.add_development_dependency 'fabrication', '~> 2.11'
gem.add_development_dependency 'rake', '~> 10.0'
gem.add_development_dependency 'rake-notes', '~> 0'
gem.add_development_dependency 'rspec', '~> 3.0'
gem.add_development_dependency 'aruba', '~> 0'
gem.add_development_dependency 'json_spec', '~> 1.0'
# Only required to push documentation, and not easily installed on Windows
# gem.add_development_dependency 'relish'
gem.add_development_dependency 'guard-rspec', '~> 4.2'
# FIXME: Rubocop upgrade needed... rubocop -a will do most of the work
gem.add_development_dependency 'rubocop', '~> 0.23', '< 0.27.0'
gem.add_development_dependency 'rubocop-rspec', '~> 1.0.rc3'
gem.add_development_dependency 'guard-rubocop', '~> 1.0'
gem.add_development_dependency 'guard-cucumber', '~> 1.4'
gem.add_development_dependency 'rb-fsevent', '~> 0' if RUBY_PLATFORM =~ /darwin/i
gem.add_development_dependency 'terminal-notifier-guard', '~> 1.5' if RUBY_PLATFORM =~ /darwin/i
end
================================================
FILE: resources/contract_schema.json
================================================
{
"title": "Example Schema",
"type": "object",
"required": ["request", "response"],
"definitions": {
"subschema": {
"anyOf": [
{ "$ref": "http://json-schema.org/draft-03/schema#" },
{ "$ref": "http://json-schema.org/draft-04/schema#" }
]
}
},
"properties": {
"name": {
"type": "string"
},
"request": {
"type": "object",
"required": ["path"],
"properties": {
"method": {
"_deprecated": true,
"type": "string"
},
"http_method": {
"type": "string"
},
"path": {
"type": "string"
},
"headers": {
"type": "object"
},
"params": {
"type": "object"
},
"body": {
"description": "body is deprecated, use schema",
"$ref": "#/definitions/subschema"
},
"schema": {
"$ref": "#/definitions/subschema"
}
}
},
"response": {
"type": "object",
"required": ["status"],
"properties": {
"status":{
"type": "integer"
},
"body": {
"description": "body is deprecated, use schema",
"$ref": "#/definitions/subschema"
},
"schema": {
"$ref": "#/definitions/subschema"
}
}
},
"examples": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["request", "response"],
"properties": {
"request": {
},
"response": {
}
}
}
}
}
}
================================================
FILE: resources/draft-03.json
================================================
{
"$schema" : "http://json-schema.org/draft-03/schema#",
"id" : "http://json-schema.org/draft-03/schema#",
"type" : "object",
"properties" : {
"type" : {
"type" : ["string", "array"],
"items" : {
"type" : ["string", {"$ref" : "#"}]
},
"uniqueItems" : true,
"default" : "any"
},
"properties" : {
"type" : "object",
"additionalProperties" : {"$ref" : "#"},
"default" : {}
},
"patternProperties" : {
"type" : "object",
"additionalProperties" : {"$ref" : "#"},
"default" : {}
},
"additionalProperties" : {
"type" : [{"$ref" : "#"}, "boolean"],
"default" : {}
},
"items" : {
"type" : [{"$ref" : "#"}, "array"],
"items" : {"$ref" : "#"},
"default" : {}
},
"additionalItems" : {
"type" : [{"$ref" : "#"}, "boolean"],
"default" : {}
},
"required" : {
"type" : "boolean",
"default" : false
},
"dependencies" : {
"type" : "object",
"additionalProperties" : {
"type" : ["string", "array", {"$ref" : "#"}],
"items" : {
"type" : "string"
}
},
"default" : {}
},
"minimum" : {
"type" : "number"
},
"maximum" : {
"type" : "number"
},
"exclusiveMinimum" : {
"type" : "boolean",
"default" : false
},
"exclusiveMaximum" : {
"type" : "boolean",
"default" : false
},
"minItems" : {
"type" : "integer",
"minimum" : 0,
"default" : 0
},
"maxItems" : {
"type" : "integer",
"minimum" : 0
},
"uniqueItems" : {
"type" : "boolean",
"default" : false
},
"pattern" : {
"type" : "string",
"format" : "regex"
},
"minLength" : {
"type" : "integer",
"minimum" : 0,
"default" : 0
},
"maxLength" : {
"type" : "integer"
},
"enum" : {
"type" : "array",
"minItems" : 1,
"uniqueItems" : true
},
"default" : {
"type" : "any"
},
"title" : {
"type" : "string"
},
"description" : {
"type" : "string"
},
"format" : {
"type" : "string"
},
"divisibleBy" : {
"type" : "number",
"minimum" : 0,
"exclusiveMinimum" : true,
"default" : 1
},
"disallow" : {
"type" : ["string", "array"],
"items" : {
"type" : ["string", {"$ref" : "#"}]
},
"uniqueItems" : true
},
"extends" : {
"type" : [{"$ref" : "#"}, "array"],
"items" : {"$ref" : "#"},
"default" : {}
},
"id" : {
"type" : "string",
"format" : "uri"
},
"$ref" : {
"type" : "string",
"format" : "uri"
},
"$schema" : {
"type" : "string",
"format" : "uri"
}
},
"dependencies" : {
"exclusiveMinimum" : "minimum",
"exclusiveMaximum" : "maximum"
},
"default" : {}
}
================================================
FILE: resources/draft-04.json
================================================
{
"id": "http://json-schema.org/draft-04/schema#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Core schema meta-schema",
"definitions": {
"schemaArray": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#" }
},
"positiveInteger": {
"type": "integer",
"minimum": 0
},
"positiveIntegerDefault0": {
"allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ]
},
"simpleTypes": {
"enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ]
},
"stringArray": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"uniqueItems": true
}
},
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uri"
},
"$schema": {
"type": "string",
"format": "uri"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"default": {},
"multipleOf": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": true
},
"maximum": {
"type": "number"
},
"exclusiveMaximum": {
"type": "boolean",
"default": false
},
"minimum": {
"type": "number"
},
"exclusiveMinimum": {
"type": "boolean",
"default": false
},
"maxLength": { "$ref": "#/definitions/positiveInteger" },
"minLength": { "$ref": "#/definitions/positiveIntegerDefault0" },
"pattern": {
"type": "string",
"format": "regex"
},
"additionalItems": {
"anyOf": [
{ "type": "boolean" },
{ "$ref": "#" }
],
"default": {}
},
"items": {
"anyOf": [
{ "$ref": "#" },
{ "$ref": "#/definitions/schemaArray" }
],
"default": {}
},
"maxItems": { "$ref": "#/definitions/positiveInteger" },
"minItems": { "$ref": "#/definitions/positiveIntegerDefault0" },
"uniqueItems": {
"type": "boolean",
"default": false
},
"maxProperties": { "$ref": "#/definitions/positiveInteger" },
"minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" },
"required": { "$ref": "#/definitions/stringArray" },
"additionalProperties": {
"anyOf": [
{ "type": "boolean" },
{ "$ref": "#" }
],
"default": {}
},
"definitions": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"properties": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"patternProperties": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"dependencies": {
"type": "object",
"additionalProperties": {
"anyOf": [
{ "$ref": "#" },
{ "$ref": "#/definitions/stringArray" }
]
}
},
"enum": {
"type": "array",
"minItems": 1,
"uniqueItems": true
},
"type": {
"anyOf": [
{ "$ref": "#/definitions/simpleTypes" },
{
"type": "array",
"items": { "$ref": "#/definitions/simpleTypes" },
"minItems": 1,
"uniqueItems": true
}
]
},
"allOf": { "$ref": "#/definitions/schemaArray" },
"anyOf": { "$ref": "#/definitions/schemaArray" },
"oneOf": { "$ref": "#/definitions/schemaArray" },
"not": { "$ref": "#" }
},
"dependencies": {
"exclusiveMaximum": [ "maximum" ],
"exclusiveMinimum": [ "minimum" ]
},
"default": {}
}
================================================
FILE: sample_apis/album/cover_api.rb
================================================
# -*- encoding : utf-8 -*-
module AlbumServices
class Cover < Grape::API
format :json
desc 'Ping'
namespace :album do
get ':id/cover' do
{ cover: 'image' }
end
end
end
end
================================================
FILE: sample_apis/config.ru
================================================
require 'grape'
require 'grape-swagger'
require 'json'
Dir[File.expand_path('../**/*_api.rb', __FILE__)].each do |f|
puts "Requiring #{f}"
require f
end
module DummyServices
class API < Grape::API
prefix 'api'
format :json
mount DummyServices::Hello
mount DummyServices::Ping
mount DummyServices::Echo
mount DummyServices::Files
mount DummyServices::Reverse
mount AlbumServices::Cover
add_swagger_documentation # api_version: 'v1'
end
end
DummyServices::API.routes.each do |route|
p route
end
run DummyServices::API
================================================
FILE: sample_apis/echo_api.rb
================================================
# -*- encoding : utf-8 -*-
# This illustrates simple get w/ params and post w/ body services
# It also illustrates having two services w/ the same endpoint (just different HTTP methods)
module DummyServices
class Echo < Grape::API
format :json
content_type :txt, 'text/plain'
helpers do
def echo(message)
error!('Bad Request', 400) unless message
message
end
end
# curl localhost:5000/api/echo --get --data-urlencode 'msg={"one fish": "two fish"}' -vv
get '/echo' do
echo params[:msg]
end
# curl localhost:5000/api/echo -H 'Content-Type: text/plain' -d '{"red fish": "blue fish"}' -vv
post '/echo' do
echo env['api.request.body']
end
end
end
================================================
FILE: sample_apis/files_api.rb
================================================
# -*- encoding : utf-8 -*-
# This example should illustrate
# - Authentication
# - Expect: 100-continue
# - Binary data
# - Content negotiation
# - Etags
# - Collections
module DummyServices
class PartialRequestException < StandardError
attr_reader :http_status, :msg
def initialize(http_status, msg)
@http_status = http_status
@msg = msg
end
end
class Files < Grape::API
format :json
content_type :binary, 'application/octet-stream'
content_type :pdf, 'application/pdf'
before do
error!('Unauthorized', 401) unless env['HTTP_X_AUTH_TOKEN'] == '12345'
if env['HTTP_EXPECT'] == '100-continue'
# Can't use Content-Type because Grape tries to handle it, causing problems
case env['CONTENT_TYPE']
when 'application/pdf'
fail DummyServices::PartialRequestException.new(100, 'Continue')
when 'application/webm'
fail DummyServices::PartialRequestException.new(415, 'Unsupported Media Type')
else
fail DummyServices::PartialRequestException.new(417, 'Expectation Failed')
end
end
end
rescue_from DummyServices::PartialRequestException do |e|
Rack::Response.new([], e.http_status, {}).finish
end
namespace '/files' do
# curl localhost:5000/api/files/myfile.txt -H 'X-Auth-Token: 12345' -d @myfile.txt -vv
put ':name' do
params[:name]
end
end
end
end
================================================
FILE: sample_apis/hello_api.rb
================================================
# -*- encoding : utf-8 -*-
# This illustrates a simple get service
module DummyServices
class Hello < Grape::API
format :json
content_type :json, 'application/json'
desc 'Hello'
get '/hello' do
header 'Vary', 'Accept'
{ message: 'Hello World!' }
end
end
end
================================================
FILE: sample_apis/ping_api.rb
================================================
# -*- encoding : utf-8 -*-
# This illustrates a simple get service
module DummyServices
class Ping < Grape::API
format :json
desc 'Ping'
get '/ping' do
{ ping: 'pong' }
end
end
end
================================================
FILE: sample_apis/reverse_api.rb
================================================
# -*- encoding : utf-8 -*-
# This illustrates simple get w/ params and post w/ body services
# It also illustrates having two services w/ the same endpoint (just different HTTP methods)
module DummyServices
class Reverse < Grape::API
format :txt
helpers do
def echo(message)
error!('Bad Request', 400) unless message
message
end
end
# curl localhost:5000/api/echo -H 'Content-Type: application/json' -d '{"red fish": "blue fish"}' -vv
post '/reverse' do
echo(env['api.request.body']).reverse
end
end
end
================================================
FILE: sample_apis/user_api.rb
================================================
# -*- encoding : utf-8 -*-
# A siple JSON service to demonstrate request/response bodies
require 'securerandom'
module DummyServices
class Echo < Grape::API
format :json
post '/users' do
user = env['api.request.body']
user[:id] = SecureRandom.uuid
user
end
end
end
================================================
FILE: samples/README.md
================================================
Welcome to the Pacto usage samples!
We have a listing of [sample contracts](contracts/README.html).
Highlighted samples:
- *[Configuration](configuration.html)*: Shows the available Pacto configuration
- *[Generation](generation.html)*: Shows how to generate Contracts
- *[RSpec](rspec.html)*: Shows the usage of RSpec expectations for collaboration tests
See the Table of Contents (upper right corner) for a full list of available samples.
================================================
FILE: samples/Rakefile
================================================
require 'pacto/rake_task' # FIXME: This require turns on WebMock
WebMock.allow_net_connect!
================================================
FILE: samples/configuration.rb
================================================
# -*- encoding : utf-8 -*-
# Just require pacto to add it to your project.
require 'pacto'
# Pacto will disable live connections, so you will get an error if
# your code unexpectedly calls an service that was not stubbed. If you
# want to re-enable connections, run `WebMock.allow_net_connect!`
WebMock.allow_net_connect!
# Pacto can be configured via a block:
Pacto.configure do |c|
# Path for loading/storing contracts.
c.contracts_path = 'contracts'
# If the request matching should be strict (especially regarding HTTP Headers).
c.strict_matchers = true
# You can set the Ruby Logger used by Pacto.
c.logger = Pacto::Logger::SimpleLogger.instance
# (Deprecated) You can specify a callback for post-processing responses. Note that only one hook
# can be active, and specifying your own will disable ERB post-processing.
c.register_hook do |_contracts, request, _response|
puts "Received #{request}"
end
# Options to pass to the [json-schema-generator](https://github.com/maxlinc/json-schema-generator) while generating contracts.
c.generator_options = { schema_version: 'draft3' }
end
# You can also do inline configuration. This example tells the json-schema-generator to store default values in the schema.
Pacto.configuration.generator_options = { defaults: true }
# If you're using Pacto's rspec matchers you might want to configure a reset between each scenario
require 'pacto/rspec'
RSpec.configure do |c|
c.after(:each) { Pacto.clear! }
end
================================================
FILE: samples/consumer.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto'
Pacto.load_contracts 'contracts', 'http://localhost:5000'
WebMock.allow_net_connect!
interactions = Pacto.simulate_consumer :my_client do
request 'Ping'
request 'Echo', body: ->(body) { body.reverse },
headers: (proc do |headers|
headers['Content-Type'] = 'text/json'
headers['Accept'] = 'none'
headers
end)
end
puts interactions
================================================
FILE: samples/contracts/README.md
================================================
This folder contains sample contracts.
================================================
FILE: samples/contracts/contract.js
================================================
// Pacto Contracts describe the constraints we want to put on interactions between a consumer and a provider. It sets some expectations about the headers expected for both the request and response, the expected response status code. It also uses [json-schema](http://json-schema.org/) to define the allowable request body (if one should exist) and response body.
{
// The Request section comes first. In this case, we're just describing a simple get request that does not require any parameters or a request body.
"request": {
"headers": {
// A request must exactly match these headers for Pacto to believe the request matches the contract, unless `Pacto.configuration.strict_matchers` is false.
"Accept": "application/vnd.github.beta+json",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3"
},
// The `method` and `path` are required. The `path` may be an [rfc6570 URI template](http://tools.ietf.org/html/rfc6570) for more flexible matching.
"method": "get",
"path": "/repos/thoughtworks/pacto/readme"
},
"response": {
"headers": {
"Content-Type": "application/json; charset=utf-8",
"Status": "200 OK",
"Cache-Control": "public, max-age=60, s-maxage=60",
"Etag": "\"fc8e78b0a9694de66d47317768b20820\"",
"Vary": "Accept, Accept-Encoding",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "ETag, Link, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval",
"Access-Control-Allow-Origin": "*"
},
"status": 200,
"body": {
"$schema": "http://json-schema.org/draft-03/schema#",
"description": "Generated from https://api.github.com/repos/thoughtworks/pacto/readme with shasum 3ae59164c6d9f84c0a81f21fb63e17b3b8ce6894",
"type": "object",
"required": true,
"properties": {
"name": {
"type": "string",
"required": true
},
"path": {
"type": "string",
"required": true
},
"sha": {
"type": "string",
"required": true
},
"size": {
"type": "integer",
"required": true
},
"url": {
"type": "string",
"required": true
},
"html_url": {
"type": "string",
"required": true
},
"git_url": {
"type": "string",
"required": true
},
"type": {
"type": "string",
"required": true
},
"content": {
"type": "string",
"required": true
},
"encoding": {
"type": "string",
"required": true
},
"_links": {
"type": "object",
"required": true,
"properties": {
"self": {
"type": "string",
"required": true
},
"git": {
"type": "string",
"required": true
},
"html": {
"type": "string",
"required": true
}
}
}
}
}
}
}
================================================
FILE: samples/contracts/get_album_cover.json
================================================
{
"request": {
"headers": {
},
"http_method": "get",
"path": "/api/album/{id}/cover"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"description": "Generated from http://localhost:5000/api/album/1/cover with shasum db640385d2b346db760dbfd78058101663197bcf",
"type": "object",
"required": true,
"properties": {
"cover": {
"type": "string",
"required": true
}
}
}
},
"examples": {
"default": {
"request": {
"method": "get",
"uri": "http://localhost:5000/api/album/1/cover",
"headers": {
"User-Agent": "Faraday v0.9.0",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Accept": "*/*"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json",
"Content-Length": "17"
},
"body": "{\"cover\":\"image\"}"
}
}
},
"name": "Get Album Cover"
}
================================================
FILE: samples/contracts/localhost/api/echo.json
================================================
{
"name": "Echo",
"request": {
"headers": {
"Content-Type": "text/plain"
},
"http_method": "post",
"path": "/api/echo",
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"oneOf": [
{ "type": "string", "required": true },
{ "type": "object", "required": true }
]
}
},
"response": {
"status": 201,
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"oneOf": [
{ "type": "string", "required": true },
{ "type": "object", "required": true }
]
}
},
"examples": {
"foo": {
"request": {
"body": "foo"
},
"response": {
"body": "foo"
}
}
}
}
================================================
FILE: samples/contracts/localhost/api/ping.json
================================================
{
"name": "Ping",
"request": {
"headers": {
},
"http_method": "get",
"path": "/api/ping"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"description": "Generated from http://localhost:9292/api/ping with shasum 2cf3478c18e3ce877fb823ed435cb75b4a801aaa",
"type": "object",
"required": true,
"properties": {
"ping": {
"type": "string",
"required": true
}
}
}
},
"examples": {
"default": {
"request": {
},
"response": {
"body": {
"ping": "pong - from the example!"
}
}
}
}
}
================================================
FILE: samples/contracts/user.json
================================================
{
"name": "User",
"request": {
"headers": {
"Content-Type": "application/json"
},
"http_method": "post",
"path": "/api/users",
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"properties": {
"firstName": {"type": "string", "required": true},
"lastName": {"type": "string", "required": true}
}
}
},
"response": {
"status": 201,
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"properties": {
"id": { "type": "string", "required": true },
"firstName": {"type": "string", "required": true},
"lastName": {"type": "string", "required": true}
}
}
},
"examples": {
"max": {
"request": {
"headers": {
"Content-Type": "application/json"
},
"body": {
"firstName": "Max",
"lastName": "Lincoln"
}
},
"response": {
"body": {
"id": "026ed411-6d12-4a76-a3c7-19758a872455",
"firstName": "Max",
"lastName": "Lincoln"
}
}
}
}
}
================================================
FILE: samples/cops.rb
================================================
# -*- encoding : utf-8 -*-
require 'rspec'
require 'rspec/autorun'
require 'pacto'
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
Pacto.validate!
# You can create a custom cop that investigates the request/response and sees if it complies with a
# contract. The cop should return a list of citations if it finds any problems.
class MyCustomCop
def investigate(_request, _response, contract)
citations = []
citations << 'Contract must have a request schema' if contract.request.schema.empty?
citations << 'Contract must have a response schema' if contract.response.schema.empty?
citations
end
end
Pacto::Cops.active_cops << MyCustomCop.new
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
contracts.stub_providers
puts contracts.simulate_consumers
# Or you can completely replace the default set of validators
Pacto::Cops.registered_cops.clear
Pacto::Cops.register_cop Pacto::Cops::ResponseBodyCop
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
puts contracts.simulate_consumers
================================================
FILE: samples/forensics.rb
================================================
# -*- encoding : utf-8 -*-
# Pacto has a few RSpec matchers to help you ensure a **consumer** and **producer** are
# interacting properly. First, let's setup the rspec suite.
require 'rspec/autorun' # Not generally needed
require 'pacto/rspec'
WebMock.allow_net_connect!
Pacto.validate!
Pacto.load_contracts('contracts', 'http://localhost:5000').stub_providers
# It's usually a good idea to reset Pacto between each scenario. `Pacto.reset` just clears the
# data and metrics about which services were called. `Pacto.clear!` also resets all configuration
# and plugins.
RSpec.configure do |c|
c.after(:each) { Pacto.reset }
end
# Pacto provides some RSpec matchers related to contract testing, like making sure
# Pacto didn't received any unrecognized requests (`have_unmatched_requests`) and that
# the HTTP requests matched up with the terms of the contract (`have_failed_investigations`).
describe Faraday do
let(:connection) { described_class.new(url: 'http://localhost:5000') }
it 'passes contract tests' do
connection.get '/api/ping'
expect(Pacto).to_not have_failed_investigations
expect(Pacto).to_not have_unmatched_requests
end
end
# There are also some matchers for collaboration testing, so you can make sure each scenario is
# calling the expected services and sending the right type of data.
describe Faraday do
let(:connection) { described_class.new(url: 'http://localhost:5000') }
before(:each) do
connection.get '/api/ping'
connection.post do |req|
req.url '/api/echo'
req.headers['Content-Type'] = 'application/json'
req.body = '{"foo": "bar"}'
end
end
it 'calls the ping service' do
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping').against_contract('Ping')
end
it 'sends data to the echo service' do
expect(Pacto).to have_investigated('Ping').with_response(body: hash_including('ping' => 'pong - from the example!'))
expect(Pacto).to have_investigated('Echo').with_request(body: hash_including('foo' => 'bar'))
echoed_body = { 'foo' => 'bar' }
expect(Pacto).to have_investigated('Echo').with_request(body: echoed_body).with_response(body: echoed_body)
end
end
================================================
FILE: samples/generation.rb
================================================
# -*- encoding : utf-8 -*-
# Some generation related [configuration](configuration.rb).
require 'pacto'
WebMock.allow_net_connect!
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
WebMock.allow_net_connect!
# Once we call `Pacto.generate!`, Pacto will record contracts for all requests it detects.
Pacto.generate!
# Now, if we run any code that makes an HTTP call (using an
# [HTTP library supported by WebMock](https://github.com/bblimke/webmock#supported-http-libraries))
# then Pacto will generate a Contract based on the HTTP request/response.
#
# This code snippet will generate a Contract and save it to `contracts/samples/contracts/localhost/api/ping.json`.
require 'faraday'
conn = Faraday.new(url: 'http://localhost:5000')
response = conn.get '/api/ping'
# We're getting back real data from GitHub, so this should be the actual file encoding.
puts response.body
# The generated contract will contain expectations based on the request/response we observed,
# including a best-guess at an appropriate json-schema. Our heuristics certainly aren't foolproof,
# so you might want to customize schema!
# Here's another sample that sends a post request.
conn.post do |req|
req.url '/api/echo'
req.headers['Content-Type'] = 'application/json'
req.body = '{"red fish": "blue fish"}'
end
# You can provide hints to Pacto to help it generate contracts. For example, Pacto doesn't have
# a good way to know a good name and correct URI template for the service. That means that Pacto
# will not know if two similar requests are for the same service or two different services, and
# will be forced to give names based on the URI that are not good display names.
# The hint below tells Pacto that requests to http://localhost:5000/album/1/cover and http://localhost:5000/album/2/cover
# are both going to the same service, which is known as "Get Album Cover". This hint will cause Pacto to
# generate a Contract for "Get Album Cover" and save it to `contracts/get_album_cover.json`, rather than two
# contracts that are stored at `contracts/localhost/album/1/cover.json` and `contracts/localhost/album/2/cover.json`.
Pacto::Generator.configure do |c|
c.hint 'Get Album Cover', http_method: :get, host: 'http://localhost:5000', path: '/api/album/{id}/cover'
end
conn.get '/api/album/1/cover'
conn.get '/api/album/2/cover'
================================================
FILE: samples/rake_tasks.sh
================================================
# # Rake tasks
# ## This is a test!
# [That](www.google.com) markdown works
bundle exec rake pacto:meta_validate['contracts']
bundle exec rake pacto:validate['http://localhost:5000','contracts']
================================================
FILE: samples/rspec.rb
================================================
# -*- encoding : utf-8 -*-
================================================
FILE: samples/samples.rb
================================================
# -*- encoding : utf-8 -*-
# # Overview
# Welcome to the Pacto usage samples!
# This document gives a quick overview of the main features.
#
# You can browse the Table of Contents (upper right corner) to view additional samples.
#
# In addition to this document, here are some highlighted samples:
#
# - Configuration: Shows all available configuration options
# - Generation: More details on generation
# - RSpec: More samples for RSpec expectations
#
# You can also find other samples using the Table of Content (upper right corner), including sample contracts.
# # Getting started
# Once you've installed the Pacto gem, you just require it. If you want, you can also require the Pacto rspec expectations.
require 'pacto'
require 'pacto/rspec'
# Pacto will disable live connections, so you will get an error if
# your code unexpectedly calls an service that was not stubbed. If you
# want to re-enable connections, run `WebMock.allow_net_connect!`
WebMock.allow_net_connect!
# Pacto can be configured via a block. The `contracts_path` option tells Pacto where it should load or save contracts. See the [Configuration](configuration.html) for all the available options.
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
# # Generating a Contract
# Calling `Pacto.generate!` enables contract generation.
# Pacto.generate!
# Now, if we run any code that makes an HTTP call (using an
# [HTTP library supported by WebMock](https://github.com/bblimke/webmock#supported-http-libraries))
# then Pacto will generate a Contract based on the HTTP request/response.
#
# We're using the sample APIs in the sample_apis directory.
require 'faraday'
conn = Faraday.new(url: 'http://localhost:5000')
response = conn.get '/api/ping'
# This is the real request, so you should see {"ping":"pong"}
puts response.body
# # Testing providers by simulating consumers
# The generated contract will contain expectations based on the request/response we observed,
# including a best-guess at an appropriate json-schema. Our heuristics certainly aren't foolproof,
# so you might want to modify the output!
# We can load the contract and validate it, by sending a new request and making sure
# the response matches the JSON schema. Obviously it will pass since we just recorded it,
# but if the service has made a change, or if you alter the contract with new expectations,
# then you will see a contract investigation message.
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
contracts.simulate_consumers
# # Stubbing providers for consumer testing
# We can also use Pacto to stub the service based on the contract.
contracts.stub_providers
# The stubbed data won't be very realistic, the default behavior is to return the simplest data
# that complies with the schema. That basically means that you'll have "bar" for every string.
response = conn.get '/api/ping'
# You're now getting stubbed data. You should see {"ping":"bar"} unless you recorded with
# the `defaults` option enabled, in which case you will still seee {"ping":"pong"}.
puts response.body
# # Collaboration tests with RSpec
# Pacto comes with rspec matchers
require 'pacto/rspec'
# It's probably a good idea to reset Pacto between each rspec scenario
RSpec.configure do |c|
c.after(:each) { Pacto.clear! }
end
# Load your contracts, and stub them if you'd like.
Pacto.load_contracts('contracts', 'http://localhost:5000').stub_providers
# You can turn on investigation mode so Pacto will detect and validate HTTP requests.
Pacto.validate!
describe 'my_code' do
it 'calls a service' do
conn = Faraday.new(url: 'http://localhost:5000')
response = conn.get '/api/ping'
# The have_validated matcher makes sure that Pacto received and successfully validated a request
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping')
end
end
================================================
FILE: samples/scripts/bootstrap
================================================
#!/bin/bash
bundle install
================================================
FILE: samples/scripts/wrapper
================================================
#!/bin/bash
# Polytrix should probably support different wrappers for different langauges
extension="${1##*.}"
if [ $extension = "rb" ];
then
bundle exec ruby "$@"
elif [ $extension = "sh" ];
then
bash "$@"
fi
================================================
FILE: samples/server.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/rspec'
require 'pacto/test_helper'
describe 'ping service' do
include Pacto::TestHelper
it 'pongs' do
with_pacto(
port: 6000,
backend_host: 'http://localhost:5000',
live: true,
stub: false,
generate: false,
directory: 'contracts'
) do |pacto_endpoint|
# call your code
system "curl #{pacto_endpoint}/api/ping"
end
# check citations
expect(Pacto).to have_validated(:get, 'http://localhost:5000/api/ping')
end
end
================================================
FILE: samples/server_cli.sh
================================================
# # Standalone server
# You can run Pacto as a server in order to test non-Ruby projects. In order to get the full set
# of options, run:
bundle exec pacto-server -h
# You probably want to run with the -sv option, which will display verbose output to stdout. You can
# run server that proxies to a live endpoint:
bundle exec pacto proxy --port 9000 --to http://example.com &
bundle exec pacto stub --port 9001 &
pkill -f 'pacto server'
================================================
FILE: samples/stenographer.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto'
Pacto.configure do |c|
c.contracts_path = 'contracts'
end
contracts = Pacto.load_contracts('contracts', 'http://localhost:5000')
contracts.stub_providers
Pacto.simulate_consumer do
request 'Echo', values: nil, response: { status: 200 } # 0 contract violations
request 'Ping', values: nil, response: { status: 200 } # 0 contract violations
request 'Unknown (http://localhost:8000/404)', values: nil, response: { status: 500 } # 0 contract violations
end
Pacto.simulate_consumer :my_consumer do
playback 'pacto_stenographer.log'
end
================================================
FILE: spec/coveralls_helper.rb
================================================
# -*- encoding : utf-8 -*-
unless ENV['NO_COVERAGE']
require 'simplecov'
require 'coveralls'
SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
SimpleCov::Formatter::HTMLFormatter,
Coveralls::SimpleCov::Formatter
]
SimpleCov.start
end
================================================
FILE: spec/fabricators/contract_fabricator.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto'
require 'hashie/mash'
# Fabricators for contracts or parts of contracts
unless defined? PACTO_DEFAULT_FORMAT
PACTO_DEFAULT_FORMAT = (ENV['PACTO_DEFAULT_FORMAT'] || 'legacy')
CONTRACT_CLASS = Pacto::Formats.const_get(PACTO_DEFAULT_FORMAT.capitalize).const_get('Contract')
REQUEST_CLAUSE_CLASS = Pacto::Formats.const_get(PACTO_DEFAULT_FORMAT.capitalize).const_get('RequestClause')
RESPONSE_CLAUSE_CLASS = Pacto::Formats.const_get(PACTO_DEFAULT_FORMAT.capitalize).const_get('ResponseClause')
end
Fabricator(:contract, from: CONTRACT_CLASS) do
initialize_with { @_klass.new(to_hash.merge(skip_freeze: true)) } # Hash based initialization
transient example_count: 0
name { 'Dummy Contract' }
file { 'file:///does/not/exist/dummy_contract.json' }
request { Fabricate(:request_clause).to_hash }
response { Fabricate(:response_clause).to_hash }
examples do |attr|
example_count = attr[:example_count]
if example_count
examples = attr[:example_count].times.each_with_object({}) do |i, h|
name = i.to_s
h[name] = Fabricate(:an_example, name: name)
end
examples
else
nil
end
end
# after_save { | contract, _transients | contract.freeze }
end
Fabricator(:partial_contract, from: CONTRACT_CLASS) do
initialize_with { @_klass.new(to_hash.merge(skip_freeze: true)) } # Hash based initialization
name { 'Dummy Contract' }
file { 'file:///does/not/exist/dummy_contract.json' }
request { Fabricate(:request_clause).to_hash }
end
Fabricator(:request_clause, from: REQUEST_CLAUSE_CLASS) do
initialize_with { @_klass.new(to_hash.merge(skip_freeze: true)) } # Hash based initialization
host { 'example.com' }
http_method { 'GET' }
path { '/abcd' }
headers do
{
'Server' => ['example.com'],
'Connection' => ['Close'],
'Content-Length' => [1234],
'Via' => ['Some Proxy'],
'User-Agent' => ['rspec']
}
end
params {}
end
Fabricator(:response_clause, from: RESPONSE_CLAUSE_CLASS) do
initialize_with { @_klass.new(to_hash.merge(skip_freeze: true)) } # Hash based initialization
status { 200 }
headers do
{
'Content-Type' => 'application/json'
}
end
schema { Fabricate(:schema).to_hash }
end
Fabricator(:schema, from: Hashie::Mash) do
transient :version
initialize_with { @_klass.new to_hash } # Hash based initialization
type { 'object' }
required do |attrs|
attrs[:version] == :draft3 ? true : []
end
properties do
{
type: 'string'
}
end
end
Fabricator(:an_example, from: Hashie::Mash) do
initialize_with { @_klass.new to_hash } # Hash based initialization
transient name: 'default'
request do |attr|
{
body: {
message: "I am example request #{attr[:name]}"
}
}
end
response do |attr|
{
body: {
message: "I am example response #{attr[:name]}"
}
}
end
end
================================================
FILE: spec/fabricators/http_fabricator.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto'
require 'hashie/mash'
# Fabricators for Pacto objects representing HTTP transactions
Fabricator(:pacto_request, from: Pacto::PactoRequest) do
initialize_with { @_klass.new @_transient_attributes } # Hash based initialization
# These transient attributes turn into the URI
transient host: 'example.com'
transient path: '/abcd'
transient params: {}
method { :get }
uri do |attr|
Addressable::URI.heuristic_parse(attr[:host]).tap do |uri|
uri.path = attr[:path]
uri.query_values = attr[:params]
end
end
headers do
{
'Server' => ['example.com'],
'Connection' => ['Close'],
'Content-Length' => [1234],
'Via' => ['Some Proxy'],
'User-Agent' => ['rspec']
}
end
body do |attr|
case attr[:method]
when :get, :head, :options
nil
else
'{"data": "something"}'
end
end
end
Fabricator(:pacto_response, from: Pacto::PactoResponse) do
initialize_with { @_klass.new to_hash } # Hash based initialization
status { 200 }
headers do
{
'Content-Type' => 'application/json'
}
end
body { '' }
end
================================================
FILE: spec/fabricators/webmock_fabricator.rb
================================================
# -*- encoding : utf-8 -*-
# Fabricators for WebMock objects
Fabricator(:webmock_request_signature, from: WebMock::RequestSignature) do
initialize_with do
uri = _transient_attributes[:uri]
method = _transient_attributes[:method]
uri = Addressable::URI.heuristic_parse(uri) unless uri.is_a? Addressable::URI
WebMock::RequestSignature.new method, uri
end
transient method: :get
transient uri: 'www.example.com'
end
Fabricator(:webmock_request_pattern, from: Pacto::RequestPattern) do
initialize_with do
uri = _transient_attributes[:uri]
method = _transient_attributes[:method]
uri = Addressable::URI.heuristic_parse(uri) unless uri.is_a? Addressable::URI
Pacto::RequestPattern.new method, uri
end
transient method: :get
transient uri: 'www.example.com'
end
================================================
FILE: spec/fixtures/contracts/deprecated/deprecated_contract.json
================================================
{
"request": {
"method": "GET",
"path": "/hello/:id",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json", "Vary": "Accept" },
"body": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"required": true,
"properties": {
"message": { "type": "string", "required": true }
}
}
}
}
================================================
FILE: spec/fixtures/contracts/legacy/contract.json
================================================
{
"request": {
"http_method": "GET",
"path": "/hello_world",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"schema": {
"description": "A simple response",
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
================================================
FILE: spec/fixtures/contracts/legacy/contract_with_examples.json
================================================
{
"request": {
"headers": {
},
"http_method": "get",
"path": "/api/echo",
"schema": {
"type": "object",
"required": ["message"],
"properties": {
"message": {
"type": "string"
}
}
}
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"schema": {
"type": "object",
"required": ["message"],
"properties": {
"message": {
"type": "string"
}
}
}
},
"examples": {
"max": {
"request": {
"body": {
"message": "max"
}
},
"response": {
"body": {
"message": "max"
}
}
},
"12345": {
"request": {
"body": {
"message": 12345
}
},
"response": {
"body": {
"message": 12345
}
}
}
}
}
================================================
FILE: spec/fixtures/contracts/legacy/simple_contract.json
================================================
{
"name": "Simple Contract",
"request": {
"http_method": "GET",
"path": "/api/hello",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json", "Vary": "Accept" },
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"required": true,
"properties": {
"message": { "type": "string", "required": true }
}
}
}
}
================================================
FILE: spec/fixtures/contracts/legacy/strict_contract.json
================================================
{
"name": "Strict Contract",
"request": {
"http_method": "GET",
"path": "/strict",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"required": true,
"properties": {
"devices": {
"type": "array",
"minItems": 2,
"items": {
"type": "string",
"required": true,
"default": "/dev/<%= values[:device_id].tap do values[:device_id] = values[:device_id] + 1 end %>",
"pattern": "^/dev/[\\d]+$"
},
"required": true,
"uniqueItems": true
}
}
}
}
}
================================================
FILE: spec/fixtures/contracts/legacy/templating_contract.json
================================================
{
"request": {
"http_method": "GET",
"path": "/echo",
"headers": {
"Accept": "application/json",
"Custom-Auth-Token": "<%= auth_token %>",
"X-Message": "<%= key %>"
},
"params": {
}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"schema": {
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"required": true,
"properties": {
"message": { "type": "string", "default": "<%= req['HEADERS']['X-Message'].reverse %>", "required": true }
}
}
}
}
================================================
FILE: spec/fixtures/contracts/swagger/petstore.yaml
================================================
swagger: 2.0
info:
version: 1.0.0
title: Swagger Petstore
license:
name: MIT
host: petstore.swagger.wordnik.com
basePath: /v1
schemes:
- http
consumes:
- application/json
produces:
- application/json
paths:
/pets:
get:
summary: List all pets
operationId: listPets
tags:
- pets
parameters:
- name: limit
in: query
description: How many items to return at one time (max 100)
required: false
type: integer
format: int32
responses:
200:
description: An paged array of pets
headers:
- x-next:
type: string
description: A link to the next page of responses
schema:
$ref: Pets
default:
description: unexpected error
schema:
$ref: Error
post:
summary: Create a pet
operationId: createPets
tags:
- pets
responses:
201:
description: Null response
default:
description: unexpected error
schema:
$ref: Error
/pets/{petId}:
get:
summary: Info for a specific pet
operationId: showPetById
tags:
- pets
parameters:
- name: petId
in: path
description: The id of the pet to retrieve
type: string
responses:
200:
description: Expected response to a valid request
schema:
$ref: Pets
default:
description: unexpected error
schema:
$ref: Error
definitions:
Pet:
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string
Pets:
type: array
items:
$ref: Pet
Error:
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string
================================================
FILE: spec/integration/e2e_spec.rb
================================================
# -*- encoding : utf-8 -*-
describe Pacto do
let(:contract_path) { contract_file 'simple_contract' }
let(:strict_contract_path) { contract_file 'strict_contract' }
before :all do
WebMock.allow_net_connect!
end
context 'Contract investigation' do
xit 'verifies the contract against a producer' do
# FIXME: Does this really test what it says it does??
contract = described_class.load_contracts(contract_path, 'http://localhost:8000')
expect(contract.simulate_consumers.map(&:successful?).uniq).to eq([true])
end
end
context 'Stubbing a collection of contracts' do
it 'generates a server that stubs the contract for consumers' do
contracts = described_class.load_contracts(contract_path, 'http://dummyprovider.com')
contracts.stub_providers
response = get_json('http://dummyprovider.com/api/hello')
expect(response['message']).to eq 'bar'
end
end
context 'Journey' do
it 'stubs multiple services with a single use' do
described_class.configure do |c|
c.strict_matchers = false
c.register_hook Pacto::Hooks::ERBHook.new
end
contracts = described_class.load_contracts contracts_folder, 'http://dummyprovider.com'
contracts.stub_providers(device_id: 42)
login_response = get_json('http://dummyprovider.com/api/hello')
expect(login_response.keys).to eq ['message']
expect(login_response['message']).to be_kind_of(String)
devices_response = get_json('http://dummyprovider.com/strict')
expect(devices_response['devices'].size).to eq(2)
expect(devices_response['devices'][0]).to eq('/dev/42')
expect(devices_response['devices'][1]).to eq('/dev/43')
end
end
def get_json(url)
response = Faraday.get(url) do |req|
req.headers = { 'Accept' => 'application/json' }
end
MultiJson.load(response.body)
end
end
================================================
FILE: spec/integration/forensics/integration_matcher_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/rspec'
module Pacto
describe '#have_investigated' do
let(:contract_path) { contract_file 'simple_contract' }
let(:strict_contract_path) { contract_file 'strict_contract' }
def expect_to_raise(message_pattern = nil, &blk)
expect { blk.call }.to raise_error(RSpec::Expectations::ExpectationNotMetError, message_pattern)
end
def json_response(url)
response = Faraday.get(url) do |req|
req.headers = { 'Accept' => 'application/json' }
end
MultiJson.load(response.body)
end
def play_bad_response
contracts.stub_providers(device_id: 1.5)
Faraday.get('http://dummyprovider.com/strict') do |req|
req.headers = { 'Accept' => 'application/json' }
end
end
context 'successful investigations' do
let(:contracts) do
Pacto.load_contracts contracts_folder, 'http://dummyprovider.com'
end
before(:each) do
Pacto.configure do |c|
c.strict_matchers = false
c.register_hook Pacto::Hooks::ERBHook.new
end
contracts.stub_providers(device_id: 42)
Pacto.validate!
Faraday.get('http://dummyprovider.com/api/hello') do |req|
req.headers = { 'Accept' => 'application/json' }
end
end
it 'performs successful assertions' do
# High level assertions
expect(Pacto).to_not have_unmatched_requests
expect(Pacto).to_not have_failed_investigations
# Increasingly strict assertions
expect(Pacto).to have_investigated('Simple Contract')
expect(Pacto).to have_investigated('Simple Contract').with_request(headers: hash_including('Accept' => 'application/json'))
expect(Pacto).to have_investigated('Simple Contract').with_request(http_method: :get, url: 'http://dummyprovider.com/api/hello')
end
it 'supports negative assertions' do
expect(Pacto).to_not have_investigated('Strict Contract')
Faraday.get('http://dummyprovider.com/strict') do |req|
req.headers = { 'Accept' => 'application/json' }
end
expect(Pacto).to have_investigated('Strict Contract')
end
it 'raises useful error messages' do
# Expected failures
header_matcher = hash_including('Accept' => 'text/plain')
matcher_description = Regexp.quote(header_matcher.description)
expect_to_raise(/but no requests matched headers #{matcher_description}/) { expect(Pacto).to have_investigated('Simple Contract').with_request(headers: header_matcher) }
end
it 'displays Contract investigation problems' do
play_bad_response
expect_to_raise(/investigation errors were found:/) { expect(Pacto).to have_investigated('Strict Contract') }
end
it 'displays the Contract file' do
play_bad_response
schema_file_uri = Addressable::URI.convert_path(File.absolute_path strict_contract_path).to_s
expect_to_raise(/in schema #{schema_file_uri}/) { expect(Pacto).to have_investigated('Strict Contract') }
end
end
end
end
================================================
FILE: spec/integration/rspec_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'pacto/rspec'
describe 'pacto/rspec' do
let(:contract_path) { contract_file 'simple_contract' }
let(:strict_contract_path) { contract_file 'strict_contract' }
around :each do |example|
with_pacto(port: 8000) do
example.run
end
end
def expect_to_raise(message_pattern = nil, &blk)
expect { blk.call }.to raise_error(RSpec::Expectations::ExpectationNotMetError, message_pattern)
end
def json_response(url)
response = Faraday.get(url) do |req|
req.headers = { 'Accept' => 'application/json' }
end
MultiJson.load(response.body)
end
def play_bad_response
contracts.stub_providers(device_id: 1.5)
Faraday.get('http://dummyprovider.com/strict') do |req|
req.headers = { 'Accept' => 'application/json' }
end
end
context 'successful investigations' do
let(:contracts) do
Pacto.load_contracts contracts_folder, 'http://dummyprovider.com'
end
before(:each) do
Pacto.configure do |c|
c.strict_matchers = false
c.register_hook Pacto::Hooks::ERBHook.new
end
contracts.stub_providers(device_id: 42)
Pacto.validate!
Faraday.get('http://dummyprovider.com/api/hello') do |req|
req.headers = { 'Accept' => 'application/json' }
end
end
it 'performs successful assertions' do
# High level assertions
expect(Pacto).to_not have_unmatched_requests
expect(Pacto).to_not have_failed_investigations
# Increasingly strict assertions
expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello')
expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello').with(headers: { 'Accept' => 'application/json' })
expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello').against_contract(/simple_contract.json/)
end
it 'supports negative assertions' do
expect(Pacto).to_not have_validated(:get, 'http://dummyprovider.com/strict')
Faraday.get('http://dummyprovider.com/strict') do |req|
req.headers = { 'Accept' => 'application/json' }
end
expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/strict')
end
it 'raises useful error messages' do
# High level error messages
expect_to_raise(/Expected Pacto to have not matched all requests to a Contract, but all requests were matched/) { expect(Pacto).to have_unmatched_requests }
expect_to_raise(/Expected Pacto to have found investigation problems, but none were found/) { expect(Pacto).to have_failed_investigations }
unmatched_url = 'http://localhost:8000/404'
Faraday.get unmatched_url
expect_to_raise(/the following requests were not matched.*#{Regexp.quote unmatched_url}/m) { expect(Pacto).to_not have_unmatched_requests }
# Expected failures
expect_to_raise(/no matching request was received/) { expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello').with(headers: { 'Accept' => 'text/plain' }) }
# No support for with accepting a block
# expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello').with { |req| req.body == "abc" }
expect_to_raise(/but it was validated against/) { expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello').against_contract(/strict_contract.json/) }
expect_to_raise(/but it was validated against/) { expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/api/hello').against_contract('simple_contract.json') }
expect_to_raise(/but no matching request was received/) { expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/strict') }
end
it 'displays Contract investigation problems' do
play_bad_response
expect_to_raise(/investigation errors were found:/) { expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/strict') }
expect_to_raise(/but the following issues were found:/) { expect(Pacto).to_not have_failed_investigations }
end
it 'displays the Contract file' do
play_bad_response
schema_file_uri = Addressable::URI.convert_path(File.absolute_path strict_contract_path).to_s
expect_to_raise(/in schema #{schema_file_uri}/) { expect(Pacto).to have_validated(:get, 'http://dummyprovider.com/strict') }
end
end
end
================================================
FILE: spec/integration/templating_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'securerandom'
describe 'Templating' do
let(:contract_path) { contract_file 'templating_contract' }
let(:contracts) { Pacto.load_contracts(contract_path, 'http://dummyprovider.com') }
let(:key) { SecureRandom.hex }
let(:auth_token) { SecureRandom.hex }
let :response do
contracts.stub_providers(key: key, auth_token: auth_token)
raw_response = Faraday.get('http://dummyprovider.com/echo') do |req|
req.headers = {
'Accept' => 'application/json',
'Custom-Auth-Token' => "#{auth_token}",
'X-Message' => "#{key}"
}
end
MultiJson.load(raw_response.body)
end
before :each do
Pacto.clear!
end
context 'No processing' do
it 'does not proccess erb tag' do
Pacto.configure do |c|
c.strict_matchers = false
c.register_hook do |_contracts, _req, res|
res
end
end
expect(response.keys).to eq ['message']
expect(response['message']).to eq("<%= req['HEADERS']['X-Message'].reverse %>")
end
end
context 'Post processing' do
it 'processes erb on each request' do
Pacto.configure do |c|
c.strict_matchers = false
c.register_hook Pacto::Hooks::ERBHook.new
end
expect(response.keys).to eq ['message']
expect(response['message']).to eq(key.reverse)
end
end
end
================================================
FILE: spec/spec_helper.rb
================================================
# -*- encoding : utf-8 -*-
require 'coveralls_helper'
require 'webmock/rspec'
require 'pacto'
require 'pacto/test_helper'
require 'fabrication'
require 'stringio'
require 'rspec'
# Pre-load shared examples
require_relative 'unit/pacto/actor_spec.rb'
RSpec.configure do |config|
config.raise_errors_for_deprecations!
config.include Pacto::TestHelper
config.expect_with :rspec do |c|
c.syntax = :expect
end
config.after(:each) do
Pacto.clear!
end
end
def default_pacto_format
ENV['PACTO_DEFAULT_FORMAT'] || 'legacy'
end
def contracts_folder(format = default_pacto_format)
"spec/fixtures/contracts/#{format}"
end
def contract_file(name, format = default_pacto_format)
file = Dir.glob("#{contracts_folder(format)}/#{name}.*").first
fail "Could not find a #{format} contract for #{name}" if file.nil?
file
end
def sample_contract
# Memoized for test speed
@sample_contract ||= Fabricate(:contract)
end
================================================
FILE: spec/unit/actors/from_examples_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Actors
describe FromExamples do
let(:fallback) { double('fallback') }
subject(:generator) { described_class.new fallback }
context 'a contract without examples' do
let(:contract) { Fabricate(:contract) }
it_behaves_like 'an actor' do
before(:each) do
allow(fallback).to receive(:build_request).with(contract, {}).and_return(Fabricate(:pacto_request))
allow(fallback).to receive(:build_response).with(contract, {}).and_return(Fabricate(:pacto_response))
end
let(:contract) { Fabricate(:contract) }
end
it 'uses the fallback actor' do
expect(fallback).to receive(:build_request).with(contract, {})
expect(fallback).to receive(:build_response).with(contract, {})
generator.build_request contract
generator.build_response contract
end
end
context 'a contract with examples' do
let(:contract) { Fabricate(:contract, example_count: 3) }
let(:request) { generator.build_request contract }
let(:response) { generator.build_response contract }
it_behaves_like 'an actor' do
let(:contract) { Fabricate(:contract, example_count: 3) }
end
context 'no example specified' do
it 'uses the first example' do
expect(request.body).to eq(contract.examples.values.first.request.body)
expect(response.body).to eq(contract.examples.values.first.response.body)
end
end
context 'example specified' do
let(:name) { '1' }
subject(:generator) { described_class.new fallback, Pacto::Actors::NamedExampleSelector }
let(:request) { generator.build_request contract, example_name: name }
let(:response) { generator.build_response contract, example_name: name }
it 'uses the named example' do
expect(request.body).to eq(contract.examples[name].request.body)
expect(response.body).to eq(contract.examples[name].response.body)
end
end
context 'with randomized behavior' do
subject(:generator) { described_class.new fallback, Pacto::Actors::RandomExampleSelector }
it 'returns a randomly selected example' do
examples_requests = contract.examples.values.map(&:request)
examples_responses = contract.examples.values.map(&:response)
request_bodies = examples_requests.map(&:body)
response_bodies = examples_responses.map(&:body)
expect(request_bodies).to include request.body
expect(response_bodies).to include response.body
end
end
end
end
end
end
================================================
FILE: spec/unit/actors/json_generator_spec.rb
================================================
# -*- encoding : utf-8 -*-
RSpec.shared_examples 'uses defaults' do
it 'uses the default values for the request' do
expect(request.body['foo']).to eq 'custom default value'
end
it 'uses the default values for the response' do
response = generator.build_response contract # , request
expect(response.body['foo']).to eq 'custom default value'
end
end
RSpec.shared_examples 'uses dumb values' do
it 'uses dumb values (request)' do
expect(request.body['foo']).to eq 'bar'
end
it 'uses dumb values (response)' do
response = generator.build_response contract # , request
expect(response.body['foo']).to eq 'bar'
end
end
module Pacto
module Actors
describe JSONGenerator do
subject(:generator) { described_class.new }
let(:request_clause) { Fabricate(:request_clause, schema: schema) }
let(:response_clause) { Fabricate(:response_clause, schema: schema) }
let(:contract) { Fabricate(:contract, request: request_clause, response: response_clause) }
let(:request) { generator.build_request contract }
context 'using default values from schema' do
context 'draft3' do
let(:schema) do
{
'$schema' => 'http://json-schema.org/draft-03/schema#',
'type' => 'object',
'required' => true,
'properties' => {
'foo' => {
'type' => 'string',
'required' => true,
'default' => 'custom default value'
}
}
}
end
include_examples 'uses defaults'
end
context 'draft4' do
let(:schema) do
{
'$schema' => 'http://json-schema.org/draft-04/schema#',
'type' => 'object',
'required' => ['foo'],
'properties' => {
'foo' => {
'type' => 'string',
'default' => 'custom default value'
}
}
}
end
skip 'draft4 is not supported by JSONGenerator'
# include_examples 'uses defaults'
end
end
context 'using dumb values (no defaults)' do
context 'draft3' do
let(:schema) do
{
'$schema' => 'http://json-schema.org/draft-03/schema#',
'type' => 'object',
'required' => true,
'properties' => {
'foo' => {
'type' => 'string',
'required' => true
}
}
}
end
include_examples 'uses dumb values'
end
context 'draft4' do
let(:schema) do
{
'$schema' => 'http://json-schema.org/draft-04/schema#',
'type' => 'object',
'required' => ['foo'],
'properties' => {
'foo' => {
'type' => 'string'
}
}
}
end
skip 'draft4 is not supported by JSONGenerator'
# include_examples 'uses dumb values'
end
end
end
end
end
================================================
FILE: spec/unit/pacto/actor_spec.rb
================================================
# -*- encoding : utf-8 -*-
RSpec.shared_examples 'an actor' do
# let(:contract) { Fabricate(:contract) }
let(:data) do
{}
end
describe '#build_request' do
let(:request) { subject.build_request contract, data }
it 'creates a PactoRequest' do
expect(request).to be_an_instance_of Pacto::PactoRequest
end
end
describe '#build_response' do
# Shouldn't build response be building a response for a request?
# let(:request) { Fabricate :pacto_request }
let(:response) { subject.build_response contract, data }
it 'creates a PactoResponse' do
expect(response).to be_an_instance_of Pacto::PactoResponse
end
end
end
================================================
FILE: spec/unit/pacto/configuration_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
describe Configuration do
subject(:configuration) { described_class.new }
let(:contracts_path) { 'path_to_contracts' }
it 'sets the http adapter by default to WebMockAdapter' do
expect(configuration.adapter).to be_kind_of Stubs::WebMockAdapter
end
it 'sets strict matchers by default to true' do
expect(configuration.strict_matchers).to be true
end
it 'sets contracts path by default to .' do
expect(configuration.contracts_path).to eq('.')
end
it 'sets logger by default to Logger' do
expect(configuration.logger).to be_kind_of Logger::SimpleLogger
end
context 'about logging' do
context 'when PACTO_DEBUG is enabled' do
around do |example|
ENV['PACTO_DEBUG'] = 'true'
example.run
ENV.delete 'PACTO_DEBUG'
end
it 'sets the log level to debug' do
expect(configuration.logger.level).to eq :debug
end
end
context 'when PACTO_DEBUG is disabled' do
it 'sets the log level to default' do
expect(configuration.logger.level).to eq :error
end
end
end
end
end
================================================
FILE: spec/unit/pacto/consumer/faraday_driver_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
class Consumer
describe FaradayDriver do
subject(:strategy) { described_class.new }
let(:get_request) { Fabricate(:pacto_request, method: :get, host: 'http://localhost/', path: 'hello_world', params: { 'foo' => 'bar' }) }
let(:post_request) { Fabricate(:pacto_request, method: :post, host: 'http://localhost/', path: 'hello_world', params: { 'foo' => 'bar' }) }
describe '#execute' do
before do
WebMock.stub_request(:get, 'http://localhost/hello_world?foo=bar').
to_return(status: 200, body: '', headers: {})
WebMock.stub_request(:post, 'http://localhost/hello_world?foo=bar').
to_return(status: 200, body: '', headers: {})
end
context 'for any request' do
it 'returns the a Pacto::PactoResponse' do
expect(strategy.execute get_request).to be_a Pacto::PactoResponse
end
end
context 'for a GET request' do
it 'makes the request thru the http client' do
strategy.execute get_request
expect(WebMock).to have_requested(:get, 'http://localhost/hello_world?foo=bar')
end
end
context 'for a POST request' do
it 'makes the request thru the http client' do
strategy.execute post_request
expect(WebMock).to have_requested(:post, 'http://localhost/hello_world?foo=bar')
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/contract_factory_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
describe ContractFactory do
let(:host) { 'http://localhost' }
let(:contract_name) { 'contract' }
let(:contract_path) { File.join(contracts_folder, "#{contract_name}.json") }
let(:contract_files) { [contract_path, contract_path] }
subject(:contract_factory) { described_class }
describe '#build' do
context 'default contract format' do
it 'builds contracts from a list of file paths and a host' do
contracts = contract_factory.build(contract_files, host)
contracts.each do |contract|
expect(contract).to be_a(Contract)
end
end
end
context 'custom format' do
let(:dummy_contract) { double }
class CustomContractFactory
def initialize(dummy_contract)
@dummy_contract = dummy_contract # rubocop:disable RSpec/InstanceVariable
end
def build_from_file(_contract_path, _host)
@dummy_contract # rubocop:disable RSpec/InstanceVariable
end
end
before do
subject.add_factory :custom, CustomContractFactory.new(dummy_contract)
end
it 'delegates to the registered factory' do
expect(contract_factory.build(contract_files, host, :custom)).to eq([dummy_contract, dummy_contract])
end
end
context 'flattening' do
let(:contracts_per_file) { 4 }
class MultiContractFactory
def initialize(contracts)
@contracts = contracts # rubocop:disable RSpec/InstanceVariable
end
def build_from_file(_contract_path, _host)
@contracts # rubocop:disable RSpec/InstanceVariable
end
end
before do
contracts = contracts_per_file.times.map do
double
end
subject.add_factory :multi, MultiContractFactory.new(contracts)
end
it 'delegates to the registered factory' do
loaded_contracts = contract_factory.build(contract_files, host, :multi)
expected_size = contracts_per_file * contract_files.size
# If the ContractFactory doesn't flatten returned contracts the size will be off. It needs
# to flatten because some factories load a single contract per file, others load multiple.
expect(loaded_contracts.size).to eq(expected_size)
end
end
end
end
end
================================================
FILE: spec/unit/pacto/contract_files_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
require 'fileutils'
module Pacto
describe ContractFiles do
let(:test_dir) { File.join(File.dirname(__FILE__), 'temp') }
let(:contract_1) { Pathname.new(File.join(test_dir, 'contract_1.json')) }
let(:contract_2) { Pathname.new(File.join(test_dir, 'contract_2.json')) }
let(:contract_3) { Pathname.new(File.join(test_dir, 'nested', 'contract_3.json')) }
before do
Dir.mkdir(test_dir)
Dir.chdir(test_dir) do
Dir.mkdir('nested')
['contract_1.json', 'contract_2.json', 'not_a_contract', 'nested/contract_3.json'].each do |file|
FileUtils.touch file
end
end
end
after do
FileUtils.rm_rf(test_dir)
end
describe 'for a dir' do
it 'returns a list with the full path of all json found recursively in that dir' do
files = ContractFiles.for(test_dir)
expect(files.size).to eq(3)
expect(files).to include(contract_1)
expect(files).to include(contract_2)
expect(files).to include(contract_3)
end
end
describe 'for a file' do
it 'returns a list containing only that file' do
files = ContractFiles.for(File.join(test_dir, 'contract_1.json'))
expect(files).to eq [contract_1]
end
end
end
end
================================================
FILE: spec/unit/pacto/contract_set_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
describe ContractSet do
let(:contract1) { Fabricate(:contract) }
let(:contract2) { Fabricate(:contract, request: Fabricate(:request_clause, host: 'www2.example.com')) }
it 'holds a list of contracts' do
list = ContractSet.new([contract1, contract2])
expect(list).to eq(Set.new([contract1, contract2]))
end
context 'when validating' do
it 'validates every contract on the list' do
expect(contract1).to receive(:simulate_request)
expect(contract2).to receive(:simulate_request)
list = ContractSet.new([contract1, contract2])
list.simulate_consumers
end
end
context 'when stubbing' do
let(:values) { Hash.new }
it 'stubs every contract on the list' do
expect(contract1).to receive(:stub_contract!).with(values)
expect(contract2).to receive(:stub_contract!).with(values)
list = ContractSet.new([contract1, contract2])
list.stub_providers(values)
end
end
end
end
================================================
FILE: spec/unit/pacto/contract_spec.rb
================================================
# -*- encoding : utf-8 -*-
RSpec.shared_examples 'a contract' do
before do
Pacto.configuration.adapter = adapter
allow(consumer_driver).to receive(:respond_to?).with(:execute).and_return true
allow(provider_actor).to receive(:respond_to?).with(:build_response).and_return true
Pacto.configuration.default_consumer.driver = consumer_driver
Pacto.configuration.default_provider.actor = provider_actor
end
it 'is a type of Contract' do
expect(subject).to be_a_kind_of(Pacto::Contract)
end
describe '#stub_contract!' do
it 'register a stub for the contract' do
expect(adapter).to receive(:stub_request!).with(contract)
contract.stub_contract!
end
end
context 'investigations' do
let(:request) { Pacto.configuration.default_consumer.build_request contract }
let(:fake_response) { Fabricate(:pacto_response) } # double('fake response') }
let(:cop) { double 'cop' }
let(:investigation_citations) { [double('investigation result')] }
before do
Pacto::Cops.active_cops.clear
Pacto::Cops.active_cops << cop
allow(cop).to receive(:investigate).with(an_instance_of(Pacto::PactoRequest), fake_response, contract).and_return investigation_citations
end
describe '#simulate_request' do
before do
allow(consumer_driver).to receive(:execute).with(an_instance_of(Pacto::PactoRequest)).and_return fake_response
end
it 'generates the response' do
expect(consumer_driver).to receive(:execute).with(an_instance_of(Pacto::PactoRequest))
contract.simulate_request
end
it 'returns the result of the validating the generated response' do
expect(cop).to receive(:investigate).with(an_instance_of(Pacto::PactoRequest), fake_response, contract)
investigation = contract.simulate_request
expect(investigation.citations).to eq investigation_citations
end
end
end
describe '#matches?' do
let(:request_pattern) { double('request_pattern') }
let(:request_signature) { double('request_signature') }
it 'delegates to the request pattern' do
expect(Pacto::RequestPattern).to receive(:for).and_return(request_pattern)
expect(request_pattern).to receive(:matches?).with(request_signature)
contract.matches?(request_signature)
end
end
end
================================================
FILE: spec/unit/pacto/cops/body_cop_spec.rb
================================================
# -*- encoding : utf-8 -*-
RSpec.shared_examples 'a body cop' do | section_to_validate |
subject(:cop) { described_class }
let(:request_clause) { Fabricate(:request_clause, schema: schema) }
let(:response_clause) { Fabricate(:response_clause, schema: schema) }
let(:contract) do
Fabricate(:contract, file: 'file:///a.json', request: request_clause, response: response_clause)
end
let(:string_required) { %w(#) }
let(:request) { Fabricate(:pacto_request) }
let(:response) { Fabricate(:pacto_response) }
let(:clause_to_validate) { contract.send section_to_validate }
let(:object_to_validate) { send section_to_validate }
describe '#validate' do
context 'when schema is not specified' do
let(:schema) { nil }
it 'gives no errors without validating body' do
expect(JSON::Validator).not_to receive(:fully_validate)
expect(cop.investigate(request, response, contract)).to be_empty
end
end
context 'when the expected body is a string' do
let(:schema) { { 'type' => 'string', 'required' => string_required } }
context 'if required' do
it 'does not return an error when body is a string' do
object_to_validate.body = 'asdf'
expect(cop.investigate(request, response, contract)).to eq([])
end
it 'returns an error when body is nil' do
object_to_validate.body = nil
expect(cop.investigate(request, response, contract).size).to eq 1
end
end
context 'if not required' do
let(:string_required) { %w() }
# Body can be empty but not nil if not required
# Not sure if this is an issue or not
skip 'does not return an error when body is a string' do
expect(cop.investigate(request, response, contract)).to be_empty
end
it 'does not return an error when body is empty' do
object_to_validate.body = ''
expect(cop.investigate(request, response, contract)).to be_empty
end
end
context 'if contains pattern' do
let(:schema) do
{ type: 'string', required: string_required, pattern: 'a.c' }
end
context 'body matches pattern' do
it 'does not return an error' do
object_to_validate.body = 'abc'
expect(cop.investigate(request, response, contract)).to be_empty
end
end
context 'body does not match pattern' do
it 'returns an error' do
object_to_validate.body = 'acb' # This does not matches the pattern /a.c/
expect(cop.investigate(request, response, contract).size).to eq 1
end
end
end
end
context 'when the body is json' do
let(:schema) { { type: 'object' } }
context 'when body matches' do
it 'does not return any errors' do
expect(JSON::Validator).to receive(:fully_validate).and_return([])
expect(cop.investigate(request, response, contract)).to be_empty
end
end
context 'when body does not match' do
it 'returns a list of errors' do
errors = double 'some errors'
expect(JSON::Validator).to receive(:fully_validate).and_return(errors)
expect(cop.investigate(request, response, contract)).to eq(errors)
end
end
end
end
end
module Pacto
module Cops
describe RequestBodyCop do
it_behaves_like 'a body cop', :request
end
describe ResponseBodyCop do
it_behaves_like 'a body cop', :response
end
end
end
================================================
FILE: spec/unit/pacto/cops/response_header_cop_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
describe ResponseHeaderCop do
subject(:cop) { described_class }
let(:contract) do
response_clause = Fabricate(:response_clause, headers: expected_headers)
Fabricate(:contract, response: response_clause)
end
let(:request) { Fabricate(:pacto_request) }
let(:response) { Fabricate(:pacto_response, headers: actual_headers) }
let(:expected_headers) do
{
'Content-Type' => 'application/json'
}
end
describe '#investigate' do
context 'when headers do not match' do
let(:actual_headers) do
{ 'Content-Type' => 'text/html' }
end
it 'indicates the exact mismatches' do
expect(cop.investigate(request, response, contract)).
to eq ['Invalid response header Content-Type: expected "application/json" but received "text/html"']
end
end
context 'when headers are missing' do
let(:actual_headers) do
{}
end
let(:expected_headers) do
{
'Content-Type' => 'application/json',
'My-Cool-Header' => 'Whiskey Pie'
}
end
it 'lists the missing headers' do
expect(cop.investigate(request, response, contract)).
to eq [
'Missing expected response header: Content-Type',
'Missing expected response header: My-Cool-Header'
]
end
end
context 'when Location Header is expected' do
before(:each) do
expected_headers.merge!('Location' => 'http://www.example.com/{foo}/bar')
end
context 'and no Location header is sent' do
let(:actual_headers) { { 'Content-Type' => 'application/json' } }
it 'returns a header error when no Location header is sent' do
expect(cop.investigate(request, response, contract)).to eq ['Missing expected response header: Location']
end
end
context 'but the Location header does not matches the pattern' do
let(:actual_headers) do
{
'Content-Type' => 'application/json',
'Location' => 'http://www.example.com/foo/bar/baz'
}
end
it 'returns a investigation error' do
response.headers = actual_headers
expect(cop.investigate(request, response, contract)).to eq ["Invalid response header Location: expected URI #{actual_headers['Location']} to match URI Template #{expected_headers['Location']}"]
end
end
context 'and the Location header matches pattern' do
let(:actual_headers) do
{
'Content-Type' => 'application/json',
'Location' => 'http://www.example.com/foo/bar'
}
end
it 'investigates successfully' do
expect(cop.investigate(request, response, contract)).to be_empty
end
end
end
context 'when headers are a subset of expected headers' do
let(:actual_headers) { { 'Content-Type' => 'application/json' } }
it 'does not return any errors' do
expect(cop.investigate(request, response, contract)).to be_empty
end
end
context 'when headers values match but keys have different case' do
let(:actual_headers) { { 'content-type' => 'application/json' } }
it 'does not return any errors' do
expect(cop.investigate(request, response, contract)).to be_empty
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/cops/response_status_cop_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Cops
describe ResponseStatusCop do
subject(:cop) { described_class }
let(:contract) { Fabricate(:contract) }
let(:request) { Fabricate(:pacto_request) }
describe '#investigate' do
context 'when status does not match' do
let(:response) { Fabricate(:pacto_response, status: 500) }
it 'returns a status error' do
expect(cop.investigate(request, response, contract)).to eq ['Invalid status: expected 200 but got 500']
end
end
context 'when the status matches' do
let(:response) { Fabricate(:pacto_response, status: 200) }
it 'returns nil' do
expect(cop.investigate(request, response, contract)).to be_empty
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/cops_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
describe Cops do
let(:investigation_errors) { ['some error', 'another error'] }
let(:expected_response) do
Fabricate(:response_clause)
end
let(:actual_response) do
# TODO: Replace this with a Fabrication for Pacto::PactoResponse (perhaps backed by WebMock)
Fabricate(:pacto_response)
# double(
# status: 200,
# headers: { 'Content-Type' => 'application/json', 'Age' => '60' },
# body: { 'message' => 'response' }
# )
end
let(:actual_request) { Fabricate(:pacto_request) }
let(:expected_request) do
Fabricate(:request_clause)
end
let(:contract) do
Fabricate(
:contract,
request: expected_request,
response: expected_response
)
end
describe '#validate_contract' do
before do
allow(Pacto::Cops::RequestBodyCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return([])
allow(Pacto::Cops::ResponseBodyCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return([])
end
context 'default cops' do
let(:investigation) { described_class.perform_investigation actual_request, actual_response, contract }
it 'calls the RequestBodyCop' do
expect(Pacto::Cops::RequestBodyCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return(investigation_errors)
expect(investigation.citations).to eq(investigation_errors)
end
it 'calls the ResponseStatusCop' do
expect(Pacto::Cops::ResponseStatusCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return(investigation_errors)
expect(investigation.citations).to eq(investigation_errors)
end
it 'calls the ResponseHeaderCop' do
expect(Pacto::Cops::ResponseHeaderCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return(investigation_errors)
expect(investigation.citations).to eq(investigation_errors)
end
it 'calls the ResponseBodyCop' do
expect(Pacto::Cops::ResponseBodyCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return(investigation_errors)
expect(investigation.citations).to eq(investigation_errors)
end
end
context 'when headers and body match and the ResponseStatusCop reports no errors' do
it 'does not return any errors' do
expect(Pacto::Cops::RequestBodyCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return([])
expect(Pacto::Cops::ResponseStatusCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return([])
expect(Pacto::Cops::ResponseHeaderCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return([])
expect(Pacto::Cops::ResponseBodyCop).to receive(:investigate).with(actual_request, actual_response, contract).and_return([])
expect(described_class.perform_investigation actual_request, actual_response, contract).to be_successful
end
end
end
end
end
================================================
FILE: spec/unit/pacto/core/configuration_spec.rb
================================================
# -*- encoding : utf-8 -*-
describe Pacto do
describe '.configure' do
let(:contracts_path) { 'path_to_contracts' }
it 'allows contracts_path manual configuration' do
expect(described_class.configuration.contracts_path).to eq('.')
described_class.configure do |c|
c.contracts_path = contracts_path
end
expect(described_class.configuration.contracts_path).to eq(contracts_path)
end
it 'register a Pacto Hook' do
hook_block = Pacto::Hook.new {}
described_class.configure do |c|
c.register_hook(hook_block)
end
expect(described_class.configuration.hook).to eq(hook_block)
end
end
end
================================================
FILE: spec/unit/pacto/core/contract_registry_spec.rb
================================================
# -*- encoding : utf-8 -*-
require_relative '../../../../lib/pacto/core/contract_registry'
module Pacto
describe ContractRegistry do
let(:contract) { Fabricate(:contract) }
let(:request_signature) { Fabricate(:webmock_request_signature) }
subject(:contract_registry) do
ContractRegistry.new
end
describe '.register' do
it 'registers the contract' do
contract_registry.register contract
expect(contract_registry).to include(contract)
end
end
describe '.contracts_for' do
before(:each) do
contract_registry.register contract
end
context 'when no contracts are found for a request' do
it 'returns an empty list' do
expect(contract).to receive(:matches?).with(request_signature).and_return false
expect(contract_registry.contracts_for request_signature).to be_empty
end
end
context 'when contracts are found for a request' do
it 'returns the matching contracts' do
expect(contract).to receive(:matches?).with(request_signature).and_return true
expect(contract_registry.contracts_for request_signature).to eq([contract])
end
end
end
def create_contracts(total, matches)
total.times.map do
double('contract',
:stub_contract! => double('request matcher'),
:matches? => matches)
end
end
def register_contracts(contracts)
contracts.each { |contract| contract_registry.register contract }
end
end
end
================================================
FILE: spec/unit/pacto/core/http_middleware_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Core
describe HTTPMiddleware do
subject(:middleware) { Pacto::Core::HTTPMiddleware.new }
let(:request) { double }
let(:response) { double }
class FailingObserver
def raise_error(_pacto_request, _pacto_response)
fail InvalidContract, ['The contract was missing things', 'and stuff']
end
end
describe '#process' do
it 'calls registered HTTP observers' do
observer1, observer2 = double, double
expect(observer1).to receive(:respond_to?).with(:do_something).and_return true
expect(observer2).to receive(:respond_to?).with(:do_something_else).and_return true
middleware.add_observer(observer1, :do_something)
middleware.add_observer(observer2, :do_something_else)
expect(observer1).to receive(:do_something).with(request, response)
expect(observer2).to receive(:do_something_else).with(request, response)
middleware.process request, response
end
pending 'logs rescues and logs failures'
pending 'calls the HTTP middleware'
pending 'calls the registered hook'
pending 'calls generate when generate is enabled'
pending 'calls validate when validate mode is enabled'
pending 'validates a WebMock request/response pair'
end
end
end
end
================================================
FILE: spec/unit/pacto/core/investigation_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
describe Investigation do
let(:request) { double('request') }
let(:response) { double('response') }
let(:file) { 'foobar' }
let(:contract) { Fabricate(:contract, file: file) }
let(:investigation_citations) { [] }
let(:investigation_citations_with_errors) { ['an error occurred'] }
it 'stores the request, response, contract and citations' do
investigation = Pacto::Investigation.new request, response, contract, investigation_citations
expect(investigation.request).to eq request
expect(investigation.response).to eq response
expect(investigation.contract).to eq contract
expect(investigation.citations).to eq investigation_citations
end
context 'if there were investigation errors' do
subject(:investigation) do
Pacto::Investigation.new request, response, contract, investigation_citations_with_errors
end
describe '#successful?' do
it 'returns false' do
expect(investigation.successful?).to be_falsey
end
end
end
context 'if there were no investigation errors' do
subject(:investigation) do
Pacto::Investigation.new request, response, contract, investigation_citations
end
it 'returns false' do
expect(investigation.successful?).to be true
end
end
describe '#against_contract?' do
it 'returns nil if there was no contract' do
investigation = Pacto::Investigation.new request, response, nil, investigation_citations
expect(investigation.against_contract? 'a').to be_nil
end
it 'returns the contract with an exact string name match' do
investigation = Pacto::Investigation.new request, response, contract, investigation_citations
expect(investigation.against_contract? 'foobar').to eq(contract)
expect(investigation.against_contract? 'foo').to be_nil
expect(investigation.against_contract? 'bar').to be_nil
end
it 'returns the contract if there is a regex match' do
allow(contract).to receive(:file).and_return 'foobar'
investigation = Pacto::Investigation.new request, response, contract, investigation_citations
expect(investigation.against_contract?(/foo/)).to eq(contract)
expect(investigation.against_contract?(/bar/)).to eq(contract)
expect(investigation.against_contract?(/baz/)).to be_nil
end
end
end
end
================================================
FILE: spec/unit/pacto/core/modes_spec.rb
================================================
# -*- encoding : utf-8 -*-
describe Pacto do
modes = %w(generate validate)
modes.each do |mode|
enable_method = "#{mode}!".to_sym # generate!
query_method = "#{mode[0..-2]}ing?".to_sym # generating?
disable_method = "stop_#{mode[0..-2]}ing!".to_sym # stop_generating!
describe ".#{mode}!" do
it "tells the provider to enable #{mode} mode" do
expect(subject.send query_method).to be_falsey
subject.send enable_method
expect(subject.send query_method).to be true
subject.send disable_method
expect(subject.send query_method).to be_falsey
end
end
end
end
================================================
FILE: spec/unit/pacto/erb_processor_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
describe ERBProcessor do
subject(:processor) { described_class.new }
describe '#process' do
let(:erb) { '2 + 2 = <%= 2 + 2 %>' }
let(:result) { '2 + 2 = 4' }
it 'returns the result of ERB' do
expect(processor.process(erb)).to eq result
end
it 'logs the erb processed' do
expect(Pacto.configuration.logger).to receive(:debug).with("Processed contract: \"#{result}\"")
processor.process erb
end
it 'does not mess with pure JSONs' do
processor.process('{"property": ["one", "two, null"]}')
end
end
end
end
================================================
FILE: spec/unit/pacto/extensions_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
describe Extensions do
describe '#normalize_header_keys' do
it 'matches headers to the style in the RFC documentation' do
expect(Pacto::Extensions.normalize_header_keys(:'user-agent' => 'a')).to eq('User-Agent' => 'a') # rubocop:disable SymbolName
expect(Pacto::Extensions.normalize_header_keys(user_agent: 'a')).to eq('User-Agent' => 'a')
expect(Pacto::Extensions.normalize_header_keys('User-Agent' => 'a')).to eq('User-Agent' => 'a')
expect(Pacto::Extensions.normalize_header_keys('user-agent' => 'a')).to eq('User-Agent' => 'a')
expect(Pacto::Extensions.normalize_header_keys('user_agent' => 'a')).to eq('User-Agent' => 'a')
expect(Pacto::Extensions.normalize_header_keys('USER_AGENT' => 'a')).to eq('User-Agent' => 'a')
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/contract_builder_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
describe ContractBuilder do
let(:data) { subject.build_hash }
describe '#name' do
it 'sets the contract name' do
subject.name = 'foo'
expect(data).to include(name: 'foo')
end
end
describe '#add_example' do
let(:examples) { subject.build_hash[:examples] }
it 'adds named examples to the contract' do
subject.add_example 'foo', Fabricate(:pacto_request), Fabricate(:pacto_response)
subject.add_example 'bar', Fabricate(:pacto_request), Fabricate(:pacto_response)
expect(examples).to be_a(Hash)
expect(examples.keys).to include('foo', 'bar')
expect(examples['foo'][:response]).to include(status: 200)
expect(data)
end
end
context 'without examples' do
describe '#infer_schemas' do
it 'does not add schemas' do
subject.name = 'test'
subject.infer_schemas
expect(data[:request][:schema]).to be_nil
expect(data[:response][:schema]).to be_nil
end
end
end
context 'with examples' do
before(:each) do
subject.add_example 'success', Fabricate(:pacto_request), Fabricate(:pacto_response)
subject.add_example 'not found', Fabricate(:pacto_request), Fabricate(:pacto_response)
end
describe '#without_examples' do
it 'stops the builder from including examples in the final data' do
expect(subject.build_hash.keys).to include(:examples)
expect(subject.without_examples.build_hash.keys).to_not include(:examples)
end
end
describe '#infer_schemas' do
it 'adds schemas' do
subject.name = 'test'
subject.infer_schemas
contract = subject.build
expect(contract.request.schema).to_not be_nil
expect(contract.request.schema).to_not be_nil
end
end
end
context 'generating from interactions' do
let(:request) { Fabricate(:pacto_request) }
let(:response) { Fabricate(:pacto_response) }
let(:data) { subject.generate_response(request, response).build_hash }
let(:contract) { subject.generate_contract(request, response).build }
describe '#generate_response' do
it 'sets the response status' do
expect(data[:response]).to include(
status: 200
)
end
it 'sets response headers' do
expect(data[:response][:headers]).to be_a(Hash)
end
end
describe '#infer_schemas' do
it 'sets the schemas based on the examples' do
expect(contract.request.schema).to_not be_nil
expect(contract.request.schema).to_not be_nil
end
end
end
skip '#add_request_header'
skip '#add_response_header'
skip '#filter'
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/contract_factory_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
module Formats
module Legacy
describe ContractFactory do
let(:host) { 'http://localhost' }
let(:contract_format) { 'legacy' }
let(:contract_name) { 'contract' }
let(:contract_path) { contract_file(contract_name, contract_format) }
subject(:contract_factory) { described_class.new }
it 'builds a Contract given a JSON file path and a host' do
contract = contract_factory.build_from_file(contract_path, host)
expect(contract).to be_a(Pacto::Formats::Legacy::Contract)
end
context 'deprecated contracts' do
let(:contract_format) { 'deprecated' }
let(:contract_name) { 'deprecated_contract' }
it 'can no longer be loaded' do
expect { contract_factory.build_from_file(contract_path, host) }.to raise_error(/old syntax no longer supported/)
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/contract_generator_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
describe ContractGenerator do
let(:record_host) do
'http://example.com'
end
let(:request_clause) { Fabricate(:request_clause, params: { 'api_key' => "<%= ENV['MY_API_KEY'] %>" }) }
let(:response_adapter) do
Faraday::Response.new(
status: 200,
response_headers: {
'Date' => [Time.now],
'Server' => ['Fake Server'],
'Content-Type' => ['application/json'],
'Vary' => ['User-Agent']
},
body: 'dummy body' # body is just a string
)
end
let(:filtered_request_headers) { double('filtered_response_headers') }
let(:filtered_response_headers) { double('filtered_response_headers') }
let(:response_body_schema) { '{"message": "dummy generated schema"}' }
let(:version) { 'draft3' }
let(:schema_generator) { double('schema_generator') }
let(:validator) { double('validator') }
let(:filters) { double :filters }
let(:consumer) { double 'consumer' }
let(:request_file) { 'request.json' }
let(:generator) { described_class.new version, schema_generator, validator, filters, consumer }
let(:request_contract) do
Fabricate(:partial_contract, request: request_clause, file: request_file)
end
let(:request) do
Pacto.configuration.default_consumer.build_request request_contract
end
def pretty(obj)
MultiJson.encode(obj, pretty: true).gsub(/^$\n/, '')
end
describe '#generate_from_partial_contract' do
# TODO: Deprecate partial contracts?
let(:generated_contract) { Fabricate(:contract) }
before do
expect(Pacto).to receive(:load_contract).with(request_file, record_host).and_return request_contract
expect(consumer).to receive(:request).with(request_contract).and_return([request, response_adapter])
end
it 'parses the request' do
expect(generator).to receive(:save).with(request_file, request, anything)
generator.generate_from_partial_contract request_file, record_host
end
it 'fetches a response' do
expect(generator).to receive(:save).with(request_file, anything, response_adapter)
generator.generate_from_partial_contract request_file, record_host
end
it 'saves the result' do
expect(generator).to receive(:save).with(request_file, request, response_adapter).and_return generated_contract
expect(generator.generate_from_partial_contract request_file, record_host).to eq(generated_contract)
end
end
describe '#save' do
before do
allow(filters).to receive(:filter_request_headers).with(request, response_adapter).and_return filtered_request_headers
allow(filters).to receive(:filter_response_headers).with(request, response_adapter).and_return filtered_response_headers
end
context 'invalid schema' do
it 'raises an error if schema generation fails' do
expect(schema_generator).to receive(:generate).and_raise ArgumentError.new('Could not generate schema')
expect { generator.save request_file, request, response_adapter }.to raise_error
end
it 'raises an error if the generated contract is invalid' do
expect(schema_generator).to receive(:generate).and_return response_body_schema
expect(validator).to receive(:validate).and_raise InvalidContract.new('dummy error')
expect { generator.save request_file, request, response_adapter }.to raise_error
end
end
context 'valid schema' do
let(:raw_contract) do
expect(schema_generator).to receive(:generate).with(request_file, response_adapter.body, Pacto.configuration.generator_options).and_return response_body_schema
expect(validator).to receive(:validate).and_return true
generator.save request_file, request, response_adapter
end
subject(:generated_contract) { JSON.parse raw_contract }
it 'sets the schema to the generated json-schema' do
expect(subject['response']['schema']).to eq(JSON.parse response_body_schema)
end
it 'sets the request attributes' do
generated_request = subject['request']
expect(generated_request['params']).to eq(request.uri.query_values)
expect(generated_request['path']).to eq(request.uri.path)
end
it 'preserves ERB in the request params' do
generated_request = subject['request']
expect(generated_request['params']).to eq('api_key' => "<%= ENV['MY_API_KEY'] %>")
end
it 'normalizes the request method' do
generated_request = subject['request']
expect(generated_request['http_method']).to eq(request.method.downcase.to_s)
end
it 'sets the response attributes' do
generated_response = subject['response']
expect(generated_response['status']).to eq(response_adapter.status)
end
it 'generates pretty JSON' do
expect(raw_contract).to eq(pretty(subject))
end
end
context 'with hints' do
let(:request1) { Fabricate(:pacto_request, host: 'example.com', path: '/album/5/cover') }
let(:request2) { Fabricate(:pacto_request, host: 'example.com', path: '/album/7/cover') }
let(:response1) { Fabricate(:pacto_response) }
let(:response2) { Fabricate(:pacto_response) }
let(:contracts_path) { Dir.mktmpdir }
before(:each) do
allow(filters).to receive(:filter_request_headers).with(request1, response1).and_return request1.headers
allow(filters).to receive(:filter_response_headers).with(request1, response1).and_return response1.headers
allow(filters).to receive(:filter_request_headers).with(request2, response2).and_return request2.headers
allow(filters).to receive(:filter_response_headers).with(request2, response2).and_return response2.headers
allow(schema_generator).to receive(:generate).with(request_file, response1.body, Pacto.configuration.generator_options).and_return response_body_schema
allow(schema_generator).to receive(:generate).with(request_file, response2.body, Pacto.configuration.generator_options).and_return response_body_schema
allow(validator).to receive(:validate).twice.and_return true
Pacto.configuration.contracts_path = contracts_path
Pacto::Generator.configure do |c|
c.hint 'Get Album Cover', http_method: :get, host: 'http://example.com', path: '/album/{id}/cover', target_file: 'album_services/get_album_cover.json'
end
Pacto.generate!
end
it 'names the contract based on the hint' do
contract1 = generator.generate request1, response1
expect(contract1.name).to eq('Get Album Cover')
end
it 'sets the path to match the hint' do
contract1 = generator.generate request1, response1
expect(contract1.request.path).to eq('/album/{id}/cover')
end
it 'sets the target file based on the hint' do
contract1 = generator.generate request1, response1
expected_path = File.expand_path('album_services/get_album_cover.json', contracts_path)
real_expected_path = Pathname.new(expected_path).realpath.to_s
expected_file_uri = Addressable::URI.convert_path(real_expected_path).to_s
expect(contract1.file).to eq(expected_file_uri)
end
xit 'does not create duplicate contracts' do
contract1 = generator.generate request1, response1
contract2 = generator.generate request2, response2
expect(contract1).to eq(contract2)
end
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/contract_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'unit/pacto/contract_spec'
module Pacto
module Formats
module Legacy
describe Contract do
let(:request_clause) do
Pacto::Formats::Legacy::RequestClause.new(
http_method: 'GET',
host: 'http://example.com',
path: '/',
schema: {
type: 'object',
required: true # , :properties => double('body definition properties')
}
)
end
let(:response_clause) do
ResponseClause.new(status: 200)
end
let(:adapter) { double 'provider' }
let(:file) { contract_file 'contract', 'legacy' }
let(:consumer_driver) { double }
let(:provider_actor) { double }
subject(:contract) do
described_class.new(
request: request_clause,
response: response_clause,
file: file,
name: 'sample'
)
end
it_behaves_like 'a contract'
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/generator/filters_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
module Generator
describe Filters do
let(:record_host) do
'http://example.com'
end
let(:request) do
RequestClause.new(
host: record_host,
http_method: 'GET',
path: '/abcd',
headers: {
'Server' => ['example.com'],
'Connection' => ['Close'],
'Content-Length' => [1234],
'Via' => ['Some Proxy'],
'User-Agent' => ['rspec']
},
params: {
'apikey' => "<%= ENV['MY_API_KEY'] %>"
}
)
end
let(:varies) { ['User-Agent'] }
let(:response) do
Faraday::Response.new(
status: 200,
response_headers: {
'Date' => Time.now.rfc2822,
'Last-Modified' => Time.now.rfc2822,
'ETag' => 'abc123',
'Server' => ['Fake Server'],
'Content-Type' => ['application/json'],
'Vary' => varies
},
body: double('dummy body')
)
end
describe '#filter_request_headers' do
subject(:filtered_request_headers) { described_class.new.filter_request_headers(request, response).keys.map(&:downcase) }
it 'keeps important request headers' do
expect(filtered_request_headers).to include 'user-agent'
end
it 'filters informational request headers' do
expect(filtered_request_headers).not_to include 'via'
expect(filtered_request_headers).not_to include 'date'
expect(filtered_request_headers).not_to include 'server'
expect(filtered_request_headers).not_to include 'content-length'
expect(filtered_request_headers).not_to include 'connection'
end
context 'multiple Vary elements' do
context 'as a single string' do
let(:varies) do
['User-Agent,Via']
end
it 'keeps each header' do
expect(filtered_request_headers).to include 'user-agent'
expect(filtered_request_headers).to include 'via'
end
end
context 'as multiple items' do
let(:varies) do
%w(User-Agent Via)
end
it 'keeps each header' do
expect(filtered_request_headers).to include 'user-agent'
expect(filtered_request_headers).to include 'via'
end
end
end
end
describe '#filter_response_headers' do
subject(:filtered_response_headers) { described_class.new.filter_response_headers(request, response).keys.map(&:downcase) }
it 'keeps important response headers' do
expect(filtered_response_headers).to include 'content-type'
end
it 'filters connection control headers' do
expect(filtered_response_headers).not_to include 'content-length'
expect(filtered_response_headers).not_to include 'via'
end
it 'filters freshness headers' do
expect(filtered_response_headers).not_to include 'date'
expect(filtered_response_headers).not_to include 'last-modified'
expect(filtered_response_headers).not_to include 'eTag'
end
it 'filters x-* headers' do
expect(filtered_response_headers).not_to include 'x-men'
end
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/request_clause_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
describe RequestClause do
let(:host) { 'http://localhost' }
let(:method) { 'GET' }
let(:path) { '/hello_world' }
let(:headers) { { 'accept' => 'application/json' } }
let(:params) { { 'foo' => 'bar' } }
let(:body) { double :body }
let(:params_as_json) { "{\"foo\":\"bar\"}" }
let(:absolute_uri) { "#{host}#{path}" }
subject(:request) do
req_hash = {
host: host,
'http_method' => method,
'path' => path,
'headers' => headers,
'params' => params
}
# The default test is for missing keys, not explicitly nil keys
req_hash.merge!('schema' => body) if body
described_class.new(req_hash)
end
it 'has a host' do
expect(request.host).to eq host
end
describe '#http_method' do
it 'delegates to definition' do
expect(request.http_method).to eq :get
end
it 'downcases the method' do
expect(request.http_method).to eq request.http_method.downcase
end
it 'returns a symbol' do
expect(request.http_method).to be_kind_of Symbol
end
end
describe '#schema' do
it 'delegates to definition\'s body' do
expect(request.schema).to eq body
end
describe 'when definition does not have a schema' do
let(:body) { nil }
it 'returns an empty empty hash' do
expect(request.schema).to eq({})
end
end
end
describe '#path' do
it 'delegates to definition' do
expect(request.path).to eq path
end
end
describe '#headers' do
it 'delegates to definition' do
expect(request.headers).to eq headers
end
end
describe '#params' do
it 'delegates to definition' do
expect(request.params).to eq params
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/legacy/response_clause_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Legacy
describe ResponseClause do
let(:body_definition) do
Fabricate(:schema)
end
let(:definition) do
{
'status' => 200,
'headers' => {
'Content-Type' => 'application/json'
},
'schema' => body_definition
}
end
subject(:response) { described_class.new(definition) }
it 'has a status' do
expect(response.status).to eq(200)
end
it 'has a headers hash' do
expect(response.headers).to eq(
'Content-Type' => 'application/json'
)
end
it 'has a schema' do
expect(response.schema).to eq(body_definition)
end
it 'has a default value for the schema' do
definition.delete 'schema'
response = described_class.new(definition)
expect(response.schema).to eq(Hash.new)
end
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/swagger/contract_factory_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Formats
module Swagger
describe ContractFactory do
let(:swagger_file) { contract_file('petstore', 'swagger') }
let(:expected_schema) do
{
'type' => 'array',
'items' => {
'required' => %w(id name),
'properties' => {
'id' => { 'type' => 'integer', 'format' => 'int64' },
'name' => { 'type' => 'string' },
'tag' => { 'type' => 'string' }
}
}
}
end
describe '#load_hints' do
pending 'loads hints from Swagger' do
hints = subject.load_hints(swagger_file)
expect(hints.size).to eq(3) # number of API operations
hints.each do | hint |
expect(hint).to be_a_kind_of(Pacto::Generator::Hint)
expect(hint.host).to eq('petstore.swagger.wordnik.com')
expect([:get, :post]).to include(hint.http_method)
expect(hint.path).to match(/\/pets/)
end
end
end
describe '#build_from_file' do
it 'loads Contracts from Swagger' do
contracts = subject.build_from_file(swagger_file)
expect(contracts.size).to eq(3) # number of API operations
contracts.each do | contract |
expect(contract).to be_a(Pacto::Formats::Swagger::Contract)
request_clause = contract.request
expect(request_clause.host).to eq('petstore.swagger.wordnik.com')
expect([:get, :post]).to include(request_clause.http_method)
expect(request_clause.path).to match(/\/pets/)
response_clause = contract.response
if request_clause.http_method == :get
expect(response_clause.status).to eq(200)
else
expect(response_clause.status).to eq(201)
end
expect(response_clause.schema).to eq(expected_schema) if response_clause.status == 200
end
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/formats/swagger/contract_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'unit/pacto/contract_spec'
module Pacto
module Formats
module Swagger
describe Contract do
let(:swagger_yaml) do
''"
swagger: '2.0'
info:
title: Sample API
version: N/A
consumes:
- application/json
produces:
- application/json
paths:
/:
get:
operationId: sample
responses:
200:
description: |-
Success.
"''
end
let(:swagger_definition) do
::Swagger.build(swagger_yaml, format: :yaml)
end
let(:api_operation) do
swagger_definition.operations.first
end
let(:adapter) { double 'provider' }
let(:file) { Tempfile.new(['swagger', '.yaml']).path }
let(:consumer_driver) { double }
let(:provider_actor) { double }
subject(:contract) do
described_class.new(api_operation, file: file)
end
it_behaves_like 'a contract'
end
end
end
end
================================================
FILE: spec/unit/pacto/hooks/erb_hook_spec.rb
================================================
# -*- encoding : utf-8 -*-
describe Pacto::Hooks::ERBHook do
describe '#process' do
let(:req) do
OpenStruct.new(headers: { 'User-Agent' => 'abcd' })
end
let(:converted_req) do
{ 'HEADERS' => { 'User-Agent' => 'abcd' } }
end
let(:res) do
Pacto::PactoResponse.new(
status: 200,
body: 'before'
)
end
before do
end
context 'no matching contracts' do
it 'binds the request' do
contracts = Set.new
mock_erb(req: converted_req)
described_class.new.process contracts, req, res
expect(res.body).to eq('after')
end
end
context 'one matching contract' do
it 'binds the request and the contract\'s values' do
contract = OpenStruct.new(values: { max: 'test' })
contracts = Set.new([contract])
mock_erb(req: converted_req, max: 'test')
described_class.new.process contracts, req, res
expect(res.body).to eq('after')
end
end
context 'multiple matching contracts' do
it 'binds the request and the first contract\'s values' do
contract1 = OpenStruct.new(values: { max: 'test' })
contract2 = OpenStruct.new(values: { mob: 'team' })
res = Pacto::PactoResponse.new(
status: 200,
body: 'before'
)
mock_erb(req: converted_req, max: 'test')
contracts = Set.new([contract1, contract2])
described_class.new.process contracts, req, res
expect(res.body).to eq('after')
end
end
end
def mock_erb(hash)
expect_any_instance_of(Pacto::ERBProcessor).to receive(:process).with('before', hash).and_return('after')
end
end
================================================
FILE: spec/unit/pacto/investigation_registry_spec.rb
================================================
# -*- encoding : utf-8 -*-
describe Pacto::InvestigationRegistry do
subject(:registry) { described_class.instance }
let(:request_pattern) { Fabricate(:webmock_request_pattern) }
let(:request_signature) { Fabricate(:webmock_request_signature) }
let(:pacto_response) { Fabricate(:pacto_response) }
let(:different_request_signature) { Fabricate(:webmock_request_signature, uri: 'www.thoughtworks.com') }
let(:investigation) { Pacto::Investigation.new(request_signature, pacto_response, nil, []) }
let(:investigation_for_a_similar_request) { Pacto::Investigation.new(request_signature, pacto_response, nil, []) }
let(:investigation_for_a_different_request) { Pacto::Investigation.new(different_request_signature, pacto_response, nil, []) }
before(:each) do
registry.reset!
end
describe 'reset!' do
before(:each) do
registry.register_investigation(investigation)
end
it 'cleans investigations' do
expect { registry.reset! }.to change { registry.validated? request_pattern }.from([investigation]).to(nil)
end
end
describe 'registering and reporting registered investigations' do
it 'returns registered investigation' do
expect(registry.register_investigation investigation).to eq(investigation)
end
it 'reports if investigation is not registered' do
expect(registry.validated? request_pattern).to be_falsey
end
it 'registers and returns matching investigations' do
registry.register_investigation(investigation)
registry.register_investigation(investigation_for_a_similar_request)
registry.register_investigation(investigation_for_a_different_request)
expect(registry.validated? request_pattern).to eq([investigation, investigation_for_a_similar_request])
end
end
describe '.unmatched_investigations' do
let(:contract) { Fabricate(:contract) }
it 'returns investigations with no contract' do
investigation_with_citations = Pacto::Investigation.new(different_request_signature, pacto_response, contract, [])
registry.register_investigation(investigation)
registry.register_investigation(investigation_for_a_similar_request)
registry.register_investigation(investigation_for_a_different_request)
registry.register_investigation(investigation_with_citations)
expect(registry.unmatched_investigations).to match_array([investigation, investigation_for_a_similar_request, investigation_for_a_different_request])
end
end
describe '.failed_investigations' do
let(:contract) { Fabricate(:contract, name: 'test') }
let(:citations2) { ['a sample citation'] }
it 'returns investigations with unsuccessful citations' do
investigation_with_successful_citations = Pacto::Investigation.new(request_signature, pacto_response, nil, ['error'])
investigation_with_unsuccessful_citations = Pacto::Investigation.new(request_signature, pacto_response, nil, %w(error2 error3))
# Twice because of debug statement...
expect(investigation_with_successful_citations).to receive(:successful?).twice.and_return true
expect(investigation_with_unsuccessful_citations).to receive(:successful?).twice.and_return false
registry.register_investigation(investigation)
registry.register_investigation(investigation_with_successful_citations)
registry.register_investigation(investigation_with_unsuccessful_citations)
expect(registry.failed_investigations).to match_array([investigation_with_unsuccessful_citations])
end
end
end
================================================
FILE: spec/unit/pacto/logger_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Logger
describe SimpleLogger do
before do
logger.log logger_lib
end
subject(:logger) { described_class.instance }
let(:logger_lib) { ::Logger.new(StringIO.new) }
it 'delegates debug to the logger lib' do
expect(logger_lib).to receive(:debug)
logger.debug
end
it 'delegates info to the logger lib' do
expect(logger_lib).to receive(:info)
logger.info
end
it 'delegates warn to the logger lib' do
expect(logger_lib).to receive(:warn)
logger.warn
end
it 'delegates error to the logger lib' do
expect(logger_lib).to receive(:error)
logger.error
end
it 'delegates fatal to the logger lib' do
expect(logger_lib).to receive(:error)
logger.error
end
it 'has the default log level as error' do
expect(logger.level).to eq :error
end
it 'provides access to the log level' do
logger.level = :info
expect(logger.level).to eq :info
end
end
end
end
================================================
FILE: spec/unit/pacto/meta_schema_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
describe MetaSchema do
let(:valid_contract) do
<<-EOF
{
"request": {
"method": "GET",
"path": "/hello_world",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"schema": {
"description": "A simple response",
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
EOF
end
let(:partial_contract) do
<<-EOF
{
"request": {
"method": "GET",
"path": "/hello_world",
"headers": {
"Accept": "application/json"
},
"params": {}
}
}
EOF
end
let(:invalid_contract) do
<<-EOF
{
"request": {
"method": "GET",
"path": "/hello_world",
"headers": {
"Accept": "application/json"
},
"params": {}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"schema": {
"description": "A simple response",
"required": {},
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
EOF
end
subject(:schema) { described_class.new }
describe 'when validating a contract against the master schema' do
context 'with a valid contract structure' do
it 'does not raise any exceptions' do
expect do
schema.validate(valid_contract)
end.to_not raise_error
end
end
context 'with an partial contract structure' do
it 'raises InvalidContract exception' do
expect do
schema.validate(invalid_contract)
end.to raise_error(InvalidContract)
end
end
context 'with an invalid contract' do
it 'raises InvalidContract exception' do
expect do
schema.validate(invalid_contract)
end.to raise_error(InvalidContract)
end
end
end
end
end
================================================
FILE: spec/unit/pacto/pacto_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
describe Pacto do
around(:each) do |example|
$stdout = StringIO.new
example.run
$stdout = STDOUT
end
def output
$stdout.string.strip
end
def mock_investigation(errors)
expect(JSON::Validator).to receive(:fully_validate).with(any_args).and_return errors
end
describe '.validate_contract' do
let(:contract_path) { contract_file 'contract' }
context 'valid' do
it 'returns true' do
mock_investigation []
success = described_class.validate_contract contract_path
expect(success).to be true
end
end
context 'invalid' do
it 'raises an InvalidContract error' do
mock_investigation ['Error 1']
expect { described_class.validate_contract contract_path }.to raise_error(Pacto::InvalidContract)
end
end
end
describe 'loading contracts' do
let(:contracts_path) { contracts_folder }
let(:host) { 'localhost' }
it 'instantiates a contract list' do
expect(Pacto::ContractSet).to receive(:new) do |contracts|
contracts.each { |contract| expect(contract).to be_a_kind_of(Pacto::Contract) }
end
described_class.load_contracts(contracts_path, host)
end
end
end
================================================
FILE: spec/unit/pacto/request_pattern_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
describe RequestPattern do
let(:http_method) { :get }
let(:uri_pattern) { double }
let(:request_pattern) { double }
let(:request) { double(http_method: http_method) }
it 'returns a pattern that combines the contracts http_method and uri_pattern' do
expect(UriPattern).to receive(:for).
with(request).
and_return(uri_pattern)
expect(Pacto::RequestPattern).to receive(:new).
with(http_method, uri_pattern).
and_return(request_pattern)
expect(RequestPattern.for(request)).to eq request_pattern
end
end
end
================================================
FILE: spec/unit/pacto/stubs/observers/stenographer_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
module Observers
describe Stenographer do
let(:pacto_request) { Fabricate(:pacto_request) }
let(:pacto_response) { Fabricate(:pacto_response) }
let(:contract) { Fabricate(:contract) }
let(:citations) { %w(one two) }
let(:investigation) { Pacto::Investigation.new(pacto_request, pacto_response, contract, citations) }
subject(:stream) { StringIO.new }
subject { described_class.new stream }
it 'writes to the stenographer log stream' do
subject.log_investigation investigation
expected_log_line = "request #{contract.name.inspect}, values: {}, response: {status: #{pacto_response.status}} # #{citations.size} contract violations\n"
expect(stream.string).to eq expected_log_line
end
context 'when the stenographer log stream is nil' do
let(:stream) { nil }
it 'does nothing' do
# Would raise an error if it tried to write to stream
subject.log_investigation investigation
end
end
end
end
end
================================================
FILE: spec/unit/pacto/stubs/uri_pattern_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
describe UriPattern do
context 'with non-strict matchers' do
before(:each) do
Pacto.configuration.strict_matchers = false
end
it 'appends host if path is an Addressable::Template' do
path_pattern = '/{account}/data{.format}{?page,per_page}'
path = Addressable::Template.new path_pattern
request = Fabricate(:request_clause, host: 'https://www.example.com', path: path)
expect(UriPattern.for(request).pattern).to include(Addressable::Template.new("https://www.example.com#{path_pattern}").pattern)
end
it 'returns a URITemplate containing the host and path and wildcard vars' do
request = Fabricate(:request_clause, host: 'myhost.com', path: '/stuff')
uri_pattern = UriPattern.for(request)
expect(uri_pattern.pattern).to eql('{scheme}://myhost.com/stuff{?anyvars*}')
end
it 'fails if segment uses : syntax' do
expect do
Fabricate(:request_clause, host: 'myhost.com', path: '/:id')
end.to raise_error(/old syntax no longer supported/)
end
it 'creates a regex that does not allow additional path elements' do
request = Fabricate(:request_clause, host: 'myhost.com', path: '/{id}')
pattern = UriPattern.for(request)
expect(pattern).to match('http://myhost.com/foo')
expect(pattern).to_not match('http://myhost.com/foo/bar')
end
it 'creates a regex that does allow query parameters' do
request = Fabricate(:request_clause, host: 'myhost.com', path: '/{id}')
pattern = UriPattern.for(request)
expect(pattern.match('http://myhost.com/foo?a=b')). to be_truthy
expect(pattern.match('http://myhost.com/foo?a=b&c=d')).to be_truthy
end
end
# Strict/relaxed matching should be done against the full URI or path only
context 'with strict matchers', deprecated: true do
it 'returns a string with the host and path' do
Pacto.configuration.strict_matchers = true
request = Fabricate(:request_clause, host: 'myhost.com', path: '/stuff')
uri_pattern = UriPattern.for(request)
expect(uri_pattern.pattern).to eq('{scheme}://myhost.com/stuff')
end
end
end
end
================================================
FILE: spec/unit/pacto/stubs/webmock_adapter_spec.rb
================================================
# -*- encoding : utf-8 -*-
module Pacto
module Stubs
# FIXME: Review this test and see which requests are Pacto vs WebMock, then use Fabricate
describe WebMockAdapter do
let(:middleware) { double('middleware') }
let(:request) do
Fabricate(:request_clause,
host: 'http://localhost',
http_method: http_method,
path: '/hello_world',
headers: { 'Accept' => 'application/json' },
params: { 'foo' => 'bar' }
)
end
let(:http_method) { :get }
let(:response) do
Fabricate(
:response_clause,
status: 200,
headers: {},
schema: {
type: 'object',
required: ['message'],
properties: {
message: {
type: 'string',
default: 'foo'
}
}
}
)
end
let(:contract) do
Fabricate(:contract, request: request, response: response)
end
let(:body) do
{ 'message' => 'foo' }
end
let(:stubbed_request) do
{
path: nil
}
end
let(:request_pattern) { double('request_pattern') }
subject(:adapter) { WebMockAdapter.new middleware }
before(:each) do
allow(stubbed_request).to receive(:to_return).with(no_args)
allow(stubbed_request).to receive(:request_pattern).and_return request_pattern
end
describe '#initialize' do
it 'sets up a hook' do
expect(WebMock).to receive(:after_request) do | _arg, &block |
expect(block.parameters.size).to eq(2)
end
Pacto.configuration.adapter # (rather than WebMockAdapter.new, to prevent rpec after block from creating a second instance
end
end
describe '#process_hooks' do
let(:request_signature) { double('request_signature') }
it 'calls the middleware for processing' do
expect(middleware).to receive(:process).with(a_kind_of(Pacto::PactoRequest), a_kind_of(Pacto::PactoResponse))
adapter.process_hooks request_signature, response
end
end
describe '#stub_request!' do
before(:each) do
expect(WebMock).to receive(:stub_request) do | _method, url |
stubbed_request[:path] = url
stubbed_request
end
end
context 'when the response body is an object' do
let(:body) do
{ 'message' => 'foo' }
end
context 'a GET request' do
let(:http_method) { :get }
it 'uses WebMock to stub the request' do
expect(request_pattern).to receive(:with).
with(headers: request.headers, query: request.params).
and_return(stubbed_request)
adapter.stub_request! contract
end
end
context 'a POST request' do
let(:http_method) { :post }
it 'uses WebMock to stub the request' do
expect(request_pattern).to receive(:with).
with(headers: request.headers, body: request.params).
and_return(stubbed_request)
adapter.stub_request! contract
end
end
context 'a request with no headers' do
let(:request) do
Fabricate(:request_clause,
host: 'http://localhost',
http_method: :get,
path: '/hello_world',
headers: {},
params: { 'foo' => 'bar' }
)
end
it 'uses WebMock to stub the request' do
expect(request_pattern).to receive(:with).
with(query: request.params).
and_return(stubbed_request)
adapter.stub_request! contract
end
end
context 'a request with no params' do
let(:request) do
Fabricate(:request_clause,
host: 'http://localhost',
http_method: :get,
path: '/hello_world',
headers: {},
params: {}
)
end
it 'does not send parameter details to WebMock' do
expect(request_pattern).to_not receive(:with)
adapter.stub_request! contract
end
end
end
end
end
end
end
================================================
FILE: spec/unit/pacto/uri_spec.rb
================================================
# -*- encoding : utf-8 -*-
require 'spec_helper'
module Pacto
describe URI do
it 'returns the path appended to the host' do
uri = URI.for('https://localtest.me', '/bla')
expect(uri.to_s).to eq 'https://localtest.me/bla'
end
it 'uses http as the default scheme for hosts' do
uri = URI.for('localtest.me', '/bla')
expect(uri.to_s).to eq 'http://localtest.me/bla'
end
it 'shows query parameters if initialized with params' do
uri = URI.for('localtest.me', '/bla', 'param1' => 'ble')
expect(uri.to_s).to eq 'http://localtest.me/bla?param1=ble'
end
end
end
================================================
FILE: tasks/release.rake
================================================
require 'octokit'
def github
@client ||= Octokit::Client.new :access_token => ENV['GITHUB_TOKEN']
end
def release_tag
"v#{Pacto::VERSION}"
end
def release
@release ||= github.list_releases('thoughtworks/pacto').find{|r| r.name == release_tag }
end
def changelog
changelog = File.read('changelog.md').split("\n\n\n", 2).first
confirm 'Does the CHANGELOG look correct? ', changelog
end
def confirm(question, data)
puts 'Please confirm...'
puts data
print question
abort 'Aborted' unless $stdin.gets.strip == 'y'
puts 'Confirmed'
data
end
desc 'Tags and pushes the gem'
task :release_gem do
sh 'git', 'tag', '-m', changelog, "v#{Pacto::VERSION}"
sh 'git push origin master'
sh "git push origin v#{Pacto::VERSION}"
sh 'ls pkg/*.gem | xargs -n 1 gem push'
end
desc 'Releases to RubyGems and GitHub'
task :release => [:build, :release_gem, :samples, :package, :create_release, :upload_docs]
desc 'Preview the changelog'
task :changelog do
changelog
end
desc 'Create a release on GitHub'
task :create_release do
github.create_release 'thoughtworks/pacto', release_tag, {:name => release_tag, :body => changelog}
end
desc 'Upload docs to the GitHub release'
task :upload_docs do
Dir['pkg/pacto_docs*'].each do |file|
next if File.directory? file
puts "Uploading #{file}"
github.upload_asset release.url, file
end
end