mockserver-client 9.1.0 → 9.2.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.
@@ -13,6 +13,7 @@ module MockServer
13
13
  'reason_phrase' => 'reasonPhrase',
14
14
  'keep_alive' => 'keepAlive',
15
15
  'respond_before_body' => 'respondBeforeBody',
16
+ 'client_ip' => 'clientIp',
16
17
  'query_string_parameters' => 'queryStringParameters',
17
18
  'path_parameters' => 'pathParameters',
18
19
  'socket_address' => 'socketAddress',
@@ -32,6 +33,7 @@ module MockServer
32
33
  'drop_connection' => 'dropConnection',
33
34
  'response_bytes' => 'responseBytes',
34
35
  'stream_error' => 'streamError',
36
+ 'reset_connection' => 'resetConnection',
35
37
  'http_request' => 'httpRequest',
36
38
  'http_response' => 'httpResponse',
37
39
  'http_response_template' => 'httpResponseTemplate',
@@ -48,6 +50,7 @@ module MockServer
48
50
  'template_type' => 'templateType',
49
51
  'template_file' => 'templateFile',
50
52
  'file_path' => 'filePath',
53
+ 'templated_file_path' => 'templatedFilePath',
51
54
  'base64_bytes' => 'base64Bytes',
52
55
  'not_body' => 'not',
53
56
  'content_type' => 'contentType',
@@ -85,6 +88,7 @@ module MockServer
85
88
  'degradation_ramp_millis' => 'degradationRampMillis',
86
89
  'http_class_callback' => 'httpClassCallback',
87
90
  'http_object_callback' => 'httpObjectCallback',
91
+ 'http_request_template' => 'httpRequestTemplate',
88
92
  'failure_policy' => 'failurePolicy',
89
93
  'http_responses' => 'httpResponses',
90
94
  'response_mode' => 'responseMode',
@@ -242,7 +246,7 @@ module MockServer
242
246
  def self.serialize_key_multi_values(items, key_match_style = nil)
243
247
  if key_match_style
244
248
  map = { 'keyMatchStyle' => key_match_style }
245
- (items || []).each { |item| (map[item.name] ||= []).concat(Array(item.values)) }
249
+ (items || []).each { |item| (map[item.name] ||= []).concat(serialize_matcher_values(Array(item.values))) }
246
250
  return map
247
251
  end
248
252
  return nil if items.nil?
@@ -251,10 +255,11 @@ module MockServer
251
255
  end
252
256
 
253
257
  # @api private
254
- # An httpRequest holding if is a conditional matcher, one naming an OpenAPI
255
- # spec (specUrlOrPayload) is an OpenAPI matcher; anything else is an HTTP (or
256
- # DNS) request matcher.
258
+ # An httpRequest holding anyOf is an anyOf matcher, one holding if a
259
+ # conditional matcher, one naming an OpenAPI spec (specUrlOrPayload) an
260
+ # OpenAPI matcher; anything else is an HTTP (or DNS) request matcher.
257
261
  def self.deserialize_request_definition(data)
262
+ return AnyOfRequestDefinition.from_hash(data) if data.is_a?(Hash) && data.key?('anyOf')
258
263
  return ConditionalRequestDefinition.from_hash(data) if data.is_a?(Hash) && data.key?('if')
259
264
  return OpenAPIDefinition.from_hash(data) if data.is_a?(Hash) && data.key?('specUrlOrPayload')
260
265
 
@@ -289,10 +294,43 @@ module MockServer
289
294
  next item unless item.is_a?(Hash)
290
295
 
291
296
  item = stringify_keys(item)
292
- KeyToMultiValue.new(name: item.fetch('name', ''), values: item.key?('value') ? [item['value']] : item.fetch('values', []))
297
+ values = item.key?('value') ? [item['value']] : item.fetch('values', [])
298
+ KeyToMultiValue.new(name: item.fetch('name', ''), values: deserialize_matcher_values(values))
293
299
  end
294
300
  end
295
301
 
302
+ # @api private
303
+ # A header, query, path-parameter or cookie value is a plain String, the
304
+ # nottable / schema object form (kept as a Hash) or a DateTimeMatcher. Only a
305
+ # Hash holding exactly one date-time operator and nothing but date-time keys
306
+ # becomes a DateTimeMatcher, so any other object form round-trips unchanged.
307
+ def self.date_time_matcher_hash?(value)
308
+ return false unless value.is_a?(Hash)
309
+
310
+ keys = value.keys.map(&:to_s)
311
+ (keys & DateTimeMatcher::OPERATOR_FIELDS).length == 1 && (keys - DateTimeMatcher::FIELDS).empty?
312
+ end
313
+
314
+ # @api private
315
+ def self.deserialize_matcher_value(value)
316
+ date_time_matcher_hash?(value) ? DateTimeMatcher.from_hash(stringify_keys(value)) : value
317
+ end
318
+
319
+ # @api private
320
+ def self.deserialize_matcher_values(values)
321
+ values.is_a?(Array) ? values.map { |v| deserialize_matcher_value(v) } : deserialize_matcher_value(values)
322
+ end
323
+
324
+ # @api private
325
+ def self.serialize_matcher_value(value)
326
+ value.is_a?(DateTimeMatcher) ? value.to_h : value
327
+ end
328
+
329
+ # @api private
330
+ def self.serialize_matcher_values(values)
331
+ values.is_a?(Array) ? values.map { |v| serialize_matcher_value(v) } : serialize_matcher_value(values)
332
+ end
333
+
296
334
  # @api private
297
335
  # Serialise a keyToMultiValue collection in the { name => [values] } object form
298
336
  # (rather than the [{name, values}] array form). MockServer accepts both wire
@@ -302,7 +340,7 @@ module MockServer
302
340
  def self.serialize_key_multi_values_object(items)
303
341
  return nil if items.nil?
304
342
 
305
- items.each_with_object({}) { |item, map| map[item.name] = item.values }
343
+ items.each_with_object({}) { |item, map| map[item.name] = serialize_matcher_values(item.values) }
306
344
  end
307
345
 
308
346
  # @api private
