mockserver-client 7.6.0 → 9.0.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.
@@ -131,6 +131,44 @@ module MockServer
131
131
  hash.reject { |_k, v| v.nil? }
132
132
  end
133
133
 
134
+ # @api private
135
+ def self.symbol_keys?(value)
136
+ case value
137
+ when Hash then value.any? { |k, v| k.is_a?(Symbol) || symbol_keys?(v) }
138
+ when Array then value.any? { |v| symbol_keys?(v) }
139
+ else false
140
+ end
141
+ end
142
+
143
+ # @api private
144
+ # Returns a copy of +value+ with every Symbol hash key, at any depth, turned
145
+ # into a String; values and other keys are unchanged.
146
+ def self.stringify_keys(value)
147
+ case value
148
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.is_a?(Symbol) ? k.to_s : k] = stringify_keys(v) }
149
+ when Array then value.map { |v| stringify_keys(v) }
150
+ else value
151
+ end
152
+ end
153
+
154
+ # @api private
155
+ # Prepended to every model's from_hash. A hash literal written with JSON-style
156
+ # keys ({ "httpRequest": {...} }) has Symbol keys in Ruby, while the models read
157
+ # the String keys JSON.parse produces, so normalise before reading. Only the
158
+ # outermost call scans: nested calls receive parts of the normalised hash.
159
+ module SymbolKeyTolerantFromHash
160
+ def from_hash(data)
161
+ return super if Thread.current[:mockserver_from_hash_normalised]
162
+
163
+ Thread.current[:mockserver_from_hash_normalised] = true
164
+ begin
165
+ super(MockServer.symbol_keys?(data) ? MockServer.stringify_keys(data) : data)
166
+ ensure
167
+ Thread.current[:mockserver_from_hash_normalised] = false
168
+ end
169
+ end
170
+ end
171
+
134
172
  # @api private
135
173
  # Coerce a class-callback value into an {HttpClassCallback}. Accepts:
136
174
  # * +nil+ -> +nil+
@@ -199,12 +237,62 @@ module MockServer
199
237
  end
200
238
 
201
239
  # @api private
202
- def self.serialize_key_multi_values(items)
240
+ # With a key_match_style (MATCHING_KEY or SUB_SET) the collection is written in
241
+ # the object form, the only form that carries keyMatchStyle.
242
+ def self.serialize_key_multi_values(items, key_match_style = nil)
243
+ if key_match_style
244
+ map = { 'keyMatchStyle' => key_match_style }
245
+ (items || []).each { |item| (map[item.name] ||= []).concat(Array(item.values)) }
246
+ return map
247
+ end
203
248
  return nil if items.nil?
204
249
 
205
250
  items.map(&:to_h)
206
251
  end
207
252
 
253
+ # @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.
257
+ def self.deserialize_request_definition(data)
258
+ return ConditionalRequestDefinition.from_hash(data) if data.is_a?(Hash) && data.key?('if')
259
+ return OpenAPIDefinition.from_hash(data) if data.is_a?(Hash) && data.key?('specUrlOrPayload')
260
+
261
+ HttpRequest.from_hash(data)
262
+ end
263
+
264
+ # @api private
265
+ def self.key_match_style(data)
266
+ data['keyMatchStyle'] || data[:keyMatchStyle] if data.is_a?(Hash)
267
+ end
268
+
269
+ # @api private
270
+ # Accept a keyToMultiValue collection as the server's JSON does, so headers,
271
+ # query parameters and trailers can be given as { name => values }, as
272
+ # [{ name:, values: }] or as KeyToMultiValue objects.
273
+ def self.coerce_key_multi_values(value)
274
+ return value if value.nil?
275
+ return deserialize_key_multi_values(stringify_keys(value)) if value.is_a?(Hash)
276
+ raise TypeError, "Expected a Hash or an Array of KeyToMultiValue, got #{value.class.name}" unless value.is_a?(Array)
277
+
278
+ value.map { |item| item.is_a?(Hash) ? KeyToMultiValue.from_hash(stringify_keys(item)) : item }
279
+ end
280
+
281
+ # @api private
282
+ # Cookies as { name => value }, [{ name:, value: }] or KeyToMultiValue objects.
283
+ def self.coerce_cookies(value)
284
+ return value if value.nil?
285
+ return deserialize_cookies(stringify_keys(value)) if value.is_a?(Hash)
286
+ raise TypeError, "Expected a Hash or an Array of KeyToMultiValue, got #{value.class.name}" unless value.is_a?(Array)
287
+
288
+ value.map do |item|
289
+ next item unless item.is_a?(Hash)
290
+
291
+ item = stringify_keys(item)
292
+ KeyToMultiValue.new(name: item.fetch('name', ''), values: item.key?('value') ? [item['value']] : item.fetch('values', []))
293
+ end
294
+ end
295
+
208
296
  # @api private
209
297
  # Serialise a keyToMultiValue collection in the { name => [values] } object form
210
298
  # (rather than the [{name, values}] array form). MockServer accepts both wire
@@ -222,7 +310,8 @@ module MockServer
222
310
  return nil if data.nil?
223
311
 
224
312
  if data.is_a?(Hash)
225
- return data.map { |k, v| KeyToMultiValue.new(name: k, values: v.is_a?(Array) ? v : [v]) }
313
+ return data.reject { |k, _v| k == 'keyMatchStyle' }
314
+ .map { |k, v| KeyToMultiValue.new(name: k, values: v.is_a?(Array) ? v : [v]) }
226
315
  end
227
316
 
228
317
  data.map do |item|
@@ -304,19 +393,25 @@ module MockServer
304
393
  end
305
394
 
306
395
  class Delay
307
- attr_accessor :time_unit, :value, :distribution
396
+ # template is rendered against the request and read as milliseconds (falling
397
+ # back to time_unit/value); template_type is VELOCITY or MUSTACHE.
398
+ attr_accessor :time_unit, :value, :distribution, :template, :template_type
308
399
 
309
- def initialize(time_unit: 'MILLISECONDS', value: 0, distribution: nil)
400
+ def initialize(time_unit: 'MILLISECONDS', value: 0, distribution: nil, template: nil, template_type: nil)
310
401
  @time_unit = time_unit
311
402
  @value = value
312
403
  @distribution = distribution
404
+ @template = template
405
+ @template_type = template_type
313
406
  end
314
407
 
315
408
  def to_h
316
409
  MockServer.strip_none({
317
410
  'timeUnit' => @time_unit,
318
411
  'value' => @value,
319
- 'distribution' => @distribution&.to_h
412
+ 'distribution' => @distribution&.to_h,
413
+ 'template' => @template,
414
+ 'templateType' => @template_type
320
415
  })
321
416
  end
322
417
 
@@ -325,9 +420,11 @@ module MockServer
325
420
 
326
421
  dist_data = data['distribution']
327
422
  new(
328
- time_unit: data.fetch('timeUnit', 'MILLISECONDS'),
329
- value: data.fetch('value', 0),
330
- distribution: dist_data ? DelayDistribution.from_hash(dist_data) : nil
423
+ time_unit: data.fetch('timeUnit', 'MILLISECONDS'),
424
+ value: data.fetch('value', 0),
425
+ distribution: dist_data ? DelayDistribution.from_hash(dist_data) : nil,
426
+ template: data['template'],
427
+ template_type: data['templateType']
331
428
  )
332
429
  end
333
430
  end
@@ -786,46 +883,85 @@ module MockServer
786
883
  # protocol pins the transport the matcher requires
787
884
  # (org.mockserver.model.Protocol: HTTP_1_1 / HTTP_2 / HTTP_3 / SOCKS / ...),
