From df899ebd3fa7f2186f37ca581a9c2536cba57d36 Mon Sep 17 00:00:00 2001 From: Beth Skurrie Date: Tue, 27 Apr 2021 13:54:22 +1000 Subject: [PATCH] feat: add endpoint to create pacts, pacticipant, version, tags in one request (#420) --- lib/pact_broker/api.rb | 5 +- .../contracts/dry_validation_predicates.rb | 4 + .../api/contracts/publish_contracts_schema.rb | 104 +++++++++ .../api/decorators/base_decorator.rb | 6 +- .../embedded_pacticipant_decorator.rb | 17 ++ .../decorators/embedded_version_decorator.rb | 2 + .../api/decorators/pacticipant_decorator.rb | 12 +- .../decorators/publish_contract_decorator.rb | 19 ++ .../decorators/publish_contracts_decorator.rb | 21 ++ .../publish_contracts_results_decorator.rb | 54 +++++ lib/pact_broker/api/pact_broker_urls.rb | 2 +- lib/pact_broker/api/resources/index.rb | 5 + lib/pact_broker/api/resources/pact.rb | 4 - .../api/resources/publish_contracts.rb | 81 +++++++ .../api/resources/triggered_webhook_logs.rb | 8 +- .../resources/webhook_execution_methods.rb | 3 +- .../contracts/contract_to_publish.rb | 17 ++ .../contracts_publication_results.rb | 14 ++ .../contracts/contracts_to_publish.rb | 9 + lib/pact_broker/contracts/log_message.rb | 17 ++ lib/pact_broker/contracts/service.rb | 220 ++++++++++++++++++ .../views/index/publish-contracts.markdown | 120 ++++++++++ lib/pact_broker/events/publisher.rb | 9 + lib/pact_broker/events/subscriber.rb | 42 ++++ lib/pact_broker/locale/en.yml | 24 ++ lib/pact_broker/messages.rb | 2 +- lib/pact_broker/pacts/service.rb | 34 ++- .../pacts/verifiable_pact_messages.rb | 4 +- lib/pact_broker/services.rb | 9 + lib/pact_broker/verifications/repository.rb | 4 + lib/pact_broker/verifications/service.rb | 10 +- lib/pact_broker/webhooks/event_listener.rb | 6 +- lib/pact_broker/webhooks/repository.rb | 4 + lib/rack/pact_broker/convert_404_to_hal.rb | 2 +- pact_broker.gemspec | 2 +- script/approval-all.sh | 6 + .../publish_pact_all_in_one_approval_spec.rb | 76 ++++++ spec/features/publish_pact_all_in_one_spec.rb | 43 ++++ .../modifiable_resources.approved.json | 3 + ...lish_contract_nothing_exists.approved.json | 121 ++++++++++ ..._nothing_exists_with_webhook.approved.json | 121 ++++++++++ ..._verification_already_exists.approved.json | 117 ++++++++++ ...ntract_with_validation_error.approved.json | 42 ++++ ..._contracts_results_decorator.approved.json | 54 +++++ .../publish_contracts_schema_spec.rb | 114 +++++++++ .../decorators/pact_version_decorator_spec.rb | 15 +- .../pact_webhooks_status_decorator_spec.rb | 2 +- ...ublish_contracts_results_decorator_spec.rb | 53 +++++ .../triggered_webhook_decorator_spec.rb | 2 +- spec/lib/pact_broker/app_spec.rb | 2 +- .../lib/pact_broker/contracts/service_spec.rb | 108 +++++++++ .../lib/pact_broker/events/subscriber_spec.rb | 43 ++++ spec/lib/pact_broker/pacts/service_spec.rb | 32 +-- .../hal_relation_proxy_app.rb | 4 +- .../provider_states_for_pact_broker_client.rb | 4 + spec/spec_helper.rb | 13 -- spec/support/approvals.rb | 10 +- 57 files changed, 1806 insertions(+), 75 deletions(-) create mode 100644 lib/pact_broker/api/contracts/publish_contracts_schema.rb create mode 100644 lib/pact_broker/api/decorators/embedded_pacticipant_decorator.rb create mode 100644 lib/pact_broker/api/decorators/publish_contract_decorator.rb create mode 100644 lib/pact_broker/api/decorators/publish_contracts_decorator.rb create mode 100644 lib/pact_broker/api/decorators/publish_contracts_results_decorator.rb create mode 100644 lib/pact_broker/api/resources/publish_contracts.rb create mode 100644 lib/pact_broker/contracts/contract_to_publish.rb create mode 100644 lib/pact_broker/contracts/contracts_publication_results.rb create mode 100644 lib/pact_broker/contracts/contracts_to_publish.rb create mode 100644 lib/pact_broker/contracts/log_message.rb create mode 100644 lib/pact_broker/contracts/service.rb create mode 100644 lib/pact_broker/doc/views/index/publish-contracts.markdown create mode 100644 lib/pact_broker/events/publisher.rb create mode 100644 lib/pact_broker/events/subscriber.rb create mode 100755 script/approval-all.sh create mode 100644 spec/features/publish_pact_all_in_one_approval_spec.rb create mode 100644 spec/features/publish_pact_all_in_one_spec.rb create mode 100644 spec/fixtures/approvals/publish_contract_nothing_exists.approved.json create mode 100644 spec/fixtures/approvals/publish_contract_nothing_exists_with_webhook.approved.json create mode 100644 spec/fixtures/approvals/publish_contract_verification_already_exists.approved.json create mode 100644 spec/fixtures/approvals/publish_contract_with_validation_error.approved.json create mode 100644 spec/fixtures/approvals/publish_contracts_results_decorator.approved.json create mode 100644 spec/lib/pact_broker/api/contracts/publish_contracts_schema_spec.rb create mode 100644 spec/lib/pact_broker/api/decorators/publish_contracts_results_decorator_spec.rb create mode 100644 spec/lib/pact_broker/contracts/service_spec.rb create mode 100644 spec/lib/pact_broker/events/subscriber_spec.rb diff --git a/lib/pact_broker/api.rb b/lib/pact_broker/api.rb index b4240544e..d12ffcb7a 100644 --- a/lib/pact_broker/api.rb +++ b/lib/pact_broker/api.rb @@ -98,7 +98,8 @@ def self.build_api(application_context = PactBroker::ApplicationContext.default_ add ['webhooks', 'execute' ], Api::Resources::WebhookExecution, {resource_name: "execute_unsaved_webhook"} add ['webhooks', :uuid ], Api::Resources::Webhook, {resource_name: "webhook"} - add ['webhooks', :uuid, 'trigger', :trigger_uuid, 'logs' ], Api::Resources::TriggeredWebhookLogs, {resource_name: "triggered_webhook_logs"} + add ['webhooks', :uuid, 'trigger', :trigger_uuid, 'logs' ], Api::Resources::TriggeredWebhookLogs, { resource_name: "triggered_webhook_logs" } + add ['triggered-webhooks', :trigger_uuid, 'logs' ], Api::Resources::TriggeredWebhookLogs, { resource_name: "triggered_webhook_logs" } add ['webhooks', :uuid, 'execute' ], Api::Resources::WebhookExecution, {resource_name: "execute_webhook"} add ['webhooks'], Api::Resources::AllWebhooks, {resource_name: "webhooks"} @@ -115,6 +116,8 @@ def self.build_api(application_context = PactBroker::ApplicationContext.default_ add ['dashboard', 'provider', :provider_name, 'consumer', :consumer_name ], Api::Resources::Dashboard, {resource_name: "integration_dashboard"} add ['test','error'], Api::Resources::ErrorTest, {resource_name: "error_test"} + add ['contracts', 'publish'], Api::Resources::PublishContracts, { resource_name: "publish_contracts" } + if PactBroker.feature_enabled?(:environments) add ['environments'], Api::Resources::Environments, { resource_name: "environments" } add ['environments', :environment_uuid], Api::Resources::Environment, { resource_name: "environment" } diff --git a/lib/pact_broker/api/contracts/dry_validation_predicates.rb b/lib/pact_broker/api/contracts/dry_validation_predicates.rb index 864615f97..ed3e1c5f4 100644 --- a/lib/pact_broker/api/contracts/dry_validation_predicates.rb +++ b/lib/pact_broker/api/contracts/dry_validation_predicates.rb @@ -10,6 +10,10 @@ module DryValidationPredicates DateTime.parse(value) rescue false end + predicate(:base64?) do |value| + Base64.strict_decode64(value) rescue false + end + predicate(:not_blank?) do | value | value && value.is_a?(String) && value.strip.size > 0 end diff --git a/lib/pact_broker/api/contracts/publish_contracts_schema.rb b/lib/pact_broker/api/contracts/publish_contracts_schema.rb new file mode 100644 index 000000000..a2dd42682 --- /dev/null +++ b/lib/pact_broker/api/contracts/publish_contracts_schema.rb @@ -0,0 +1,104 @@ +require 'dry-validation' +require 'pact_broker/api/contracts/dry_validation_workarounds' +require 'pact_broker/api/contracts/dry_validation_predicates' +require 'pact_broker/messages' + +module PactBroker + module Api + module Contracts + class PublishContractsSchema + extend DryValidationWorkarounds + using PactBroker::HashRefinements + extend PactBroker::Messages + + SCHEMA = Dry::Validation.Schema do + configure do + predicates(DryValidationPredicates) + config.messages_file = File.expand_path("../../../locale/en.yml", __FILE__) + end + + required(:pacticipantName).filled(:str?, :not_blank?) + required(:pacticipantVersionNumber).filled(:not_blank?, :single_line?) + optional(:tags).each(:not_blank?, :single_line?) + optional(:branch).maybe(:not_blank?, :single_line?) + optional(:buildUrl).maybe(:single_line?) + + required(:contracts).each do + required(:consumerName).filled(:str?, :not_blank?) + required(:providerName).filled(:str?, :not_blank?) + required(:content).filled(:str?) + required(:contentType).filled(included_in?: ["application/json"]) + required(:specification).filled(included_in?: ["pact"]) + optional(:writeMode).filled(included_in?:["overwrite", "merge"]) + end + end + + def self.call(params) + select_first_message( + flatten_indexed_messages( + add_cross_field_validation_errors( + params&.symbolize_keys, + SCHEMA.call(params&.symbolize_keys).messages(full: true) + ) + ) + ) + end + + def self.add_cross_field_validation_errors(params, errors) + if params[:contracts].is_a?(Array) + params[:contracts].each_with_index do | contract, i | + if contract.is_a?(Hash) + validate_consumer_name(params, contract, i, errors) + validate_consumer_name_in_content(params, contract, i, errors) + validate_provider_name_in_content(contract, i, errors) + validate_encoding(contract, i, errors) + validate_content_matches_content_type(contract, i, errors) + end + end + end + errors + end + + def self.validate_consumer_name(params, contract, i, errors) + if params[:pacticipantName] && contract[:consumerName] && (contract[:consumerName] != params[:pacticipantName]) + add_contract_error(validation_message('consumer_name_in_contract_mismatch_pacticipant_name', { consumer_name_in_contract: contract[:consumerName], pacticipant_name: params[:pacticipantName] } ), i, errors) + end + end + + def self.validate_consumer_name_in_content(params, contract, i, errors) + consumer_name_in_content = contract.dig(:decodedParsedContent, :consumer, :name) + if consumer_name_in_content && consumer_name_in_content != params[:pacticipantName] + add_contract_error(validation_message('consumer_name_in_content_mismatch_pacticipant_name', { consumer_name_in_content: consumer_name_in_content, pacticipant_name: params[:pacticipantName] } ), i, errors) + end + end + + def self.validate_provider_name_in_content(contract, i, errors) + provider_name_in_content = contract.dig(:decodedParsedContent, :provider, :name) + if provider_name_in_content && provider_name_in_content != contract[:providerName] + add_contract_error(validation_message('provider_name_in_content_mismatch', { provider_name_in_content: provider_name_in_content, provider_name: contract[:providerName] } ), i, errors) + end + end + + def self.validate_encoding(contract, i, errors) + if contract[:decodedContent].nil? + add_contract_error(message('errors.base64?', scope: nil), i, errors) + end + end + + def self.validate_content_matches_content_type(contract, i, errors) + if contract[:decodedParsedContent].nil? && contract[:contentType] + add_contract_error(validation_message('invalid_content_for_content_type', { content_type: contract[:contentType]}), i, errors) + end + end + + + def self.add_contract_error(message, i, errors) + errors[:contracts] ||= {} + errors[:contracts][i] ||= [] + errors[:contracts][i] << message + errors + end + end + end + end +end diff --git a/lib/pact_broker/api/decorators/base_decorator.rb b/lib/pact_broker/api/decorators/base_decorator.rb index cb1ed57b3..719990401 100644 --- a/lib/pact_broker/api/decorators/base_decorator.rb +++ b/lib/pact_broker/api/decorators/base_decorator.rb @@ -15,8 +15,12 @@ class BaseDecorator < Roar::Decorator include FormatDateTime using PactBroker::StringRefinements + def self.camelize_property_names + @camelize = true + end + def self.property(name, options={}, &block) - if options.delete(:camelize) + if options.delete(:camelize) || @camelize camelized_name = name.to_s.camelcase(false).to_sym super(name, { as: camelized_name }.merge(options), &block) else diff --git a/lib/pact_broker/api/decorators/embedded_pacticipant_decorator.rb b/lib/pact_broker/api/decorators/embedded_pacticipant_decorator.rb new file mode 100644 index 000000000..345527027 --- /dev/null +++ b/lib/pact_broker/api/decorators/embedded_pacticipant_decorator.rb @@ -0,0 +1,17 @@ +require_relative 'base_decorator' + +module PactBroker + module Api + module Decorators + class EmbeddedPacticipantDecorator < BaseDecorator + camelize_property_names + + property :name + + link :self do | options | + pacticipant_url(options[:base_url], represented) + end + end + end + end +end diff --git a/lib/pact_broker/api/decorators/embedded_version_decorator.rb b/lib/pact_broker/api/decorators/embedded_version_decorator.rb index 4fa93589b..d86b593cb 100644 --- a/lib/pact_broker/api/decorators/embedded_version_decorator.rb +++ b/lib/pact_broker/api/decorators/embedded_version_decorator.rb @@ -4,9 +4,11 @@ module PactBroker module Api module Decorators class EmbeddedVersionDecorator < BaseDecorator + camelize_property_names property :number property :branch + property :build_url link :self do | options | { diff --git a/lib/pact_broker/api/decorators/pacticipant_decorator.rb b/lib/pact_broker/api/decorators/pacticipant_decorator.rb index c4830509d..35acb97fa 100644 --- a/lib/pact_broker/api/decorators/pacticipant_decorator.rb +++ b/lib/pact_broker/api/decorators/pacticipant_decorator.rb @@ -8,12 +8,14 @@ module PactBroker module Api module Decorators class PacticipantDecorator < BaseDecorator + camelize_property_names + property :name - property :display_name, camelize: true - property :repository_url, camelize: true - property :repository_name, camelize: true - property :repository_namespace, camelize: true - property :main_development_branches, camelize: true + property :display_name + property :repository_url + property :repository_name + property :repository_namespace + property :main_development_branches property :latest_version, as: :latestVersion, :class => PactBroker::Domain::Version, extend: PactBroker::Api::Decorators::EmbeddedVersionDecorator, embedded: true, writeable: false collection :labels, :class => PactBroker::Domain::Label, extend: PactBroker::Api::Decorators::EmbeddedLabelDecorator, embedded: true diff --git a/lib/pact_broker/api/decorators/publish_contract_decorator.rb b/lib/pact_broker/api/decorators/publish_contract_decorator.rb new file mode 100644 index 000000000..1bbbaf311 --- /dev/null +++ b/lib/pact_broker/api/decorators/publish_contract_decorator.rb @@ -0,0 +1,19 @@ +require 'pact_broker/api/decorators/base_decorator' +require 'pact_broker/api/decorators/timestamps' + +module PactBroker + module Api + module Decorators + class PublishContractDecorator < BaseDecorator + camelize_property_names + + property :consumer_name + property :provider_name + property :specification + property :content_type + property :decoded_content + property :write_mode, default: "overwrite" + end + end + end +end diff --git a/lib/pact_broker/api/decorators/publish_contracts_decorator.rb b/lib/pact_broker/api/decorators/publish_contracts_decorator.rb new file mode 100644 index 000000000..cfcc924cd --- /dev/null +++ b/lib/pact_broker/api/decorators/publish_contracts_decorator.rb @@ -0,0 +1,21 @@ +require 'pact_broker/api/decorators/base_decorator' +require 'pact_broker/api/decorators/publish_contract_decorator' +require 'pact_broker/contracts/contract_to_publish' + +module PactBroker + module Api + module Decorators + class PublishContractsDecorator < BaseDecorator + camelize_property_names + + property :pacticipant_name + property :pacticipant_version_number + property :tags + property :branch + property :build_url + + collection :contracts, :extend => PublishContractDecorator, class: PactBroker::Contracts::ContractToPublish + end + end + end +end diff --git a/lib/pact_broker/api/decorators/publish_contracts_results_decorator.rb b/lib/pact_broker/api/decorators/publish_contracts_results_decorator.rb new file mode 100644 index 000000000..ebaa200f0 --- /dev/null +++ b/lib/pact_broker/api/decorators/publish_contracts_results_decorator.rb @@ -0,0 +1,54 @@ +require 'pact_broker/api/decorators/base_decorator' +require 'pact_broker/api/decorators/publish_contract_decorator' +require 'pact_broker/api/decorators/embedded_version_decorator' + +module PactBroker + module Api + module Decorators + class PublishContractsResultsDecorator < BaseDecorator + camelize_property_names + + property :logs, getter: ->(represented:, **) { represented.logs.collect(&:to_h) } + + property :pacticipant, embedded: true, extend: EmbeddedPacticipantDecorator + property :version, embedded: true, extend: EmbeddedVersionDecorator + + link :'pb:pacticipant' do | options | + { + title: "Pacticipant", + name: represented.pacticipant.name, + href: pacticipant_url(options.fetch(:base_url), represented.pacticipant) + } + end + + link :'pb:pacticipant-version' do | options | + { + title: "Pacticipant version", + name: represented.version.number, + href: version_url(options.fetch(:base_url), represented.version) + } + end + + links :'pb:pacticipant-version-tags' do | options | + represented.tags.collect do | tag | + { + title: "Tag", + name: tag.name, + href: tag_url(options.fetch(:base_url), tag) + } + end + end + + links :'pb:contracts' do | options | + represented.contracts.collect do | contract | + { + title: 'Pact', + name: contract.name, + href: pact_url(options.fetch(:base_url), contract) + } + end + end + end + end + end +end diff --git a/lib/pact_broker/api/pact_broker_urls.rb b/lib/pact_broker/api/pact_broker_urls.rb index cd6dec89f..106ee3d55 100644 --- a/lib/pact_broker/api/pact_broker_urls.rb +++ b/lib/pact_broker/api/pact_broker_urls.rb @@ -264,7 +264,7 @@ def pact_triggered_webhooks_url pact, base_url = '' end def triggered_webhook_logs_url triggered_webhook, base_url - "#{base_url}/webhooks/#{triggered_webhook.webhook_uuid}/trigger/#{triggered_webhook.trigger_uuid}/logs" + "#{base_url}/triggered-webhooks/#{triggered_webhook.trigger_uuid}/logs" end def badge_url_for_latest_pact pact, base_url = '' diff --git a/lib/pact_broker/api/resources/index.rb b/lib/pact_broker/api/resources/index.rb index 5e72fbbb9..dae795ad7 100644 --- a/lib/pact_broker/api/resources/index.rb +++ b/lib/pact_broker/api/resources/index.rb @@ -31,6 +31,11 @@ def links title: 'Publish a pact', templated: true }, + 'pb:publish-contracts' => { + href: base_url + '/contracts/publish', + title: 'Publish contracts', + templated: false + }, 'pb:latest-pact-versions' => { href: base_url + '/pacts/latest', diff --git a/lib/pact_broker/api/resources/pact.rb b/lib/pact_broker/api/resources/pact.rb index bc49e7313..5963e053b 100644 --- a/lib/pact_broker/api/resources/pact.rb +++ b/lib/pact_broker/api/resources/pact.rb @@ -108,10 +108,6 @@ def policy_pacticipant def pact @pact ||= pact_service.find_pact(pact_params) end - - def pact_params - @pact_params ||= PactBroker::Pacts::PactParams.from_request request, path_info - end end end end diff --git a/lib/pact_broker/api/resources/publish_contracts.rb b/lib/pact_broker/api/resources/publish_contracts.rb new file mode 100644 index 000000000..036595724 --- /dev/null +++ b/lib/pact_broker/api/resources/publish_contracts.rb @@ -0,0 +1,81 @@ +require 'pact_broker/api/resources/base_resource' +require 'pact_broker/api/resources/webhook_execution_methods' +require 'pact_broker/contracts/contracts_to_publish' +require 'pact_broker/api/contracts/publish_contracts_schema' +require 'pact_broker/pacts/parse' + +module PactBroker + module Api + module Resources + class PublishContracts < BaseResource + include WebhookExecutionMethods + + def content_types_provided + [["application/hal+json", :to_json]] + end + + def content_types_accepted + [["application/json"]] + end + + def allowed_methods + ["POST", "OPTIONS"] + end + + def malformed_request? + if request.post? + invalid_json? || validation_errors_for_schema? + else + false + end + end + + def process_post + handle_webhook_events do + results = contract_service.publish(parsed_contracts, base_url: base_url) + response.body = decorator_class(:publish_contracts_results_decorator).new(results).to_json(decorator_options) + end + true + end + + def policy_name + :'contracts::contracts' + end + + # for Pactflow + def policy_record + @policy_record ||= pacticipant_service.find_pacticipant_by_name(parsed_contracts.pacticipant_name) + end + + private + + def parsed_contracts + @parsed_contracts ||= decorator_class(:publish_contracts_decorator).new(PactBroker::Contracts::ContractsToPublish.new).from_hash(params) + end + + def params + p = super(default: {}, symbolize_names: false) + if p["contracts"].is_a?(Array) + p["contracts"].each do | contract | + if contract.is_a?(Hash) + decode_and_parse_content(contract) + end + end + end + p + end + + def schema + PactBroker::Api::Contracts::PublishContractsSchema + end + + def decode_and_parse_content(contract) + contract["decodedContent"] = Base64.strict_decode64(contract["content"]) rescue nil + if contract["decodedContent"] + contract["decodedParsedContent"] = PactBroker::Pacts::Parse.call(contract["decodedContent"]) rescue nil + end + end + end + end + end +end diff --git a/lib/pact_broker/api/resources/triggered_webhook_logs.rb b/lib/pact_broker/api/resources/triggered_webhook_logs.rb index 630b59b30..b43b6c3bc 100644 --- a/lib/pact_broker/api/resources/triggered_webhook_logs.rb +++ b/lib/pact_broker/api/resources/triggered_webhook_logs.rb @@ -20,7 +20,11 @@ def resource_exists? def to_text # Too simple to bother putting into a service - triggered_webhook.webhook_executions.collect(&:logs).join("\n") + if triggered_webhook.webhook_executions.any? + triggered_webhook.webhook_executions.collect(&:logs).join("\n") + else + "Webhook has not executed yet. Please retry in a few seconds." + end end def policy_name @@ -35,7 +39,7 @@ def policy_record def triggered_webhook @triggered_webhook ||= begin - criteria = { webhook_uuid: identifier_from_path[:uuid], trigger_uuid: identifier_from_path[:trigger_uuid] } + criteria = { webhook_uuid: identifier_from_path[:uuid], trigger_uuid: identifier_from_path[:trigger_uuid] }.compact PactBroker::Webhooks::TriggeredWebhook.where(criteria).single_record end end diff --git a/lib/pact_broker/api/resources/webhook_execution_methods.rb b/lib/pact_broker/api/resources/webhook_execution_methods.rb index b536b1e2b..94aea1026 100644 --- a/lib/pact_broker/api/resources/webhook_execution_methods.rb +++ b/lib/pact_broker/api/resources/webhook_execution_methods.rb @@ -1,4 +1,5 @@ require 'pact_broker/webhooks/event_listener' +require 'pact_broker/events/subscriber' module PactBroker module Api @@ -20,7 +21,7 @@ def webhook_event_listener end def handle_webhook_events - Wisper.subscribe(webhook_event_listener) do + PactBroker::Events.subscribe(webhook_event_listener) do yield end end diff --git a/lib/pact_broker/contracts/contract_to_publish.rb b/lib/pact_broker/contracts/contract_to_publish.rb new file mode 100644 index 000000000..223beaafa --- /dev/null +++ b/lib/pact_broker/contracts/contract_to_publish.rb @@ -0,0 +1,17 @@ +module PactBroker + module Contracts + ContractToPublish = Struct.new(:consumer_name, :provider_name, :decoded_content, :content_type, :specification, :write_mode) do + def self.from_hash(consumer_name: nil, provider_name: nil, decoded_content: nil, content_type: nil, specification: nil, write_mode: nil) + new(consumer_name, provider_name, decoded_content, content_type, specification, write_mode) + end + + def pact? + specification == "pact" + end + + def merge? + write_mode == "merge" + end + end + end +end diff --git a/lib/pact_broker/contracts/contracts_publication_results.rb b/lib/pact_broker/contracts/contracts_publication_results.rb new file mode 100644 index 000000000..301aecb56 --- /dev/null +++ b/lib/pact_broker/contracts/contracts_publication_results.rb @@ -0,0 +1,14 @@ +module PactBroker + module Contracts + ContractsPublicationResults = Struct.new(:pacticipant, :version, :tags, :contracts, :logs) do + def self.from_hash(params) + new(params[:pacticipant], + params[:version], + params[:tags], + params[:contracts], + params[:logs] + ) + end + end + end +end diff --git a/lib/pact_broker/contracts/contracts_to_publish.rb b/lib/pact_broker/contracts/contracts_to_publish.rb new file mode 100644 index 000000000..7c0a256a3 --- /dev/null +++ b/lib/pact_broker/contracts/contracts_to_publish.rb @@ -0,0 +1,9 @@ +module PactBroker + module Contracts + ContractsToPublish = Struct.new(:pacticipant_name, :pacticipant_version_number, :tags, :branch, :build_url, :contracts) do + def self.from_hash(pacticipant_name: nil, pacticipant_version_number: nil, tags: nil, branch: nil, build_url: nil, contracts: nil) + new(pacticipant_name, pacticipant_version_number, tags, branch, build_url, contracts) + end + end + end +end diff --git a/lib/pact_broker/contracts/log_message.rb b/lib/pact_broker/contracts/log_message.rb new file mode 100644 index 000000000..d6a3689ae --- /dev/null +++ b/lib/pact_broker/contracts/log_message.rb @@ -0,0 +1,17 @@ +module PactBroker + module Contracts + LogMessage = Struct.new(:level, :message) do + def self.info(message) + LogMessage.new("info", message) + end + + def self.warn(message) + LogMessage.new("warn", message) + end + + def self.debug(message) + LogMessage.new("debug", message) + end + end + end +end diff --git a/lib/pact_broker/contracts/service.rb b/lib/pact_broker/contracts/service.rb new file mode 100644 index 000000000..17117f438 --- /dev/null +++ b/lib/pact_broker/contracts/service.rb @@ -0,0 +1,220 @@ +require 'pact_broker/logging' +require 'pact_broker/repositories' +require 'pact_broker/services' +require 'pact_broker/messages' +require 'pact_broker/contracts/contracts_publication_results' +require 'pact_broker/contracts/log_message' +require 'pact_broker/events/subscriber' +require 'pact_broker/api/pact_broker_urls' + +module PactBroker + module Contracts + module Service + extend self + extend PactBroker::Repositories + extend PactBroker::Services + include PactBroker::Logging + extend PactBroker::Messages + + class TriggeredWebhooksCreatedListener + attr_reader :detected_events + + def initialize + @detected_events = [] + end + + def triggered_webhooks_created_for_event(params) + detected_events << params.fetch(:event) + end + end + + def publish(parsed_contracts, base_url: ) + version, version_logs = create_version(parsed_contracts) + tags = create_tags(parsed_contracts, version) + pacts, pact_logs = create_pacts(parsed_contracts, base_url) + logs = version_logs + pact_logs + ContractsPublicationResults.from_hash( + pacticipant: version.pacticipant, + version: version, + tags: tags, + contracts: pacts, + logs: logs + ) + end + + # private + + def create_version(parsed_contracts) + version_params = { + build_url: parsed_contracts.build_url, + branch: parsed_contracts.branch + }.compact + + existing_version = find_existing_version(parsed_contracts) + version = create_or_update_version(parsed_contracts, version_params) + + message = log_message_for_version_creation(existing_version, parsed_contracts) + + logs = [message] + return version, logs + end + + def find_existing_version(parsed_contracts) + version_service.find_by_pacticipant_name_and_number( + pacticipant_name: parsed_contracts.pacticipant_name, + pacticipant_version_number: parsed_contracts.pacticipant_version_number + ) + end + + def create_or_update_version(parsed_contracts, version_params) + version_service.create_or_update( + parsed_contracts.pacticipant_name, + parsed_contracts.pacticipant_version_number, + OpenStruct.new(version_params) + ) + end + + def create_tags(parsed_contracts, version) + (parsed_contracts.tags || []).collect do | tag_name | + tag_repository.create(version: version, name: tag_name) + end + end + + def create_pacts(parsed_contracts, base_url) + logs = [] + pacts = parsed_contracts.contracts.select(&:pact?).collect do | contract_to_publish | + pact_params = create_pact_params(parsed_contracts, contract_to_publish) + existing_pact = pact_service.find_pact(pact_params) + listener = TriggeredWebhooksCreatedListener.new + created_pact = create_or_merge_pact(contract_to_publish.merge?, existing_pact, pact_params, listener) + logs << log_mesage_for_pact_publication(parsed_contracts, contract_to_publish.merge?, existing_pact, created_pact) + logs << log_message_for_pact_url(created_pact, base_url) + logs.concat(event_and_webhook_logs(listener, created_pact)) + logs.concat(next_steps_logs(created_pact)) + created_pact + end + return pacts, logs + end + + def create_pact_params(parsed_contracts, contract_to_publish) + PactBroker::Pacts::PactParams.new( + consumer_name: parsed_contracts.pacticipant_name, + provider_name: contract_to_publish.provider_name, + consumer_version_number: parsed_contracts.pacticipant_version_number, + json_content: contract_to_publish.decoded_content + ) + end + + def create_or_merge_pact(merge, existing_pact, pact_params, listener) + PactBroker::Events.subscribe(listener) do + if merge && existing_pact + pact_service.merge_pact(pact_params) + else + pact_service.create_or_update_pact(pact_params) + end + end + end + + def log_message_for_version_creation(existing_version, parsed_contracts) + message_params = parsed_contracts.to_h + if parsed_contracts.tags&.any? + message_params[:tags] = parsed_contracts.tags.join(", ") + end + message_params[:action] = existing_version ? "Updated" : "Created" + LogMessage.debug(message(log_message_key_for_version_creation(parsed_contracts), message_params)) + end + + def log_message_key_for_version_creation(parsed_contracts) + if parsed_contracts.branch && parsed_contracts.tags&.any? + "messages.version.created_for_branch_with_tags" + elsif parsed_contracts.branch + "messages.version.created_for_branch" + elsif parsed_contracts.tags&.any? + "messages.version.created_with_tags" + else + "messages.version.created" + end + end + + def log_mesage_for_pact_publication(parsed_contracts, merge, existing_pact, created_pact) + log_message_params = { + consumer_name: parsed_contracts.pacticipant_name, + consumer_version_number: parsed_contracts.pacticipant_version_number, + provider_name: created_pact.provider_name + } + if merge + if existing_pact + LogMessage.info(message("messages.contract.pact_merged", log_message_params)) + else + LogMessage.info(message("messages.contract.pact_published", log_message_params)) + end + else + if existing_pact + if existing_pact.pact_version_sha != created_pact.pact_version_sha + LogMessage.warn(message("messages.contract.pact_modified_for_same_version", log_message_params)) + else + LogMessage.info(message("messages.contract.same_pact_content_published", log_message_params)) + end + else + LogMessage.info(message("messages.contract.pact_published", log_message_params)) + end + end + end + + def log_message_for_pact_url(pact, base_url) + LogMessage.debug(" View the published pact at #{PactBroker::Api::PactBrokerUrls.pact_url(base_url, pact)}") + end + + def event_and_webhook_logs(listener, pact) + event_descriptions(listener) + triggered_webhook_logs(listener, pact) + end + + def event_descriptions(listener) + event_descriptions = listener.detected_events.collect{ | event | event.name + (event.comment ? " (#{event.comment})" : "") } + if event_descriptions.any? + [LogMessage.debug(" Events detected: " + event_descriptions.join(", "))] + else + [] + end + end + + # TODO add can-i-deploy and record-deployment + def next_steps_logs(pact) + logs = [] + if !verification_service.any_verifications?(pact.consumer, pact.provider) + logs << LogMessage.warn(" * " + message("messages.next_steps.verifications", provider_name: pact.provider_name)) + end + + if !webhook_service.find_for_pact(pact).any? + logs << LogMessage.warn(" * " + message("messages.next_steps.webhooks", provider_name: pact.provider_name)) + end + + if logs.any? + logs.unshift(LogMessage.warn(" Next steps:")) + end + + logs + end + + def triggered_webhook_logs(listener, pact) + triggered_webhooks = listener.detected_events.flat_map(&:triggered_webhooks) + if triggered_webhooks.any? + triggered_webhooks.collect do | triggered_webhook | + base_url = triggered_webhook.event_context[:base_url] + triggered_webhooks_logs_url = PactBroker::Api::PactBrokerUrls.triggered_webhook_logs_url(triggered_webhook, base_url) + text_2_params = { webhook_description: triggered_webhook.webhook.description&.inspect || triggered_webhook.webhook_uuid, event_name: triggered_webhook.event_name } + text_1 = message("messages.webhooks.webhook_triggered_for_event", text_2_params) + text_2 = message("messages.webhooks.triggered_webhook_see_logs", url: triggered_webhooks_logs_url) + LogMessage.debug(" #{text_1}\n #{text_2}") + end + else + if webhook_service.find_for_pact(pact).any? + [LogMessage.debug(" " + message("messages.webhooks.no_webhooks_enabled_for_event"))] + else + [] + end + end + end + end + end +end diff --git a/lib/pact_broker/doc/views/index/publish-contracts.markdown b/lib/pact_broker/doc/views/index/publish-contracts.markdown new file mode 100644 index 000000000..cd4c83d6e --- /dev/null +++ b/lib/pact_broker/doc/views/index/publish-contracts.markdown @@ -0,0 +1,120 @@ +# Publish Contracts + +Allowed methods: `POST` + +Path: `/contracts/publish` + +This is the preferred endpoint with which to publish contracts (previously, contracts were published using multiple calls to different endpoints to create the tag and contract resources). To detect whether this endpoint exists in a particular version of the Pact Broker, make a request to the index resource, and locate the `pb:publish-contracts` relation. Do a `POST` to the href specified for that relation. + +The previous tag and pact endpoints are still supported, however, future features that build on this endpoint may not be able to be backported into those endpoints (eg. publishing pacts with a branch). + +## Parameters +* `pacticipantName`: the name of the application. Required. +* `pacticipantVersionNumber`: the version number of the application. Required. It is recommended that this should be or include the git SHA. See [http://docs.pact.io/versioning](http://docs.pact.io/versioning). +* `branch`: The git branch name. Optional but strongly recommended. +* `tags`: The consumer version tags. Use of the branch parameter is preferred now. Optional. +* `buildUrl`: The CI/CD build URL. Optional. +* `contracts` + * `consumerName`: the name of the consumer. Required. Must match the pacticipant name and the consumer name inside the pact. While this field may seem redundant currently, this endpoint will be extended to support publication of provider generated, non-pact contracts, and the consumerName and providerName fields will be used to indicate which role the pacticipant is taking in the contract. + * `providerName`: the name of the provider. Required. + * `specification`: currently, only contracts of type "pact" are supported, but this will be extended in the future. Required. + * `contentType`: currently, only contracts with a content type of "application/json" are supported. Required. + * `content`: the content of the contract. Must be Base64 encoded. Required. + * `writeMode`: Allowed values are `overwrite`|`merge`. Optional. Defaults to `overwrite`. When `merge` is specified, the interactions are merged into any pre-existing pact for the same consumer/provider/consumer version. This is required when the tests that generate pact files are split over multiple nodes. + +## Example + + POST http://broker/contracts/publish + { + "pacticipantName": "Foo", + "pacticipantVersionNumber": "dc5eb529230038a4673b8c971395bd2922d8b240", + "branch": "main", + "tags": ["main"], + "buildUrl": "https://ci/builds/1234", + "contracts": [ + { + "consumerName": "Foo", + "providerName": "Bar", + "specification": "pact", + "contentType": "application/json", + "content": "", + "writeMode": "overwrite" + } + ] + } + + { + { + "logs": [ + { + "level": "debug", + "message": "Created Foo version dc5eb529230038a4673b8c971395bd2922d8b240 with branch main and tags main" + }, + { + "level": "info", + "message": "Pact published for Foo version dc5eb529230038a4673b8c971395bd2922d8b240 and provider Bar." + }, + { + "level": "debug", + "message": " Events detected: contract_published, contract_content_changed (first time any pact published for this consumer with consumer version tagged main)" + }, + { + "level": "debug", + "message": " Webhook \"foo webhook\" triggered for event contract_content_changed.\n See logs at http://example.org/triggered-webhooks/1234/logs\"" + } + ], + "_embedded": { + "pacticipant": { + "name": "Foo", + "_links": { + "self": { + "href": "http://example.org/pacticipants/Foo" + } + } + }, + "version": { + "number": "1", + "branch": "main", + "buildUrl": "http://ci/builds/1234", + "_links": { + "self": { + "title": "Version", + "name": "dc5eb529230038a4673b8c971395bd2922d8b240", + "href": "http://example.org/pacticipants/Foo/versions/dc5eb529230038a4673b8c971395bd2922d8b240" + } + } + } + }, + "_links": { + "pb:pacticipant": { + "title": "Pacticipant", + "name": "Foo", + "href": "http://example.org/pacticipants/Foo" + }, + "pb:pacticipant-version": { + "title": "Pacticipant version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/dc5eb529230038a4673b8c971395bd2922d8b240" + }, + "pb:pacticipant-version-tags": [ + { + "title": "Tag", + "name": "a", + "href": "http://example.org/pacticipants/Foo/versions/dc5eb529230038a4673b8c971395bd2922d8b240/tags/a" + }, + { + "title": "Tag", + "name": "b", + "href": "http://example.org/pacticipants/Foo/versions/dc5eb529230038a4673b8c971395bd2922d8b240/tags/b" + } + ], + "pb:contracts": [ + { + "title": "Pact", + "name": "Pact between Foo (dc5eb529230038a4673b8c971395bd2922d8b240) and Bar", + "href": "http://example.org/pacts/provider/Bar/consumer/Foo/version/dc5eb529230038a4673b8c971395bd2922d8b240" + } + ] + } + } + } diff --git a/lib/pact_broker/events/publisher.rb b/lib/pact_broker/events/publisher.rb new file mode 100644 index 000000000..aa52f40b4 --- /dev/null +++ b/lib/pact_broker/events/publisher.rb @@ -0,0 +1,9 @@ +require 'wisper' + +module PactBroker + module Events + module Publisher + include Wisper::Publisher + end + end +end diff --git a/lib/pact_broker/events/subscriber.rb b/lib/pact_broker/events/subscriber.rb new file mode 100644 index 000000000..720a5227d --- /dev/null +++ b/lib/pact_broker/events/subscriber.rb @@ -0,0 +1,42 @@ +require 'wisper' + +# The Wisper implementation of temporary listeners clears all listeners at the end of the block, +# rather the just the ones that were supplied in block. This implementation just clears the specified ones, +# allowing multiple temporary overlapping listeners. + +module PactBroker + module Events + class TemporaryListeners < Wisper::TemporaryListeners + def subscribe(*listeners) + options = listeners.last.is_a?(Hash) ? listeners.pop : {} + begin + listeners.each { |listener| registrations << Wisper::ObjectRegistration.new(listener, options) } + yield + ensure + unsubscribe(listeners) + end + self + end + + def unsubscribe(listeners) + registrations.delete_if do |registration| + listeners.include?(registration.listener) + end + end + end + end +end + +module PactBroker + module Events + extend self + + def subscribe(*args) + result = nil + TemporaryListeners.subscribe(*args) do + result = yield + end + result + end + end +end diff --git a/lib/pact_broker/locale/en.yml b/lib/pact_broker/locale/en.yml index 28d8eefee..ff6121a40 100644 --- a/lib/pact_broker/locale/en.yml +++ b/lib/pact_broker/locale/en.yml @@ -4,6 +4,7 @@ en: filled?: "can't be blank" valid_method?: "is not a recognised HTTP method" valid_url?: "is not a valid URL eg. http://example.org" + base64?: "is not valid Base64" valid_version_number?: "Version number '%{value}' cannot be parsed to a version number. The expected format (unless this configuration has been overridden) is a semantic version. eg. 1.3.0 or 2.0.4.rc1" name_in_path_matches_name_in_pact?: "does not match %{left} name in path ('%{right}')." valid_consumer_version_number?: "Consumer version number '%{value}' cannot be parsed to a version number. The expected format (unless this configuration has been overridden) is a semantic version. eg. 1.3.0 or 2.0.4.rc1" @@ -33,7 +34,26 @@ en: verificationResultUrl: the URL to the relevant verification result. githubVerificationStatus: the verification status using the correct keywords for posting to the Github commit status API. See https://developer.github.com/v3/repos/statuses. bitbucketVerificationStatus: the verification status using the correct keywords for posting to the Bitbucket commit status API. See https://developer.atlassian.com/server/bitbucket/how-tos/updating-build-status-for-commits/. + no_webhooks_enabled_for_event: No enabled webhooks found for the detected events + webhook_triggered_for_event: Webhook %{webhook_description} triggered for event %{event_name}. + triggered_webhook_see_logs: View logs at %{url} + version: + created: "%{action} %{pacticipant_name} version %{pacticipant_version_number}" + created_for_branch: "%{action} %{pacticipant_name} version %{pacticipant_version_number} with branch %{branch}" + created_for_branch_with_tags: "%{action} %{pacticipant_name} version %{pacticipant_version_number} with branch %{branch} and tags %{tags}" + created_with_tags: "%{action} %{pacticipant_name} version %{pacticipant_version_number} with tags %{tags}" + contract: + pact_published: Pact successfully published for %{consumer_name} version %{consumer_version_number} and provider %{provider_name}. + same_pact_content_published: Pact successfully republished for %{consumer_name} version %{consumer_version_number} and provider %{provider_name} with no content changes. + pact_modified_for_same_version: Pact with changed content published over existing content for %{consumer_name} version %{consumer_version_number} and provider %{provider_name}. This is not recommended in normal cicumstances and may indicate that you have not configured your Pact pipeline correctly. For more information see https://docs.pact.io/go/versioning + pact_merged: Pact content merged with existing content for %{consumer_name} version %{consumer_version_number} and provider %{provider_name}. + events: + pact_published_unchanged_with_single_tag: pact content is the same as previous version with tag %{tag_name} and no new tags were applied + pact_published_unchanged_with_multiple_tags: pact content is the same as previous versions with tags %{tag_names} and no new tags were applied + next_steps: + verifications: Add Pact verification tests to the %{provider_name} build. See https://docs.pact.io/go/provider_verification + webhooks: Configure separate %{provider_name} pact verification build and webhook to trigger it when the pact content changes. See https://docs.pact.io/go/webhooks errors: validation: blank: "cannot be blank." @@ -45,6 +65,9 @@ en: consumer_version_number_header_invalid: "X-Pact-Consumer-Version '%{consumer_version_number}' cannot be parsed to a version number. The expected format (unless this configuration has been overridden) is a semantic version. eg. 1.3.0 or 2.0.4.rc1" consumer_version_number_invalid: "Consumer version number '%{consumer_version_number}' cannot be parsed to a version number. The expected format (unless this configuration has been overridden) is a semantic version. eg. 1.3.0 or 2.0.4.rc1" pacticipant_name_mismatch: "in pact ('%{name_in_pact}') does not match %{pacticipant} name in path ('%{name}')." + consumer_name_in_content_mismatch_pacticipant_name: "consumer name in contract content ('%{consumer_name_in_content}') must match pacticipantName ('%{pacticipant_name}')" + consumer_name_in_contract_mismatch_pacticipant_name: "consumerName ('%{consumer_name_in_contract}') must match pacticipantName ('%{pacticipant_name}')" + provider_name_in_content_mismatch: "provider name in contract content ('%{provider_name_in_content}') must match providerName ('%{provider_name}') in contracts" connection_encoding_not_utf8: "The Sequel connection encoding (%{encoding}) is strongly recommended to be \"utf8\". If you need to set it to %{encoding} for some particular reason, then disable this check by setting config.validate_database_connection_config = false" invalid_webhook_uuid: The UUID can only contain the characters A-Z, a-z, 0-9, _ and -, and must be 16 or more characters. pacticipant_not_found: No pacticipant with name '%{name}' found @@ -53,6 +76,7 @@ en: cannot_specify_latest_and_environment: Cannot specify both latest=true and an environment. environment_with_name_not_found: "Environment with name '%{name}' does not exist" cannot_modify_version_branch: "The branch for a pacticipant version cannot be changed once set (currently '%{old_branch}', proposed value '%{new_branch}'). Your pact publication/verification publication configurations may be in conflict with each other if you are seeing this error. If the current branch value is incorrect, you must delete the pacticipant version resource at %{version_url} and publish the pacts/verification results again with a consistent branch name." + invalid_content_for_content_type: "The content could not be parsed as %{content_type}" duplicate_pacticipant: | This is the first time a pact has been published for "%{new_name}". The name "%{new_name}" is very similar to the following existing consumers/providers: diff --git a/lib/pact_broker/messages.rb b/lib/pact_broker/messages.rb index f46b3991d..fa70768e9 100644 --- a/lib/pact_broker/messages.rb +++ b/lib/pact_broker/messages.rb @@ -16,7 +16,7 @@ module Messages # variables to interpolate. # @return [String] the interpolated string def message(key, options={}) - ::I18n.t(key, options.merge(:scope => :pact_broker)) + ::I18n.t(key, { :scope => :pact_broker }.merge(options)) end def validation_message key, options = {} diff --git a/lib/pact_broker/pacts/service.rb b/lib/pact_broker/pacts/service.rb index 2601cb744..7533ecdb7 100644 --- a/lib/pact_broker/pacts/service.rb +++ b/lib/pact_broker/pacts/service.rb @@ -4,18 +4,20 @@ require 'pact_broker/pacts/merger' require 'pact_broker/pacts/verifiable_pact' require 'pact_broker/pacts/squash_pacts_for_verification' -require 'wisper' +require 'pact_broker/events/publisher' +require 'pact_broker/messages' module PactBroker module Pacts module Service extend self - extend Wisper::Publisher + extend PactBroker::Events::Publisher extend PactBroker::Repositories extend PactBroker::Services include PactBroker::Logging + extend PactBroker::Messages extend SquashPactsForVerification def find_latest_pact params @@ -156,9 +158,9 @@ def update_pact params, existing_pact broadcast(:contract_published, event_params) if existing_pact.pact_version_sha != updated_pact.pact_version_sha - broadcast(:contract_content_changed, event_params.merge(event_comment: "Pact content modified since previous revision")) + broadcast(:contract_content_changed, event_params.merge(event_comment: "pact content modified since previous publication for #{updated_pact.consumer_name} version #{updated_pact.consumer_version_number}")) else - broadcast(:contract_content_unchanged, event_params.merge(event_comment: "Pact content was unchanged")) + broadcast(:contract_content_unchanged, event_params.merge(event_comment: "pact content was unchanged")) end updated_pact @@ -181,13 +183,13 @@ def create_pact params, version, provider ) event_params = { event_context: { consumer_version_tags: pact.consumer_version_tag_names }, pact: pact } - broadcast(:contract_published, event_params) - content_changed, explanation = pact_is_new_or_newly_tagged_or_pact_has_changed_since_previous_version?(pact) + if content_changed + broadcast(:contract_published, event_params) broadcast(:contract_content_changed, event_params.merge(event_comment: explanation)) else - broadcast(:contract_content_unchanged, event_params.merge(event_comment: "Pact content the same as previous version and no new tags were applied")) + broadcast(:contract_published, event_params.merge(event_comment: contract_published_event_comment(pact))) end pact @@ -228,23 +230,31 @@ def explanation_for_content_changed(changed_pacts) messages = changed_pacts.collect do |tag, previous_pact| if tag == :untagged if previous_pact - "Pact content has changed since previous untagged version" + "pact content has changed since previous untagged version" else - "First time untagged pact published" + "first time untagged pact published" end else if previous_pact - "Pact content has changed since the last consumer version tagged with #{tag}" + "pact content has changed since the last consumer version tagged with #{tag}" else - "First time pact published with consumer version tagged #{tag}" + "first time any pact published for this consumer with consumer version tagged #{tag}" end end end - messages.join(',') + messages.join(', ') end end private :explanation_for_content_changed + + def contract_published_event_comment pact + if pact.consumer_version_tag_names.count == 1 + message("messages.events.pact_published_unchanged_with_single_tag", tag_name: pact.consumer_version_tag_names.first) + else + message("messages.events.pact_published_unchanged_with_multiple_tags", tag_names: pact.consumer_version_tag_names.join(", ")) + end + end end end end diff --git a/lib/pact_broker/pacts/verifiable_pact_messages.rb b/lib/pact_broker/pacts/verifiable_pact_messages.rb index 57836e437..cce61bc4f 100644 --- a/lib/pact_broker/pacts/verifiable_pact_messages.rb +++ b/lib/pact_broker/pacts/verifiable_pact_messages.rb @@ -7,8 +7,8 @@ class VerifiablePactMessages extend Forwardable include PactBroker::Messages - READ_MORE_PENDING = "Read more at https://pact.io/pending" - READ_MORE_WIP = "Read more at https://pact.io/wip" + READ_MORE_PENDING = "Read more at https://docs.pact.io/go/pending" + READ_MORE_WIP = "Read more at https://docs.pact.io/go/wip" delegate [:consumer_name, :provider_name, :consumer_version_number, :pending_provider_tags, :non_pending_provider_tags, :provider_branch, :pending?, :wip?] => :verifiable_pact diff --git a/lib/pact_broker/services.rb b/lib/pact_broker/services.rb index e99723d03..f0a47e4f5 100644 --- a/lib/pact_broker/services.rb +++ b/lib/pact_broker/services.rb @@ -81,6 +81,10 @@ def deployed_version_service get(:deployed_version_service) end + def contract_service + get(:contract_service) + end + def register_default_services register_service(:index_service) do require 'pact_broker/index/service' @@ -166,6 +170,11 @@ def register_default_services require 'pact_broker/deployments/deployed_version_service' PactBroker::Deployments::DeployedVersionService end + + register_service(:contract_service) do + require 'pact_broker/contracts/service' + PactBroker::Contracts::Service + end end end end diff --git a/lib/pact_broker/verifications/repository.rb b/lib/pact_broker/verifications/repository.rb index 0facc1bdb..baf7369d2 100644 --- a/lib/pact_broker/verifications/repository.rb +++ b/lib/pact_broker/verifications/repository.rb @@ -62,6 +62,10 @@ def find_latest_for_pact(pact) PactBroker::Pacts::PactPublication.where(id: pact.id).single_record.latest_verification end + def any_verifications?(consumer, provider) + PactBroker::Domain::Verification.where(consumer_id: consumer.id, provider_id: provider.id).any? + end + def search_for_latest consumer_name, provider_name query = LatestVerificationForPactVersion .select_all_qualified diff --git a/lib/pact_broker/verifications/service.rb b/lib/pact_broker/verifications/service.rb index 19cd1081d..35fff7ce7 100644 --- a/lib/pact_broker/verifications/service.rb +++ b/lib/pact_broker/verifications/service.rb @@ -1,22 +1,26 @@ +require 'delegate' require 'pact_broker/repositories' require 'pact_broker/api/decorators/verification_decorator' require 'pact_broker/verifications/summary_for_consumer_version' require 'pact_broker/logging' require 'pact_broker/hash_refinements' -require 'wisper' +require 'pact_broker/events/publisher' module PactBroker - module Verifications module Service + extend Forwardable + extend self extend PactBroker::Repositories extend PactBroker::Services include PactBroker::Logging using PactBroker::HashRefinements - extend Wisper::Publisher + extend PactBroker::Events::Publisher + + delegate [:any_verifications?] => :verification_repository def next_number verification_repository.next_number diff --git a/lib/pact_broker/webhooks/event_listener.rb b/lib/pact_broker/webhooks/event_listener.rb index b7fd2e6db..a7fc168de 100644 --- a/lib/pact_broker/webhooks/event_listener.rb +++ b/lib/pact_broker/webhooks/event_listener.rb @@ -1,12 +1,14 @@ require 'pact_broker/services' require 'pact_broker/events/event' require 'pact_broker/logging' +require 'pact_broker/events/publisher' module PactBroker module Webhooks class EventListener include PactBroker::Services include PactBroker::Logging + include PactBroker::Events::Publisher def initialize(webhook_options) @webhook_options = webhook_options @@ -70,11 +72,13 @@ def handle_event_for_webhook(event_name, params) event_name, base_webhook_context.merge(params.fetch(:event_context)) ) - detected_events << PactBroker::Events::Event.new( + event = PactBroker::Events::Event.new( event_name, params[:event_comment], triggered_webhooks ) + detected_events << event + broadcast(:triggered_webhooks_created_for_event, event: event) log_detected_event end end diff --git a/lib/pact_broker/webhooks/repository.rb b/lib/pact_broker/webhooks/repository.rb index 6e11576ba..a345edfaa 100644 --- a/lib/pact_broker/webhooks/repository.rb +++ b/lib/pact_broker/webhooks/repository.rb @@ -203,6 +203,10 @@ def fail_retrying_triggered_webhooks TriggeredWebhook.retrying.update(status: TriggeredWebhook::STATUS_FAILURE) end + def any_webhooks_exist? + scope_for(Webhook).any? + end + private def scope_for(scope) diff --git a/lib/rack/pact_broker/convert_404_to_hal.rb b/lib/rack/pact_broker/convert_404_to_hal.rb index 13e6456e2..48e2b735c 100644 --- a/lib/rack/pact_broker/convert_404_to_hal.rb +++ b/lib/rack/pact_broker/convert_404_to_hal.rb @@ -10,7 +10,7 @@ def call env response = @app.call(env) if response.first == 404 && response[1]['Content-Type'] == 'text/html' && !(env['HTTP_ACCEPT'] =~ /html|javascript|css/) - [404, { 'Content-Type' => 'application/hal+json'},[]] + [404, { 'Content-Type' => 'application/hal+json;charset=utf-8'},[]] else response end diff --git a/pact_broker.gemspec b/pact_broker.gemspec index 574aa0830..2c07a9fe4 100644 --- a/pact_broker.gemspec +++ b/pact_broker.gemspec @@ -64,5 +64,5 @@ Gem::Specification.new do |gem| gem.add_runtime_dependency 'table_print', '~> 1.5' gem.add_runtime_dependency 'semantic_logger', '~> 4.3' gem.add_runtime_dependency 'sanitize', '>= 5.2.1', '~> 5.2' - gem.add_runtime_dependency 'wisper', '2.0.0' + gem.add_runtime_dependency 'wisper', '~> 2.0' end diff --git a/script/approval-all.sh b/script/approval-all.sh new file mode 100755 index 000000000..5eecca097 --- /dev/null +++ b/script/approval-all.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +for file in $(find spec/fixtures/approvals -ipath "*.received.*"); do + approved_path=$(echo "$file" | sed 's/received/approved/') + mv "$file" "$approved_path" +done; diff --git a/spec/features/publish_pact_all_in_one_approval_spec.rb b/spec/features/publish_pact_all_in_one_approval_spec.rb new file mode 100644 index 000000000..b52c8814a --- /dev/null +++ b/spec/features/publish_pact_all_in_one_approval_spec.rb @@ -0,0 +1,76 @@ +require 'pact_broker/hash_refinements' +require 'pact_broker/string_refinements' + +RSpec.describe "publishing a pact using the all in one endpoint" do + using PactBroker::HashRefinements + using PactBroker::StringRefinements + # TODO merge branches + let(:request_body_hash) do + { + :pacticipantName => "Foo", + :pacticipantVersionNumber => "1", + :branch => "main", + :tags => ["a", "b"], + :buildUrl => "http://ci/builds/1234", + :contracts => [ + { + :consumerName => "Foo", + :providerName => "Bar", + :specification => "pact", + :contentType => "application/json", + :content => encoded_contract, + :writeMode => "overwrite", + } + ] + } + end + let(:rack_headers) { { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/hal+json" } } + let(:contract) { { consumer: { name: "Foo" }, provider: { name: "Bar" }, interactions: [] }.to_json } + let(:encoded_contract) { Base64.strict_encode64(contract) } + let(:path) { "/contracts/publish" } + let(:request_headers) do + rack_headers.each_with_object({}) do |(name, value), converted_headers| + env_key = name.gsub(/^HTTP_/, '').split('_').collect{ |w| w.downcase.camelcase(true) }.join("-") + converted_headers[env_key] = value + end + end + let(:fixture) do + { + request: { path: path, headers: request_headers, body: request_body_hash }, + response: { status: subject.status, headers: subject.headers.without("Date", "Server"), body: JSON.parse(subject.body)} + } + end + + subject { post(path, request_body_hash.to_json, rack_headers) } + + it { is_expected.to be_a_hal_json_success_response } + + context "with no webhooks" do + it { Approvals.verify(fixture, :name => "publish_contract_nothing_exists", format: :json) } + end + + context "with a webhooks that gets triggered" do + before do + allow(PactBroker::Webhooks::TriggerService).to receive(:next_uuid).and_return("1234") + td.create_global_webhook(description: "foo webhook") + end + + it { Approvals.verify(fixture, :name => "publish_contract_nothing_exists_with_webhook", format: :json) } + end + + context "with a validation error" do + before do + request_body_hash.delete(:pacticipantVersionNumber) + end + + it { Approvals.verify(fixture, :name => "publish_contract_with_validation_error", format: :json) } + end + + context "when a verification already exists for the consumer/provider" do + before do + td.create_pact_with_verification("Foo", "1", "Bar", "2") + end + + it { Approvals.verify(fixture, :name => "publish_contract_verification_already_exists", format: :json) } + end +end diff --git a/spec/features/publish_pact_all_in_one_spec.rb b/spec/features/publish_pact_all_in_one_spec.rb new file mode 100644 index 000000000..33dc52506 --- /dev/null +++ b/spec/features/publish_pact_all_in_one_spec.rb @@ -0,0 +1,43 @@ +RSpec.describe "publishing a pact using the all in one endpoint" do + # TODO merge branches + let(:request_body_hash) do + { + :pacticipantName => "Foo", + :pacticipantVersionNumber => "1", + :branch => "main", + :tags => ["a", "b"], + :buildUrl => "http://ci/builds/1234", + :contracts => [ + { + :consumerName => "Foo", + :providerName => "Bar", + :specification => "pact", + :contentType => "application/json", + :content => encoded_contract, + :writeMode => "overwrite", + } + ] + } + end + let(:rack_headers) { { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/hal+json" } } + let(:contract) { { consumer: { name: "Foo" }, provider: { name: "Bar" }, interactions: [] }.to_json } + let(:encoded_contract) { Base64.strict_encode64(contract) } + let(:path) { "/contracts/publish" } + + subject { post(path, request_body_hash.to_json, rack_headers) } + + it { is_expected.to be_a_hal_json_success_response } + + it "creates a pact" do + expect { subject }.to change { PactBroker::Pacts::PactPublication.count }.by(1) + expect(PactBroker::Pacts::PactVersion.last.content).to eq contract + end + + context "with a validation error" do + before do + request_body_hash.delete(:pacticipantName) + end + + it { is_expected.to be_a_json_error_response("missing") } + end +end diff --git a/spec/fixtures/approvals/modifiable_resources.approved.json b/spec/fixtures/approvals/modifiable_resources.approved.json index 2b707e77b..33989d462 100644 --- a/spec/fixtures/approvals/modifiable_resources.approved.json +++ b/spec/fixtures/approvals/modifiable_resources.approved.json @@ -55,6 +55,9 @@ { "resource_class_name": "PactBroker::Api::Resources::ProviderPactsForVerification" }, + { + "resource_class_name": "PactBroker::Api::Resources::PublishContracts" + }, { "resource_class_name": "PactBroker::Api::Resources::Tag" }, diff --git a/spec/fixtures/approvals/publish_contract_nothing_exists.approved.json b/spec/fixtures/approvals/publish_contract_nothing_exists.approved.json new file mode 100644 index 000000000..49467744f --- /dev/null +++ b/spec/fixtures/approvals/publish_contract_nothing_exists.approved.json @@ -0,0 +1,121 @@ +{ + "request": { + "path": "/contracts/publish", + "headers": { + "Content-Type": "application/json", + "Accept": "application/hal+json" + }, + "body": { + "pacticipantName": "Foo", + "pacticipantVersionNumber": "1", + "branch": "main", + "tags": [ + "a", + "b" + ], + "buildUrl": "http://ci/builds/1234", + "contracts": [ + { + "consumerName": "Foo", + "providerName": "Bar", + "specification": "pact", + "contentType": "application/json", + "content": "eyJjb25zdW1lciI6eyJuYW1lIjoiRm9vIn0sInByb3ZpZGVyIjp7Im5hbWUiOiJCYXIifSwiaW50ZXJhY3Rpb25zIjpbXX0=", + "writeMode": "overwrite" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/hal+json;charset=utf-8", + "Content-Length": "1788" + }, + "body": { + "logs": [ + { + "level": "debug", + "message": "Created Foo version 1 with branch main and tags a, b" + }, + { + "level": "info", + "message": "Pact successfully published for Foo version 1 and provider Bar." + }, + { + "level": "debug", + "message": " View the published pact at http://example.org/pacts/provider/Bar/consumer/Foo/version/1" + }, + { + "level": "debug", + "message": " Events detected: contract_published, contract_content_changed (first time any pact published for this consumer with consumer version tagged a, first time any pact published for this consumer with consumer version tagged b)" + }, + { + "level": "warn", + "message": " Next steps:" + }, + { + "level": "warn", + "message": " * Add Pact verification tests to the Bar build. See https://docs.pact.io/go/provider_verification" + }, + { + "level": "warn", + "message": " * Configure separate Bar pact verification build and webhook to trigger it when the pact content changes. See https://docs.pact.io/go/webhooks" + } + ], + "_embedded": { + "pacticipant": { + "name": "Foo", + "_links": { + "self": { + "href": "http://example.org/pacticipants/Foo" + } + } + }, + "version": { + "number": "1", + "branch": "main", + "buildUrl": "http://ci/builds/1234", + "_links": { + "self": { + "title": "Version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + } + } + } + }, + "_links": { + "pb:pacticipant": { + "title": "Pacticipant", + "name": "Foo", + "href": "http://example.org/pacticipants/Foo" + }, + "pb:pacticipant-version": { + "title": "Pacticipant version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + }, + "pb:pacticipant-version-tags": [ + { + "title": "Tag", + "name": "a", + "href": "http://example.org/pacticipants/Foo/versions/1/tags/a" + }, + { + "title": "Tag", + "name": "b", + "href": "http://example.org/pacticipants/Foo/versions/1/tags/b" + } + ], + "pb:contracts": [ + { + "title": "Pact", + "name": "Pact between Foo (1) and Bar", + "href": "http://example.org/pacts/provider/Bar/consumer/Foo/version/1" + } + ] + } + } + } +} diff --git a/spec/fixtures/approvals/publish_contract_nothing_exists_with_webhook.approved.json b/spec/fixtures/approvals/publish_contract_nothing_exists_with_webhook.approved.json new file mode 100644 index 000000000..a55a69b87 --- /dev/null +++ b/spec/fixtures/approvals/publish_contract_nothing_exists_with_webhook.approved.json @@ -0,0 +1,121 @@ +{ + "request": { + "path": "/contracts/publish", + "headers": { + "Content-Type": "application/json", + "Accept": "application/hal+json" + }, + "body": { + "pacticipantName": "Foo", + "pacticipantVersionNumber": "1", + "branch": "main", + "tags": [ + "a", + "b" + ], + "buildUrl": "http://ci/builds/1234", + "contracts": [ + { + "consumerName": "Foo", + "providerName": "Bar", + "specification": "pact", + "contentType": "application/json", + "content": "eyJjb25zdW1lciI6eyJuYW1lIjoiRm9vIn0sInByb3ZpZGVyIjp7Im5hbWUiOiJCYXIifSwiaW50ZXJhY3Rpb25zIjpbXX0=", + "writeMode": "overwrite" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/hal+json;charset=utf-8", + "Content-Length": "1780" + }, + "body": { + "logs": [ + { + "level": "debug", + "message": "Created Foo version 1 with branch main and tags a, b" + }, + { + "level": "info", + "message": "Pact successfully published for Foo version 1 and provider Bar." + }, + { + "level": "debug", + "message": " View the published pact at http://example.org/pacts/provider/Bar/consumer/Foo/version/1" + }, + { + "level": "debug", + "message": " Events detected: contract_published, contract_content_changed (first time any pact published for this consumer with consumer version tagged a, first time any pact published for this consumer with consumer version tagged b)" + }, + { + "level": "debug", + "message": " Webhook \"foo webhook\" triggered for event contract_content_changed.\n View logs at http://example.org/triggered-webhooks/1234/logs" + }, + { + "level": "warn", + "message": " Next steps:" + }, + { + "level": "warn", + "message": " * Add Pact verification tests to the Bar build. See https://docs.pact.io/go/provider_verification" + } + ], + "_embedded": { + "pacticipant": { + "name": "Foo", + "_links": { + "self": { + "href": "http://example.org/pacticipants/Foo" + } + } + }, + "version": { + "number": "1", + "branch": "main", + "buildUrl": "http://ci/builds/1234", + "_links": { + "self": { + "title": "Version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + } + } + } + }, + "_links": { + "pb:pacticipant": { + "title": "Pacticipant", + "name": "Foo", + "href": "http://example.org/pacticipants/Foo" + }, + "pb:pacticipant-version": { + "title": "Pacticipant version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + }, + "pb:pacticipant-version-tags": [ + { + "title": "Tag", + "name": "a", + "href": "http://example.org/pacticipants/Foo/versions/1/tags/a" + }, + { + "title": "Tag", + "name": "b", + "href": "http://example.org/pacticipants/Foo/versions/1/tags/b" + } + ], + "pb:contracts": [ + { + "title": "Pact", + "name": "Pact between Foo (1) and Bar", + "href": "http://example.org/pacts/provider/Bar/consumer/Foo/version/1" + } + ] + } + } + } +} diff --git a/spec/fixtures/approvals/publish_contract_verification_already_exists.approved.json b/spec/fixtures/approvals/publish_contract_verification_already_exists.approved.json new file mode 100644 index 000000000..607e996ac --- /dev/null +++ b/spec/fixtures/approvals/publish_contract_verification_already_exists.approved.json @@ -0,0 +1,117 @@ +{ + "request": { + "path": "/contracts/publish", + "headers": { + "Content-Type": "application/json", + "Accept": "application/hal+json" + }, + "body": { + "pacticipantName": "Foo", + "pacticipantVersionNumber": "1", + "branch": "main", + "tags": [ + "a", + "b" + ], + "buildUrl": "http://ci/builds/1234", + "contracts": [ + { + "consumerName": "Foo", + "providerName": "Bar", + "specification": "pact", + "contentType": "application/json", + "content": "eyJjb25zdW1lciI6eyJuYW1lIjoiRm9vIn0sInByb3ZpZGVyIjp7Im5hbWUiOiJCYXIifSwiaW50ZXJhY3Rpb25zIjpbXX0=", + "writeMode": "overwrite" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/hal+json;charset=utf-8", + "Content-Length": "1778" + }, + "body": { + "logs": [ + { + "level": "debug", + "message": "Updated Foo version 1 with branch main and tags a, b" + }, + { + "level": "warn", + "message": "Pact with changed content published over existing content for Foo version 1 and provider Bar. This is not recommended in normal cicumstances and may indicate that you have not configured your Pact pipeline correctly. For more information see https://docs.pact.io/go/versioning" + }, + { + "level": "debug", + "message": " View the published pact at http://example.org/pacts/provider/Bar/consumer/Foo/version/1" + }, + { + "level": "debug", + "message": " Events detected: contract_published, contract_content_changed (pact content modified since previous publication for Foo version 1)" + }, + { + "level": "warn", + "message": " Next steps:" + }, + { + "level": "warn", + "message": " * Configure separate Bar pact verification build and webhook to trigger it when the pact content changes. See https://docs.pact.io/go/webhooks" + } + ], + "_embedded": { + "pacticipant": { + "name": "Foo", + "_links": { + "self": { + "href": "http://example.org/pacticipants/Foo" + } + } + }, + "version": { + "number": "1", + "branch": "main", + "buildUrl": "http://ci/builds/1234", + "_links": { + "self": { + "title": "Version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + } + } + } + }, + "_links": { + "pb:pacticipant": { + "title": "Pacticipant", + "name": "Foo", + "href": "http://example.org/pacticipants/Foo" + }, + "pb:pacticipant-version": { + "title": "Pacticipant version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + }, + "pb:pacticipant-version-tags": [ + { + "title": "Tag", + "name": "a", + "href": "http://example.org/pacticipants/Foo/versions/1/tags/a" + }, + { + "title": "Tag", + "name": "b", + "href": "http://example.org/pacticipants/Foo/versions/1/tags/b" + } + ], + "pb:contracts": [ + { + "title": "Pact", + "name": "Pact between Foo (1) and Bar", + "href": "http://example.org/pacts/provider/Bar/consumer/Foo/version/1" + } + ] + } + } + } +} diff --git a/spec/fixtures/approvals/publish_contract_with_validation_error.approved.json b/spec/fixtures/approvals/publish_contract_with_validation_error.approved.json new file mode 100644 index 000000000..b10cd72a7 --- /dev/null +++ b/spec/fixtures/approvals/publish_contract_with_validation_error.approved.json @@ -0,0 +1,42 @@ +{ + "request": { + "path": "/contracts/publish", + "headers": { + "Content-Type": "application/json", + "Accept": "application/hal+json" + }, + "body": { + "pacticipantName": "Foo", + "branch": "main", + "tags": [ + "a", + "b" + ], + "buildUrl": "http://ci/builds/1234", + "contracts": [ + { + "consumerName": "Foo", + "providerName": "Bar", + "specification": "pact", + "contentType": "application/json", + "content": "eyJjb25zdW1lciI6eyJuYW1lIjoiRm9vIn0sInByb3ZpZGVyIjp7Im5hbWUiOiJCYXIifSwiaW50ZXJhY3Rpb25zIjpbXX0=", + "writeMode": "overwrite" + } + ] + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/hal+json;charset=utf-8", + "Content-Length": "79" + }, + "body": { + "errors": { + "pacticipantVersionNumber": [ + "pacticipantVersionNumber is missing" + ] + } + } + } +} diff --git a/spec/fixtures/approvals/publish_contracts_results_decorator.approved.json b/spec/fixtures/approvals/publish_contracts_results_decorator.approved.json new file mode 100644 index 000000000..be845d361 --- /dev/null +++ b/spec/fixtures/approvals/publish_contracts_results_decorator.approved.json @@ -0,0 +1,54 @@ +{ + "logs": [ + { + "level": "warn", + "message": "foo" + } + ], + "_embedded": { + "pacticipant": { + "name": "Foo", + "_links": { + "self": { + "href": "http://example.org/pacticipants/Foo" + } + } + }, + "version": { + "number": "1", + "_links": { + "self": { + "title": "Version", + "name": "1", + "href": "http://example.org/pacticipants/Foo/versions/1" + } + } + } + }, + "_links": { + "pb:pacticipant": { + "title": "Pacticipant", + "name": "Foo", + "href": "pacticipant_url" + }, + "pb:pacticipant-version": { + "title": "Pacticipant version", + "name": "1", + "href": "version_url" + }, + "pb:pacticipant-version-tags": [ + { + "title": "Tag", + "name": "main", + "href": "tag_url" + } + ], + "pb:contracts": [ + { + "title": "Pact", + "name": "pact name", + "href": "pact_url" + } + ] + } +} diff --git a/spec/lib/pact_broker/api/contracts/publish_contracts_schema_spec.rb b/spec/lib/pact_broker/api/contracts/publish_contracts_schema_spec.rb new file mode 100644 index 000000000..c53ac8378 --- /dev/null +++ b/spec/lib/pact_broker/api/contracts/publish_contracts_schema_spec.rb @@ -0,0 +1,114 @@ +require 'pact_broker/api/contracts/publish_contracts_schema' + +module PactBroker + module Api + module Contracts + describe PublishContractsSchema do + let(:params) do + { + :pacticipantName => pacticipant_name, + :pacticipantVersionNumber => version_number, + :tags => tags, + :branch => branch, + :buildUrl => build_url, + :contracts => [ + { + :consumerName => consumer_name, + :providerName => "Bar", + :specification => "pact", + :contentType => content_type, + :content => encoded_contract, + :decodedContent => decoded_content, + :decodedParsedContent => decoded_parsed_content + } + ] + } + end + + let(:pacticipant_name) { "Foo" } + let(:consumer_name) { pacticipant_name } + let(:version_number) { "34" } + let(:tags) { ["a", "b"] } + let(:branch) { "main" } + let(:build_url) { "http://ci/builds/1234" } + let(:contract_hash) { { "consumer" => { "name" => "Foo" }, "provider" => { "name" => "Bar" }, "interactions" => [] } } + let(:encoded_contract) { Base64.strict_encode64(contract_hash.to_json) } + let(:decoded_content) { contract_hash.to_json } + let(:decoded_parsed_content) { contract_hash } + let(:content_type) { "application/json" } + + subject { PublishContractsSchema.call(params) } + + context "with valid params" do + it { is_expected.to be_empty } + end + + context "with an empty tag" do + let(:tags) { [""] } + + its([:tags, 0]) { is_expected.to include "blank" } + end + + context "with an empty build_url" do + let(:build_url) { "" } + + it { is_expected.to be_empty } + end + + context "with an invalid content type" do + let(:content_type) { "foo" } + + its([:contracts, 0]) { is_expected.to include "one of" } + end + + context "when the specification is pact and consumer name does not match the pacticipant name" do + let(:consumer_name) { "waffle" } + + its([:contracts, 0]) { is_expected.to include "must match" } + end + + context "when the decoded content is nil" do + let(:decoded_content) { nil } + + its([:contracts, 0]) { is_expected.to include "Base64" } + end + + context "when the decoded parsed content is nil" do + let(:decoded_parsed_content) { nil } + + its([:contracts, 0]) { is_expected.to include "The content could not be parsed as application/json" } + + context "when the content type is also nil" do + let(:content_type) { nil } + + its([:contracts, 0]) { is_expected.to include "contentType can't be blank at index 0" } + end + end + + context "when the consumer name in the content does not match the pacticipant name" do + let(:contract_hash) { { "consumer" => { "name" => "WRONG" }, "provider" => { "name" => "Bar" }, "interactions" => [] } } + + its([:contracts, 0]) { is_expected.to include "consumer name in contract content ('WRONG') must match pacticipantName ('Foo') at index 0" } + end + + context "when there is no consumer name in the content" do + let(:contract_hash) { { } } + + it { is_expected.to be_empty } + end + + context "when the consumer name in the contract node does not match the pacticipant name" do + let(:consumer_name) { "WRONG" } + + its([:contracts, 0]) { is_expected.to include "consumerName ('WRONG') must match pacticipantName ('Foo') at index 0" } + end + + context "when the providerName in the contract node does not match the provider name in the contract content" do + let(:contract_hash) { { "consumer" => { "name" => "Foo" }, "provider" => { "name" => "WRONG" }, "interactions" => [] } } + + its([:contracts, 0]) { is_expected.to include "provider name in contract content ('WRONG') must match providerName ('Bar') in contracts at index 0" } + end + end + end + end +end diff --git a/spec/lib/pact_broker/api/decorators/pact_version_decorator_spec.rb b/spec/lib/pact_broker/api/decorators/pact_version_decorator_spec.rb index 07d050ed7..40d21402a 100644 --- a/spec/lib/pact_broker/api/decorators/pact_version_decorator_spec.rb +++ b/spec/lib/pact_broker/api/decorators/pact_version_decorator_spec.rb @@ -5,7 +5,6 @@ module PactBroker module Api module Decorators describe PactVersionDecorator do - let(:json_content) { { 'consumer' => {'name' => 'Consumer'}, @@ -25,9 +24,16 @@ module Decorators consumer_version: consumer_version, consumer_version_number: '1234', name: 'pact_name')} - let(:consumer) { instance_double(PactBroker::Domain::Pacticipant, name: 'Consumer')} - let(:provider) { instance_double(PactBroker::Domain::Pacticipant, name: 'Provider')} - let(:consumer_version) { instance_double(PactBroker::Domain::Version, number: '1234', branch: 'main', pacticipant: consumer)} + let(:consumer) { instance_double(PactBroker::Domain::Pacticipant, name: 'Consumer') } + let(:provider) { instance_double(PactBroker::Domain::Pacticipant, name: 'Provider') } + let(:consumer_version) do + instance_double(PactBroker::Domain::Version, + number: '1234', + branch: 'main', + pacticipant: consumer, + build_url: "http://build" + ) + end let(:decorator_context) { DecoratorContext.new(base_url, '', {}) } let(:json) { PactVersionDecorator.new(pact).to_json(user_options: decorator_context) } @@ -49,7 +55,6 @@ module Decorators it "includes timestamps" do expect(subject[:createdAt]).to_not be_nil end - end end end diff --git a/spec/lib/pact_broker/api/decorators/pact_webhooks_status_decorator_spec.rb b/spec/lib/pact_broker/api/decorators/pact_webhooks_status_decorator_spec.rb index 8159d77d7..cad98bced 100644 --- a/spec/lib/pact_broker/api/decorators/pact_webhooks_status_decorator_spec.rb +++ b/spec/lib/pact_broker/api/decorators/pact_webhooks_status_decorator_spec.rb @@ -38,7 +38,7 @@ module Decorators let(:failure) { false } let(:retrying) { false } let(:status) { PactBroker::Webhooks::TriggeredWebhook::STATUS_SUCCESS } - let(:logs_url) { "http://example.org/webhooks/4321/trigger/1234/logs" } + let(:logs_url) { "http://example.org/triggered-webhooks/1234/logs" } let(:triggered_webhooks) { [triggered_webhook] } let(:json) do diff --git a/spec/lib/pact_broker/api/decorators/publish_contracts_results_decorator_spec.rb b/spec/lib/pact_broker/api/decorators/publish_contracts_results_decorator_spec.rb new file mode 100644 index 000000000..a93d4742a --- /dev/null +++ b/spec/lib/pact_broker/api/decorators/publish_contracts_results_decorator_spec.rb @@ -0,0 +1,53 @@ +require 'pact_broker/api/decorators/publish_contracts_results_decorator' +require 'pact_broker/contracts/contracts_publication_results' +require 'pact_broker/contracts/log_message' + +module PactBroker + module Api + module Decorators + describe PublishContractsResultsDecorator do + describe "to_hash" do + before do + allow(decorator).to receive(:version_url).and_return("version_url") + allow(decorator).to receive(:tag_url).and_return("tag_url") + allow(decorator).to receive(:pact_url).and_return("pact_url") + allow(decorator).to receive(:pacticipant_url).and_return("pacticipant_url") + allow(version).to receive(:pacticipant).and_return(pacticipant) + allow(tag).to receive(:version).and_return(version) + end + + let(:results) do + PactBroker::Contracts::ContractsPublicationResults.from_hash( + logs: logs, + pacticipant: pacticipant, + version: version, + contracts: contracts, + tags: tags + ) + end + let(:contracts) { [pact] } + let(:pact) { instance_double(PactBroker::Domain::Pact, name: "pact name") } + let(:pacticipant) { PactBroker::Domain::Pacticipant.new(name: "Foo" ) } + let(:tags) { [tag]} + let(:tag) { PactBroker::Domain::Tag.new(name: "main")} + let(:version) { PactBroker::Domain::Version.new(number: "1" ) } + let(:logs) { [PactBroker::Contracts::LogMessage.warn("foo") ] } + let(:decorator_options) { { user_options: user_options } } + let(:user_options) do + { + base_url: 'http://example.org' + } + end + + let(:decorator) { PublishContractsResultsDecorator.new(results) } + + subject { decorator.to_hash(decorator_options) } + + it { + Approvals.verify(subject, :name => "publish_contracts_results_decorator", format: :json) + } + end + end + end + end +end diff --git a/spec/lib/pact_broker/api/decorators/triggered_webhook_decorator_spec.rb b/spec/lib/pact_broker/api/decorators/triggered_webhook_decorator_spec.rb index 3f737c167..7f90ea0b9 100644 --- a/spec/lib/pact_broker/api/decorators/triggered_webhook_decorator_spec.rb +++ b/spec/lib/pact_broker/api/decorators/triggered_webhook_decorator_spec.rb @@ -34,7 +34,7 @@ module Decorators let(:failure) { false } let(:retrying) { false } let(:status) { PactBroker::Webhooks::TriggeredWebhook::STATUS_SUCCESS } - let(:logs_url) { "http://example.org/webhooks/4321/trigger/1234/logs" } + let(:logs_url) { "http://example.org/triggered-webhooks/1234/logs" } let(:user_options) { { base_url: "http://example.org" } } let(:json) do diff --git a/spec/lib/pact_broker/app_spec.rb b/spec/lib/pact_broker/app_spec.rb index f66dd4b87..d0102a1d8 100644 --- a/spec/lib/pact_broker/app_spec.rb +++ b/spec/lib/pact_broker/app_spec.rb @@ -333,7 +333,7 @@ class TestAuth2 < TestAuth1; end subject { get("/does/not/exist", nil, { 'CONTENT_TYPE' => 'application/hal+json' }) } it "returns a Content-Type of application/hal+json" do - expect(subject.headers['Content-Type']).to eq 'application/hal+json' + expect(subject.headers['Content-Type']).to eq 'application/hal+json;charset=utf-8' end it "returns a JSON body" do diff --git a/spec/lib/pact_broker/contracts/service_spec.rb b/spec/lib/pact_broker/contracts/service_spec.rb new file mode 100644 index 000000000..37f5c2f0c --- /dev/null +++ b/spec/lib/pact_broker/contracts/service_spec.rb @@ -0,0 +1,108 @@ +require 'pact_broker/contracts/service' + +module PactBroker + module Contracts + describe Service do + describe "#publish" do + let(:contracts_to_publish) do + ContractsToPublish.from_hash( + pacticipant_name: "Foo", + pacticipant_version_number: "1", + tags: ["a", "b"], + branch: branch, + contracts: contracts + ) + end + + let(:write_mode) { "overwrite" } + let(:branch) { "main" } + let(:contracts) { [contract_1] } + let(:contract_1) do + ContractToPublish.from_hash( + consumer_name: "Foo", + provider_name: "Bar", + decoded_content: decoded_contract, + specification: "pact", + write_mode: write_mode + ) + end + + let(:contract_hash) { { consumer: { name: "Foo" }, provider: { name: "Bar" }, interactions: [{a: "b"}] } } + let(:decoded_contract) { contract_hash.to_json } + let(:base_url) { "http://example.org" } + + subject { Service.publish(contracts_to_publish, base_url: base_url) } + + it "creates the tags" do + expect { subject }.to change { PactBroker::Domain::Tag.count }.by 2 + end + + it "sets the version branch" do + subject + expect(PactBroker::Domain::Version.order(:id).last.branch).to eq "main" + end + + it "returns a results object" do + expect(subject.contracts.first).to be_a(PactBroker::Domain::Pact) + end + + context "when the pact does not already exist" do + context "when the write mode is overwrite" do + it "returns an info message" do + expect(subject.logs.find{ |log| log.level == "info" && log.message.include?(" published ") }).to_not be nil + end + end + + context "when the write mode is merge" do + let(:write_mode) { "merge" } + + it "returns an info message" do + expect(subject.logs.find{ |log| log.level == "info" && log.message.include?(" published ") }).to_not be nil + end + end + end + + context "when the pact already exists" do + before do + td.create_consumer("Foo") + .create_provider("Bar") + .create_consumer_version("1", branch: "feat/x", tag_names: ["z"]) + .create_pact + end + + it "adds the tags to the existing version" do + expect { subject }.to change { PactBroker::Domain::Version.order(:id).last.tags.count}.from(1).to(3) + end + + it "updates the branch (TODO this should add to the branches when branches is updated to be a collection)" do + expect { subject }.to change { PactBroker::Domain::Version.order(:id).last.branch}.from("feat/x").to("main") + end + + context "when the write mode is overwrite" do + context "when the content is different" do + it "returns a warning message" do + expect(subject.logs.find{ |log| log.level == "warn" && log.message.include?("changed content") }).to_not be nil + end + end + + context "when the content is the same" do + let(:decoded_contract) { PactBroker::Pacts::PactVersion.last.content } + + it "returns an info message" do + expect(subject.logs.find{ |log| log.level == "info" && log.message.include?("republished") }).to_not be nil + end + end + end + + context "when the write mode is merge" do + let(:write_mode) { "merge" } + + it "returns an info message" do + expect(subject.logs.find{ |log| log.level == "info" && log.message.include?("merged") }).to_not be nil + end + end + end + end + end + end +end diff --git a/spec/lib/pact_broker/events/subscriber_spec.rb b/spec/lib/pact_broker/events/subscriber_spec.rb new file mode 100644 index 000000000..413de0e20 --- /dev/null +++ b/spec/lib/pact_broker/events/subscriber_spec.rb @@ -0,0 +1,43 @@ +require 'pact_broker/events/subscriber' +require 'pact_broker/events/publisher' + +module PactBroker + module Events + describe "#subscribe" do + class TestPublisher + include PactBroker::Events::Publisher + + def broadcast_foo(id) + broadcast(:foo, id ) + end + end + + class TestListener + attr_reader :events + + def initialize + @events = [] + end + + def foo(params) + @events << params + end + end + + it "allows overlapping subscriptions" do + listener_1 = TestListener.new + listener_2 = TestListener.new + PactBroker::Events.subscribe(listener_1) do + TestPublisher.new.broadcast_foo(1) + PactBroker::Events.subscribe(listener_2) do + TestPublisher.new.broadcast_foo(2) + end + TestPublisher.new.broadcast_foo(3) + end + + expect(listener_1.events).to eq [1, 2, 3] + expect(listener_2.events).to eq [2] + end + end + end +end diff --git a/spec/lib/pact_broker/pacts/service_spec.rb b/spec/lib/pact_broker/pacts/service_spec.rb index ffdf2fe7d..ddbd5129e 100644 --- a/spec/lib/pact_broker/pacts/service_spec.rb +++ b/spec/lib/pact_broker/pacts/service_spec.rb @@ -23,7 +23,7 @@ module Pacts let(:provider) { double('provider', id: 2) } let(:version) { double('version', id: 3, pacticipant_id: 1) } let(:existing_pact) { nil } - let(:new_pact) { double('new_pact', consumer_version_tag_names: %w[dev], json_content: json_content, pact_version_sha: "1") } + let(:new_pact) { double('new_pact', consumer_version_tag_names: %w[dev], json_content: json_content, pact_version_sha: "1", consumer_name: "Foo", consumer_version_number: "2") } let(:json_content) { { the: "contract" }.to_json } let(:json_content_with_ids) { { the: "contract with ids" }.to_json } let(:previous_pacts) { [] } @@ -60,12 +60,7 @@ module Pacts subject end - it "broadcasts the contract_published event" do - expect(Service).to receive(:broadcast).with(:contract_published, pact: new_pact, event_context: { consumer_version_tags: %w[dev] }) - subject - end - - # TODO test all this properly! + # TODO test all this contract_content_changed logic properly! context "when the latest pact for one of the tags has a different pact_version_sha" do before do allow(pact_repository).to receive(:find_previous_pacts).and_return(previous_pacts_by_tag) @@ -78,12 +73,23 @@ module Pacts } end + it "broadcasts the contract_published event" do + expect(Service).to receive(:broadcast).with( + :contract_published, + { + pact: new_pact, + event_context: { consumer_version_tags: %w[dev] } + } + ) + subject + end + it "broadcasts the contract_content_changed event" do expect(Service).to receive(:broadcast).with( :contract_content_changed, { pact: new_pact, - event_comment: "Pact content has changed since the last consumer version tagged with dev", + event_comment: "pact content has changed since the last consumer version tagged with dev", event_context: { consumer_version_tags: %w[dev] } } ) @@ -103,12 +109,12 @@ module Pacts } end - it "broadcasts the contract_content_unchanged event" do + it "broadcasts the contract_published event" do expect(Service).to receive(:broadcast).with( - :contract_content_unchanged, + :contract_published, { pact: new_pact, - event_comment: "Pact content the same as previous version and no new tags were applied", + event_comment: "pact content is the same as previous version with tag dev and no new tags were applied", event_context: { consumer_version_tags: %w[dev] } } ) @@ -154,7 +160,7 @@ module Pacts :contract_content_changed, { pact: new_pact, - event_comment: "Pact content modified since previous revision", + event_comment: "pact content modified since previous publication for Foo version 2", event_context: { consumer_version_tags: %w[dev] } } ) @@ -168,7 +174,7 @@ module Pacts :contract_content_unchanged, { pact: new_pact, - event_comment: "Pact content was unchanged", + event_comment: "pact content was unchanged", event_context: { consumer_version_tags: %w[dev] } } ) diff --git a/spec/service_consumers/hal_relation_proxy_app.rb b/spec/service_consumers/hal_relation_proxy_app.rb index f054ca167..e4ab91283 100644 --- a/spec/service_consumers/hal_relation_proxy_app.rb +++ b/spec/service_consumers/hal_relation_proxy_app.rb @@ -17,7 +17,9 @@ class HalRelationProxyApp '/HAL-REL-PLACEHOLDER-PB-PACTICIPANT-VERSION-Foo-5556b8149bf8bac76bc30f50a8a2dd4c22c85f30' => '/pacticipants/Foo/versions/5556b8149bf8bac76bc30f50a8a2dd4c22c85f30', '/HAL-REL-PLACEHOLDER-PB-RECORD-DEPLOYMENT-FOO-5556B8149BF8BAC76BC30F50A8A2DD4C22C85F30-TEST' => - '/pacticipants/Foo/versions/5556b8149bf8bac76bc30f50a8a2dd4c22c85f30/deployed-versions/environment/cb632df3-0a0d-4227-aac3-60114dd36479' + '/pacticipants/Foo/versions/5556b8149bf8bac76bc30f50a8a2dd4c22c85f30/deployed-versions/environment/cb632df3-0a0d-4227-aac3-60114dd36479', + '/HAL-REL-PLACEHOLDER-PB-PUBLISH-CONTRACTS' => + '/contracts/publish' } RESPONSE_BODY_REPLACEMENTS = { diff --git a/spec/service_consumers/provider_states_for_pact_broker_client.rb b/spec/service_consumers/provider_states_for_pact_broker_client.rb index e40d09210..b370bb7a5 100644 --- a/spec/service_consumers/provider_states_for_pact_broker_client.rb +++ b/spec/service_consumers/provider_states_for_pact_broker_client.rb @@ -271,4 +271,8 @@ .create_consumer_version("26f353580936ad3b9baddb17b00e84f33c69e7cb") end end + + provider_state "the pb:publish-contracts relations exists in the index resource" do + no_op + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f108a3f37..b31b7c915 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -52,19 +52,6 @@ config.example_status_persistence_file_path = "./spec/examples.txt" config.filter_run_excluding skip: true - config.after(:each) do | example, something | - if example.exception.is_a?(Approvals::ApprovalError) - require "pact/support" - parts = example.exception.message.split('"') - received_file = parts[1] - approved_file = parts[3] - received_hash = JSON.parse(File.read(received_file)) - approved_hash = JSON.parse(File.read(approved_file)) - diff = Pact::Matchers.diff(approved_hash, received_hash) - puts Pact::Matchers::UnixDiffFormatter.call(diff) - end - end - def app PactBroker::API end diff --git a/spec/support/approvals.rb b/spec/support/approvals.rb index c14260181..4c7389f3f 100644 --- a/spec/support/approvals.rb +++ b/spec/support/approvals.rb @@ -9,10 +9,12 @@ def print_diff(exception) parts = exception.message.split('"') received_file = parts[1] approved_file = parts[3] - received_hash = JSON.parse(File.read(received_file)) - approved_hash = JSON.parse(File.read(approved_file)) - diff = Pact::Matchers.diff(approved_hash, received_hash) - puts Pact::Matchers::UnixDiffFormatter.call(diff) + if File.exist?(received_file) && File.exist?(approved_file) + received_hash = JSON.parse(File.read(received_file)) + approved_hash = JSON.parse(File.read(approved_file)) + diff = Pact::Matchers.diff(approved_hash, received_hash) + puts Pact::Matchers::UnixDiffFormatter.call(diff) + end end RSpec.configure do | config |