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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5058c222282161435bc425f0993f4795a0d4d8ca0f8fd1a921f5848ebe7b7fe8
4
- data.tar.gz: 91e14949df3f145e9c107374ee8edc6edea90343c91d509922495ea5eb220c37
3
+ metadata.gz: 65c675ce8a2644ccefd09d08e036b68676dc481222d4e0e41f8d2a31f5ee4d9e
4
+ data.tar.gz: 5ce4a28531387f73bef657eac15879122b9bfce161c29b7ac03fe2e609fea74e
5
5
  SHA512:
6
- metadata.gz: b038601c5a23e3d510d85f3a7843d9f53cf87b849c1b0c76e9bab0a7dfca5a30daf81e036384c36afdae28e09504ec4ff8ff27ebf2d31bbd03f0c348a5f567ba
7
- data.tar.gz: 1b2958cd9f90ad4f8570f061d75fe20faf255eb427303056f8d7fdd4c6872c74dd504a05ed28a30694ecb0a4fc759411bdacf584c330e4f2b9bfd00b3b1d6398
6
+ metadata.gz: 5b3e1a67574cfb21096ded9e3d651ef37f0f0963d077f87829d1ea43940c957f2404a6144772b2d8d72230a568073b8869fd81006e7dde367a046faa436cc83a
7
+ data.tar.gz: '09436cda59e502b5b89444e0ca7aa32aa8fdd0196445aebc99e7d984c5f21b3ce259dc86cc31b1e08b1949c15a06eb80300fd3d6eca84e921a8a5fb1cd73c791'
data/README.md CHANGED
@@ -85,7 +85,7 @@ client.close
85
85
 
86
86
  ## Models
87
87
 
88
- All 26 domain model classes are available under the `MockServer` module:
88
+ All 27 domain model classes are available under the `MockServer` module:
89
89
 
90
90
  - `Delay`, `Times`, `TimeToLive`
91
91
  - `KeyToMultiValue`, `Body`, `SocketAddress`
@@ -94,12 +94,61 @@ All 26 domain model classes are available under the `MockServer` module:
94
94
  - `HttpOverrideForwardedRequest`, `HttpRequestAndHttpResponse`
95
95
  - `ConnectionOptions`
96
96
  - `Expectation`, `ExpectationId`
97
- - `OpenAPIDefinition`, `OpenAPIExpectation`
97
+ - `OpenAPIDefinition`, `OpenAPIExpectation`, `ConditionalRequestDefinition`
98
98
  - `Verification`, `VerificationSequence`, `VerificationTimes`
99
99
  - `Ports`
100
100
  - `RequestDefinition` (alias for `HttpRequest`)
101
101
  - `Jwt` (JWT / bearer-token request matcher)
102
102
 
103
+ Every model can also be built from the expectation JSON with `from_hash`. Keys
104
+ may be strings (`'httpRequest' => {...}`, as `JSON.parse` returns them) or
105
+ symbols (`"httpRequest": {...}` or `httpRequest: {...}`):
106
+
107
+ ```ruby
108
+ client.upsert(MockServer::Expectation.from_hash({
109
+ "httpRequest": { "path": '/some/path' },
110
+ "httpResponse": { "statusCode": 200, "body": 'some_response_body' }
111
+ }))
112
+ ```
113
+
114
+ Symbol keys need a client released after 8.0.0; 8.0.0 and earlier read only <!-- version-ref:keep -->
115
+ string keys and build an empty expectation from a symbol-keyed hash.
116
+
117
+ Headers, query parameters, path parameters, trailers and cookies can be given
118
+ in the same shapes the JSON uses. A key match style sets whether at least one
119
+ value of each listed key must match (`SUB_SET`, the default) or every value of
120
+ it (`MATCHING_KEY`):
121
+
122
+ ```ruby
123
+ MockServer::HttpRequest.new(
124
+ path: '/view/cart',
125
+ headers: { 'Accept' => ['application/json'] },
126
+ headers_key_match_style: 'MATCHING_KEY',
127
+ query_string_parameters: [{ name: 'cartId', values: ['055CA455'] }],
128
+ cookies: { 'session' => '4930456C' }
129
+ )
130
+ ```
131
+
132
+ An `httpRequest` holding `specUrlOrPayload` is read as an `OpenAPIDefinition`
133
+ matcher, and `HttpOverrideForwardedRequest` carries `request_override`,
134
+ `response_override` and `response_template`. These need a client released
135
+ after 8.0.0. <!-- version-ref:keep -->
136
+
137
+ An `httpRequest` holding `if` is read as a `ConditionalRequestDefinition`: when
138
+ `if_request` matches, `then_request` must match too, otherwise `else_request`
139
+ must. A load scenario `LoadStep` takes `checks`, a list of `LoadCheck`
140
+ assertions on each step's response. These need a client released after 8.0.0. <!-- version-ref:keep -->
141
+
142
+ ```ruby
143
+ client.when(
144
+ MockServer::ConditionalRequestDefinition.new(
145
+ if_request: MockServer::HttpRequest.new(method: 'POST'),
146
+ then_request: MockServer::HttpRequest.new(path: '/orders'),
147
+ else_request: MockServer::HttpRequest.new(method: 'GET')
148
+ )
149
+ ).respond(MockServer::HttpResponse.new(status_code: 200))
150
+ ```
151
+
103
152
  ## JWT and combined body matchers