788
885
  # round-tripping the httpRequest +protocol+ wire key.
789
- attr_accessor :method, :path, :query_string_parameters, :headers,
790
- :cookies, :body, :secure, :protocol, :keep_alive, :respond_before_body,
791
- :path_parameters, :socket_address, :jwt,
792
- :dns_name, :dns_type, :dns_class
886
+ # headers, query_string_parameters, path_parameters and cookies also accept a
887
+ # Hash ({ name => values }) or an Array of Hashes, as in the server's JSON.
888
+ # *_key_match_style is SUB_SET (the server's default: one value of a key
889
+ # must match) or MATCHING_KEY (every value must); a Hash with a
890
+ # 'keyMatchStyle' key sets it too.
891
+ attr_accessor :method, :path, :body, :secure, :protocol, :keep_alive, :respond_before_body,
892
+ :socket_address, :jwt, :dns_name, :dns_type, :dns_class,
893
+ :headers_key_match_style, :query_string_parameters_key_match_style,
894
+ :path_parameters_key_match_style
895
+ attr_reader :query_string_parameters, :headers, :cookies, :path_parameters
793
896
 
794
897
  def initialize(method: nil, path: nil, query_string_parameters: nil, headers: nil,
795
898
  cookies: nil, body: nil, secure: nil, protocol: nil, keep_alive: nil,
796
899
  respond_before_body: nil, path_parameters: nil, socket_address: nil,
797
- jwt: nil, dns_name: nil, dns_type: nil, dns_class: nil)
900
+ jwt: nil, dns_name: nil, dns_type: nil, dns_class: nil,
901
+ headers_key_match_style: nil, query_string_parameters_key_match_style: nil,
902
+ path_parameters_key_match_style: nil)
798
903
  @method = method
799
904
  @path = path
800
- @query_string_parameters = query_string_parameters
801
- @headers = headers
802
- @cookies = cookies
905
+ self.query_string_parameters = query_string_parameters
906
+ self.headers = headers
907
+ self.cookies = cookies
803
908
  @body = body
804
909
  @secure = secure
805
910
  @protocol = protocol
806
911
  @keep_alive = keep_alive
807
912
  @respond_before_body = respond_before_body
808
- @path_parameters = path_parameters
913
+ self.path_parameters = path_parameters
809
914
  @socket_address = socket_address
810
915
  @jwt = jwt
811
916
  @dns_name = dns_name
812
917
  @dns_type = dns_type
813
918
  @dns_class = dns_class
919
+ @headers_key_match_style = headers_key_match_style if headers_key_match_style
920
+ @query_string_parameters_key_match_style = query_string_parameters_key_match_style if query_string_parameters_key_match_style
921
+ @path_parameters_key_match_style = path_parameters_key_match_style if path_parameters_key_match_style
922
+ end
923
+
924
+ def headers=(value)
925
+ style = MockServer.key_match_style(value)
926
+ @headers_key_match_style = style if style
927
+ @headers = MockServer.coerce_key_multi_values(value)
928
+ end
929
+
930
+ def query_string_parameters=(value)
931
+ style = MockServer.key_match_style(value)
932
+ @query_string_parameters_key_match_style = style if style
933
+ @query_string_parameters = MockServer.coerce_key_multi_values(value)
934
+ end
935
+
936
+ def path_parameters=(value)
937
+ style = MockServer.key_match_style(value)
938
+ @path_parameters_key_match_style = style if style
939
+ @path_parameters = MockServer.coerce_key_multi_values(value)
940
+ end
941
+
942
+ def cookies=(value)
943
+ @cookies = MockServer.coerce_cookies(value)
814
944
  end
815
945
 
816
946
  def to_h
947
+ path_parameters = if @path_parameters_key_match_style
948
+ MockServer.serialize_key_multi_values(@path_parameters, @path_parameters_key_match_style)
949
+ else
950
+ MockServer.serialize_key_multi_values_object(@path_parameters)
951
+ end
817
952
  MockServer.strip_none({
818
953
  'method' => @method,
819
954
  'path' => @path,
820
- 'queryStringParameters' => MockServer.serialize_key_multi_values(@query_string_parameters),
821
- 'headers' => MockServer.serialize_key_multi_values(@headers),
955
+ 'queryStringParameters' => MockServer.serialize_key_multi_values(@query_string_parameters,
956
+ @query_string_parameters_key_match_style),
957
+ 'headers' => MockServer.serialize_key_multi_values(@headers, @headers_key_match_style),
822
958
  'cookies' => MockServer.serialize_cookies(@cookies),
823
959
  'body' => MockServer.serialize_body(@body),
824
960
  'secure' => @secure,
825
961
  'protocol' => @protocol,
826
962
  'keepAlive' => @keep_alive,
827
963
  'respondBeforeBody' => @respond_before_body,
828
- 'pathParameters' => MockServer.serialize_key_multi_values_object(@path_parameters),
964
+ 'pathParameters' => path_parameters,
829
965
  'socketAddress' => @socket_address&.to_h,
830
966
  'jwt' => @jwt&.to_h,
831
967
  'dnsName' => @dns_name,
@@ -853,7 +989,10 @@ module MockServer
853
989
  jwt: Jwt.from_hash(data['jwt']),
854
990
  dns_name: data['dnsName'],
855
991
  dns_type: data['dnsType'],
856
- dns_class: data['dnsClass']
992
+ dns_class: data['dnsClass'],
993
+ headers_key_match_style: MockServer.key_match_style(data['headers']),
994
+ query_string_parameters_key_match_style: MockServer.key_match_style(data['queryStringParameters']),
995
+ path_parameters_key_match_style: MockServer.key_match_style(data['pathParameters'])
857
996
  )
858
997
  end
859
998
 
@@ -968,23 +1107,74 @@ module MockServer
968
1107
  end
969
1108
  end
970
1109
 
1110
+ # Serves +fail_response+ for the first +fail_times+ matches, then the real
1111
+ # response; +idempotency_header+ keys an independent window per header value.
1112
+ class RecoverAfter
1113
+ attr_accessor :fail_times, :fail_response, :idempotency_header
1114
+
1115
+ def initialize(fail_times: nil, fail_response: nil, idempotency_header: nil)
1116
+ @fail_times = fail_times
1117
+ @fail_response = fail_response
1118
+ @idempotency_header = idempotency_header
1119
+ end
1120
+
1121
+ def to_h
1122
+ MockServer.strip_none({
1123
+ 'failTimes' => @fail_times,
1124
+ 'failResponse' => @fail_response&.to_h,
1125
+ 'idempotencyHeader' => @idempotency_header
1126
+ })
1127
+ end
1128
+
1129
+ def self.from_hash(data)
1130
+ return nil if data.nil?
1131
+
1132
+ new(
1133
+ fail_times: data['failTimes'],
1134
+ fail_response: HttpResponse.from_hash(data['failResponse']),
1135
+ idempotency_header: data['idempotencyHeader']
1136
+ )
1137
+ end
1138
+ end
1139
+
971
1140
  class HttpResponse
972
1141
  # trailers carries HTTP trailing headers (org.mockserver.model.Headers on the
973
1142
  # response's +trailers+ slot), a keyToMultiValue collection like +headers+.
974
- attr_accessor :status_code, :reason_phrase, :headers, :cookies,
975
- :body, :delay, :connection_options, :trailers, :primary
1143
+ # status_code_range serves a status from a range such as "2xx";
1144
+ # generate_from_schema is a JSON schema the body is generated from.
1145
+ # headers, trailers and cookies also accept a Hash or an Array of Hashes,
1146
+ # as in the server's JSON (see HttpRequest).
1147
+ attr_accessor :status_code, :reason_phrase, :body, :delay, :connection_options, :primary,
1148
+ :status_code_range, :generate_from_schema, :recover_after
1149
+ attr_reader :headers, :cookies, :trailers
976
1150
 
977
1151
  def initialize(status_code: nil, reason_phrase: nil, headers: nil, cookies: nil,
978
- body: nil, delay: nil, connection_options: nil, trailers: nil, primary: nil)
1152
+ body: nil, delay: nil, connection_options: nil, trailers: nil, primary: nil,
1153
+ status_code_range: nil, generate_from_schema: nil, recover_after: nil)
979
1154
  @status_code = status_code