@@ -311,7 +349,7 @@ module MockServer
311
349
 
312
350
  if data.is_a?(Hash)
313
351
  return data.reject { |k, _v| k == 'keyMatchStyle' }
314
- .map { |k, v| KeyToMultiValue.new(name: k, values: v.is_a?(Array) ? v : [v]) }
352
+ .map { |k, v| KeyToMultiValue.new(name: k, values: deserialize_matcher_values(v.is_a?(Array) ? v : [v])) }
315
353
  end
316
354
 
317
355
  data.map do |item|
@@ -332,7 +370,7 @@ module MockServer
332
370
  return nil if items.nil?
333
371
 
334
372
  items.each_with_object({}) do |item, map|
335
- map[item.name] = item.values.is_a?(Array) ? item.values.first : item.values
373
+ map[item.name] = serialize_matcher_value(item.values.is_a?(Array) ? item.values.first : item.values)
336
374
  end
337
375
  end
338
376
 
@@ -341,7 +379,7 @@ module MockServer
341
379
  return nil if data.nil?
342
380
 
343
381
  if data.is_a?(Hash)
344
- return data.map { |k, v| KeyToMultiValue.new(name: k, values: v.is_a?(Array) ? v : [v]) }
382
+ return data.map { |k, v| KeyToMultiValue.new(name: k, values: deserialize_matcher_values(v.is_a?(Array) ? v : [v])) }
345
383
  end
346
384
 
347
385
  # tolerate the legacy array form on read
@@ -515,7 +553,7 @@ module MockServer
515
553
  def to_h
516
554
  {
517
555
  'name' => @name,
518
- 'values' => @values
556
+ 'values' => MockServer.serialize_matcher_values(@values)
519
557
  }
520
558
  end
521
559
 
@@ -524,11 +562,136 @@ module MockServer
524
562
 
525
563
  new(
526
564
  name: data.fetch('name', ''),
527
- values: data.fetch('values', [])
565
+ values: MockServer.deserialize_matcher_values(data.fetch('values', []))
528
566
  )
529
567
  end
530
568
  end
531
569
 
570
+ # A date-time matcher for a header, query-string parameter, path parameter or
571
+ # cookie value, written on the wire as an object such as
572
+ # { "after": "now -1 hours", "actualFormat": "rfc1123" }. Instances are frozen;
573
+ # every with_* method returns a new matcher.
574
+ #
575
+ # request.with_header('X-Issued-At', MockServer::DateTimeMatcher.after('now -1 hours'))
576
+ class DateTimeMatcher
577
+ OPERATOR_FIELDS = %w[before after equalToDateTime].freeze
578
+ FIELDS = (%w[not] + OPERATOR_FIELDS +
579
+ %w[actualFormat truncateExpected truncateActual applyTruncationLast zone]).freeze
580
+
581
+ # +operator+ is the wire field name: 'before', 'after' or 'equalToDateTime'.
582
+ attr_reader :operator, :expected, :actual_format, :truncate_expected, :truncate_actual,
583
+ :apply_truncation_last, :zone, :not_matcher
584
+
585
+ def initialize(operator:, expected:, actual_format: nil, truncate_expected: nil, truncate_actual: nil,
586
+ apply_truncation_last: nil, zone: nil, not_matcher: nil)
587
+ unless operator.nil? || OPERATOR_FIELDS.include?(operator)
588
+ raise ArgumentError, "Unknown date-time operator #{operator.inspect}; expected one of #{OPERATOR_FIELDS.join(', ')}"
589
+ end
590
+
591
+ @operator = operator
592
+ @expected = expected
593
+ @actual_format = actual_format
594
+ @truncate_expected = truncate_expected
595
+ @truncate_actual = truncate_actual
596
+ @apply_truncation_last = apply_truncation_last
597
+ @zone = zone
598
+ @not_matcher = not_matcher
599
+ freeze
600
+ end
601
+
602
+ def self.before(expected)
603
+ new(operator: 'before', expected: expected)
604
+ end
605
+
606
+ def self.after(expected)
607
+ new(operator: 'after', expected: expected)
608
+ end
609
+
610
+ def self.equal_to_date_time(expected)
611
+ new(operator: 'equalToDateTime', expected: expected)
612
+ end
613
+
614
+ def with_actual_format(actual_format)
615
+ copy(actual_format: actual_format)
616
+ end
617
+
618
+ def with_truncate_expected(truncate_expected)
619
+ copy(truncate_expected: truncate_expected)
620
+ end
621
+
622
+ def with_truncate_actual(truncate_actual)
623
+ copy(truncate_actual: truncate_actual)
624
+ end
625
+
626
+ def with_apply_truncation_last(apply_truncation_last)
627
+ copy(apply_truncation_last: apply_truncation_last)
628
+ end
629
+
630
+ def with_zone(zone)
631
+ copy(zone: zone)
632
+ end
633
+
634
+ def with_not(not_matcher)
635
+ copy(not_matcher: not_matcher)
636
+ end
637
+
638
+ # Field order and omission follow the server: not only when true, every
639
+ # other optional field only when set.
640
+ def to_h
641
+ result = {}
642
+ result['not'] = true if @not_matcher == true
643
+ result[@operator] = @expected unless @operator.nil?
644
+ result['actualFormat'] = @actual_format unless @actual_format.nil?
645
+ result['truncateExpected'] = @truncate_expected unless @truncate_expected.nil?
646
+ result['truncateActual'] = @truncate_actual unless @truncate_actual.nil?
647
+ result['applyTruncationLast'] = @apply_truncation_last unless @apply_truncation_last.nil?
648
+ result['zone'] = @zone unless @zone.nil?
649
+ result
650
+ end
651
+
652
+ def to_json(*args)
653
+ to_h.to_json(*args)
654
+ end
655
+
656
+ def ==(other)
657
+ other.is_a?(DateTimeMatcher) && other.to_h == to_h
658
+ end
659
+ alias eql? ==
660
+
661
+ def hash
662
+ to_h.hash
663
+ end
664
+
665
+ def self.from_hash(data)
666
+ return nil if data.nil?
667
+
668
+ operators = OPERATOR_FIELDS.select { |field| data.key?(field) }
669
+ if operators.length > 1
670
+ raise ArgumentError, "A date-time matcher takes one of #{OPERATOR_FIELDS.join(', ')}, got #{operators.join(', ')}"
671
+ end
672
+
673
+ new(
674
+ operator: operators.first,
675
+ expected: operators.first && data[operators.first],
676
+ actual_format: data['actualFormat'],
677
+ truncate_expected: data['truncateExpected'],
678
+ truncate_actual: data['truncateActual'],
679
+ apply_truncation_last: data['applyTruncationLast'],
680
+ zone: data['zone'],
681
+ not_matcher: data['not']
682
+ )
683
+ end
684
+
685
+ private
686
+
687
+ def copy(**changes)
688
+ fields = { operator: @operator, expected: @expected, actual_format: @actual_format,
689
+ truncate_expected: @truncate_expected, truncate_actual: @truncate_actual,
690
+ apply_truncation_last: @apply_truncation_last, zone: @zone, not_matcher: @not_matcher }
691
+ self.class.new(**fields.merge(changes))
692
+ end
693
+ end
694
+
532
695
  class Body
