From 16fc1a08b9eef3f8747c97139e4f7a8203e470df Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Thu, 10 Jul 2014 16:18:42 -0400 Subject: [PATCH 1/8] Splitting ContractBuilder out from Generator --- features/generate/generation.feature | 1 + lib/pacto/contract_builder.rb | 80 ++++++++++++++++++++++++ lib/pacto/generator.rb | 43 ++----------- spec/unit/pacto/contract_builder_spec.rb | 29 +++++++++ spec/unit/pacto/generator_spec.rb | 25 +++++++- 5 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 lib/pacto/contract_builder.rb create mode 100644 spec/unit/pacto/contract_builder_spec.rb diff --git a/features/generate/generation.feature b/features/generate/generation.feature index 1de7ff5..2618bed 100644 --- a/features/generate/generation.feature +++ b/features/generate/generation.feature @@ -46,6 +46,7 @@ Feature: Contract Generation Then the stdout should match this contract: """json { + "name": "/hello", "request": { "headers": { "Accept": "application/json" diff --git a/lib/pacto/contract_builder.rb b/lib/pacto/contract_builder.rb new file mode 100644 index 0000000..970e8e8 --- /dev/null +++ b/lib/pacto/contract_builder.rb @@ -0,0 +1,80 @@ +module Pacto + class ContractBuilder < Hashie::Dash + attr_accessor :source + + def initialize(options = {}) + @schema_generator = options[:schema_generator] ||= JSON::SchemaGenerator + @filters = options[:filters] ||= Pacto::Generator::Filters.new + @data = {} + @source = 'Pacto' # Currently used by JSONSchemaGeneator, but not really useful + end + + # def add_example(name, pacto_request, pacto_response) + # end + + # def add_request_header(name, value) + # end + + # def add_response_header(name, value) + # end + + def name=(service_name) + @data[:name] = service_name + end + + def generate_contract(request, response) + # if hint + # @data[:name] = hint.service_name + # else + @data[:name] = request.uri.path + # end + generate_request(request, response) + generate_response(request, response) + self + end + + def generate_request(request, response) + request = clean({ + headers: @filters.filter_request_headers(request, response), + http_method: request.method, + params: request.uri.query_values, + path: request.uri.path, + schema: generate_schema(request.body) + }) + @data[:request] = request + self + end + + def generate_response(request, response) + response = clean({ + headers: @filters.filter_response_headers(request, response), + status: response.status, + schema: generate_schema(response.body) + }) + @data[:response] = response + self + end + + def build_hash + instance_eval &block if block_given? + clean(@data) + end + + def build(&block) + Contract.new build_hash(&block) + end + + protected + + 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 + end +end diff --git a/lib/pacto/generator.rb b/lib/pacto/generator.rb index 34ca836..e7d69f8 100644 --- a/lib/pacto/generator.rb +++ b/lib/pacto/generator.rb @@ -1,4 +1,5 @@ require 'json/schema_generator' +require 'pacto/contract_builder' module Pacto class Generator @@ -9,11 +10,9 @@ def initialize(schema_version = 'draft3', 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 + @contract_builder = ContractBuilder.new(schema_generator: schema_generator, filters: filters) @consumer = consumer + @validator = validator end def generate(pacto_request, pacto_response) @@ -43,7 +42,9 @@ def generate_from_partial_contract(request_file, host) end def save(source, request, response) - contract = generate_contract source, request, response + @contract_builder.source = source + @contract_builder.generate_contract request, response + 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/, '') @@ -53,38 +54,6 @@ def save(source, request, response) private - def generate_contract(source, request, response) - { - request: generate_request(request, response, source), - response: generate_response(request, response, source) - } - 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 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? } - end - - def generate_schema(source, body, generator_options = Pacto.configuration.generator_options) - return if body.nil? || body.empty? - - body_schema = JSON::SchemaGenerator.generate source, body, generator_options - MultiJson.load(body_schema) - end - def load_contract_file(pacto_request) uri = URI(pacto_request.uri) path = uri.path diff --git a/spec/unit/pacto/contract_builder_spec.rb b/spec/unit/pacto/contract_builder_spec.rb new file mode 100644 index 0000000..454de5a --- /dev/null +++ b/spec/unit/pacto/contract_builder_spec.rb @@ -0,0 +1,29 @@ +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 + + context 'generating from interactions' do + let(:request) { Fabricate(:pacto_request) } + let(:response) { Fabricate(:pacto_response) } + let(:data) { subject.generate_response(request, response).build_hash } + + 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 + end + end +end diff --git a/spec/unit/pacto/generator_spec.rb b/spec/unit/pacto/generator_spec.rb index e0b0cd8..515aec8 100644 --- a/spec/unit/pacto/generator_spec.rb +++ b/spec/unit/pacto/generator_spec.rb @@ -68,12 +68,12 @@ def pretty(obj) 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(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(JSON::SchemaGenerator).to receive(:generate).and_return response_body_schema + 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 @@ -81,7 +81,7 @@ def pretty(obj) 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(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 @@ -116,6 +116,25 @@ def pretty(obj) expect(raw_contract).to eq(pretty(subject)) end end + + context 'with hints' 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) { Pacto::Contract.new(JSON.parse raw_contract) } + + before(:each) do + # Pacto::Generator.hints do + # hint name: 'Foo', method: :get, uri: 'example.com/{asdf}', target_file: '/a/b/c/d/get_foo.json' + # end + end + + xit 'names the contract based on the hint' do + expect(generated_contract.name).to eq('Foo') + end + end end end end From bb5dd811f851241890ec657148c077d43e997f22 Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Thu, 10 Jul 2014 18:45:20 -0400 Subject: [PATCH 2/8] Generation: save examples --- docs/configuration.md | 69 +++++++++++ docs/cops.md | 39 ++++++ docs/forensics.md | 65 ++++++++++ docs/generation.md | 48 ++++++++ docs/rake_tasks.md | 10 ++ docs/rspec.md | 0 docs/samples.md | 137 ++++++++++++++++++++++ docs/server.md | 34 ++++++ docs/server_cli.md | 18 +++ docs/stenographer.md | 20 ++++ features/evolve/existing_services.feature | 6 +- features/generate/generation.feature | 1 + features/steps/pacto_steps.rb | 2 +- lib/pacto/contract_builder.rb | 68 +++++++---- lib/pacto/core/pacto_request.rb | 9 ++ lib/pacto/core/pacto_response.rb | 8 ++ lib/pacto/generator.rb | 5 +- lib/pacto/generator/hint.rb | 9 ++ spec/fabricators/http_fabricator.rb | 2 +- spec/unit/pacto/contract_builder_spec.rb | 65 +++++++++- 20 files changed, 582 insertions(+), 33 deletions(-) create mode 100644 docs/configuration.md create mode 100644 docs/cops.md create mode 100644 docs/forensics.md create mode 100644 docs/generation.md create mode 100644 docs/rake_tasks.md create mode 100644 docs/rspec.md create mode 100644 docs/samples.md create mode 100644 docs/server.md create mode 100644 docs/server_cli.md create mode 100644 docs/stenographer.md create mode 100644 lib/pacto/generator/hint.rb 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..7ec7ab4 --- /dev/null +++ b/docs/generation.md @@ -0,0 +1,48 @@ +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 +``` + 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..2bee87e --- /dev/null +++ b/docs/samples.md @@ -0,0 +1,137 @@ +# 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. + +```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. + +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 2618bed..e025283 100644 --- a/features/generate/generation.feature +++ b/features/generate/generation.feature @@ -36,6 +36,7 @@ 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 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/contract_builder.rb b/lib/pacto/contract_builder.rb index 970e8e8..fca8b84 100644 --- a/lib/pacto/contract_builder.rb +++ b/lib/pacto/contract_builder.rb @@ -5,28 +5,42 @@ class ContractBuilder < Hashie::Dash def initialize(options = {}) @schema_generator = options[:schema_generator] ||= JSON::SchemaGenerator @filters = options[:filters] ||= Pacto::Generator::Filters.new - @data = {} + @data = { request: {}, response: {}, examples: {} } @source = 'Pacto' # Currently used by JSONSchemaGeneator, but not really useful end - # def add_example(name, pacto_request, pacto_response) - # end + def name=(service_name) + @data[:name] = service_name + end - # def add_request_header(name, value) - # 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) + end - # def add_response_header(name, value) - # end + def infer_schemas + # TODO: It'd be awesome if we could infer across all examples + return self if @data[:examples].empty? - def name=(service_name) - @data[:name] = service_name + example = @data[:examples].values.first + sample_request_body = example[:request][:body] + sample_response_body = example[:response][:body] + @data[:request][:schema] = generate_schema(sample_request_body) if sample_request_body + @data[:response][:schema] = generate_schema(sample_response_body) if sample_request_body + self + end + + def without_examples + @export_examples = false + self end def generate_contract(request, response) # if hint # @data[:name] = hint.service_name # else - @data[:name] = request.uri.path + @data[:name] = request.uri.path # end generate_request(request, response) generate_response(request, response) @@ -34,30 +48,32 @@ def generate_contract(request, response) end def generate_request(request, response) - request = clean({ - headers: @filters.filter_request_headers(request, response), - http_method: request.method, - params: request.uri.query_values, - path: request.uri.path, - schema: generate_schema(request.body) - }) + request = clean( + headers: @filters.filter_request_headers(request, response), + http_method: request.method, + params: request.uri.query_values, + path: request.uri.path, + schema: generate_schema(request.body) + ) @data[:request] = request self end def generate_response(request, response) - response = clean({ - headers: @filters.filter_response_headers(request, response), - status: response.status, - schema: generate_schema(response.body) - }) + response = clean( + headers: @filters.filter_response_headers(request, response), + status: response.status, + schema: generate_schema(response.body) + ) @data[:response] = response self end def build_hash - instance_eval &block if block_given? - clean(@data) + instance_eval(&block) if block_given? + @final_data = @data.dup + @final_data.delete(:examples) if exclude_examples? + clean(@final_data) end def build(&block) @@ -66,6 +82,10 @@ def build(&block) protected + def exclude_examples? + @export_examples == false + end + def generate_schema(body, generator_options = Pacto.configuration.generator_options) return if body.nil? || body.empty? diff --git a/lib/pacto/core/pacto_request.rb b/lib/pacto/core/pacto_request.rb index 3ecdc98..1eab296 100644 --- a/lib/pacto/core/pacto_request.rb +++ b/lib/pacto/core/pacto_request.rb @@ -13,6 +13,15 @@ def initialize(data) @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) 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 e7d69f8..3ef42db 100644 --- a/lib/pacto/generator.rb +++ b/lib/pacto/generator.rb @@ -5,7 +5,7 @@ module Pacto class Generator include Logger - def initialize(schema_version = 'draft3', + def initialize(_schema_version = 'draft3', schema_generator = JSON::SchemaGenerator, validator = Pacto::MetaSchema.new, filters = Pacto::Generator::Filters.new, @@ -43,7 +43,10 @@ def generate_from_partial_contract(request_file, host) def save(source, request, response) @contract_builder.source = source + @contract_builder.add_example('default', request, response) + @contract_builder.infer_schemas @contract_builder.generate_contract request, response + @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 diff --git a/lib/pacto/generator/hint.rb b/lib/pacto/generator/hint.rb new file mode 100644 index 0000000..30ce06e --- /dev/null +++ b/lib/pacto/generator/hint.rb @@ -0,0 +1,9 @@ +module Pacto + class Generator + class Hint < Hashie::Dash + property :service_name, required: true + # property :uri_template, required: true + property :target_file + end + end +end diff --git a/spec/fabricators/http_fabricator.rb b/spec/fabricators/http_fabricator.rb index 9a9dd80..cbd66bc 100644 --- a/spec/fabricators/http_fabricator.rb +++ b/spec/fabricators/http_fabricator.rb @@ -30,7 +30,7 @@ when :get, :head, :options nil else - 'some data' + '{"data": "something"}' end end end diff --git a/spec/unit/pacto/contract_builder_spec.rb b/spec/unit/pacto/contract_builder_spec.rb index 454de5a..1b8ef87 100644 --- a/spec/unit/pacto/contract_builder_spec.rb +++ b/spec/unit/pacto/contract_builder_spec.rb @@ -8,22 +8,81 @@ module Pacto 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 - }) + 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 From 12ed912d96d0abae0b4b624c11f3003cc7849d16 Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Fri, 11 Jul 2014 12:00:33 -0400 Subject: [PATCH 3/8] Make Generator a module, move old code to Pacto::Generator::NativeContractGenerator --- features/generate/generation.feature | 2 +- lib/pacto/contract_builder.rb | 8 +- lib/pacto/core/configuration.rb | 2 +- lib/pacto/generator.rb | 77 +++------- lib/pacto/generator/filters.rb | 2 +- lib/pacto/generator/hint.rb | 4 +- .../generator/native_contract_generator.rb | 69 +++++++++ lib/pacto/rake_task.rb | 2 +- spec/unit/pacto/generator/filters_spec.rb | 2 +- .../native_contract_generator_spec.rb | 142 ++++++++++++++++++ spec/unit/pacto/generator_spec.rb | 140 ----------------- 11 files changed, 246 insertions(+), 204 deletions(-) create mode 100644 lib/pacto/generator/native_contract_generator.rb create mode 100644 spec/unit/pacto/generator/native_contract_generator_spec.rb delete mode 100644 spec/unit/pacto/generator_spec.rb diff --git a/features/generate/generation.feature b/features/generate/generation.feature index e025283..f2c8a65 100644 --- a/features/generate/generation.feature +++ b/features/generate/generation.feature @@ -39,7 +39,7 @@ Feature: Contract Generation 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 """ diff --git a/lib/pacto/contract_builder.rb b/lib/pacto/contract_builder.rb index fca8b84..96e274e 100644 --- a/lib/pacto/contract_builder.rb +++ b/lib/pacto/contract_builder.rb @@ -27,7 +27,7 @@ def infer_schemas sample_request_body = example[:request][:body] sample_response_body = example[:response][:body] @data[:request][:schema] = generate_schema(sample_request_body) if sample_request_body - @data[:response][:schema] = generate_schema(sample_response_body) if sample_request_body + @data[:response][:schema] = generate_schema(sample_response_body) if sample_response_body self end @@ -52,8 +52,7 @@ def generate_request(request, response) headers: @filters.filter_request_headers(request, response), http_method: request.method, params: request.uri.query_values, - path: request.uri.path, - schema: generate_schema(request.body) + path: request.uri.path ) @data[:request] = request self @@ -62,8 +61,7 @@ def generate_request(request, response) def generate_response(request, response) response = clean( headers: @filters.filter_response_headers(request, response), - status: response.status, - schema: generate_schema(response.body) + status: response.status ) @data[:response] = response self diff --git a/lib/pacto/core/configuration.rb b/lib/pacto/core/configuration.rb index c6d4c5c..f9b9dd0 100644 --- a/lib/pacto/core/configuration.rb +++ b/lib/pacto/core/configuration.rb @@ -8,7 +8,7 @@ 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 diff --git a/lib/pacto/generator.rb b/lib/pacto/generator.rb index 3ef42db..2f68d92 100644 --- a/lib/pacto/generator.rb +++ b/lib/pacto/generator.rb @@ -1,67 +1,38 @@ -require 'json/schema_generator' -require 'pacto/contract_builder' +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) - @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) - File.write(contract_file, save(uri, pacto_request, pacto_response)) - logger.debug("Generating #{contract_file}") + class << self + # Factory method to return the active contract generator implementation + def contract_generator + NativeContractGenerator.new + end - 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}") + # Factory method to return the active contract generator implementation + def schema_generator + JSON::SchemaGenerator 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 configuration + @configuration ||= Configuration.new + end - def save(source, request, response) - @contract_builder.source = source - @contract_builder.add_example('default', request, response) - @contract_builder.infer_schemas - @contract_builder.generate_contract request, response - @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 + def configure + yield(configuration) + end end - private + class Configuration + 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 index 30ce06e..af546c4 100644 --- a/lib/pacto/generator/hint.rb +++ b/lib/pacto/generator/hint.rb @@ -1,9 +1,11 @@ module Pacto - class Generator + module Generator class Hint < Hashie::Dash property :service_name, required: true # property :uri_template, required: true property :target_file + property :http_method + property :uri_template 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..092b6c1 --- /dev/null +++ b/lib/pacto/generator/native_contract_generator.rb @@ -0,0 +1,69 @@ +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) + 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}") + 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 + @contract_builder.add_example('default', request, response) + @contract_builder.generate_contract request, response + @contract_builder.infer_schemas + @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) + 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) + 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/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..4bc744d --- /dev/null +++ b/spec/unit/pacto/generator/native_contract_generator_spec.rb @@ -0,0 +1,142 @@ +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 + 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(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(: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) { Pacto::Contract.new(JSON.parse raw_contract) } + + # before(:each) do + # Pacto::Generator.configure do |c| + # c.hint 'Foo', http_method: :get, uri_template: 'example.com/{asdf}', target_file: '/a/b/c/d/get_foo.json' + # end + # end + + # xit 'names the contract based on the hint' do + # expect(generated_contract.name).to eq('Foo') + # 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 515aec8..0000000 --- a/spec/unit/pacto/generator_spec.rb +++ /dev/null @@ -1,140 +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(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(: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) { Pacto::Contract.new(JSON.parse raw_contract) } - - before(:each) do - # Pacto::Generator.hints do - # hint name: 'Foo', method: :get, uri: 'example.com/{asdf}', target_file: '/a/b/c/d/get_foo.json' - # end - end - - xit 'names the contract based on the hint' do - expect(generated_contract.name).to eq('Foo') - end - end - end - end -end From bfdecb93272fdc9267dd8bf635e2b73484239128 Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Fri, 11 Jul 2014 15:44:57 -0400 Subject: [PATCH 4/8] Infer names and file path --- lib/pacto/contract_builder.rb | 57 +++++++++++++---- lib/pacto/core/configuration.rb | 2 +- lib/pacto/core/http_middleware.rb | 6 +- lib/pacto/core/pacto_request.rb | 6 +- lib/pacto/generator.rb | 6 ++ lib/pacto/generator/hint.rb | 10 +-- .../generator/native_contract_generator.rb | 24 ++++--- spec/fabricators/http_fabricator.rb | 2 +- spec/unit/pacto/configuration_spec.rb | 4 +- spec/unit/pacto/core/configuration_spec.rb | 2 +- spec/unit/pacto/core/http_middleware_spec.rb | 13 ++++ .../native_contract_generator_spec.rb | 63 +++++++++++++------ 12 files changed, 142 insertions(+), 53 deletions(-) diff --git a/lib/pacto/contract_builder.rb b/lib/pacto/contract_builder.rb index 96e274e..ff40876 100644 --- a/lib/pacto/contract_builder.rb +++ b/lib/pacto/contract_builder.rb @@ -1,5 +1,6 @@ module Pacto - class ContractBuilder < Hashie::Dash + class ContractBuilder < Hashie::Dash # rubocop:disable Style/ClassLength + extend Forwardable attr_accessor :source def initialize(options = {}) @@ -9,25 +10,51 @@ def initialize(options = {}) @source = 'Pacto' # Currently used by JSONSchemaGeneator, but not really useful end - def name=(service_name) - @data[:name] = service_name + 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_name + infer_file + 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_file + return self if @data[:examples].empty? + + _example, hint = example_and_hint + @data[:file] = hint.target_file unless hint.nil? + self end def infer_schemas - # TODO: It'd be awesome if we could infer across all examples return self if @data[:examples].empty? - example = @data[:examples].values.first + # 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 - @data[:response][:schema] = generate_schema(sample_response_body) if sample_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 @@ -37,13 +64,9 @@ def without_examples end def generate_contract(request, response) - # if hint - # @data[:name] = hint.service_name - # else - @data[:name] = request.uri.path - # end generate_request(request, response) generate_response(request, response) + infer_all self end @@ -80,6 +103,12 @@ def build(&block) 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 @@ -94,5 +123,9 @@ def generate_schema(body, generator_options = Pacto.configuration.generator_opti 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 f9b9dd0..4acc77a 100644 --- a/lib/pacto/core/configuration.rb +++ b/lib/pacto/core/configuration.rb @@ -15,7 +15,7 @@ def initialize @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 1eab296..6442a27 100644 --- a/lib/pacto/core/pacto_request.rb +++ b/lib/pacto/core/pacto_request.rb @@ -9,7 +9,7 @@ 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 @@ -35,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/generator.rb b/lib/pacto/generator.rb index 2f68d92..b6b9a47 100644 --- a/lib/pacto/generator.rb +++ b/lib/pacto/generator.rb @@ -23,9 +23,15 @@ def configuration 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 diff --git a/lib/pacto/generator/hint.rb b/lib/pacto/generator/hint.rb index af546c4..97d9577 100644 --- a/lib/pacto/generator/hint.rb +++ b/lib/pacto/generator/hint.rb @@ -1,11 +1,13 @@ module Pacto module Generator - class Hint < Hashie::Dash + class Hint < Pacto::RequestClause property :service_name, required: true - # property :uri_template, required: true property :target_file - property :http_method - property :uri_template + + def matches?(pacto_request) + return false if pacto_request.nil? + Pacto::RequestPattern.for(self).matches?(pacto_request) + end end end end diff --git a/lib/pacto/generator/native_contract_generator.rb b/lib/pacto/generator/native_contract_generator.rb index 092b6c1..e757345 100644 --- a/lib/pacto/generator/native_contract_generator.rb +++ b/lib/pacto/generator/native_contract_generator.rb @@ -25,14 +25,14 @@ def generate(pacto_request, pacto_response) 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)) + 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 - logger.error("Error while generating Contract #{contract_file}: #{e.message}") - logger.error("Backtrace: #{e.backtrace}") + raise StandardError, "Error while generating Contract #{contract_file}: #{e.message}", e.backtrace end end @@ -44,9 +44,8 @@ def generate_from_partial_contract(request_file, host) def save(source, request, response) @contract_builder.source = source - @contract_builder.add_example('default', request, response) - @contract_builder.generate_contract request, response - @contract_builder.infer_schemas + # 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) @@ -59,10 +58,15 @@ def save(source, request, response) private 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) + 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 diff --git a/spec/fabricators/http_fabricator.rb b/spec/fabricators/http_fabricator.rb index cbd66bc..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] 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/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/native_contract_generator_spec.rb b/spec/unit/pacto/generator/native_contract_generator_spec.rb index 4bc744d..301e3ef 100644 --- a/spec/unit/pacto/generator/native_contract_generator_spec.rb +++ b/spec/unit/pacto/generator/native_contract_generator_spec.rb @@ -64,8 +64,8 @@ def pretty(obj) 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 + 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 @@ -118,24 +118,47 @@ def pretty(obj) end end - # context 'with hints' 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) { Pacto::Contract.new(JSON.parse raw_contract) } - - # before(:each) do - # Pacto::Generator.configure do |c| - # c.hint 'Foo', http_method: :get, uri_template: 'example.com/{asdf}', target_file: '/a/b/c/d/get_foo.json' - # end - # end - - # xit 'names the contract based on the hint' do - # expect(generated_contract.name).to eq('Foo') - # 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 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 From 24e68ff39388c702ea5f5b35bd1f64ae0cfb292d Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Mon, 14 Jul 2014 12:19:35 -0400 Subject: [PATCH 5/8] Expand hint file within contracts path --- lib/pacto/contract_builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pacto/contract_builder.rb b/lib/pacto/contract_builder.rb index ff40876..e2336f9 100644 --- a/lib/pacto/contract_builder.rb +++ b/lib/pacto/contract_builder.rb @@ -42,7 +42,7 @@ def infer_file return self if @data[:examples].empty? _example, hint = example_and_hint - @data[:file] = hint.target_file unless hint.nil? + @data[:file] = File.expand_path(hint.target_file, Pacto.configuration.contracts_path) unless hint.nil? self end From 712c0671d6ef494135216fb23c3d3f3c38c9724c Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Fri, 18 Jul 2014 11:11:39 -0400 Subject: [PATCH 6/8] Infer URI path, using hints --- lib/pacto/contract_builder.rb | 13 +++---------- lib/pacto/generator/hint.rb | 12 ++++++++++++ .../generator/native_contract_generator_spec.rb | 5 +++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/pacto/contract_builder.rb b/lib/pacto/contract_builder.rb index e2336f9..0c79ff3 100644 --- a/lib/pacto/contract_builder.rb +++ b/lib/pacto/contract_builder.rb @@ -22,8 +22,8 @@ def add_example(name, pacto_request, pacto_response) end def infer_all + # infer_file # The target file is being chosen inferred by the Generator infer_name - infer_file infer_schemas end @@ -38,14 +38,6 @@ def infer_name self end - def infer_file - return self if @data[:examples].empty? - - _example, hint = example_and_hint - @data[:file] = File.expand_path(hint.target_file, Pacto.configuration.contracts_path) unless hint.nil? - self - end - def infer_schemas return self if @data[:examples].empty? @@ -71,11 +63,12 @@ def generate_contract(request, response) 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: request.uri.path + path: hint.nil? ? request.uri.path : hint.path ) @data[:request] = request self diff --git a/lib/pacto/generator/hint.rb b/lib/pacto/generator/hint.rb index 97d9577..ea84678 100644 --- a/lib/pacto/generator/hint.rb +++ b/lib/pacto/generator/hint.rb @@ -4,10 +4,22 @@ 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/spec/unit/pacto/generator/native_contract_generator_spec.rb b/spec/unit/pacto/generator/native_contract_generator_spec.rb index 301e3ef..2497a0b 100644 --- a/spec/unit/pacto/generator/native_contract_generator_spec.rb +++ b/spec/unit/pacto/generator/native_contract_generator_spec.rb @@ -145,6 +145,11 @@ def pretty(obj) 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) From 55a2551c75856086e849c04c5697b6c8470ce366 Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Fri, 18 Jul 2014 11:12:08 -0400 Subject: [PATCH 7/8] Updated samples and docs --- docs/generation.md | 17 +++++++++ docs/samples.md | 4 --- samples/contracts/get_album_cover | 49 ++++++++++++++++++++++++++ samples/contracts/get_album_cover.json | 48 +++++++++++++++++++++++++ samples/generation.rb | 15 ++++++++ samples/sample_apis/album/cover_api.rb | 11 ++++++ samples/sample_apis/config.ru | 12 ++----- samples/samples.rb | 2 +- 8 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 samples/contracts/get_album_cover create mode 100644 samples/contracts/get_album_cover.json create mode 100644 samples/sample_apis/album/cover_api.rb diff --git a/docs/generation.md b/docs/generation.md index 7ec7ab4..39f7e4c 100644 --- a/docs/generation.md +++ b/docs/generation.md @@ -46,3 +46,20 @@ conn.post do |req| 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/samples.md b/docs/samples.md index 2bee87e..d8b972f 100644 --- a/docs/samples.md +++ b/docs/samples.md @@ -37,11 +37,7 @@ end # Generating a Contract Calling `Pacto.generate!` enables contract generation. - -```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. 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)) From e48397c5de4945d67da0a65ebda52d7f0cd18fd1 Mon Sep 17 00:00:00 2001 From: Max Lincoln Date: Fri, 18 Jul 2014 11:13:37 -0400 Subject: [PATCH 8/8] Expand URI templates when generating requests --- lib/pacto/actors/from_examples.rb | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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