980
1155
  @reason_phrase = reason_phrase
981
- @headers = headers
982
- @cookies = cookies
1156
+ self.headers = headers
1157
+ self.cookies = cookies
983
1158
  @body = body
984
1159
  @delay = delay
985
1160
  @connection_options = connection_options
986
- @trailers = trailers
1161
+ self.trailers = trailers
987
1162
  @primary = primary
1163
+ @status_code_range = status_code_range
1164
+ @generate_from_schema = generate_from_schema
1165
+ @recover_after = recover_after
1166
+ end
1167
+
1168
+ def headers=(value)
1169
+ @headers = MockServer.coerce_key_multi_values(value)
1170
+ end
1171
+
1172
+ def trailers=(value)
1173
+ @trailers = MockServer.coerce_key_multi_values(value)
1174
+ end
1175
+
1176
+ def cookies=(value)
1177
+ @cookies = MockServer.coerce_cookies(value)
988
1178
  end
989
1179
 
990
1180
  def to_h
@@ -997,7 +1187,10 @@ module MockServer
997
1187
  'delay' => @delay&.to_h,
998
1188
  'connectionOptions' => @connection_options&.to_h,
999
1189
  'trailers' => MockServer.serialize_key_multi_values(@trailers),
1000
- 'primary' => @primary
1190
+ 'primary' => @primary,
1191
+ 'statusCodeRange' => @status_code_range,
1192
+ 'generateFromSchema' => @generate_from_schema,
1193
+ 'recoverAfter' => @recover_after&.to_h
1001
1194
  })
1002
1195
  end
1003
1196
 
@@ -1013,7 +1206,10 @@ module MockServer
1013
1206
  delay: Delay.from_hash(data['delay']),
1014
1207
  connection_options: ConnectionOptions.from_hash(data['connectionOptions']),
1015
1208
  trailers: MockServer.deserialize_key_multi_values(data['trailers']),
1016
- primary: data['primary']
1209
+ primary: data['primary'],
1210
+ status_code_range: data['statusCodeRange'],
1211
+ generate_from_schema: data['generateFromSchema'],
1212
+ recover_after: RecoverAfter.from_hash(data['recoverAfter'])
1017
1213
  )
1018
1214
  end
1019
1215
 
@@ -1068,6 +1264,21 @@ module MockServer
1068
1264
  @reason_phrase = reason_phrase
1069
1265
  self
1070
1266
  end
1267
+
1268
+ def with_status_code_range(status_code_range)
1269
+ @status_code_range = status_code_range
1270
+ self
1271
+ end
1272
+
1273
+ def with_generate_from_schema(schema)
1274
+ @generate_from_schema = schema
1275
+ self
1276
+ end
1277
+
1278
+ def with_recover_after(recover_after)
1279
+ @recover_after = recover_after
1280
+ self
1281
+ end
1071
1282
  end
1072
1283
 
1073
1284
  class HttpForward
@@ -1109,23 +1320,31 @@ module MockServer
1109
1320
  end
1110
1321
 
1111
1322
  class HttpTemplate
1112
- attr_accessor :template_type, :template, :template_file, :delay, :primary
1323
+ # response_override (an {HttpResponse}) is applied over the rendered output;
1324
+ # response_modifier is the server's responseModifier object, as a Hash.
1325
+ attr_accessor :template_type, :template, :template_file, :delay, :primary,
1326
+ :response_override, :response_modifier
1113
1327
 
1114
- def initialize(template_type: 'JAVASCRIPT', template: nil, template_file: nil, delay: nil, primary: nil)
1328
+ def initialize(template_type: 'JAVASCRIPT', template: nil, template_file: nil, delay: nil, primary: nil,
1329
+ response_override: nil, response_modifier: nil)
1115
1330
  @template_type = template_type
1116
1331
  @template = template
1117
1332
  @template_file = template_file
1118
1333
  @delay = delay
1119
1334
  @primary = primary
1335
+ @response_override = response_override
1336
+ @response_modifier = response_modifier
1120
1337
  end
1121
1338
 
1122
1339
  def to_h
1123
1340
  MockServer.strip_none({
1124
- 'templateType' => @template_type,
1125
- 'template' => @template,
1126
- 'templateFile' => @template_file,
1127
- 'delay' => @delay&.to_h,
1128
- 'primary' => @primary
1341
+ 'templateType' => @template_type,
1342
+ 'template' => @template,
1343
+ 'templateFile' => @template_file,
1344
+ 'delay' => @delay&.to_h,
1345
+ 'primary' => @primary,
1346
+ 'responseOverride' => @response_override&.to_h,
1347
+ 'responseModifier' => @response_modifier
1129
1348
  })
1130
1349
  end
1131
1350
 
@@ -1137,7 +1356,9 @@ module MockServer
1137
1356
  template: data['template'],
1138
1357
  template_file: data['templateFile'],
1139
1358
  delay: Delay.from_hash(data['delay']),
1140
- primary: data['primary']
1359
+ primary: data['primary'],
1360
+ response_override: HttpResponse.from_hash(data['responseOverride']),
1361
+ response_modifier: data['responseModifier']
1141
1362
  )
1142
1363
  end
1143
1364
 
@@ -1149,6 +1370,16 @@ module MockServer
1149
1370
  @template_file = template_file
1150
1371
  self
1151
1372
  end
1373
+
1374
+ def with_response_override(response_override)
1375
+ @response_override = response_override
1376
+ self
1377
+ end
1378
+
1379
+ def with_response_modifier(response_modifier)
1380
+ @response_modifier = response_modifier
1381
+ self
1382
+ end
1152
1383
  end
1153
1384
 
1154
1385
  class HttpClassCallback
@@ -1256,16 +1487,27 @@ module MockServer
1256
1487
  end
1257
1488
 
1258
1489
  class HttpOverrideForwardedRequest
1490
+ # request_override (an {HttpRequest}) is merged over the forwarded request and
1491
+ # response_override (an {HttpResponse}) over the response; response_template
1492
+ # (an {HttpTemplate}) renders the response from the request and the forwarded
1493
+ # response. http_request / http_response are the older names of the overrides;
1494
+ # the server accepts either set, not both.
1259
1495
  attr_accessor :http_request, :http_response, :delay,
1260
- :request_modifier, :response_modifier, :primary
1496
+ :request_override, :request_modifier,
1497
+ :response_override, :response_modifier, :response_template, :primary
1261
1498
 
1262
1499
  def initialize(http_request: nil, http_response: nil, delay: nil,
1263
- request_modifier: nil, response_modifier: nil, primary: nil)
1500
+ request_override: nil, request_modifier: nil,
1501
+ response_override: nil, response_modifier: nil, response_template: nil,
1502
+ primary: nil)
1264
1503
  @http_request = http_request
1265
1504
  @http_response = http_response
1266
1505
  @delay = delay
1506
+ @request_override = request_override
1267
1507
  @request_modifier = request_modifier
