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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/Gemfile +1 -4
  4. data/lib/pact_broker/api/contracts/consumer_version_selector_contract.rb +17 -1
  5. data/lib/pact_broker/api/contracts/utf_8_validation.rb +5 -1
  6. data/lib/pact_broker/api/decorators/pacts_for_verification_query_decorator.rb +1 -0
  7. data/lib/pact_broker/api/decorators/publish_contract_decorator.rb +4 -3
  8. data/lib/pact_broker/api/decorators/verification_decorator.rb +12 -2
  9. data/lib/pact_broker/api/decorators/verification_summary_decorator.rb +6 -0
  10. data/lib/pact_broker/api/renderers/html_pact_renderer.rb +6 -69
  11. data/lib/pact_broker/api/renderers/markdown/consumer_contract_renderer.rb +87 -0
  12. data/lib/pact_broker/api/renderers/markdown/interaction_renderer.rb +52 -0
  13. data/lib/pact_broker/api/renderers/markdown/interaction_view_model.rb +162 -0
  14. data/lib/pact_broker/api/renderers/markdown.rb +9 -0
  15. data/lib/pact_broker/api/resources/publish_contracts.rb +10 -6
  16. data/lib/pact_broker/doc/views/provider-pacts-for-verification.markdown +10 -2
  17. data/lib/pact_broker/hash_refinements.rb +2 -2
  18. data/lib/pact_broker/locale/en.yml +3 -0
  19. data/lib/pact_broker/pacts/create_formatted_diff.rb +2 -7
  20. data/lib/pact_broker/pacts/generate_sha.rb +7 -4
  21. data/lib/pact_broker/pacts/pacts_for_verification_repository.rb +41 -21
  22. data/lib/pact_broker/pacts/repository.rb +2 -2
  23. data/lib/pact_broker/pacts/selected_pact.rb +4 -0
  24. data/lib/pact_broker/pacts/selector.rb +20 -2
  25. data/lib/pact_broker/pacts/selectors.rb +4 -0
  26. data/lib/pact_broker/unified_diff.rb +26 -0
  27. data/lib/pact_broker/verifications/repository.rb +1 -1
  28. data/lib/pact_broker/verifications/summary_for_consumer_version.rb +4 -0
  29. data/lib/pact_broker/version.rb +1 -1
  30. data/pact_broker.gemspec +2 -2
  31. metadata +25 -30
  32. data/lib/pact/doc/doc_file.rb +0 -38
  33. data/lib/pact/doc/generate.rb +0 -11
  34. data/lib/pact/doc/generator.rb +0 -82
  35. data/lib/pact/doc/interaction_view_model.rb +0 -157
  36. data/lib/pact/doc/markdown/consumer_contract_renderer.rb +0 -74
  37. data/lib/pact/doc/markdown/generator.rb +0 -24
  38. data/lib/pact/doc/markdown/index_renderer.rb +0 -40
  39. data/lib/pact/doc/markdown/interaction_renderer.rb +0 -51
  40. data/lib/pact/doc/sort_interactions.rb +0 -14
  41. data/lib/pact/doc.rb +0 -5
  42. /data/lib/{pact/doc → pact_broker/api/renderers}/markdown/interaction.erb +0 -0
  43. /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: 49b03441c7f3ea31c20e1db9c053c6b4ce782ad321007ae1da0614774a62d614
4
- data.tar.gz: 4898703bc36456dfd31a8d590834c2e8fd6c6f7f2a3f1919285b6358cb7e1311
3
+ metadata.gz: e464517845e4046dd28826ab16b367d5080b69cb535f9a9d5901feda4f262bd8
4
+ data.tar.gz: 9959dfb3c78a5e3940a94306133b1e92244a2531cd275f2fb7353c98faa153a8
5
5
  SHA512:
6
- metadata.gz: 54728a387b6667b0867929ad94eec94fcd0eb303a84c1e8fce2311705b7971c168205a62364f77fc5754483cef7605f582d3b820d833c25693cd6cd2d17c23f7
7
- data.tar.gz: '069dbe6afe3197ef3ad6f8035d6aee22998d8ecdec1cd582dae0d94a5555741a347a5be2ba129609f97189526a5a2ca2469fe2673805dabf26b8c22abe9acab6'
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.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
@@ -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
- string.force_encoding("UTF-8").each_char.with_index do | char, index |
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
@@ -34,6 +34,7 @@ module PactBroker
34
34
  }
35
35
  property :fallback_tag
36
36
  property :fallback_branch
37
+ property :fallback_to_main_branch
37
38
  property :consumer
38
39
  property :environment_name, as: :environment
39
40
  property :currently_deployed, as: :deployed
@@ -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
- represented.pact_version_sha = user_options.fetch(:sha_generator).call(fragment)
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[:pact] || represented.latest_pact_publication
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[:pact] || represented.latest_pact_publication
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 "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