pact_broker 2.121.2 → 2.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/Gemfile +1 -4
- data/lib/pact_broker/api/contracts/consumer_version_selector_contract.rb +17 -1
- data/lib/pact_broker/api/contracts/utf_8_validation.rb +5 -1
- data/lib/pact_broker/api/decorators/pacts_for_verification_query_decorator.rb +1 -0
- data/lib/pact_broker/api/decorators/publish_contract_decorator.rb +4 -3
- data/lib/pact_broker/api/decorators/verification_decorator.rb +12 -2
- data/lib/pact_broker/api/decorators/verification_summary_decorator.rb +6 -0
- data/lib/pact_broker/api/renderers/html_pact_renderer.rb +6 -69
- data/lib/pact_broker/api/renderers/markdown/consumer_contract_renderer.rb +87 -0
- data/lib/pact_broker/api/renderers/markdown/interaction_renderer.rb +52 -0
- data/lib/pact_broker/api/renderers/markdown/interaction_view_model.rb +162 -0
- data/lib/pact_broker/api/renderers/markdown.rb +9 -0
- data/lib/pact_broker/api/resources/publish_contracts.rb +10 -6
- data/lib/pact_broker/doc/views/provider-pacts-for-verification.markdown +10 -2
- data/lib/pact_broker/hash_refinements.rb +2 -2
- data/lib/pact_broker/locale/en.yml +3 -0
- data/lib/pact_broker/pacts/create_formatted_diff.rb +2 -7
- data/lib/pact_broker/pacts/generate_sha.rb +7 -4
- data/lib/pact_broker/pacts/pacts_for_verification_repository.rb +41 -21
- data/lib/pact_broker/pacts/repository.rb +2 -2
- data/lib/pact_broker/pacts/selected_pact.rb +4 -0
- data/lib/pact_broker/pacts/selector.rb +20 -2
- data/lib/pact_broker/pacts/selectors.rb +4 -0
- data/lib/pact_broker/unified_diff.rb +26 -0
- data/lib/pact_broker/verifications/repository.rb +1 -1
- data/lib/pact_broker/verifications/summary_for_consumer_version.rb +4 -0
- data/lib/pact_broker/version.rb +1 -1
- data/pact_broker.gemspec +2 -2
- metadata +25 -30
- data/lib/pact/doc/doc_file.rb +0 -38
- data/lib/pact/doc/generate.rb +0 -11
- data/lib/pact/doc/generator.rb +0 -82
- data/lib/pact/doc/interaction_view_model.rb +0 -157
- data/lib/pact/doc/markdown/consumer_contract_renderer.rb +0 -74
- data/lib/pact/doc/markdown/generator.rb +0 -24
- data/lib/pact/doc/markdown/index_renderer.rb +0 -40
- data/lib/pact/doc/markdown/interaction_renderer.rb +0 -51
- data/lib/pact/doc/sort_interactions.rb +0 -14
- data/lib/pact/doc.rb +0 -5
- /data/lib/{pact/doc → pact_broker/api/renderers}/markdown/interaction.erb +0 -0
- /data/lib/{pact/doc → pact_broker/api/renderers}/markdown/interaction_async.erb +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e464517845e4046dd28826ab16b367d5080b69cb535f9a9d5901feda4f262bd8
|
|
4
|
+
data.tar.gz: 9959dfb3c78a5e3940a94306133b1e92244a2531cd275f2fb7353c98faa153a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cfdf7a02904a49f475cc615a0a097eaa6b2a3d438df70e3523eae4fe29d7eb7052bc211a07bf70446d6ae76941309bc98846c155285d9ce1efae85718d39e94a
|
|
7
|
+
data.tar.gz: b8dd289895ecf228815e1532d607e15ce7ffd5d41b4ff43f141b2944239aec2f415152189fc73b9aa1725cb4d1dd1ab7a231563bdb0cf576ed4afa53d9067f65
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
+
## [2.123.0] _2026-11-11_
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- Add fallbackToMainBranch consumer version selector option
|
|
6
|
+
|
|
7
|
+
### 🐛 Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Stop the consumer version verification summary querying per verification
|
|
10
|
+
- Apply fallbackTag per consumer
|
|
11
|
+
- Honour fallbackBranch in consumer version selectors
|
|
12
|
+
|
|
13
|
+
### 🚜 Refactor
|
|
14
|
+
|
|
15
|
+
- Name the fallback resolver for tags and branches
|
|
16
|
+
|
|
17
|
+
### ⚡️ Performance
|
|
18
|
+
|
|
19
|
+
- Speed up publishing large contracts (PACT-7601) ([#1067](https://github.com/pact-foundation/pact_broker/issues/1067))
|
|
20
|
+
> **Migration note:** when contracts are published with /contracts/publish,
|
|
21
|
+
> the configured sha_generator is now called with a second argument,
|
|
22
|
+
- Exclude consumers in the fallback tag query
|
|
23
|
+
- Exclude consumers in the fallback branch query
|
|
24
|
+
|
|
25
|
+
### 📚 Documentation
|
|
26
|
+
|
|
27
|
+
- Say fallbackTag applies per consumer
|
|
28
|
+
- Say fallbackBranch applies per consumer
|
|
29
|
+
|
|
30
|
+
### 🧪 Testing
|
|
31
|
+
|
|
32
|
+
- Share one SQL query counter across feature specs
|
|
33
|
+
- Pin fallbackTag semantics in a table
|
|
34
|
+
- Cover fallbackTag dedup, branches and notices
|
|
35
|
+
- Pin fallbackBranch semantics in a table
|
|
36
|
+
- Cover the fallbackBranch request and notices end to end
|
|
37
|
+
- Pin fallbackToMainBranch semantics in the fallback branch table
|
|
38
|
+
- Cover the fallbackToMainBranch request and notices end to end
|
|
39
|
+
|
|
40
|
+
### 🛠️ Miscellaneous Tasks
|
|
41
|
+
|
|
42
|
+
- Drop a TODO the selector contract already resolves
|
|
43
|
+
|
|
44
|
+
## [2.122.0] _2026-10-01_
|
|
45
|
+
|
|
46
|
+
### 🚀 Features
|
|
47
|
+
|
|
48
|
+
- Add a unified diff renderer
|
|
49
|
+
- Render pact diffs as unified diffs
|
|
50
|
+
> **Migration note:** the pact diff endpoints return a standard unified diff.
|
|
51
|
+
> This covers the `diff/previous-distinct`, `diff/version/{version}` and
|
|
52
|
+
> `pact-version/{sha}/diff/pact-version/{sha}` resources under
|
|
53
|
+
> `/pacts/provider/{provider}/consumer/{consumer}`, and the diff notice
|
|
54
|
+
> that `POST /contracts/publish` returns when a publish would modify an
|
|
55
|
+
> existing pact. Clients that parse this output must handle `@@` hunk
|
|
56
|
+
> headers and unchanged context lines. Keys that exist only in the newer
|
|
57
|
+
> pact now appear as `+` lines.
|
|
58
|
+
>
|
|
59
|
+
> As an example, take an interaction whose request method changes from
|
|
60
|
+
> post to get and which gains an `Accept` header. The diff was previously:
|
|
61
|
+
>
|
|
62
|
+
> ```diff
|
|
63
|
+
> "interactions": [
|
|
64
|
+
> {
|
|
65
|
+
> "request": {
|
|
66
|
+
> - "method": "post"
|
|
67
|
+
> + "method": "get"
|
|
68
|
+
> }
|
|
69
|
+
> }
|
|
70
|
+
> ]
|
|
71
|
+
> ```
|
|
72
|
+
>
|
|
73
|
+
> It is now:
|
|
74
|
+
>
|
|
75
|
+
> ```diff
|
|
76
|
+
> @@ -6,7 +6,10 @@
|
|
77
|
+
> {
|
|
78
|
+
> "description": "a request",
|
|
79
|
+
> "request": {
|
|
80
|
+
> - "method": "post",
|
|
81
|
+
> + "headers": {
|
|
82
|
+
> + "Accept": "application/json"
|
|
83
|
+
> + },
|
|
84
|
+
> + "method": "get",
|
|
85
|
+
> "path": "/"
|
|
86
|
+
> },
|
|
87
|
+
> "response": {
|
|
88
|
+
> ```
|
|
89
|
+
|
|
90
|
+
### 🐛 Bug Fixes
|
|
91
|
+
|
|
92
|
+
- Require date_helper in HtmlPactRenderer
|
|
93
|
+
|
|
94
|
+
### 🚜 Refactor
|
|
95
|
+
|
|
96
|
+
- Remove unused pact/doc generator code
|
|
97
|
+
- Compare pact content with Hash equality
|
|
98
|
+
- Move lib/pact/doc under lib/pact_broker/api/renderers
|
|
99
|
+
- Move the markdown pact renderer under PactBroker
|
|
100
|
+
- Render pact HTML from the parsed hash
|
|
101
|
+
|
|
102
|
+
### 🧪 Testing
|
|
103
|
+
|
|
104
|
+
- Stop snapshotting non-determinate response headers
|
|
105
|
+
- Replace Pact::Matchers in spec helpers with a subset matcher
|
|
106
|
+
|
|
107
|
+
### 🛠️ Miscellaneous Tasks
|
|
108
|
+
|
|
109
|
+
- _(ci)_ Standardize renovate config
|
|
110
|
+
- _(ci)_ Remove stale internal workflow
|
|
111
|
+
- Remove the pact-support dependency
|
|
112
|
+
> **Migration note:** pact_broker no longer depends on pact-support, so code
|
|
113
|
+
> alongside the broker can no longer rely on Pact::* constants. Add
|
|
114
|
+
> pact-support to your own Gemfile if that code needs them.
|
|
115
|
+
- _(release)_ Render Migration-Note footers in the changelog
|
|
116
|
+
|
|
1
117
|
## [2.121.2] _2026-09-17_
|
|
2
118
|
|
|
3
119
|
### 🐛 Bug Fixes
|
data/Gemfile
CHANGED
|
@@ -26,7 +26,7 @@ group :test do
|
|
|
26
26
|
gem "pact-ffi", path: "../pact-ruby-ffi"
|
|
27
27
|
else
|
|
28
28
|
gem "pact", ">=2.0.3"
|
|
29
|
-
gem "pact-ffi", "~>0.
|
|
29
|
+
gem "pact-ffi", "~>0.5.0"
|
|
30
30
|
end
|
|
31
31
|
gem "rack-test", ">= 0.6.3", "< 3.0.0"
|
|
32
32
|
gem "bundler-audit", "~>0.4"
|
|
@@ -47,6 +47,3 @@ group :pg, optional: true do
|
|
|
47
47
|
gem "pg", "~>1.6"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
if ENV["X_PACT_DEVELOPMENT"] == "true"
|
|
51
|
-
gem "pact-support", path: "../pact-support"
|
|
52
|
-
end
|
|
@@ -6,7 +6,7 @@ module PactBroker
|
|
|
6
6
|
class ConsumerVersionSelectorContract < BaseContract
|
|
7
7
|
option :parent # the parent hash in which the ConsumerVersionSelector is embedded
|
|
8
8
|
|
|
9
|
-
BRANCH_KEYS = [:latest, :tag, :fallbackTag, :branch, :fallbackBranch, :matchingBranch, :mainBranch]
|
|
9
|
+
BRANCH_KEYS = [:latest, :tag, :fallbackTag, :branch, :fallbackBranch, :fallbackToMainBranch, :matchingBranch, :mainBranch]
|
|
10
10
|
ENVIRONMENT_KEYS = [:environment, :deployed, :released, :deployedOrReleased]
|
|
11
11
|
ALL_KEYS = BRANCH_KEYS + ENVIRONMENT_KEYS + [:consumer]
|
|
12
12
|
|
|
@@ -18,6 +18,7 @@ module PactBroker
|
|
|
18
18
|
optional(:latest).filled(included_in?: [true, false])
|
|
19
19
|
optional(:fallbackTag).filled(:str?)
|
|
20
20
|
optional(:fallbackBranch).filled(:str?)
|
|
21
|
+
optional(:fallbackToMainBranch).filled(included_in?: [true])
|
|
21
22
|
optional(:consumer).filled(:str?)
|
|
22
23
|
optional(:deployed).filled(included_in?: [true])
|
|
23
24
|
optional(:released).filled(included_in?: [true])
|
|
@@ -108,6 +109,21 @@ module PactBroker
|
|
|
108
109
|
end
|
|
109
110
|
end
|
|
110
111
|
|
|
112
|
+
# fallbackToMainBranch
|
|
113
|
+
rule(:fallbackToMainBranch, :fallbackBranch, :branch, :latest) do
|
|
114
|
+
if values[:fallbackToMainBranch] && !values[:branch]
|
|
115
|
+
base.failure(validation_message("pacts_for_verification_selector_fallback_to_main_branch_requires_branch"))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if values[:fallbackToMainBranch] && values[:fallbackBranch]
|
|
119
|
+
base.failure(validation_message("pacts_for_verification_selector_fallback_to_main_branch_and_fallback_branch_disallowed"))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if values[:fallbackToMainBranch] && values[:latest] == false
|
|
123
|
+
base.failure(validation_message("pacts_for_verification_selector_fallback_to_main_branch_and_latest_false_disallowed"))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
111
127
|
# branch/latest
|
|
112
128
|
rule(:branch, :latest) do
|
|
113
129
|
if values[:branch] && values[:latest] == false
|
|
@@ -5,7 +5,11 @@ module PactBroker
|
|
|
5
5
|
extend self
|
|
6
6
|
|
|
7
7
|
def fragment_before_invalid_utf_8_char(string)
|
|
8
|
-
|
|
8
|
+
# Iterating over each char allocates a String per char, which is very slow for large strings,
|
|
9
|
+
# so only do it to find the location of the invalid char once we know there is one.
|
|
10
|
+
return nil if string.force_encoding("UTF-8").valid_encoding?
|
|
11
|
+
|
|
12
|
+
string.each_char.with_index do | char, index |
|
|
9
13
|
if !char.valid_encoding?
|
|
10
14
|
fragment = index < 100 ? string[0...index] : string[index-100...index]
|
|
11
15
|
return index + 1, fragment
|
|
@@ -11,10 +11,11 @@ module PactBroker
|
|
|
11
11
|
property :provider_name
|
|
12
12
|
property :specification
|
|
13
13
|
property :content_type
|
|
14
|
-
property :decoded_content, setter: -> (fragment:, represented:, user_options:, **) {
|
|
14
|
+
property :decoded_content, setter: -> (fragment:, represented:, user_options:, doc:, **) {
|
|
15
15
|
represented.decoded_content = fragment
|
|
16
|
-
# Set the pact version sha when we set the content
|
|
17
|
-
|
|
16
|
+
# Set the pact version sha when we set the content, reusing the content that has already been
|
|
17
|
+
# parsed in the resource, as parsing very large pacts is expensive
|
|
18
|
+
represented.pact_version_sha = user_options.fetch(:sha_generator).call(fragment, parsed_content: doc["decodedParsedContent"])
|
|
18
19
|
}
|
|
19
20
|
property :on_conflict, default: "overwrite"
|
|
20
21
|
|
|
@@ -24,7 +24,7 @@ module PactBroker
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
link :self do | options |
|
|
27
|
-
pact = options
|
|
27
|
+
pact = pact_for(options)
|
|
28
28
|
{
|
|
29
29
|
title: "Verification result",
|
|
30
30
|
name: "Verification result #{represented.number} for #{pact.name}",
|
|
@@ -33,7 +33,7 @@ module PactBroker
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
link "pb:pact-version" do | options |
|
|
36
|
-
pact = options
|
|
36
|
+
pact = pact_for(options)
|
|
37
37
|
{
|
|
38
38
|
title: "Pact",
|
|
39
39
|
name: pact.name,
|
|
@@ -47,6 +47,16 @@ module PactBroker
|
|
|
47
47
|
href: verification_triggered_webhooks_url(represented, options.fetch(:base_url))
|
|
48
48
|
}
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# A decorator rendering many verifications passes :pacts_by_pact_version_sha
|
|
54
|
+
# so each one avoids its own lookup.
|
|
55
|
+
def pact_for(options)
|
|
56
|
+
options[:pact] ||
|
|
57
|
+
options[:pacts_by_pact_version_sha]&.[](represented.pact_version_sha) ||
|
|
58
|
+
represented.latest_pact_publication
|
|
59
|
+
end
|
|
50
60
|
end
|
|
51
61
|
end
|
|
52
62
|
end
|
|
@@ -25,6 +25,12 @@ module PactBroker
|
|
|
25
25
|
}
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# representable passes through the kwargs from to_json as normal args
|
|
29
|
+
def to_hash(options)
|
|
30
|
+
user_options = options.fetch(:user_options, {}).merge(pacts_by_pact_version_sha: represented.pacts_by_pact_version_sha)
|
|
31
|
+
super(options.merge(user_options: user_options))
|
|
32
|
+
end
|
|
33
|
+
|
|
28
34
|
def provider_summary
|
|
29
35
|
OpenStruct.new(
|
|
30
36
|
successful: represented.select(&:success).collect(&:provider_name),
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
require "pact/consumer_contract"
|
|
2
|
-
require "pact/reification"
|
|
3
1
|
require "redcarpet"
|
|
4
|
-
require "
|
|
2
|
+
require "pact_broker/api/renderers/markdown/consumer_contract_renderer"
|
|
5
3
|
require "pact_broker/api/pact_broker_urls"
|
|
6
4
|
require "pact_broker/logging"
|
|
5
|
+
require "pact_broker/date_helper"
|
|
7
6
|
require "rack"
|
|
8
7
|
|
|
9
8
|
module PactBroker
|
|
@@ -11,8 +10,6 @@ module PactBroker
|
|
|
11
10
|
module Renderers
|
|
12
11
|
class HtmlPactRenderer
|
|
13
12
|
|
|
14
|
-
class NotAPactError < StandardError; end
|
|
15
|
-
|
|
16
13
|
include PactBroker::Logging
|
|
17
14
|
|
|
18
15
|
def self.call pact, options = {}
|
|
@@ -182,10 +179,11 @@ module PactBroker
|
|
|
182
179
|
end
|
|
183
180
|
|
|
184
181
|
def markdown
|
|
185
|
-
|
|
186
|
-
rescue StandardError
|
|
182
|
+
Markdown::ConsumerContractRenderer.call(JSON.parse(@json_content))
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
logger.info "Could not render the following content as a pact due to #{e.class} #{e.message}, showing raw content instead: #{@json_content}"
|
|
187
185
|
heading = "### A contract between #{@pact.consumer.name} and #{@pact.provider.name}"
|
|
188
|
-
warning = "_Note: this
|
|
186
|
+
warning = "_Note: this content could not be rendered as a pact, showing raw JSON instead._"
|
|
189
187
|
pretty_json = JSON.pretty_generate(@pact.content_hash)
|
|
190
188
|
"#{heading}\n#{warning}\n```json\n#{pretty_json}\n```\n"
|
|
191
189
|
end
|
|
@@ -194,67 +192,6 @@ module PactBroker
|
|
|
194
192
|
Redcarpet::Markdown.new(Redcarpet::Render::HTML, :fenced_code_blocks => true, :lax_spacing => true).render(markdown)
|
|
195
193
|
end
|
|
196
194
|
|
|
197
|
-
def consumer_contract
|
|
198
|
-
pact_object = JSON.parse(@json_content)
|
|
199
|
-
convert_v3_messages_to_interactions(pact_object)
|
|
200
|
-
|
|
201
|
-
pact_object["interactions"]&.each do |interaction|
|
|
202
|
-
add_dummy_html_request_response_to_asynchronous_message(interaction)
|
|
203
|
-
add_dummy_html_request_response_to_synchronous_message(interaction)
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
new_json_content = pact_object.to_json
|
|
207
|
-
|
|
208
|
-
Pact::ConsumerContract.from_json(new_json_content)
|
|
209
|
-
rescue => e
|
|
210
|
-
logger.info "Could not parse the following content to a Pact due to #{e.class} #{e.message}, showing raw content instead: #{@json_content}"
|
|
211
|
-
raise NotAPactError
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def convert_v3_messages_to_interactions(pact_object)
|
|
215
|
-
return unless pact_object.key?("messages")
|
|
216
|
-
|
|
217
|
-
logger.warn "Detected a v3 Pact, converting 'messages' to 'interactions'."
|
|
218
|
-
pact_object["interactions"] = pact_object.delete("messages")
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def add_dummy_html_request_response_to_asynchronous_message(interaction)
|
|
222
|
-
if !interaction["request"] || !interaction["response"]
|
|
223
|
-
logger.warn "Interaction '#{interaction['description']}' does not have a request or response, adding dummy request and response so that they can be parsed and appear on the UI."
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
interaction["request"] ||= {
|
|
227
|
-
method: "FAKE_ASYNC_METHOD",
|
|
228
|
-
path: interaction["description"]
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
unless interaction.key?("response")
|
|
232
|
-
interaction["response"] = {
|
|
233
|
-
status: "FAKE_ASYNC_METHOD",
|
|
234
|
-
body: {
|
|
235
|
-
contents: interaction.delete("contents"),
|
|
236
|
-
metadata: interaction.delete("metadata")
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def add_dummy_html_request_response_to_synchronous_message(interaction)
|
|
243
|
-
if interaction["type"] == "Synchronous/Messages"
|
|
244
|
-
interaction["request"] = {
|
|
245
|
-
method: "FAKE_SYNC_METHOD",
|
|
246
|
-
path: interaction["description"],
|
|
247
|
-
body: interaction.delete("request"),
|
|
248
|
-
}
|
|
249
|
-
interaction["response"] = {
|
|
250
|
-
status: "FAKE_SYNC_METHOD",
|
|
251
|
-
body: {
|
|
252
|
-
contents: interaction.delete("response"),
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
|
|
258
195
|
def h string
|
|
259
196
|
Rack::Utils.escape_html(string)
|
|
260
197
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "pact_broker/api/renderers/markdown/interaction_renderer"
|
|
2
|
+
require "pact_broker/api/renderers/markdown/interaction_view_model"
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
require "pact_broker/api/renderers/markdown"
|
|
5
|
+
|
|
6
|
+
module PactBroker
|
|
7
|
+
module Api
|
|
8
|
+
module Renderers
|
|
9
|
+
module Markdown
|
|
10
|
+
class ConsumerContractRenderer
|
|
11
|
+
|
|
12
|
+
# Raised when the content does not have the shape of a pact.
|
|
13
|
+
class NotAPactError < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize pact_hash
|
|
16
|
+
unless pact_hash.is_a?(Hash) && pact_hash["consumer"].is_a?(Hash) && pact_hash["provider"].is_a?(Hash)
|
|
17
|
+
raise NotAPactError, "content does not have consumer and provider objects"
|
|
18
|
+
end
|
|
19
|
+
@pact_hash = pact_hash
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.call pact_hash
|
|
23
|
+
new(pact_hash).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
title + summaries_title + summaries + interactions_title + full_interactions
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :pact_hash
|
|
33
|
+
|
|
34
|
+
def title
|
|
35
|
+
"# A pact between #{consumer_name} and #{provider_name}\n\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def interaction_renderers
|
|
39
|
+
@interaction_renderers ||= sorted_interactions.collect { |view_model| InteractionRenderer.new(view_model) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def summaries_title
|
|
43
|
+
"### Requests from #{consumer_name} to #{provider_name}\n\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def interactions_title
|
|
47
|
+
"### Interactions\n\n"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def summaries
|
|
51
|
+
interaction_renderers.collect(&:render_summary).join
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def full_interactions
|
|
55
|
+
interaction_renderers.collect(&:render_full_interaction).join
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sorted_interactions
|
|
59
|
+
interactions.collect { |interaction| InteractionViewModel.new(interaction, pact_hash) }.sort_by(&:sortable_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# v3 message pacts keep their interactions under "messages"
|
|
63
|
+
def interactions
|
|
64
|
+
Array(pact_hash["interactions"] || pact_hash["messages"])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def consumer_name
|
|
68
|
+
h(markdown_escape pact_hash.dig("consumer", "name"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def provider_name
|
|
72
|
+
h(markdown_escape pact_hash.dig("provider", "name"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def markdown_escape string
|
|
76
|
+
return nil unless string
|
|
77
|
+
string.gsub(MARKDOWN_SPECIAL_CHARS_REGEXP) { |char| "\\#{char}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def h(text)
|
|
81
|
+
Rack::Utils.escape_html(text)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "rack/utils"
|
|
3
|
+
|
|
4
|
+
module PactBroker
|
|
5
|
+
module Api
|
|
6
|
+
module Renderers
|
|
7
|
+
module Markdown
|
|
8
|
+
class InteractionRenderer
|
|
9
|
+
attr_reader :interaction
|
|
10
|
+
|
|
11
|
+
def initialize interaction_view_model
|
|
12
|
+
@interaction = interaction_view_model
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render_summary
|
|
16
|
+
formatted_provider_states = h(interaction.formatted_provider_states)
|
|
17
|
+
suffix = formatted_provider_states.empty? ? "" : " given #{formatted_provider_states}"
|
|
18
|
+
"* [#{h(interaction.description(true))}](##{interaction.id})#{suffix}\n\n"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render_full_interaction
|
|
22
|
+
if interaction.async?
|
|
23
|
+
render("/interaction_async.erb")
|
|
24
|
+
else
|
|
25
|
+
render("/interaction.erb")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render template_file
|
|
30
|
+
ERB.new(template_string(template_file)).result(binding)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The template file is written with only ASCII range characters, so we
|
|
34
|
+
# can read as UTF-8. But rendered strings must have same encoding as
|
|
35
|
+
# script encoding because it will joined to strings which are produced by
|
|
36
|
+
# string literal.
|
|
37
|
+
def template_string(template_file)
|
|
38
|
+
File.read(template_contents(template_file), external_encoding: Encoding::UTF_8).force_encoding(__ENCODING__)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def template_contents(template_file)
|
|
42
|
+
File.dirname(__FILE__) + template_file
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def h(text)
|
|
46
|
+
Rack::Utils.escape_html(text)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "json"
|
|
3
|
+
require "pact_broker/api/renderers/markdown"
|
|
4
|
+
|
|
5
|
+
module PactBroker
|
|
6
|
+
module Api
|
|
7
|
+
module Renderers
|
|
8
|
+
module Markdown
|
|
9
|
+
# Presents one interaction from a parsed pact hash for the markdown
|
|
10
|
+
# templates. Bodies are printed as stored, so v1 pacts show their
|
|
11
|
+
# inline json_class matcher objects verbatim.
|
|
12
|
+
class InteractionViewModel
|
|
13
|
+
MARKDOWN_BOLD_CHARACTERS = "**"
|
|
14
|
+
ORDERED_KEYS = ["method", "path", "query", "status", "headers", "body"].freeze
|
|
15
|
+
ASYNC_REQUEST = "ASYNC_REQUEST"
|
|
16
|
+
|
|
17
|
+
def initialize interaction, pact
|
|
18
|
+
@interaction = interaction
|
|
19
|
+
@pact = pact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def id
|
|
23
|
+
@id ||= begin
|
|
24
|
+
full_desc = if has_provider_state?
|
|
25
|
+
"#{description} given #{provider_state_name}"
|
|
26
|
+
else
|
|
27
|
+
description
|
|
28
|
+
end
|
|
29
|
+
CGI.escapeHTML(full_desc.gsub(/\s+/, "_"))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sortable_id
|
|
34
|
+
"#{(interaction["description"] || "").downcase} #{http_response["status"]} #{(provider_state_name || "").downcase}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def consumer_name
|
|
38
|
+
markdown_escape pact.dig("consumer", "name")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def provider_name
|
|
42
|
+
markdown_escape pact.dig("provider", "name")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def has_provider_state?
|
|
46
|
+
!provider_state_name.nil? && !provider_state_name.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def provider_state start_of_sentence = false
|
|
50
|
+
markdown_escape apply_capitals(provider_state_name.strip, start_of_sentence)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def formatted_provider_states mark_bold: false
|
|
54
|
+
bold_marker = mark_bold ? MARKDOWN_BOLD_CHARACTERS : ""
|
|
55
|
+
|
|
56
|
+
provider_state_names.map do |name|
|
|
57
|
+
"#{bold_marker}#{markdown_escape(apply_capitals(name.strip, false))}#{bold_marker}"
|
|
58
|
+
end.join(" and ")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def description start_of_sentence = false
|
|
62
|
+
return "" unless interaction["description"]
|
|
63
|
+
markdown_escape apply_capitals(interaction["description"].strip, start_of_sentence)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def request
|
|
67
|
+
return ASYNC_REQUEST if async?
|
|
68
|
+
JSON.pretty_generate(clean_request)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def response
|
|
72
|
+
JSON.pretty_generate(clean_response)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def async?
|
|
76
|
+
interaction["type"] == "Asynchronous/Messages" || !interaction.key?("request")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
attr_reader :interaction, :pact
|
|
82
|
+
|
|
83
|
+
def sync?
|
|
84
|
+
interaction["type"] == "Synchronous/Messages"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def http_request
|
|
88
|
+
interaction["request"].is_a?(Hash) ? interaction["request"] : {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def http_response
|
|
92
|
+
interaction["response"].is_a?(Hash) ? interaction["response"] : {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def clean_request
|
|
96
|
+
if sync?
|
|
97
|
+
http_request.slice("contents", "metadata")
|
|
98
|
+
else
|
|
99
|
+
ordered_clean_hash(http_request).tap do |h|
|
|
100
|
+
h["body"] = http_request["body"] if http_request["body"]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def clean_response
|
|
106
|
+
if sync?
|
|
107
|
+
Array(interaction["response"]).map { |item| item.to_h.slice("contents", "metadata") }
|
|
108
|
+
elsif async?
|
|
109
|
+
{ "contents" => interaction["contents"], "metadata" => interaction["metadata"] }
|
|
110
|
+
else
|
|
111
|
+
ordered_clean_hash(http_response)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Removes empty header and body hashes, as an empty hash means
|
|
116
|
+
# "allow anything" and reads more cleanly when omitted.
|
|
117
|
+
def ordered_clean_hash source
|
|
118
|
+
ORDERED_KEYS.each_with_object({}) do |key, target|
|
|
119
|
+
if source.key? key
|
|
120
|
+
target[key] = source[key] unless value_is_an_empty_hash(source[key])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def value_is_an_empty_hash value
|
|
126
|
+
value.is_a?(Hash) && value.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def provider_state_names
|
|
130
|
+
states = interaction["providerStates"]
|
|
131
|
+
if states.is_a?(Array) && states.any?
|
|
132
|
+
states.map { |state| state.is_a?(Hash) ? state["name"] : state }.compact
|
|
133
|
+
else
|
|
134
|
+
[interaction["providerState"] || interaction["provider_state"]].compact
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def provider_state_name
|
|
139
|
+
provider_state_names.first
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def apply_capitals string, start_of_sentence = false
|
|
143
|
+
start_of_sentence ? capitalize_first_letter(string) : lowercase_first_letter(string)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def capitalize_first_letter string
|
|
147
|
+
string[0].upcase + string[1..-1]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def lowercase_first_letter string
|
|
151
|
+
string[0].downcase + string[1..-1]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def markdown_escape string
|
|
155
|
+
return nil unless string
|
|
156
|
+
string.gsub(MARKDOWN_SPECIAL_CHARS_REGEXP) { |char| "\\#{char}" }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|