533
696
  # sub_string / match_type / match_numbers_as_strings / json_schema /
534
697
  # parameter_styles / xml_schema / parameters / module_name / optional are the
@@ -540,16 +703,19 @@ module MockServer
540
703
  # from +string+: the legacy Body.xml(...) factory populates +string+ (pinned by a
541
704
  # test), whereas a server-produced XML body serialises its content under +xml+.
542
705
  attr_accessor :type, :string, :xml, :json, :regex, :json_path, :xpath, :base64_bytes,
543
- :not_body, :content_type, :charset, :file_path, :template_type, :body_all_of,
706
+ :not_body, :content_type, :charset, :file_path, :template_type, :templated_file_path, :body_all_of,
544
707
  :sub_string, :match_type, :match_numbers_as_strings, :json_schema,
545
- :parameter_styles, :xml_schema, :parameters, :module_name, :optional
708
+ :parameter_styles, :xml_schema, :parameters, :module_name, :optional, :date_time
546
709
 
710
+ # +date_time+ (a DateTimeMatcher) applies to a JSON_PATH or XPATH body: it then
711
+ # matches only when a value the JSONPath selects, or the text of a node the
712
+ # XPath selects, is a date-time satisfying it.
547
713
  def initialize(type: nil, string: nil, xml: nil, json: nil, regex: nil, json_path: nil, xpath: nil,
548
714
  base64_bytes: nil, not_body: nil, content_type: nil, charset: nil,
549
- file_path: nil, template_type: nil, body_all_of: nil,
715
+ file_path: nil, template_type: nil, templated_file_path: nil, body_all_of: nil,
550
716
  sub_string: nil, match_type: nil, match_numbers_as_strings: nil,
551
717
  json_schema: nil, parameter_styles: nil, xml_schema: nil,
552
- parameters: nil, module_name: nil, optional: nil)
718
+ parameters: nil, module_name: nil, optional: nil, date_time: nil)
553
719
  @type = type
554
720
  @string = string
555
721
  @xml = xml
@@ -563,6 +729,7 @@ module MockServer
563
729
  @charset = charset
564
730
  @file_path = file_path
565
731
  @template_type = template_type
732
+ @templated_file_path = templated_file_path
566
733
  @body_all_of = body_all_of
567
734
  @sub_string = sub_string
568
735
  @match_type = match_type
@@ -573,6 +740,7 @@ module MockServer
573
740
  @parameters = parameters
574
741
  @module_name = module_name
575
742
  @optional = optional
743
+ @date_time = date_time
576
744
  end
577
745
 
578
746
  def to_h
@@ -584,12 +752,15 @@ module MockServer
584
752
  result['regex'] = @regex unless @regex.nil?
585
753
  result['jsonPath'] = @json_path unless @json_path.nil?
586
754
  result['xpath'] = @xpath unless @xpath.nil?
755
+ result['dateTime'] = MockServer.serialize_matcher_value(@date_time) unless @date_time.nil?
587
756
  result['base64Bytes'] = @base64_bytes unless @base64_bytes.nil?
588
757
  result['not'] = @not_body unless @not_body.nil?
589
758
  result['contentType'] = @content_type unless @content_type.nil?
590
759
  result['charset'] = @charset unless @charset.nil?
591
760
  result['filePath'] = @file_path unless @file_path.nil?
592
761
  result['templateType'] = @template_type unless @template_type.nil?
762
+ # FILE body: render filePath as a template; omitted unless true.
763
+ result['templatedFilePath'] = true if @templated_file_path == true
593
764
  result['subString'] = @sub_string unless @sub_string.nil?
594
765
  result['matchType'] = @match_type unless @match_type.nil?
595
766
  result['matchNumbersAsStrings'] = @match_numbers_as_strings unless @match_numbers_as_strings.nil?
@@ -623,6 +794,7 @@ module MockServer
623
794
  charset: data['charset'],
624
795
  file_path: data['filePath'],
625
796
  template_type: data['templateType'],
797
+ templated_file_path: data['templatedFilePath'],
626
798
  sub_string: data['subString'],
627
799
  match_type: data['matchType'],
628
800
  match_numbers_as_strings: data['matchNumbersAsStrings'],
@@ -632,6 +804,7 @@ module MockServer
632
804
  parameters: data['parameters'],
633
805
  module_name: data['moduleName'],
634
806
  optional: data['optional'],
807
+ date_time: MockServer.deserialize_matcher_value(data['dateTime']),
635
808
  body_all_of: all_of&.map { |b| MockServer.deserialize_body(b) }
636
809
  )
637
810
  end
@@ -664,12 +837,12 @@ module MockServer
664
837
  new(type: 'ALL_OF', body_all_of: bodies.flatten)
665
838
  end
666
839
 
667
- def self.json_path(value)
668
- new(type: 'JSON_PATH', json_path: value)
840
+ def self.json_path(value, date_time: nil)
841
+ new(type: 'JSON_PATH', json_path: value, date_time: date_time)
669
842
  end