1508
+ @response_override = response_override
1268
1509
  @response_modifier = response_modifier
1510
+ @response_template = response_template
1269
1511
  @primary = primary
1270
1512
  end
1271
1513
 
@@ -1274,8 +1516,11 @@ module MockServer
1274
1516
  'httpRequest' => @http_request&.to_h,
1275
1517
  'httpResponse' => @http_response&.to_h,
1276
1518
  'delay' => @delay&.to_h,
1519
+ 'requestOverride' => @request_override&.to_h,
1277
1520
  'requestModifier' => @request_modifier,
1521
+ 'responseOverride' => @response_override&.to_h,
1278
1522
  'responseModifier' => @response_modifier,
1523
+ 'responseTemplate' => @response_template&.to_h,
1279
1524
  'primary' => @primary
1280
1525
  })
1281
1526
  end
@@ -1287,8 +1532,11 @@ module MockServer
1287
1532
  http_request: HttpRequest.from_hash(data['httpRequest']),
1288
1533
  http_response: HttpResponse.from_hash(data['httpResponse']),
1289
1534
  delay: Delay.from_hash(data['delay']),
1535
+ request_override: HttpRequest.from_hash(data['requestOverride']),
1290
1536
  request_modifier: data['requestModifier'],
1537
+ response_override: HttpResponse.from_hash(data['responseOverride']),
1291
1538
  response_modifier: data['responseModifier'],
1539
+ response_template: HttpTemplate.from_hash(data['responseTemplate']),
1292
1540
  primary: data['primary']
1293
1541
  )
1294
1542
  end
@@ -1296,6 +1544,21 @@ module MockServer
1296
1544
  def self.forward_overridden_request(request: nil)
1297
1545
  new(http_request: request)
1298
1546
  end
1547
+
1548
+ def with_request_override(request_override)
1549
+ @request_override = request_override
1550
+ self
1551
+ end
1552
+
1553
+ def with_response_override(response_override)
1554
+ @response_override = response_override
1555
+ self
1556
+ end
1557
+
1558
+ def with_response_template(response_template)
1559
+ @response_template = response_template
1560
+ self
1561
+ end
1299
1562
  end
1300
1563
 
1301
1564
  class HttpRequestAndHttpResponse
@@ -1376,15 +1639,18 @@ module MockServer
1376
1639
  end
1377
1640
 
1378
1641
  class HttpSseResponse
1379
- attr_accessor :status_code, :headers, :events, :close_connection, :delay, :primary
1642
+ # template_type renders each event with that engine (VELOCITY / JAVASCRIPT / MUSTACHE).
1643
+ attr_accessor :status_code, :headers, :events, :close_connection, :delay, :primary, :template_type
1380
1644
 
1381
- def initialize(status_code: nil, headers: nil, events: nil, close_connection: nil, delay: nil, primary: nil)
1645
+ def initialize(status_code: nil, headers: nil, events: nil, close_connection: nil, delay: nil, primary: nil,
1646
+ template_type: nil)
1382
1647
  @status_code = status_code
1383
1648
  @headers = headers
1384
1649
  @events = events
1385
1650
  @close_connection = close_connection
1386
1651
  @delay = delay
1387
1652
  @primary = primary
1653
+ @template_type = template_type
1388
1654
  end
1389
1655
 
1390
1656
  def to_h
@@ -1395,6 +1661,7 @@ module MockServer
1395
1661
  result['closeConnection'] = @close_connection unless @close_connection.nil?
1396
1662
  result['delay'] = @delay.to_h if @delay
1397
1663
  result['primary'] = @primary unless @primary.nil?
1664
+ result['templateType'] = @template_type unless @template_type.nil?
1398
1665
  result
1399
1666
  end
1400
1667
 
@@ -1409,7 +1676,8 @@ module MockServer
1409
1676
  events: events,
1410
1677
  close_connection: data['closeConnection'],
1411
1678
  delay: Delay.from_hash(data['delay']),
1412
- primary: data['primary']
1679
+ primary: data['primary'],
1680
+ template_type: data['templateType']
1413
1681
  )
1414
1682
  end
1415
1683
  end
@@ -1478,18 +1746,63 @@ module MockServer
1478
1746
  end
1479
1747
  end
1480
1748
 
1749
+ # Matches a graphql-transport-ws subscribe message against this GraphQL query.
1750
+ # +type+ is an optional body-type discriminator; only "GRAPHQL" is meaningful.
1751
+ class GraphQLSubscriptionFilter
1752
+ attr_accessor :query, :operation_name, :variables_schema, :selection_set_match_type, :fields, :type
1753
+
1754
+ def initialize(query: nil, operation_name: nil, variables_schema: nil, selection_set_match_type: nil,
1755
+ fields: nil, type: nil)
1756
+ @query = query
1757
+ @operation_name = operation_name
1758
+ @variables_schema = variables_schema
1759
+ @selection_set_match_type = selection_set_match_type
1760
+ @fields = fields
1761
+ @type = type
1762
+ end
1763
+
1764
+ def to_h
1765
+ MockServer.strip_none({
1766
+ 'type' => @type,
1767
+ 'query' => @query,
1768
+ 'operationName' => @operation_name,
1769
+ 'variablesSchema' => @variables_schema,
1770
+ 'selectionSetMatchType' => @selection_set_match_type,
1771
+ 'fields' => @fields
1772
+ })
1773
+ end
1774
+
1775
+ def self.from_hash(data)
1776
+ return nil if data.nil?
1777
+
1778
+ new(
1779
+ query: data['query'],
1780
+ operation_name: data['operationName'],
1781
+ variables_schema: data['variablesSchema'],
1782
+ selection_set_match_type: data['selectionSetMatchType'],
1783
+ fields: data['fields'],
1784
+ type: data['type']
1785
+ )
1786
+ end
1787
+ end
1788
+
1481
1789
  class HttpWebSocketResponse
1482
1790
  # matchers carries per-incoming-frame response rules (server
1483
1791
  # HttpWebSocketResponseDTO.matchers); each is a {WebSocketFrameMatcher}.
1484
- attr_accessor :subprotocol, :messages, :matchers, :close_connection, :delay, :primary
1792
+ # template_type renders each message with that engine.
1793
+ attr_accessor :subprotocol, :messages, :matchers, :close_connection, :delay, :primary,
1794
+ :template_type, :graphql_subscription_filter
1485
1795
 
1486
- def initialize(subprotocol: nil, messages: nil, matchers: nil, close_connection: nil, delay: nil, primary: nil)
1796
+ def initialize(subprotocol: nil, messages: nil, matchers: nil, close_connection: nil, delay: nil, primary: nil,
1797
+ template_type: nil, graphql_subscription_filter: nil)
1487
1798
  @subprotocol = subprotocol
1488
1799
  @messages = messages
1489
1800
  @matchers = matchers
1490
1801
  @close_connection = close_connection
1491
1802
  @delay = delay
1492
1803
  @primary = primary
1804
+ @template_type = template_type
1805
+ @graphql_subscription_filter = graphql_subscription_filter
1493
1806
  end
1494
1807
 
1495
1808
  def to_h
@@ -1500,6 +1813,8 @@ module MockServer
1500
1813
  result['closeConnection'] = @close_connection unless @close_connection.nil?
1501
1814
  result['delay'] = @delay.to_h if @delay
1502
1815
  result['primary'] = @primary unless @primary.nil?
1816
+ result['templateType'] = @template_type unless @template_type.nil?
1817
+ result['graphqlSubscriptionFilter'] = @graphql_subscription_filter.to_h if @graphql_subscription_filter
1503
1818
  result
