mockserver-client 8.0.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: b32e53f8e5117c75dddf6e9b86e379892b47066fe7dbe3919da8d97232600fce
4
- data.tar.gz: 64aa0e6608d0be522249a761e0009fce60058ebe2f6c67dbd9617bd3fffaa3fe
3
+ metadata.gz: 65c675ce8a2644ccefd09d08e036b68676dc481222d4e0e41f8d2a31f5ee4d9e
4
+ data.tar.gz: 5ce4a28531387f73bef657eac15879122b9bfce161c29b7ac03fe2e609fea74e
5
5
  SHA512:
6
- metadata.gz: c87f1c996f7ec3f49502a5465c8990a076bef51262408570706cb520359f6446e9adbfafd0a9b6c1bace45b3164caaf3340fb1f4d848d877047c0f41d361a159
7
- data.tar.gz: 741e8bd63ccb4d63edcfd7be2467d6b619fa5be8924f295d2f886da3af0017e201d8891adc8076c6af65c42dbbbf4202d259573e6c55f9a58f0a131993f4294d
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: '8.0.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: '8.0.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
@@ -928,6 +928,85 @@ module MockServer
928
928
  response_body && !response_body.empty? ? JSON.parse(response_body) : {}
929
929
  end
930
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
+
931
1010
  # -------------------------------------------------------------------
932
1011
  # Stateful scenarios (state machine control plane)
933
1012
  # -------------------------------------------------------------------