670
843
 
671
- def self.xpath(value)
672
- new(type: 'XPATH', xpath: value)
844
+ def self.xpath(value, date_time: nil)
845
+ new(type: 'XPATH', xpath: value, date_time: date_time)
673
846
  end
674
847
 
675
848
  # A JSON_SCHEMA body matcher. +parameter_styles+ is an optional
@@ -700,8 +873,11 @@ module MockServer
700
873
  new(type: 'WASM', module_name: module_name)
701
874
  end
702
875
 
703
- def self.file(file_path, content_type: nil, template_type: nil)
704
- new(type: 'FILE', file_path: file_path, content_type: content_type, template_type: template_type)
876
+ # A FILE body. +templated_file_path: true+ (with a +template_type+) also renders
877
+ # +file_path+ as a template against the request before the file is read.
878
+ def self.file(file_path, content_type: nil, template_type: nil, templated_file_path: nil)
879
+ new(type: 'FILE', file_path: file_path, content_type: content_type, template_type: template_type,
880
+ templated_file_path: templated_file_path)
705
881
  end
706
882
 
707
883
  def self.json_rpc(method_name, params_schema: nil)
@@ -716,6 +892,11 @@ module MockServer
716
892
  @template_type = template_type
717
893
  self
718
894
  end
895
+
896
+ def with_templated_file_path(templated_file_path = true)
897
+ @templated_file_path = templated_file_path
898
+ self
899
+ end
719
900
  end
720
901
 
721
902
  class JsonRpcBody
@@ -891,7 +1072,7 @@ module MockServer
891
1072
  attr_accessor :method, :path, :body, :secure, :protocol, :keep_alive, :respond_before_body,
892
1073
  :socket_address, :jwt, :dns_name, :dns_type, :dns_class,
893
1074
  :headers_key_match_style, :query_string_parameters_key_match_style,
894
- :path_parameters_key_match_style
1075
+ :path_parameters_key_match_style, :client_ip
895
1076
  attr_reader :query_string_parameters, :headers, :cookies, :path_parameters
896
1077
 
897
1078
  def initialize(method: nil, path: nil, query_string_parameters: nil, headers: nil,
@@ -899,7 +1080,7 @@ module MockServer
899
1080
  respond_before_body: nil, path_parameters: nil, socket_address: nil,
900
1081
  jwt: nil, dns_name: nil, dns_type: nil, dns_class: nil,
901
1082
  headers_key_match_style: nil, query_string_parameters_key_match_style: nil,
902
- path_parameters_key_match_style: nil)
1083
+ path_parameters_key_match_style: nil, client_ip: nil)
903
1084
  @method = method
904
1085
  @path = path
905
1086
  self.query_string_parameters = query_string_parameters
@@ -916,6 +1097,7 @@ module MockServer
916
1097
  @dns_name = dns_name
917
1098
  @dns_type = dns_type
918
1099
  @dns_class = dns_class
1100
+ @client_ip = client_ip
919
1101
  @headers_key_match_style = headers_key_match_style if headers_key_match_style
920
1102
  @query_string_parameters_key_match_style = query_string_parameters_key_match_style if query_string_parameters_key_match_style
921
1103
  @path_parameters_key_match_style = path_parameters_key_match_style if path_parameters_key_match_style
@@ -961,6 +1143,7 @@ module MockServer
961
1143
  'protocol' => @protocol,
962
1144
  'keepAlive' => @keep_alive,
963
1145
  'respondBeforeBody' => @respond_before_body,
1146
+ 'clientIp' => @client_ip,
964
1147
  'pathParameters' => path_parameters,
965
1148
  'socketAddress' => @socket_address&.to_h,
966
1149
  'jwt' => @jwt&.to_h,
@@ -984,6 +1167,7 @@ module MockServer
984
1167
  protocol: data['protocol'],
985
1168
  keep_alive: data['keepAlive'],
986
1169
  respond_before_body: data['respondBeforeBody'],
1170
+ client_ip: data['clientIp'],
987
1171
  path_parameters: MockServer.deserialize_key_multi_values(data['pathParameters']),
988
1172
  socket_address: SocketAddress.from_hash(data['socketAddress']),
989
1173
  jwt: Jwt.from_hash(data['jwt']),
@@ -1057,6 +1241,13 @@ module MockServer
1057
1241
  @respond_before_body = respond_before_body
1058
1242
  self
1059
1243
  end
1244
+
1245
+ # Match only requests from this client IP address or CIDR range (e.g. '10.0.0.0/8'); prefix with '!'
1246
+ # to match any other address. It is the TCP peer address, not X-Forwarded-For.
1247
+ def with_client_ip(client_ip)
1248
+ @client_ip = client_ip
1249
+ self
1250
+ end
1060
1251
  end
1061
1252
 
1062
1253
  class ConnectionOptions
@@ -1322,11 +1513,12 @@ module MockServer
1322
1513
  class HttpTemplate
1323
1514
  # response_override (an {HttpResponse}) is applied over the rendered output;
1324
1515
  # response_modifier is the server's responseModifier object, as a Hash.
1516
+ # parameters is a Hash of values the template reads as parameters (parameters.greeting), kept exactly.
1325
1517
  attr_accessor :template_type, :template, :template_file, :delay, :primary,
1326
- :response_override, :response_modifier
1518
+ :response_override, :response_modifier, :parameters
1327
1519
 
1328
1520
  def initialize(template_type: 'JAVASCRIPT', template: nil, template_file: nil, delay: nil, primary: nil,
1329
- response_override: nil, response_modifier: nil)
1521
+ response_override: nil, response_modifier: nil, parameters: nil)
1330
1522
  @template_type = template_type
1331
1523
  @template = template
1332
1524
  @template_file = template_file
@@ -1334,6 +1526,7 @@ module MockServer
1334
1526
  @primary = primary
1335
1527
  @response_override = response_override
1336
1528
  @response_modifier = response_modifier
1529
+ @parameters = parameters
1337
1530
  end
1338
1531
 
1339
1532
  def to_h
@@ -1344,7 +1537,8 @@ module MockServer
1344
1537
  'delay' => @delay&.to_h,
