diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9fd53b94..a82bcaa9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,13 +23,33 @@ jobs: with: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true - - run: "bundle exec rake" - - run: "bundle install && bundle exec rake spec" + - uses: you54f/pact-cli@main + - run: pact-cli plugin install --yes https://github.com/mefellows/pact-matt-plugin/releases/tag/v0.1.1 + - name: Test Pact-Ruby Specs + run: "bundle exec rake" + - name: Test Pact-Ruby Zoo App Specs + run: "bundle install && bundle exec rake spec" if: matrix.ruby_version > '3.0' working-directory: example/zoo-app - - run: "bundle install && bundle exec rake pact:verify" + - name: Test Pact-Ruby Animal Service Specs + run: "bundle install && bundle exec rake pact:verify" if: matrix.os != 'windows-latest' && matrix.ruby_version > '3.0' working-directory: example/animal-service + - name: Test Pact-Ruby v2 spec:v2 + run: "bundle exec rake spec:v2" + - name: Test Pact-Ruby v2 pact:v2:spec + run: "bundle exec rake pact:v2:spec" + - name: Test Pact-Ruby v2 pact:v2:verify + run: "bundle exec rake pact:v2:verify" + - name: Test Pact-Ruby v2 Zoo App Specs + run: "bundle install && bundle exec rake spec:v2" + if: matrix.ruby_version > '3.0' + working-directory: example/zoo-app-v2 + - name: Test Pact-Ruby v2 Animal Service Specs + run: "bundle install && bundle exec rake pact:v2:verify" + if: matrix.os != 'windows-latest' && matrix.ruby_version > '3.0' + working-directory: example/animal-service-v2 + test-with-rack-2: runs-on: ${{ matrix.os }} strategy: @@ -43,8 +63,17 @@ jobs: with: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true + - uses: you54f/pact-cli@main + - run: pact-cli plugin install --yes https://github.com/mefellows/pact-matt-plugin/releases/tag/v0.1.1 - run: "bundle exec appraisal install" - run: "bundle exec appraisal rack-2 rake" + - run: "bundle exec appraisal rack-2 rake spec:v2" + - name: Test Mixed Pacts (Http/Kafaka/Grpc) - Pact-Ruby v2 + run: "bundle exec appraisal rack-2 rake pact:v2:spec" + - name: Verify Mixed Pacts (Http/Kafaka/Grpc) - Pact-Ruby v2 + run: "bundle exec appraisal rack-2 rake pact:v2:verify" + if: matrix.os != 'windows-latest' && matrix.ruby_version > '3.0' + test-with-active-support: runs-on: ${{ matrix.os }} strategy: @@ -61,5 +90,12 @@ jobs: with: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true + - run: bundle install - run: "bundle exec appraisal install" - - run: "bundle exec appraisal activesupport rake spec_with_active_support" \ No newline at end of file + name: "install active support - pact-ruby" + - run: "bundle exec appraisal activesupport rake spec_with_active_support" + name: "test with active support - pact-ruby" + - run: "bundle exec rake spec:v2" + name: "test with active support - pact-ruby v2" + env: + LOAD_ACTIVE_SUPPORT: 'true' diff --git a/.gitignore b/.gitignore index 04a55010..89b4b4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ _yardoc coverage lib/bundler/man pkg +# grpc generated files required for testing +!spec/internal/pkg rdoc spec/reports test/tmp @@ -28,8 +30,9 @@ log .idea reports Gemfile.lock +example/**/Gemfile.lock *.gemfile.lock -gemfiles/*.gemfile.lock +gemfiles reports/pacts spec/examples.txt *bethtest* diff --git a/.rspec b/.rspec index 2559e39f..5f164763 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,2 @@ --color --format progress ---require spec_helper diff --git a/.rspec_v2 b/.rspec_v2 new file mode 100644 index 00000000..db0725f6 --- /dev/null +++ b/.rspec_v2 @@ -0,0 +1,4 @@ +--color +--format progress +--require spec_helper_v2 +--require rails_helper_v2 \ No newline at end of file diff --git a/Appraisals b/Appraisals index b62fb71d..f7279152 100644 --- a/Appraisals +++ b/Appraisals @@ -8,4 +8,9 @@ end appraise "activesupport" do gem "activesupport", "~> 5.1" + if RUBY_VERSION >= "3.4" + gem "csv" + gem "mutex_m" + gem "base64" + end end diff --git a/Gemfile b/Gemfile index 4523ca9c..31342d03 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,7 @@ gemspec gem "rspec-mocks", "3.13.5" gem "appraisal", "~> 2.5" +gem "pact-support", git: "https://github.com/pact-foundation/pact-support.git", branch: "feat/generator_mock_server-url" if ENV['X_PACT_DEVELOPMENT'] gem "pact-support", path: '../pact-support' @@ -26,11 +27,12 @@ end group :test do gem 'faraday', '~>2.0', '<3.0' gem 'faraday-retry', '~>2.0' - gem 'rackup', '~> 2.1' + gem 'rackup' + gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw] end if RUBY_VERSION >= "3.4" gem "csv" gem "mutex_m" gem "base64" -end \ No newline at end of file +end diff --git a/documentation/README_V2.md b/documentation/README_V2.md new file mode 100644 index 00000000..e79e5828 --- /dev/null +++ b/documentation/README_V2.md @@ -0,0 +1,483 @@ +# Pact Ruby V2 + +The `pact-ruby-v1` is in maintenance mode, as there has been a transition to rust-core, which is intended to be used through FFI in non-Rust stacks. + +`pact-ruby v2` implements support for the latest versions of Pact specifications: + +- It's based on pact-ffi and pact-ruby-ffi +- It provides a convenient DSL, simplifying the writing of contract tests in Ruby/RSpec +- Writing contract tests with HTTP transports +- Writing contract tests with non-HTTP transports (for example, gRPC) +- Writing contract tests for async messages (Kafka, etc.) +- Verifying contract tests for HTTP/non-HTTP/async message transport + - V4 specification supports mixed pact interactions in a single file. + +## Architecture + +![Pact tests architecture](./pact-v2-arch.png) + +- DSL - implementation of RSpec-DSL for convenient writing of Pact tests +- Matchers - implementation of Pact matchers, which are convenient helpers used in consumer-DSL, encapsulating all the logic for serialization into Pact format +- Mock servers - mock servers that allow for correct execution of provider tests + +## Usage + +For each type of interaction (due to their specific features), a separate version of DSL has been implemented. However, the general principles remain the same for each type of interaction. + +Place your consumer tests under + +`spec/pact/provider/**` + +**it's not an error: consumer tests contain `providers` subdirectory (because we're testing against different providers)** + +```ruby + +# Declaration of a consumer test, always include the :pact tag +# This is used in CI/CD pipelines to separate Pact tests from other RSpec tests +# Pact tests are not run as part of the general RSpec pipeline +RSpec.describe "SomePactConsumerTestForAnyTransport", :pact do + # declaration of the type of interaction - here we determine which consumer and provider interact on which transport + has_http_pact_between "CONSUMER-NAME", "PROVIDER-NAME" + # or + has_grpc_pact_between "CONSUMER-NAME", "PROVIDER-NAME" + # or + has_message_pact_between "CONSUMER-NAME", "PROVIDER-NAME" + + # the context for one of the interactions, for example GET /api/v2/stores + context "with GET /api/v2/stores" do + let(:interaction) do + # creating a new interaction - within which we describe the contract + new_interaction + # if you need to save any metadata for subsequent use by the test provider, + # for example, specify the entity ID that will need to be moved to the database in the test provider + # we use the provider states, see more at https://docs.pact.io/getting_started/provider_states + .given("UNIQUE PROVIDER STATE", key1: value1, key2: value2) + # the description of the interaction, used for identification inside the package binding, + # is optional in some cases, but it is recommended to always specify + .upon_receiving("UNIQUE INTERACTION DESCRIPTION") + # the description of the request using the matchers + # the name and parameters of the method differ for different transports + .with_request(...) + # the description of the response using the matchers + # the name and parameters of the method differ for different transports + .will_respond_with(...) + # further, there are differences for different types of transports, + # for more information, see the relevant sections of the documentation + end + + it "executes the pact test without errors" do | mock_server | + interaction.execute do + # the url of the started mock server, you should pass this into your api client in the next step + mock_server_url = mock_server.url + # here our client is called for the API being tested + # in this context, the client can be: http client, grpc client, kafka consumer + expect(make_request).to be_success + end + end + end + end + +``` + +Common DSL Methods: + +- `new_interaction` - initializes a new interaction +- `given` - allows specifying a provider state with or without parameters, for more details see +- `upon_receiving` - allows specifying the name of the interaction + +Multiple interactions can be declared within a single rspec example, in order to call the mock server + +- `execute_http_pact`: Use this instead of `interaction.execute` + +### HTTP consumers + +Specific DSL methods: + +- `with_request({method: string, path: string, headers: kv_hash, body: kv_hash})` - request definition +- `will_respond_with({status: int, headers: kv_hash, body: kv_hash})` - response definition + +More at [http_client_spec.rb](../spec/pact/providers/pact-ruby-v2-test-app/http_client_spec.rb) + +### gRPC consumers + +Specific DSL methods: + +- `with_service(PROTO_PATH, RPC_SERVICE_AND_ACTION)` - specifies the contract used, PROTO_PATH is relative from the app root +- `with_request(request_kv_hash)` - request definition +- `will_respond_with(response_kv_hash)` - response definition + +More at [grpc_client_spec.rb](../spec/pact/providers/pact-ruby-v2-test-app/grpc_client_spec.rb) + +### Kafka consumers + +Specific DSL methods: + +- `with_headers(kv_hash)` - message-headers definition; you can use matchers +- `with_metadata(kv_hash)` - message-metadata definition (special keys are `key` and `topic`, where, respectively, you can specify the matchers for the partitioning key and the topic + +Next, the specifics are one of two options for describing the format: + +**JSON** (to describe a message in a JSON representation): + +- `with_json_contents(kv_hash)` - message format definition + +**PROTO** (to describe the message in the protobuf view): + +- `with_proto_class(PROTO_PATH, PROTO_MESSAGE_NAME)` - specifies the contract used, PROTO_PATH is relative to the root, PROTO_MESSAGE_NAME is the name of the message used from the proto file +- `with_proto_contents(kv_hash)` - message format definition + +More at [kafka_spec.rb](../spec/pact/providers/pact-ruby-v2-test-app/kafka_spec.rb) + +### Matchers + +Matchers are special helper methods that allow you to define rules for matching request/response parameters at the level of the pact manifest. +The matchers are described in the [Pact specifications](https://github.com/pact-foundation/pact-specification). In this gem, the matchers are implemented as RSpec helpers. + +For details of the implementation, see [matchers.rb](../lib/pact/v2/matchers.rb) + +- `match_exactly(sample)` - match the exact value specified in the sample +- `match_type_of(sample)` - match the data type (integer, string, boolean) specified in the sample +- `match_include(sample)` - match a substring +- `match_any_string(sample)` - match any string, because of the peculiarities, null and empty strings will also be matched here +- `match_any_integer(sample)` - match any integer +- `match_any_decimal(sample)` - match any float/double +- `match_any_number(sample)` - match any integer/float/double +- `match_any_boolean(sample)` - match any true/false +- `match_uuid(sample)` - match any UUID (`match_regex` is used under the hood) +- `match_regex(regex, sample)` - match by regexp +- `match_datetime(format, sample)` - match any datetime +- `match_iso8601(sample)` - match datetime in ISO8601 (the matcher does not fully comply with ISO8601, matches only the most common variants, `match_regex` is used under the hood) +- `match_date(format, sample)` - match any date (rust datetime) +- `match_time(format, sample)` - match any time (rust datetime) +- `match_each(template)` - match all the elements of the array according to the specified template, you can use it for nested elements +- `match_each_regex(regex, sample)` - match all array elements by regex, used for arrays with string elements +- `match_each_key(template, key_matchers)` - match each hash key according to the specified template +- `match_each_value(template)` - match each hash value according to the specified template, can be used for nested elements +- `match_each_kv(template, key_matchers)` - match all the keys/values of Hash according to the specified template and key_matchers, can be used for nested elements + +See the different uses of the matchers in [matchers_spec.rb](../spec/pact/v2/matchers_spec.rb) + +### Generators + +Generators are helper methods that allow you to specify dynamic values in your contract tests. These values are generated at runtime, making your contracts more flexible and robust. Below are the available generator methods: + +For details of the implementation, see [matchers.rb](../lib/pact/v2/generators.rb) + +- `generate_random_int(min:, max:)` - Generates a random integer between the specified `min` and `max`. +- `generate_random_decimal(digits:)` - Generates a random decimal number with the specified number of `digits`. +- `generate_random_hexadecimal(digits:)` - Generates a random hexadecimal string with the specified number of `digits`. +- `generate_random_string(size:)` - Generates a random string of the specified `size`. +- `generate_uuid(example: nil)` - Generates a random UUID. Optionally, provide an `example` value. +- `generate_date(format: nil, example: nil)` - Generates a date string in the specified `format`. Optionally, provide an `example`. +- `generate_time(format: nil)` - Generates a time string in the specified `format`. +- `generate_datetime(format: nil)` - Generates a datetime string in the specified `format`. +- `generate_random_boolean` - Generates a random boolean value (`true` or `false`). +- `generate_from_provider_state(expression:, example:)` - Generates a value from the provider state using the given `expression` and `example` value. Allows templating of url and query paths with values only know at provider verification time. +- `generate_mock_server_url(regex: nil, example: nil)` - Generates a mock server URL. Optionally, specify a `regex` matches and/or an `example` value. + +These generators can be used in your DSL definitions to provide dynamic values for requests, responses, or messages in your contract tests. + +#### Generator Examples + +```rb + .with_request( + method: :get, + path: generate_from_provider_state( + expression: '/alligators/${alligator_name}', + example: '/alligators/Mary'), + headers: headers) + +... + + body: { + _links: { + :'pf:publish-provider-contract' => { + href: generate_mock_server_url( + regex: ".*(\\/provider-contracts\\/provider\\/.*\\/publish)$", + example: "/provider-contracts/provider/{provider}/publish" + ), + boolean: generate_random_boolean, + integer: generate_random_int(min: 1, max: 100), + decimal: generate_random_decimal(digits: 2), + hexidecimal: generate_random_hexadecimal(digits: 8), + string: generate_random_string(size: 10), + uuid: generate_uuid, + date: generate_date(format: "yyyyy.MMMMM.dd GGG"), + time: generate_time(), + datetime: generate_datetime(format: "%Y-%m-%dT%H:%M:%S%z") + } + } + } +``` + +## Provider verification + +Place your provider verification file under + +`spec/pact/consumers/**` + +**it's not an error: provider tests contain `consumers` subdirectory (because we're verifying against different consumer)** + +### Provider verification options + +```rb + @provider_name = provider_name + @log_level = opts[:log_level] || :info + @pact_dir = opts[:pact_dir] || nil + @provider_setup_port = opts[:provider_setup_port] || 9001 + @pact_proxy_port = opts[:provider_setup_port] || 9002 + @pact_uri = ENV.fetch("PACT_URL", nil) || opts.fetch(:pact_uri, nil) + @publish_verification_results = ENV.fetch("PACT_PUBLISH_VERIFICATION_RESULTS", nil) == "true" || opts.fetch(:publish_verification_results, false) + @provider_version = ENV.fetch("PACT_PROVIDER_VERSION", nil) || opts.fetch(:provider_version, nil) + @provider_build_uri = ENV.fetch("PACT_PROVIDER_BUILD_URL", nil) || opts.fetch(:provider_build_uri, nil) + @provider_version_branch = ENV.fetch("PACT_PROVIDER_BRANCH", nil) || opts.fetch(:provider_version_branch, nil) + @provider_version_tags = ENV.fetch("PACT_PROVIDER_VERSION_TAGS", nil) || opts.fetch(:provider_version_tags, []) + @consumer_version_tags = ENV.fetch("PACT_CONSUMER_VERSION_TAGS", nil) || opts.fetch(:consumer_version_tags, []) + @consumer_version_selectors = ENV.fetch("PACT_CONSUMER_VERSION_SELECTORS", nil) || opts.fetch(:consumer_version_selectors, nil) + @enable_pending = ENV.fetch("PACT_VERIFIER_ENABLE_PENDING", nil) == "true" || opts.fetch(:enable_pending, false) + @include_wip_pacts_since = ENV.fetch("PACT_INCLUDE_WIP_PACTS_SINCE", nil) || opts.fetch(:include_wip_pacts_since, nil) + @fail_if_no_pacts_found = ENV.fetch("PACT_FAIL_IF_NO_PACTS_FOUND", nil) == "true" || opts.fetch(:fail_if_no_pacts_found, true) + @consumer_branch = ENV.fetch("PACT_CONSUMER_BRANCH", nil) || opts.fetch(:consumer_branch, nil) + @consumer_version = ENV.fetch("PACT_CONSUMER_VERSION", nil) || opts.fetch(:consumer_version, nil) + @consumer_name = opts[:consumer_name] + @broker_url = ENV.fetch("PACT_BROKER_BASE_URL", nil) || opts.fetch(:broker_url, nil) + @broker_username = ENV.fetch("PACT_BROKER_USERNAME", nil) || opts.fetch(:broker_username, nil) + @broker_password = ENV.fetch("PACT_BROKER_PASSWORD", nil) || opts.fetch(:broker_password, nil) + @broker_token = ENV.fetch("PACT_BROKER_TOKEN", nil) || opts.fetch(:broker_token, nil) + @verify_only = [ENV.fetch("PACT_CONSUMER_FULL_NAME", nil)].compact || opts.fetch(:verify_only, []) +``` + +### Single transport providers + +```rb +# frozen_string_literal: true + +require "pact_broker" +require "pact_broker/app" +require "rspec/mocks" +include RSpec::Mocks::ExampleMethods +require_relative "../../service_consumers/hal_relation_proxy_app" + +PactBroker.configuration.base_urls = ["http://example.org"] + +pact_broker = PactBroker::App.new { |c| c.database_connection = PactBroker::TestDatabase.connection_for_test_database } +app_to_verify = HalRelationProxyApp.new(pact_broker) + +require "pact" +require "pact/v2/rspec" +require_relative "../../service_consumers/shared_provider_states" +RSpec.describe "Verify consumers for Pact Broker", :pact_v2 do + + http_pact_provider "Pact Broker", opts: { + + # rails apps should be automatically detected + # if you need to configure your own app, you can do so here + + app: app_to_verify, + # start rackup with a different port. Useful if you already have something + # running on the default port *9292* + http_port: 9393, + + # Set the log level, default is :info + + log_level: :info, + + fail_if_no_pacts_found: true, + + # Pact Sources + + # 1. Local pacts from a directory + + # Default is pacts directory in the current working directory + # pact_dir: File.expand_path('../../../../consumer/spec/internal/pacts', __dir__), + + # 2. Broker based pacts + + # Broker credentials + + # broker_username: "pact_workshop", # can be set via PACT_BROKER_USERNAME env var + # broker_password: "pact_workshop", # can be set via PACT_BROKER_PASSWORD env var + # broker_token: "pact_workshop", # can be set via PACT_BROKER_TOKEN env var + + # Remote pact via a uri, traditionally triggered via webhooks + # when a pact that requires verification is published + + # 2a. Webhook triggered pacts + # Can be a local file or a remote URL + # Most used via webhooks + # Can be set via PACT_URL env var + # pact_uri: File.expand_path("../../../pacts/pact.json", __dir__), + pact_uri: "https://raw.githubusercontent.com/YOU54F/pact_broker-client/refs/heads/feat/pact-ruby-v2/spec/pacts/Pact%20Broker%20Client%20V2-Pact%20Broker.json", + # pact_uri: "https://raw.githubusercontent.com/YOU54F/pact_broker-client/refs/heads/feat/pact-ruby-v2/spec/pacts/pact_broker_client-pact_broker.json", + # pact_uri: "http://localhost:9292/pacts/provider/Pact%20Broker/consumer/Pact%20Broker%20Client/version/96532124f3a53a499276c69ff2df785b8377588e", + + # 2b. Dynamically fetched pacts from broker + + # i. Set the broker url + # broker_url: "http://localhost:9292", # can be set via PACT_BROKER_URL env var + + # ii. Set the consumer version selectors + # Consumer version selectors + # The pact broker will return the following pacts by default, if no selectors are specified + # For the recommended setup, you dont _actually_ need to specify these selectors in ruby + # consumer_version_selectors: [{"deployedOrReleased" => true},{"mainBranch" => true},{"matchingBranch" => true}], + + # iii. Set additional dynamic selection verification options + # additional dynamic selection verification options + enable_pending: true, + include_wip_pacts_since: "2021-01-01", + + # Publish verification results to the broker + publish_verification_results: ENV["PACT_PUBLISH_VERIFICATION_RESULTS"] == "true", + provider_version: `git rev-parse HEAD`.strip, + provider_version_branch: `git rev-parse --abbrev-ref HEAD`.strip, + provider_version_tags: [`git rev-parse --abbrev-ref HEAD`.strip], + # provider_build_uri: "YOUR CI URL HERE - must be a valid url", + + } + + before_state_setup do + PactBroker::TestDatabase.truncate + end + + after_state_teardown do + PactBroker::TestDatabase.truncate + end + + shared_provider_states + +end +``` + +### Multiple transport providers + +You may have a consumer pact which consumes multiple transport protocols, if they are using pact specification v4. + +In order to validate an entire pact in a single test run, you will need to configure each transport as appropriate. + +```rb +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe "Pact::V2::Consumers::Http", :pact_v2 do + mixed_pact_provider "pact-v2-test-app", opts: { + http: { + http_port: 3000, + log_level: :info, + pact_dir: File.expand_path('../../pacts', __dir__), + }, + grpc: { + grpc_port: 3009 + }, + async: { + message_handlers: { + # "pet message as json" => proc do |provider_state| + # pet_id = provider_state.dig("params", "pet_id") + # with_pact_producer { |client| PetJsonProducer.new(client: client).call(pet_id) } + # end, + # "pet message as proto" => proc do |provider_state| + # pet_id = provider_state.dig("params", "pet_id") + # with_pact_producer { |client| PetProtoProducer.new(client: client).call(pet_id) } + # end + } + } + } + + handle_message "pet message as json" do |provider_state| + pet_id = provider_state.dig("params", "pet_id") + with_pact_producer { |client| PetJsonProducer.new(client: client).call(pet_id) } + end + + handle_message "pet message as proto" do |provider_state| + pet_id = provider_state.dig("params", "pet_id") + with_pact_producer { |client| PetProtoProducer.new(client: client).call(pet_id) } + end + +end + +``` + +## Development & Test + +### Setup + +```shell +bundle install +``` + +### Run unit tests + +```shell +bundle exec rake spec:v2 +``` + +### Run pact tests + +The Pact tests are not run within the general rspec pipeline, they need to be run separately, see below + +#### Consumer tests + +```shell +bundle exec rspec -t pact spec/pact/providers/**/*_spec.rb +or +bundle exec rake pact:v2:spec +``` + +**NOTE** If you have never run it, you need to run it at least once to generate the pact files that will be used in provider tests (below) + +#### Provider tests + +```shell +bundle exec rspec -t pact spec/pact/consumers/*_spec.rb +or +bundle exec rake pact:v2:spec +``` + +## Examples + +### Migration + +The following projects were designed for pact-ruby-v1 and have been migrated to pact-ruby-v2. They can serve as an example of the work required. + +- pact broker client + - v1 + - v2 +- pact broker + - v1 + - v2 +- animal service + - v1 + - In repo: [example/animal-service](../example/animal-service/) + - Standalone: + - v2 + - In repo: [example/animal-service-v2](../example/animal-service-v2/) + - Standalone: +- zoo app + - v1 + - In repo: [example/zoo-app](../example/zoo-app/) + - Standalone: + - v2 + - In repo: [example/zoo-app-v2](../example/zoo-app-v2/) + - Standalone: +- message consumer/provider + - v1 + - v2 +- e2e http consumer/provider + - v1 + - v2 + +### Demos + +- http consumer + - In repo: [http_client_spec.rb](../spec/pact/providers/pact-ruby-v2-test-app/http_client_spec.rb) + - Standalone: +- kafka consumer + - In repo: [kafka_spec.rb](../spec/pact/providers/pact-ruby-v2-test-app/kafka_spec.rb) + - Standalone: +- grpc consumer + - In repo: [grpc_client_spec.rb](../spec/pact/providers/pact-ruby-v2-test-app/grpc_client_spec.rb) + - Standalone: +- mixed(http/kafka/grpc) provider + - In repo: [multi_spec.rb](../spec/pact/consumers/multi_spec.rb) + - Standalone: diff --git a/documentation/pact-v2-arch.png b/documentation/pact-v2-arch.png new file mode 100644 index 00000000..eed533c6 Binary files /dev/null and b/documentation/pact-v2-arch.png differ diff --git a/example/animal-service-v2/Gemfile b/example/animal-service-v2/Gemfile new file mode 100644 index 00000000..32db4e3e --- /dev/null +++ b/example/animal-service-v2/Gemfile @@ -0,0 +1,18 @@ +source 'https://rubygems.org' + +group :development, :test do + gem 'rspec' + gem 'pact', path: '../../' + gem 'pry' + # required for pact-ruby-v2 + gem 'combustion' + gem 'webmock' +end + +gem 'rake' +gem 'rack' +gem 'sqlite3' +gem 'sequel' +gem 'sinatra' + +gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw] \ No newline at end of file diff --git a/example/animal-service-v2/Rakefile b/example/animal-service-v2/Rakefile new file mode 100644 index 00000000..1573f5da --- /dev/null +++ b/example/animal-service-v2/Rakefile @@ -0,0 +1,12 @@ +$: << File.join(File.dirname(__FILE__), "lib") + +require 'pact/tasks' + +task :default => 'pact:verify' + +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new('pact:v2:verify') do |task| + task.pattern = 'spec/pact/consumers/*_spec.rb' + task.rspec_opts = ['-t pact_v2', '--require rails_helper'] +end \ No newline at end of file diff --git a/example/animal-service-v2/config.ru b/example/animal-service-v2/config.ru new file mode 100644 index 00000000..c5623c9f --- /dev/null +++ b/example/animal-service-v2/config.ru @@ -0,0 +1,3 @@ +require File.dirname(__FILE__) + '/lib/animal_service/api' + +run AnimalService::Api diff --git a/example/animal-service-v2/db/animal_db.sqlite3 b/example/animal-service-v2/db/animal_db.sqlite3 new file mode 100644 index 00000000..bc4f88e3 Binary files /dev/null and b/example/animal-service-v2/db/animal_db.sqlite3 differ diff --git a/example/animal-service-v2/lib/animal_service/animal_repository.rb b/example/animal-service-v2/lib/animal_service/animal_repository.rb new file mode 100644 index 00000000..f26b4a23 --- /dev/null +++ b/example/animal-service-v2/lib/animal_service/animal_repository.rb @@ -0,0 +1,12 @@ +require 'sequel' +require_relative 'db' + +module AnimalService + class AnimalRepository + + def self.find_alligator_by_name name + DATABASE[:animals].where(name: name).single_record + end + + end +end diff --git a/example/animal-service-v2/lib/animal_service/api.rb b/example/animal-service-v2/lib/animal_service/api.rb new file mode 100644 index 00000000..1eede90d --- /dev/null +++ b/example/animal-service-v2/lib/animal_service/api.rb @@ -0,0 +1,29 @@ +require 'sinatra/base' +require_relative 'animal_repository' +require 'json' + +module AnimalService + + class Api < Sinatra::Base + + set :raise_errors, false + set :show_exceptions, false + + error do + e = env['sinatra.error'] + content_type :json, :charset => 'utf-8' + status 500 + {error: e.message, backtrace: e.backtrace}.to_json + end + + get '/alligators/:name' do + if (alligator = AnimalRepository.find_alligator_by_name(params[:name])) + content_type :json, :charset => 'utf-8' + alligator.to_json + else + status 404 + end + end + + end +end diff --git a/example/animal-service-v2/lib/animal_service/db.rb b/example/animal-service-v2/lib/animal_service/db.rb new file mode 100644 index 00000000..56185928 --- /dev/null +++ b/example/animal-service-v2/lib/animal_service/db.rb @@ -0,0 +1,5 @@ +require 'sequel' + +module AnimalService + DATABASE ||= Sequel.connect(adapter: 'sqlite', database: './db/animal_db.sqlite3') +end \ No newline at end of file diff --git a/example/animal-service-v2/spec/pact/consumers/http_spec.rb b/example/animal-service-v2/spec/pact/consumers/http_spec.rb new file mode 100644 index 00000000..1fedb5de --- /dev/null +++ b/example/animal-service-v2/spec/pact/consumers/http_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'pact/v2/rspec' +require 'sequel' +require 'animal_service/api' +require 'animal_service/db' +require 'animal_service/animal_repository' +require 'rspec/mocks' +include RSpec::Mocks::ExampleMethods + +RSpec.describe 'Verify consumers for Bar Provider', :pact_v2 do + http_pact_provider 'Animal Service', opts: { + pact_dir: File.expand_path('../../../../zoo-app-v2/spec/pacts', __dir__), + http_port: 9292, + app: AnimalService::Api + } + + before_state_setup do + AnimalService::DATABASE[:animals].truncate + end + + provider_state 'there is an alligator named Mary' do + set_up do + AnimalService::DATABASE[:animals].insert(name: 'Mary') + end + end + + provider_state 'there is an alligator named {alligator_name}' do + set_up do |params| + AnimalService::DATABASE[:animals].insert(name: params['alligator_name']) + end + end + + provider_state 'there is not an alligator named Mary' do + set_up do + AnimalService::DATABASE[:animals].truncate + end + end + + provider_state 'an error occurs retrieving an alligator' do + set_up do + allow(AnimalService::AnimalRepository).to receive(:find_alligator_by_name).and_raise('Argh!!!') + end + tear_down do + allow(AnimalService::AnimalRepository).to receive(:find_alligator_by_name).and_call_original + end + end +end \ No newline at end of file diff --git a/example/animal-service-v2/spec/rails_helper.rb b/example/animal-service-v2/spec/rails_helper.rb new file mode 100644 index 00000000..a8d0d351 --- /dev/null +++ b/example/animal-service-v2/spec/rails_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "combustion" +begin + Combustion.initialize! :action_controller do + config.log_level = :fatal if ENV["LOG"].to_s.empty? + end +rescue => e + # Fail fast if application couldn't be loaded + warn "💥 Failed to load the app: #{e.message}\n#{e.backtrace.join("\n")}" + exit(1) +end \ No newline at end of file diff --git a/example/animal-service/Gemfile b/example/animal-service/Gemfile index 81b554f6..a5d31500 100644 --- a/example/animal-service/Gemfile +++ b/example/animal-service/Gemfile @@ -7,7 +7,7 @@ group :development, :test do end gem 'rake' -gem 'rack', '>= 3.1.12' +gem 'rack' gem 'sqlite3' gem 'sequel' -gem 'sinatra', '>= 4.1.0' +gem 'sinatra' diff --git a/example/animal-service/Gemfile.lock b/example/animal-service/Gemfile.lock deleted file mode 100644 index 7e246b72..00000000 --- a/example/animal-service/Gemfile.lock +++ /dev/null @@ -1,113 +0,0 @@ -PATH - remote: ../.. - specs: - pact (1.66.1) - jsonpath (~> 1.0) - pact-mock_service (~> 3.0, >= 3.3.1) - pact-support (~> 1.21, >= 1.21.0) - rack-test (>= 0.6.3, < 3.0.0) - rainbow (~> 3.1) - rspec (~> 3.0) - string_pattern (~> 2.0) - thor (>= 0.20, < 2.0) - -GEM - remote: https://rubygems.org/ - specs: - awesome_print (1.9.2) - base64 (0.2.0) - bigdecimal (3.2.3) - coderay (1.1.3) - diff-lcs (1.5.1) - expgen (0.1.1) - parslet - find_a_port (1.0.1) - json (2.8.2) - jsonpath (1.1.5) - multi_json - logger (1.6.1) - method_source (1.1.0) - mini_portile2 (2.8.9) - multi_json (1.15.0) - mustermann (3.0.3) - ruby2_keywords (~> 0.0.1) - pact-mock_service (3.12.3) - find_a_port (~> 1.0.1) - json - pact-support (~> 1.16, >= 1.16.4) - rack (>= 3.0, < 4.0) - rackup (~> 2.0) - rspec (>= 2.14) - thor (>= 0.19, < 2.0) - webrick (~> 1.8) - pact-support (1.21.0) - awesome_print (~> 1.9) - diff-lcs (~> 1.5) - expgen (~> 0.1) - jsonpath (~> 1.0) - rainbow (~> 3.1.1) - string_pattern (~> 2.0) - parslet (2.0.0) - pry (0.15.2) - coderay (~> 1.1) - method_source (~> 1.0) - rack (3.2.1) - rack-protection (4.1.1) - base64 (>= 0.1.0) - logger (>= 1.6.0) - rack (>= 3.0.0, < 4) - rack-session (2.0.0) - rack (>= 3.0.0) - rack-test (2.1.0) - rack (>= 1.3) - rackup (2.2.1) - rack (>= 3) - rainbow (3.1.1) - rake (13.3.0) - regexp_parser (2.9.2) - rspec (3.13.1) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.4) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.4) - ruby2_keywords (0.0.5) - sequel (5.97.0) - bigdecimal - sinatra (4.1.1) - logger (>= 1.6.0) - mustermann (~> 3.0) - rack (>= 3.0.0, < 4) - rack-protection (= 4.1.1) - rack-session (>= 2.0.0, < 3) - tilt (~> 2.0) - sqlite3 (2.7.4) - mini_portile2 (~> 2.8.0) - string_pattern (2.3.0) - regexp_parser (~> 2.5, >= 2.5.0) - thor (1.3.2) - tilt (2.4.0) - webrick (1.9.0) - -PLATFORMS - ruby - -DEPENDENCIES - pact! - pry - rack (>= 3.1.12) - rake - rspec - sequel - sinatra (>= 4.1.0) - sqlite3 - -BUNDLED WITH - 2.5.11 diff --git a/example/zoo-app-v2/Gemfile b/example/zoo-app-v2/Gemfile new file mode 100644 index 00000000..0442df7e --- /dev/null +++ b/example/zoo-app-v2/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' + +group :development, :test do + gem 'rspec' + gem 'pact', path: '../../' + gem 'pact_broker-client' + gem 'pry' + # required for pact-ruby-v2 + gem 'combustion' +end + +gem 'rake' + +gem 'rack' +gem 'httparty', '>= 0.21.0' + +gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw] \ No newline at end of file diff --git a/example/zoo-app-v2/Rakefile b/example/zoo-app-v2/Rakefile new file mode 100644 index 00000000..7a1607ad --- /dev/null +++ b/example/zoo-app-v2/Rakefile @@ -0,0 +1,19 @@ +require 'rspec/core/rake_task' +require 'pact_broker/client/tasks' + +$: << './lib' + +RSpec::Core::RakeTask.new(:spec) + +PactBroker::Client::PublicationTask.new do | task | + require 'zoo_app/version' + task.consumer_version = ZooApp::VERSION + task.pact_broker_base_url = "http://localhost:9292" +end + +RSpec::Core::RakeTask.new('spec:v2') do |task| + task.pattern = 'spec/pact/providers/**/*_spec.rb' + task.rspec_opts = ['-t pact_v2', '--require rails_helper'] +end + +task :default => :spec \ No newline at end of file diff --git a/example/zoo-app-v2/doc/pacts/markdown/README.md b/example/zoo-app-v2/doc/pacts/markdown/README.md new file mode 100644 index 00000000..a037dffb --- /dev/null +++ b/example/zoo-app-v2/doc/pacts/markdown/README.md @@ -0,0 +1,3 @@ +### Pacts for Zoo App + +* [Animal Service](Zoo%20App%20-%20Animal%20Service.md) diff --git a/example/zoo-app-v2/doc/pacts/markdown/Zoo App - Animal Service.md b/example/zoo-app-v2/doc/pacts/markdown/Zoo App - Animal Service.md new file mode 100644 index 00000000..19525f63 --- /dev/null +++ b/example/zoo-app-v2/doc/pacts/markdown/Zoo App - Animal Service.md @@ -0,0 +1,75 @@ +### A pact between Zoo App and Animal Service + +#### Requests from Zoo App to Animal Service + +* [A request for an alligator](#a_request_for_an_alligator_given_there_is_an_alligator_named_Mary) given there is an alligator named Mary + +* [A request for an alligator](#a_request_for_an_alligator_given_there_is_not_an_alligator_named_Mary) given there is not an alligator named Mary + +* [A request for an alligator](#a_request_for_an_alligator_given_an_error_occurs_retrieving_an_alligator) given an error occurs retrieving an alligator + +#### Interactions + + +Given **there is an alligator named Mary**, upon receiving **a request for an alligator** from Zoo App, with +```json +{ + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } +} +``` +Animal Service will respond with: +```json +{ + "status": 200, + "headers": { + "Content-Type": "application/json;charset=utf-8" + }, + "body": { + "name": "Mary" + } +} +``` + +Given **there is not an alligator named Mary**, upon receiving **a request for an alligator** from Zoo App, with +```json +{ + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } +} +``` +Animal Service will respond with: +```json +{ + "status": 404 +} +``` + +Given **an error occurs retrieving an alligator**, upon receiving **a request for an alligator** from Zoo App, with +```json +{ + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } +} +``` +Animal Service will respond with: +```json +{ + "status": 500, + "headers": { + "Content-Type": "application/json;charset=utf-8" + }, + "body": { + "error": "Argh!!!" + } +} +``` diff --git a/example/zoo-app-v2/lib/zoo_app/animal_service_client.rb b/example/zoo-app-v2/lib/zoo_app/animal_service_client.rb new file mode 100644 index 00000000..2acde606 --- /dev/null +++ b/example/zoo-app-v2/lib/zoo_app/animal_service_client.rb @@ -0,0 +1,31 @@ +require 'httparty' +require 'zoo_app/models/alligator' + +module ZooApp + class AnimalServiceClient + + include HTTParty + base_uri 'animal-service.com' + + def self.find_alligator_by_name name + response = get("/alligators/#{name}", :headers => {'Accept' => 'application/json'}) + when_successful(response) do + ZooApp::Animals::Alligator.new(parse_body(response)) + end + end + + def self.when_successful response + if response.success? + yield + elsif response.code == 404 + nil + else + raise response.body + end + end + + def self.parse_body response + JSON.parse(response.body, {:symbolize_names => true}) + end + end +end \ No newline at end of file diff --git a/example/zoo-app-v2/lib/zoo_app/models/alligator.rb b/example/zoo-app-v2/lib/zoo_app/models/alligator.rb new file mode 100644 index 00000000..49233d4d --- /dev/null +++ b/example/zoo-app-v2/lib/zoo_app/models/alligator.rb @@ -0,0 +1,17 @@ +module ZooApp + module Animals + class Alligator + + attr_reader :name + + def initialize attributes + @name = attributes[:name] + end + + def == other + other.is_a?(Alligator) && other.name == self.name + end + + end + end +end \ No newline at end of file diff --git a/example/zoo-app-v2/lib/zoo_app/version.rb b/example/zoo-app-v2/lib/zoo_app/version.rb new file mode 100644 index 00000000..9bbf90c0 --- /dev/null +++ b/example/zoo-app-v2/lib/zoo_app/version.rb @@ -0,0 +1,3 @@ +module ZooApp + VERSION = '1.0.0' +end \ No newline at end of file diff --git a/example/zoo-app-v2/spec/pact/providers/animal_service/animal_service_client_spec.rb b/example/zoo-app-v2/spec/pact/providers/animal_service/animal_service_client_spec.rb new file mode 100644 index 00000000..c9170c21 --- /dev/null +++ b/example/zoo-app-v2/spec/pact/providers/animal_service/animal_service_client_spec.rb @@ -0,0 +1,71 @@ +require 'pact/v2/rspec' +require 'zoo_app/animal_service_client' + +RSpec.describe 'ZooApp::AnimalServiceClient', :pact_v2 do + has_http_pact_between 'Zoo App', 'Animal Service', opts: { pact_specification: 'V4' } + + subject { ZooApp::AnimalServiceClient } + + let(:alligator_name) { 'Mary' } + let(:alligator_body) { { name: alligator_name } } + let(:headers) { { 'Accept' => 'application/json' } } + let(:content_headers) { { 'Content-Type' => 'application/json;charset=utf-8' } } + + describe 'Pact with Animal Service Provider' do + let(:interaction) { new_interaction } + + describe '.find_alligator_by_name' do + context 'when an alligator by the given name exists' do + let(:interaction) do + super() + .given('there is an alligator named {alligator_name}', { alligator_name: alligator_name }) + .upon_receiving('a request for an alligator') + .with_request(method: :get, path: generate_from_provider_state(expression: '/alligators/${alligator_name}', + example: '/alligators/Mary'), headers: headers) + .will_respond_with(status: 200, body: alligator_body, headers: content_headers) + end + + it 'returns the alligator' do + interaction.execute do |mock_server| + ZooApp::AnimalServiceClient.base_uri(mock_server.url) + expect(subject.find_alligator_by_name(alligator_name)).to eq ZooApp::Animals::Alligator.new(name: alligator_name) + end + end + end + + context 'when an alligator by the given name does not exist' do + let(:interaction) do + super() + .given("there is not an alligator named #{alligator_name}") + .upon_receiving('a request for an alligator') + .with_request(method: :get, path: "/alligators/#{alligator_name}", headers: headers) + .will_respond_with(status: 404) + end + + it 'returns nil' do + interaction.execute do |mock_server| + ZooApp::AnimalServiceClient.base_uri(mock_server.url) + expect(subject.find_alligator_by_name(alligator_name)).to be_nil + end + end + end + + context 'when an error occurs retrieving the alligator' do + let(:interaction) do + super() + .given('an error occurs retrieving an alligator') + .upon_receiving('a request for an alligator') + .with_request(method: :get, path: "/alligators/#{alligator_name}", headers: headers) + .will_respond_with(status: 500, body: { error: 'Argh!!!' }, headers: content_headers) + end + + it 'raises an error' do + interaction.execute do |mock_server| + ZooApp::AnimalServiceClient.base_uri(mock_server.url) + expect { subject.find_alligator_by_name(alligator_name) }.to raise_error(/Argh/) + end + end + end + end + end +end diff --git a/example/zoo-app-v2/spec/pacts/Zoo App-Animal Service.json b/example/zoo-app-v2/spec/pacts/Zoo App-Animal Service.json new file mode 100644 index 00000000..94800cea --- /dev/null +++ b/example/zoo-app-v2/spec/pacts/Zoo App-Animal Service.json @@ -0,0 +1,135 @@ +{ + "consumer": { + "name": "Zoo App" + }, + "interactions": [ + { + "description": "a request for an alligator", + "pending": false, + "providerStates": [ + { + "name": "an error occurs retrieving an alligator" + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/alligators/Mary" + }, + "response": { + "body": { + "content": { + "error": "Argh!!!" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json;charset=utf-8" + ] + }, + "status": 500 + }, + "transport": "http", + "type": "Synchronous/HTTP" + }, + { + "description": "a request for an alligator", + "pending": false, + "providerStates": [ + { + "name": "there is an alligator named {alligator_name}", + "params": { + "alligator_name": "Mary" + } + } + ], + "request": { + "generators": { + "path": { + "expression": "/alligators/${alligator_name}", + "type": "ProviderState" + } + }, + "headers": { + "Accept": [ + "application/json" + ] + }, + "matchingRules": { + "path": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "method": "GET", + "path": "/alligators/Mary" + }, + "response": { + "body": { + "content": { + "name": "Mary" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json;charset=utf-8" + ] + }, + "status": 200 + }, + "transport": "http", + "type": "Synchronous/HTTP" + }, + { + "description": "a request for an alligator", + "pending": false, + "providerStates": [ + { + "name": "there is not an alligator named Mary" + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ] + }, + "method": "GET", + "path": "/alligators/Mary" + }, + "response": { + "status": 404 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pact-ruby-v2": { + "pact-ffi": "0.4.28" + }, + "pactRust": { + "ffi": "0.4.28", + "mockserver": "1.2.16", + "models": "1.3.5" + }, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "Animal Service" + } +} \ No newline at end of file diff --git a/example/zoo-app-v2/spec/rails_helper.rb b/example/zoo-app-v2/spec/rails_helper.rb new file mode 100644 index 00000000..477edeaa --- /dev/null +++ b/example/zoo-app-v2/spec/rails_helper.rb @@ -0,0 +1,14 @@ + +require "combustion" + +begin + Combustion.initialize! :action_controller do + config.log_level = :fatal if ENV["LOG"].to_s.empty? + config.i18n.available_locales = %i[en] + config.i18n.default_locale = :en + end +rescue => e + # Fail fast if application couldn't be loaded + warn "💥 Failed to load the app: #{e.message}\n#{e.backtrace.join("\n")}" + exit(1) +end diff --git a/example/zoo-app/Gemfile b/example/zoo-app/Gemfile index eb52922c..71cc235f 100644 --- a/example/zoo-app/Gemfile +++ b/example/zoo-app/Gemfile @@ -9,5 +9,5 @@ end gem 'rake' -gem 'rack', '>= 3.1.5' +gem 'rack' gem 'httparty', '>= 0.21.0' \ No newline at end of file diff --git a/example/zoo-app/Gemfile.lock b/example/zoo-app/Gemfile.lock deleted file mode 100644 index 42017afd..00000000 --- a/example/zoo-app/Gemfile.lock +++ /dev/null @@ -1,115 +0,0 @@ -PATH - remote: ../.. - specs: - pact (1.66.1) - jsonpath (~> 1.0) - pact-mock_service (~> 3.0, >= 3.3.1) - pact-support (~> 1.21, >= 1.21.0) - rack-test (>= 0.6.3, < 3.0.0) - rainbow (~> 3.1) - rspec (~> 3.0) - string_pattern (~> 2.0) - thor (>= 0.20, < 2.0) - -GEM - remote: https://rubygems.org/ - specs: - awesome_print (1.9.2) - base64 (0.2.0) - bigdecimal (3.1.9) - coderay (1.1.3) - csv (3.3.3) - diff-lcs (1.5.1) - dig_rb (1.0.1) - expgen (0.1.1) - parslet - find_a_port (1.0.1) - httparty (0.23.1) - csv - mini_mime (>= 1.0.0) - multi_xml (>= 0.5.2) - json (2.8.2) - jsonpath (1.1.5) - multi_json - method_source (1.1.0) - mini_mime (1.1.5) - multi_json (1.15.0) - multi_xml (0.7.1) - bigdecimal (~> 3.1) - ostruct (0.6.1) - pact-mock_service (3.12.3) - find_a_port (~> 1.0.1) - json - pact-support (~> 1.16, >= 1.16.4) - rack (>= 3.0, < 4.0) - rackup (~> 2.0) - rspec (>= 2.14) - thor (>= 0.19, < 2.0) - webrick (~> 1.8) - pact-support (1.21.2) - awesome_print (~> 1.9) - diff-lcs (~> 1.5) - expgen (~> 0.1) - jsonpath (~> 1.0) - rainbow (~> 3.1.1) - string_pattern (~> 2.0) - pact_broker-client (1.77.0) - base64 (~> 0.2) - dig_rb (~> 1.0) - httparty (>= 0.21.0, < 1.0.0) - ostruct - rake (~> 13.0) - table_print (~> 1.5) - term-ansicolor (~> 1.7) - thor (>= 0.20, < 2.0) - parslet (2.0.0) - pry (0.15.2) - coderay (~> 1.1) - method_source (~> 1.0) - rack (3.2.1) - rack-test (2.1.0) - rack (>= 1.3) - rackup (2.2.1) - rack (>= 3) - rainbow (3.1.1) - rake (13.3.0) - regexp_parser (2.10.0) - rspec (3.13.1) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.4) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.4) - string_pattern (2.3.0) - regexp_parser (~> 2.5, >= 2.5.0) - sync (0.5.0) - table_print (1.5.7) - term-ansicolor (1.11.2) - tins (~> 1.0) - thor (1.3.2) - tins (1.37.0) - bigdecimal - sync - webrick (1.9.0) - -PLATFORMS - ruby - -DEPENDENCIES - httparty (>= 0.21.0) - pact! - pact_broker-client - pry - rack (>= 3.1.5) - rake - rspec - -BUNDLED WITH - 2.5.11 diff --git a/lib/pact.rb b/lib/pact.rb index 4bc571ef..6633d268 100644 --- a/lib/pact.rb +++ b/lib/pact.rb @@ -4,3 +4,4 @@ require 'pact/consumer' require 'pact/provider' require 'pact/consumer_contract' +require 'pact/v2' \ No newline at end of file diff --git a/lib/pact/v2.rb b/lib/pact/v2.rb new file mode 100644 index 00000000..a8911a86 --- /dev/null +++ b/lib/pact/v2.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "zeitwerk" +require "pact/ffi" + +require "pact/v2/railtie" if defined?(Rails::Railtie) + +module Pact + module V2 + class Error < StandardError; end + + class ImplementationRequired < Error; end + + class FfiError < Error + def initialize(msg, reason, status) + super(msg) + + @msg = msg + @reason = reason + @status = status + end + + def message + "FFI error: reason: #{@reason}, status: #{@status}, message: #{@msg}" + end + end + + def self.configure + yield configuration if block_given? + end + + def self.configuration + @configuration ||= Pact::V2::Configuration.new + end + end +end + +loader = Zeitwerk::Loader.new +loader.push_dir(File.join(__dir__, "..")) + +loader.tag = "pact-v2" + +# existing pact-ruby ignores +# loader.ignore("#{__dir__}/../pact") # ignore the pact dir at the root of the repo +# loader.ignore("#{__dir__}/../pact/v2",false) # ignore the pact dir at the root of the repo +# loader.push_dir(File.join(__dir__)) + + +loader.ignore("#{__dir__}/../pact/version.rb") +loader.ignore("#{__dir__}/../pact/cli") +loader.ignore("#{__dir__}/../pact/cli.rb") +loader.ignore("#{__dir__}/../pact/consumer") +loader.ignore("#{__dir__}/../pact/consumer.rb") +loader.ignore("#{__dir__}/../pact/doc") +loader.ignore("#{__dir__}/../pact/hal") +loader.ignore("#{__dir__}/../pact/hash_refinements.rb") +loader.ignore("#{__dir__}/../pact/pact_broker") +loader.ignore("#{__dir__}/../pact/pact_broker.rb") +loader.ignore("#{__dir__}/../pact/project_root.rb") +loader.ignore("#{__dir__}/../pact/provider") +loader.ignore("#{__dir__}/../pact/provider.rb") +loader.ignore("#{__dir__}/../pact/retry.rb") +loader.ignore("#{__dir__}/../pact/tasks") +loader.ignore("#{__dir__}/../pact/tasks.rb") +loader.ignore("#{__dir__}/../pact/templates") +loader.ignore("#{__dir__}/../pact/utils") +loader.ignore("#{__dir__}/../pact/v2/rspec.rb") +loader.ignore("#{__dir__}/../pact/v2/rspec") +loader.ignore("#{__dir__}/../pact/v2/railtie.rb") unless defined?(Rails::Railtie) +loader.setup +loader.eager_load diff --git a/lib/pact/v2/configuration.rb b/lib/pact/v2/configuration.rb new file mode 100644 index 00000000..39bbf594 --- /dev/null +++ b/lib/pact/v2/configuration.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Pact + module V2 + class Configuration + attr_reader :before_provider_state_proc, :after_provider_state_proc + + class GlobalProviderConfigurationError < ::Pact::V2::Error; end + + def before_provider_state_setup(&block) + raise GlobalProviderConfigurationError, "no block given" unless block + + @before_provider_state_proc = block + end + + def after_provider_state_teardown(&block) + raise GlobalProviderConfigurationError, "no block given" unless block + + @after_provider_state_proc = block + end + end + end +end diff --git a/lib/pact/v2/consumer.rb b/lib/pact/v2/consumer.rb new file mode 100644 index 00000000..f2e57c01 --- /dev/null +++ b/lib/pact/v2/consumer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Consumer + end + end +end diff --git a/lib/pact/v2/consumer/grpc_interaction_builder.rb b/lib/pact/v2/consumer/grpc_interaction_builder.rb new file mode 100644 index 00000000..0f873f38 --- /dev/null +++ b/lib/pact/v2/consumer/grpc_interaction_builder.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "pact/ffi/sync_message_consumer" +require "pact/ffi/plugin_consumer" +require "pact/ffi/logger" + +module Pact + module V2 + module Consumer + class GrpcInteractionBuilder + CONTENT_TYPE = "application/protobuf" + GRPC_CONTENT_TYPE = "application/grpc" + PROTOBUF_PLUGIN_NAME = "protobuf" + PROTOBUF_PLUGIN_VERSION = "0.6.5" + + class PluginInitError < Pact::V2::FfiError; end + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html + INIT_PLUGIN_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"}, + 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"} + }.freeze + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html + CREATE_INTERACTION_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"}, + 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"}, + 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"}, + 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"}, + 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"} + }.freeze + + class CreateInteractionError < Pact::V2::FfiError; end + + class InteractionMismatchesError < Pact::V2::Error; end + + class InteractionBuilderError < Pact::V2::Error; end + + def initialize(pact_config, description: nil) + @pact_config = pact_config + @description = description || "" + @proto_path = nil + @proto_include_dirs = [] + @service_name = nil + @method_name = nil + @request = nil + @response = nil + @response_meta = nil + @provider_state_meta = nil + end + + def with_service(proto_path, method, include_dirs = []) + raise InteractionBuilderError.new("invalid grpc method: cannot be blank") if method.blank? + + service_name, method_name = method.split("/") || [] + raise InteractionBuilderError.new("invalid grpc method: #{method}, should be like service/SomeMethod") if service_name.blank? || method_name.blank? + + absolute_path = File.expand_path(proto_path) + raise InteractionBuilderError.new("proto file #{proto_path} does not exist") unless File.exist?(absolute_path) + + @proto_path = absolute_path + @service_name = service_name + @method_name = method_name + @proto_include_dirs = include_dirs.map { |dir| File.expand_path(dir) } + + self + end + + def with_pact_protobuf_plugin_version(version) + raise InteractionBuilderError.new("version is required") if version.blank? + + @proto_plugin_version = version + self + end + + def given(provider_state, metadata = {}) + @provider_state_meta = {provider_state => metadata} + self + end + + def upon_receiving(description) + @description = description + self + end + + def with_request(req_hash) + @request = InteractionContents.plugin(req_hash) + self + end + + def will_respond_with(resp_hash) + @response = InteractionContents.plugin(resp_hash) + self + end + + def will_respond_with_meta(meta_hash) + @response_meta = InteractionContents.plugin(meta_hash) + self + end + + def interaction_json + result = { + "pact:proto": @proto_path, + "pact:proto-service": "#{@service_name}/#{@method_name}", + "pact:content-type": CONTENT_TYPE, + request: @request + } + + result["pact:protobuf-config"] = {additionalIncludes: @proto_include_dirs} if @proto_include_dirs.present? + + result[:response] = @response if @response.is_a?(Hash) + result[:responseMetadata] = @response_meta if @response_meta.is_a?(Hash) + + JSON.dump(result) + end + + def validate! + raise InteractionBuilderError.new("uninitialized service params, use #with_service to configure") if @proto_path.blank? || @service_name.blank? || @method_name.blank? + raise InteractionBuilderError.new("invalid request format, should be a hash") unless @request.is_a?(Hash) + raise InteractionBuilderError.new("invalid response format, should be a hash") unless @response.is_a?(Hash) || @response_meta.is_a?(Hash) + end + + def execute(&block) + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + + validate! + + pact_handle = init_pact + init_plugin!(pact_handle) + + message_pact = PactFfi::SyncMessageConsumer.new_interaction(pact_handle, @description) + @provider_state_meta&.each_pair do |provider_state, meta| + if meta.present? + meta.each_pair { |k, v| PactFfi.given_with_param(message_pact, provider_state, k.to_s, v.to_s) } + else + PactFfi.given(message_pact, provider_state) + end + end + + result = PactFfi::PluginConsumer.interaction_contents(message_pact, 0, GRPC_CONTENT_TYPE, interaction_json) + if CREATE_INTERACTION_ERRORS[result].present? + error = CREATE_INTERACTION_ERRORS[result] + raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status]) + end + + mock_server = MockServer.create_for_grpc!(pact: pact_handle, host: @pact_config.mock_host, port: @pact_config.mock_port) + + yield(message_pact, mock_server) + + ensure + if mock_server.matched? + mock_server.write_pacts!(@pact_config.pact_dir) + else + msg = mismatches_error_msg(mock_server) + raise InteractionMismatchesError.new(msg) + end + @used = true + mock_server&.cleanup + PactFfi::PluginConsumer.cleanup_plugins(pact_handle) + PactFfi.free_pact_handle(pact_handle) + end + + private + + def mismatches_error_msg(mock_server) + rspec_example_desc = RSpec.current_example&.description + return "interaction for #{@service_name}/#{@method_name} has mismatches: #{mock_server.mismatches}" if rspec_example_desc.blank? + + "#{rspec_example_desc} has mismatches: #{mock_server.mismatches}" + end + + def init_pact + handle = PactFfi.new_pact(@pact_config.consumer_name, @pact_config.provider_name) + PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"]) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level) + + handle + end + + def init_plugin!(pact_handle) + result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, @proto_plugin_version || PROTOBUF_PLUGIN_VERSION) + return result if INIT_PLUGIN_ERRORS[result].blank? + + error = INIT_PLUGIN_ERRORS[result] + raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{@proto_plugin_version || PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status]) + end + end + end + end +end diff --git a/lib/pact/v2/consumer/http_interaction_builder.rb b/lib/pact/v2/consumer/http_interaction_builder.rb new file mode 100644 index 00000000..b8ba4d1c --- /dev/null +++ b/lib/pact/v2/consumer/http_interaction_builder.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "pact/ffi/sync_message_consumer" +require "pact/ffi/plugin_consumer" +require "pact/ffi/logger" +require "json" + +module Pact + module V2 + module Consumer + class HttpInteractionBuilder + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html + CREATE_INTERACTION_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"}, + 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"}, + 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"}, + 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"}, + 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"} + }.freeze + + class CreateInteractionError < Pact::V2::FfiError; end + + class InteractionMismatchesError < Pact::V2::Error; end + + class InteractionBuilderError < Pact::V2::Error; end + + class << self + def create_finalizer(pact_handle) + proc { PactFfi.free_pact_handle(pact_handle) } + end + end + + def initialize(pact_config, description: nil) + @pact_config = pact_config + @description = description || "" + + @pact_handle = pact_config.pact_handle ||= init_pact + @pact_interaction = PactFfi.new_interaction(pact_handle, @description) + + ObjectSpace.define_finalizer(self, self.class.create_finalizer(pact_interaction)) + end + + def given(provider_state, metadata = {}) + if metadata.present? + PactFfi.given_with_params(pact_interaction, provider_state, JSON.dump(metadata)) + else + PactFfi.given(pact_interaction, provider_state) + end + + self + end + + def upon_receiving(description) + @description = description + PactFfi.upon_receiving(pact_interaction, @description) + self + end + + def with_request(method: nil, path: nil, query: {}, headers: {}, body: nil) + interaction_part = PactFfi::FfiInteractionPart["INTERACTION_PART_REQUEST"] + PactFfi.with_request(pact_interaction, method.to_s, format_value(path)) + + # Processing as an array of hashes, allows us to consider duplicate keys + # which should be passed to the core, at a non 0 index + if query.is_a?(Array) + key_index = Hash.new(0) + query.each do |query_item| + InteractionContents.basic(query_item).each_pair do |key, value_item| + PactFfi.with_query_parameter_v2(pact_interaction, key.to_s, key_index[key], format_value(value_item)) + key_index[key] += 1 + end + end + else + InteractionContents.basic(query).each_pair do |key, value_item| + PactFfi.with_query_parameter_v2(pact_interaction, key.to_s, 0, format_value(value_item)) + end + end + + InteractionContents.basic(headers).each_pair do |key, value_item| + PactFfi.with_header_v2(pact_interaction, interaction_part, key.to_s, 0, format_value(value_item)) + end + + if body + PactFfi.with_body(pact_interaction, interaction_part, "application/json", format_value(InteractionContents.basic(body))) + end + + self + end + + def will_respond_with(status: nil, headers: {}, body: nil) + interaction_part = PactFfi::FfiInteractionPart["INTERACTION_PART_RESPONSE"] + PactFfi.response_status(pact_interaction, status) + + InteractionContents.basic(headers).each_pair do |key, value_item| + PactFfi.with_header_v2(pact_interaction, interaction_part, key.to_s, 0, format_value(value_item)) + end + + if body + PactFfi.with_body(pact_interaction, interaction_part, "application/json", format_value(InteractionContents.basic(body))) + end + + self + end + + def execute(&block) + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + + mock_server = MockServer.create_for_http!( + pact: pact_handle, host: pact_config.mock_host, port: pact_config.mock_port + ) + + yield(mock_server) + + ensure + if mock_server.matched? + mock_server.write_pacts!(pact_config.pact_dir) + else + msg = mismatches_error_msg(mock_server) + raise InteractionMismatchesError.new(msg) + end + @used = true + mock_server&.cleanup + # Reset the pact handle to allow for a new interaction to be built + # without previous interactions being included + @pact_config.reset_pact + end + + private + + attr_reader :pact_handle, :pact_interaction, :pact_config + + def mismatches_error_msg(mock_server) + rspec_example_desc = RSpec.current_example&.description + mismatches = JSON.pretty_generate(JSON.parse(mock_server.mismatches)) + mismatches_with_colored_keys = mismatches.gsub(/"([^"]+)":/) { |match| "\e[34m#{match}\e[0m" } # Blue keys / white values + + "#{rspec_example_desc} has mismatches: #{mismatches_with_colored_keys}" + end + + def init_pact + handle = PactFfi.new_pact(pact_config.consumer_name, pact_config.provider_name) + PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_#{pact_config.pact_specification}"]) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(pact_config.log_level) + + handle + end + + def format_value(obj) + return obj if obj.is_a?(String) + + return JSON.dump({value: obj}) if obj.is_a?(Array) + + JSON.dump(obj) + end + end + end + end +end diff --git a/lib/pact/v2/consumer/interaction_contents.rb b/lib/pact/v2/consumer/interaction_contents.rb new file mode 100644 index 00000000..8e17360e --- /dev/null +++ b/lib/pact/v2/consumer/interaction_contents.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Consumer + class InteractionContents < Hash + BASIC_FORMAT = :basic + PLUGIN_FORMAT = :plugin + + attr_reader :format + + def self.basic(contents_hash) + new(contents_hash, BASIC_FORMAT) + end + + def self.plugin(contents_hash) + new(contents_hash, PLUGIN_FORMAT) + end + + def initialize(contents_hash, format) + init_hash(contents_hash, format).each_pair { |k, v| self[k] = v } + @format = format + end + + private + + def serialize(hash, format) + # serialize recursively + if hash.is_a?(String) + return hash + end + if hash.is_a?(Pact::V2::Matchers::Base) + return hash.as_basic if format == :basic + return hash.as_plugin if format == :plugin + end + if hash.is_a?(Pact::V2::Generators::Base) + return hash.as_basic if format == :basic + return hash.as_plugin if format == :plugin + end + hash.each_pair do |key, value| + next serialize(value, format) if value.is_a?(Hash) + next hash[key] = value.map { |v| serialize(v, format) } if value.is_a?(Array) + if value.is_a?(Pact::V2::Matchers::Base) + hash[key] = value.as_basic if format == :basic + hash[key] = value.as_plugin if format == :plugin + end + if value.is_a?(Pact::V2::Generators::Base) + hash[key] = value.as_basic if format == :basic + hash[key] = value.as_plugin if format == :plugin + end + end + + hash + end + + def init_hash(hash, format) + serialize(hash.deep_dup, format) + end + end + end + end +end diff --git a/lib/pact/v2/consumer/message_interaction_builder.rb b/lib/pact/v2/consumer/message_interaction_builder.rb new file mode 100644 index 00000000..10375651 --- /dev/null +++ b/lib/pact/v2/consumer/message_interaction_builder.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require "pact/ffi/message_consumer" +require "pact/ffi/plugin_consumer" +require "pact/ffi/logger" + +module Pact + module V2 + module Consumer + class MessageInteractionBuilder + META_CONTENT_TYPE_HEADER = "contentType" + + JSON_CONTENT_TYPE = "application/json" + PROTO_CONTENT_TYPE = "application/protobuf" + + PROTOBUF_PLUGIN_NAME = "protobuf" + PROTOBUF_PLUGIN_VERSION = "0.6.5" + + # https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/handles/fn.pactffi_write_message_pact_file.html + WRITE_PACT_FILE_ERRORS = { + 1 => {reason: :file_not_accessible, status: 1, description: "The pact file was not able to be written"}, + 2 => {reason: :internal_error, status: 2, description: "The message pact for the given handle was not found"} + }.freeze + + class PluginInitError < Pact::V2::FfiError; end + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html + INIT_PLUGIN_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"}, + 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"} + }.freeze + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html + CREATE_INTERACTION_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"}, + 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"}, + 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"}, + 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"}, + 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"} + }.freeze + + class CreateInteractionError < Pact::V2::FfiError; end + + class InteractionMismatchesError < Pact::V2::Error; end + + class InteractionBuilderError < Pact::V2::Error; end + + def initialize(pact_config, description: nil) + @pact_config = pact_config + @description = description + + @json_contents = nil + @proto_contents = nil + @proto_path = nil + @proto_message_class = nil + @proto_include_dirs = [] + @meta = {} + @headers = {} + @provider_state_meta = nil + end + + def given(provider_state, metadata = {}) + @provider_state_meta = {provider_state => metadata} + self + end + + def upon_receiving(description) + @description = description + self + end + + def with_json_contents(contents_hash) + @json_contents = InteractionContents.basic(contents_hash) + self + end + + def with_proto_class(proto_path, message_class_name, include_dirs = []) + absolute_path = File.expand_path(proto_path) + raise InteractionBuilderError.new("proto file #{proto_path} does not exist") unless File.exist?(absolute_path) + + @proto_path = absolute_path + @proto_message_class = message_class_name + @proto_include_dirs = include_dirs.map { |dir| File.expand_path(dir) } + self + end + + def with_pact_protobuf_plugin_version(version) + raise InteractionBuilderError.new("version is required") if version.blank? + + @proto_plugin_version = version + self + end + + def with_proto_contents(contents_hash) + @proto_contents = InteractionContents.plugin(contents_hash) + self + end + + def with_metadata(meta_hash) + @meta = InteractionContents.basic(meta_hash) + self + end + + def with_headers(headers_hash) + @headers = InteractionContents.basic(headers_hash) + self + end + + def with_header(key, value) + @headers[key] = value + self + end + + def validate! + if proto_interaction? + raise InteractionBuilderError.new("proto_path / proto_message are not defined, please set ones with #with_proto_message") if @proto_contents.blank? || @proto_message_class.blank? + raise InteractionBuilderError.new("invalid request format, should be a hash") unless @proto_contents.is_a?(Hash) + else + raise InteractionBuilderError.new("invalid request format, should be a hash") unless @json_contents.is_a?(Hash) + end + raise InteractionBuilderError.new("description is required for message interactions, please set one with #upon_receiving") if @description.blank? + end + + def execute(&block) + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + + validate! + pact_handle = init_pact + init_plugin!(pact_handle) if proto_interaction? + + message_pact = PactFfi::MessageConsumer.new_message_interaction(pact_handle, @description) + + configure_interaction!(message_pact) + + # strip out matchers and get raw payload/metadata + payload, metadata = fetch_reified_message(pact_handle) + configure_provider_state(message_pact, metadata) + + yield(payload, metadata) + + write_pacts!(pact_handle, @pact_config.pact_dir) + ensure + @used = true + PactFfi::MessageConsumer.free_handle(message_pact) + PactFfi.free_pact_handle(pact_handle) + end + + def build_interaction_json + return JSON.dump(@json_contents) unless proto_interaction? + + contents = { + "pact:proto": @proto_path, + "pact:message-type": @proto_message_class, + "pact:content-type": PROTO_CONTENT_TYPE + }.merge(@proto_contents) + + contents["pact:protobuf-config"] = {additionalIncludes: @proto_include_dirs} if @proto_include_dirs.present? + + JSON.dump(contents) + end + + private + + def write_pacts!(handle, dir) + result = PactFfi.write_message_pact_file(handle, @pact_config.pact_dir, false) + return result if WRITE_PACT_FILE_ERRORS[result].blank? + + error = WRITE_PACT_FILE_ERRORS[result] + raise WritePactsError.new("There was an error while trying to write pact file to #{dir}", error[:reason], error[:status]) + end + + def init_pact + handle = PactFfi::MessageConsumer.new_message_pact(@pact_config.consumer_name, @pact_config.provider_name) + PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"]) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level) + + handle + end + + def fetch_reified_message(pact_handle) + iterator = PactFfi::MessageConsumer.pact_handle_get_message_iter(pact_handle) + raise InteractionBuilderError.new("cannot get message iterator: internal error") if iterator.blank? + + message_handle = PactFfi.pact_message_iter_next(iterator) + raise InteractionBuilderError.new("cannot get message from iterator: no messages") if message_handle.blank? + + contents = fetch_reified_message_body(message_handle) + meta = fetch_reified_message_headers(message_handle) + + [contents, meta.compact] + ensure + PactFfi.pact_message_iter_delete(iterator) if iterator.present? + end + + def fetch_reified_message_headers(message_handle) + meta = {"headers" => {}} + + meta[META_CONTENT_TYPE_HEADER] = PactFfi.message_find_metadata(message_handle, META_CONTENT_TYPE_HEADER) + + @meta.each_key do |key| + meta[key.to_s] = PactFfi.message_find_metadata(message_handle, key.to_s) + end + + @headers.each_key do |key| + meta["headers"][key.to_s] = PactFfi.message_find_metadata(message_handle, key.to_s) + end + + meta + end + + def configure_provider_state(message_pact, reified_metadata) + content_type = reified_metadata[META_CONTENT_TYPE_HEADER] + @provider_state_meta&.each_pair do |provider_state, meta| + if meta.present? + meta.each_pair { |k, v| PactFfi.given_with_param(message_pact, provider_state, k.to_s, v.to_s) } + PactFfi.given_with_param(message_pact, provider_state, META_CONTENT_TYPE_HEADER, content_type.to_s) if content_type + elsif content_type.present? + PactFfi.given_with_param(message_pact, provider_state, META_CONTENT_TYPE_HEADER, content_type.to_s) + else + PactFfi.given(message_pact, provider_state) + end + end + end + + def fetch_reified_message_body(message_handle) + if proto_interaction? + len = PactFfi::MessageConsumer.get_contents_length(message_handle) + ptr = PactFfi::MessageConsumer.get_contents_bin(message_handle) + return nil if ptr.blank? || len == 0 + + return String.new(ptr.read_string_length(len)) + end + + contents = PactFfi::MessageConsumer.get_contents(message_handle) + return nil if contents.blank? + + JSON.parse(contents) + end + + def configure_interaction!(message_pact) + interaction_json = build_interaction_json + + if proto_interaction? + result = PactFfi::PluginConsumer.interaction_contents(message_pact, 0, PROTO_CONTENT_TYPE, interaction_json) + if CREATE_INTERACTION_ERRORS[result].present? + error = CREATE_INTERACTION_ERRORS[result] + raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status]) + end + else + result = PactFfi.with_body(message_pact, 0, JSON_CONTENT_TYPE, interaction_json) + unless result + raise InteractionMismatchesError.new("There was an error while trying to add message interaction contents \"#{@description}\"") + end + end + + # meta should be configured last to avoid resetting after body is set + InteractionContents.basic(@meta.merge(@headers)).each_pair do |key, value| + PactFfi::MessageConsumer.with_metadata_v2(message_pact, key.to_s, JSON.dump(value)) + end + end + + def init_plugin!(pact_handle) + result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, @proto_plugin_version || PROTOBUF_PLUGIN_VERSION) + return result if INIT_PLUGIN_ERRORS[result].blank? + + error = INIT_PLUGIN_ERRORS[result] + raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{@proto_plugin_version || PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status]) + end + + def serialize_metadata(metadata_hash) + metadata = metadata_hash.deep_dup + serialize_as!(metadata, :basic) + + metadata + end + + def proto_interaction? + @proto_contents.present? + end + end + end + end +end diff --git a/lib/pact/v2/consumer/mock_server.rb b/lib/pact/v2/consumer/mock_server.rb new file mode 100644 index 00000000..d3159bb1 --- /dev/null +++ b/lib/pact/v2/consumer/mock_server.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "pact/ffi/mock_server" + +module Pact + module V2 + module Consumer + class MockServer + attr_reader :host, :port, :transport, :handle, :url + + TRANSPORT_HTTP = "http" + TRANSPORT_GRPC = "grpc" + + class MockServerCreateError < Pact::V2::FfiError; end + + class WritePactsError < Pact::V2::FfiError; end + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/mock_server/fn.pactffi_create_mock_server_for_transport.html + CREATE_TRANSPORT_ERRORS = { + -1 => {reason: :invalid_handle, status: -1, description: "An invalid handle was received. Handles should be created with pactffi_new_pact"}, + -2 => {reason: :invalid_transport_json, status: -2, description: "Transport_config is not valid JSON"}, + -3 => {reason: :mock_server_not_started, status: -3, description: "The mock server could not be started"}, + -4 => {reason: :internal_error, status: -4, description: "The method panicked"}, + -5 => {reason: :invalid_host, status: -5, description: "The address is not valid"} + }.freeze + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/mock_server/fn.pactffi_write_pact_file.html + WRITE_PACT_FILE_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :file_not_accessible, status: 2, description: "The pact file was not able to be written"}, + 3 => {reason: :mock_server_not_found, status: 3, description: "A mock server with the provided port was not found"} + }.freeze + + def self.create_for_grpc!(pact:, host: "127.0.0.1", port: 0) + new(pact: pact, transport: TRANSPORT_GRPC, host: host, port: port) + end + + def self.create_for_http!(pact:, host: "127.0.0.1", port: 0) + new(pact: pact, transport: TRANSPORT_HTTP, host: host, port: port) + end + + def self.create_for_transport!(pact:, transport:, host: "127.0.0.1", port: 0) + new(pact: pact, transport: transport, host: host, port: port) + end + + def initialize(pact:, transport:, host:, port:) + + @pact = pact + @transport = transport + @host = host + @port = port + + @handle = init_transport! + # the returned handle is the port number + # we set it here, so we can consume a port number of 0 + # and allow pact to assign a random available port + @port = @handle + # construct the url for the mock server + # as a convenience for the user + @url = "#{transport}://#{host}:#{@handle}" + # TODO: handle auto-GC of native memory + # ObjectSpace.define_finalizer(self, proc do + # cleanup + # end) + end + + def write_pacts!(dir) + result = PactFfi::MockServer.write_pact_file(@handle, dir, false) + return result if WRITE_PACT_FILE_ERRORS[result].blank? + + error = WRITE_PACT_FILE_ERRORS[result] + raise WritePactsError.new("There was an error while trying to write pact file to #{dir}", error[:reason], error[:status]) + end + + def matched? + PactFfi::MockServer.matched(@handle) + end + + def mismatches + PactFfi::MockServer.mismatches(@handle) + end + + def cleanup + PactFfi::MockServer.cleanup(@handle) + end + + private + + def init_transport! + handle = PactFfi::MockServer.create_for_transport(@pact, @host, @port, @transport, nil) + # the returned handle is the port number + return handle if CREATE_TRANSPORT_ERRORS[handle].blank? + + error = CREATE_TRANSPORT_ERRORS[handle] + raise MockServerCreateError.new("There was an error while trying to create mock server for transport:#{@transport}", error[:reason], error[:status]) + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config.rb b/lib/pact/v2/consumer/pact_config.rb new file mode 100644 index 00000000..599ef86b --- /dev/null +++ b/lib/pact/v2/consumer/pact_config.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "pact_config/grpc" + +module Pact + module V2 + module Consumer + module PactConfig + def self.new(transport_type, consumer_name:, provider_name:, opts: {}) + case transport_type + when :http + Http.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts) + when :grpc + Grpc.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts) + when :message + Message.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts) + when :plugin_sync_message + PluginSyncMessage.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts) + when :plugin_async_message + PluginAsyncMessage.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts) + when :plugin_http + PluginHttp.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts) + else + raise ArgumentError, "unknown transport_type: #{transport_type}" + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/base.rb b/lib/pact/v2/consumer/pact_config/base.rb new file mode 100644 index 00000000..f1250fa7 --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/base.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Consumer + module PactConfig + class Base + attr_reader :consumer_name, :provider_name, :pact_dir, :log_level + + def initialize(consumer_name:, provider_name:, opts: {}) + @consumer_name = consumer_name + @provider_name = provider_name + @pact_dir = opts[:pact_dir] || (defined?(Rails) ? Rails.root.join("../pacts").to_s : "pacts") + @log_level = opts[:log_level] || :info + end + + def new_interaction(description = nil) + raise Pact::V2::ImplementationRequired, "#new_interaction should be implemented" + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/grpc.rb b/lib/pact/v2/consumer/pact_config/grpc.rb new file mode 100644 index 00000000..dd673c5f --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/grpc.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Consumer + module PactConfig + class Grpc < Base + attr_reader :mock_host, :mock_port + + def initialize(consumer_name:, provider_name:, opts: {}) + super + + @mock_host = opts[:mock_host] || "127.0.0.1" + @mock_port = opts[:mock_port] || 3009 + end + + def new_interaction(description = nil) + GrpcInteractionBuilder.new(self, description: description) + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/http.rb b/lib/pact/v2/consumer/pact_config/http.rb new file mode 100644 index 00000000..ff0d767b --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/http.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Consumer + module PactConfig + class Http < Base + attr_reader :mock_host, :mock_port, :pact_handle + + def initialize(consumer_name:, provider_name:, opts: {}) + super + + @mock_host = opts[:mock_host] || "127.0.0.1" + @mock_port = opts[:mock_port] || 0 + @log_level = opts[:log_level] || :info + @pact_specification = get_pact_specification(opts) + @pact_handle = init_pact + end + + def new_interaction(description = nil) + HttpInteractionBuilder.new(self, description: description) + end + + def reset_pact + @pact_handle = init_pact + end + + def get_pact_specification(opts) + pact_spec_version = opts[:pact_specification] || "V4" + unless pact_spec_version.match?(/^v?[1-4](\.\d+){0,2}$/i) + raise ArgumentError, "Invalid pact specification version format \n Valid versions are 1, 1.1, 2, 3, 4. Default is V4 \n V prefix is optional, and case insensitive" + end + pact_spec_version = pact_spec_version.upcase + pact_spec_version = "V#{pact_spec_version}" unless pact_spec_version.start_with?("V") + pact_spec_version = pact_spec_version.sub(/(\.0+)+$/, "") + pact_spec_version = pact_spec_version.tr(".", "_") + PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_#{pact_spec_version.upcase}"] + end + + def init_pact + handle = PactFfi.new_pact(consumer_name, provider_name) + PactFfi.with_specification(handle, @pact_specification) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(@log_level) + + handle + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/message.rb b/lib/pact/v2/consumer/pact_config/message.rb new file mode 100644 index 00000000..76aa34b9 --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/message.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Consumer + module PactConfig + class Message < Base + def new_interaction(description = nil) + MessageInteractionBuilder.new(self, description: description) + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/plugin_async_message.rb b/lib/pact/v2/consumer/pact_config/plugin_async_message.rb new file mode 100644 index 00000000..1bb0f59a --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/plugin_async_message.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Consumer + module PactConfig + class PluginAsyncMessage < Base + attr_reader :mock_host, :mock_port + + def initialize(consumer_name:, provider_name:, opts: {}) + super + + @mock_host = opts[:mock_host] || "127.0.0.1" + @mock_port = opts[:mock_port] || 0 + end + + def new_interaction(description = nil) + PluginAsyncMessageInteractionBuilder.new(self, description: description) + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/plugin_http.rb b/lib/pact/v2/consumer/pact_config/plugin_http.rb new file mode 100644 index 00000000..b7961cb5 --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/plugin_http.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Consumer + module PactConfig + class PluginHttp < Base + attr_reader :mock_host, :mock_port + + def initialize(consumer_name:, provider_name:, opts: {}) + super + + @mock_host = opts[:mock_host] || "127.0.0.1" + @mock_port = opts[:mock_port] || 0 + end + + def new_interaction(description = nil) + PluginHttpInteractionBuilder.new(self, description: description) + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/pact_config/plugin_sync_message.rb b/lib/pact/v2/consumer/pact_config/plugin_sync_message.rb new file mode 100644 index 00000000..bc6c2d8c --- /dev/null +++ b/lib/pact/v2/consumer/pact_config/plugin_sync_message.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Consumer + module PactConfig + class PluginSyncMessage < Base + attr_reader :mock_host, :mock_port + + def initialize(consumer_name:, provider_name:, opts: {}) + super + + @mock_host = opts[:mock_host] || "127.0.0.1" + @mock_port = opts[:mock_port] || 0 + end + + def new_interaction(description = nil) + PluginSyncMessageInteractionBuilder.new(self, description: description) + end + end + end + end + end +end diff --git a/lib/pact/v2/consumer/plugin_async_message_interaction_builder.rb b/lib/pact/v2/consumer/plugin_async_message_interaction_builder.rb new file mode 100644 index 00000000..40b49617 --- /dev/null +++ b/lib/pact/v2/consumer/plugin_async_message_interaction_builder.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "pact/ffi/async_message_pact" +require "pact/ffi/plugin_consumer" +require "pact/ffi/logger" + +module Pact + module V2 + module Consumer + class PluginAsyncMessageInteractionBuilder + + class PluginInitError < Pact::V2::FfiError; end + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html + INIT_PLUGIN_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"}, + 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"} + }.freeze + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html + CREATE_INTERACTION_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"}, + 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"}, + 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"}, + 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"}, + 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"} + }.freeze + + class CreateInteractionError < Pact::V2::FfiError; end + + class InteractionMismatchesError < Pact::V2::Error; end + + class InteractionBuilderError < Pact::V2::Error; end + + def initialize(pact_config, description: nil) + @pact_config = pact_config + @description = description || "" + @contents = nil + @provider_state_meta = nil + end + + def with_plugin(plugin_name, plugin_version) + raise InteractionBuilderError.new("plugin_name is required") if plugin_name.blank? + raise InteractionBuilderError.new("plugin_version is required") if plugin_version.blank? + + @plugin_name = plugin_name + @plugin_version = plugin_version + self + end + + def given(provider_state, metadata = {}) + @provider_state_meta = {provider_state => metadata} + self + end + + def upon_receiving(description) + @description = description + self + end + + def with_contents(contents_hash) + @contents = InteractionContents.plugin(contents_hash) + self + end + + def with_content_type(content_type) + @interaction_content_type = content_type || @content_type + self + end + + def with_plugin_metadata(meta_hash) + @plugin_metadata = meta_hash + self + end + + def with_transport(transport) + @transport = transport + self + end + + def interaction_json + result = { + contents: @contents + } + result.merge!(@plugin_metadata) if @plugin_metadata.is_a?(Hash) + JSON.dump(result) + end + + def validate! + raise InteractionBuilderError.new("invalid contents format, should be a hash") unless @contents.is_a?(Hash) + end + + def execute(&block) + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + + validate! + + pact_handle = init_pact + init_plugin!(pact_handle) + + interaction = PactFfi::AsyncMessageConsumer.new(pact_handle, @description) + + @provider_state_meta&.each_pair do |provider_state, meta| + if meta.present? + meta.each_pair do |k, v| + if v.nil? || (v.respond_to?(:empty?) && v.empty?) + PactFfi.given(interaction, provider_state) + else + PactFfi.given_with_param(interaction, provider_state, k.to_s, v.to_s) + end + end + else + PactFfi.given(interaction, provider_state) + end + end + + result = PactFfi.with_body(interaction, 0, @interaction_content_type, interaction_json) + if CREATE_INTERACTION_ERRORS[result].present? + error = CREATE_INTERACTION_ERRORS[result] + raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status]) + end + + mock_server = MockServer.create_for_transport!(pact: pact_handle, transport: @transport, host: @pact_config.mock_host, port: @pact_config.mock_port) + + yield(pact_handle, mock_server) + + ensure + if mock_server.matched? + mock_server.write_pacts!(@pact_config.pact_dir) + else + msg = mismatches_error_msg(mock_server) + raise InteractionMismatchesError.new(msg) + end + @used = true + mock_server&.cleanup + PactFfi::PluginConsumer.cleanup_plugins(pact_handle) if pact_handle + PactFfi.free_pact_handle(pact_handle) if pact_handle + end + + private + + def mismatches_error_msg(mock_server) + rspec_example_desc = RSpec.current_example&.description + return "interaction for has mismatches: #{mock_server.mismatches}" if rspec_example_desc.blank? + + "#{rspec_example_desc} has mismatches: #{mock_server.mismatches}" + end + + def init_pact + handle = PactFfi.new_pact(@pact_config.consumer_name, @pact_config.provider_name) + PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"]) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level) + + handle + end + + def init_plugin!(pact_handle) + result = PactFfi::PluginConsumer.using_plugin(pact_handle, @plugin_name, @plugin_version) + return result if INIT_PLUGIN_ERRORS[result].blank? + + error = INIT_PLUGIN_ERRORS[result] + raise PluginInitError.new("There was an error while trying to initialize plugin #{@plugin_name}/#{@plugin_version}", error[:reason], error[:status]) + end + end + end + end +end diff --git a/lib/pact/v2/consumer/plugin_http_interaction_builder.rb b/lib/pact/v2/consumer/plugin_http_interaction_builder.rb new file mode 100644 index 00000000..0996d06a --- /dev/null +++ b/lib/pact/v2/consumer/plugin_http_interaction_builder.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "pact/ffi/http_consumer" +require "pact/ffi/plugin_consumer" +require "pact/ffi/logger" + +module Pact + module V2 + module Consumer + class PluginHttpInteractionBuilder + + class PluginInitError < Pact::V2::FfiError; end + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html + INIT_PLUGIN_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"}, + 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"} + }.freeze + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html + CREATE_INTERACTION_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"}, + 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"}, + 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"}, + 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"}, + 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"} + }.freeze + + class CreateInteractionError < Pact::V2::FfiError; end + + class InteractionMismatchesError < Pact::V2::Error; end + + class InteractionBuilderError < Pact::V2::Error; end + + def initialize(pact_config, description: nil) + @pact_config = pact_config + @description = description || "" + @contents = nil + @provider_state_meta = nil + end + + def with_plugin(plugin_name, plugin_version) + raise InteractionBuilderError.new("plugin_name is required") if plugin_name.blank? + raise InteractionBuilderError.new("plugin_version is required") if plugin_version.blank? + + @plugin_name = plugin_name + @plugin_version = plugin_version + self + end + + def given(provider_state, metadata = {}) + @provider_state_meta = {provider_state => metadata} + self + end + + def upon_receiving(description) + @description = description + self + end + + def with_request(method: nil, path: nil, query: {}, headers: {}, body: nil) + @request = { + method: method, + path: path, + query: query, + headers: headers, + body: body + } + self + end + + def will_respond_with(status: nil, headers: {}, body: nil) + @response = { + status: status, + headers: headers, + body: body + } + self + end + + def with_content_type(content_type) + @content_type = content_type + self + end + + + def with_plugin_metadata(meta_hash) + @plugin_metadata = meta_hash + self + end + + def with_transport(transport) + @transport = transport + self + end + + def interaction_json + result = { + request: @request, + response: @response + } + result.merge!(@plugin_metadata) if @plugin_metadata.is_a?(Hash) + JSON.dump(result) + end + + def validate! + raise InteractionBuilderError.new("invalid request format, should be a hash") unless @request.is_a?(Hash) + raise InteractionBuilderError.new("invalid response format, should be a hash") unless @response.is_a?(Hash) + end + + def execute(&block) + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + + validate! + + pact_handle = init_pact + init_plugin!(pact_handle) + + interaction = PactFfi.new_interaction(pact_handle, @description) + @provider_state_meta&.each_pair do |provider_state, meta| + if meta.present? + meta.each_pair do |k, v| + if v.nil? || (v.respond_to?(:empty?) && v.empty?) + PactFfi.given(interaction, provider_state) + else + PactFfi.given_with_param(interaction, provider_state, k.to_s, v.to_s) + end + end + else + PactFfi.given(interaction, provider_state) + end + end + PactFfi::HttpConsumer.with_request(interaction, @request[:method], @request[:path]) + + result = PactFfi::PluginConsumer.interaction_contents(interaction, 0, @request[:headers]["content-type"], format_value(@request[:body])) + if CREATE_INTERACTION_ERRORS[result].present? + error = CREATE_INTERACTION_ERRORS[result] + raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status]) + end + result = PactFfi::PluginConsumer.interaction_contents(interaction, 1, @response[:headers]["content-type"], format_value(@response[:body])) + if CREATE_INTERACTION_ERRORS[result].present? + error = CREATE_INTERACTION_ERRORS[result] + raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status]) + end + mock_server = MockServer.create_for_transport!(pact: pact_handle, transport: @transport || 'http', host: @pact_config.mock_host, port: @pact_config.mock_port) + + yield(mock_server) + + ensure + if mock_server.matched? + mock_server.write_pacts!(@pact_config.pact_dir) + else + msg = mismatches_error_msg(mock_server) + raise InteractionMismatchesError.new(msg) + end + @used = true + mock_server&.cleanup + PactFfi::PluginConsumer.cleanup_plugins(pact_handle) if pact_handle + PactFfi.free_pact_handle(pact_handle) if pact_handle + end + + private + + def mismatches_error_msg(mock_server) + rspec_example_desc = RSpec.current_example&.description + return "interaction for has mismatches: #{mock_server.mismatches}" if rspec_example_desc.blank? + + "#{rspec_example_desc} has mismatches: #{mock_server.mismatches}" + end + + def init_pact + handle = PactFfi.new_pact(@pact_config.consumer_name, @pact_config.provider_name) + PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"]) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level) + + handle + end + + def init_plugin!(pact_handle) + result = PactFfi::PluginConsumer.using_plugin(pact_handle, @plugin_name, @plugin_version) + return result if INIT_PLUGIN_ERRORS[result].blank? + + error = INIT_PLUGIN_ERRORS[result] + raise PluginInitError.new("There was an error while trying to initialize plugin #{@plugin_name}/#{@plugin_version}", error[:reason], error[:status]) + end + + def format_value(obj) + return obj if obj.is_a?(String) + + return JSON.dump({value: obj}) if obj.is_a?(Array) + + JSON.dump(obj) + end + end + end + end +end diff --git a/lib/pact/v2/consumer/plugin_sync_message_interaction_builder.rb b/lib/pact/v2/consumer/plugin_sync_message_interaction_builder.rb new file mode 100644 index 00000000..8994f0ae --- /dev/null +++ b/lib/pact/v2/consumer/plugin_sync_message_interaction_builder.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "pact/ffi/sync_message_consumer" +require "pact/ffi/plugin_consumer" +require "pact/ffi/logger" + +module Pact + module V2 + module Consumer + class PluginSyncMessageInteractionBuilder + + class PluginInitError < Pact::V2::FfiError; end + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html + INIT_PLUGIN_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"}, + 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"} + }.freeze + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html + CREATE_INTERACTION_ERRORS = { + 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"}, + 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"}, + 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"}, + 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"}, + 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"}, + 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"} + }.freeze + + class CreateInteractionError < Pact::V2::FfiError; end + + class InteractionMismatchesError < Pact::V2::Error; end + + class InteractionBuilderError < Pact::V2::Error; end + + def initialize(pact_config, description: nil) + @pact_config = pact_config + @description = description || "" + @request = nil + @response = nil + @response_meta = nil + @provider_state_meta = nil + end + + def with_plugin(plugin_name, plugin_version) + raise InteractionBuilderError.new("plugin_name is required") if plugin_name.blank? + raise InteractionBuilderError.new("plugin_version is required") if plugin_version.blank? + + @plugin_name = plugin_name + @plugin_version = plugin_version + self + end + + def given(provider_state, metadata = {}) + @provider_state_meta = {provider_state => metadata} + self + end + + def upon_receiving(description) + @description = description + self + end + + def with_request(req_hash) + @request = InteractionContents.plugin(req_hash) + self + end + + def with_content_type(content_type) + @content_type = content_type + self + end + + def will_respond_with(resp_hash) + @response = InteractionContents.plugin(resp_hash) + self + end + + def will_respond_with_meta(meta_hash) + @response_meta = InteractionContents.plugin(meta_hash) + self + end + + def with_plugin_metadata(meta_hash) + @plugin_metadata = meta_hash + self + end + + def with_transport(transport) + @transport = transport + self + end + + def interaction_json + result = { + request: @request + } + result.merge!(@plugin_metadata) if @plugin_metadata.is_a?(Hash) + + result[:response] = @response if @response.is_a?(Hash) + result[:responseMetadata] = @response_meta if @response_meta.is_a?(Hash) + + JSON.dump(result) + end + + def validate! + raise InteractionBuilderError.new("invalid request format, should be a hash") unless @request.is_a?(Hash) + raise InteractionBuilderError.new("invalid response format, should be a hash") unless @response.is_a?(Hash) || @response_meta.is_a?(Hash) + end + + def execute(&block) + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + + validate! + + pact_handle = init_pact + init_plugin!(pact_handle) + + message_pact = PactFfi::SyncMessageConsumer.new_interaction(pact_handle, @description) + @provider_state_meta&.each_pair do |provider_state, meta| + if meta.present? + meta.each_pair { |k, v| PactFfi.given_with_param(message_pact, provider_state, k.to_s, v.to_s) } + else + PactFfi.given(message_pact, provider_state) + end + end + result = PactFfi::PluginConsumer.interaction_contents(message_pact, 0, @content_type, interaction_json) + if CREATE_INTERACTION_ERRORS[result].present? + error = CREATE_INTERACTION_ERRORS[result] + raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status]) + end + + mock_server = MockServer.create_for_transport!(pact: pact_handle, transport: @transport, host: @pact_config.mock_host, port: @pact_config.mock_port) + + yield(message_pact, mock_server) + + ensure + if mock_server.matched? + mock_server.write_pacts!(@pact_config.pact_dir) + else + msg = mismatches_error_msg(mock_server) + raise InteractionMismatchesError.new(msg) + end + @used = true + mock_server&.cleanup + PactFfi::PluginConsumer.cleanup_plugins(pact_handle) + PactFfi.free_pact_handle(pact_handle) + end + + private + + def mismatches_error_msg(mock_server) + rspec_example_desc = RSpec.current_example&.description + return "interaction for has mismatches: #{mock_server.mismatches}" if rspec_example_desc.blank? + + "#{rspec_example_desc} has mismatches: #{mock_server.mismatches}" + end + + def init_pact + handle = PactFfi.new_pact(@pact_config.consumer_name, @pact_config.provider_name) + PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"]) + PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version) + + Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level) + + handle + end + + def init_plugin!(pact_handle) + result = PactFfi::PluginConsumer.using_plugin(pact_handle, @plugin_name, @plugin_version) + return result if INIT_PLUGIN_ERRORS[result].blank? + + error = INIT_PLUGIN_ERRORS[result] + raise PluginInitError.new("There was an error while trying to initialize plugin #{@plugin_name}/#{@plugin_version}", error[:reason], error[:status]) + end + end + end + end +end diff --git a/lib/pact/v2/generators.rb b/lib/pact/v2/generators.rb new file mode 100644 index 00000000..3e244d47 --- /dev/null +++ b/lib/pact/v2/generators.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Generators + + def generate_random_int(min:, max:) + Pact::V2::Generators::RandomIntGenerator.new(min: min, max: max) + end + def generate_random_decimal(digits:) + Pact::V2::Generators::RandomDecimalGenerator.new(digits: digits) + end + def generate_random_hexadecimal(digits:) + Pact::V2::Generators::RandomHexadecimalGenerator.new(digits: digits) + end + def generate_random_string(size:) + Pact::V2::Generators::RandomStringGenerator.new(size: size) + end + + def generate_uuid(example: nil) + Pact::V2::Generators::UuidGenerator.new(example: example) + end + + def generate_date(format: nil, example: nil) + Pact::V2::Generators::DateGenerator.new(format: format, example: example) + end + + def generate_time(format: nil) + Pact::V2::Generators::TimeGenerator.new(format: format) + end + + def generate_datetime(format: nil) + Pact::V2::Generators::DateTimeGenerator.new(format: format) + end + + def generate_random_boolean + Pact::V2::Generators::RandomBooleanGenerator.new + end + + def generate_from_provider_state(expression:, example:) + Pact::V2::Generators::ProviderStateGenerator.new(expression: expression, example: example).as_basic + end + + def generate_mock_server_url(regex: nil, example: nil) + Pact::V2::Generators::MockServerURLGenerator.new(regex: regex, example: example) + end + end + end +end diff --git a/lib/pact/v2/generators/base.rb b/lib/pact/v2/generators/base.rb new file mode 100644 index 00000000..fd21b75c --- /dev/null +++ b/lib/pact/v2/generators/base.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Generators + module Base + def as_basic + raise NotImplementedError, "Subclasses must implement the as_basic method" + end + end + + class RandomIntGenerator + include Base + + def initialize(min:, max:) + @min = min + @max = max + end + + def as_basic + { + "pact:matcher:type" => "integer", + "pact:generator:type" => "RandomInt", + "min" => @min, + "max" => @max, + "value" => rand(@min..@max) + } + end + end + + class RandomDecimalGenerator + include Base + + def initialize(digits:) + @digits = digits + end + + def as_basic + { + 'pact:matcher:type' => 'decimal', + "pact:generator:type" => "RandomDecimal", + "digits" => @digits, + "value" => rand.round(@digits) + } + end + end + + class RandomHexadecimalGenerator + include Base + + def initialize(digits:) + @digits = digits + end + + def as_basic + { + "pact:matcher:type" => "decimal", + "pact:generator:type" => "RandomHexadecimal", + "digits" => @digits, + "value" => SecureRandom.hex((@digits / 2.0).ceil)[0...@digits] + } + end + end + + class RandomStringGenerator + include Base + + def initialize(size:, example: nil) + @size = size + @example = example + end + + def as_basic + { + "pact:matcher:type" => "type", + "pact:generator:type" => "RandomString", + "size" => @size, + "value" => @example || SecureRandom.alphanumeric(@size) + } + end + end + + class UuidGenerator + include Base + + + def initialize(example: nil) + @example = example + @regexStr = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; + if @example + regex = Regexp.new("^#{@regexStr}$") + unless @example.match?(regex) + raise ArgumentError, "regex: Example value '#{@example}' does not match the UUID regular expression '#{@regexStr}'" + end + end + end + + def as_basic + { + "pact:matcher:type" => "regex", + "pact:generator:type" => "Uuid", + "regex" => @regexStr, + "value" => @example || SecureRandom.uuid + } + end + end + + class DateGenerator + include Base + + def initialize(format: nil, example: nil) + @format = format || default_format + @example = example || Time.now.strftime(convert_from_java_simple_date_format(@format)) + end + + def as_basic + h = { "pact:generator:type" => type } + h["pact:matcher:type"] = matcher_type + h["format"] = @format if @format + h["value"] = @example + h + end + + def type + 'Date' + end + + def matcher_type + 'date' + end + + def default_format + 'yyyy-MM-dd' + end + + # Converts Java SimpleDateFormat to Ruby strftime format + def convert_from_java_simple_date_format(format) + f = format.dup + # Year + f.gsub!(/(? "boolean", + "pact:generator:type" => "RandomBoolean", + "value" => @example.nil? ? [true, false].sample : @example + } + end + end + + class ProviderStateGenerator + include Base + + def initialize(expression:, example:) + @expression = expression + @value = example + end + + def as_basic + { + 'pact:matcher:type': 'type', + "pact:generator:type" => "ProviderState", + "expression" => @expression, + "value" => @value + } + end + end + + class MockServerURLGenerator + include Base + + def initialize(regex:, example:) + @regex = regex + @example = example + end + + def as_basic + { + "pact:generator:type" => "MockServerURL", + "pact:matcher:type" => "regex", + "regex" => @regex, + "example" => @example, + "value" => @example + } + end + end + end + end +end diff --git a/lib/pact/v2/matchers.rb b/lib/pact/v2/matchers.rb new file mode 100644 index 00000000..7886dce7 --- /dev/null +++ b/lib/pact/v2/matchers.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + PACT_SPEC_V1 = 1 + PACT_SPEC_V2 = 2 + PACT_SPEC_V3 = 3 + PACT_SPEC_V4 = 4 + + ANY_STRING_REGEX = /.*/ + UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + + # simplified + ISO8601_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)*(.\d{2}:\d{2})*/i + + def match_exactly(arg) + V1::Equality.new(arg) + end + + def match_type_of(arg) + V2::Type.new(arg) + end + + def match_include(arg) + V3::Include.new(arg) + end + + def match_any_string(sample = "any") + V2::Regex.new(ANY_STRING_REGEX, sample) + end + + def match_any_integer(sample = 10) + V3::Integer.new(sample) + end + + def match_any_decimal(sample = 10.0) + V3::Decimal.new(sample) + end + + def match_any_number(sample = 10.0) + V3::Number.new(sample) + end + + def match_any_boolean(sample = true) + V3::Boolean.new(sample) + end + + def match_uuid(sample = "e1d01e04-3a2b-4eed-a4fb-54f5cd257338") + V2::Regex.new(UUID_REGEX, sample) + end + + def match_regex(regex, sample) + V2::Regex.new(regex, sample) + end + + def match_datetime(format, sample) + V3::DateTime.new(format, sample) + end + + def match_iso8601(sample = "2024-08-12T12:25:00.243118+03:00") + V2::Regex.new(ISO8601_REGEX, sample) + end + + def match_date(format, sample) + V3::Date.new(format, sample) + end + + def match_time(format, sample) + V3::Time.new(format, sample) + end + + def match_each(template, min = nil) + V3::Each.new(template, min) + end + + def match_each_regex(regex, sample) + match_each_value(sample, match_regex(regex, sample)) + end + + def match_each_key(template, key_matchers) + V4::EachKey.new(key_matchers.is_a?(Array) ? key_matchers : [key_matchers], template) + end + + def match_each_value(template, value_matchers = V2::Type.new("")) + V4::EachValue.new(value_matchers.is_a?(Array) ? value_matchers : [value_matchers], template) + end + + def match_each_kv(template, key_matchers) + V4::EachKeyValue.new(key_matchers.is_a?(Array) ? key_matchers : [key_matchers], template) + end + + def match_semver(template = nil) + V3::Semver.new(template) + end + + def match_content_type(content_type, template = nil) + V3::ContentType.new(content_type, template: template) + end + + def match_not_empty(template = nil) + V4::NotEmpty.new(template) + end + + def match_status_code(template) + V4::StatusCode.new(template) + end + end + end +end diff --git a/lib/pact/v2/matchers/base.rb b/lib/pact/v2/matchers/base.rb new file mode 100644 index 00000000..1a2dd13f --- /dev/null +++ b/lib/pact/v2/matchers/base.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + # see https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md + class Base + attr_reader :spec_version, :kind, :template, :opts + + class MatcherInitializationError < Pact::V2::Error; end + + def initialize(spec_version:, kind:, template: nil, opts: {}) + @spec_version = spec_version + @kind = kind + @template = template + @opts = opts + end + + def as_basic + result = { + "pact:matcher:type" => serialize!(@kind.deep_dup, :basic) + } + result["status"] = serialize!(@opts[:status].deep_dup, :basic) if @opts[:status] + result["value"] = serialize!(@template.deep_dup, :basic) unless @template.nil? + result.merge!(serialize!(@opts.deep_dup, :basic)) + result + end + + def as_plugin + params = @opts.values.map { |v| format_primitive(v) }.join(",") + value = format_primitive(@template) unless @template.nil? + + if @template.nil? + return "matching(#{@kind}#{params.present? ? ", #{params}" : ""})" + end + + return "matching(#{@kind}, #{params}, #{value})" if params.present? + + "matching(#{@kind}, #{value})" + end + + private + + def serialize!(data, format) + # serialize complex types recursively + case data + when TrueClass, FalseClass, Numeric, String + data + when Array + data.map { |v| serialize!(v, format) } + when Hash + data.transform_values { |v| serialize!(v, format) } + when Pact::V2::Matchers::Base + return data.as_basic if format == :basic + data.as_plugin if format == :plugin + else + data + end + end + + def format_primitive(arg) + case arg + when TrueClass, FalseClass, Numeric + arg.to_s + when String + "'#{arg}'" + else + raise "#{arg.class} is not a primitive" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v1/equality.rb b/lib/pact/v2/matchers/v1/equality.rb new file mode 100644 index 00000000..6ad34c21 --- /dev/null +++ b/lib/pact/v2/matchers/v1/equality.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V1 + class Equality < Pact::V2::Matchers::Base + def initialize(template) + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V1, kind: "equality", template: template) + end + + def as_plugin + "matching(equalTo, #{format_primitive(@template)})" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v2/regex.rb b/lib/pact/v2/matchers/v2/regex.rb new file mode 100644 index 00000000..7ea56db4 --- /dev/null +++ b/lib/pact/v2/matchers/v2/regex.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V2 + class Regex < Pact::V2::Matchers::Base + def initialize(regex, template) + raise MatcherInitializationError, "#{self.class}: #{regex} should be an instance of Regexp" unless regex.is_a?(Regexp) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of String or Array" unless template.is_a?(String) || template.is_a?(Array) + raise MatcherInitializationError, "#{self.class}: #{template} array values should be strings" if template.is_a?(Array) && !template.all?(String) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V2, kind: "regex", template: template, opts: {regex: regex.to_s}) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v2/type.rb b/lib/pact/v2/matchers/v2/type.rb new file mode 100644 index 00000000..6aa1f389 --- /dev/null +++ b/lib/pact/v2/matchers/v2/type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V2 + class Type < Pact::V2::Matchers::Base + def initialize(template) + raise MatcherInitializationError, "#{self.class}: template is not a primitive" unless template.is_a?(TrueClass) || template.is_a?(FalseClass) || template.is_a?(Numeric) || template.is_a?(String) || template.is_a?(Array) || template.is_a?(Hash) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V2, kind: "type", template: template) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/boolean.rb b/lib/pact/v2/matchers/v3/boolean.rb new file mode 100644 index 00000000..142bf725 --- /dev/null +++ b/lib/pact/v2/matchers/v3/boolean.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Boolean < Pact::V2::Matchers::Base + def initialize(template) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of Boolean" unless template.is_a?(TrueClass) || template.is_a?(FalseClass) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "boolean", template: template) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/content_type.rb b/lib/pact/v2/matchers/v3/content_type.rb new file mode 100644 index 00000000..0e95f42d --- /dev/null +++ b/lib/pact/v2/matchers/v3/content_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class ContentType < Pact::V2::Matchers::Base + def initialize(content_type, template: nil) + @content_type = content_type + @template = template + @opts = {} + @opts[:plugin_template] = template unless template.nil? + unless content_type.is_a?(String) && !content_type.empty? + raise MatcherInitializationError, "#{self.class}: content_type must be a non-empty String" + end + + super( + spec_version: Pact::V2::Matchers::PACT_SPEC_V3, + kind: "contentType", + template: content_type, + opts: @opts + ) + end + + def as_plugin + "matching(contentType, '#{@content_type}', '#{@opts[:plugin_template]}')" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/date.rb b/lib/pact/v2/matchers/v3/date.rb new file mode 100644 index 00000000..4a8ad71f --- /dev/null +++ b/lib/pact/v2/matchers/v3/date.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Date < Pact::V2::Matchers::Base + def initialize(format, template) + raise MatcherInitializationError, "#{self.class}: #{format} should be an instance of String" unless template.is_a?(String) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of String" unless template.is_a?(String) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "date", template: template, opts: {format: format}) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/date_time.rb b/lib/pact/v2/matchers/v3/date_time.rb new file mode 100644 index 00000000..7cb0e265 --- /dev/null +++ b/lib/pact/v2/matchers/v3/date_time.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class DateTime < Pact::V2::Matchers::Base + def initialize(format, template) + raise MatcherInitializationError, "#{self.class}: #{format} should be an instance of String" unless template.is_a?(String) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of String" unless template.is_a?(String) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "datetime", template: template, opts: {format: format}) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/decimal.rb b/lib/pact/v2/matchers/v3/decimal.rb new file mode 100644 index 00000000..16aa2b9e --- /dev/null +++ b/lib/pact/v2/matchers/v3/decimal.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Decimal < Pact::V2::Matchers::Base + def initialize(template) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of Float" unless template.is_a?(Float) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "decimal", template: template) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/each.rb b/lib/pact/v2/matchers/v3/each.rb new file mode 100644 index 00000000..d92fbee5 --- /dev/null +++ b/lib/pact/v2/matchers/v3/each.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Each < Pact::V2::Matchers::Base + def initialize(template, min) + raise MatcherInitializationError, "#{self.class}: #{min} should be greater than 0" if min.present? && min < 1 + + min_array_size = min.presence || 1 + val = template.is_a?(Array) ? template : [template] * min_array_size + + raise MatcherInitializationError, "#{self.class}: #{min} is invalid: template size is #{val.size}" if min_array_size != val.size + + super( + spec_version: Pact::V2::Matchers::PACT_SPEC_V3, + kind: "type", + template: val, + opts: {min: min_array_size}) + end + + def as_plugin + if @template.first.is_a?(Hash) + return { + "pact:match" => "eachValue(matching($'SAMPLE'))", + "SAMPLE" => serialize!(@template.first.deep_dup, :plugin) + } + end + + params = @opts.except(:min).values.map { |v| format_primitive(v) }.join(",") + value = format_primitive(@template.first) + + return "eachValue(matching(#{@kind}, #{params}, #{value}))" if params.present? + + "eachValue(matching(#{@kind}, #{value}))" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/include.rb b/lib/pact/v2/matchers/v3/include.rb new file mode 100644 index 00000000..16d2a25b --- /dev/null +++ b/lib/pact/v2/matchers/v3/include.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Include < Pact::V2::Matchers::Base + def initialize(template) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of String" unless template.is_a?(String) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "include", template: template) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/integer.rb b/lib/pact/v2/matchers/v3/integer.rb new file mode 100644 index 00000000..1318ac77 --- /dev/null +++ b/lib/pact/v2/matchers/v3/integer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Integer < Pact::V2::Matchers::Base + def initialize(template) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of Integer" unless template.is_a?(::Integer) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "integer", template: template) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/null.rb b/lib/pact/v2/matchers/v3/null.rb new file mode 100644 index 00000000..d059a163 --- /dev/null +++ b/lib/pact/v2/matchers/v3/null.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Null < Pact::V2::Matchers::Base + def initialize + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "null", template: nil) + end + + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/number.rb b/lib/pact/v2/matchers/v3/number.rb new file mode 100644 index 00000000..ca5d234c --- /dev/null +++ b/lib/pact/v2/matchers/v3/number.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Number < Pact::V2::Matchers::Base + def initialize(template) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of Numeric" unless template.is_a?(Numeric) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "number", template: template) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/semver.rb b/lib/pact/v2/matchers/v3/semver.rb new file mode 100644 index 00000000..d706f82b --- /dev/null +++ b/lib/pact/v2/matchers/v3/semver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Semver < Pact::V2::Matchers::Base + def initialize(template = nil) + @template = template + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "semver", template: template) + end + + def as_plugin + if @template.nil? || @template.blank? + raise MatcherInitializationError, "#{self.class}: template must be provided when calling as_plugin" + end + "matching(semver, '#{@template}')" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/time.rb b/lib/pact/v2/matchers/v3/time.rb new file mode 100644 index 00000000..4664b905 --- /dev/null +++ b/lib/pact/v2/matchers/v3/time.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Time < Pact::V2::Matchers::Base + def initialize(format, template) + raise MatcherInitializationError, "#{self.class}: #{format} should be an instance of String" unless template.is_a?(String) + raise MatcherInitializationError, "#{self.class}: #{template} should be an instance of String" unless template.is_a?(String) + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "time", template: template, opts: {format: format}) + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v3/values.rb b/lib/pact/v2/matchers/v3/values.rb new file mode 100644 index 00000000..0ce965ff --- /dev/null +++ b/lib/pact/v2/matchers/v3/values.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V3 + class Values < Pact::V2::Matchers::Base + def initialize + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V3, kind: "values", template: nil) + end + + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v4/each_key.rb b/lib/pact/v2/matchers/v4/each_key.rb new file mode 100644 index 00000000..c1a18070 --- /dev/null +++ b/lib/pact/v2/matchers/v4/each_key.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V4 + class EachKey < Pact::V2::Matchers::Base + def initialize(key_matchers, template) + raise MatcherInitializationError, "#{self.class}: #{template} should be a Hash" unless template.is_a?(Hash) + raise MatcherInitializationError, "#{self.class}: #{key_matchers} should be an Array" unless key_matchers.is_a?(Array) + raise MatcherInitializationError, "#{self.class}: #{key_matchers} should be instances of Pact::V2::Matchers::Base" unless key_matchers.all?(Pact::V2::Matchers::Base) + raise MatcherInitializationError, "#{self.class}: #{key_matchers} size should be greater than 0" unless key_matchers.size > 0 + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V4, kind: "each-key", template: template, opts: {rules: key_matchers}) + end + + def as_plugin + @opts[:rules].map do |matcher| + "eachKey(#{matcher.as_plugin})" + end.join(", ") + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v4/each_key_value.rb b/lib/pact/v2/matchers/v4/each_key_value.rb new file mode 100644 index 00000000..996cf1dc --- /dev/null +++ b/lib/pact/v2/matchers/v4/each_key_value.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V4 + class EachKeyValue < Pact::V2::Matchers::Base + def initialize(key_matchers, template) + raise MatcherInitializationError, "#{self.class}: #{template} should be a Hash" unless template.is_a?(Hash) + raise MatcherInitializationError, "#{self.class}: #{key_matchers} should be an Array" unless key_matchers.is_a?(Array) + raise MatcherInitializationError, "#{self.class}: #{key_matchers} should be instances of Pact::V2::Matchers::Base" unless key_matchers.all?(Pact::V2::Matchers::Base) + + super( + spec_version: Pact::V2::Matchers::PACT_SPEC_V4, + kind: [ + EachKey.new(key_matchers, {}), + EachValue.new([Pact::V2::Matchers::V2::Type.new("")], {}) + ], + template: template + ) + + @key_matchers = key_matchers + end + + def as_plugin + raise MatcherInitializationError, "#{self.class}: each-key-value is not supported in plugin syntax. Use each / each_key / each_value matchers instead" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v4/each_value.rb b/lib/pact/v2/matchers/v4/each_value.rb new file mode 100644 index 00000000..a7d90044 --- /dev/null +++ b/lib/pact/v2/matchers/v4/each_value.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V4 + class EachValue < Pact::V2::Matchers::Base + def initialize(value_matchers, template) + # raise MatcherInitializationError, "#{self.class}: #{template} should be a Hash" unless template.is_a?(Hash) + raise MatcherInitializationError, "#{self.class}: #{value_matchers} should be an Array" unless value_matchers.is_a?(Array) + raise MatcherInitializationError, "#{self.class}: #{value_matchers} should be instances of Pact::V2::Matchers::Base" unless value_matchers.all?(Pact::V2::Matchers::Base) + raise MatcherInitializationError, "#{self.class}: #{value_matchers} size should be greater than 0" unless value_matchers.size > 0 + + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V4, kind: "each-value", template: template, opts: {rules: value_matchers}) + end + + def as_plugin + if @template.is_a?(Hash) + return { + "pact:match" => "eachValue(matching($'SAMPLE'))", + "SAMPLE" => serialize!(@template.deep_dup, :plugin) + } + end + + @opts[:rules].map do |matcher| + "eachValue(#{matcher.as_plugin})" + end.join(", ") + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v4/not_empty.rb b/lib/pact/v2/matchers/v4/not_empty.rb new file mode 100644 index 00000000..dfeca347 --- /dev/null +++ b/lib/pact/v2/matchers/v4/not_empty.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V4 + class NotEmpty < Pact::V2::Matchers::Base + def initialize(template = nil) + @template = template + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V4, kind: 'notEmpty', template: @template) + end + + def as_plugin + if @template.nil? || @template.blank? + raise MatcherInitializationError, "#{self.class}: template must be provided when calling as_plugin" + end + + "notEmpty('#{@template}')" + end + end + end + end + end +end diff --git a/lib/pact/v2/matchers/v4/status_code.rb b/lib/pact/v2/matchers/v4/status_code.rb new file mode 100644 index 00000000..efdc0837 --- /dev/null +++ b/lib/pact/v2/matchers/v4/status_code.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Matchers + module V4 + class StatusCode < Pact::V2::Matchers::Base + def initialize(template = nil) + super(spec_version: Pact::V2::Matchers::PACT_SPEC_V4, kind: 'statusCode', opts: { + 'status' => template + }) + end + end + end + end + end +end diff --git a/lib/pact/v2/native/blocking_verifier.rb b/lib/pact/v2/native/blocking_verifier.rb new file mode 100644 index 00000000..818e7144 --- /dev/null +++ b/lib/pact/v2/native/blocking_verifier.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "ffi" +require "pact/ffi/verifier" + +module Pact + module V2 + module Native + module BlockingVerifier + extend FFI::Library + ffi_lib DetectOS.get_bin_path + + attach_function :execute, :pactffi_verifier_execute, %i[pointer], :int32, blocking: true + end + end + end +end diff --git a/lib/pact/v2/native/logger.rb b/lib/pact/v2/native/logger.rb new file mode 100644 index 00000000..e435f515 --- /dev/null +++ b/lib/pact/v2/native/logger.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "pact/ffi/logger" + +module Pact + module V2 + module Native + module Logger + LOG_LEVELS = { + off: PactFfi::FfiLogLevelFilter["LOG_LEVEL_OFF"], + error: PactFfi::FfiLogLevelFilter["LOG_LEVEL_ERROR"], + warn: PactFfi::FfiLogLevelFilter["LOG_LEVEL_WARN"], + info: PactFfi::FfiLogLevelFilter["LOG_LEVEL_INFO"], + debug: PactFfi::FfiLogLevelFilter["LOG_LEVEL_DEBUG"], + trace: PactFfi::FfiLogLevelFilter["LOG_LEVEL_TRACE"] + }.freeze + + def self.log_to_stdout(log_level) + raise "invalid log level for PactFfi::FfiLogLevelFilter" unless LOG_LEVELS.key?(log_level) + PactFfi::Logger.log_to_stdout(LOG_LEVELS[log_level]) + end + end + end + end +end diff --git a/lib/pact/v2/provider.rb b/lib/pact/v2/provider.rb new file mode 100644 index 00000000..973c675f --- /dev/null +++ b/lib/pact/v2/provider.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Provider + end + end +end diff --git a/lib/pact/v2/provider/async_message_verifier.rb b/lib/pact/v2/provider/async_message_verifier.rb new file mode 100644 index 00000000..26a2430f --- /dev/null +++ b/lib/pact/v2/provider/async_message_verifier.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "pact/ffi/verifier" +require "pact/v2/native/logger" + +module Pact + module V2 + module Provider + class AsyncMessageVerifier < BaseVerifier + PROVIDER_TRANSPORT_TYPE = "message" + + def initialize(pact_config, mixed_config = nil) + super + + raise ArgumentError, "pact_config must be an instance of Pact::V2::Provider::PactConfig::Message" unless pact_config.is_a?(::Pact::V2::Provider::PactConfig::Async) + end + + private + + def add_provider_transport(pact_handle) + setup_uri = URI(@pact_config.message_setup_url) + PactFfi::Verifier.add_provider_transport(pact_handle, PROVIDER_TRANSPORT_TYPE, setup_uri.port, setup_uri.path, "") + end + + end + end + end +end diff --git a/lib/pact/v2/provider/base_verifier.rb b/lib/pact/v2/provider/base_verifier.rb new file mode 100644 index 00000000..d4e54924 --- /dev/null +++ b/lib/pact/v2/provider/base_verifier.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require "pact/ffi/verifier" +require "pact/v2/native/logger" +require "pact/v2/native/blocking_verifier" + +module Pact + module V2 + module Provider + class BaseVerifier + PROVIDER_TRANSPORT_TYPE = nil + attr_reader :logger + + class VerificationError < Pact::V2::FfiError; end + + class VerifierError < Pact::V2::Error; end + + DEFAULT_CONSUMER_SELECTORS = {} + + # https://docs.rs/pact_ffi/0.4.17/pact_ffi/verifier/fn.pactffi_verify.html#errors + VERIFICATION_ERRORS = { + 1 => {reason: :verification_failed, status: 1, description: "The verification process failed, see output for errors"}, + 2 => {reason: :null_pointer, status: 2, description: "A null pointer was received"}, + 3 => {reason: :internal_error, status: 3, description: "The method panicked"}, + 4 => {reason: :invalid_arguments, status: 4, description: "Invalid arguments were provided to the verification process"} + }.freeze + + # env below are set up by pipeline-builder + # see paas/cicd/images/pact/pipeline-builder/-/blob/master/internal/commands/consumers-pipeline/ruby.go + def initialize(pact_config, mixed_config = nil) + raise ArgumentError, "pact_config must be a subclass of Pact::V2::Provider::PactConfig::Base" unless pact_config.is_a?(::Pact::V2::Provider::PactConfig::Base) + @pact_config = pact_config + @mixed_config = mixed_config + @logger = Logger.new($stdout) + end + + def verify! + raise VerifierError.new("interaction is designed to be used one-time only") if defined?(@used) + + # if consumer_selectors.blank? + # logger.info("[verifier] does not need to verify consumer #{@pact_config.consumer_name}") + # return + # end + + exception = nil + pact_handle = init_pact + + start_servers! + + logger.info("[verifier] starting provider verification") + + result = Pact::V2::Native::BlockingVerifier.execute(pact_handle) + if VERIFICATION_ERRORS[result].present? + error = VERIFICATION_ERRORS[result] + exception = VerificationError.new("There was an error while trying to verify provider \"#{@pact_config.provider_name}\"", error[:reason], error[:status]) + end + ensure + @used = true + PactFfi::Verifier.shutdown(pact_handle) if pact_handle + stop_servers + @grpc_server.stop if @grpc_server + raise exception if exception + end + + private + + def create_c_pointer_array_from_string_array(string_array) + pointers = string_array.map { |str| FFI::MemoryPointer.from_string(str) } + array_pointer = FFI::MemoryPointer.new(:pointer, pointers.size) + pointers.each_with_index do |ptr, index| + array_pointer[index].put_pointer(0, ptr) + end + array_pointer + end + + def bool_to_int(value) + value ? 1 : 0 + end + + def init_pact + handle = PactFfi::Verifier.new_for_application("pact-ruby-v2", PactFfi.version) + set_provider_info(handle) + + if defined?(@mixed_config.grpc_config) && @mixed_config.grpc_config + @grpc_server = GrufServer.new(host: "127.0.0.1:#{@mixed_config.grpc_config.grpc_port}", services: @mixed_config.grpc_config.grpc_services) + @grpc_server.start + PactFfi::Verifier.add_provider_transport(handle, "grpc", @mixed_config.grpc_config.grpc_port, "", "") + end + + if defined?(@mixed_config.async_config) && @mixed_config.async_config + setup_uri = URI(@mixed_config.async_config.message_setup_url) + PactFfi::Verifier.add_provider_transport(handle, "message", setup_uri.port, setup_uri.path, "") + end + + # todo: add http transport? + + PactFfi::Verifier.set_provider_state(handle, @pact_config.provider_setup_url, 1, 1) + PactFfi::Verifier.set_verification_options(handle, 0, 10000) + # pactffi_verifier_set_publish_options( + # handle: *mut VerifierHandle, + # provider_version: *const c_char, + # build_url: *const c_char, + # provider_tags: *const *const c_char, + # provider_tags_len: c_ushort, + # provider_branch: *const c_char, + # ) + c_provider_version_tags = create_c_pointer_array_from_string_array(@pact_config.provider_version_tags) + c_provider_version_tags_size = @pact_config.provider_version_tags.size + c_consumer_version_tags = create_c_pointer_array_from_string_array(@pact_config.consumer_version_tags) + c_consumer_version_tags_size = @pact_config.consumer_version_tags.size + + if @pact_config.provider_build_uri.present? + begin + URI.parse(@pact_config.provider_build_uri) + rescue URI::InvalidURIError + raise VerifierError.new("provider_build_uri is not a valid URI") + end + end + + if @pact_config.publish_verification_results == true + if @pact_config.provider_version + PactFfi::Verifier.set_publish_options(handle, @pact_config.provider_version, @pact_config.provider_build_uri, c_provider_version_tags, c_provider_version_tags_size, @pact_config.provider_version_branch) + else + logger.warn("[verifier] - unable to publish verification results as provider version is not set") + end + end + + configure_verification_source(handle, c_provider_version_tags, c_provider_version_tags_size, c_consumer_version_tags, c_consumer_version_tags_size) + + PactFfi::Verifier.set_no_pacts_is_error(handle, bool_to_int(@pact_config.fail_if_no_pacts_found)) + + add_provider_transport(handle) + + # the core doesnt pick up these env vars, so we need to set them here + # https://github.com/pact-foundation/pact-reference/issues/451#issuecomment-2338130587 + # PACT_DESCRIPTION + # Only validate interactions whose descriptions match this filter (regex format) + # PACT_PROVIDER_STATE + # Only validate interactions whose provider states match this filter (regex format) + # PACT_PROVIDER_NO_STATE + # Only validate interactions that have no defined provider state (true or false) + PactFfi::Verifier.set_filter_info( + handle, + ENV["PACT_DESCRIPTION"] || nil, + ENV["PACT_PROVIDER_STATE"] || nil, + bool_to_int(ENV["PACT_PROVIDER_NO_STATE"] || false) + ) + + Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level) + + logger.info("[verifier] verification initialized for provider #{@pact_config.provider_name}, version #{@pact_config.provider_version}, transport #{self.class::PROVIDER_TRANSPORT_TYPE}") + + handle + end + + def set_provider_info(pact_handle) + # pub extern "C" fn pactffi_verifier_set_provider_info( + # handle: *mut VerifierHandle, + # name: *const c_char, + # scheme: *const c_char, + # host: *const c_char, + # port: c_ushort, + # path: *const c_char, + # ) { + PactFfi::Verifier.set_provider_info(pact_handle, @pact_config.provider_name, "", "", 0, "") + end + + def add_provider_transport(pact_handle) + raise Pact::V2::ImplementationRequired, "Implement #add_provider_transport in a subclass" + end + + def start_servers! + logger.info("[verifier] starting services") + + @servers_started = true + @pact_config.start_servers + end + + def stop_servers + return unless @servers_started + + logger.info("[verifier] stopping services") + + @pact_config.stop_servers + end + + def configure_verification_source(handle, c_provider_version_tags, c_provider_version_tags_size, c_consumer_version_tags, c_consumer_version_tags_size) + logger.info("[verifier] configuring verification source") + if @pact_config.pact_broker_proxy_url.blank? && @pact_config.pact_uri.blank? + # todo support non rail apps + path = @pact_config.pact_dir || (defined?(Rails) ? Rails.root.join("pacts").to_s : "pacts") + logger.info("[verifier] pact broker url or pact uri is not set, using directory #{path} as a verification source") + return PactFfi::Verifier.add_directory_source(handle, path) + end + + if @pact_config.pact_uri.present? + if @pact_config.pact_uri.start_with?("http") + logger.info("[verifier] using pact uri #{@pact_config.pact_uri} as a verification source") + PactFfi::Verifier.url_source(handle, @pact_config.pact_uri, @pact_config.broker_username, @pact_config.broker_password, @pact_config.broker_token) + else + logger.info("[verifier] using pact file #{@pact_config.pact_uri} as a verification source") + PactFfi::Verifier.add_file_source(handle, @pact_config.pact_uri) + end + else + logger.info("[verifier] using pact broker url #{@pact_config.broker_url} with consumer selectors: #{JSON.dump(consumer_selectors)} as a verification source") + consumer_selectors = [] if consumer_selectors.nil? + filters = consumer_selectors.map do |selector| + FFI::MemoryPointer.from_string(JSON.dump(selector).to_s) + end + filters_ptr = FFI::MemoryPointer.new(:pointer, filters.size + 1) + filters_ptr.write_array_of_pointer(filters) + PactFfi::Verifier.broker_source_with_selectors(handle, @pact_config.pact_broker_proxy_url, @pact_config.broker_username, @pact_config.broker_password, @pact_config.broker_token, bool_to_int(@pact_config.enable_pending), @pact_config.include_wip_pacts_since, c_provider_version_tags, c_provider_version_tags_size, @pact_config.provider_version_branch, filters_ptr, consumer_selectors.size, c_consumer_version_tags, c_consumer_version_tags_size) + end + end + + def consumer_selectors + (!@pact_config.consumer_version_selectors.empty? && @pact_config.consumer_version_selectors) || @consumer_selectors if @pact_config.consumer_version_selectors + end + + def build_consumer_selectors(verify_only, consumer_name, consumer_branch) + # if verify_only and consumer_name are defined - select only needed consumer + if verify_only.present? + # select proper consumer branch if defined + if consumer_name.present? + return [] unless verify_only.include?(consumer_name) + return [{"branch" => consumer_branch, "consumer" => consumer_name}] if consumer_branch.present? + return [DEFAULT_CONSUMER_SELECTORS.merge("consumer" => consumer_name)] + end + # or default selectors + return verify_only.map { |name| DEFAULT_CONSUMER_SELECTORS.merge("consumer" => name) } + end + + # select provided consumer_name + return [{"branch" => consumer_branch, "consumer" => consumer_name}] if consumer_name.present? && consumer_branch.present? + return [DEFAULT_CONSUMER_SELECTORS.merge("consumer" => consumer_name)] if consumer_name.present? + + [DEFAULT_CONSUMER_SELECTORS] + end + end + end + end +end diff --git a/lib/pact/v2/provider/grpc_verifier.rb b/lib/pact/v2/provider/grpc_verifier.rb new file mode 100644 index 00000000..aee1e5e6 --- /dev/null +++ b/lib/pact/v2/provider/grpc_verifier.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "pact/ffi/verifier" +require "pact/v2/native/logger" + +module Pact + module V2 + module Provider + class GrpcVerifier < BaseVerifier + PROVIDER_TRANSPORT_TYPE = "grpc" + + def initialize(pact_config, mixed_config = nil) + super + + raise ArgumentError, "pact_config must be an instance of Pact::V2::Provider::PactConfig::Grpc" unless pact_config.is_a?(::Pact::V2::Provider::PactConfig::Grpc) + @grpc_server = GrufServer.new(host: "127.0.0.1:#{@pact_config.grpc_port}", services: @pact_config.grpc_services) + end + + private + + def add_provider_transport(pact_handle) + PactFfi::Verifier.add_provider_transport(pact_handle, PROVIDER_TRANSPORT_TYPE, @pact_config.grpc_port, "", "") + end + + + def start_servers! + super + @grpc_server.start + end + + def stop_servers + super + @grpc_server.stop + end + end + end + end +end diff --git a/lib/pact/v2/provider/gruf_server.rb b/lib/pact/v2/provider/gruf_server.rb new file mode 100644 index 00000000..925df4b3 --- /dev/null +++ b/lib/pact/v2/provider/gruf_server.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Provider + # inspired by Gruf::Cli::Executor + class GrufServer + SERVER_STOP_TIMEOUT_SEC = 15 + + def initialize(options = {}) + @options = options + + setup! + + @server_pid = nil + + @services = @options[:services].is_a?(Array) ? @options[:services] : [] + @logger = @options[:logger] || ::Logger.new($stdout) + end + + def start + raise "server already running, stop server before starting new one" if @thread + + @logger.info("[gruf] starting standalone server with options: #{@options}") + + @server = Gruf::Server.new(Gruf.server_options) + @services.each { |s| @server.add_service(s) } if @services.any? + @thread = Thread.new do + @logger.debug "[gruf] starting grpc server" + @server.start! + end + @server.server.wait_till_running(10) + + @logger.info("[gruf] standalone server started") + end + + def stop + @logger.info("[gruf] stopping standalone server") + + @server&.server&.stop + @thread&.join(SERVER_STOP_TIMEOUT_SEC) + @thread&.kill + + @logger.info("[gruf] standalone server stopped") + end + + ## + # Run the server + # + def run + start + + yield + rescue => e + @logger.fatal("FATAL ERROR: #{e.message} #{e.backtrace.join("\n")}") + raise + ensure + stop + end + + private + + def setup! + Gruf.server_binding_url = @options[:host] if @options[:host] + if @options[:suppress_default_interceptors] + Gruf.interceptors.remove(Gruf::Interceptors::ActiveRecord::ConnectionReset) + Gruf.interceptors.remove(Gruf::Interceptors::Instrumentation::OutputMetadataTimer) + end + Gruf.backtrace_on_error = true if @options[:backtrace_on_error] + Gruf.health_check_enabled = true if @options[:health_check] + end + end + end + end +end diff --git a/lib/pact/v2/provider/http_server.rb b/lib/pact/v2/provider/http_server.rb new file mode 100644 index 00000000..6d87c0af --- /dev/null +++ b/lib/pact/v2/provider/http_server.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Provider + # inspired by Gruf::Cli::Executor + class HttpServer + SERVER_STOP_TIMEOUT_SEC = 15 + + def initialize(options = {}) + @options = options + + @server_pid = nil + + @host = @options[:host] || "localhost" + @logger = @options[:logger] || ::Logger.new($stdout) + # allow any rack based app to be passed in, otherwise + # we will load a Rails.application + # allows for backwards compat with pact-ruby v1 + @app = @options[:app] || nil + end + + def start + raise "server already running, stop server before starting new one" if @thread + + @logger.info("[webrick] starting server with options: #{@options}") + + @thread = Thread.new do + @logger.debug "[webrick] starting http server" + + # TODO: load from config.ru, if not rails and no app provided? + # Rack 2/3 compatibility + begin + require 'rack/handler/webrick' + handler = ::Rack::Handler::WEBrick + rescue LoadError + require 'rackup/handler/webrick' + handler = Class.new(Rackup::Handler::WEBrick) + end + handler.run(@app || (defined?(Rails) ? Rails.application : nil), + Host: @options[:host], + Port: @options[:port], + Logger: @logger, + StartCallback: -> { @started = true }) do |server| + @server = server + end + end + sleep 0.001 until @started + + @logger.info("[webrick] server started") + end + + def stop + @logger.info("[webrick] stopping server") + + @server&.shutdown + @thread&.join(SERVER_STOP_TIMEOUT_SEC) + @thread&.kill + + @logger.info("[webrick] server stopped") + end + + ## + # Run the server + # + def run + start + + yield + rescue => e + @logger.fatal("FATAL ERROR: #{e.message} #{e.backtrace.join("\n")}") + raise + ensure + stop + end + end + end + end +end diff --git a/lib/pact/v2/provider/http_verifier.rb b/lib/pact/v2/provider/http_verifier.rb new file mode 100644 index 00000000..fc0cbabe --- /dev/null +++ b/lib/pact/v2/provider/http_verifier.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "pact/ffi/verifier" +require "pact/v2/native/logger" + +module Pact + module V2 + module Provider + class HttpVerifier < BaseVerifier + PROVIDER_TRANSPORT_TYPE = "http" + + def initialize(pact_config, mixed_config = nil) + super + + raise ArgumentError, "pact_config must be an instance of Pact::V2::Provider::PactConfig::Http" unless pact_config.is_a?(::Pact::V2::Provider::PactConfig::Http) + @http_server = HttpServer.new(host: "127.0.0.1", port: @pact_config.http_port, app: @pact_config.app) + end + + private + + def set_provider_info(pact_handle) + PactFfi::Verifier.set_provider_info(pact_handle, @pact_config.provider_name, "", "", @pact_config.http_port, "") + end + + def add_provider_transport(pact_handle) + # The http transport is already added when the `set_provider_info` method is called, + # so we don't need to explicitly add the transport here + end + + + def start_servers! + super + @http_server.start + end + + def stop_servers + super + @http_server.stop + end + end + end + end +end diff --git a/lib/pact/v2/provider/message_provider_servlet.rb b/lib/pact/v2/provider/message_provider_servlet.rb new file mode 100644 index 00000000..3369d115 --- /dev/null +++ b/lib/pact/v2/provider/message_provider_servlet.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "webrick" + +module Pact + module V2 + module Provider + class MessageProviderServlet < WEBrick::HTTPServlet::ProcHandler + attr_reader :logger + + CONTENT_TYPE_JSON = "application/json" + CONTENT_TYPE_PROTO = "application/protobuf" + METADATA_HEADER = "pact-message-metadata" + + def initialize(logger: Logger.new($stdout)) + super(build_proc) + + @message_handlers = {} + + @logger = logger + end + + def add_message_handler(name, &block) + raise "message handler for #{name} already configured" if @message_handlers[name].present? + + @message_handlers[name] = {proc: block} + end + + private + + def build_proc + proc do |request, response| + # {"description":"message: ","providerStates":[{"name":"pet exists","params":{"pet_id":1}}]} + data = JSON.parse(request.body) + + description = data["description"] + provider_states = data["providerStates"] + + body, metadata = handle(description, provider_states) + + response.status = 200 + if body.is_a?(String) + # protobuf-serialized body + response.body = body + response.content_type = metadata[:content_type] || CONTENT_TYPE_PROTO + else + response.body = body.to_json + response.content_type = CONTENT_TYPE_JSON + end + response[METADATA_HEADER] = Base64.urlsafe_encode64(metadata.to_json) + rescue JSON::ParserError => ex + logger.error("cannot parse request: #{ex.message}") + response.status = 500 + rescue => ex + logger.error("cannot handle message request: #{ex.message}") + response.status = 500 + end + end + + def handle(description, provider_states) + handler = find_handler_for(description) + return {}, {} unless handler + + body, metadata = handler[:proc].call(provider_states&.first || {}) + unless metadata[:content_type] + # try to find content-type in provider states + content_type = provider_states&.filter_map { |state| state.dig("params", "contentType") }&.first + metadata[:content_type] = content_type if content_type + end + [body, metadata] + end + + def find_handler_for(description) + @message_handlers[description] + end + end + end + end +end diff --git a/lib/pact/v2/provider/mixed_verifier.rb b/lib/pact/v2/provider/mixed_verifier.rb new file mode 100644 index 00000000..2b5c8581 --- /dev/null +++ b/lib/pact/v2/provider/mixed_verifier.rb @@ -0,0 +1,22 @@ +# # frozen_string_literal: true +module Pact + module V2 + module Provider + # MixedVerifier coordinates verification for all present configs (async, grpc, http) + class MixedVerifier + attr_reader :mixed_config, :verifiers + + def initialize(mixed_config) + unless mixed_config.is_a?(::Pact::V2::Provider::PactConfig::Mixed) + raise ArgumentError, "mixed_config must be a PactConfig::Mixed" + end + @mixed_config = mixed_config + @verifiers = [] + @verifiers << AsyncMessageVerifier.new(mixed_config.async_config) if mixed_config.async_config + @verifiers << GrpcVerifier.new(mixed_config.grpc_config) if mixed_config.grpc_config + @verifiers << HttpVerifier.new(mixed_config.http_config) if mixed_config.http_config + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_broker_proxy.rb b/lib/pact/v2/provider/pact_broker_proxy.rb new file mode 100644 index 00000000..942b5f72 --- /dev/null +++ b/lib/pact/v2/provider/pact_broker_proxy.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rack-proxy" + +module Pact + module V2 + module Provider + class PactBrokerProxy < Rack::Proxy + attr_reader :backend_uri, :path, :logger + + # e.g. /pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/metadata/c1tdW2VdPXByb2R1Y3Rpb24mc1tdW2N2XT03MzIy + PACT_FILE_REQUEST_PATH_REGEX = %r{/pacts/provider/.+?/consumer/.+?/pact-version/.+}.freeze + + def initialize(app = nil, opts = {}) + super + @backend_uri = URI(opts[:backend]) + @path = nil + @logger = opts[:logger] || Logger.new($stdout) + end + + def perform_request(env) + request = Rack::Request.new(env) + env["rack.timeout"] ||= ENV.fetch("PACT_BROKER_REQUEST_TIMEOUT", 5).to_i + @path = request.path + + super + end + + def rewrite_env(env) + env["HTTP_HOST"] = backend_uri.host + env + end + + def rewrite_response(triplet) + status, headers, body = triplet + + if status == "200" && PACT_FILE_REQUEST_PATH_REGEX.match?(path) + patched_body = patch_response(body.first) + + # we need to recalculate content length + headers[Rack::CONTENT_LENGTH] = patched_body.bytesize.to_s + + return [status, headers, [patched_body]] + end + + triplet + end + + private + + def patch_response(raw_body) + parsed_body = JSON.parse(raw_body) + + return body if parsed_body["consumer"].blank? || parsed_body["provider"].blank? + return body if parsed_body["interactions"].blank? + + + JSON.generate(parsed_body) + rescue JSON::ParserError => ex + logger.error("cannot parse broker response: #{ex.message}") + end + + end + end + end +end diff --git a/lib/pact/v2/provider/pact_broker_proxy_runner.rb b/lib/pact/v2/provider/pact_broker_proxy_runner.rb new file mode 100644 index 00000000..0242ce0a --- /dev/null +++ b/lib/pact/v2/provider/pact_broker_proxy_runner.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "webrick" + +module Pact + module V2 + module Provider + class PactBrokerProxyRunner + attr_reader :logger + + + def initialize(pact_broker_host:, port: 9002, host: "127.0.0.1", pact_broker_user: nil, pact_broker_password: nil, pact_broker_token: nil, logger: nil) + @host = host + @port = port + @pact_broker_host = pact_broker_host + @pact_broker_user = pact_broker_user + @pact_broker_password = pact_broker_password + @pact_broker_token = pact_broker_token + @logger = logger || Logger.new($stdout) + + @thread = nil + end + + def proxy_url + "http://#{@host}:#{@port}" + end + + def start + raise "server already running, stop server before starting new one" if @thread + # Rack 2/3 compatibility + begin + require 'rack/handler/webrick' + handler = ::Rack::Handler::WEBrick + rescue LoadError + require 'rackup/handler/webrick' + handler = Class.new(Rackup::Handler::WEBrick) + end + @server = WEBrick::HTTPServer.new({BindAddress: @host, Port: @port}, WEBrick::Config::HTTP) + @server.mount("/", handler, PactBrokerProxy.new( + nil, + backend: @pact_broker_host, + streaming: false, + username: @pact_broker_user || nil, + password: @pact_broker_password || nil, + token: @pact_broker_token || nil, + logger: @logger + )) + + @thread = Thread.new do + @logger.debug "starting pact broker proxy server" + @server.start + end + end + + def stop + @logger.info("stopping pact broker proxy server") + + @server&.shutdown + @thread&.join + + @logger.info("pact broker proxy server stopped") + end + + def run + start + + yield + rescue => e + logger.fatal("FATAL ERROR: #{e.message} #{e.backtrace.join("\n")}") + raise + ensure + stop + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_config.rb b/lib/pact/v2/provider/pact_config.rb new file mode 100644 index 00000000..f64e9310 --- /dev/null +++ b/lib/pact/v2/provider/pact_config.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# require_relative "pact_config/grpc" + +module Pact + module V2 + module Provider + module PactConfig + def self.new(transport_type, provider_name:, opts: {}) + case transport_type + when :http + Http.new(provider_name: provider_name, opts: opts) + when :grpc + Grpc.new(provider_name: provider_name, opts: opts) + when :async + Async.new(provider_name: provider_name, opts: opts) + when :mixed + Mixed.new(provider_name: provider_name, opts: opts) + else + raise ArgumentError, "unknown transport_type: #{transport_type}" + end + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_config/async.rb b/lib/pact/v2/provider/pact_config/async.rb new file mode 100644 index 00000000..50808678 --- /dev/null +++ b/lib/pact/v2/provider/pact_config/async.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Provider + module PactConfig + class Async < Base + def initialize(provider_name:, opts: {}) + super + handlers = opts[:message_handlers] || {} + handlers.each do |name, block| + new_message_handler(name, &block) + end + end + + def new_message_handler(name, opts: {}, &block) + provider_setup_server.add_message_handler(name, &block) + end + + def new_verifier(config = nil) + AsyncMessageVerifier.new(self, config) + end + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_config/base.rb b/lib/pact/v2/provider/pact_config/base.rb new file mode 100644 index 00000000..7d586781 --- /dev/null +++ b/lib/pact/v2/provider/pact_config/base.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Provider + module PactConfig + class Base + attr_reader :provider_name, :provider_version, :log_level, :provider_setup_server, :provider_setup_port, :pact_proxy_port, + :consumer_branch, :consumer_version, :consumer_name, :broker_url, :broker_username, :broker_password, :verify_only, :pact_dir, + :pact_uri, :provider_version_branch, :provider_version_tags, :consumer_version_selectors, :enable_pending, :include_wip_pacts_since, + :fail_if_no_pacts_found, :provider_build_uri, :broker_token, :consumer_version_tags, :publish_verification_results + + + def initialize(provider_name:, opts: {}) + @provider_name = provider_name + @log_level = opts[:log_level] || :info + @pact_dir = opts[:pact_dir] || nil + @provider_setup_port = opts[:provider_setup_port] || 9001 + @pact_proxy_port = opts[:provider_setup_port] || 9002 + @pact_uri = ENV.fetch("PACT_URL", nil) || opts.fetch(:pact_uri, nil) + @publish_verification_results = ENV.fetch("PACT_PUBLISH_VERIFICATION_RESULTS", nil) == "true" || opts.fetch(:publish_verification_results, false) + @provider_version = ENV.fetch("PACT_PROVIDER_VERSION", nil) || opts.fetch(:provider_version, nil) + @provider_build_uri = ENV.fetch("PACT_PROVIDER_BUILD_URL", nil) || opts.fetch(:provider_build_uri, nil) + @provider_version_branch = ENV.fetch("PACT_PROVIDER_BRANCH", nil) || opts.fetch(:provider_version_branch, nil) + @provider_version_tags = ENV.fetch("PACT_PROVIDER_VERSION_TAGS", nil) || opts.fetch(:provider_version_tags, []) + @consumer_version_tags = ENV.fetch("PACT_CONSUMER_VERSION_TAGS", nil) || opts.fetch(:consumer_version_tags, []) + @consumer_version_selectors = ENV.fetch("PACT_CONSUMER_VERSION_SELECTORS", nil) || opts.fetch(:consumer_version_selectors, nil) + @enable_pending = ENV.fetch("PACT_VERIFIER_ENABLE_PENDING", nil) == "true" || opts.fetch(:enable_pending, false) + @include_wip_pacts_since = ENV.fetch("PACT_INCLUDE_WIP_PACTS_SINCE", nil) || opts.fetch(:include_wip_pacts_since, nil) + @fail_if_no_pacts_found = ENV.fetch("PACT_FAIL_IF_NO_PACTS_FOUND", nil) == "true" || opts.fetch(:fail_if_no_pacts_found, true) + @consumer_branch = ENV.fetch("PACT_CONSUMER_BRANCH", nil) || opts.fetch(:consumer_branch, nil) + @consumer_version = ENV.fetch("PACT_CONSUMER_VERSION", nil) || opts.fetch(:consumer_version, nil) + @consumer_name = opts[:consumer_name] + @broker_url = ENV.fetch("PACT_BROKER_BASE_URL", nil) || opts.fetch(:broker_url, nil) + @broker_username = ENV.fetch("PACT_BROKER_USERNAME", nil) || opts.fetch(:broker_username, nil) + @broker_password = ENV.fetch("PACT_BROKER_PASSWORD", nil) || opts.fetch(:broker_password, nil) + @broker_token = ENV.fetch("PACT_BROKER_TOKEN", nil) || opts.fetch(:broker_token, nil) + @verify_only = [ENV.fetch("PACT_CONSUMER_FULL_NAME", nil)].compact || opts.fetch(:verify_only, []) + + @provider_setup_server = opts[:provider_setup_server] || ProviderServerRunner.new(port: @provider_setup_port) + if @broker_url.present? + @pact_proxy_server = PactBrokerProxyRunner.new( + port: @pact_proxy_port, + pact_broker_host: @broker_url, + pact_broker_user: @broker_username, + pact_broker_password: @broker_password, + pact_broker_token: @broker_token + ) + end + end + + + def start_servers + @provider_setup_server.start + @pact_proxy_server&.start + end + + def stop_servers + @provider_setup_server.stop + @pact_proxy_server&.stop + end + + def provider_setup_url + @provider_setup_server.state_setup_url + end + + def message_setup_url # rubocop:disable Rails/Delegate + @provider_setup_server.message_setup_url + end + + def pact_broker_proxy_url + @pact_proxy_server&.proxy_url + end + + def new_provider_state(name, opts: {}, &block) + config = ProviderStateConfiguration.new(name, opts: opts) + config.instance_eval(&block) + config.validate! + + use_hooks = !opts[:skip_hooks] + + @provider_setup_server.add_setup_state(name, use_hooks, &config.setup_proc) if config.setup_proc + @provider_setup_server.add_teardown_state(name, use_hooks, &config.teardown_proc) if config.teardown_proc + end + + def before_setup(&block) + @provider_setup_server.set_before_setup_hook(&block) + end + + def after_teardown(&block) + @provider_setup_server.set_after_teardown_hook(&block) + end + + def new_verifier + raise Pact::V2::ImplementationRequired, "#new_verifier should be implemented" + end + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_config/grpc.rb b/lib/pact/v2/provider/pact_config/grpc.rb new file mode 100644 index 00000000..ea3b98d3 --- /dev/null +++ b/lib/pact/v2/provider/pact_config/grpc.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Provider + module PactConfig + class Grpc < Base + attr_reader :grpc_port, :grpc_services, :grpc_server + + def initialize(provider_name:, opts: {}) + super + + @grpc_port = opts[:grpc_port] || 0 + @grpc_services = opts[:grpc_services] || [] + end + + def new_verifier(config = nil) + GrpcVerifier.new(self, config) + end + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_config/http.rb b/lib/pact/v2/provider/pact_config/http.rb new file mode 100644 index 00000000..212ed1d8 --- /dev/null +++ b/lib/pact/v2/provider/pact_config/http.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "base" + +module Pact + module V2 + module Provider + module PactConfig + class Http < Base + attr_reader :http_port + attr_reader :app + + def initialize(provider_name:, opts: {}) + super + + @http_port = opts[:http_port] || 0 + @app = opts[:app] || nil + end + + def new_verifier(config = nil) + HttpVerifier.new(self, config) + end + end + end + end + end +end diff --git a/lib/pact/v2/provider/pact_config/mixed.rb b/lib/pact/v2/provider/pact_config/mixed.rb new file mode 100644 index 00000000..65819435 --- /dev/null +++ b/lib/pact/v2/provider/pact_config/mixed.rb @@ -0,0 +1,39 @@ +# # frozen_string_literal: true + +module Pact + module V2 + module Provider + module PactConfig + # Mixed config allows composing one of each: async, grpc, http + class Mixed < Base + attr_reader :async_config, :grpc_config, :http_config + + def initialize(provider_name:, opts: {}) + super + @provider_setup_server = ProviderServerRunner.new(port: @provider_setup_port) + if @broker_url.present? + @pact_proxy_server = PactBrokerProxyRunner.new( + port: @pact_proxy_port, + pact_broker_host: @broker_url, + pact_broker_user: @broker_username, + pact_broker_password: @broker_password, + pact_broker_token: @broker_token + ) + end + @http_config = opts[:http] ? Http.new(provider_name: provider_name, opts: opts[:http].merge(provider_setup_server: provider_setup_server, pact_proxy_server: @pact_proxy_server)) : nil + @grpc_config = opts[:grpc] ? Grpc.new(provider_name: provider_name, opts: opts[:grpc].merge(provider_setup_server: provider_setup_server, pact_proxy_server: @pact_proxy_server)) : nil + @async_config = opts[:async] ? Async.new(provider_name: provider_name, opts: opts[:async].merge(provider_setup_server: provider_setup_server, pact_proxy_server: @pact_proxy_server)) : nil + end + + def configs + [@async_config, @grpc_config, @http_config].compact + end + + def start_servers + end + + end + end + end + end +end diff --git a/lib/pact/v2/provider/provider_server_runner.rb b/lib/pact/v2/provider/provider_server_runner.rb new file mode 100644 index 00000000..8b4cb905 --- /dev/null +++ b/lib/pact/v2/provider/provider_server_runner.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "webrick" + +module Pact + module V2 + module Provider + class ProviderServerRunner + attr_reader :logger + + SETUP_PROVIDER_STATE_PATH = "/setup-provider" + VERIFY_MESSAGE_PATH = "/verify-message" + + def initialize(port: 9001, host: "127.0.0.1", logger: nil) + @host = host + @port = port + @provider_setup_states = {} + @provider_teardown_states = {} + @logger = logger || Logger.new($stdout) + + @state_servlet = ProviderStateServlet.new(logger: @logger) + @message_servlet = MessageProviderServlet.new(logger: @logger) + @thread = nil + end + + def state_setup_url + "http://#{@host}:#{@port}#{SETUP_PROVIDER_STATE_PATH}" + end + + def message_setup_url + "http://#{@host}:#{@port}#{VERIFY_MESSAGE_PATH}" + end + + def start + raise "server already running, stop server before starting new one" if @thread + + @server = WEBrick::HTTPServer.new({BindAddress: @host, Port: @port}, WEBrick::Config::HTTP) + @server.mount(SETUP_PROVIDER_STATE_PATH, @state_servlet) + @server.mount(VERIFY_MESSAGE_PATH, @message_servlet) + + @thread = Thread.new do + @logger.debug "starting provider setup server" + @server.start + end + end + + def stop + @logger.info("stopping provider setup server") + + @server&.shutdown + @thread&.join + + @logger.info("provider setup server stopped") + end + + def run + start + + yield + rescue => e + logger.fatal("FATAL ERROR: #{e.message} #{e.backtrace.join("\n")}") + raise + ensure + stop + end + + def add_message_handler(state_name, &block) + @message_servlet.add_message_handler(state_name, &block) + end + + def add_setup_state(state_name, use_before_setup_hook = true, &block) + @state_servlet.add_setup_state(state_name, use_before_setup_hook, &block) + end + + def add_teardown_state(state_name, use_after_teardown_hook = true, &block) + @state_servlet.add_teardown_state(state_name, use_after_teardown_hook, &block) + end + + def set_before_setup_hook(&block) + @state_servlet.before_setup(&block) + end + + def set_after_teardown_hook(&block) + @state_servlet.after_teardown(&block) + end + end + end + end +end diff --git a/lib/pact/v2/provider/provider_state_configuration.rb b/lib/pact/v2/provider/provider_state_configuration.rb new file mode 100644 index 00000000..e4b53cb5 --- /dev/null +++ b/lib/pact/v2/provider/provider_state_configuration.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Pact + module V2 + module Provider + class ProviderStateConfiguration + attr_reader :name, :opts, :setup_proc, :teardown_proc + + class ProviderStateConfigurationError < ::Pact::V2::Error; end + + def initialize(name, opts: {}) + @name = name + @opts = opts + @setup_proc = nil + @teardown_proc = nil + end + + def set_up(&block) + @setup_proc = block + end + + def tear_down(&block) + @teardown_proc = block + end + + def validate! + raise ProviderStateConfigurationError.new("no hooks configured for state #{@name}: \"provider_state\" declaration only needed if setup/teardown hooks are used for that state. Please add hooks or remove \"provider_state\" declaration") unless @setup_proc || @teardown_proc + end + end + end + end +end diff --git a/lib/pact/v2/provider/provider_state_servlet.rb b/lib/pact/v2/provider/provider_state_servlet.rb new file mode 100644 index 00000000..cb832769 --- /dev/null +++ b/lib/pact/v2/provider/provider_state_servlet.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "webrick" + +module Pact + module V2 + module Provider + class ProviderStateServlet < WEBrick::HTTPServlet::ProcHandler + attr_reader :logger + + def initialize(logger: Logger.new($stdout)) + super(build_proc) + + @logger = logger + + @provider_setup_states = {} + @provider_teardown_states = {} + + @before_setup_hook_proc = nil + @after_teardown_hook_proc = nil + + @global_setup_hook = ::Pact::V2.configuration.before_provider_state_proc + @global_teardown_hook = ::Pact::V2.configuration.after_provider_state_proc + end + + def add_setup_state(name, use_before_setup_hook, &block) + raise "provider state #{name} already configured" if @provider_setup_states[name].present? + + @provider_setup_states[name] = {proc: block, use_hooks: use_before_setup_hook} + end + + def add_teardown_state(name, use_after_teardown_hook, &block) + raise "provider state #{name} already configured" if @provider_teardown_states[name].present? + + @provider_teardown_states[name] = {proc: block, use_hooks: use_after_teardown_hook} + end + + def before_setup(&block) + @before_setup_hook_proc = block + end + + def after_teardown(&block) + @after_teardown_hook_proc = block + end + + private + + def call_setup(state_name, state_data) + logger.debug "call_setup #{state_name} with #{state_data}" + @global_setup_hook&.call + @before_setup_hook_proc&.call(state_name, state_data) if @provider_setup_states.dig(state_name, :use_hooks) + @provider_setup_states.dig(state_name, :proc)&.call(state_data) + end + + def call_teardown(state_name, state_data) + logger.debug "call_teardown #{state_name} with #{state_data}" + @provider_teardown_states.dig(state_name, :proc)&.call(state_data) + @after_teardown_hook_proc&.call(state_name, state_data) if @provider_setup_states.dig(state_name, :use_hooks) + @global_teardown_hook&.call + end + + def build_proc + proc do |request, response| + # {"action" => "setup", "params" => {"order_uuid" => "mxfcpcsfUOHO"},"state" => "order exists and can be saved"} + # {"action"=> "teardown", "params" => {"order_uuid" => "mxfcpcsfUOHO"}, "state" => "order exists and can be saved"} + data = JSON.parse(request.body) + + action = data["action"] + state_name = data["state"] + state_data = data["params"] + + logger.warn("unknown callback state action: #{action}") if action.blank? + + call_setup(state_name, state_data) if action == "setup" + call_teardown(state_name, state_data) if action == "teardown" + + response.status = 200 + rescue JSON::ParserError => ex + logger.error("cannot parse request: #{ex.message}") + response.status = 500 + end + end + end + end + end +end diff --git a/lib/pact/v2/railtie.rb b/lib/pact/v2/railtie.rb new file mode 100644 index 00000000..01980bfe --- /dev/null +++ b/lib/pact/v2/railtie.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails/railtie" + +module Pact + module V2 + class Railtie < Rails::Railtie + rake_tasks do + load "pact/v2/tasks/pact.rake" + end + end + end +end diff --git a/lib/pact/v2/rspec.rb b/lib/pact/v2/rspec.rb new file mode 100644 index 00000000..8a8bb647 --- /dev/null +++ b/lib/pact/v2/rspec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rspec" +require_relative "rspec/support/pact_consumer_helpers" +require_relative "rspec/support/pact_provider_helpers" + +RSpec.configure do |config| + config.define_derived_metadata(file_path: %r{spec/pact/}) { |metadata| metadata[:pact] = true } + + # it's not an error: consumer tests contain `providers` subdirectory (because we're testing against different providers) + config.define_derived_metadata(file_path: %r{spec/pact/providers/}) { |metadata| metadata[:pact_entity] = :consumer } + # for provider tests it's the same thing: we're running tests which test consumers + config.define_derived_metadata(file_path: %r{spec/pact/consumers/}) { |metadata| metadata[:pact_entity] = :provider } + + # exclude pact specs from generic rspec pipeline + config.filter_run_excluding :pact_v2 +end diff --git a/lib/pact/v2/rspec/support/pact_consumer_helpers.rb b/lib/pact/v2/rspec/support/pact_consumer_helpers.rb new file mode 100644 index 00000000..64f66be3 --- /dev/null +++ b/lib/pact/v2/rspec/support/pact_consumer_helpers.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "pact_message_helpers" +require "json" + +module PactV2ConsumerDsl + include Pact::V2::Matchers + include Pact::V2::Generators + + module ClassMethods + def has_http_pact_between(consumer, provider, opts: {}) + _has_pact_between(:http, consumer, provider, opts: opts) + end + + def has_grpc_pact_between(consumer, provider, opts: {}) + _has_pact_between(:grpc, consumer, provider, opts: opts) + end + + def has_message_pact_between(consumer, provider, opts: {}) + _has_pact_between(:message, consumer, provider, opts: opts) + end + + def has_plugin_http_pact_between(consumer, provider, opts: {}) + _has_pact_between(:plugin_http, consumer, provider, opts: opts) + end + + def has_plugin_sync_message_pact_between(consumer, provider, opts: {}) + _has_pact_between(:plugin_sync_message, consumer, provider, opts: opts) + end + + def has_plugin_async_message_pact_between(consumer, provider, opts: {}) + _has_pact_between(:plugin_async_message, consumer, provider, opts: opts) + end + + def _has_pact_between(transport_type, consumer, provider, opts: {}) + raise "has_#{transport_type}_pact_between is designed to be used with RSpec 3+" unless defined?(::RSpec) + raise "has_#{transport_type}_pact_between has to be declared at the top level of a suite" unless top_level? + raise "has_*_pact_between cannot be declared more than once per suite" if defined?(@_pact_config) + + # rubocop:disable RSpec/BeforeAfterAll + before(:context) do + @_pact_config = Pact::V2::Consumer::PactConfig.new(transport_type, consumer_name: consumer, provider_name: provider, opts: opts) + end + # rubocop:enable RSpec/BeforeAfterAll + end + end + + def new_interaction(description = nil) + pact_config.new_interaction(description) + end + + def reset_pact # rubocop:disable Rails/Delegate + pact_config.reset_pact + end + + def pact_config + instance_variable_get(:@_pact_config) + end + + def execute_http_pact + raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used) + mock_server = Pact::V2::Consumer::MockServer.create_for_http!( + pact: pact_config.pact_handle, host: pact_config.mock_host, port: pact_config.mock_port + ) + + yield(mock_server) + + ensure + if mock_server.matched? + mock_server.write_pacts!(pact_config.pact_dir) + else + msg = mismatches_error_msg(mock_server) + raise Pact::V2::Consumer::HttpInteractionBuilder::InteractionMismatchesError.new(msg) + end + @used = true + mock_server&.cleanup + reset_pact + end + + + def mismatches_error_msg(mock_server) + rspec_example_desc = RSpec.current_example&.description + mismatches = JSON.pretty_generate(JSON.parse(mock_server.mismatches)) + mismatches_with_colored_keys = mismatches.gsub(/"([^"]+)":/) { |match| "\e[34m#{match}\e[0m" } # Blue keys / white values + + "#{rspec_example_desc} has mismatches: #{mismatches_with_colored_keys}" + end +end + +RSpec.configure do |config| + config.include PactV2ConsumerDsl, pact_entity: :consumer + config.extend PactV2ConsumerDsl::ClassMethods, pact_entity: :consumer +end diff --git a/lib/pact/v2/rspec/support/pact_message_helpers.rb b/lib/pact/v2/rspec/support/pact_message_helpers.rb new file mode 100644 index 00000000..5c158ca5 --- /dev/null +++ b/lib/pact/v2/rspec/support/pact_message_helpers.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "waterdrop/pact_waterdrop_client" + +module PactMessageHelpers + module ProviderHelpers + def with_pact_producer + client = PactWaterdropClient.new + yield(client) + client.to_pact + end + + def produce_outbox_item(item) + raise "Please require sbmt/kafka_producer to use helper" unless defined?(::Sbmt::KafkaProducer) + + with_pact_producer do |client| + Sbmt::KafkaProducer::OutboxProducer.new( + client: client, topic: item.transports.first.topic + ).call(item, item.payload) + end + end + end + + module ConsumerHelpers + def outbox_headers + raise "Please require sbmt/outbox to use helper" unless defined?(::Sbmt::Outbox) + + { + Sbmt::Outbox::OutboxItem::OUTBOX_HEADER_NAME => match_regex(/(.+?_)*outbox_item/, "order_outbox_item"), + Sbmt::Outbox::OutboxItem::IDEMPOTENCY_HEADER_NAME => match_uuid, + Sbmt::Outbox::OutboxItem::SEQUENCE_HEADER_NAME => match_regex(/\d+/, "68"), + Sbmt::Outbox::OutboxItem::EVENT_TIME_HEADER_NAME => match_iso8601, + Sbmt::Outbox::OutboxItem::DISPATCH_TIME_HEADER_NAME => match_iso8601 + } + end + end +end + +RSpec.configure do |config| + config.extend PactMessageHelpers::ProviderHelpers, pact_entity: :provider + config.include PactMessageHelpers::ConsumerHelpers, pact_entity: :consumer +end diff --git a/lib/pact/v2/rspec/support/pact_provider_helpers.rb b/lib/pact/v2/rspec/support/pact_provider_helpers.rb new file mode 100644 index 00000000..b25de3b2 --- /dev/null +++ b/lib/pact/v2/rspec/support/pact_provider_helpers.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require_relative "pact_message_helpers" +require_relative "webmock/webmock_helpers" + +module PactV2ProducerDsl + module ClassMethods + PACT_PROVIDER_NOT_DECLARED_MESSAGE = "http_pact_provider or grpc_pact_provider should be declared first" + + def http_pact_provider(provider, opts: {}) + _pact_provider(:http, provider, opts: opts) + end + + def grpc_pact_provider(provider, opts: {}) + _pact_provider(:grpc, provider, opts: opts) + end + + def message_pact_provider(provider, opts: {}) + _pact_provider(:async, provider, opts: opts) + end + + def mixed_pact_provider(provider, opts: {}) + execute_mixed_pact_provider(:mixed, provider, opts: opts) + end + + def execute_mixed_pact_provider(transport_type, provider, opts: {}) + raise "#{transport_type}_pact_provider is designed to be used with RSpec" unless defined?(::RSpec) + raise "#{transport_type}_pact_provider has to be declared at the top level of a suite" unless top_level? + raise "mixed_pact_provider is designed to be run once per provider so cannot be declared more than once" if defined?(@_pact_config) + + pact_config_instance = Pact::V2::Provider::PactConfig.new(transport_type, provider_name: provider, opts: opts) + instance_variable_set(:@_pact_config, pact_config_instance) + + # rubocop:disable RSpec/BeforeAfterAll + before(:context) do + # rspec allows only context ivars in specs and ignores the rest + # so we use block-as-a-closure feature to save pact_config ivar reference and make it available for descendants + @_pact_config = pact_config_instance + end + # rubocop:enable RSpec/BeforeAfterAll + + it "verifies mixed interactions with provider #{provider}" do + pact_config.start_servers + # todo: call any available verifier, or exit if none specified + pact_config.http_config.new_verifier(@_pact_config).verify! + end + end + + def _pact_provider(transport_type, provider, opts: {}) + raise "#{transport_type}_pact_provider is designed to be used with RSpec" unless defined?(::RSpec) + raise "#{transport_type}_pact_provider has to be declared at the top level of a suite" unless top_level? + raise "*_pact_provider is designed to be run once per provider so cannot be declared more than once" if defined?(@_pact_config) + + pact_config_instance = Pact::V2::Provider::PactConfig.new(transport_type, provider_name: provider, opts: opts) + instance_variable_set(:@_pact_config, pact_config_instance) + + # rubocop:disable RSpec/BeforeAfterAll + before(:context) do + # rspec allows only context ivars in specs and ignores the rest + # so we use block-as-a-closure feature to save pact_config ivar reference and make it available for descendants + @_pact_config = pact_config_instance + end + # rubocop:enable RSpec/BeforeAfterAll + + it "verifies interactions with provider #{provider}" do + pact_config.new_verifier.verify! + end + end + + def before_state_setup(&block) + raise PACT_PROVIDER_NOT_DECLARED_MESSAGE unless pact_config + pact_config.before_setup(&block) + end + + def after_state_teardown(&block) + raise PACT_PROVIDER_NOT_DECLARED_MESSAGE unless pact_config + pact_config.after_teardown(&block) + end + + def provider_state(name, opts: {}, &block) + raise PACT_PROVIDER_NOT_DECLARED_MESSAGE unless pact_config + pact_config.new_provider_state(name, opts: opts, &block) + end + + def handle_message(name, opts: {}, &block) + async_klass = Pact::V2::Provider::PactConfig::Async + if defined?(@_pact_config) && + @_pact_config.respond_to?(:async_config) && + @_pact_config.async_config.is_a?(async_klass) + @_pact_config.async_config.new_message_handler(name, opts: opts, &block) + elsif pact_config && + pact_config.respond_to?(:async_config) && + pact_config.async_config.is_a?(async_klass) + pact_config.async_config.new_message_handler(name, opts: opts, &block) + elsif defined?(@_pact_config) && + @_pact_config.is_a?(async_klass) + @_pact_config.new_message_handler(name, opts: opts, &block) + elsif pact_config.is_a?(async_klass) + pact_config.new_message_handler(name, opts: opts, &block) + + else + raise "handle_message can only be used with message_pact_provider or mixed_pact_provider with an async block" + end + end + + def pact_config + instance_variable_get(:@_pact_config) + end + end + + def pact_config + instance_variable_get(:@_pact_config) + end +end + +RSpec.configure do |config| + config.include PactV2ProducerDsl, pact_entity: :provider + config.extend PactV2ProducerDsl::ClassMethods, pact_entity: :provider + + config.around pact_entity: :provider do |example| + WebmockHelpers.turned_off do + if defined?(::VCR) + VCR.turned_off { example.run } + else + example.run + end + end + end +end diff --git a/lib/pact/v2/rspec/support/waterdrop/pact_waterdrop_client.rb b/lib/pact/v2/rspec/support/waterdrop/pact_waterdrop_client.rb new file mode 100644 index 00000000..9f072b06 --- /dev/null +++ b/lib/pact/v2/rspec/support/waterdrop/pact_waterdrop_client.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class PactWaterdropClient + attr_reader :message + + Report = Struct.new(:partition, :offset, :topic_name, keyword_init: true) + + def produce_async(message) + @message = message + end + + def produce_sync(message) + @message = message + Report.new(partition: 0, offset: 0, topic_name: message[:topic]) + end + + def to_pact(content_type: nil) + payload = message[:payload] + metadata = { + key: message[:key], + topic: message[:topic], + content_type: content_type + }.merge(message[:headers] || {}) + + [payload, metadata] + end +end diff --git a/lib/pact/v2/rspec/support/webmock/webmock_helpers.rb b/lib/pact/v2/rspec/support/webmock/webmock_helpers.rb new file mode 100644 index 00000000..57526ddd --- /dev/null +++ b/lib/pact/v2/rspec/support/webmock/webmock_helpers.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module WebmockHelpers + def self.turned_off + yield unless defined?(::WebMock) + + allow_net_connect = WebMock::Config.instance.allow_net_connect + allow_localhost = WebMock::Config.instance.allow_localhost + allow_hosts = WebMock::Config.instance.allow + net_http_connect_on_start = WebMock::Config.instance.net_http_connect_on_start + + return yield if allow_net_connect + + WebMock.allow_net_connect! + + result = yield + + # disable_net_connect! resets previous config settings + # so we need to specify them explicitly + WebMock.disable_net_connect!( + { + allow_localhost: allow_localhost, + allow: allow_hosts, + net_http_connect_on_start: net_http_connect_on_start + } + ) + + result + end +end diff --git a/lib/pact/v2/tasks/pact.rake b/lib/pact/v2/tasks/pact.rake new file mode 100644 index 00000000..e0c555cb --- /dev/null +++ b/lib/pact/v2/tasks/pact.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:pact_v2).tap do |task| + task.pattern = "spec/pact/consumers/**/*_spec.rb" + task.rspec_opts = "--require rails_helper_v2 --tag pact_v2" +end + +namespace :pact_v2 do + desc "Verifies the pact files" + task verify: :pact_v2 +end diff --git a/lib/pact/v2/version.rb b/lib/pact/v2/version.rb new file mode 100644 index 00000000..18ca8438 --- /dev/null +++ b/lib/pact/v2/version.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Pact + module V2 + VERSION = '0.12.0' + Version = '0.12.0' + end +end diff --git a/pact.gemspec b/pact.gemspec index 6f6c7f45..9b3b65f2 100644 --- a/pact.gemspec +++ b/pact.gemspec @@ -26,26 +26,71 @@ Gem::Specification.new do |gem| 'documentation_uri' => 'https://github.com/pact-foundation/pact-ruby/blob/master/README.md' } + # Shared dev dependencies between v1 and v2 + gem.add_development_dependency 'rake', '~> 13.0' + gem.add_development_dependency 'faraday', '~>2.0', '<3.0' + gem.add_development_dependency 'webmock', '~> 3.0' + + # Shared runtime dependencies between v1 and v2 gem.add_runtime_dependency 'rspec', '~> 3.0' + + # Pact v2 dependencies + + # Core dependencies (code loading) + gem.add_dependency "zeitwerk", "~> 2.3" + # For Pact support via Pact Rust Core + gem.add_dependency "pact-ffi", "~> 0.4.28" + # For Provider Side Verification + gem.add_dependency "rack" + gem.add_dependency "rack-proxy" + gem.add_dependency "webrick", '~> 1.8' + # For Rails support, including testing non rails apps + gem.add_development_dependency "combustion", ">= 1.3" + # For Kafka support + unless RUBY_PLATFORM =~ /win32|x64-mingw32|x64-mingw-ucrt/ + # windows does not support librdkafka + gem.add_development_dependency "sbmt-kafka_consumer", ">= 2.0.1" + gem.add_development_dependency "sbmt-kafka_producer", ">= 1.0" + end + if ENV['X_PACT_DEVELOPMENT_RDKAFKA'] == 'true' + # darwin-arm64 prebuilt gems available from 0.20.0 + gem.add_development_dependency "karafka-rdkafka", ">= 0.20.0" + end + # For gRPC support + gem.add_development_dependency "gruf", ">= 2.18" + gem.add_development_dependency "gruf-rspec", ">= 0.6.0" + # Testing tools + gem.add_development_dependency "rspec" + gem.add_development_dependency "rspec-rails" + gem.add_development_dependency "rspec_junit_formatter" + gem.add_development_dependency "vcr", ">= 6.0" + # Development and linting tools + gem.add_development_dependency "appraisal", ">= 2.4" + gem.add_development_dependency "bundler", ">= 2.2" + gem.add_development_dependency "rubocop" + gem.add_development_dependency "rubocop-rspec" + gem.add_development_dependency "rubocop-rails" + gem.add_development_dependency "rubocop-performance" + gem.add_development_dependency "standard", ">= 1.35.1" + + + # Pact v1 dependencies gem.add_runtime_dependency 'rack-test', '>= 0.6.3', '< 3.0.0' gem.add_runtime_dependency 'thor', '>= 0.20', '< 2.0' gem.add_runtime_dependency "rainbow", '~> 3.1' gem.add_runtime_dependency 'string_pattern', '~> 2.0' gem.add_runtime_dependency 'jsonpath', '~> 1.0' - gem.add_runtime_dependency 'pact-support', '~> 1.21', '>= 1.21.0' + gem.add_runtime_dependency "pact-support" , "~> 1.21", ">=1.21.2" gem.add_runtime_dependency 'pact-mock_service', '~> 3.0', '>= 3.3.1' - - gem.add_development_dependency 'rake', '~> 13.0' - gem.add_development_dependency 'webmock', '~> 3.0' gem.add_development_dependency 'fakefs', '2.4' gem.add_development_dependency 'hashie', '~> 5.0' - gem.add_development_dependency 'faraday', '~>2.0', '<3.0' gem.add_development_dependency 'faraday-multipart', '~> 1.0' gem.add_development_dependency 'conventional-changelog', '~> 1.3' gem.add_development_dependency 'bump', '~> 0.5' gem.add_development_dependency 'pact-message', '~> 0.8' gem.add_development_dependency 'rspec-its', '~> 1.3' - gem.add_development_dependency 'webrick', '~> 1.8' + # gem.add_development_dependency 'webrick', '~> 1.8' # webrick is a runtime dependency of pact v2, so included above gem.add_development_dependency 'ostruct' -end + +end \ No newline at end of file diff --git a/spec/fixtures/vcr/pact-broker/for_verification.yml b/spec/fixtures/vcr/pact-broker/for_verification.yml new file mode 100644 index 00000000..07d743aa --- /dev/null +++ b/spec/fixtures/vcr/pact-broker/for_verification.yml @@ -0,0 +1,73 @@ +--- +http_interactions: +- request: + method: get + uri: https://example.org/pacts/provider/paas-stand-seeker/for-verification + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - test-app / 0.0.1 Faraday v2.10.1 + X-Request-Id: + - 4de62273-41aa-46e3-b428-63ff7506ea78 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + Connection: + - keep-alive + Keep-Alive: + - '30' + response: + status: + code: 200 + message: OK + headers: + Vary: + - Accept + Cache-Control: + - no-cache + Content-Type: + - application/hal+json;charset=utf-8 + Date: + - Thu, 08 Aug 2024 14:43:22 GMT + Server: + - istio-envoy + X-Pact-Broker-Version: + - 2.110.0 + X-Content-Type-Options: + - nosniff + Content-Length: + - '2817' + X-Envoy-Upstream-Service-Time: + - '37' + body: + encoding: UTF-8 + string: '{"_embedded":{"pacts":[{"shortDescription":"deployed to production, + latest from main branch","verificationProperties":{"pending":false,"notices":[{"when":"before_verification","text":"WARNING + - this version of the Pact library uses a beta version of the API which will + be removed in the future. Please upgrade your Pact library. See https://docs.pact.io/pact_broker/advanced_topics/provider_verification_results/#pacts-for-verification + for minimum required versions."},{"when":"before_verification","text":"The + pact at https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892 + is being verified because the pact content belongs to the consumer versions + matching the following criteria:\n * consumer version(s) currently deployed + to production (98c66ec6)\n * latest version of paas-stand-placer from the + main branch ''master'' (98c66ec6)"},{"when":"before_verification","text":"This + pact has previously been successfully verified by paas-stand-seeker. If this + verification fails, it will fail the build. Read more at https://docs.pact.io/go/pending"}]},"_links":{"self":{"href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/metadata/c1tdW2N2XT03MzIyJnNbXVtjdl09NzMyMiZwPWZhbHNl","name":"Pact + between paas-stand-placer (98c66ec6) and paas-stand-seeker"}}},{"shortDescription":"deployed + to test","verificationProperties":{"pending":false,"notices":[{"when":"before_verification","text":"WARNING + - this version of the Pact library uses a beta version of the API which will + be removed in the future. Please upgrade your Pact library. See https://docs.pact.io/pact_broker/advanced_topics/provider_verification_results/#pacts-for-verification + for minimum required versions."},{"when":"before_verification","text":"The + pact at https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/c6d07ac41916c31609f0f2879c80ff3cce0df7b9 + is being verified because the pact content belongs to the consumer version + matching the following criterion:\n * consumer version(s) currently deployed + to test (6b35eb7a)"},{"when":"before_verification","text":"This pact has previously + been successfully verified by paas-stand-seeker. If this verification fails, + it will fail the build. Read more at https://docs.pact.io/go/pending"}]},"_links":{"self":{"href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/c6d07ac41916c31609f0f2879c80ff3cce0df7b9/metadata/c1tdW2N2XT03Njc0JnA9ZmFsc2U","name":"Pact + between paas-stand-placer (6b35eb7a) and paas-stand-seeker"}}}]},"_links":{"self":{"href":"https://example.org/pacts/provider/paas-stand-seeker/for-verification","title":"Pacts + to be verified"}}}' + recorded_at: Thu, 08 Aug 2024 14:43:22 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/vcr/pact-broker/not_found.yml b/spec/fixtures/vcr/pact-broker/not_found.yml new file mode 100644 index 00000000..be1862ee --- /dev/null +++ b/spec/fixtures/vcr/pact-broker/not_found.yml @@ -0,0 +1,55 @@ +--- +http_interactions: +- request: + method: get + uri: https://example.org/pacts/provider/non-existent-provider/for-verification + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - test-app / 0.0.1 Faraday v2.10.1 + X-Request-Id: + - c293d78b-e515-4986-aec4-f948ba935286 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + Connection: + - keep-alive + Keep-Alive: + - '30' + Host: + - example.org + Version: + - HTTP/1.1 + X-Forwarded-For: + - 127.0.0.1 + response: + status: + code: 404 + message: Not Found + headers: + Vary: + - Accept + Cache-Control: + - no-cache + Content-Type: + - application/hal+json;charset=utf-8 + Date: + - Thu, 08 Aug 2024 15:05:51 GMT + Server: + - istio-envoy + X-Pact-Broker-Version: + - 2.110.0 + X-Content-Type-Options: + - nosniff + Content-Length: + - '63' + X-Envoy-Upstream-Service-Time: + - '25' + body: + encoding: UTF-8 + string: '{"error":"No provider with name ''non-existent-provider'' found"}' + recorded_at: Thu, 08 Aug 2024 15:05:51 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/vcr/pact-broker/pact_data.yml b/spec/fixtures/vcr/pact-broker/pact_data.yml new file mode 100644 index 00000000..daf5896b --- /dev/null +++ b/spec/fixtures/vcr/pact-broker/pact_data.yml @@ -0,0 +1,79 @@ +--- +http_interactions: +- request: + method: get + uri: https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/metadata/c1tdW2VdPXByb2R1Y3Rpb24mc1tdW2N2XT03MzIy + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - test-app / 0.0.1 Faraday v2.10.1 + X-Request-Id: + - de7fc6fa-d335-4103-a696-e729f06d295c + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + Connection: + - keep-alive + Keep-Alive: + - '30' + response: + status: + code: 200 + message: OK + headers: + Vary: + - Accept + Cache-Control: + - no-cache + Content-Type: + - application/hal+json;charset=utf-8 + Date: + - Thu, 08 Aug 2024 14:43:46 GMT + Server: + - istio-envoy + X-Pact-Broker-Version: + - 2.110.0 + X-Content-Type-Options: + - nosniff + Content-Length: + - '9450' + X-Envoy-Upstream-Service-Time: + - '135' + body: + encoding: UTF-8 + string: '{"consumer":{"name":"paas-stand-placer"},"interactions":[{"_id":"29380d95a12351ab677ce97a02160e48c6377c4e","description":"","interactionMarkup":{"markup":"```protobuf\nmessage + OrderResponse {\n message .orders.Order order = 1;\n}\n```\n","markupType":"COMMON_MARK"},"pending":false,"pluginConfiguration":{"protobuf":{"descriptorKey":"0bd6fa36f4bd9fb12f69d903196144ed","service":"ProcessOrders/StatusToProcessed"}},"providerStates":[{"name":"order + exists and can be saved","params":{"order_uuid":"mxfcpcsfUOHO"}}],"request":{"contents":{"content":"CgxteGZjcGNzZlVPSE8=","contentType":"application/protobuf;message=OrderRequest","contentTypeHint":"BINARY","encoded":"base64"},"matchingRules":{"body":{"$.uuid":{"combine":"AND","matchers":[{"match":"regex","regex":"(?-mix:.*)"}]}}},"metadata":{"contentType":"application/protobuf;message=OrderRequest"}},"response":[{"contents":{"content":"ChQIChIDYW55GAMiCQoDUlVCEAoYCg==","contentType":"application/protobuf;message=OrderResponse","contentTypeHint":"BINARY","encoded":"base64"},"matchingRules":{"body":{"$.order.id":{"combine":"AND","matchers":[{"match":"integer"}]},"$.order.name":{"combine":"AND","matchers":[{"match":"regex","regex":"(?-mix:.*)"}]},"$.order.price.currency_code":{"combine":"AND","matchers":[{"match":"regex","regex":"(?-mix:[A-Z]+)"}]},"$.order.price.nanos":{"combine":"AND","matchers":[{"match":"integer"}]},"$.order.price.units":{"combine":"AND","matchers":[{"match":"integer"}]},"$.order.status":{"combine":"AND","matchers":[{"match":"equality"}]}}},"metadata":{"contentType":"application/protobuf;message=OrderResponse"}}],"transport":"grpc","type":"Synchronous/Messages"},{"_id":"fb76a7fdcc476dd441cf5bd272a2dc993fb38c7d","description":"","interactionMarkup":{"markup":"```protobuf\nmessage + OrderResponse {\n}\n```\n","markupType":"COMMON_MARK"},"pending":false,"pluginConfiguration":{"protobuf":{"descriptorKey":"0bd6fa36f4bd9fb12f69d903196144ed","service":"ProcessOrders/StatusToProcessed"}},"providerStates":[{"name":"order + not found"}],"request":{"contents":{"content":"CgxteGZjcGNzZlVPSE8=","contentType":"application/protobuf;message=OrderRequest","contentTypeHint":"BINARY","encoded":"base64"},"matchingRules":{"body":{"$.uuid":{"combine":"AND","matchers":[{"match":"regex","regex":"(?-mix:.*)"}]}}},"metadata":{"contentType":"application/protobuf;message=OrderRequest"}},"response":[{"contents":{"content":"","contentType":"application/protobuf;message=OrderResponse","contentTypeHint":"BINARY","encoded":"base64"},"metadata":{"contentType":"application/protobuf;message=OrderResponse","grpc-message":"Failed + to find Order with UUID: mxfcpcsfUOHO","grpc-status":"NOT_FOUND"}}],"transport":"grpc","type":"Synchronous/Messages"}],"metadata":{"pactRust":{"ffi":"0.4.7","mockserver":"1.2.3","models":"1.1.9"},"pactSpecification":{"version":"4.0"},"plugins":[{"configuration":{"0bd6fa36f4bd9fb12f69d903196144ed":{"protoDescriptors":"CuoBChdnb29nbGUvdHlwZS9tb25leS5wcm90bxILZ29vZ2xlLnR5cGUiWAoFTW9uZXkSIwoNY3VycmVuY3lfY29kZRgBIAEoCVIMY3VycmVuY3lDb2RlEhQKBXVuaXRzGAIgASgDUgV1bml0cxIUCgVuYW5vcxgDIAEoBVIFbmFub3NCYAoPY29tLmdvb2dsZS50eXBlQgpNb25leVByb3RvUAFaNmdvb2dsZS5nb2xhbmcub3JnL2dlbnByb3RvL2dvb2dsZWFwaXMvdHlwZS9tb25leTttb25lefgBAaICA0dUUGIGcHJvdG8zCsYDCgxvcmRlcnMucHJvdG8SBm9yZGVycxoXZ29vZ2xlL3R5cGUvbW9uZXkucHJvdG8ixgEKBU9yZGVyEg4KAmlkGAEgASgFUgJpZBISCgRuYW1lGAIgASgJUgRuYW1lEiwKBnN0YXR1cxgDIAEoDjIULm9yZGVycy5PcmRlci5TdGF0dXNSBnN0YXR1cxIoCgVwcmljZRgEIAEoCzISLmdvb2dsZS50eXBlLk1vbmV5UgVwcmljZSJBCgZTdGF0dXMSCwoHUEVORElORxAAEg0KCUNPTVBMRVRFRBABEgwKCENBTkNFTEVEEAISDQoJUFJPQ0VTU0VEEAMiIgoMT3JkZXJSZXF1ZXN0EhIKBHV1aWQYASABKAlSBHV1aWQiNAoNT3JkZXJSZXNwb25zZRIjCgVvcmRlchgBIAEoCzINLm9yZGVycy5PcmRlclIFb3JkZXIyUQoNUHJvY2Vzc09yZGVycxJAChFTdGF0dXNUb1Byb2Nlc3NlZBIULm9yZGVycy5PcmRlclJlcXVlc3QaFS5vcmRlcnMuT3JkZXJSZXNwb25zZUIX6gIUU2Vla2VyOjpHcnBjOjpPcmRlcnNiBnByb3RvMw==","protoFile":"syntax + = \"proto3\";\n\nimport \"google/type/money.proto\";\n\npackage orders;\noption + ruby_package = \"Seeker::Grpc::Orders\";\n\nservice ProcessOrders {\n rpc + StatusToProcessed(OrderRequest) returns (OrderResponse);\n}\n\nmessage Order + {\n int32 id = 1;\n string name = 2;\n enum Status {\n PENDING = 0;\n COMPLETED + = 1;\n CANCELED = 2;\n PROCESSED = 3;\n }\n Status status = 3;\n google.type.Money + price = 4;\n}\n\nmessage OrderRequest {\n string uuid = 1;\n}\n\nmessage OrderResponse + {\n Order order = 1;\n}\n"}},"name":"protobuf","version":"0.5.5"}],"pact-ruby-v2":{"pact-ffi":"0.4.7"}},"provider":{"name":"paas-stand-seeker"},"createdAt":"2024-08-01T13:24:10+00:00","_links":{"self":{"title":"Pact","name":"Pact + between paas-stand-placer (98c66ec6) and paas-stand-seeker","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/version/98c66ec6"},"pb:consumer":{"title":"Consumer","name":"paas-stand-placer","href":"https://example.org/pacticipants/paas-stand-placer"},"pb:consumer-version":{"title":"Consumer + version","name":"98c66ec6","href":"https://example.org/pacticipants/paas-stand-placer/versions/98c66ec6"},"pb:consumer-versions":[{"title":"Consumer + version","name":"98c66ec6","href":"https://example.org/pacticipants/paas-stand-placer/versions/98c66ec6"}],"pb:provider":{"title":"Provider","name":"paas-stand-seeker","href":"https://example.org/pacticipants/paas-stand-seeker"},"pb:pact-version":{"title":"Pact + content version permalink","name":"2967a9343bd8fdd28a286c4b8322380020618892","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892"},"pb:latest-pact-version":{"title":"Latest + version of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/latest"},"pb:all-pact-versions":{"title":"All + versions of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/versions"},"pb:latest-untagged-pact-version":{"title":"Latest + untagged version of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/latest-untagged"},"pb:latest-tagged-pact-version":{"title":"Latest + tagged version of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/latest/{tag}","templated":true},"pb:previous-distinct":{"title":"Previous + distinct version of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/version/98c66ec6/previous-distinct"},"pb:diff-previous-distinct":{"title":"Diff + with previous distinct version of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/version/98c66ec6/diff/previous-distinct"},"pb:diff":{"title":"Diff + with another specified version of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/diff/pact-version/{pactVersion}","templated":true},"pb:pact-webhooks":{"title":"Webhooks + for the pact between paas-stand-placer and paas-stand-seeker","href":"https://example.org/webhooks/provider/paas-stand-seeker/consumer/paas-stand-placer"},"pb:consumer-webhooks":{"title":"Webhooks + for all pacts with provider paas-stand-seeker","href":"https://example.org/webhooks/consumer/paas-stand-seeker"},"pb:tag-prod-version":{"title":"PUT + to this resource to tag this consumer version as ''production''","href":"https://example.org/pacticipants/paas-stand-placer/versions/98c66ec6/tags/prod"},"pb:tag-version":{"title":"PUT + to this resource to tag this consumer version","href":"https://example.org/pacticipants/paas-stand-placer/versions/98c66ec6/tags/{tag}"},"pb:publish-verification-results":{"title":"Publish + verification results","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/metadata/c1tdW2VdPXByb2R1Y3Rpb24mc1tdW2N2XT03MzIy/verification-results"},"pb:latest-verification-results":{"href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/verification-results/latest"},"pb:triggered-webhooks":{"title":"Webhooks + triggered by the publication of this pact","href":"https://example.org/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/version/98c66ec6/triggered-webhooks"},"pb:matrix-for-consumer-version":{"title":"View + matrix rows for the consumer version to which this pact belongs","href":"https://example.org/matrix?q[][pacticipant]=paas-stand-placer&q[][version]=98c66ec6&latestby=cvpv"},"curies":[{"name":"pb","href":"https://example.org/doc/{rel}?context=pact","templated":true}]}}' + recorded_at: Thu, 08 Aug 2024 14:43:46 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/internal/app/consumers/pet_json_consumer.rb b/spec/internal/app/consumers/pet_json_consumer.rb new file mode 100644 index 00000000..0bc7777c --- /dev/null +++ b/spec/internal/app/consumers/pet_json_consumer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class PetJsonConsumer < Sbmt::KafkaConsumer::BaseConsumer + def process_message(message) + pet_id = message.payload["id"] + Rails.logger.info "Pet ID: #{pet_id}" + end +end diff --git a/spec/internal/app/consumers/pet_proto_consumer.rb b/spec/internal/app/consumers/pet_proto_consumer.rb new file mode 100644 index 00000000..d4ee89c6 --- /dev/null +++ b/spec/internal/app/consumers/pet_proto_consumer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PetProtoConsumer < Sbmt::KafkaConsumer::BaseConsumer + def process_message(message) + Rails.logger.info "Pet ID: #{message.payload.id}" + end +end diff --git a/spec/internal/app/consumers/test_message_consumer.rb b/spec/internal/app/consumers/test_message_consumer.rb new file mode 100644 index 00000000..0d5eaa30 --- /dev/null +++ b/spec/internal/app/consumers/test_message_consumer.rb @@ -0,0 +1,9 @@ +class TestMessageConsumer + + def consume_message(message) + puts "Message consumed" + puts message.to_json + message + end + + end \ No newline at end of file diff --git a/spec/internal/app/controllers/application_controller.rb b/spec/internal/app/controllers/application_controller.rb new file mode 100644 index 00000000..13c271fb --- /dev/null +++ b/spec/internal/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::API +end diff --git a/spec/internal/app/controllers/pets_controller.rb b/spec/internal/app/controllers/pets_controller.rb new file mode 100644 index 00000000..06956d45 --- /dev/null +++ b/spec/internal/app/controllers/pets_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class PetsController < ApplicationController + def show + render json: { + pet: { + id: params[:id].to_i, + bark: true, + breed: "Husky" + } + } + end + + def update + response.set_header("TRACE_ID", "xxx-xxx") + render json: { + pet: { + id: params[:id].to_i, + bark: true, + breed: params[:breed] + } + } + end +end diff --git a/spec/internal/app/producers/pet_json_producer.rb b/spec/internal/app/producers/pet_json_producer.rb new file mode 100644 index 00000000..be426642 --- /dev/null +++ b/spec/internal/app/producers/pet_json_producer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class PetJsonProducer < Sbmt::KafkaProducer::BaseProducer + option :topic, default: -> { "json-topic" } + option :uuid, default: -> { SecureRandom.uuid } + + def call(pet_id) + pet = { + id: pet_id, + tags: %w[tag1 tag2], + colors: + { + red: { + description: "red color", + link: "http://some-pet-resource.com/red", + relatesTo: ["green", "blue"], + title: "Red" + }, + green: { + description: "green color", + link: "http://some-pet-resource.com/red", + relatesTo: ["red", "blue"], + title: "Green" + }, + blue: { + description: "blue color", + link: "http://some-pet-resource.com/blue", + relatesTo: ["green", "red"], + title: "Blue" + } + } + + } + sync_publish(pet, headers: {"identity-key" => uuid}) + end +end diff --git a/spec/internal/app/producers/pet_proto_producer.rb b/spec/internal/app/producers/pet_proto_producer.rb new file mode 100644 index 00000000..141b5423 --- /dev/null +++ b/spec/internal/app/producers/pet_proto_producer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class PetProtoProducer < Sbmt::KafkaProducer::BaseProducer + option :topic, default: -> { "proto-topic" } + option :uuid, default: -> { SecureRandom.uuid } + option :serializer, default: -> { PetStore::Grpc::PetStore::V1::Pet } + + def call(pet_id) + message = serializer.new( + id: pet_id, + name: "some pet", + tags: %w[tag1 tag2], + colors: [ + PetStore::Grpc::PetStore::V1::PetColor.new( + description: "red color", + link: "http://some-pet-resource.com/red", + relates_to: ["green", "blue"], + color: "RED" + ), + PetStore::Grpc::PetStore::V1::PetColor.new( + description: "green color", + link: "http://some-pet-resource.com/green", + relates_to: ["red", "blue"], + color: "GREEN" + ) + ] + ) + sync_publish(serializer.encode(message), headers: {"identity-key" => uuid}) + end +end diff --git a/spec/internal/app/producers/test_message_producer.rb b/spec/internal/app/producers/test_message_producer.rb new file mode 100644 index 00000000..5fa6be61 --- /dev/null +++ b/spec/internal/app/producers/test_message_producer.rb @@ -0,0 +1,12 @@ +class TestMessageProducer + +def publish_message() + message = { + "email": "jane@example.com", + "title": "Miss", + "first_name": "Jane", + "surname": "Doe" + } +end + +end \ No newline at end of file diff --git a/spec/internal/app/rpc/pet_store/pet_store_controller.rb b/spec/internal/app/rpc/pet_store/pet_store_controller.rb new file mode 100644 index 00000000..5cb069ae --- /dev/null +++ b/spec/internal/app/rpc/pet_store/pet_store_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module PetStore + class PetStoreController < Gruf::Controllers::Base + bind PetStore::Grpc::PetStore::V1::Pets::Service + + def pet_by_id + req = request.message + PetStore::Grpc::PetStore::V1::PetResponse.new(pet: {id: req.id, name: "Jack"}, metadata: request.metadata) + end + end +end diff --git a/spec/internal/config/initializers/grpc_server.rb b/spec/internal/config/initializers/grpc_server.rb new file mode 100644 index 00000000..5c002171 --- /dev/null +++ b/spec/internal/config/initializers/grpc_server.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +::Gruf.interceptors.clear + +::Gruf.configure do |c| + c.server_binding_url = "0.0.0.0:3009" + c.logger = Rails.logger +end + +puts "Loading gRPC service files from #{Rails.root}" +required_files = [] +required_files += Rails.root.glob("pkg/server/**/*_services_pb.rb").sort +required_files += Rails.root.glob("app/rpc/**/*.rb").sort +puts "Requiring files:" +required_files.each do |file| + require file + puts "Required: #{file}" +end diff --git a/spec/internal/config/kafka_consumer.yml b/spec/internal/config/kafka_consumer.yml new file mode 100644 index 00000000..6f0c645b --- /dev/null +++ b/spec/internal/config/kafka_consumer.yml @@ -0,0 +1,42 @@ +default: &default + auth: + kind: 'sasl_plaintext' + sasl_mechanism: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').first %> + sasl_username: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').second %> + sasl_password: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').last %> + client_id: pact-ruby-v2-test-app + kafka: + servers: <%= ENV.fetch('KAFKA_BROKERS'){ 'kafka:9092' } %> + consumer_groups: + json: + name: json + topics: + - name: 'json-topic' + consumer: + klass: "PetJsonConsumer" + deserializer: + klass: "Sbmt::KafkaConsumer::Serialization::JsonDeserializer" + proto: + name: proto + topics: + - name: 'proto-topic' + consumer: + klass: "PetProtoConsumer" + deserializer: + klass: "Sbmt::KafkaConsumer::Serialization::ProtobufDeserializer" + init_attrs: + message_decoder_klass: "PetStore::Grpc::PetStore::V1::Pet" +development: + <<: *default + auth: + kind: 'plaintext' +test: + <<: *default + deliver: false + wait_on_queue_full: false + auth: + kind: 'plaintext' +staging: &staging + <<: *default +production: + <<: *staging diff --git a/spec/internal/config/kafka_producer.yml b/spec/internal/config/kafka_producer.yml new file mode 100644 index 00000000..75e6bf1e --- /dev/null +++ b/spec/internal/config/kafka_producer.yml @@ -0,0 +1,19 @@ +default: &default + deliver: true + auth: + kind: 'sasl_plaintext' + sasl_mechanism: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').first %> + sasl_username: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').second %> + sasl_password: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').last %> + kafka: + servers: <%= ENV.fetch('KAFKA_BROKERS'){ 'localhost:9092' } %> +development: + <<: *default + auth: + kind: 'plaintext' +test: + <<: *default + deliver: false + wait_on_queue_full: false + auth: + kind: 'plaintext' diff --git a/spec/internal/config/pet_store_grpc.yml b/spec/internal/config/pet_store_grpc.yml new file mode 100644 index 00000000..9feb72ad --- /dev/null +++ b/spec/internal/config/pet_store_grpc.yml @@ -0,0 +1,10 @@ +default: &default + host: localhost + port: 3009 + client_name: test-app + +development: + <<: *default + +test: + <<: *default diff --git a/spec/internal/config/routes.rb b/spec/internal/config/routes.rb new file mode 100644 index 00000000..729773d4 --- /dev/null +++ b/spec/internal/config/routes.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + resources :pets +end diff --git a/spec/internal/deps/services/pet_store/grpc/pet_store.proto b/spec/internal/deps/services/pet_store/grpc/pet_store.proto new file mode 100644 index 00000000..866c7682 --- /dev/null +++ b/spec/internal/deps/services/pet_store/grpc/pet_store.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; +package pet_store.v1; +option ruby_package = "PetStore::Grpc::PetStore::V1"; + +service Pets { + rpc PetById (PetByIdRequest) returns (PetResponse); +} + +message Pet { + int32 id = 1; + string name = 2; + repeated string tags = 3; + repeated PetColor colors = 4; +} + +message PetColor { + enum BaseColor { + RED = 0; + GREEN = 1; + BLUE = 2; + } + + string description = 1; + string link = 2; + repeated string relates_to = 3; + BaseColor color = 4; +} + +message PetByIdRequest { + int32 id = 1; +} + +message PetResponse { + Pet pet = 1; + map metadata = 2; +} diff --git a/spec/internal/deps/services/pet_store/openapi/v1.yaml b/spec/internal/deps/services/pet_store/openapi/v1.yaml new file mode 100644 index 00000000..e5f36857 --- /dev/null +++ b/spec/internal/deps/services/pet_store/openapi/v1.yaml @@ -0,0 +1,103 @@ +openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + termsOfService: 'http://swagger.io/terms/' + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' + version: 1.0.4 +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +servers: + - url: /api/v3 +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: 'http://swagger.io' + - name: store + description: Operations about user + - name: user + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: 'http://swagger.io' +paths: + /pets/{id}: + patch: + tags: + - Pets + parameters: + - name: id + in: path + required: true + description: ID of pet to return + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Updated + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + get: + tags: + - Pets + parameters: + - name: id + in: path + required: true + description: ID of pet to return + schema: + type: integer + responses: + '200': + description: A pet object + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + Dog: + type: object + properties: + id: + type: integer + bark: + type: boolean + breed: + type: string + enum: [ Dingo, Husky, Retriever, Shepherd ] + Cat: + type: object + properties: + id: + type: integer + hunts: + type: boolean + age: + type: integer diff --git a/spec/internal/pkg/client/pet_store/grpc/pet_store_pb.rb b/spec/internal/pkg/client/pet_store/grpc/pet_store_pb.rb new file mode 100644 index 00000000..1d193706 --- /dev/null +++ b/spec/internal/pkg/client/pet_store/grpc/pet_store_pb.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: spec/internal/deps/services/pet_store/grpc/pet_store.proto + +require 'google/protobuf' + +descriptor_data = "\n:spec/internal/deps/services/pet_store/grpc/pet_store.proto\x12\x0cpet_store.v1\"U\n\x03Pet\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04tags\x18\x03 \x03(\t\x12&\n\x06\x63olors\x18\x04 \x03(\x0b\x32\x16.pet_store.v1.PetColor\"\x9d\x01\n\x08PetColor\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x0c\n\x04link\x18\x02 \x01(\t\x12\x12\n\nrelates_to\x18\x03 \x03(\t\x12/\n\x05\x63olor\x18\x04 \x01(\x0e\x32 .pet_store.v1.PetColor.BaseColor\")\n\tBaseColor\x12\x07\n\x03RED\x10\x00\x12\t\n\x05GREEN\x10\x01\x12\x08\n\x04\x42LUE\x10\x02\"\x1c\n\x0ePetByIdRequest\x12\n\n\x02id\x18\x01 \x01(\x05\"\x99\x01\n\x0bPetResponse\x12\x1e\n\x03pet\x18\x01 \x01(\x0b\x32\x11.pet_store.v1.Pet\x12\x39\n\x08metadata\x18\x02 \x03(\x0b\x32\'.pet_store.v1.PetResponse.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x32J\n\x04Pets\x12\x42\n\x07PetById\x12\x1c.pet_store.v1.PetByIdRequest\x1a\x19.pet_store.v1.PetResponseB\x1f\xea\x02\x1cPetStore::Grpc::PetStore::V1b\x06proto3" + +pool = Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module PetStore + module Grpc + module PetStore + module V1 + Pet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.Pet").msgclass + PetColor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetColor").msgclass + PetColor::BaseColor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetColor.BaseColor").enummodule + PetByIdRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetByIdRequest").msgclass + PetResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetResponse").msgclass + end + end + end +end diff --git a/spec/internal/pkg/client/pet_store/grpc/pet_store_services_pb.rb b/spec/internal/pkg/client/pet_store/grpc/pet_store_services_pb.rb new file mode 100644 index 00000000..bcf7f0b0 --- /dev/null +++ b/spec/internal/pkg/client/pet_store/grpc/pet_store_services_pb.rb @@ -0,0 +1,28 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: pet_store.proto for package 'PetStore.Grpc.PetStore.V1' + +require "grpc" +require_relative "pet_store_pb" + +module PetStore + module Grpc + module PetStore + module V1 + module Pets + class Service + + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = "pet_store.v1.Pets" + + rpc :PetById, ::PetStore::Grpc::PetStore::V1::PetByIdRequest, ::PetStore::Grpc::PetStore::V1::PetResponse + end + + Stub = Service.rpc_stub_class + end + end + end + end +end diff --git a/spec/internal/pkg/server/pet_store_pb.rb b/spec/internal/pkg/server/pet_store_pb.rb new file mode 100644 index 00000000..7d902c77 --- /dev/null +++ b/spec/internal/pkg/server/pet_store_pb.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: pet_store.proto + +require "google/protobuf" + +descriptor_data = "\n:spec/internal/deps/services/pet_store/grpc/pet_store.proto\x12\x0cpet_store.v1\"U\n\x03Pet\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04tags\x18\x03 \x03(\t\x12&\n\x06\x63olors\x18\x04 \x03(\x0b\x32\x16.pet_store.v1.PetColor\"\x9d\x01\n\x08PetColor\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x0c\n\x04link\x18\x02 \x01(\t\x12\x12\n\nrelates_to\x18\x03 \x03(\t\x12/\n\x05\x63olor\x18\x04 \x01(\x0e\x32 .pet_store.v1.PetColor.BaseColor\")\n\tBaseColor\x12\x07\n\x03RED\x10\x00\x12\t\n\x05GREEN\x10\x01\x12\x08\n\x04\x42LUE\x10\x02\"\x1c\n\x0ePetByIdRequest\x12\n\n\x02id\x18\x01 \x01(\x05\"\x99\x01\n\x0bPetResponse\x12\x1e\n\x03pet\x18\x01 \x01(\x0b\x32\x11.pet_store.v1.Pet\x12\x39\n\x08metadata\x18\x02 \x03(\x0b\x32'.pet_store.v1.PetResponse.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x32J\n\x04Pets\x12\x42\n\x07PetById\x12\x1c.pet_store.v1.PetByIdRequest\x1a\x19.pet_store.v1.PetResponseB\x1f\xea\x02\x1cPetStore::Grpc::PetStore::V1b\x06proto3" + +pool = Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module PetStore + module Grpc + module PetStore + module V1 + Pet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.Pet").msgclass + PetColor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetColor").msgclass + PetColor::BaseColor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetColor.BaseColor").enummodule + PetByIdRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetByIdRequest").msgclass + PetResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pet_store.v1.PetResponse").msgclass + end + end + end +end diff --git a/spec/internal/pkg/server/pet_store_services_pb.rb b/spec/internal/pkg/server/pet_store_services_pb.rb new file mode 100644 index 00000000..9bdc4d1f --- /dev/null +++ b/spec/internal/pkg/server/pet_store_services_pb.rb @@ -0,0 +1,27 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# Source: pet_store.proto for package 'PetStore.Grpc.PetStore.V1' + +require "grpc" +require_relative "pet_store_pb" + +module PetStore + module Grpc + module PetStore + module V1 + module Pets + class Service + include ::GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = "pet_store.v1.Pets" + + rpc :PetById, ::PetStore::Grpc::PetStore::V1::PetByIdRequest, ::PetStore::Grpc::PetStore::V1::PetResponse + end + + Stub = Service.rpc_stub_class + end + end + end + end +end diff --git a/spec/pact/consumers/kafka_spec.rb b/spec/pact/consumers/kafka_spec.rb new file mode 100644 index 00000000..55c879b9 --- /dev/null +++ b/spec/pact/consumers/kafka_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe "Pact::V2::Consumers::Kafka", :pact_v2, skip_windows: true do + message_pact_provider "pact-v2-test-app-kafka", opts: { + pact_dir: File.expand_path('../../pacts', __dir__), + message_handlers: { + "pet message as json" => proc do |provider_state| + pet_id = provider_state.dig("params", "pet_id") + with_pact_producer { |client| PetJsonProducer.new(client: client).call(pet_id) } + end, + "pet message as proto" => proc do |provider_state| + pet_id = provider_state.dig("params", "pet_id") + with_pact_producer { |client| PetProtoProducer.new(client: client).call(pet_id) } + end + } + } + + # handle_message "pet message as json" do |provider_state| + # pet_id = provider_state.dig("params", "pet_id") + # with_pact_producer { |client| PetJsonProducer.new(client: client).call(pet_id) } + # end + + # handle_message "pet message as proto" do |provider_state| + # pet_id = provider_state.dig("params", "pet_id") + # with_pact_producer { |client| PetProtoProducer.new(client: client).call(pet_id) } + # end + +end diff --git a/spec/pact/consumers/message_spec.rb b/spec/pact/consumers/message_spec.rb new file mode 100644 index 00000000..9ac10c47 --- /dev/null +++ b/spec/pact/consumers/message_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'pact/v2' +require 'pact/v2/rspec' +require_relative '../../internal/app/producers/test_message_producer' + +RSpec.describe 'Test Message Provider', :pact_v2 do + message_pact_provider 'Test Message Producer', opts: { + pact_dir: File.expand_path('../../pacts', __dir__), + } + + handle_message 'a customer created message' do |provider_state| + body = TestMessageProducer.new.publish_message + metadata = {} + [body, metadata] + end +end diff --git a/spec/pact/consumers/multi_spec.rb b/spec/pact/consumers/multi_spec.rb new file mode 100644 index 00000000..17848b8a --- /dev/null +++ b/spec/pact/consumers/multi_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe "Pact::V2::Consumers::Http", :pact_v2 do + mixed_pact_provider "pact-v2-test-app", opts: { + http: { + http_port: 3000, + log_level: :info, + pact_dir: File.expand_path('../../pacts', __dir__), + }, + grpc: { + grpc_port: 3009 + } + } + +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/grpc_client_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/grpc_client_spec.rb new file mode 100644 index 00000000..d2529e3b --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/grpc_client_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + + +RSpec.describe "Pact::V2::Providers::Test::GrpcClient", :pact_v2 do + has_grpc_pact_between "pact-ruby-v2-test-app", "pact-ruby-v2-test-app" + + let(:pet_id) { 123 } + + let(:api) { ::PetStore::Grpc::PetStore::V1::Pets::Stub.new("localhost:3009", :this_channel_is_insecure) } + let(:make_request) { api.pet_by_id(PetStore::Grpc::PetStore::V1::PetByIdRequest.new(id: pet_id)) } + + let(:interaction) do + new_interaction + .with_service("spec/internal/deps/services/pet_store/grpc/pet_store.proto", "Pets/PetById") + end + + context "with Pets/PetById" do + context "with successful interaction" do + let(:interaction) do + super() + .given("pet exists", pet_id: pet_id) + .with_request(id: match_any_integer(pet_id)) + .will_respond_with( + pet: { + id: match_any_integer, name: match_any_string + } + ) + end + + it "executes the pact test without errors" do + interaction.execute do + expect { make_request }.not_to raise_error + end + end + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/http_client_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/http_client_spec.rb new file mode 100644 index 00000000..966e0c5c --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/http_client_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe "Pact::V2::Providers::Test::HttpClient", :pact_v2 do + has_http_pact_between "pact-ruby-v2-test-app", "pact-ruby-v2-test-app", opts: { + mock_port: 3000 + } + + let(:pet_id) { 123 } + let(:host) { "localhost:3000" } + let(:interaction) { new_interaction } + let(:http_client) do + Faraday.new do |conn| + conn.response :json + conn.request :json + end + end + + context "with GET /pets/:id" do + let(:make_request) do + http_client.get("http://#{host}/pets/#{pet_id}") + end + + context "with successful interaction" do + let(:interaction) do + super() + .given("pet exists", pet_id: pet_id) + .upon_receiving("getting a pet") + .with_request(method: :get, path: "/pets/#{pet_id}") + .will_respond_with(status: 200, body: { + pet: { + id: match_any_integer(pet_id), + bark: match_any_boolean(true), + breed: match_any_string("Husky") + } + }) + end + + it "executes the pact test without errors" do + interaction.execute do + expect(make_request).to be_success + end + end + end + end + + context "with PATCH /pets" do + let(:make_request) do + http_client.patch("http://#{host}/pets/#{pet_id}", pet_data.to_json, + {"Authorization" => "some-token"}) + end + let(:pet_data) { {breed: "Shepherd"} } + + context "with successful interaction" do + let(:interaction) do + super() + .given("pet exists", pet_id: pet_id) + .upon_receiving("updating a pet") + .with_request(method: :patch, path: "/pets/#{pet_id}", + headers: {Authorization: match_any_string("some-token")}, + body: pet_data) + .will_respond_with(status: 200, + headers: {TRACE_ID: match_any_string("xxx-xxx")}, + body: { + pet: { + id: match_any_integer(pet_id), + bark: match_any_boolean(true), + breed: match_any_string("Shepherd") + } + }) + end + + it "executes the pact test without errors" do + interaction.execute do + expect(make_request).to be_success + end + end + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/kafka_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/kafka_spec.rb new file mode 100644 index 00000000..e13af3fc --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/kafka_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe "Pact::V2::Providers::Test::Kafka", :pact_v2, skip_windows: true do + has_message_pact_between "pact-ruby-v2-test-app", "pact-ruby-v2-test-app-kafka" + + let(:karafka_message) { Struct.new(:payload, keyword_init: true) } + + let(:interaction) do + new_interaction + .given("pet exists", pet_id: 1) + .with_headers( + "identity-key" => match_any_string("some-key") + ) + .with_metadata( + topic: match_regex(/.+/, "some-topic"), + key: match_any_string("key") + ) + end + + context "with json message payload" do + let(:consumer) { PetJsonConsumer.consumer_klass } + let(:interaction) do + super() + .upon_receiving("pet message as json") + .with_json_contents( + id: match_any_integer(1), + tags: match_each_regex(/\w+/, %w[tagX tagY]), + colors: match_each_kv( + { + "red" => { + description: match_any_string("description"), + link: match_any_string("http://some-site.ru"), + relatesTo: match_each_regex(/(red|green|blue)/, %w[blue]), + title: match_any_string("title") + } + }, + match_regex(/(red|green|blue)/, "red") + ) + ) + end + + it "executes the pact test without errors" do + interaction.execute do |json_payload, meta| + message = karafka_message.new(payload: json_payload) + + expect(Rails.logger).to receive(:info) + expect(meta).to eq( + { + "contentType" => "application/json", + "headers" => { + "identity-key" => "some-key" + }, + "key" => "key", + "topic" => "some-topic" + } + ) + + consumer.new.process_message(message) + end + end + end + + context "with proto message payload" do + let(:consumer) { PetProtoConsumer.consumer_klass } + let(:interaction) do + super() + .upon_receiving("pet message as proto") + .with_proto_class("spec/internal/deps/services/pet_store/grpc/pet_store.proto", "Pet") + .with_proto_contents( + id: match_any_integer(1), + name: match_any_string("some pet"), + tags: match_each_regex(/\w+/, "tagX"), + colors: match_each( + { + description: match_any_string("description"), + link: match_any_string("http://some-site.ru"), + relates_to: match_each_regex(/(red|green|blue)/, "blue"), + color: match_regex(/(RED|GREEN|BLUE)/, "RED") + } + ) + ) + end + + it "executes the pact test without errors" do + interaction.execute do |proto_payload, meta| + deserialized = PetStore::Grpc::PetStore::V1::Pet.decode(proto_payload) + message = karafka_message.new(payload: deserialized) + + expect(Rails.logger).to receive(:info) + expect(meta).to eq( + { + "contentType" => "application/protobuf;message=.pet_store.v1.Pet", + "headers" => { + "identity-key" => "some-key" + }, + "key" => "key", + "topic" => "some-topic" + } + ) + + consumer.new.process_message(message) + end + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/message_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/message_spec.rb new file mode 100644 index 00000000..450b163b --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/message_spec.rb @@ -0,0 +1,65 @@ +require 'pact/v2' +require 'pact/v2/rspec' +require_relative '../../../internal/app/consumers/test_message_consumer' + +describe TestMessageConsumer, :pact_v2 do + has_message_pact_between 'Test Message Consumer', 'Test Message Provider' + + subject(:consumer) { TestMessageConsumer.new } + + describe 'Test Message Consumer' do + # Notice that the expected message payload has fields which are different to that of the actual producer. + # The TestMessageProducer actually sends a message with an additional 'title' field and a renamed 'surname' field. + # See app/producers/test_message_producer.rb. + + # before do + # # Here we are calling test_message_producer, which is mocking the actual TestMessageProducer defined in app/producers/test_message_producer.rb. + # # In pact-message we use mocked providers in consumer side tests. These are defined in a similar way to mocked APIs/service providers in standard HTTP CDCT. + # # See spec/support/pact_spec_helper.rb. + # let(:expected_payload) + # { + # "email": match_type_of('jane@example.com'), + # "first_name": match_type_of('Jane') + # # "last_name": Pact.like("Doe") # uncomment to see failure in provider code + # } + + # let(:interaction) do + # new_interaction.given('A customer is created') + # .upon_receiving('a customer created message') + # # .with_metadata() + # # .with_json_contents(match_type_of(expected_payload)) + # .with_json_contents(expected_payload) + # end + # end + + # This test is a bit redundant, it's essentially marking our own homework and will always pass. + # However IRL the consumer would probably be doing something more complex which we could assert on. + # See spec/pacts/test_message_consumer-test_message_producer.json for the generated contract file. + # Note that this contract does not match what the producer outputs in app/producers/test_message_producer.rb.. + # If we were to run producer side verification on this contract, it should fail. + # This failure would indicate a mismatch between the consumers expectations of the message format and what the producer actually sends. + + let(:expected_payload) do + { + "email": match_type_of('jane@example.com'), + "first_name": match_type_of('Jane') + # "last_name": Pact.like("Doe") # uncomment to see failure in provider code + } + end + + let(:interaction) do + new_interaction.given('A customer is created') + .upon_receiving('a customer created message') + # .with_metadata() + # .with_json_contents(match_type_of(expected_payload)) + .with_json_contents(expected_payload) + end + + it 'Successfully consumes the message and creates a pact contract file' do + interaction.execute do |json_payload, _meta| + @message = consumer.consume_message(json_payload) + expect(@message).to eq(json_payload) + end + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/plugin_grpc_sync_message_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/plugin_grpc_sync_message_spec.rb new file mode 100644 index 00000000..32b65f20 --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/plugin_grpc_sync_message_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'pact/v2/rspec' + +RSpec.describe 'Test grpc sync message plugin loading', :pact_v2 do + has_plugin_sync_message_pact_between 'pact-ruby-v2-test-app', 'pact-ruby-v2-test-app', opts: { mock_port: 3009 } + + let(:pet_id) { 123 } + + let(:api) { ::PetStore::Grpc::PetStore::V1::Pets::Stub.new('localhost:3009', :this_channel_is_insecure) } + let(:make_request) { api.pet_by_id(PetStore::Grpc::PetStore::V1::PetByIdRequest.new(id: pet_id)) } + + let(:interaction) do + new_interaction + end + + context 'with Pets/PetById' do + context 'with successful interaction' do + let(:interaction) do + super() + .given('pet exists', pet_id: pet_id) + .with_plugin('protobuf', '0.6.5') + .with_content_type('application/grpc') + .with_transport('grpc') + .with_plugin_metadata({ + 'pact:proto' => File.expand_path('spec/internal/deps/services/pet_store/grpc/pet_store.proto'), + 'pact:proto-service' => 'Pets/PetById', + 'pact:content-type' => 'application/protobuf' + }) + .with_request(id: match_any_integer(pet_id)) + .will_respond_with( + pet: { + id: match_any_integer, name: match_any_string + } + ) + end + + it 'executes the pact test without errors' do + interaction.execute do + expect { make_request }.not_to raise_error + end + end + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_async_message_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_async_message_spec.rb new file mode 100644 index 00000000..54940fd4 --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_async_message_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe 'Test matt plugin sync message loading', :pact_v2 do + has_plugin_async_message_pact_between "matttcpconsumer", "matttcpprovider" + + let(:matt_message) do + { + "response" => { "body" => "tcpworld" } + } + end + + let(:interaction) do + new_interaction + .given("the world exists") + .with_plugin("matt", "0.1.1") + .with_content_type("application/matt") + .with_transport("matt") + .with_contents(matt_message) + end + + it "executes the matt plugin pact test without errors" do + interaction.execute do |transport| + # Here you would call your matt TCP service using the transport info. + # For demonstration, we'll just check the response body. + # Replace the following with actual TCP call if needed. + response = matt_message["response"]["body"] + expect(response).to eq("tcpworld") + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_http_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_http_spec.rb new file mode 100644 index 00000000..4afd691c --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_http_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'pact/v2/rspec' +require 'net/http' +require 'json' +require 'faraday' +RSpec.describe 'HTTP transport', :pact_v2 do + has_plugin_http_pact_between 'myconsumer', 'myprovider' + + let(:matt_request) { { 'request' => { 'body' => 'hello' } } } + let(:matt_response) { { 'response' => { 'body' => 'world' } } } + + let(:interaction) do + new_interaction + .given('the Matt protocol is up') + .upon_receiving('an HTTP request to /matt') + .with_plugin('matt', '0.1.1') + .with_request(method: 'POST', path: '/matt', body: matt_request, headers: { 'content-type' => 'application/matt' }) + .will_respond_with(status: 200, body: matt_response, headers: { 'content-type' => 'application/matt' }) + end + + it 'returns a valid MATT message' do + interaction.execute do |mock_server| + uri = URI("#{mock_server.url}/matt") + req = Net::HTTP::Post.new(uri) + req['content-type'] = 'application/matt' + req['accept'] = 'application/matt' + req.body = 'MATT' + matt_request['request']['body'] + "MATT\n" + + res = Net::HTTP.start(uri.hostname, uri.port) do |http| + http.request(req) + end + + expect(res.body).to eq('MATTworldMATT') + end + end +end diff --git a/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_sync_message_spec.rb b/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_sync_message_spec.rb new file mode 100644 index 00000000..2ac55319 --- /dev/null +++ b/spec/pact/providers/pact-ruby-v2-test-app/plugin_matt_sync_message_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "pact/v2/rspec" + +RSpec.describe 'Test matt plugin sync message loading', :pact_v2 do + has_plugin_sync_message_pact_between "myconsumer", "myprovider" + + let(:matt_message) do + { + "request" => { "body" => "hellotcp" }, + "response" => { "body" => "tcpworld" } + } + end + + let(:interaction) do + new_interaction("a MATT message") + .given("the world exists") + .with_plugin("matt", "0.1.1") + .with_content_type("application/matt") + .with_transport("matt") + .with_request(matt_message["request"]) + .will_respond_with(matt_message["response"]) + end + + it "returns a valid MATT message" do + interaction.execute do |transport| + # Replace this with your actual TCP call if needed + # For demonstration, we'll just check the response body. + response = matt_message["response"]["body"] + expect(response).to eq("tcpworld") + end + end +end diff --git a/spec/rails_helper_v2.rb b/spec/rails_helper_v2.rb new file mode 100644 index 00000000..4d5b3a46 --- /dev/null +++ b/spec/rails_helper_v2.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] = "test" + +# Engine root is used by rails_configuration to correctly +# load fixtures and support files +require "pathname" +ENGINE_ROOT = Pathname.new(File.expand_path("..", __dir__)) + +puts "Loading Rails environment for tests from #{ENGINE_ROOT}" +require "webmock" +require "vcr" +require "faraday" +require "gruf" +require "gruf/rspec" +# require "yabeda" # we have to require it becase of this https://github.com/yabeda-rb/yabeda/pull/38 + +require "combustion" +puts "Rails root: #{Rails.root}" + +begin + Combustion.initialize! :action_controller do + config.log_level = :fatal if ENV["LOG"].to_s.empty? + end +rescue => e + # Fail fast if application couldn't be loaded + warn "💥 Failed to load the app: #{e.message}\n#{e.backtrace.join("\n")}" + exit(1) +end + +require "rspec/rails" +puts "Rails root: #{Rails.root}" +# Add additional requires below this line. Rails is not loaded until this point! + +Dir["#{__dir__}/support/vcr.rb"].sort.each { |f| require f } +# Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } + +# Optional dependencies +unless RUBY_PLATFORM =~ /win32|x64-mingw32|x64-mingw-ucrt/ + require "sbmt/kafka_consumer" + require "sbmt/kafka_producer" +end + +# Monkey patch Gruf::Server to remove QUIT from KILL_SIGNALS for windows compatibility +if Gem.win_platform? + warn "[⚠️] Windows platform detected, monkey patching Gruf::Server to remove QUIT from KILL_SIGNALS" + module Gruf + class Server + remove_const(:KILL_SIGNALS) if const_defined?(:KILL_SIGNALS) + KILL_SIGNALS = %w[INT TERM].freeze + end + end +end diff --git a/spec/spec_helper_v2.rb b/spec/spec_helper_v2.rb new file mode 100644 index 00000000..0215ee27 --- /dev/null +++ b/spec/spec_helper_v2.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] = "test" + +require "bundler/setup" +require "rspec" +require "rspec_junit_formatter" + +is_windows = Gem.win_platform? + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.filter_run_when_matching :focus + config.filter_run_excluding skip_windows: is_windows + config.example_status_persistence_file_path = "tmp/rspec_examples.txt" + config.run_all_when_everything_filtered = true + + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + config.order = :random + Kernel.srand config.seed +end diff --git a/spec/support/ssl_server.rb b/spec/support/ssl_server.rb index 35abf61b..3009183e 100644 --- a/spec/support/ssl_server.rb +++ b/spec/support/ssl_server.rb @@ -32,17 +32,17 @@ def webrick_opts port require "webrick" require "webrick/https" require "rack" + # Rack 2/3 compatibility begin - require "rackup/handler/webrick" # rack 3 - PactWEBrick = Rackup::Handler::WEBrick + require 'rack/handler/webrick' + handler = Rack::Handler::WEBrick rescue LoadError - require "rack/handler/webrick" # rack 2 - PactWEBrick = Rack::Handler::WEBrick + require 'rackup/handler/webrick' + handler = Class.new(Rackup::Handler::WEBrick) end - opts = webrick_opts(4444) - PactWEBrick.run(app, **opts) do |server| + handler.run(app, **opts) do |server| @server = server end end diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb new file mode 100644 index 00000000..4ce42fa1 --- /dev/null +++ b/spec/support/vcr.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +VCR.configure do |c| + c.cassette_library_dir = "spec/fixtures/vcr" + c.hook_into :webmock + c.configure_rspec_metadata! + c.ignore_hosts "127.0.0.1", "localhost" + c.default_cassette_options = { + record: :once, + match_requests_on: %i[method uri body], + decode_compressed_response: true + } +end diff --git a/spec/v2/pact/configuration_spec.rb b/spec/v2/pact/configuration_spec.rb new file mode 100644 index 00000000..2930a1d8 --- /dev/null +++ b/spec/v2/pact/configuration_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe Pact::V2::Configuration do + subject(:config) { described_class.new } + + describe "#before_provider_state_setup" do + it "raises if block is not given" do + expect { config.before_provider_state_setup }.to raise_error(/no block given/) + end + + it "configures setup block" do + config.before_provider_state_setup {} + expect(config.before_provider_state_proc).to be_instance_of(Proc) + end + end + + describe "#after_provider_state_teardown" do + it "raises if block is not given" do + expect { config.after_provider_state_teardown }.to raise_error(/no block given/) + end + + it "configures teardown block" do + config.after_provider_state_teardown {} + expect(config.after_provider_state_proc).to be_instance_of(Proc) + end + end +end diff --git a/spec/v2/pact/consumer/grpc_interaction_builder_spec.rb b/spec/v2/pact/consumer/grpc_interaction_builder_spec.rb new file mode 100644 index 00000000..0a6d70a7 --- /dev/null +++ b/spec/v2/pact/consumer/grpc_interaction_builder_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.describe Pact::V2::Consumer::GrpcInteractionBuilder do + subject { described_class.new(nil) } + + let(:proto_path) { Rails.root.join("deps/services/pet_store/grpc/pet_store.proto").to_s } + let(:builder) do + subject + .with_service(proto_path, "Pets/PetById") + .with_request(param: "some data") + .will_respond_with(result: "some data") + end + + it "builds proper json" do + result = JSON.parse(builder.interaction_json) + expect(result).to eq( + "pact:content-type" => "application/protobuf", + "pact:proto" => File.expand_path(proto_path).to_s, + "pact:proto-service" => "Pets/PetById", + "request" => { + "param" => "some data" + }, + "response" => { + "result" => "some data" + } + ) + end +end diff --git a/spec/v2/pact/consumer/interaction_contents_spec.rb b/spec/v2/pact/consumer/interaction_contents_spec.rb new file mode 100644 index 00000000..b31e6c72 --- /dev/null +++ b/spec/v2/pact/consumer/interaction_contents_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Pact::V2::Consumer::InteractionContents do + include Pact::V2::Matchers + + let(:contents) do + { + str: match_any_string("str"), + bool: match_any_boolean(true), + num: match_any_number(1), + nested: match_each( + { + a: 1, + b: "2" + } + ) + } + end + + context "with plugin interaction" do + it "serializes properly to json" do + expect(described_class.plugin(contents).to_json) + .to eq("{\"str\":\"matching(regex, '(?-mix:.*)', 'str')\",\"bool\":\"matching(boolean, true)\",\"num\":\"matching(number, 1)\",\"nested\":{\"pact:match\":\"eachValue(matching($'SAMPLE'))\",\"SAMPLE\":{\"a\":1,\"b\":\"2\"}}}") + end + end + + context "with basic interaction" do + it "serializes properly to json" do + expect(described_class.basic(contents).to_json) + .to eq("{\"str\":{\"pact:matcher:type\":\"regex\",\"value\":\"str\",\"regex\":\"(?-mix:.*)\"},\"bool\":{\"pact:matcher:type\":\"boolean\",\"value\":true},\"num\":{\"pact:matcher:type\":\"number\",\"value\":1},\"nested\":{\"pact:matcher:type\":\"type\",\"value\":[{\"a\":1,\"b\":\"2\"}],\"min\":1}}") + end + end +end diff --git a/spec/v2/pact/consumer/message_interaction_builder_spec.rb b/spec/v2/pact/consumer/message_interaction_builder_spec.rb new file mode 100644 index 00000000..e86022e9 --- /dev/null +++ b/spec/v2/pact/consumer/message_interaction_builder_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe Pact::V2::Consumer::MessageInteractionBuilder do + subject { described_class.new(nil) } + + context "when proto message is used" do + let(:proto_path) { "spec/internal/deps/services/pet_store/grpc/pet_store.proto" } + let(:builder) do + subject + .upon_receiving("message as proto") + .with_proto_class(proto_path, "Pet") + .with_proto_contents(id: 1) + end + + it "builds proper json" do + result = JSON.parse(builder.build_interaction_json) + expect(result).to eq( + "pact:content-type" => "application/protobuf", + "pact:message-type" => "Pet", + "pact:proto" => File.expand_path(proto_path).to_s, + "id" => 1 + ) + end + end + + context "when json message is used" do + let(:proto_path) { "spec/internal/deps/services/pet_store/grpc/pet_store.proto" } + let(:builder) do + subject + .upon_receiving("message as proto") + .with_json_contents(id: 1) + end + + it "builds proper json" do + result = JSON.parse(builder.build_interaction_json) + expect(result).to eq("id" => 1) + end + end +end diff --git a/spec/v2/pact/generators_spec.rb b/spec/v2/pact/generators_spec.rb new file mode 100644 index 00000000..a670497e --- /dev/null +++ b/spec/v2/pact/generators_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'pact/v2/generators' + +module Pact + module V2 + module Generators + RSpec.describe RandomIntGenerator do + subject { described_class.new(min: 1, max: 10) } + + describe '#as_basic' do + it 'returns the correct hash' do + expect(subject.as_basic).to match({ + 'pact:matcher:type' => 'integer', + 'pact:generator:type' => 'RandomInt', + 'min' => 1, + 'max' => 10, + 'value' => a_value_between(1, 10) + }) + end + end + end + + RSpec.describe RandomDecimalGenerator do + subject { described_class.new(digits: 5) } + + describe '#as_basic' do + it 'returns the correct hash' do + expect(subject.as_basic).to match({ + 'pact:matcher:type' => 'decimal', + 'pact:generator:type' => 'RandomDecimal', + 'digits' => 5, + 'value' => a_value_between(0.00001, 0.99999) + }) + end + end + end + + RSpec.describe RandomHexadecimalGenerator do + subject { described_class.new(digits: 8) } + + describe '#as_basic' do + it 'returns the correct hash' do + expect(subject.as_basic).to match({ + 'pact:matcher:type' => 'decimal', + 'pact:generator:type' => 'RandomHexadecimal', + 'digits' => 8, + 'value' => match(/[0-9a-f]{8}/) + }) + end + end + end + + RSpec.describe RandomStringGenerator do + subject { described_class.new(size: 12) } + + describe '#as_basic' do + it 'returns the correct hash' do + expect(subject.as_basic).to match({ + 'pact:matcher:type' => 'type', + 'pact:generator:type' => 'RandomString', + 'size' => 12, + 'value' => match(/[a-zA-Z0-9]{12}/) + }) + end + end + end + + RSpec.describe UuidGenerator do + subject { described_class.new } + + describe '#as_basic' do + it 'returns the correct hash' do + match({ + 'pact:generator:type' => 'Uuid', + 'pact:matcher:type' => 'regex', + 'regex' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + 'value' => match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) + }) + end + end + end + + RSpec.describe DateGenerator do + context 'with format' do + subject { described_class.new(format: 'yyyy-MM-dd') } + + it 'returns the correct hash' do + expect(subject.as_basic).to match({ + 'pact:matcher:type' => 'date', + 'pact:generator:type' => 'Date', + 'format' => 'yyyy-MM-dd', + 'value' => match(/\d{4}-\d{2}-\d{2}/) + }) + end + end + + context 'without format' do + subject { described_class.new } + + it 'returns the correct hash' do + match({ + 'format' => 'yyyy-MM-dd', + 'pact:generator:type' => 'Date', + 'pact:matcher:type' => 'date', + 'value' => match(/\d{4}-\d{2}-\d{2}/) + }) + end + end + end + + RSpec.describe TimeGenerator do + context 'with format' do + subject { described_class.new(format: 'HH:mm:ss') } + + it 'returns the correct hash' do + match({ + 'pact:generator:type' => 'Time', + 'pact:matcher:type' => 'time', + 'format' => 'HH:mm:ss', + 'value' => match(/\d{2}:\d{2}:\d{2}/) + }) + end + end + + context 'without format' do + subject { described_class.new } + + it 'returns the correct hash' do + match({ + 'format' => 'HH:mm', + 'pact:generator:type' => 'Time', + 'pact:matcher:type' => 'time', + 'value' => match(/\d{2}:\d{2}/) + }) + end + end + end + + RSpec.describe DateTimeGenerator do + context 'with format' do + subject { described_class.new(format: "yyyy-MM-dd'T'HH:mm:ssZ") } + + it 'returns the correct hash' do + match({ + 'pact:generator:type' => 'DateTime', + 'pact:matcher:type' => 'datetime', + 'format' => "yyyy-MM-dd'T'HH:mm:ssZ", + 'value' => match(/\d{4}-\d{2}-\d{2}'T'\d{2}:\d{2}:\d{2}\+\d{4}/) + }) + end + end + + context 'without format' do + subject { described_class.new } + + it 'returns the correct hash' do + match({ + 'format' => 'yyyy-MM-dd HH:mm', + 'pact:generator:type' => 'DateTime', + 'pact:matcher:type' => 'datetime', + 'value' => match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/) + }) + end + end + end + + RSpec.describe RandomBooleanGenerator do + subject { described_class.new } + + describe '#as_basic' do + it 'returns the correct hash' do + eq({ + 'pact:generator:type' => 'RandomBoolean', + 'pact:matcher:type' => 'boolean', + 'value' => true + }) + end + end + end + + RSpec.describe ProviderStateGenerator do + subject { described_class.new(expression: '/alligators/${alligator_name}', example: '/alligators/Mary') } + + describe '#as_basic' do + it 'returns the correct hash' do + eq({ + 'pact:generator:type' => 'ProviderState', + 'pact:matcher:type' => 'type', + 'expression' => '/alligators/${alligator_name}', + 'value' => '/alligators/Mary' + }) + end + end + end + + RSpec.describe MockServerURLGenerator do + subject { described_class.new(regex: 'http://localhost:\\d+', example: 'http://localhost:1234') } + + describe '#as_basic' do + it 'returns the correct hash' do + expect(subject.as_basic).to eq({ + 'pact:generator:type' => 'MockServerURL', + 'pact:matcher:type' => 'regex', + 'regex' => 'http://localhost:\\d+', + 'example' => 'http://localhost:1234', + 'value' => 'http://localhost:1234' + }) + end + end + end + end + end +end diff --git a/spec/v2/pact/matchers_spec.rb b/spec/v2/pact/matchers_spec.rb new file mode 100644 index 00000000..55009f0b --- /dev/null +++ b/spec/v2/pact/matchers_spec.rb @@ -0,0 +1,480 @@ +# frozen_string_literal: true + +RSpec.describe Pact::V2::Matchers do + subject(:test_class) { Class.new { extend Pact::V2::Matchers } } + + context "with basic format serialization" do + it "properly builds matcher for UUID" do + expect(test_class.match_uuid.as_basic).to eq({ + "pact:matcher:type" => "regex", + "value" => "e1d01e04-3a2b-4eed-a4fb-54f5cd257338", + :regex => "(?i-mx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})" + }) + end + + it "properly builds matcher for regex" do + expect(test_class.match_regex(/(A-Z){1,3}/, "ABC").as_basic).to eq({ + "pact:matcher:type" => "regex", + "value" => "ABC", + :regex => "(?-mix:(A-Z){1,3})" + }) + end + + it "properly builds matcher for datetime" do + expect(test_class.match_datetime("yyyy-MM-dd HH:mm:ssZZZZZ", "2020-05-21 16:44:32+10:00").as_basic).to eq({ + "pact:matcher:type" => "datetime", + "value" => "2020-05-21 16:44:32+10:00", + :format => "yyyy-MM-dd HH:mm:ssZZZZZ" + }) + end + + it "properly builds matcher for iso8601" do + expect(test_class.match_iso8601("2020-05-21T16:44:32").as_basic).to eq({ + "pact:matcher:type" => "regex", + "value" => "2020-05-21T16:44:32", + :regex => "(?i-mx:\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)*(.\\d{2}:\\d{2})*)" + }) + end + + it "properly builds matcher for date" do + expect(test_class.match_date("yyyy-MM-dd", "2020-05-21").as_basic).to eq({ + "pact:matcher:type" => "date", + "value" => "2020-05-21", + :format => "yyyy-MM-dd" + }) + end + + it "properly builds matcher for time" do + expect(test_class.match_time("HH:mm:ss", "16:44:32").as_basic).to eq({ + "pact:matcher:type" => "time", + "value" => "16:44:32", + :format => "HH:mm:ss" + }) + end + + it "properly builds matcher for include" do + expect(test_class.match_include("some string").as_basic).to eq({ + "pact:matcher:type" => "include", + "value" => "some string" + }) + end + + it "properly builds matcher for any string" do + expect(test_class.match_any_string.as_basic).to eq({ + "pact:matcher:type" => "regex", + "value" => "any", + :regex => "(?-mix:.*)" + }) + expect(test_class.match_any_string("").as_basic).to eq({ + "pact:matcher:type" => "regex", + "value" => "", + :regex => "(?-mix:.*)" + }) + end + + it "properly builds matcher for boolean values" do + expect(test_class.match_any_boolean.as_basic).to eq({ + "pact:matcher:type" => "boolean", + "value" => true + }) + end + + it "properly builds matcher for integer values" do + expect(test_class.match_any_integer.as_basic).to eq({ + "pact:matcher:type" => "integer", + "value" => 10 + }) + end + + it "properly builds matcher for float values" do + expect(test_class.match_any_decimal.as_basic).to eq({ + "pact:matcher:type" => "decimal", + "value" => 10.0 + }) + end + + it "properly builds matcher for exact values" do + expect(test_class.match_exactly("some arg").as_basic).to eq({ + "pact:matcher:type" => "equality", + "value" => "some arg" + }) + expect(test_class.match_exactly(1).as_basic).to eq({ + "pact:matcher:type" => "equality", + "value" => 1 + }) + expect(test_class.match_exactly(true).as_basic).to eq({ + "pact:matcher:type" => "equality", + "value" => true + }) + end + + it "properly builds typed matcher" do + expect(test_class.match_type_of(1).as_basic).to eq({ + "pact:matcher:type" => "type", + "value" => 1 + }) + expect { test_class.match_type_of(Object.new).as_basic }.to raise_error(/is not a primitive/) + end + + it "properly builds each matcher" do + expect(test_class.match_each(1).as_basic).to eq({ + "pact:matcher:type" => "type", + "value" => [1], + :min => 1 + }) + expect(test_class.match_each(true).as_basic).to eq({ + "pact:matcher:type" => "type", + "value" => [true], + :min => 1 + }) + expect(test_class.match_each("some").as_basic).to eq({ + "pact:matcher:type" => "type", + "value" => ["some"], + :min => 1 + }) + expect(test_class.match_each( + { + str: test_class.match_any_string("str"), + bool: test_class.match_any_boolean(true), + num: test_class.match_any_number(1), + nested: test_class.match_each( + { + a: 1, + b: "2" + } + ) + } + ).as_basic).to eq({ + "pact:matcher:type" => "type", + "value" => [ + { + str: { + "pact:matcher:type" => "regex", + :regex => "(?-mix:.*)", + "value" => "str" + }, + bool: { + "pact:matcher:type" => "boolean", + "value" => true + }, + num: { + "pact:matcher:type" => "number", + "value" => 1 + }, + nested: { + "pact:matcher:type" => "type", + "value" => [ + {a: 1, b: "2"} + ], + :min => 1 + } + } + ], + :min => 1 + }) + end + + it "properly builds each-key matcher" do + expect(test_class.match_each_key({"some-key" => "value"}, test_class.match_regex(/\w+-\w+/, "some-key")).as_basic).to eq( + { + "pact:matcher:type" => "each-key", + :rules => [ + { + "pact:matcher:type" => "regex", + :regex => "(?-mix:\\w+-\\w+)", + "value" => "some-key" + } + ], + "value" => {"some-key" => "value"} + } + ) + expect(test_class.match_each_key({"some-key" => {"value1" => 1, "value2" => 2}}, test_class.match_regex(/\w+-\w+/, "some-key")).as_basic).to eq( + { + "pact:matcher:type" => "each-key", + :rules => [ + { + "pact:matcher:type" => "regex", + :regex => "(?-mix:\\w+-\\w+)", + "value" => "some-key" + } + ], + "value" => {"some-key" => {"value1" => 1, "value2" => 2}} + } + ) + end + + it "properly builds each-value matcher" do + expect(test_class.match_each_value({"some-key" => "value"}, test_class.match_regex(/\w+/, "value")).as_basic).to eq( + { + "pact:matcher:type" => "each-value", + :rules => [ + { + "pact:matcher:type" => "regex", + :regex => "(?-mix:\\w+)", + "value" => "value" + } + ], + "value" => {"some-key" => "value"} + } + ) + expect(test_class.match_each_value( + {"some-key" => {"value1" => test_class.match_any_string("1"), "value2" => test_class.match_any_number(2)}}, + test_class.match_regex(/\w+-\w+/, "some-key") + ).as_basic).to eq( + { + "pact:matcher:type" => "each-value", + :rules => [ + { + "pact:matcher:type" => "regex", + :regex => "(?-mix:\\w+-\\w+)", + "value" => "some-key" + } + ], + "value" => { + "some-key" => { + "value1" => { + "pact:matcher:type" => "regex", + :regex => "(?-mix:.*)", + "value" => "1" + }, + "value2" => { + "pact:matcher:type" => "number", + "value" => 2 + } + } + } + } + ) + end + + it "properly builds each-key-value matcher" do + expect(test_class.match_each_kv( + { + "some-key" => { + "value1" => test_class.match_any_string("1") + } + }, test_class.match_regex(/\w+/, "value") + ).as_basic).to eq({ + "pact:matcher:type" => [ + { + "pact:matcher:type" => "each-key", + :rules => [ + { + "pact:matcher:type" => "regex", + :regex => "(?-mix:\\w+)", + "value" => "value" + } + ], + "value" => {} + }, + { + "pact:matcher:type" => "each-value", + :rules => [ + { + "pact:matcher:type" => "type", + "value" => "" + } + ], + "value" => {} + } + ], + "value" => { + "some-key" => { + "value1" => { + "pact:matcher:type" => "regex", + :regex => "(?-mix:.*)", + "value" => "1" + } + } + } + }) + end + + it "properly builds semver matcher" do + expect(test_class.match_semver.as_basic).to eq({ + "pact:matcher:type" => "semver", + }) + end + it "properly builds content_type matcher" do + expect(test_class.match_content_type("application/xml").as_basic).to eq({ + "pact:matcher:type" => "contentType", + "value" => "application/xml" + }) + end + it "properly builds not_empty matcher" do + expect(test_class.match_not_empty.as_basic).to eq({ + "pact:matcher:type" => "notEmpty" + }) + end + + it "properly builds values matcher" do + expect(Pact::V2::Matchers::V3::Values.new.as_basic).to eq({ + "pact:matcher:type" => "values" + }) + end + + it "properly builds null matcher" do + expect(Pact::V2::Matchers::V3::Null.new.as_basic).to eq({ + "pact:matcher:type" => "null" + }) + end + + it "properly builds status_code matcher" do + expect(test_class.match_status_code(200).as_basic).to eq({ + "pact:matcher:type" => "statusCode", + "status" => 200 + }) + expect(test_class.match_status_code('nonError').as_basic).to eq({ + "pact:matcher:type" => "statusCode", + "status" => 'nonError' + }) + end + end + + context "with plugin format serialization" do + it "properly builds matcher for UUID" do + expect(test_class.match_uuid.as_plugin).to eq("matching(regex, '(?i-mx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', 'e1d01e04-3a2b-4eed-a4fb-54f5cd257338')") + end + + it "properly builds matcher for regex" do + expect(test_class.match_regex(/(A-Z){1,3}/, "ABC").as_plugin).to eq("matching(regex, '(?-mix:(A-Z){1,3})', 'ABC')") + end + + it "properly builds matcher for datetime" do + expect(test_class.match_datetime("yyyy-MM-dd HH:mm:ssZZZZZ", "2020-05-21 16:44:32+10:00").as_plugin).to eq("matching(datetime, 'yyyy-MM-dd HH:mm:ssZZZZZ', '2020-05-21 16:44:32+10:00')") + end + + it "properly builds matcher for iso8601" do + expect(test_class.match_iso8601("2020-05-21T16:44:32").as_plugin).to eq("matching(regex, '(?i-mx:\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)*(.\\d{2}:\\d{2})*)', '2020-05-21T16:44:32')") + end + + it "properly builds matcher for date" do + expect(test_class.match_date("yyyy-MM-dd", "2020-05-21").as_plugin).to eq("matching(date, 'yyyy-MM-dd', '2020-05-21')") + end + + it "properly builds matcher for time" do + expect(test_class.match_time("HH:mm:ss", "16:44:32").as_plugin).to eq("matching(time, 'HH:mm:ss', '16:44:32')") + end + + it "properly builds matcher for include" do + expect(test_class.match_include("some string").as_plugin).to eq("matching(include, 'some string')") + end + + it "properly builds matcher for any string" do + expect(test_class.match_any_string.as_plugin).to eq("matching(regex, '(?-mix:.*)', 'any')") + expect(test_class.match_any_string("").as_plugin).to eq("matching(regex, '(?-mix:.*)', '')") + end + + it "properly builds matcher for boolean values" do + expect(test_class.match_any_boolean.as_plugin).to eq("matching(boolean, true)") + end + + it "properly builds matcher for integer values" do + expect(test_class.match_any_integer.as_plugin).to eq("matching(integer, 10)") + end + + it "properly builds matcher for float values" do + expect(test_class.match_any_decimal.as_plugin).to eq("matching(decimal, 10.0)") + end + + it "properly builds matcher for exact values" do + expect(test_class.match_exactly("some arg").as_plugin).to eq("matching(equalTo, 'some arg')") + expect(test_class.match_exactly(1).as_plugin).to eq("matching(equalTo, 1)") + expect(test_class.match_exactly(true).as_plugin).to eq("matching(equalTo, true)") + end + + it "properly builds typed matcher" do + expect(test_class.match_type_of(1).as_plugin).to eq("matching(type, 1)") + expect { test_class.match_type_of(Object.new).as_plugin }.to raise_error(/is not a primitive/) + end + + it "properly builds each matcher" do + expect(test_class.match_each(1).as_plugin).to eq("eachValue(matching(type, 1))") + expect(test_class.match_each(true).as_plugin).to eq("eachValue(matching(type, true))") + expect(test_class.match_each("some").as_plugin).to eq("eachValue(matching(type, 'some'))") + expect(test_class.match_each( + { + str: test_class.match_any_string("str"), + bool: test_class.match_any_boolean(true), + num: test_class.match_any_number(1), + nested: test_class.match_each( + { + a: 1, + b: "2" + } + ) + } + ).as_plugin).to eq({ + "pact:match" => "eachValue(matching($'SAMPLE'))", + "SAMPLE" => { + str: "matching(regex, '(?-mix:.*)', 'str')", + bool: "matching(boolean, true)", + num: "matching(number, 1)", + nested: { + "pact:match" => "eachValue(matching($'SAMPLE'))", + "SAMPLE" => {a: 1, b: "2"} + } + } + }) + end + + it "properly builds each-key matcher" do + expect(test_class.match_each_key({"some-key" => "value"}, test_class.match_regex(/\w+-\w+/, "some-key")).as_plugin).to eq("eachKey(matching(regex, '(?-mix:\\w+-\\w+)', 'some-key'))") + expect(test_class.match_each_key({"some-key" => {"value1" => 1, "value2" => 2}}, test_class.match_regex(/\w+-\w+/, "some-key")).as_plugin).to eq("eachKey(matching(regex, '(?-mix:\\w+-\\w+)', 'some-key'))") + end + + it "properly builds each-value matcher" do + expect(test_class.match_each_value( + { + str: test_class.match_any_string("str"), + bool: test_class.match_any_boolean(true), + num: test_class.match_any_number(1), + nested: test_class.match_each( + { + a: 1, + b: "2" + } + ) + } + ).as_plugin).to eq({ + "pact:match" => "eachValue(matching($'SAMPLE'))", + "SAMPLE" => { + str: "matching(regex, '(?-mix:.*)', 'str')", + bool: "matching(boolean, true)", + num: "matching(number, 1)", + nested: { + "pact:match" => "eachValue(matching($'SAMPLE'))", + "SAMPLE" => {a: 1, b: "2"} + } + } + }) + end + + it "properly builds semver matcher" do + expect(test_class.match_semver("1.2.3").as_plugin).to eq("matching(semver, '1.2.3')") + end + + it "properly builds content_type matcher" do + expect(test_class.match_content_type("application/xml", '').as_plugin).to eq("matching(contentType, 'application/xml', '')") + end + + it "properly builds not_empty matcher" do + expect(test_class.match_not_empty("some value").as_plugin).to eq("notEmpty('some value')") + end + end + + context "with common regex" do + it "has valid regex for iso8601" do + expect(described_class::ISO8601_REGEX).to match("2020-05-21T16:44:32") + expect(described_class::ISO8601_REGEX).to match("2020-05-21T16:44:32+10:00") + expect(described_class::ISO8601_REGEX).to match("2020-05-21T16:44:32.123+10:00") + expect(described_class::ISO8601_REGEX).to match("2020-05-21T16:44:32.123") + expect(described_class::ISO8601_REGEX).to match("2020-05-21T16:44:32.123456+10:00") + expect(described_class::ISO8601_REGEX).to match("2020-05-21T16:44:32.123456") + end + + it "has valid regex for UUID" do + expect(described_class::UUID_REGEX).to match(SecureRandom.uuid) + end + end +end diff --git a/spec/v2/pact/provider/base_verifier_spec.rb b/spec/v2/pact/provider/base_verifier_spec.rb new file mode 100644 index 00000000..cb969409 --- /dev/null +++ b/spec/v2/pact/provider/base_verifier_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +describe Pact::V2::Provider::BaseVerifier do + subject { described_class.new(Pact::V2::Provider::PactConfig::Base.new(provider_name: "provider")) } + + let(:build_selectors) { subject.send(:build_consumer_selectors, verify_only, consumer_name, consumer_branch) } + + context "when verify_only is defined" do + let(:verify_only) { ["consumer-1", "consumer-2"] } + + context "when consumer / branch are defined and matched" do + let(:consumer_name) { "consumer-1" } + let(:consumer_branch) { "32b53c01" } + + it "builds proper selectors" do + expect(build_selectors).to eq([{"branch" => "32b53c01", "consumer" => "consumer-1"}]) + end + end + + context "when consumer / branch are defined and not matched" do + let(:consumer_name) { "consumer-3" } + let(:consumer_branch) { "feature-branch" } + + it "builds proper selectors" do + expect(build_selectors).to be_empty + end + end + + context "when consumer is not defined" do + let(:consumer_name) { nil } + let(:consumer_branch) { nil } + + it "builds proper selectors" do + expect(build_selectors) + .to eq([ + {"consumer" => "consumer-1"}, + {"consumer" => "consumer-2"} + ]) + end + end + end + + context "when verify_only is not defined" do + let(:verify_only) { [] } + + context "when consumer / branch are defined" do + let(:consumer_name) { "consumer-1" } + let(:consumer_branch) { "32b53c01" } + + it "builds proper selectors" do + expect(build_selectors).to eq([{"branch" => "32b53c01", "consumer" => "consumer-1"}]) + end + end + + context "when only consumer is defined" do + let(:consumer_name) { "consumer-3" } + let(:consumer_branch) { nil } + + it "builds proper selectors" do + expect(build_selectors).to eq([{"consumer" => "consumer-3"}]) + end + end + + context "when consumer is not defined" do + let(:consumer_name) { nil } + let(:consumer_branch) { nil } + + it "builds proper selectors" do + expect(build_selectors).to eq([{}]) + end + end + end +end diff --git a/spec/v2/pact/provider/gruf_server_spec.rb b/spec/v2/pact/provider/gruf_server_spec.rb new file mode 100644 index 00000000..c475f8cc --- /dev/null +++ b/spec/v2/pact/provider/gruf_server_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +describe Pact::V2::Provider::GrufServer do + let(:api) { ::PetStore::Grpc::PetStore::V1::Pets::Stub.new("localhost:3009", :this_channel_is_insecure) } + let(:call_rpc) do + subject.run { api.pet_by_id(PetStore::Grpc::PetStore::V1::PetByIdRequest.new(id: 1)) } + end + + context "when success" do + it "succeeds" do + resp = call_rpc + + expect(resp.pet.id).to eq 1 + expect(resp.pet.name).to eq "Jack" + end + end +end diff --git a/spec/v2/pact/provider/pact_broker_proxy_runner_spec.rb b/spec/v2/pact/provider/pact_broker_proxy_runner_spec.rb new file mode 100644 index 00000000..ad649514 --- /dev/null +++ b/spec/v2/pact/provider/pact_broker_proxy_runner_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +describe Pact::V2::Provider::PactBrokerProxyRunner do + let(:http_client) do + Faraday.new do |conn| + conn.response :json + conn.request :json + end + end + + let(:broker_host) { "https://example.org" } + let(:proxy_host) { server.proxy_url } + let(:make_request) { server.run { http_client.get(request_url) } } + + context "with pact data request" do + let(:request_url) { "#{proxy_host}/pacts/provider/paas-stand-seeker/consumer/paas-stand-placer/pact-version/2967a9343bd8fdd28a286c4b8322380020618892/metadata/c1tdW2VdPXByb2R1Y3Rpb24mc1tdW2N2XT03MzIy" } + let(:server) { described_class.new(pact_broker_host: broker_host) } + + around do |example| + VCR.use_cassette("pact-broker/pact_data") { example.run } + end + + end + + context "with other broker request" do + let(:server) { described_class.new(pact_broker_host: broker_host) } + let(:request_url) { "#{proxy_host}/pacts/provider/paas-stand-seeker/for-verification" } + + it "proxies without modification" do + VCR.use_cassette "pact-broker/for_verification" do + response = make_request + expect(response.status).to eq(200) + expect(response.headers["content-length"]).to eq("2817") + end + end + end + + context "with broker error" do + let(:server) { described_class.new(pact_broker_host: broker_host) } + let(:request_url) { "#{proxy_host}/pacts/provider/non-existent-provider/for-verification" } + + it "proxies without modification" do + VCR.use_cassette "pact-broker/not_found" do + response = make_request + expect(response.status).to eq(404) + expect(response.body).to eq("error" => "No provider with name 'non-existent-provider' found") + end + end + end +end diff --git a/spec/v2/pact/provider/provider_server_runner_spec.rb b/spec/v2/pact/provider/provider_server_runner_spec.rb new file mode 100644 index 00000000..fbf546d4 --- /dev/null +++ b/spec/v2/pact/provider/provider_server_runner_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +describe Pact::V2::Provider::ProviderServerRunner do + let(:http_client) do + Faraday.new do |conn| + conn.response :json + conn.request :json + end + end + + let(:make_request) do + server.run { http_client.post("http://localhost:9001/setup-provider", request_body) } + end + + let(:server) do + subject.tap do |s| + s.add_setup_state("state1") {} + s.add_teardown_state("state1") {} + end + end + + context "with setup callback" do + let(:request_body) do + {"action" => "setup", "params" => {"param1" => "value1"}, "state" => "state1"} + end + + it "succeeds" do + expect_any_instance_of(Pact::V2::Provider::ProviderStateServlet).to receive(:call_setup).and_call_original + + response = make_request + expect(response.status).to eq(200) + end + end + + context "with teardown callback" do + let(:request_body) do + {"action" => "teardown", "params" => {"param1" => "value1"}, "state" => "state1"} + end + + it "succeeds" do + expect_any_instance_of(Pact::V2::Provider::ProviderStateServlet).to receive(:call_teardown).and_call_original + + response = make_request + expect(response.status).to eq(200) + end + end + + context "with unknown state callback" do + let(:request_body) do + {"action" => "unknown", "params" => {"param1" => "value1"}, "state" => "state1"} + end + + it "succeeds" do + expect_any_instance_of(Pact::V2::Provider::ProviderStateServlet).not_to receive(:call_setup) + expect_any_instance_of(Pact::V2::Provider::ProviderStateServlet).not_to receive(:call_teardown) + + response = make_request + expect(response.status).to eq(200) + end + end + + context "with unknown data" do + let(:request_body) { "non-json data" } + + it "fails" do + response = make_request + expect(response.status).to eq(500) + end + end +end diff --git a/spec/v2/pact_spec.rb b/spec/v2/pact_spec.rb new file mode 100644 index 00000000..750c1f01 --- /dev/null +++ b/spec/v2/pact_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Pact::V2 do + it "has a version number" do + expect(Pact::V2::VERSION).not_to be_nil + end +end diff --git a/tasks/spec.rake b/tasks/spec.rake index e1a58139..c1218bad 100644 --- a/tasks/spec.rake +++ b/tasks/spec.rake @@ -1,17 +1,19 @@ -RSpec::Core::RakeTask.new(:spec) - +RSpec::Core::RakeTask.new(:spec) do |t| + t.pattern = 'spec/**/*_spec.rb' + t.exclude_pattern = 'spec/pact/**/*_spec.rb,spec/v2/**/*_spec.rb' +end # Need to run this in separate process because left over state from # testing the actual pact framework messes up the tests that actually # use pact. -RSpec::Core::RakeTask.new('spec:provider') do | task | - task.pattern = "spec/service_providers/**/*_test.rb" +RSpec::Core::RakeTask.new('spec:provider') do |task| + task.pattern = 'spec/service_providers/**/*_test.rb' end task :set_active_support_on do - ENV["LOAD_ACTIVE_SUPPORT"] = 'true' + ENV['LOAD_ACTIVE_SUPPORT'] = 'true' end -desc "This is to ensure that the gem still works even when active support JSON is loaded." -task :spec_with_active_support => [:set_active_support_on] do +desc 'This is to ensure that the gem still works even when active support JSON is loaded.' +task spec_with_active_support: [:set_active_support_on] do Rake::Task['spec'].execute end diff --git a/tasks/spec_v2.rake b/tasks/spec_v2.rake new file mode 100644 index 00000000..c4ccbb74 --- /dev/null +++ b/tasks/spec_v2.rake @@ -0,0 +1,34 @@ +RSpec::Core::RakeTask.new('spec:v2') do |t| + t.pattern = 'spec/v2/**/*_spec.rb' + t.rspec_opts = '--require spec_helper_v2 --require rails_helper_v2' +end + +RSpec::Core::RakeTask.new('pact:v2:spec') do |task| + task.pattern = 'spec/pact/providers/**/*_spec.rb' + task.rspec_opts = ['-t pact_v2', '--require spec_helper_v2 --require rails_helper_v2'] +end + +RSpec::Core::RakeTask.new('pact:v2:verify') do |task| + task.pattern = 'spec/pact/consumers/*_spec.rb' + task.rspec_opts = ['-t pact_v2', '--require spec_helper_v2 --require rails_helper_v2'] +end + +# Need to run this in separate process because left over state from +# testing the actual pact framework messes up the tests that actually +# use pact. +# RSpec::Core::RakeTask.new('spec:provider') do |task| +# task.pattern = 'spec/service_providers/**/*_test.rb' +# end + +# task :set_active_support_on do +# ENV['LOAD_ACTIVE_SUPPORT'] = 'true' +# end + +# desc 'This is to ensure that the gem still works even when active support JSON is loaded.' +# task : [:set_active_support_on] do +# Rake::Task['pact:v2'].execute +# end + + +desc 'Run all v2 spec tasks' +task 'spec:v2:all' => ['spec:v2', 'pact:v2:spec', 'pact:v2:verify'] \ No newline at end of file