1504
1819
  end
1505
1820
 
@@ -1516,7 +1831,9 @@ module MockServer
1516
1831
  matchers: matchers,
1517
1832
  close_connection: data['closeConnection'],
1518
1833
  delay: Delay.from_hash(data['delay']),
1519
- primary: data['primary']
1834
+ primary: data['primary'],
1835
+ template_type: data['templateType'],
1836
+ graphql_subscription_filter: GraphQLSubscriptionFilter.from_hash(data['graphqlSubscriptionFilter'])
1520
1837
  )
1521
1838
  end
1522
1839
  end
@@ -1672,13 +1989,23 @@ module MockServer
1672
1989
  end
1673
1990
  end
1674
1991
 
1992
+ # Values for BinaryResponse#upstream: what happens upstream to a message a binary
1993
+ # expectation matches on a relayed connection (matchBinaryExpectationsWhenProxying).
1994
+ # ANSWER_ONLY is the server's default; the other two need binaryMessageFraming POSTGRESQL.
1995
+ module BinaryUpstream
1996
+ ANSWER_ONLY = 'ANSWER_ONLY'
1997
+ ANSWER_AND_FORWARD = 'ANSWER_AND_FORWARD'
1998
+ FORWARD_AND_REPLACE = 'FORWARD_AND_REPLACE'
1999
+ end
2000
+
1675
2001
  class BinaryResponse
1676
- attr_accessor :binary_data, :delay, :primary
2002
+ attr_accessor :binary_data, :delay, :primary, :upstream
1677
2003
 
1678
- def initialize(binary_data: nil, delay: nil, primary: nil)
2004
+ def initialize(binary_data: nil, delay: nil, primary: nil, upstream: nil)
1679
2005
  @binary_data = binary_data
1680
2006
  @delay = delay
1681
2007
  @primary = primary
2008
+ @upstream = upstream
1682
2009
  end
1683
2010
 
1684
2011
  def to_h
@@ -1686,6 +2013,7 @@ module MockServer
1686
2013
  result['binaryData'] = @binary_data unless @binary_data.nil?
1687
2014
  result['delay'] = @delay.to_h if @delay
1688
2015
  result['primary'] = @primary unless @primary.nil?
2016
+ result['upstream'] = @upstream unless @upstream.nil?
1689
2017
  result
1690
2018
  end
1691
2019
 
@@ -1695,7 +2023,8 @@ module MockServer
1695
2023
  new(
1696
2024
  binary_data: data['binaryData'],
1697
2025
  delay: Delay.from_hash(data['delay']),
1698
- primary: data['primary']
2026
+ primary: data['primary'],
2027
+ upstream: data['upstream']
1699
2028
  )
1700
2029
  end
1701
2030
  end
@@ -2541,7 +2870,7 @@ module MockServer
2541
2870
  id: data['id'],
2542
2871
  priority: data['priority'],
2543
2872
  percentage: data['percentage'],
2544
- http_request: HttpRequest.from_hash(data['httpRequest']),
2873
+ http_request: MockServer.deserialize_request_definition(data['httpRequest']),
2545
2874
  http_response: HttpResponse.from_hash(data['httpResponse']),
2546
2875
  http_response_template: HttpTemplate.from_hash(data['httpResponseTemplate']),
2547
2876
  http_response_class_callback: HttpClassCallback.from_hash(data['httpResponseClassCallback']),
@@ -2583,18 +2912,25 @@ module MockServer
2583
2912
  end
2584
2913
  end
2585
2914
 
2915
+ # An OpenAPI request matcher: as an expectation's or verification's
2916
+ # httpRequest it matches the requests an OpenAPI operation accepts.
2586
2917
  class OpenAPIDefinition
2587
- attr_accessor :spec_url_or_payload, :operation_id
2918
+ # not_operation inverts the match (the server's +not+ field).
2919
+ attr_accessor :spec_url_or_payload, :operation_id, :context_path_prefix, :not_operation
2588
2920
 
2589
- def initialize(spec_url_or_payload: nil, operation_id: nil)
2921
+ def initialize(spec_url_or_payload: nil, operation_id: nil, context_path_prefix: nil, not_operation: nil)
2590
2922
  @spec_url_or_payload = spec_url_or_payload
2591
2923
  @operation_id = operation_id
2924
+ @context_path_prefix = context_path_prefix
2925
+ @not_operation = not_operation
2592
2926
  end
2593
2927
 
2594
2928
  def to_h
2595
2929
  MockServer.strip_none({
2596
- 'specUrlOrPayload' => @spec_url_or_payload,
2597
- 'operationId' => @operation_id
2930
+ 'not' => @not_operation,
2931
+ 'specUrlOrPayload' => @spec_url_or_payload,
2932
+ 'operationId' => @operation_id,
2933
+ 'contextPathPrefix' => @context_path_prefix
2598
2934
  })
2599
2935
  end
2600
2936
 
@@ -2603,7 +2939,46 @@ module MockServer
2603
2939
 
2604
2940
  new(
2605
2941
  spec_url_or_payload: data['specUrlOrPayload'],
2606
- operation_id: data['operationId']
2942
+ operation_id: data['operationId'],
2943
+ context_path_prefix: data['contextPathPrefix'],
2944
+ not_operation: data['not']
2945
+ )
2946
+ end
2947
+ end
2948
+
2949
+ # A conditional (if/then/else) request matcher, written as an httpRequest
2950
+ # holding if, then and else. When +if_request+ matches, +then_request+ must
2951
+ # match too; otherwise +else_request+ must (with none, the matcher matches
2952
+ # whenever +if_request+ does not). Each branch is an HttpRequest, an
2953
+ # OpenAPIDefinition or another ConditionalRequestDefinition.
2954
+ class ConditionalRequestDefinition
2955
+ # not_condition inverts the match (the server's +not+ field).
2956
+ attr_accessor :if_request, :then_request, :else_request, :not_condition
2957
+
2958
+ def initialize(if_request: nil, then_request: nil, else_request: nil, not_condition: nil)
2959
+ @if_request = if_request
2960
+ @then_request = then_request
2961
+ @else_request = else_request
2962
+ @not_condition = not_condition
2963
+ end
2964
+
2965
+ def to_h
2966
+ MockServer.strip_none({
2967
+ 'not' => @not_condition,
2968
+ 'if' => @if_request&.to_h,
2969
+ 'then' => @then_request&.to_h,
2970
+ 'else' => @else_request&.to_h
2971
+ })
2972
+ end
2973
+
2974
+ def self.from_hash(data)
2975
+ return nil if data.nil?
2976
+
2977
+ new(
2978
+ if_request: MockServer.deserialize_request_definition(data['if']),
2979
+ then_request: MockServer.deserialize_request_definition(data['then']),
2980
+ else_request: MockServer.deserialize_request_definition(data['else']),
2981
+ not_condition: data['not']
2607
2982
  )
2608
2983
  end
2609
2984
  end
@@ -2705,7 +3080,7 @@ module MockServer
2705
3080
  return nil if data.nil?
2706
3081
 