1345
1538
  'primary' => @primary,
1346
1539
  'responseOverride' => @response_override&.to_h,
1347
- 'responseModifier' => @response_modifier
1540
+ 'responseModifier' => @response_modifier,
1541
+ 'parameters' => (@parameters.nil? || @parameters.empty? ? nil : @parameters)
1348
1542
  })
1349
1543
  end
1350
1544
 
@@ -1358,7 +1552,8 @@ module MockServer
1358
1552
  delay: Delay.from_hash(data['delay']),
1359
1553
  primary: data['primary'],
1360
1554
  response_override: HttpResponse.from_hash(data['responseOverride']),
1361
- response_modifier: data['responseModifier']
1555
+ response_modifier: data['responseModifier'],
1556
+ parameters: data['parameters']
1362
1557
  )
1363
1558
  end
1364
1559
 
@@ -1380,6 +1575,12 @@ module MockServer
1380
1575
  @response_modifier = response_modifier
1381
1576
  self
1382
1577
  end
1578
+
1579
+ # Adds a value the template reads as parameters.<name>.
1580
+ def with_parameter(name, value)
1581
+ @parameters = (@parameters || {}).merge(name.to_s => value)
1582
+ self
1583
+ end
1383
1584
  end
1384
1585
 
1385
1586
  class HttpClassCallback
@@ -1449,10 +1650,14 @@ module MockServer
1449
1650
  # stream_error: reset the matched request stream with this error code (HTTP/2 RST_STREAM /
1450
1651
  # HTTP/3 RESET_STREAM) instead of returning a response; HTTP/1.1 has no stream concept so this
1451
1652
  # falls back to dropping the connection. Takes precedence over drop_connection when both are set.
1452
- attr_accessor :drop_connection, :response_bytes, :stream_error, :delay, :primary
1653
+ # reset_connection: reset the connection with a TCP RST so the client sees "connection reset by
1654
+ # peer". Takes precedence over stream_error and drop_connection.
1655
+ attr_accessor :drop_connection, :reset_connection, :response_bytes, :stream_error, :delay, :primary
1453
1656
 
1454
- def initialize(drop_connection: nil, response_bytes: nil, stream_error: nil, delay: nil, primary: nil)
1657
+ def initialize(drop_connection: nil, response_bytes: nil, stream_error: nil, delay: nil, primary: nil,
1658
+ reset_connection: nil)
1455
1659
  @drop_connection = drop_connection
1660
+ @reset_connection = reset_connection
1456
1661
  @response_bytes = response_bytes
1457
1662
  @stream_error = stream_error
1458
1663
  @delay = delay
@@ -1462,6 +1667,7 @@ module MockServer
1462
1667
  def to_h
1463
1668
  MockServer.strip_none({
1464
1669
  'dropConnection' => @drop_connection,
1670
+ 'resetConnection' => @reset_connection,
1465
1671
  'responseBytes' => @response_bytes,
1466
1672
  'streamError' => @stream_error,
1467
1673
  'delay' => @delay&.to_h,
@@ -1474,6 +1680,7 @@ module MockServer
1474
1680
 
1475
1681
  new(
1476
1682
  drop_connection: data['dropConnection'],
1683
+ reset_connection: data['resetConnection'],
1477
1684
  response_bytes: data['responseBytes'],
1478
1685
  stream_error: data['streamError'],
1479
1686
  delay: Delay.from_hash(data['delay']),
@@ -2254,13 +2461,16 @@ module MockServer
2254
2461
  end
2255
2462
 
2256
2463
  class AfterAction
2257
- # blocking, timeout and failure_policy are only meaningful for before-actions
2464
+ # blocking, timeout and failure_policy are only meaningful for before-actions.
2465
+ # http_request_template (an {HttpTemplate}) renders the webhook request from the triggering
2466
+ # request; escape request values in it and keep them out of its destination.
2258
2467
  attr_accessor :http_request, :http_class_callback, :http_object_callback, :delay,
2259
- :blocking, :timeout, :failure_policy
2468
+ :blocking, :timeout, :failure_policy, :http_request_template
2260
2469
 
2261
2470
  def initialize(http_request: nil, http_class_callback: nil, http_object_callback: nil, delay: nil,
2262
- blocking: nil, timeout: nil, failure_policy: nil)
2471
+ blocking: nil, timeout: nil, failure_policy: nil, http_request_template: nil)
2263
2472
  @http_request = http_request
2473
+ @http_request_template = http_request_template
2264
2474
  @http_class_callback = http_class_callback
2265
2475
  @http_object_callback = http_object_callback
2266
2476
  @delay = delay
@@ -2271,9 +2481,10 @@ module MockServer
2271
2481
 
2272
2482
  def to_h
2273
2483
  MockServer.strip_none({
2274
- 'httpRequest' => @http_request&.to_h,
2275
- 'httpClassCallback' => @http_class_callback&.to_h,
2276
- 'httpObjectCallback' => @http_object_callback&.to_h,
2484
+ 'httpRequest' => @http_request&.to_h,
2485
+ 'httpRequestTemplate' => @http_request_template&.to_h,
2486
+ 'httpClassCallback' => @http_class_callback&.to_h,
2487
+ 'httpObjectCallback' => @http_object_callback&.to_h,
2277
2488
  'delay' => @delay&.to_h,
2278
2489
  'blocking' => @blocking,
2279
2490
  'timeout' => @timeout&.to_h,
@@ -2291,7 +2502,8 @@ module MockServer
2291
2502
  delay: Delay.from_hash(data['delay']),
2292
2503
  blocking: data['blocking'],
2293
2504
  timeout: Delay.from_hash(data['timeout']),
2294
- failure_policy: data['failurePolicy']
2505
+ failure_policy: data['failurePolicy'],
2506
+ http_request_template: HttpTemplate.from_hash(data['httpRequestTemplate'])
2295
2507
  )
2296
2508
  end
2297
2509
  end
@@ -2710,6 +2922,7 @@ module MockServer
2710
2922
  :http_responses, :response_mode, :response_weights, :switch_after,
2711
2923
  :cross_protocol_scenarios, :steps, :capture, :namespace,
2712
2924
  :scenario_name, :scenario_state, :new_scenario_state, :timestamp
2925
+ attr_reader :metadata
2713
2926
 
2714
2927
  def initialize(id: nil, priority: nil, percentage: nil, http_request: nil, http_response: nil,
2715
2928
  http_response_template: nil, http_response_class_callback: nil,
@@ -2726,7 +2939,7 @@ module MockServer
2726
2939
  switch_after: nil, cross_protocol_scenarios: nil, steps: nil,
2727
2940
  capture: nil, namespace: nil,
2728
2941
  scenario_name: nil, scenario_state: nil, new_scenario_state: nil,
2729
- timestamp: nil)
2942
+ timestamp: nil, metadata: nil)
2730
2943
  @id = id
2731
2944
  @priority = priority
2732
2945
  @percentage = percentage
@@ -2771,6 +2984,18 @@ module MockServer
2771
2984
  @scenario_state = scenario_state
2772
2985
  @new_scenario_state = new_scenario_state
2773
2986
  @timestamp = timestamp
2987
+ self.metadata = metadata
2988
+ end
2989
+
2990
+ # Free-form labels (owner, ticket, tags, ...) used to retrieve or clear
2991
+ # expectations by metadata. Keys are stored as Strings (Symbol keys are
2992
+ # converted, at any depth); values may be any JSON value, including nil.
2993
+ def metadata=(value)
2994
+ unless value.nil? || value.is_a?(Hash)
2995
+ raise ArgumentError, "metadata must be a Hash, got #{value.class}"
2996
+ end
2997
+
2998
+ @metadata = value.nil? ? nil : MockServer.stringify_keys(value)
2774
2999
  end
2775
3000
 
2776
3001
  # Set the response class-callback action. Accepts either a fully-qualified
@@ -2845,7 +3070,8 @@ module MockServer
2845
3070
  'scenarioName' => @scenario_name,
2846
3071
  'scenarioState' => @scenario_state,
2847
3072
  'newScenarioState' => @new_scenario_state,
2848
- 'timestamp' => @timestamp
3073
+ 'timestamp' => @timestamp,
3074
+ 'metadata' => @metadata.nil? || @metadata.empty? ? nil : @metadata
2849
3075
  })
