pact_broker 2.121.2 → 2.122.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49b03441c7f3ea31c20e1db9c053c6b4ce782ad321007ae1da0614774a62d614
4
- data.tar.gz: 4898703bc36456dfd31a8d590834c2e8fd6c6f7f2a3f1919285b6358cb7e1311
3
+ metadata.gz: f515fab91233eb259d233ef8ff18b35014c55dfebb10a5cec183283bbe760d54
4
+ data.tar.gz: 2f35f5f3f748d3df875df0ce5b8270f2b1e4b2cf8682804fda41def6d31b88a1
5
5
  SHA512:
6
- metadata.gz: 54728a387b6667b0867929ad94eec94fcd0eb303a84c1e8fce2311705b7971c168205a62364f77fc5754483cef7605f582d3b820d833c25693cd6cd2d17c23f7
7
- data.tar.gz: '069dbe6afe3197ef3ad6f8035d6aee22998d8ecdec1cd582dae0d94a5555741a347a5be2ba129609f97189526a5a2ca2469fe2673805dabf26b8c22abe9acab6'
6
+ metadata.gz: 6eaefdf8cd5b2b9856bb076579bf034889b7fdab56cfe159c6e1ed56e74d72dca7929ca983290238e32f280e8d7e9b9bc53b9512b4d1562bde122de035e2f50b
7
+ data.tar.gz: fe3c3008fa385b8585d7b2faa08e8196194e92fdfc496a813f0208373905abacdcded76e0eb4b8dc115b588b20be7e5cd32580ec78c4cbddfc7db15222936b73
data/CHANGELOG.md CHANGED
@@ -1,3 +1,76 @@
1
+ ## [2.122.0] _2026-10-01_
2
+
3
+ ### 🚀 Features
4
+
5
+ - Add a unified diff renderer
6
+ - Render pact diffs as unified diffs
7
+ > **Migration note:** the pact diff endpoints return a standard unified diff.
8
+ > This covers the `diff/previous-distinct`, `diff/version/{version}` and
9
+ > `pact-version/{sha}/diff/pact-version/{sha}` resources under
10
+ > `/pacts/provider/{provider}/consumer/{consumer}`, and the diff notice
11
+ > that `POST /contracts/publish` returns when a publish would modify an
12
+ > existing pact. Clients that parse this output must handle `@@` hunk
13
+ > headers and unchanged context lines. Keys that exist only in the newer
14
+ > pact now appear as `+` lines.
15
+ >
16
+ > As an example, take an interaction whose request method changes from
17
+ > post to get and which gains an `Accept` header. The diff was previously:
18
+ >
19
+ > ```diff
20
+ > "interactions": [
21
+ > {
22
+ > "request": {
23
+ > - "method": "post"
24
+ > + "method": "get"
25
+ > }
26
+ > }
27
+ > ]
28
+ > ```
29
+ >
30
+ > It is now:
31
+ >
32
+ > ```diff
33
+ > @@ -6,7 +6,10 @@
34
+ > {
35
+ > "description": "a request",
36
+ > "request": {
37
+ > - "method": "post",
38
+ > + "headers": {
39
+ > + "Accept": "application/json"
40
+ > + },
41
+ > + "method": "get",
42
+ > "path": "/"
43
+ > },
44
+ > "response": {
45
+ > ```
46
+
47
+ ### 🐛 Bug Fixes
48
+
49
+ - Require date_helper in HtmlPactRenderer
50
+
51
+ ### 🚜 Refactor
52
+
53
+ - Remove unused pact/doc generator code
54
+ - Compare pact content with Hash equality
55
+ - Move lib/pact/doc under lib/pact_broker/api/renderers
56
+ - Move the markdown pact renderer under PactBroker
57
+ - Render pact HTML from the parsed hash
58
+
59
+ ### 🧪 Testing
60
+
61
+ - Stop snapshotting non-determinate response headers
62
+ - Replace Pact::Matchers in spec helpers with a subset matcher
63
+
64
+ ### 🛠️ Miscellaneous Tasks
65
+
66
+ - _(ci)_ Standardize renovate config
67
+ - _(ci)_ Remove stale internal workflow
68
+ - Remove the pact-support dependency
69
+ > **Migration note:** pact_broker no longer depends on pact-support, so code
70
+ > alongside the broker can no longer rely on Pact::* constants. Add
71
+ > pact-support to your own Gemfile if that code needs them.
72
+ - _(release)_ Render Migration-Note footers in the changelog
73
+
1
74
  ## [2.121.2] _2026-09-17_
