Skip to content

Commit

Permalink
feat: allow webhooks to match pacticipants by label (#501)
Browse files Browse the repository at this point in the history
  • Loading branch information
barthez authored Sep 17, 2021
1 parent b45398b commit f30a9dc
Show file tree
Hide file tree
Showing 22 changed files with 536 additions and 80 deletions.
14 changes: 14 additions & 0 deletions db/migrations/20210914_add_labels_to_webhooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Sequel.migration do
change do
alter_table(:webhooks) do
add_column(:consumer_label, String)
add_column(:provider_label, String)
end

# SQLite workaround - with one `alter_table` block it adds only last constraint.
alter_table(:webhooks) do
add_constraint(:consumer_label_exclusion, "consumer_id IS NULL OR (consumer_id IS NOT NULL AND consumer_label IS NULL)")
add_constraint(:provider_label_exclusion, "provider_id IS NULL OR (provider_id IS NOT NULL AND provider_label IS NULL)")
end
end
end
26 changes: 24 additions & 2 deletions lib/pact_broker/api/contracts/webhook_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def self.errors; @first_errors end

property :consumer do
property :name
property :label

validation do
configure do
Expand All @@ -50,12 +51,23 @@ def pacticipant_exists?(name)
end
end

required(:name).filled(:pacticipant_exists?)
optional(:name)
.maybe(:pacticipant_exists?)
.when(:none?) { value(:label).filled? }

optional(:label)
.maybe(:str?)
.when(:none?) { value(:name).filled? }

rule(label: [:name, :label]) do |name, label|
(name.filled? & label.filled?) > label.none?
end
end
end

property :provider do
property :name
property :label

validation do
configure do
Expand All @@ -66,7 +78,17 @@ def pacticipant_exists?(name)
end
end

required(:name).filled(:pacticipant_exists?)
optional(:name)
.maybe(:pacticipant_exists?)
.when(:none?) { value(:label).filled? }

optional(:label)
.maybe(:str?)
.when(:none?) { value(:name).filled? }

rule(label: [:name, :label]) do |name, label|
(name.filled? & label.filled?) > label.none?
end
end
end

Expand Down
31 changes: 27 additions & 4 deletions lib/pact_broker/api/decorators/webhook_decorator.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require_relative "base_decorator"
require "pact_broker/domain/webhook_pacticipant"
require "pact_broker/api/decorators/webhook_request_template_decorator"
require "pact_broker/api/decorators/timestamps"
require "pact_broker/webhooks/webhook_request_template"
Expand All @@ -19,12 +20,14 @@ class WebhookEventDecorator < BaseDecorator

property :description, getter: lambda { |context| context[:represented].display_description }

property :consumer, :class => PactBroker::Domain::Pacticipant, default: nil do
property :consumer, class: Domain::WebhookPacticipant, default: nil do
property :name
property :label
end

property :provider, :class => PactBroker::Domain::Pacticipant, default: nil do
property :provider, class: Domain::WebhookPacticipant, default: nil do
property :name
property :label
end

property :enabled, default: true
Expand All @@ -50,7 +53,7 @@ class WebhookEventDecorator < BaseDecorator
end

link :'pb:consumer' do | options |
if represented.consumer
if represented.consumer&.name
{
title: "Consumer",
name: represented.consumer.name,
Expand All @@ -59,8 +62,18 @@ class WebhookEventDecorator < BaseDecorator
end
end

link :'pb:consumers' do | options |
if represented.consumer&.label
{
title: "Consumers by label",
name: represented.consumer.label,
href: pacticipants_with_label_url(options.fetch(:base_url), represented.consumer.label)
}
end
end

link :'pb:provider' do | options |
if represented.provider
if represented.provider&.name
{
title: "Provider",
name: represented.provider.name,
Expand All @@ -69,6 +82,16 @@ class WebhookEventDecorator < BaseDecorator
end
end

link :'pb:providers' do | options |
if represented.provider&.label
{
title: "Providers by label",
name: represented.provider.label,
href: pacticipants_with_label_url(options.fetch(:base_url), represented.provider.label)
}
end
end

link :'pb:pact-webhooks' do | options |
if represented.consumer && represented.provider
{
Expand Down
4 changes: 4 additions & 0 deletions lib/pact_broker/api/pact_broker_urls.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ def pacticipant_url base_url, pacticipant
"#{pacticipants_url(base_url)}/#{url_encode(pacticipant.name)}"
end

def pacticipants_with_label_url base_url, label_name
"#{pacticipants_url(base_url)}/label/#{url_encode(label_name)}"
end

def pacticipant_url_from_params params, base_url = ""
[
base_url,
Expand Down
4 changes: 2 additions & 2 deletions lib/pact_broker/api/resources/all_webhooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ def validation_errors? webhook
end

def consumer
webhook.consumer ? pacticipant_service.find_pacticipant_by_name(webhook.consumer.name) : nil
webhook.consumer&.name ? pacticipant_service.find_pacticipant_by_name(webhook.consumer.name) : nil
end

def provider
webhook.provider ? pacticipant_service.find_pacticipant_by_name(webhook.provider.name) : nil
webhook.provider&.name ? pacticipant_service.find_pacticipant_by_name(webhook.provider.name) : nil
end

def webhooks
Expand Down
12 changes: 12 additions & 0 deletions lib/pact_broker/doc/views/pacticipant/label.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

# Pacticipant labels

Allowed methods: `GET`, `PUT`, `DELETE`

Path: `/pacticipants/{pacticipant}/labels/{label}`

Get, create or delete pacticipant labels.

Pacticipants can be queried by label with `/pacticipants/label/{label}`.

Labels are also used to create generic webhooks that are triggered for subset of pacticipants with label.
17 changes: 17 additions & 0 deletions lib/pact_broker/doc/views/webhooks.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ To specify an XML body, you will need to use a correctly escaped string (or use

**BEWARE** While the basic auth password, and any header containing the word `authorization` or `token` will be redacted from the UI and the logs, the password could be reverse engineered from the database, so make a separate account for the Pact Broker to use in your webhooks. Don't use your personal account!

#### Consumer or provider label matching

Webhooks can be created to match events of certain set of [consumers or providers by label](/doc/label?context=pacticipant). Use `label` attribute for either `provider` or `consumer`. Both are optional, but they cannot be provided when `name` attribute is present. Following example would trigger a webhook when any contract with `async` labeled provider changed its content:

{
"provider": {
"label": "async"
},
"events": [{
"name": "contract_content_changed"
}],
"request": {
"method": "POST",
"url": "http://master.ci.my.domain:8085/rest/api/latest/queue/SOME-PROJECT"
}
}

#### Event types

`contract_published:` triggered every time a contract is published. It is not recommended to trigger your provider verification build every time a contract is published - see `contract_content_changed` below.
Expand Down
4 changes: 4 additions & 0 deletions lib/pact_broker/domain/pacticipant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ def any_versions?
def branch_head_for(branch_name)
branch_heads.find{ | branch_head | branch_head.branch_name == branch_name }
end

def label?(name)
labels.any? { |label| label.name == name }
end
end
end
end
Expand Down
10 changes: 5 additions & 5 deletions lib/pact_broker/domain/webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ def display_description

def scope_description
if consumer && provider
"A webhook for the pact between #{consumer.name} and #{provider.name}"
"A webhook for the pact between #{consumer_name} and #{provider_name}"
elsif provider
"A webhook for all pacts with provider #{provider.name}"
"A webhook for all pacts with provider #{provider_name}"
elsif consumer
"A webhook for all pacts with consumer #{consumer.name}"
"A webhook for all pacts with consumer #{consumer_name}"
else
"A webhook for all pacts"
end
Expand All @@ -63,11 +63,11 @@ def to_s
end

def consumer_name
consumer && consumer.name
consumer && (consumer.name || (consumer.label && "labeled `#{consumer.label}`"))
end

def provider_name
provider && provider.name
provider && (provider.name || (provider.label && "labeled `#{provider.label}`"))
end

def trigger_on_contract_content_changed?
Expand Down
6 changes: 6 additions & 0 deletions lib/pact_broker/domain/webhook_pacticipant.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

module PactBroker
module Domain
WebhookPacticipant = Struct.new(:name, :label, keyword_init: true)
end
end
1 change: 1 addition & 0 deletions lib/pact_broker/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ en:
single_line?: "cannot contain multiple lines"
no_spaces?: "cannot contain spaces"
environment_with_name_exists?: "with name '%{value}' does not exist"
none?: "cannot be provided"

pact_broker:
messages:
Expand Down
4 changes: 2 additions & 2 deletions lib/pact_broker/matrix/row.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,11 @@ def summary
end

def consumer
@consumer ||= OpenStruct.new(name: consumer_name, id: consumer_id)
@consumer ||= Domain::Pacticipant.new(name: consumer_name).tap { |pacticipant| pacticipant.id = consumer_id }
end

def provider
@provider ||= OpenStruct.new(name: provider_name, id: provider_id)
@provider ||= Domain::Pacticipant.new(name: provider_name).tap { |pacticipant| pacticipant.id = provider_id }
end

def consumer_version
Expand Down
47 changes: 37 additions & 10 deletions lib/pact_broker/test/http_test_data_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ def create_pacticipant(name, main_branch: nil)
self
end

def create_label(name, label)
puts "Creating label `#{label}` for #{name}"
client.put("pacticipants/#{encode(name)}/labels/#{encode(label)}", {}).tap { |response| check_for_error(response) }
separate
self
end

def publish_contract(consumer: last_consumer_name, consumer_version:, provider: last_provider_name, content_id:, tag: nil, branch: nil)
content = generate_content(consumer, provider, content_id)
request_body_hash = {
Expand Down Expand Up @@ -206,26 +213,35 @@ def verify_pact(index: 0, success: true, provider: last_provider_name, provider_
self
end

def create_global_webhook_for_event(uuid: nil, url: "https://postman-echo.com/post", body: nil, event_name: )
puts "Creating global webhook for contract changed event with uuid #{uuid}"
def create_global_webhook_for_event(**kwargs)
create_webhook_for_event(**kwargs)
end

def create_webhook_for_event(uuid: nil, url: "https://postman-echo.com/post", body: nil, provider: nil, consumer: nil, event_name:)
require "securerandom"
webhook_prefix = "global " if provider.nil? && consumer.nil?
puts "Creating #{webhook_prefix}webhook for contract changed event with uuid #{uuid}"
uuid ||= SecureRandom.uuid
default_body = {
"providerVersionNumber" => "${pactbroker.providerVersionNumber}",
"providerVersionBranch" => "${pactbroker.providerVersionBranch}",
"eventName" => "${pactbroker.eventName}",
"consumerName" => "${pactbroker.consumerName}",
"consumerVersionNumber" => "${pactbroker.consumerVersionNumber}",
"consumerVersionBranch" => "${pactbroker.consumerVersionBranch}"
"providerVersionBranch" => "${pactbroker.providerVersionBranch}",
"providerName" => "${pactbroker.providerName}",
"providerVersionNumber" => "${pactbroker.providerVersionNumber}",
"consumerVersionBranch" => "${pactbroker.consumerVersionBranch}",
}
request_body = {
"description" => "A webhook for all consumers and providers",
"events" => [{
"name" => event_name
}],
"consumer" => consumer,
"provider" => provider,
"description" => webhook_description(consumer, provider),
"events" => Array(event_name).map { |name| {"name" => name} },
"request" => {
"method" => "POST",
"url" => url,
"body" => body || default_body
}
}
}.compact
path = "webhooks/#{uuid}"
client.put(path, request_body.to_json).tap { |response| check_for_error(response) }
separate
Expand Down Expand Up @@ -333,6 +349,17 @@ def generate_content(consumer_name, provider_name, content_id)

private

def webhook_description(consumer, provider)
return "A webhook for all consumers and providers" if consumer.nil? && provider.nil?

suffix = {consumer: consumer, provider: provider}.compact.map do |name, pacticipant|
desc = pacticipant.compact.map { |k, v| "#{k}: `#{v}`"}.first
"#{name}s by #{desc}"
end

"A webhook for #{suffix.join(' and ')}"
end

def publish_verification_results(url_of_pact_to_verify, provider, provider_version, provider_version_tag, provider_version_branch, success)
[*provider_version_tag].each do | tag |
create_tag(pacticipant: provider, version: provider_version, tag: tag)
Expand Down
25 changes: 21 additions & 4 deletions lib/pact_broker/test/test_data_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,10 @@ def create_pact_version_without_publication(json_content = nil )
self
end

# rubocop: disable Metrics/CyclomaticComplexity
def create_webhook parameters = {}
params = parameters.dup
consumer = params.key?(:consumer) ? params.delete(:consumer) : @consumer
provider = params.key?(:provider) ? params.delete(:provider) : @provider
consumer, webhook_consumer = webhook_pacticipant(:consumer, params)
provider, webhook_provider = webhook_pacticipant(:provider, params)
uuid = params[:uuid] || PactBroker::Webhooks::Service.next_uuid
enabled = params.key?(:enabled) ? params.delete(:enabled) : true
event_params = if params[:event_names]
Expand All @@ -284,7 +283,15 @@ def create_webhook parameters = {}
events = event_params.collect{ |e| PactBroker::Webhooks::WebhookEvent.new(e) }
template_params = { method: "POST", url: "http://example.org", headers: {"Content-Type" => "application/json"}, username: params[:username], password: params[:password] }
request = PactBroker::Webhooks::WebhookRequestTemplate.new(template_params.merge(params))
@webhook = PactBroker::Webhooks::Repository.new.create uuid, PactBroker::Domain::Webhook.new(request: request, events: events, description: params[:description], enabled: enabled), consumer, provider
new_webhook = PactBroker::Domain::Webhook.new(
request: request,
events: events,
description: params[:description],
enabled: enabled,
consumer: webhook_consumer,
provider: webhook_provider
)
@webhook = PactBroker::Webhooks::Repository.new.create uuid, new_webhook, consumer, provider
self
end
# rubocop: enable Metrics/CyclomaticComplexity
Expand Down Expand Up @@ -597,6 +604,16 @@ def default_json_content
"random" => rand
}.to_json
end

def webhook_pacticipant(name, params)
pacticipant = params.key?(name) ? params.delete(name) : instance_variable_get(:"@#{name}")
label = params.delete(:"#{name}_label")
if pacticipant
[pacticipant, Domain::WebhookPacticipant.new(name: pacticipant.name)]
elsif label
[nil, Domain::WebhookPacticipant.new(label: label)]
end
end
end
end
end
Loading

0 comments on commit f30a9dc

Please sign in to comment.