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 ================================================ [![Gem Version](https://badge.fury.io/rb/pacto.png)](http://badge.fury.io/rb/pacto) [![Build Status](https://travis-ci.org/thoughtworks/pacto.png)](https://travis-ci.org/thoughtworks/pacto) [![Code Climate](https://codeclimate.com/github/thoughtworks/pacto.png)](https://codeclimate.com/github/thoughtworks/pacto) [![Dependency Status](https://gemnasium.com/thoughtworks/pacto.png)](https://gemnasium.com/thoughtworks/pacto) [![Coverage Status](https://coveralls.io/repos/thoughtworks/pacto/badge.png)](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