2850
3076
  end
2851
3077
 
@@ -2907,11 +3133,232 @@ module MockServer
2907
3133
  scenario_name: data['scenarioName'],
2908
3134
  scenario_state: data['scenarioState'],
2909
3135
  new_scenario_state: data['newScenarioState'],
2910
- timestamp: data['timestamp']
3136
+ timestamp: data['timestamp'],
3137
+ metadata: data['metadata']
2911
3138
  )
2912
3139
  end
2913
3140
  end
2914
3141
 
3142
+ # Selects expectations to retrieve or clear by metadata, by whether they have
3143
+ # ever matched a request, and pages a retrieved list. Sent as query parameters
3144
+ # on +PUT /mockserver/retrieve+ and +PUT /mockserver/clear+; every selection
3145
+ # that is set must hold. Invalid arguments raise ArgumentError immediately, so
3146
+ # a broken filter can never be sent as "no filter" (which, on clear, clears
3147
+ # every expectation).
3148
+ #
3149
+ # MockServer::ExpectationFilter.new.with_metadata('owner', 'team-a').with_limit(10)
3150
+ class ExpectationFilter
3151
+ # Pass as the value to {#with_metadata} to select a stored JSON null; Ruby
3152
+ # +nil+ is rejected so it can never read as "any value".
3153
+ JSON_NULL = Object.new.tap do |null|
3154
+ def null.inspect
3155
+ 'MockServer::ExpectationFilter::JSON_NULL'
3156
+ end
3157
+ end.freeze
3158
+
3159
+ attr_reader :metadata_key, :metadata_value, :metadata_json_path, :unmatched, :offset, :limit
3160
+
3161
+ def initialize
3162
+ @metadata_key = nil
3163
+ @metadata_value = nil
3164
+ @metadata_json_path = nil
3165
+ @unmatched = false
3166
+ @offset = nil
3167
+ @limit = nil
3168
+ end
3169
+
3170
+ # Select expectations whose metadata has +key+ with a value equal to
3171
+ # +value+. A String is sent as text; any other value (number, boolean,
3172
+ # Array, Hash, {JSON_NULL}) is sent as its compact JSON.
3173
+ # @raise [ArgumentError] if +key+ is blank or +value+ is nil
3174
+ def with_metadata(key, value)
3175
+ if value.nil?
3176
+ raise ArgumentError, 'the metadata value must not be nil; use with_metadata_key(key) to select any value, ' \
3177
+ 'or ExpectationFilter::JSON_NULL to select a JSON null'
3178
+ end
3179
+
3180
+ @metadata_key = ExpectationFilter.require_not_blank(key, 'metadata key')
3181
+ @metadata_value = ExpectationFilter.encode_value(value)
3182
+ self
3183
+ end
3184
+
3185
+ # Select expectations whose metadata has +key+, with any value.
3186
+ # @raise [ArgumentError] if +key+ is blank
3187
+ def with_metadata_key(key)
3188
+ @metadata_key = ExpectationFilter.require_not_blank(key, 'metadata key')
3189
+ @metadata_value = nil
3190
+ self
3191
+ end
3192
+
3193
+ # Select expectations whose metadata object the JSONPath selects at least
3194
+ # one value from, e.g. <tt>$[?(@.owner == 'team-a')]</tt>.
3195
+ # @raise [ArgumentError] if +json_path+ is blank
3196
+ def with_metadata_json_path(json_path)
3197
+ @metadata_json_path = ExpectationFilter.require_not_blank(json_path, 'metadata JSONPath')
3198
+ self
3199
+ end
3200
+
3201
+ # Select only expectations that have not matched any request.
3202
+ def with_unmatched(unmatched = true)
3203
+ unless [true, false].include?(unmatched)
3204
+ raise ArgumentError, "unmatched must be true or false, got #{unmatched.inspect}"
3205
+ end
3206
+
3207
+ @unmatched = unmatched
3208
+ self
3209
+ end
3210
+
3211
+ # Skip this many results (retrieve only).
3212
+ def with_offset(offset)
3213
+ @offset = ExpectationFilter.require_non_negative(offset, 'offset')
3214
+ self
3215
+ end
3216
+
3217
+ # Return at most this many results (retrieve only).
3218
+ def with_limit(limit)
3219
+ @limit = ExpectationFilter.require_non_negative(limit, 'limit')
3220
+ self
3221
+ end
3222
+
3223
+ # @return [Boolean] true when a metadata or unmatched selection is set
3224
+ def selection?
3225
+ !@metadata_key.nil? || !@metadata_json_path.nil? || @unmatched
3226
+ end
3227
+
3228
+ # @return [Boolean] true when offset or limit is set
3229
+ def paging?
3230
+ !@offset.nil? || !@limit.nil?
3231
+ end
3232
+
3233
+ # The query parameters for this filter, names and values exactly as sent.
3234
+ # @return [Hash{String => String}]
3235
+ def to_query_params
3236
+ params = {}
3237
+ unless @metadata_key.nil?
3238
+ params['metadataKey'] = @metadata_key
3239
+ params['metadataValue'] = @metadata_value unless @metadata_value.nil?
3240
+ end
3241
+ params['metadataJsonPath'] = @metadata_json_path unless @metadata_json_path.nil?
3242
+ params['unmatched'] = 'true' if @unmatched
3243
+ params['offset'] = @offset.to_s unless @offset.nil?
3244
+ params['limit'] = @limit.to_s unless @limit.nil?
3245
+ params
3246
+ end
3247
+
3248
+ # @api private
3249
+ def self.require_not_blank(value, description)
3250
+ unless value.is_a?(String) || value.is_a?(Symbol)
3251
+ raise ArgumentError, "the #{description} must be a String, got #{value.inspect}"
3252
+ end
3253
+ raise ArgumentError, "the #{description} must not be blank" if value.to_s.strip.empty?
3254
+
3255
+ value.to_s
3256
+ end
3257
+
3258
+ # @api private
3259
+ def self.require_non_negative(value, description)
3260
+ unless value.is_a?(Integer) && value >= 0
3261
+ raise ArgumentError, "#{description} must be an Integer of zero or more, got #{value.inspect}"
3262
+ end
3263
+
3264
+ value
3265
+ end
3266
+
3267
+ # @api private
3268
+ def self.encode_value(value)
3269
+ return 'null' if value.equal?(JSON_NULL)
3270
+ return value if value.is_a?(String)
3271
+
3272
+ JSON.generate(MockServer.stringify_keys(value))
3273
+ rescue JSON::GeneratorError => e
3274
+ raise ArgumentError, "the metadata value #{value.inspect} cannot be sent as JSON: #{e.message}"
3275
+ end
3276
+ end
3277
+
3278
+ # Options that shape the expectations returned by
3279
+ # {Client#retrieve_recorded_expectations}. Sent as query parameters on
3280
+ # +PUT /mockserver/retrieve?type=RECORDED_EXPECTATIONS+; each option is off
3281
+ # unless set. Invalid arguments raise ArgumentError immediately, so nothing
3282
+ # is sent.
3283
+ #
3284
+ # MockServer::RecordedExpectationsOptions.new
3285
+ # .with_repeats_as_scenarios(true)
3286
+ # .with_capture_headers('X-Tenant')
3287
+ # .with_extract_bodies_larger_than(1024)
3288
+ class RecordedExpectationsOptions
3289
+ attr_reader :consolidate, :parameterize, :repeats_as_scenarios, :capture_headers, :extract_bodies_larger_than
3290
+
3291
+ def initialize
3292
+ @consolidate = nil
3293
+ @parameterize = nil
3294
+ @repeats_as_scenarios = nil
3295
+ @capture_headers = nil
3296
+ @extract_bodies_larger_than = nil
3297
+ end
3298
+
3299
+ # Collapse recorded exchanges that share a request shape into one
3300
+ # reusable, unlimited-times expectation.
3301
+ def with_consolidate(consolidate = true)
3302
+ @consolidate = RecordedExpectationsOptions.require_boolean(consolidate, 'consolidate')
3303
+ self
3304
+ end
3305
+
3306
+ # Also generalise volatile path, query, header and body values into
3307
+ # matchers (implies consolidate).
3308
+ def with_parameterize(parameterize = true)
3309
+ @parameterize = RecordedExpectationsOptions.require_boolean(parameterize, 'parameterize')
3310
+ self
3311
+ end
3312
+
3313
+ # Replay a request recorded several times with different responses in the
3314
+ # recorded order, then keep returning the last one.
3315
+ def with_repeats_as_scenarios(repeats_as_scenarios = true)
3316
+ @repeats_as_scenarios = RecordedExpectationsOptions.require_boolean(repeats_as_scenarios, 'repeats_as_scenarios')
3317
+ self
3318
+ end
3319
+
3320
+ # Keep only these request headers (case-insensitive) in each recorded
3321
+ # matcher; no names keeps none.
3322
+ # @raise [ArgumentError] if a name is blank or contains a comma
3323
+ def with_capture_headers(*header_names)
3324
+ header_names.each do |name|
3325
+ unless name.is_a?(String) && !name.strip.empty? && !name.include?(',')
3326
+ raise ArgumentError,
3327
+ "each capture header must be a non-blank header name without a comma, got #{name.inspect}"
3328
+ end
3329
+ end
3330
+ @capture_headers = header_names
3331
+ self
3332
+ end
3333
+
3334
+ # Write each response body larger than this many bytes to the server's
3335
+ # +recordedBodyFilesDirectory+ and return a FILE body that points at it.
3336
+ # @raise [ArgumentError] unless an Integer of zero or more
3337
+ def with_extract_bodies_larger_than(threshold_bytes)
3338
+ @extract_bodies_larger_than = ExpectationFilter.require_non_negative(threshold_bytes, 'extract_bodies_larger_than')
3339
+ self
3340
+ end
3341
+
3342
+ # The query parameters for these options, names and values exactly as sent.
3343
+ # @return [Hash{String => String}]
3344
+ def to_query_params
3345
+ params = {}
3346
+ params['consolidate'] = 'true' if @consolidate
3347
+ params['parameterize'] = 'true' if @parameterize
3348
+ params['repeatsAsScenarios'] = 'true' if @repeats_as_scenarios
3349
+ params['captureHeaders'] = @capture_headers.join(',') unless @capture_headers.nil?
3350
+ params['extractBodiesLargerThan'] = @extract_bodies_larger_than.to_s unless @extract_bodies_larger_than.nil?
3351
+ params
3352
+ end
3353
+
3354
+ # @api private
3355
+ def self.require_boolean(value, description)
3356
+ raise ArgumentError, "#{description} must be true or false, got #{value.inspect}" unless [true, false].include?(value)
3357
+
3358
+ value
3359
+ end
3360
+ end
3361
+
2915
3362
  # An OpenAPI request matcher: as an expectation's or verification's