104
153
 
105
154
  Match a request by the claims in its JWT (carried by default in the
@@ -280,6 +329,21 @@ client.start_chaos_experiment(
280
329
  SLO tracking must be enabled on the server (`sloTrackingEnabled=true`) or
281
330
  `verify_slo` raises `MockServer::Error` on HTTP 400.
282
331
 
332
+ ## Diagnose Matching
333
+
334
+ `explain(correlation_id)` tells you which expectation would serve a recorded request now and why
335
+ each expectation ahead of it was passed over; `debug_mismatch(request)` compares a request with
336
+ every active expectation; `explain_unmatched(limit:, suggest_fixes:)` explains recent requests
337
+ that matched nothing. Each returns a typed result (`RequestExplanation`, `DebugMismatchResult`,
338
+ `ExplainUnmatchedResult`) and raises `MockServer::Error` with the server's reason on failure.
339
+
340
+ ```ruby
341
+ explanation = client.explain('abc-123-def-456')
342
+ explanation.skipped_expectations.each do |skipped|
343
+ puts "#{skipped.expectation_id} #{skipped.reason}: #{skipped.explanation}"
344
+ end
345
+ ```
346
+
283
347
  ## Interactive Breakpoints
284
348
 
285
349
  The client supports matcher-driven interactive breakpoints over the callback WebSocket. Register a breakpoint matcher to pause forwarded/proxied exchanges at specific phases and inspect/modify/continue them via callback handlers.
@@ -353,7 +417,7 @@ launcher_path = MockServer::BinaryLauncher.ensure_launcher
353
417
  ### Specify a version
354
418
 
355
419
  ```ruby
356
- handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.6.0')
420
+ handle = MockServer::BinaryLauncher.start(port: 1080, version: '9.0.0')
357
421
  ```
358
422
 
359
423
  ### API reference
@@ -364,6 +428,8 @@ handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.6.0')
364
428
  | `MockServer::BinaryLauncher.start(port:, version:, extra_args:, log:)` | Ensure the binary and start MockServer. Returns a `ServerHandle`. |
365
429
  | `MockServer::BinaryLauncher::ServerHandle` | Handle to the running process. Methods: `stop(timeout:)`, `running?`. Attributes: `pid`, `port`, `launcher`. |
366
430
 
431
+ On Windows the launcher runs `mockserver.bat` through `cmd.exe` with every argument quoted, so characters such as `^`, `!` and parentheses reach MockServer unchanged. `start` raises `MockServer::Error` if the launcher path or an `extra_args` entry contains `"`, `<`, `>`, `|`, `&`, a line break or NUL, or if together they contain more than one `%` (which `cmd.exe` would expand as a `%NAME%` variable); it also needs a native Windows Ruby (mswin or mingw, such as RubyInstaller). No shell is used on Linux or macOS.
432
+
367
433
  ### Supported platforms
368
434
 
369
435
  | OS | Architecture |
@@ -48,6 +48,9 @@ module MockServer
48
48
  # Strict pattern for version strings — blocks path separators and '..'.
49
49
  VERSION_PATTERN = /\A[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?\z/
50
50
 
51
+ # Characters refused in the launcher path and arguments on Windows (see +windows_command_line+).
52
+ WINDOWS_UNSAFE = /["<>|&\r\n\0]/
53
+
51
54
  class << self
52
55
  # Resolve the current platform to the bundle naming tokens.
53
56
  #
@@ -241,18 +244,53 @@ module MockServer
241
244
  launcher = ensure_launcher(version: version, log: log)
242
245
  args = ['-serverPort', port.to_s] + extra_args
243
246
 
244
- # H4: On Windows, .bat files must be invoked via cmd.exe /c.
245
247
  # H5: Drain stdout/stderr via :out/:err redirection to avoid pipe-buffer deadlock.
248
+ # On Unix the [command, argv0] form never goes through a shell, even with no args.
246
249
  pid = if windows?
247
- Process.spawn('cmd.exe', '/c', launcher, *args,
250
+ Process.spawn(windows_command_line(launcher, args),
248
251
  out: File::NULL, err: File::NULL)
249
252
  else
250
- Process.spawn(launcher, *args,
253
+ Process.spawn([launcher, launcher], *args,
251
254
  out: File::NULL, err: File::NULL)
252
255
  end
253
256
  ServerHandle.new(pid: pid, port: port, launcher: launcher)
254
257
  end
255
258
 
259
+ # Build the cmd.exe command line that runs the +.bat+ launcher on Windows.
260
+ #
261
+ # Native Windows Ruby passes a single command string to CreateProcess
262
+ # unchanged unless it sees a redirection (+<+, +>+, +|+, +&+, a line break
263
+ # or +%NAME%+), so those are refused, which keeps the line away from a
264
+ # second shell. Each part is double-quoted so cmd.exe takes +^+, +(+, +)+
265
+ # and +!+ literally (+/v:off+), and +/d+ skips AutoRun. cmd.exe expands
266
+ # +%NAME%+ even inside quotes, so a line with two or more +%+ is refused.
267
+ #
268
+ # @param launcher [String] path of the +.bat+ launcher
269
+ # @param args [Array<String>] arguments for the launcher
270
+ # @return [String]
271
+ # @raise [Error] if a part cannot be passed to cmd.exe safely, or Ruby is not a native Windows build
272
+ def windows_command_line(launcher, args)
273
+ unless RbConfig::CONFIG['host_os'] =~ /mswin|mingw/i
274
+ raise Error, 'starting the Windows launcher needs a native Windows Ruby (mswin or mingw, ' \
275
+ "such as RubyInstaller); this Ruby is #{RbConfig::CONFIG['host_os']}"
276
+ end
277
+ # Messages name the part, never its value, which may be a secret.
278
+ parts = [launcher, *args].map(&:to_s)
279
+ parts.each_with_index do |part, i|
280
+ next unless part.match?(WINDOWS_UNSAFE)
281
+
282
+ name = i.zero? ? 'the launcher path' : "argument #{i - 1}"
283
+ raise Error, "#{name} cannot be passed through cmd.exe safely " \
284
+ '(contains ", <, >, |, &, a line break or NUL)'
285
+ end
286
+ line = parts.map { |part| quote_cmd_arg(part) }.join(' ')
287
+ if line.count('%') > 1
288
+ raise Error, 'the launcher path and arguments contain more than one %, which cmd.exe would expand ' \
289
+ 'as an environment variable reference (%NAME%); remove the % characters to run on Windows'
290
+ end
291
+ %(cmd.exe /d /v:off /s /c "#{line}")
292
+ end
293
+
256
294
  # Remove old version directories from the cache, keeping the current version
257
295
  # and at most MAX_PREVIOUS_VERSIONS_TO_KEEP previous versions.
258
296
  #
@@ -329,6 +367,13 @@ module MockServer
329
367
 
330
368
  private
331
369
 
370
+ # Double-quote one cmd.exe argument, doubling a trailing run of backslashes
371
+ # so it cannot escape the closing quote when the program splits its command line.
372
+ def quote_cmd_arg(arg)
373
+ trailing = arg[/\\*\z/]
374
+ %("#{arg}#{trailing}")
375
+ end
376
+
332
377
  # Build a clear, actionable error message for a missing release bundle.
333
378
  #
334
379
  # Explains that no downloadable bundle exists for +version+ and lists the
@@ -862,8 +862,8 @@ module MockServer
862
862
  # (PUT /mockserver/verifySLO).
863
863
  #
864
864
  # The server encodes the verdict in the HTTP status: 200 for PASS or
865
- # INCONCLUSIVE, 406 for FAIL, 400 for malformed criteria or when SLO tracking
866
- # is disabled (+sloTrackingEnabled=false+). The decoded verdict Hash carries
865
+ # INCONCLUSIVE, 406 for FAIL, 403 when SLO tracking is disabled
866
+ # (+sloTrackingEnabled=false+) and 400 for malformed criteria. The decoded verdict Hash carries
867
867
  # the overall +result+ and the per-objective +objectiveResults+ so callers can
868
868
  # inspect why an SLO failed.
869
869
  #
@@ -875,7 +875,7 @@ module MockServer
875
875
  # @param criteria [Hash, #to_h] the SLO criteria
876
876
  # @return [Hash] the SLO verdict (result PASS or INCONCLUSIVE)
877
877
  # @raise [VerificationError] if the verdict is FAIL (HTTP 406)
878
- # @raise [Error] if criteria are malformed or SLO tracking is disabled (HTTP 400),
878
+ # @raise [Error] if SLO tracking is disabled (HTTP 403) or criteria are malformed (HTTP 400),
879
879
  # or on any other failure
880
880
  def verify_slo(criteria)
881
881
  payload = criteria.respond_to?(:to_h) ? criteria.to_h : criteria
@@ -884,9 +884,12 @@ module MockServer
884
884
  if status == 406
885
885
  raise VerificationError, (response_body && !response_body.empty? ? response_body : 'SLO verdict: FAIL')
886
886
  end
887
+ if status == 403
888
+ raise Error, 'SLO tracking is disabled — set sloTrackingEnabled=true ' \
889
+ "on the server: #{response_body}"
890
+ end
887
891
  if status == 400
888
- raise Error, 'Invalid SLO criteria (or SLO tracking disabled — set ' \
889
- "sloTrackingEnabled=true on the server): #{response_body}"
892
+ raise Error, "Invalid SLO criteria: #{response_body}"
890
893
  end
891
894
  if status >= 400
892
895
  raise Error, "Failed to verify SLO (status=#{status}): #{response_body}"
@@ -925,6 +928,85 @@ module MockServer
925
928
  response_body && !response_body.empty? ? JSON.parse(response_body) : {}
926
929
  end
927
930
 
931
+ # -------------------------------------------------------------------
932
+ # Diagnostics (explain / debug_mismatch / explain_unmatched)
933
+ # -------------------------------------------------------------------
934
+
935
+ # Explain how a recorded request would be served now
936
+ # (PUT /mockserver/explain?correlationId=<id>): re-checks it against the
937
+ # current expectations, in the order MockServer evaluates them, and returns
938
+ # the expectation that would serve it, why each expectation ahead of that
939
+ # one was passed over, and which lower-priority expectations it shadows.
940
+ # Read-only: it consumes no Times and moves no scenario.
941
+ #
942
+ # @param correlation_id [String] the correlation id of the request's log entries
943
+ # @return [RequestExplanation]
944
+ # @raise [ArgumentError] if +correlation_id+ is nil or blank
945
+ # @raise [Error] with the server's reason when no request is recorded with
946
+ # this id (never received, cleared or evicted) or it is not an HTTP request
947
+ def explain(correlation_id)
948
+ if correlation_id.nil? || correlation_id.to_s.strip.empty?
949
+ raise ArgumentError, 'explain requires a non blank correlation_id'
950
+ end
951
+
952
+ RequestExplanation.from_hash(
953
+ diagnostic_request('/mockserver/explain', nil, { 'correlationId' => correlation_id.to_s },
954
+ "explain failed for correlationId \"#{correlation_id}\"")
955
+ )
956
+ end
957
+
958
+ # Compare a request with every active expectation
959
+ # (PUT /mockserver/debugMismatch): for each, whether it matches and the
960
+ # per-field differences, plus the closest match. Only an HTTP request is
961
+ # supported.
962
+ #
963
+ # @param request [HttpRequest, Hash] the request to check
964
+ # @return [DebugMismatchResult]
965
+ # @raise [Error] with the server's reason for a non-HTTP request definition
966
+ def debug_mismatch(request)
967
+ payload = request.respond_to?(:to_h) ? request.to_h : request
968
+ DebugMismatchResult.from_hash(
969
+ diagnostic_request('/mockserver/debugMismatch', JSON.generate(payload), nil, 'debugMismatch failed')
970
+ )
971
+ end
972
+
973
+ # Explain why recent requests matched no expectation
974
+ # (PUT /mockserver/explainUnmatched): each recent unmatched request with its
975
+ # closest expectations, closest first.
976
+ #
977
+ # @param limit [Integer, nil] how many recent unmatched requests to analyse (server default 10)
978
+ # @param suggest_fixes [Boolean, nil] add a suggested JSON patch to each of
979
+ # the five closest non-matching expectations (nothing is applied)
980
+ # @return [ExplainUnmatchedResult]
981
+ def explain_unmatched(limit: nil, suggest_fixes: nil)
982
+ options = {}
983
+ options['limit'] = limit unless limit.nil?
984
+ options['suggestFixes'] = suggest_fixes unless suggest_fixes.nil?
985
+ ExplainUnmatchedResult.from_hash(
986
+ diagnostic_request('/mockserver/explainUnmatched', options.empty? ? nil : JSON.generate(options), nil,
987
+ 'explainUnmatched failed')
988
+ )
989
+ end
990
+
991
+ # @api private
992
+ # PUT a diagnostic request and return the parsed 200 body; any other status,
993
+ # or a body that is not a JSON object, raises {Error} with the server's
994
+ # +error+ or the body.
995
+ def diagnostic_request(path, body, query_params, failure)
996
+ status, response_body = do_request('PUT', path, body, query_params)
997
+ parsed = begin
998
+ response_body.nil? || response_body.empty? ? {} : JSON.parse(response_body)
999
+ rescue JSON::ParserError
1000
+ nil
1001
+ end
1002
+ if status != 200 || !parsed.is_a?(Hash)
1003
+ reason = parsed.is_a?(Hash) ? parsed['error'] : nil
1004
+ raise Error, "#{failure} (status=#{status}): #{reason || response_body}"
1005
+ end
1006
+
1007
+ parsed
1008
+ end
1009
+
928
1010
  # -------------------------------------------------------------------
929
1011
  # Stateful scenarios (state machine control plane)
930
1012
  # -------------------------------------------------------------------
@@ -1129,6 +1211,116 @@ module MockServer
1129
1211
  []
1130
1212
  end
1131
1213
 
1214
+ # Send a retrieve request filtered by expectation id.
1215
+ #
1216
+ # An expectation id may be sent in place of a request matcher. For
1217
+ # "ACTIVE_EXPECTATIONS" the server returns only the expectation with that id;
1218
+ # for every other type it returns the entries matching that expectation's
1219
+ # request, exactly as verify by expectation id matches them. An unknown id is
1220
+ # rejected with a 400.
1221
+ #
1222
+ # @param expectation_id [String]
1223
+ # @param query_params [Hash]
1224
+ # @return [Array(Integer, String)] the status and response body
1225
+ # @api private
1226
+ def retrieve_by_id(expectation_id, query_params)
1227
+ do_request(
1228
+ 'PUT', '/mockserver/retrieve', JSON.generate({ 'id' => expectation_id }), query_params
1229
+ )
1230
+ end
1231
+ private :retrieve_by_id
1232
+
1233
+ # Retrieve the recorded requests matching the request of the expectation with the given id.
1234
+ # @param expectation_id [String]
1235
+ # @return [Array<HttpRequest>]
1236
+ def retrieve_recorded_requests_by_id(expectation_id)
1237
+ status, response_body = retrieve_by_id(
1238
+ expectation_id, { 'type' => 'REQUESTS', 'format' => 'JSON' }
1239
+ )
1240
+ if status >= 400
1241
+ raise Error, "Failed to retrieve recorded requests by id (status=#{status}): #{response_body}"
1242
+ end
1243
+
1244
+ if response_body && !response_body.empty?
1245
+ parsed = JSON.parse(response_body)
1246
+ return parsed.map { |r| HttpRequest.from_hash(r) } if parsed.is_a?(Array)
1247
+ end
1248
+ []
1249
+ end
1250
+
1251
+ # Retrieve the active expectation with the given id.
1252
+ # @param expectation_id [String]
1253
+ # @return [Array<Expectation>]
1254
+ def retrieve_active_expectations_by_id(expectation_id)
1255
+ status, response_body = retrieve_by_id(
1256
+ expectation_id, { 'type' => 'ACTIVE_EXPECTATIONS', 'format' => 'JSON' }
1257
+ )
1258
+ if status >= 400
1259
+ raise Error, "Failed to retrieve active expectations by id (status=#{status}): #{response_body}"
1260
+ end
1261
+
1262
+ if response_body && !response_body.empty?
1263
+ parsed = JSON.parse(response_body)
1264
+ return parsed.map { |e| Expectation.from_hash(e) } if parsed.is_a?(Array)
1265
+ end
1266
+ []
1267
+ end
1268
+
1269
+ # Retrieve the recorded expectations matching the request of the expectation with the given id.
1270
+ # @param expectation_id [String]
1271
+ # @return [Array<Expectation>]
1272
+ def retrieve_recorded_expectations_by_id(expectation_id)
1273
+ status, response_body = retrieve_by_id(
1274
+ expectation_id, { 'type' => 'RECORDED_EXPECTATIONS', 'format' => 'JSON' }
1275
+ )
1276
+ if status >= 400
1277
+ raise Error, "Failed to retrieve recorded expectations by id (status=#{status}): #{response_body}"
1278
+ end
1279
+
1280
+ if response_body && !response_body.empty?
1281
+ parsed = JSON.parse(response_body)
1282
+ return parsed.map { |e| Expectation.from_hash(e) } if parsed.is_a?(Array)
1283
+ end
1284
+ []
1285
+ end
1286
+
1287
+ # Retrieve the recorded requests and responses matching the request of the expectation with the given id.
1288
+ # @param expectation_id [String]
1289
+ # @return [Array<HttpRequestAndHttpResponse>]
1290
+ def retrieve_recorded_requests_and_responses_by_id(expectation_id)
1291
+ status, response_body = retrieve_by_id(
1292
+ expectation_id, { 'type' => 'REQUEST_RESPONSES', 'format' => 'JSON' }
1293
+ )
1294
+ if status >= 400
1295
+ raise Error, "Failed to retrieve request/responses by id (status=#{status}): #{response_body}"
1296
+ end
1297
+
1298
+ if response_body && !response_body.empty?
1299
+ parsed = JSON.parse(response_body)
1300
+ return parsed.map { |rr| HttpRequestAndHttpResponse.from_hash(rr) } if parsed.is_a?(Array)
1301
+ end
1302
+ []
1303
+ end
1304
+
1305
+ # Retrieve the log messages matching the request of the expectation with the given id.
1306
+ # @param expectation_id [String]
1307
+ # @return [Array<String>]
1308
+ def retrieve_log_messages_by_id(expectation_id)
1309
+ status, response_body = retrieve_by_id(expectation_id, { 'type' => 'LOGS' })
1310
+ if status >= 400
1311
+ raise Error, "Failed to retrieve log messages by id (status=#{status}): #{response_body}"
1312
+ end
1313
+
1314
+ return [] unless response_body && !response_body.empty?
1315
+
1316
+ begin
1317
+ parsed = JSON.parse(response_body)
1318
+ parsed.is_a?(Array) ? parsed : []
1319
+ rescue JSON::ParserError
1320
+ response_body.split("------------------------------------\n")
1321
+ end
1322
+ end
1323
+
1132
1324
  # Retrieve the active expectations as MockServer SDK setup code (the builder
1133
1325
  # code that recreates the expectations) in the requested language.
1134
1326
  # @param format [String] one of "java", "javascript", "python", "go",