2707
3082
  new(
2708
- http_request: HttpRequest.from_hash(data['httpRequest']),
3083
+ http_request: MockServer.deserialize_request_definition(data['httpRequest']),
2709
3084
  http_response: HttpResponse.from_hash(data['httpResponse']),
2710
3085
  expectation_id: ExpectationId.from_hash(data['expectationId']),
2711
3086
  times: VerificationTimes.from_hash(data['times']),
@@ -2738,7 +3113,7 @@ module MockServer
2738
3113
  http_responses_data = data['httpResponses']
2739
3114
  expectation_ids_data = data['expectationIds']
2740
3115
  new(
2741
- http_requests: http_requests_data&.map { |r| HttpRequest.from_hash(r) },
3116
+ http_requests: http_requests_data&.map { |r| MockServer.deserialize_request_definition(r) },
2742
3117
  http_responses: http_responses_data&.map { |r| HttpResponse.from_hash(r) },
2743
3118
  expectation_ids: expectation_ids_data&.map { |e| ExpectationId.from_hash(e) }
2744
3119
  )
@@ -3116,20 +3491,61 @@ module MockServer
3116
3491
  end
3117
3492
  end
3118
3493
 
3494
+ # A per-step response assertion for a load scenario: reads a value from the
3495
+ # step's response (+source+ +STATUS+, +HEADER+ with +header_name+, or
3496
+ # +BODY_JSONPATH+ with +json_path+) and compares it with +value+ using
3497
+ # +comparator+ (+EQUALS+, +NOT_EQUALS+, +CONTAINS+, +MATCHES+, +GT+, +LT+,
3498
+ # +GTE+ or +LTE+). A failing check never fails the request; failures feed the
3499
+ # +CHECK_FAILURE_RATE+ threshold.
3500
+ class LoadCheck
3501
+ attr_accessor :source, :header_name, :json_path, :comparator, :value
3502
+
3503
+ def initialize(source:, comparator:, header_name: nil, json_path: nil, value: nil)
3504
+ @source = source
3505
+ @comparator = comparator
3506
+ @header_name = header_name
3507
+ @json_path = json_path
3508
+ @value = value
3509
+ end
3510
+
3511
+ def to_h
3512
+ MockServer.strip_none({
3513
+ 'source' => @source,
3514
+ 'headerName' => @header_name,
3515
+ 'jsonPath' => @json_path,
3516
+ 'comparator' => @comparator,
3517
+ 'value' => @value
3518
+ })
3519
+ end
3520
+
3521
+ def self.from_hash(data)
3522
+ return nil if data.nil?
3523
+
3524
+ new(
3525
+ source: data['source'],
3526
+ header_name: data['headerName'],
3527
+ json_path: data['jsonPath'],
3528
+ comparator: data['comparator'],
3529
+ value: data['value']
3530
+ )
3531
+ end
3532
+ end
3533
+
3119
3534
  # A single step within a load scenario. Each step fires +request+ (an HttpRequest)
3120
3535
  # against the target, optionally pausing for +think_time+ (a Delay) afterwards.
3121
3536
  # +captures+ binds values from this step's response for later steps in the same
3122
- # iteration; +weight+ is the relative selection weight when the scenario's
3123
- # +step_selection+ is +WEIGHTED+.
3537
+ # iteration; +checks+ (LoadCheck) assert on that response; +weight+ is the
3538
+ # relative selection weight when the scenario's +step_selection+ is +WEIGHTED+.
3124
3539
  class LoadStep
3125
- attr_accessor :name, :labels, :think_time, :request, :captures, :weight
3540
+ attr_accessor :name, :labels, :think_time, :request, :captures, :checks, :weight
3126
3541
 
3127
- def initialize(request:, name: nil, labels: nil, think_time: nil, captures: nil, weight: nil)
3542
+ def initialize(request:, name: nil, labels: nil, think_time: nil, captures: nil, checks: nil, weight: nil)
3128
3543
  @request = request
3129
3544
  @name = name
3130
3545
  @labels = labels
3131
3546
  @think_time = think_time
3132
3547
  @captures = captures
3548
+ @checks = checks
3133
3549
  @weight = weight
3134
3550
  end
3135
3551
 
@@ -3140,6 +3556,7 @@ module MockServer
3140
3556
  'thinkTime' => @think_time&.to_h,
3141
3557
  'request' => @request&.to_h,
3142
3558
  'captures' => @captures.nil? ? nil : @captures.map(&:to_h),
3559
+ 'checks' => @checks.nil? ? nil : @checks.map(&:to_h),
3143
3560
  'weight' => @weight
3144
3561
  })
3145
3562
  end
@@ -3148,12 +3565,14 @@ module MockServer
3148
3565
  return nil if data.nil?
3149
3566
 
3150
3567
  captures_data = data['captures']
3568
+ checks_data = data['checks']
3151
3569
  new(
3152
3570
  name: data['name'],
3153
3571
  labels: data['labels'],
3154
3572
  think_time: Delay.from_hash(data['thinkTime']),
3155
3573
  request: HttpRequest.from_hash(data['request']),
3156
3574
  captures: captures_data ? captures_data.map { |c| LoadCapture.from_hash(c) } : nil,
3575
+ checks: checks_data ? checks_data.map { |c| LoadCheck.from_hash(c) } : nil,
3157
3576
  weight: data['weight']
3158
3577
  )
3159
3578
  end
@@ -3230,6 +3649,384 @@ module MockServer
3230
3649
  end
3231
3650
  end
3232
3651
 
