diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..ccf0874
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,69 @@
+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
+```
+
diff --git a/docs/cops.md b/docs/cops.md
new file mode 100644
index 0000000..540fd11
--- /dev/null
+++ b/docs/cops.md
@@ -0,0 +1,39 @@
+
+```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
+```
+
diff --git a/docs/forensics.md b/docs/forensics.md
new file mode 100644
index 0000000..ead4ca3
--- /dev/null
+++ b/docs/forensics.md
@@ -0,0 +1,65 @@
+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'))
+ expect(Pacto).to have_investigated('Echo').with_response(body: /foo.*bar/)
+ end
+end
+```
+
diff --git a/docs/generation.md b/docs/generation.md
new file mode 100644
index 0000000..39f7e4c
--- /dev/null
+++ b/docs/generation.md
@@ -0,0 +1,65 @@
+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'
+```
+
diff --git a/docs/rake_tasks.md b/docs/rake_tasks.md
new file mode 100644
index 0000000..7c6bedf
--- /dev/null
+++ b/docs/rake_tasks.md
@@ -0,0 +1,10 @@
+# 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']
+```
+
diff --git a/docs/rspec.md b/docs/rspec.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/samples.md b/docs/samples.md
new file mode 100644
index 0000000..d8b972f
--- /dev/null
+++ b/docs/samples.md
@@ -0,0 +1,133 @@
+# 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.
+
+```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
+```
+
diff --git a/docs/server.md b/docs/server.md
new file mode 100644
index 0000000..db4a824
--- /dev/null
+++ b/docs/server.md
@@ -0,0 +1,34 @@
+
+```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
+```
+
diff --git a/docs/server_cli.md b/docs/server_cli.md
new file mode 100644
index 0000000..42d3cb5
--- /dev/null
+++ b/docs/server_cli.md
@@ -0,0 +1,18 @@
+# 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
+```
+
diff --git a/docs/stenographer.md b/docs/stenographer.md
new file mode 100644
index 0000000..28e1b3c
--- /dev/null
+++ b/docs/stenographer.md
@@ -0,0 +1,20 @@
+
+```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
+```
+
diff --git a/features/evolve/existing_services.feature b/features/evolve/existing_services.feature
index d929608..76bcab6 100644
--- a/features/evolve/existing_services.feature
+++ b/features/evolve/existing_services.feature
@@ -50,8 +50,8 @@ Feature: Existing services journey
When I successfully run `bundle exec ruby test.rb`
Then the stdout should contain exactly:
"""
- {"thoughtworks":"bar"}
- {"service2":["bar"]}
+ {"thoughtworks":"pacto"}
+ {"service2":["thoughtworks","pacto"]}
"""
@@ -78,5 +78,5 @@ Feature: Existing services journey
"""
Then the stdout should contain exactly:
"""
-
+
"""
diff --git a/features/generate/generation.feature b/features/generate/generation.feature
index 1de7ff5..f2c8a65 100644
--- a/features/generate/generation.feature
+++ b/features/generate/generation.feature
@@ -36,9 +36,10 @@ Feature: Contract Generation
Given a file named "generate.rb" with:
"""ruby
require 'pacto'
+ Pacto.configuration.generator_options[:no_examples] = true
WebMock.allow_net_connect!
- generator = Pacto::Generator.new
+ generator = Pacto::Generator.contract_generator
contract = generator.generate_from_partial_contract('requests/my_contract.json', 'http://localhost:8000')
puts contract
"""
@@ -46,6 +47,7 @@ Feature: Contract Generation
Then the stdout should match this contract:
"""json
{
+ "name": "/hello",
"request": {
"headers": {
"Accept": "application/json"
diff --git a/features/steps/pacto_steps.rb b/features/steps/pacto_steps.rb
index cb1b611..b9e6acc 100644
--- a/features/steps/pacto_steps.rb
+++ b/features/steps/pacto_steps.rb
@@ -36,7 +36,7 @@
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(: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
diff --git a/lib/pacto/actors/from_examples.rb b/lib/pacto/actors/from_examples.rb
index 8f915ab..ca710f1 100644
--- a/lib/pacto/actors/from_examples.rb
+++ b/lib/pacto/actors/from_examples.rb
@@ -30,7 +30,7 @@ def build_request(contract, values = {})
if contract.examples?
example = @selector.select(contract.examples, values)
data = contract.request.to_hash
- data['uri'] = contract.request.uri
+ data['uri'] = build_uri(contract, values)
data['body'] = example.request.body
data['method'] = contract.request.http_method
Pacto::PactoRequest.new(data)
@@ -49,6 +49,19 @@ def build_response(contract, values = {})
@fallback_actor.build_response contract, values
end
end
+
+ def build_uri(contract, values)
+ values ||= {}
+ uri_template = Addressable::Template.new(contract.request.uri)
+ if contract.examples && contract.examples.values.first[:request][:uri]
+ example_uri = contract.examples.values.first[:request][:uri]
+ example_values = uri_template.extract example_uri
+ values = example_values.merge values
+ end
+ missing_keys = uri_template.keys - values.keys
+ logger.warn "Missing keys for building a complete URL: #{missing_keys.inspect}" unless missing_keys.empty?
+ uri_template.expand values
+ end
end
end
end
diff --git a/lib/pacto/contract_builder.rb b/lib/pacto/contract_builder.rb
new file mode 100644
index 0000000..0c79ff3
--- /dev/null
+++ b/lib/pacto/contract_builder.rb
@@ -0,0 +1,124 @@
+module Pacto
+ class ContractBuilder < Hashie::Dash # rubocop:disable Style/ClassLength
+ extend Forwardable
+ attr_accessor :source
+
+ def initialize(options = {})
+ @schema_generator = options[:schema_generator] ||= JSON::SchemaGenerator
+ @filters = options[:filters] ||= Pacto::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
diff --git a/lib/pacto/core/configuration.rb b/lib/pacto/core/configuration.rb
index c6d4c5c..4acc77a 100644
--- a/lib/pacto/core/configuration.rb
+++ b/lib/pacto/core/configuration.rb
@@ -8,14 +8,14 @@ class Configuration
def initialize
@middleware = Pacto::Core::HTTPMiddleware.new
@middleware.add_observer Pacto::Cops, :investigate
- @generator = Pacto::Generator.new
+ @generator = Pacto::Generator.contract_generator
@middleware.add_observer @generator, :generate
@stenographer_log_file ||= File.expand_path('pacto_stenographer.log')
@default_consumer = Pacto::Consumer
@default_provider = Pacto::Provider
@adapter = Stubs::WebMockAdapter.new(@middleware)
@strict_matchers = true
- @contracts_path = nil
+ @contracts_path = '.'
@logger = Logger::SimpleLogger.instance
define_logger_level
@hook = Hook.new {}
diff --git a/lib/pacto/core/http_middleware.rb b/lib/pacto/core/http_middleware.rb
index 3d0a8ab..6b70d71 100644
--- a/lib/pacto/core/http_middleware.rb
+++ b/lib/pacto/core/http_middleware.rb
@@ -11,7 +11,11 @@ def process(request, response)
Pacto.configuration.hook.process contracts, request, response
changed
- notify_observers request, response
+ begin
+ notify_observers request, response
+ rescue StandardError => e
+ logger.error(e)
+ end
end
end
end
diff --git a/lib/pacto/core/pacto_request.rb b/lib/pacto/core/pacto_request.rb
index 3ecdc98..6442a27 100644
--- a/lib/pacto/core/pacto_request.rb
+++ b/lib/pacto/core/pacto_request.rb
@@ -9,10 +9,19 @@ def initialize(data)
mash = Hashie::Mash.new data
@headers = mash.headers.nil? ? {} : mash.headers
@body = mash.body
- @method = mash[:method]
+ @method = normalize(mash[:method])
@uri = mash.uri
end
+ def to_hash
+ {
+ method: method,
+ uri: uri,
+ headers: headers,
+ body: body
+ }
+ end
+
def parsed_body
if body.is_a?(String) && content_type == 'application/json'
JSON.parse(body)
@@ -26,5 +35,9 @@ def parsed_body
def content_type
headers['Content-Type']
end
+
+ def normalize(method)
+ method.to_s.downcase.to_sym
+ end
end
end
diff --git a/lib/pacto/core/pacto_response.rb b/lib/pacto/core/pacto_response.rb
index 43878d4..e588510 100644
--- a/lib/pacto/core/pacto_response.rb
+++ b/lib/pacto/core/pacto_response.rb
@@ -11,6 +11,14 @@ def initialize(data)
@status = mash.status.to_i
end
+ def to_hash
+ {
+ status: status,
+ headers: headers,
+ body: body
+ }
+ end
+
def parsed_body
if body.is_a?(String) && content_type == 'application/json'
JSON.parse(body)
diff --git a/lib/pacto/generator.rb b/lib/pacto/generator.rb
index 34ca836..b6b9a47 100644
--- a/lib/pacto/generator.rb
+++ b/lib/pacto/generator.rb
@@ -1,95 +1,44 @@
-require 'json/schema_generator'
+require 'pacto/generator/native_contract_generator'
+require 'pacto/generator/hint'
module Pacto
- class Generator
+ module Generator
include Logger
- def initialize(schema_version = 'draft3',
- schema_generator = JSON::SchemaGenerator,
- validator = Pacto::MetaSchema.new,
- filters = Pacto::Generator::Filters.new,
- consumer = Pacto::Consumer.new)
- @schema_version = schema_version
- @validator = validator
- @schema_generator = schema_generator
- @filters = filters
- @consumer = consumer
- 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)
- File.write(contract_file, save(uri, pacto_request, pacto_response))
- logger.debug("Generating #{contract_file}")
-
- Pacto.load_contract contract_file, uri.host
- end
- rescue => e
- logger.error("Error while generating Contract #{contract_file}: #{e.message}")
- logger.error("Backtrace: #{e.backtrace}")
+ class << self
+ # Factory method to return the active contract generator implementation
+ def contract_generator
+ NativeContractGenerator.new
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 = generate_contract source, request, response
- 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
+ # Factory method to return the active contract generator implementation
+ def schema_generator
+ JSON::SchemaGenerator
+ end
- def generate_contract(source, request, response)
- {
- request: generate_request(request, response, source),
- response: generate_response(request, response, source)
- }
- end
+ def configuration
+ @configuration ||= Configuration.new
+ end
- def generate_request(request, response, source)
- {
- headers: @filters.filter_request_headers(request, response),
- http_method: request.method,
- params: request.uri.query_values,
- path: request.uri.path,
- schema: generate_schema(source, request.body)
- }.delete_if { |_k, v| v.nil? }
- end
+ def configure
+ yield(configuration)
+ end
- def generate_response(request, response, source)
- {
- headers: @filters.filter_response_headers(request, response),
- status: response.status,
- schema: generate_schema(source, response.body)
- }.delete_if { |_k, v| v.nil? }
+ def hint_for(pacto_request)
+ configuration.hints.find { |hint| hint.matches? pacto_request }
+ end
end
- def generate_schema(source, body, generator_options = Pacto.configuration.generator_options)
- return if body.nil? || body.empty?
+ class Configuration
+ attr_reader :hints
- body_schema = JSON::SchemaGenerator.generate source, body, generator_options
- MultiJson.load(body_schema)
- end
+ def initialize
+ @hints = Set.new
+ end
- def load_contract_file(pacto_request)
- 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)
+ def hint(name, hint_data)
+ @hints << Pacto::Generator::Hint.new(hint_data.merge(service_name: name))
+ end
end
end
end
diff --git a/lib/pacto/generator/filters.rb b/lib/pacto/generator/filters.rb
index 8996f3f..1dfec19 100644
--- a/lib/pacto/generator/filters.rb
+++ b/lib/pacto/generator/filters.rb
@@ -1,5 +1,5 @@
module Pacto
- class Generator
+ module Generator
class Filters
CONNECTION_CONTROL_HEADERS = %w(
Via
diff --git a/lib/pacto/generator/hint.rb b/lib/pacto/generator/hint.rb
new file mode 100644
index 0000000..ea84678
--- /dev/null
+++ b/lib/pacto/generator/hint.rb
@@ -0,0 +1,25 @@
+module Pacto
+ module Generator
+ class Hint < Pacto::RequestClause
+ property :service_name, required: true
+ property :target_file
+
+ def initialize(data)
+ super
+ self.target_file ||= "#{slugify(service_name)}.json"
+ self
+ end
+
+ def matches?(pacto_request)
+ return false if pacto_request.nil?
+ Pacto::RequestPattern.for(self).matches?(pacto_request)
+ end
+
+ private
+
+ def slugify(path)
+ path.downcase.gsub(' ', '_')
+ end
+ end
+ end
+end
diff --git a/lib/pacto/generator/native_contract_generator.rb b/lib/pacto/generator/native_contract_generator.rb
new file mode 100644
index 0000000..e757345
--- /dev/null
+++ b/lib/pacto/generator/native_contract_generator.rb
@@ -0,0 +1,73 @@
+require 'json/schema_generator'
+require 'pacto/contract_builder'
+
+module Pacto
+ module Generator
+ class NativeContractGenerator
+ include Logger
+
+ def initialize(_schema_version = 'draft3',
+ schema_generator = JSON::SchemaGenerator,
+ validator = Pacto::MetaSchema.new,
+ filters = Pacto::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
diff --git a/lib/pacto/rake_task.rb b/lib/pacto/rake_task.rb
index 90fdb6f..f154662 100644
--- a/lib/pacto/rake_task.rb
+++ b/lib/pacto/rake_task.rb
@@ -92,7 +92,7 @@ def validate_contracts(host, dir)
# rubocop:disable MethodLength
def generate_contracts(input_dir, output_dir, host)
WebMock.allow_net_connect!
- generator = Pacto::Generator.new
+ generator = Pacto::Generator.contract_generator
puts "Generating contracts from partial contracts in #{input_dir} and recording to #{output_dir}\n\n"
failed_contracts = []
diff --git a/samples/contracts/get_album_cover b/samples/contracts/get_album_cover
new file mode 100644
index 0000000..309b899
--- /dev/null
+++ b/samples/contracts/get_album_cover
@@ -0,0 +1,49 @@
+{
+ "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": "/Users/Thoughtworker/repos/opensource/pacto/samples/contracts/get_album_cover"
+}
\ No newline at end of file
diff --git a/samples/contracts/get_album_cover.json b/samples/contracts/get_album_cover.json
new file mode 100644
index 0000000..12005d8
--- /dev/null
+++ b/samples/contracts/get_album_cover.json
@@ -0,0 +1,48 @@
+{
+ "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"
+}
\ No newline at end of file
diff --git a/samples/generation.rb b/samples/generation.rb
index 15af7a5..9910c29 100755
--- a/samples/generation.rb
+++ b/samples/generation.rb
@@ -30,3 +30,18 @@
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'
diff --git a/samples/sample_apis/album/cover_api.rb b/samples/sample_apis/album/cover_api.rb
new file mode 100644
index 0000000..a3e62bf
--- /dev/null
+++ b/samples/sample_apis/album/cover_api.rb
@@ -0,0 +1,11 @@
+module AlbumServices
+ class Cover < Grape::API
+ format :json
+ desc 'Ping'
+ namespace :album do
+ get ':id/cover' do
+ { cover: 'image' }
+ end
+ end
+ end
+end
diff --git a/samples/sample_apis/config.ru b/samples/sample_apis/config.ru
index e790357..38466ac 100644
--- a/samples/sample_apis/config.ru
+++ b/samples/sample_apis/config.ru
@@ -1,7 +1,7 @@
require 'grape'
require 'grape-swagger'
require 'json'
-Dir[File.expand_path('../*_api.rb', __FILE__)].each do |f|
+Dir[File.expand_path('../**/*_api.rb', __FILE__)].each do |f|
puts "Requiring #{f}"
require f
end
@@ -15,15 +15,7 @@ module DummyServices
mount DummyServices::Echo
mount DummyServices::Files
mount DummyServices::Reverse
- # mount RescueFrom
- # mount PathVersioning
- # mount HeaderVersioning
- # mount PostPut
- # mount WrapResponse
- # mount PostJson
- # mount ContentType
- # mount UploadFile
- # mount Entities::API
+ mount AlbumServices::Cover
add_swagger_documentation # api_version: 'v1'
end
end
diff --git a/samples/samples.rb b/samples/samples.rb
index fb800df..6a6072e 100755
--- a/samples/samples.rb
+++ b/samples/samples.rb
@@ -30,7 +30,7 @@
# # Generating a Contract
# Calling `Pacto.generate!` enables contract generation.
-Pacto.generate!
+# 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))
diff --git a/spec/fabricators/http_fabricator.rb b/spec/fabricators/http_fabricator.rb
index 9a9dd80..379456b 100644
--- a/spec/fabricators/http_fabricator.rb
+++ b/spec/fabricators/http_fabricator.rb
@@ -9,7 +9,7 @@
transient host: 'example.com'
transient path: '/abcd'
transient params: {}
- method { 'GET' }
+ method { :get }
uri do |attr|
Addressable::URI.heuristic_parse(attr[:host]).tap do |uri|
uri.path = attr[:path]
@@ -30,7 +30,7 @@
when :get, :head, :options
nil
else
- 'some data'
+ '{"data": "something"}'
end
end
end
diff --git a/spec/unit/pacto/configuration_spec.rb b/spec/unit/pacto/configuration_spec.rb
index 0f46c30..7c9e800 100644
--- a/spec/unit/pacto/configuration_spec.rb
+++ b/spec/unit/pacto/configuration_spec.rb
@@ -11,8 +11,8 @@ module Pacto
expect(configuration.strict_matchers).to be true
end
- it 'sets contracts path by default to nil' do
- expect(configuration.contracts_path).to be_nil
+ it 'sets contracts path by default to .' do
+ expect(configuration.contracts_path).to eq('.')
end
it 'sets logger by default to Logger' do
diff --git a/spec/unit/pacto/contract_builder_spec.rb b/spec/unit/pacto/contract_builder_spec.rb
new file mode 100644
index 0000000..1b8ef87
--- /dev/null
+++ b/spec/unit/pacto/contract_builder_spec.rb
@@ -0,0 +1,88 @@
+module Pacto
+ 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
diff --git a/spec/unit/pacto/core/configuration_spec.rb b/spec/unit/pacto/core/configuration_spec.rb
index 0aae6dc..a679f98 100644
--- a/spec/unit/pacto/core/configuration_spec.rb
+++ b/spec/unit/pacto/core/configuration_spec.rb
@@ -3,7 +3,7 @@
let(:contracts_path) { 'path_to_contracts' }
it 'allows contracts_path manual configuration' do
- expect(described_class.configuration.contracts_path).to be_nil
+ expect(described_class.configuration.contracts_path).to eq('.')
described_class.configure do |c|
c.contracts_path = contracts_path
end
diff --git a/spec/unit/pacto/core/http_middleware_spec.rb b/spec/unit/pacto/core/http_middleware_spec.rb
index 3fcb5ff..7b07476 100644
--- a/spec/unit/pacto/core/http_middleware_spec.rb
+++ b/spec/unit/pacto/core/http_middleware_spec.rb
@@ -5,6 +5,12 @@ module Core
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
@@ -17,6 +23,13 @@ module Core
middleware.process request, response
end
+ it 'logs rescues and logs failures' do
+ middleware.add_observer FailingObserver.new, :raise_error
+ middleware.process request, response
+ # FIXME: Add this assertion after switching to the Logging gem.
+ # expect(@log_output).to include 'InvalidContract'
+ end
+
it 'calls the HTTP middleware' do
end
diff --git a/spec/unit/pacto/generator/filters_spec.rb b/spec/unit/pacto/generator/filters_spec.rb
index 5675867..ab06d9b 100644
--- a/spec/unit/pacto/generator/filters_spec.rb
+++ b/spec/unit/pacto/generator/filters_spec.rb
@@ -1,5 +1,5 @@
module Pacto
- class Generator
+ module Generator
describe Filters do
let(:record_host) do
'http://example.com'
diff --git a/spec/unit/pacto/generator/native_contract_generator_spec.rb b/spec/unit/pacto/generator/native_contract_generator_spec.rb
new file mode 100644
index 0000000..2497a0b
--- /dev/null
+++ b/spec/unit/pacto/generator/native_contract_generator_spec.rb
@@ -0,0 +1,170 @@
+module Pacto
+ module Generator
+ describe NativeContractGenerator 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
diff --git a/spec/unit/pacto/generator_spec.rb b/spec/unit/pacto/generator_spec.rb
deleted file mode 100644
index e0b0cd8..0000000
--- a/spec/unit/pacto/generator_spec.rb
+++ /dev/null
@@ -1,121 +0,0 @@
-module Pacto
- describe Generator 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
- expect(filters).to receive(:filter_request_headers).with(request, response_adapter).and_return filtered_request_headers
- expect(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(JSON::SchemaGenerator).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(JSON::SchemaGenerator).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(JSON::SchemaGenerator).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
- end
- end
-end