2916
3363
  # httpRequest it matches the requests an OpenAPI operation accepts.
2917
3364
  class OpenAPIDefinition
@@ -2983,6 +3430,37 @@ module MockServer
2983
3430
  end
2984
3431
  end
2985
3432
 
3433
+ # A logical OR of request matchers, written as an httpRequest holding an
3434
+ # anyOf array. It matches a request when any branch matches it, trying them
3435
+ # in order. Each branch is an HttpRequest, an OpenAPIDefinition, a
3436
+ # ConditionalRequestDefinition or another AnyOfRequestDefinition.
3437
+ class AnyOfRequestDefinition
3438
+ # not_any_of inverts the match (the server's +not+ field): it then matches
3439
+ # when no branch does.
3440
+ attr_accessor :any_of, :not_any_of
3441
+
3442
+ def initialize(any_of: [], not_any_of: nil)
3443
+ @any_of = any_of
3444
+ @not_any_of = not_any_of
3445
+ end
3446
+
3447
+ def to_h
3448
+ MockServer.strip_none({
3449
+ 'not' => @not_any_of,
3450
+ 'anyOf' => (@any_of || []).map(&:to_h)
3451
+ })
3452
+ end
3453
+
3454
+ def self.from_hash(data)
3455
+ return nil if data.nil?
3456
+
3457
+ new(
3458
+ any_of: (data['anyOf'] || []).map { |branch| MockServer.deserialize_request_definition(branch) },
3459
+ not_any_of: data['not']
3460
+ )
3461
+ end
3462
+ end
3463
+
2986
3464
  class OpenAPIExpectation