3652
+ # -------------------------------------------------------------------
3653
+ # Diagnostics: explain, debug_mismatch and explain_unmatched results.
3654
+ # They follow the RequestExplanation and ExplainedExpectation OpenAPI
3655
+ # schemas; an enum value a newer server sends is kept as its String.
3656
+ # -------------------------------------------------------------------
3657
+
3658
+ # A JSON patch (RFC 6902) of an expectation that would make it match the
3659
+ # request; nothing is applied. +unpatched_fields+ lists each field that
3660
+ # could not be patched, with a +reason+.
3661
+ class SuggestedFix
3662
+ attr_reader :patch, :patched_fields, :unpatched_fields, :matches_after_patch
3663
+
3664
+ def initialize(patch: [], patched_fields: [], unpatched_fields: [], matches_after_patch: false)
3665
+ @patch = patch
3666
+ @patched_fields = patched_fields
3667
+ @unpatched_fields = unpatched_fields
3668
+ @matches_after_patch = matches_after_patch
3669
+ end
3670
+
3671
+ def self.from_hash(data)
3672
+ return nil if data.nil?
3673
+
3674
+ new(
3675
+ patch: data['patch'] || [],
3676
+ patched_fields: data['patchedFields'] || [],
3677
+ unpatched_fields: data['unpatchedFields'] || [],
3678
+ matches_after_patch: data['matchesAfterPatch'] == true
3679
+ )
3680
+ end
3681
+ end
3682
+
3683
+ # One expectation as explain, debug_mismatch or explain_unmatched evaluated
3684
+ # it. The matched expectation has no +reason+; a skipped or shadowed one has
3685
+ # a +reason+ (NAMESPACE, INACTIVE, FIELDS_DIFFER, NOT_OPERATOR, CONVERSATION,
3686
+ # SCENARIO_STATE, PERCENTAGE or LOWER_PRIORITY) and an +explanation+.
3687
+ # +suggested_fix+ is set only by explain_unmatched with +suggest_fixes+.
3688
+ class ExplainedExpectation
3689
+ attr_reader :expectation_id, :expectation_method, :expectation_path, :position, :priority,
3690
+ :reason, :explanation, :matches, :matched_field_count, :total_field_count,
3691
+ :differing_field_count, :differences, :remediation, :suggested_fix
3692
+
3693
+ def initialize(expectation_id: nil, expectation_method: nil, expectation_path: nil, position: nil,
3694
+ priority: nil, reason: nil, explanation: nil, matches: nil, matched_field_count: nil,
3695
+ total_field_count: nil, differing_field_count: nil, differences: {}, remediation: {},
3696
+ suggested_fix: nil)
3697
+ @expectation_id = expectation_id
3698
+ @expectation_method = expectation_method
3699
+ @expectation_path = expectation_path
3700
+ @position = position
3701
+ @priority = priority
3702
+ @reason = reason
3703
+ @explanation = explanation
3704
+ @matches = matches
3705
+ @matched_field_count = matched_field_count
3706
+ @total_field_count = total_field_count
3707
+ @differing_field_count = differing_field_count
3708
+ @differences = differences
3709
+ @remediation = remediation
3710
+ @suggested_fix = suggested_fix
3711
+ end
3712
+
3713
+ def self.from_hash(data)
3714
+ return nil if data.nil?
3715
+
3716
+ new(
3717
+ expectation_id: data['expectationId'],
3718
+ expectation_method: data['expectationMethod'],
3719
+ expectation_path: data['expectationPath'],
3720
+ position: data['position'],
3721
+ priority: data['priority'],
3722
+ reason: data['reason'],
3723
+ explanation: data['explanation'],
3724
+ matches: data['matches'],
3725
+ matched_field_count: data['matchedFieldCount'],
3726
+ total_field_count: data['totalFieldCount'],
3727
+ differing_field_count: data['differingFieldCount'],
3728
+ differences: data['differences'] || {},
3729
+ remediation: data['remediation'] || {},
3730
+ suggested_fix: SuggestedFix.from_hash(data['suggestedFix'])
3731
+ )
3732
+ end
3733
+
3734
+ # @api private
3735
+ def self.list(items)
3736
+ (items || []).map { |item| from_hash(item) }
3737
+ end
3738
+ end
3739
+
3740
+ # The recorded request that explain re-checked.
3741
+ class ExplainedRequest
3742
+ attr_reader :timestamp, :method, :path, :body_truncated, :body_truncated_note
3743
+
3744
+ def initialize(timestamp: nil, method: nil, path: nil, body_truncated: false, body_truncated_note: nil)
3745
+ @timestamp = timestamp
3746
+ @method = method
3747
+ @path = path
3748
+ @body_truncated = body_truncated
3749
+ @body_truncated_note = body_truncated_note
3750
+ end
3751
+
3752
+ def self.from_hash(data)
3753
+ return nil if data.nil?
3754
+
3755
+ new(
3756
+ timestamp: data['timestamp'],
3757
+ method: data['method'],
3758
+ path: data['path'],
3759
+ body_truncated: data['bodyTruncated'] == true,
3760
+ body_truncated_note: data['bodyTruncatedNote']
3761
+ )
3762
+ end
3763
+ end
3764
+
3765
+ # How a request was handled when it arrived: +outcome+ is MATCHED, NO_MATCH,
3766
+ # FORWARDED_WITHOUT_EXPECTATION, RESPONDED_WITHOUT_EXPECTATION or UNKNOWN;
3767
+ # the expectation fields are set for MATCHED only.
3768
+ # +forward_failure+ is set only when the request was forwarded and the
3769
+ # forward failed.
3770
+ class Arrival
3771
+ attr_reader :outcome, :expectation_id, :expectation_still_stored, :forward_failure
3772
+
3773
+ def initialize(outcome: nil, expectation_id: nil, expectation_still_stored: nil, forward_failure: nil)
3774
+ @outcome = outcome
3775
+ @expectation_id = expectation_id
3776
+ @expectation_still_stored = expectation_still_stored
3777
+ @forward_failure = forward_failure
3778
+ end
3779
+
3780
+ def self.from_hash(data)
3781
+ return nil if data.nil?
3782
+
3783
+ new(
3784
+ outcome: data['outcome'],
3785
+ expectation_id: data['expectationId'],
3786
+ expectation_still_stored: data['expectationStillStored'],
3787
+ forward_failure: ArrivalForwardFailure.from_hash(data['forwardFailure'])
3788
+ )
3789
+ end
3790
+ end
3791
+
3792
+ # Why a request's forward failed, so MockServer answered 502: the same
3793
+ # facts the explain_forward_failure MCP tool gives. +reason+ is redacted as
3794
+ # the event log is and capped at 2048 characters (+reason_truncated+); when
3795
+ # +cause_named+ is false the reason is the raw error and +hint+ says what to
3796
+ # look at next.
3797
+ class ArrivalForwardFailure
3798
+ attr_reader :status_code, :reason, :reason_truncated, :cause_named, :forward_loop_suspected, :hint
3799
+
3800
+ def initialize(status_code: nil, reason: nil, reason_truncated: false, cause_named: nil,
3801
+ forward_loop_suspected: nil, hint: nil)
3802
+ @status_code = status_code
3803
+ @reason = reason
3804
+ @reason_truncated = reason_truncated
3805
+ @cause_named = cause_named
3806
+ @forward_loop_suspected = forward_loop_suspected
3807
+ @hint = hint
3808
+ end
3809
+
3810
+ def self.from_hash(data)
3811
+ return nil if data.nil?
3812
+
3813
+ new(
3814
+ status_code: data['statusCode'],
3815
+ reason: data['reason'],
3816
+ reason_truncated: data['reasonTruncated'] == true,
3817
+ cause_named: data['causeNamed'],
3818
+ forward_loop_suspected: data['forwardLoopSuspected'],
3819
+ hint: data['hint']
3820
+ )
3821
+ end
3822
+ end
3823
+
3824
+ # Set only when narration was asked for: a plain-language summary the
3825
+ # configured LLM backend wrote, or why none is available (+reason+). It is
3826
+ # AI-generated and can be wrong; the other fields are the facts.
3827
+ class ExplanationNarration
3828
+ attr_reader :available, :ai_generated, :label, :provider, :model, :text, :reason
3829
+
3830
+ def initialize(available: nil, ai_generated: nil, label: nil, provider: nil, model: nil, text: nil,
3831
+ reason: nil)
3832
+ @available = available
3833
+ @ai_generated = ai_generated
3834
+ @label = label
3835
+ @provider = provider
3836
+ @model = model
3837
+ @text = text
3838
+ @reason = reason
3839
+ end
3840
+
3841
+ def self.from_hash(data)
3842
+ return nil if data.nil?
3843
+
3844
+ new(
3845
+ available: data['available'],
3846
+ ai_generated: data['aiGenerated'],
3847
+ label: data['label'],
3848
+ provider: data['provider'],
3849
+ model: data['model'],
3850
+ text: data['text'],
3851
+ reason: data['reason']
3852
+ )
3853
+ end
3854
+ end
3855
+
3856
+ # Which expectation would serve a recorded request now, why each one
3857
+ # evaluated ahead of it was passed over, which lower-priority ones it
3858
+ # shadows, and how the request was handled when it arrived. Recomputed when
3859
+ # asked for, against the current expectations, scenario states and Times.
3860
+ class RequestExplanation
3861
+ attr_reader :correlation_id, :timestamp, :recomputed, :note, :request, :at_arrival,
3862
+ :same_outcome_as_at_arrival, :expectations_changed_since, :expectations_last_changed,
3863
+ :matched_expectation, :closest_expectation_id, :skipped_expectations,
3864
+ :shadowed_expectations, :total_expectations, :evaluated_expectations, :truncated,
3865
+ :max_expectations_evaluated, :narration
3866
+
3867
+ def initialize(correlation_id: nil, timestamp: nil, recomputed: nil, note: nil, request: nil,
3868
+ at_arrival: nil, same_outcome_as_at_arrival: nil, expectations_changed_since: false,
3869
+ expectations_last_changed: nil, matched_expectation: nil, closest_expectation_id: nil,
3870
+ skipped_expectations: [], shadowed_expectations: [], total_expectations: 0,
3871
+ evaluated_expectations: 0, truncated: false, max_expectations_evaluated: nil,
3872
+ narration: nil)
3873
+ @correlation_id = correlation_id
3874
+ @timestamp = timestamp
3875
+ @recomputed = recomputed
3876
+ @note = note
3877
+ @request = request
3878
+ @at_arrival = at_arrival
3879
+ @same_outcome_as_at_arrival = same_outcome_as_at_arrival
3880
+ @expectations_changed_since = expectations_changed_since
3881
+ @expectations_last_changed = expectations_last_changed
3882
+ @matched_expectation = matched_expectation
3883
+ @closest_expectation_id = closest_expectation_id
3884
+ @skipped_expectations = skipped_expectations
3885
+ @shadowed_expectations = shadowed_expectations
3886
+ @total_expectations = total_expectations
3887
+ @evaluated_expectations = evaluated_expectations
3888
+ @truncated = truncated
3889
+ @max_expectations_evaluated = max_expectations_evaluated
3890
+ @narration = narration
3891
+ end
3892
+
3893
+ def self.from_hash(data)
3894
+ return nil if data.nil?
3895
+
3896
+ new(
3897
+ correlation_id: data['correlationId'],
3898
+ timestamp: data['timestamp'],
3899
+ recomputed: data['recomputed'],
3900
+ note: data['note'],
3901
+ request: ExplainedRequest.from_hash(data['request']),
3902
+ at_arrival: Arrival.from_hash(data['atArrival']),
3903
+ same_outcome_as_at_arrival: data['sameOutcomeAsAtArrival'],
3904
+ expectations_changed_since: data['expectationsChangedSince'] == true,
3905
+ expectations_last_changed: data['expectationsLastChanged'],
3906
+ matched_expectation: ExplainedExpectation.from_hash(data['matchedExpectation']),
3907
+ closest_expectation_id: data['closestExpectationId'],
3908
+ skipped_expectations: ExplainedExpectation.list(data['skippedExpectations']),
3909
+ shadowed_expectations: ExplainedExpectation.list(data['shadowedExpectations']),
3910
+ total_expectations: data['totalExpectations'] || 0,
3911
+ evaluated_expectations: data['evaluatedExpectations'] || 0,
3912
+ truncated: data['truncated'] == true,
3913
+ max_expectations_evaluated: data['maxExpectationsEvaluated'],
3914
+ narration: ExplanationNarration.from_hash(data['narration'])
3915
+ )
3916
+ end
3917
+ end
3918
+
3919
+ # The expectation debug_mismatch found with the fewest differing fields.
3920
+ class ClosestMatch
3921
+ attr_reader :expectation_id, :matched_fields, :total_fields
3922
+
3923
+ def initialize(expectation_id: nil, matched_fields: nil, total_fields: nil)
3924
+ @expectation_id = expectation_id
3925
+ @matched_fields = matched_fields
3926
+ @total_fields = total_fields
3927
+ end
3928
+
3929
+ def self.from_hash(data)
3930
+ return nil if data.nil?
3931
+
3932
+ new(expectation_id: data['expectationId'], matched_fields: data['matchedFields'],
3933
+ total_fields: data['totalFields'])
3934
+ end
3935
+ end
3936
+
3937
+ # The result of debug_mismatch: each active expectation compared with the
3938
+ # request, and the closest match (nil when every expectation matches).
3939
+ class DebugMismatchResult
3940
+ attr_reader :correlation_id, :timestamp, :total_expectations, :evaluated_expectations,
3941
+ :truncated, :max_expectations_evaluated, :closest_match, :results
3942
+
3943
+ def initialize(correlation_id: nil, timestamp: nil, total_expectations: 0, evaluated_expectations: 0,
3944
+ truncated: false, max_expectations_evaluated: nil, closest_match: nil, results: [])
3945
+ @correlation_id = correlation_id
3946
+ @timestamp = timestamp
3947
+ @total_expectations = total_expectations
3948
+ @evaluated_expectations = evaluated_expectations
3949
+ @truncated = truncated
3950
+ @max_expectations_evaluated = max_expectations_evaluated
3951
+ @closest_match = closest_match
3952
+ @results = results
3953
+ end
3954
+
3955
+ def self.from_hash(data)
3956
+ return nil if data.nil?
3957
+
3958
+ new(
3959
+ correlation_id: data['correlationId'],
3960
+ timestamp: data['timestamp'],
3961
+ total_expectations: data['totalExpectations'] || 0,
3962
+ evaluated_expectations: data['evaluatedExpectations'] || 0,
3963
+ truncated: data['truncated'] == true,
3964
+ max_expectations_evaluated: data['maxExpectationsEvaluated'],
3965
+ closest_match: ClosestMatch.from_hash(data['closestMatch']),
3966
+ results: ExplainedExpectation.list(data['results'])
3967
+ )
3968
+ end
3969
+ end
3970
+
3971
+ # One recent unmatched request and its closest expectations, closest first.
3972
+ class UnmatchedRequest
3973
+ attr_reader :timestamp, :method, :path, :closest_expectations, :total_expectations_evaluated
3974
+
3975
+ def initialize(timestamp: nil, method: nil, path: nil, closest_expectations: [],
3976
+ total_expectations_evaluated: 0)
3977
+ @timestamp = timestamp
3978
+ @method = method
3979
+ @path = path
3980
+ @closest_expectations = closest_expectations
3981
+ @total_expectations_evaluated = total_expectations_evaluated
3982
+ end
3983
+
3984
+ def self.from_hash(data)
3985
+ return nil if data.nil?
3986
+
3987
+ new(
3988
+ timestamp: data['timestamp'],
3989
+ method: data['method'],
3990
+ path: data['path'],
3991
+ closest_expectations: ExplainedExpectation.list(data['closestExpectations']),
3992
+ total_expectations_evaluated: data['totalExpectationsEvaluated'] || 0
3993
+ )
3994
+ end
3995
+ end
3996
+
3997
+ # The result of explain_unmatched.
3998
+ class ExplainUnmatchedResult
3999
+ attr_reader :correlation_id, :timestamp, :unmatched_request_count, :truncated, :unmatched_requests,
4000
+ :narration
4001
+
4002
+ def initialize(correlation_id: nil, timestamp: nil, unmatched_request_count: 0, truncated: false,
4003
+ unmatched_requests: [], narration: nil)
4004
+ @correlation_id = correlation_id
4005
+ @timestamp = timestamp
4006
+ @unmatched_request_count = unmatched_request_count
4007
+ @truncated = truncated
4008
+ @unmatched_requests = unmatched_requests
4009
+ @narration = narration
4010
+ end
4011
+
4012
+ def self.from_hash(data)
4013
+ return nil if data.nil?
4014
+
4015
+ new(
4016
+ correlation_id: data['correlationId'],
4017
+ timestamp: data['timestamp'],
4018
+ unmatched_request_count: data['unmatchedRequestCount'] || 0,
4019
+ truncated: data['truncated'] == true,
4020
+ unmatched_requests: (data['unmatchedRequests'] || []).map { |r| UnmatchedRequest.from_hash(r) },
4021
+ narration: ExplanationNarration.from_hash(data['narration'])
4022
+ )
4023
+ end
4024
+ end
4025
+
3233
4026
  # Alias matching the Python client
3234
4027
  RequestDefinition = HttpRequest
4028
+
4029
+ constants.map { |name| const_get(name) }.each do |model|
4030
+ model.singleton_class.prepend(SymbolKeyTolerantFromHash) if model.is_a?(Class) && model.respond_to?(:from_hash)
4031
+ end
3235
4032
  end