2
75
 
3
76
  ### 🐛 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.4.28"
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
@@ -1,9 +1,8 @@
1
- require "pact/consumer_contract"
2
- require "pact/reification"
3
1
  require "redcarpet"
4
- require "pact/doc/markdown/consumer_contract_renderer"
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
- Pact::Doc::Markdown::ConsumerContractRenderer.call consumer_contract
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 contract could not be parsed to a v1 or v2 Pact, showing raw content instead._"
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
@@ -0,0 +1,9 @@
1
+ module PactBroker
2
+ module Api
3
+ module Renderers
4
+ module Markdown
5
+ MARKDOWN_SPECIAL_CHARS_REGEXP = /[\\`*_\[\](){}#+\-.!|]/
6
+ end
7
+ end
8
+ end
9
+ end
@@ -1,14 +1,11 @@
1
- require "pact/support/matchers"
2
1
  require "pact_broker/json"
3
- require "pact/matchers/unix_diff_formatter"
2
+ require "pact_broker/unified_diff"
4
3
  require "pact_broker/pacts/sort_content"
5
4
  require "pact_broker/pacts/content"
6
5
 
7
6
  module PactBroker
8
7
  module Pacts
9
8
  class CreateFormattedDiff
10
- extend Pact::Matchers
11
-
12
9
  def self.call pact_json_content, previous_pact_json_content, raw: false
13
10
  pact_hash = JSON.parse(pact_json_content, **PactBroker::PACT_PARSING_OPTIONS)
14
11
  previous_pact_hash = JSON.parse(previous_pact_json_content, **PactBroker::PACT_PARSING_OPTIONS)
@@ -18,9 +15,7 @@ module PactBroker
18
15
  previous_pact_hash = SortContent.call(PactBroker::Pacts::Content.from_hash(previous_pact_hash).without_ids.to_hash)
19
16
  end
20
17
 
21
- difference = diff(previous_pact_hash, pact_hash)
22
-
23
- Pact::Matchers::UnixDiffFormatter.call(difference, colour: false, include_explanation: false)
18
+ PactBroker::UnifiedDiff.call(JSON.pretty_generate(previous_pact_hash), JSON.pretty_generate(pact_hash))
24
19
  end
25
20
  end
26
21
  end
@@ -3,7 +3,6 @@ require "pact_broker/logging"
3
3
  require "pact_broker/pacts/generate_sha"
4
4
  require "pact_broker/pacts/pact_publication"
5
5
  require "pact_broker/pacts/pact_version"
6
- require "pact/shared/json_differ"
7
6
  require "pact_broker/domain"
8
7
  require "pact_broker/pacts/parse"
9
8
  require "pact_broker/pacts/latest_pact_publication_id_for_consumer_version"
@@ -386,7 +385,7 @@ module PactBroker
386
385
  end
387
386
 
388
387
  def different? pact, other_pact
389
- Pact::JsonDiffer.(pact.content_hash, other_pact.content_hash, allow_unexpected_keys: false).any?
388
+ pact.content_hash != other_pact.content_hash
390
389
  end
391
390
 
392
391
  def find_or_create_pact_version consumer_id, provider_id, pact_version_sha, json_content
@@ -0,0 +1,26 @@
1
+ require "diff/lcs"
2
+ require "diff/lcs/hunk"
3
+
4
+ module PactBroker
5
+ # Renders the difference between two texts in unified diff format,
6
+ # without the file header lines.
7
+ class UnifiedDiff
8
+ def self.call(old_text, new_text, context: 3)
9
+ old_lines = old_text.split("\n")
10
+ new_lines = new_text.split("\n")
11
+ pieces = Diff::LCS.diff(old_lines, new_lines)
12
+ return "" if pieces.empty?
13
+
14
+ hunks = []
15
+ length_difference = 0
16
+ pieces.each do |piece|
17
+ hunk = Diff::LCS::Hunk.new(old_lines, new_lines, piece, context, length_difference)
18
+ length_difference = hunk.file_length_difference
19
+ hunk.merge(hunks.pop) if context.positive? && hunks.last && hunk.overlaps?(hunks.last)
20
+ hunks << hunk
21
+ end
22
+
23
+ hunks.collect { |hunk| hunk.diff(:unified) }.join("\n")
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module PactBroker
2
- VERSION = "2.121.2"
2
+ VERSION = "2.122.0"
3
3
  end
data/pact_broker.gemspec CHANGED
@@ -59,7 +59,6 @@ Gem::Specification.new do |gem|
59
59
  gem.add_runtime_dependency "semver2", "~> 3.4.2"
60
60
  gem.add_runtime_dependency "rack", "~> 3.2"
61
61
  gem.add_runtime_dependency "redcarpet", "~> 3.5"
62
- gem.add_runtime_dependency "pact-support", "~> 2.0"
63
62
  gem.add_runtime_dependency "haml", "~>5.0"
64
63
  gem.add_runtime_dependency "sucker_punch", "~>3.0"
65
64
  gem.add_runtime_dependency "rack-protection", "~> 4.1"
@@ -70,6 +69,7 @@ Gem::Specification.new do |gem|
70
69
  gem.add_runtime_dependency "anyway_config", "~> 2.1"
71
70
  gem.add_runtime_dependency "request_store", "~> 1.5"
72
71
  gem.add_runtime_dependency "moments", "~> 0.2"
72
+ gem.add_runtime_dependency "diff-lcs", "~> 1.5"
73
73
  # dropped in ruby 3.4 stdlib
74
74
  gem.add_runtime_dependency "mutex_m", "~> 0.3" # until as-notifications -> webmachine requires it
75
75
  gem.add_runtime_dependency "csv", "~> 3.0" # lib/pact_broker/api/decorators/relationships_csv_decorator.rb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pact_broker
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.121.2
4
+ version: 2.122.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bethany Skurrie
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-09-17 00:00:00.000000000 Z
13
+ date: 2026-10-01 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: json
@@ -152,20 +152,6 @@ dependencies:
152
152
  - - "~>"
153
153
  - !ruby/object:Gem::Version
154
154
  version: '3.5'
155
- - !ruby/object:Gem::Dependency
156
- name: pact-support
157
- requirement: !ruby/object:Gem::Requirement
158
- requirements:
159
- - - "~>"
160
- - !ruby/object:Gem::Version
161
- version: '2.0'
162
- type: :runtime
163
- prerelease: false
164
- version_requirements: !ruby/object:Gem::Requirement
165
- requirements:
166
- - - "~>"
167
- - !ruby/object:Gem::Version
168
- version: '2.0'
169
155
  - !ruby/object:Gem::Dependency
170
156
  name: haml
171
157
  requirement: !ruby/object:Gem::Requirement
@@ -306,6 +292,20 @@ dependencies:
306
292
  - - "~>"
307
293
  - !ruby/object:Gem::Version
308
294
  version: '0.2'
295
+ - !ruby/object:Gem::Dependency
296
+ name: diff-lcs
297
+ requirement: !ruby/object:Gem::Requirement
298
+ requirements:
299
+ - - "~>"
300
+ - !ruby/object:Gem::Version
301
+ version: '1.5'
302
+ type: :runtime
303
+ prerelease: false
304
+ version_requirements: !ruby/object:Gem::Requirement
305
+ requirements:
306
+ - - "~>"
307
+ - !ruby/object:Gem::Version
308
+ version: '1.5'
309
309
  - !ruby/object:Gem::Dependency
310
310
  name: mutex_m
311
311
  requirement: !ruby/object:Gem::Requirement
@@ -593,18 +593,6 @@ files:
593
593
  - docs/developer/matrix.md
594
594
  - docs/developer/rack.md
595
595
  - docs/matrix-optimisation/README.md
596
- - lib/pact/doc.rb
597
- - lib/pact/doc/doc_file.rb
598
- - lib/pact/doc/generate.rb
599
- - lib/pact/doc/generator.rb
600
- - lib/pact/doc/interaction_view_model.rb
601
- - lib/pact/doc/markdown/consumer_contract_renderer.rb
602
- - lib/pact/doc/markdown/generator.rb
603
- - lib/pact/doc/markdown/index_renderer.rb
604
- - lib/pact/doc/markdown/interaction.erb
605
- - lib/pact/doc/markdown/interaction_async.erb
606
- - lib/pact/doc/markdown/interaction_renderer.rb
607
- - lib/pact/doc/sort_interactions.rb
608
596
  - lib/pact_broker.rb
609
597
  - lib/pact_broker/api.rb
610
598
  - lib/pact_broker/api/authorization/resource_access_policy.rb
@@ -717,6 +705,12 @@ files:
717
705
  - lib/pact_broker/api/paths.rb
718
706
  - lib/pact_broker/api/renderers/html_pact_renderer.rb
719
707
  - lib/pact_broker/api/renderers/integrations_dot_renderer.rb
708
+ - lib/pact_broker/api/renderers/markdown.rb
709
+ - lib/pact_broker/api/renderers/markdown/consumer_contract_renderer.rb
710
+ - lib/pact_broker/api/renderers/markdown/interaction.erb
711
+ - lib/pact_broker/api/renderers/markdown/interaction_async.erb
712
+ - lib/pact_broker/api/renderers/markdown/interaction_renderer.rb
713
+ - lib/pact_broker/api/renderers/markdown/interaction_view_model.rb
720
714
  - lib/pact_broker/api/resources.rb
721
715
  - lib/pact_broker/api/resources/after_reply.rb
722
716
  - lib/pact_broker/api/resources/all_webhooks.rb
@@ -1093,6 +1087,7 @@ files:
1093
1087
  - lib/pact_broker/ui/views/index/show.haml
1094
1088
  - lib/pact_broker/ui/views/layouts/main.haml
1095
1089
  - lib/pact_broker/ui/views/matrix/show.haml
1090
+ - lib/pact_broker/unified_diff.rb
1096
1091
  - lib/pact_broker/verifications/latest_verification_for_consumer_and_provider.rb
1097
1092
  - lib/pact_broker/verifications/latest_verification_for_consumer_version_tag.rb
1098
1093
  - lib/pact_broker/verifications/latest_verification_id_for_pact_version_and_provider_version.rb
@@ -1,38 +0,0 @@
1
- module Pact
2
- module Doc
3
- class DocFile
4
-
5
- def initialize consumer_contract, dir, consumer_contract_renderer, file_extension
6
- @dir = dir
7
- @consumer_contract = consumer_contract
8
- @consumer_contract_renderer = consumer_contract_renderer
9
- @file_extension = file_extension
10
- end
11
-
12
- def write
13
- File.open(path, "w") { |io| io << doc_file_contents }
14
- end
15
-
16
- def title
17
- consumer_contract.provider.name
18
- end
19
-
20
- def name
21
- "#{consumer_contract.consumer.name} - #{consumer_contract.provider.name}#{file_extension}"
22
- end
23
-
24
- private
25
-
26
- attr_reader :dir, :consumer_contract, :consumer_contract_renderer, :file_extension
27
-
28
- def path
29
- File.join(dir, name)
30
- end
31
-
32
- def doc_file_contents
33
- consumer_contract_renderer.call(consumer_contract)
34
- end
35
-
36
- end
37
- end
38
- end
@@ -1,11 +0,0 @@
1
- module Pact
2
- module Doc
3
- class Generate
4
-
5
- def self.call pact_dir = Pact.configuration.pact_dir, doc_dir = Pact.configuration.doc_dir, doc_generators = Pact.configuration.doc_generators
6
- doc_generators.each{| doc_generator| doc_generator.call pact_dir, doc_dir }
7
- end
8
-
9
- end
10
- end
11
- end
@@ -1,82 +0,0 @@
1
- require "pact/doc/doc_file"
2
- require "fileutils"
3
-
4
- module Pact
5
- module Doc
6
-
7
- class Generator
8
-
9
- def initialize pact_dir, doc_dir, options
10
- @doc_dir = doc_dir
11
- @pact_dir = pact_dir
12
- @consumer_contract_renderer = options[:consumer_contract_renderer]
13
- @doc_type = options[:doc_type]
14
- @file_extension = options[:file_extension]
15
- @index_renderer = options[:index_renderer]
16
- @index_name = options[:index_name]
17
- @after = options.fetch(:after, lambda{|pact_dir_arg, target_dir_arg, consumer_contracts_arg| })
18
- end
19
-
20
- def call
21
- ensure_target_dir_exists_and_is_clean
22
- write_index if consumer_contracts.any?
23
- write_doc_files
24
- perform_after_hook
25
- end
26
-
27
- private
28
-
29
- attr_reader :doc_dir, :pact_dir, :consumer_contract_renderer, :doc_type, :file_extension, :index_renderer, :after
30
-
31
- def write_index
32
- File.open(index_file_path, "w") { |io| io << index_file_contents }
33
- end
34
-
35
- def index_file_path
36
- File.join(target_dir, "#{@index_name}#{file_extension}")
37
- end
38
-
39
- def index_file_contents
40
- index_renderer.call(consumer_contracts.first.consumer.name, index_data)
41
- end
42
-
43
- def index_data
44
- doc_files.each_with_object({}) do | doc_file, data |
45
- data[doc_file.title] = doc_file.name
46
- end
47
- end
48
-
49
- def write_doc_files
50
- doc_files.each(&:write)
51
- end
52
-
53
- def doc_files
54
- consumer_contracts.collect do | consumer_contract |
55
- DocFile.new(consumer_contract, target_dir, consumer_contract_renderer, file_extension)
56
- end
57
- end
58
-
59
- def consumer_contracts
60
- @consumer_contracts ||= begin
61
- Dir.glob("#{pact_dir}/**").collect do |file|
62
- Pact::ConsumerContract.from_uri file
63
- end
64
- end
65
- end
66
-
67
- def perform_after_hook
68
- after.call(pact_dir, target_dir, consumer_contracts)
69
- end
70
-
71
- def ensure_target_dir_exists_and_is_clean
72
- FileUtils.rm_rf target_dir
73
- FileUtils.mkdir_p target_dir
74
- end
75
-
76
- def target_dir
77
- File.join(doc_dir, doc_type)
78
- end
79
-
80
- end
81
- end
82
- end
@@ -1,157 +0,0 @@
1
- require "pact/shared/active_support_support"
2
- require "pact/reification"
3
- require "cgi"
4
- require "pact/doc"
5
-
6
- module Pact
7
- module Doc
8
- class InteractionViewModel
9
-
10
- include Pact::ActiveSupportSupport
11
-
12
- MARKDOWN_BOLD_CHARACTERS = "**"
13
-
14
- def initialize interaction, consumer_contract
15
- @interaction = interaction
16
- @consumer_contract = consumer_contract
17
- end
18
-
19
- def id
20
- @id ||= begin
21
- full_desc = if has_provider_state?
22
- "#{description} given #{interaction.provider_state}"
23
- else
24
- description
25
- end
26
- CGI.escapeHTML(full_desc.gsub(/\s+/,"_"))
27
- end
28
- end
29
-
30
- def request_method
31
- interaction.request.method.upcase
32
- end
33
-
34
- def request_path
35
- interaction.request.path
36
- end
37
-
38
- def response_status
39
- interaction.response.status
40
- end
41
-
42
- def consumer_name
43
- markdown_escape @consumer_contract.consumer.name
44
- end
45
-
46
- def provider_name
47
- markdown_escape @consumer_contract.provider.name
48
- end
49
-
50
- def has_provider_state?
51
- @interaction.provider_state && !@interaction.provider_state.empty?
52
- end
53
-
54
- def provider_state start_of_sentence = false
55
- markdown_escape apply_capitals(@interaction.provider_state.strip, start_of_sentence)
56
- end
57
-
58
- def formatted_provider_states mark_bold: false
59
- bold_marker = mark_bold ? MARKDOWN_BOLD_CHARACTERS : ""
60
-
61
- (@interaction.provider_states || []).map do |ps|
62
- "#{bold_marker}" \
63
- "#{markdown_escape(apply_capitals(ps.name.strip, false))}" \
64
- "#{bold_marker}"
65
- end.join(" and ")
66
- end
67
-
68
- def description start_of_sentence = false
69
- return "" unless @interaction.description
70
- markdown_escape apply_capitals(@interaction.description.strip, start_of_sentence)
71
- end
72
-
73
- def request
74
- the_clean_request = clean_request
75
- if the_clean_request == "ASYNC_REQUEST"
76
- "ASYNC_REQUEST"
77
- else
78
- fix_json_formatting JSON.pretty_generate(clean_request)
79
- end
80
- end
81
-
82
- def response
83
- fix_json_formatting JSON.pretty_generate(clean_response)
84
- end
85
-
86
- private
87
-
88
- attr_reader :interaction, :consumer_contract
89
-
90
- def clean_request
91
- reified = Reification.from_term(interaction.request)
92
- return "ASYNC_REQUEST" if reified[:method] == "FAKE_ASYNC_METHOD"
93
-
94
- ordered = ordered_clean_hash(reified).tap do |h|
95
- h[:body] = reified[:body] if reified[:body]
96
- end
97
-
98
- return ordered[:body].to_h.slice("contents", "metadata") if ordered[:method] == "FAKE_SYNC_METHOD"
99
-
100
- ordered
101
- end
102
-
103
- def clean_response
104
- raw = Reification.from_term(interaction.response)
105
-
106
- case raw[:status]
107
- when "FAKE_ASYNC_METHOD"
108
- raw[:body] || {}
109
- when "FAKE_SYNC_METHOD"
110
- Array(raw.dig(:body, "contents")).map { |item| item.slice("contents", "metadata") }
111
- else
112
- ordered_clean_hash(raw)
113
- end
114
- end
115
-
116
- # Remove empty body and headers hashes from response, and empty headers from request,
117
- # as an empty hash means "allow anything" - it's more intuitive and cleaner to just
118
- # remove the empty hashes from display.
119
- def ordered_clean_hash source
120
- ordered_keys.each_with_object({}) do |key, target|
121
- if source.key? key
122
- target[key] = source[key] unless value_is_an_empty_hash(source[key])
123
- end
124
- end
125
- end
126
-
127
- def value_is_an_empty_hash value
128
- value.is_a?(Hash) && value.empty?
129
- end
130
-
131
- def ordered_keys
132
- [:method, :path, :query, :status, :headers, :body]
133
- end
134
-
135
- def remove_key_if_empty key, hash
136
- hash.delete(key) if hash[key].is_a?(Hash) && hash[key].empty?
137
- end
138
-
139
- def apply_capitals string, start_of_sentence = false
140
- start_of_sentence ? capitalize_first_letter(string) : lowercase_first_letter(string)
141
- end
142
-
143
- def capitalize_first_letter string
144
- string[0].upcase + string[1..-1]
145
- end
146
-
147
- def lowercase_first_letter string
148
- string[0].downcase + string[1..-1]
149
- end
150
-
151
- def markdown_escape string
152
- return nil unless string
153
- string.gsub(Pact::Doc::MARKDOWN_SPECIAL_CHARS_REGEXP) { |char| "\\#{char}" }
154
- end
155
- end
156
- end
157
- end
@@ -1,74 +0,0 @@
1
- require "pact/doc/markdown/interaction_renderer"
2
- require "pact/doc/sort_interactions"
3
- require "rack/utils"
4
- require "pact/doc"
5
-
6
- module Pact
7
- module Doc
8
- module Markdown
9
- class ConsumerContractRenderer
10
-
11
- def initialize consumer_contract
12
- @consumer_contract = consumer_contract
13
- end
14
-
15
- def self.call consumer_contract
16
- new(consumer_contract).call
17
- end
18
-
19
- def call
20
- title + summaries_title + summaries + interactions_title + full_interactions
21
- end
22
-
23
- private
24
-
25
- attr_reader :consumer_contract
26
-
27
- def title
28
- "# A pact between #{consumer_name} and #{provider_name}\n\n"
29
- end
30
-
31
- def interaction_renderers
32
- @interaction_renderers ||= sorted_interactions.collect{|interaction| InteractionRenderer.new interaction, @consumer_contract}
33
- end
34
-
35
- def summaries_title
36
- "### Requests from #{consumer_name} to #{provider_name}\n\n"
37
- end
38
-
39
- def interactions_title
40
- "### Interactions\n\n"
41
- end
42
-
43
- def summaries
44
- interaction_renderers.collect(&:render_summary).join
45
- end
46
-
47
- def full_interactions
48
- interaction_renderers.collect(&:render_full_interaction).join
49
- end
50
-
51
- def sorted_interactions
52
- SortInteractions.call(consumer_contract.interactions)
53
- end
54
-
55
- def consumer_name
56
- h(markdown_escape consumer_contract.consumer.name)
57
- end
58
-
59
- def provider_name
60
- h(markdown_escape consumer_contract.provider.name)
61
- end
62
-
63
- def markdown_escape string
64
- return nil unless string
65
- string.gsub(Pact::Doc::MARKDOWN_SPECIAL_CHARS_REGEXP) { |char| "\\#{char}" }
66
- end
67
-
68
- def h(text)
69
- Rack::Utils.escape_html(text)
70
- end
71
- end
72
- end
73
- end
74
- end
@@ -1,24 +0,0 @@
1
- require "pact/doc/generator"
2
- require "pact/doc/markdown/consumer_contract_renderer"
3
- require "pact/doc/markdown/index_renderer"
4
-
5
- module Pact
6
- module Doc
7
- module Markdown
8
- class Generator < Pact::Doc::Generator
9
- def initialize pact_dir, doc_dir
10
- super(pact_dir, doc_dir,
11
- consumer_contract_renderer: ConsumerContractRenderer,
12
- doc_type: "markdown",
13
- file_extension: ".md",
14
- index_renderer: IndexRenderer,
15
- index_name: "README")
16
- end
17
-
18
- def self.call pact_dir, doc_dir
19
- new(pact_dir, doc_dir).call
20
- end
21
- end
22
- end
23
- end
24
- end
@@ -1,40 +0,0 @@
1
- module Pact
2
- module Doc
3
- module Markdown
4
- class IndexRenderer
5
- attr_reader :consumer_name
6
- attr_reader :docs # Hash of pact title => file_name
7
-
8
- def initialize consumer_name, docs
9
- @consumer_name = consumer_name
10
- @docs = docs
11
- end
12
-
13
- def self.call consumer_name, docs
14
- new(consumer_name, docs).call
15
- end
16
-
17
- def call
18
- title + "\n\n" + table_of_contents + "\n"
19
- end
20
-
21
- private
22
-
23
- def table_of_contents
24
- docs.collect do | title, file_name |
25
- item title, file_name
26
- end.join("\n")
27
- end
28
-
29
- def title
30
- "### Pacts for #{consumer_name}"
31
- end
32
-
33
- def item title, file_name
34
- "* [#{title}](#{file_name})"
35
- end
36
-
37
- end
38
- end
39
- end
40
- end
@@ -1,51 +0,0 @@
1
- require "erb"
2
- require "pact/doc/interaction_view_model"
3
- require "rack/utils"
4
-
5
- module Pact
6
- module Doc
7
- module Markdown
8
- class InteractionRenderer
9
- attr_reader :interaction
10
-
11
- def initialize interaction, pact
12
- @interaction = InteractionViewModel.new(interaction, pact)
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.request == "ASYNC_REQUEST"
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
@@ -1,14 +0,0 @@
1
- module Pact
2
- module Doc
3
- class SortInteractions
4
- def self.call interactions
5
- interactions.sort{|a, b| sortable_id(a) <=> sortable_id(b)}
6
- end
7
-
8
-
9
- def self.sortable_id interaction
10
- "#{(interaction.description || '').downcase} #{interaction.response.status} #{(interaction.provider_state || '').downcase}"
11
- end
12
- end
13
- end
14
- end
data/lib/pact/doc.rb DELETED
@@ -1,5 +0,0 @@
1
- module Pact
2
- module Doc
3
- MARKDOWN_SPECIAL_CHARS_REGEXP = /[\\`*_\[\](){}#+\-.!|]/
4
- end
5
- end