2987
3465
  attr_accessor :spec_url_or_payload, :operations_and_responses
2988
3466
 
@@ -3680,20 +4158,40 @@ module MockServer
3680
4158
  end
3681
4159
  end
3682
4160
 
4161
+ # For an anyOf request matcher that does not match the request, which branch
4162
+ # came closest (+closest_branch+, 0-based, the earlier on a tie) and how each
4163
+ # differed: each of +branches+ has branch, matches, differingFieldCount and
4164
+ # differingFields.
4165
+ class AnyOfBranchComparison
4166
+ attr_reader :closest_branch, :branches
4167
+
4168
+ def initialize(closest_branch: nil, branches: [])
4169
+ @closest_branch = closest_branch
4170
+ @branches = branches
4171
+ end
4172
+
4173
+ def self.from_hash(data)
4174
+ return nil if data.nil?
4175
+
4176
+ new(closest_branch: data['closestBranch'], branches: data['branches'] || [])
4177
+ end
4178
+ end
4179
+
3683
4180
  # One expectation as explain, debug_mismatch or explain_unmatched evaluated
3684
4181
  # it. The matched expectation has no +reason+; a skipped or shadowed one has
3685
4182
  # a +reason+ (NAMESPACE, INACTIVE, FIELDS_DIFFER, NOT_OPERATOR, CONVERSATION,
3686
4183
  # SCENARIO_STATE, PERCENTAGE or LOWER_PRIORITY) and an +explanation+.
3687
- # +suggested_fix+ is set only by explain_unmatched with +suggest_fixes+.
4184
+ # +suggested_fix+ is set only by explain_unmatched with +suggest_fixes+;
4185
+ # +any_of+ only for an anyOf request matcher that does not match.
3688
4186
  class ExplainedExpectation
3689
4187
  attr_reader :expectation_id, :expectation_method, :expectation_path, :position, :priority,
3690
4188
  :reason, :explanation, :matches, :matched_field_count, :total_field_count,
3691
- :differing_field_count, :differences, :remediation, :suggested_fix
4189
+ :differing_field_count, :differences, :remediation, :suggested_fix, :any_of
3692
4190
 
3693
4191
  def initialize(expectation_id: nil, expectation_method: nil, expectation_path: nil, position: nil,
3694
4192
  priority: nil, reason: nil, explanation: nil, matches: nil, matched_field_count: nil,
3695
4193
  total_field_count: nil, differing_field_count: nil, differences: {}, remediation: {},
3696
- suggested_fix: nil)
4194
+ suggested_fix: nil, any_of: nil)
3697
4195
  @expectation_id = expectation_id
3698
4196
  @expectation_method = expectation_method
3699
4197
  @expectation_path = expectation_path
@@ -3708,6 +4206,7 @@ module MockServer
3708
4206
  @differences = differences
3709
4207
  @remediation = remediation
3710
4208
  @suggested_fix = suggested_fix
4209
+ @any_of = any_of
3711
4210
  end
3712
4211
 
3713
4212
  def self.from_hash(data)
@@ -3727,7 +4226,8 @@ module MockServer
3727
4226
  differing_field_count: data['differingFieldCount'],
3728
4227
  differences: data['differences'] || {},
3729
4228
  remediation: data['remediation'] || {},
3730
- suggested_fix: SuggestedFix.from_hash(data['suggestedFix'])
4229
+ suggested_fix: SuggestedFix.from_hash(data['suggestedFix']),
4230
+ any_of: AnyOfBranchComparison.from_hash(data['anyOf'])
3731
4231
  )
3732
4232
  end
3733
4233