mockserver-client 8.0.0 → 9.1.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: 3b414bb7cc138b2e2a46b674e67743d1145bc004bc7a62be5d25e75fab95409f
4
+ data.tar.gz: 8d11ed79c2d777ab576c4181e9cb2c6b2730c352afc532d5df4070e9accddaf6
5
5
  SHA512:
6
- metadata.gz: c87f1c996f7ec3f49502a5465c8990a076bef51262408570706cb520359f6446e9adbfafd0a9b6c1bace45b3164caaf3340fb1f4d848d877047c0f41d361a159
7
- data.tar.gz: 741e8bd63ccb4d63edcfd7be2467d6b619fa5be8924f295d2f886da3af0017e201d8891adc8076c6af65c42dbbbf4202d259573e6c55f9a58f0a131993f4294d
6
+ metadata.gz: f0b346b83cbc825456b2dc85b6b1462e1fa3610f779f9037e77ba2f40f4a780e9c64a793d80df95dbceb0b7d67b57805cce281d75fa8ba36114edb7e35851892
7
+ data.tar.gz: 5bde30af6fe4e94208ac4a275e52309d77e1d78a94d74ce152c993a656e8db00d98adc88f9556c21f2c1d3939b180e2f7ac9588195a4462a9a99ab035883eaac
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.1.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 |
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'uri'
5
+ require_relative 'mcp'
5
6
 
6
7
  module MockServer
7
8
  # Fluent builder for mocking an A2A (Agent-to-Agent) agent.
@@ -45,11 +46,11 @@ module MockServer
45
46
  end
46
47
 
47
48
  # Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
49
+ # Shares the MCP builder's rules (backslash runs before +$+, +#+ or the end
50
+ # of the value are escaped too).
48
51
  # @api private
49
52
  def self.escape_velocity(value)
50
- return value if value.nil?
51
-
52
- value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
53
+ MCP.escape_velocity(value)
53
54
  end
54
55
 
55
56
  # @api private
@@ -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
@@ -912,14 +912,15 @@ module MockServer
912
912
  #
913
913
  # @param experiment [Hash, #to_h] the experiment definition
914
914
  # @return [Hash] the started status, e.g. { "status" => "started", "name" => ... }
915
- # @raise [Error] if the experiment definition is invalid or chaos is disabled
916
- # (HTTP 400/403), or on any other failure
915
+ # @raise [Error] if the experiment definition is invalid (HTTP 400), if
916
+ # control-plane authorization rejects the request (HTTP 403), or on any other
917
+ # failure; there is no server setting that disables chaos experiments
917
918
  def start_chaos_experiment(experiment)
918
919
  payload = experiment.respond_to?(:to_h) ? experiment.to_h : experiment
919
920
  body = JSON.generate(payload)
920
921
  status, response_body = request('PUT', '/mockserver/chaosExperiment', body)
921
922
  if status == 403
922
- raise Error, 'Chaos experiment rejected (status=403): chaos is disabled on the server'
923
+ raise Error, "Chaos experiment rejected (status=403): control-plane authorization denied: #{response_body}"
923
924
  end
924
925
  if status >= 400
925
926
  raise Error, "Failed to start chaos experiment (status=#{status}): #{response_body}"
@@ -928,6 +929,85 @@ module MockServer
928
929
  response_body && !response_body.empty? ? JSON.parse(response_body) : {}
929
930
  end
930
931
 
932
+ # -------------------------------------------------------------------
933
+ # Diagnostics (explain / debug_mismatch / explain_unmatched)
934
+ # -------------------------------------------------------------------
935
+
936
+ # Explain how a recorded request would be served now
937
+ # (PUT /mockserver/explain?correlationId=<id>): re-checks it against the
938
+ # current expectations, in the order MockServer evaluates them, and returns
939
+ # the expectation that would serve it, why each expectation ahead of that
940
+ # one was passed over, and which lower-priority expectations it shadows.
941
+ # Read-only: it consumes no Times and moves no scenario.
942
+ #
943
+ # @param correlation_id [String] the correlation id of the request's log entries
944
+ # @return [RequestExplanation]
945
+ # @raise [ArgumentError] if +correlation_id+ is nil or blank
946
+ # @raise [Error] with the server's reason when no request is recorded with
947
+ # this id (never received, cleared or evicted) or it is not an HTTP request
948
+ def explain(correlation_id)
949
+ if correlation_id.nil? || correlation_id.to_s.strip.empty?
950
+ raise ArgumentError, 'explain requires a non blank correlation_id'
951
+ end
952
+
953
+ RequestExplanation.from_hash(
954
+ diagnostic_request('/mockserver/explain', nil, { 'correlationId' => correlation_id.to_s },
955
+ "explain failed for correlationId \"#{correlation_id}\"")
956
+ )
957
+ end
958
+
959
+ # Compare a request with every active expectation
960
+ # (PUT /mockserver/debugMismatch): for each, whether it matches and the
961
+ # per-field differences, plus the closest match. Only an HTTP request is
962
+ # supported.
963
+ #
964
+ # @param request [HttpRequest, Hash] the request to check
965
+ # @return [DebugMismatchResult]
966
+ # @raise [Error] with the server's reason for a non-HTTP request definition
967
+ def debug_mismatch(request)
968
+ payload = request.respond_to?(:to_h) ? request.to_h : request
969
+ DebugMismatchResult.from_hash(
970
+ diagnostic_request('/mockserver/debugMismatch', JSON.generate(payload), nil, 'debugMismatch failed')
971
+ )
972
+ end
973
+
974
+ # Explain why recent requests matched no expectation
975
+ # (PUT /mockserver/explainUnmatched): each recent unmatched request with its
976
+ # closest expectations, closest first.
977
+ #
978
+ # @param limit [Integer, nil] how many recent unmatched requests to analyse (server default 10)
979
+ # @param suggest_fixes [Boolean, nil] add a suggested JSON patch to each of
980
+ # the five closest non-matching expectations (nothing is applied)
981
+ # @return [ExplainUnmatchedResult]
982
+ def explain_unmatched(limit: nil, suggest_fixes: nil)
983
+ options = {}
984
+ options['limit'] = limit unless limit.nil?
985
+ options['suggestFixes'] = suggest_fixes unless suggest_fixes.nil?
986
+ ExplainUnmatchedResult.from_hash(
987
+ diagnostic_request('/mockserver/explainUnmatched', options.empty? ? nil : JSON.generate(options), nil,
988
+ 'explainUnmatched failed')
989
+ )
990
+ end
991
+
992
+ # @api private
993
+ # PUT a diagnostic request and return the parsed 200 body; any other status,
994
+ # or a body that is not a JSON object, raises {Error} with the server's
995
+ # +error+ or the body.
996
+ def diagnostic_request(path, body, query_params, failure)
997
+ status, response_body = do_request('PUT', path, body, query_params)
998
+ parsed = begin
999
+ response_body.nil? || response_body.empty? ? {} : JSON.parse(response_body)
1000
+ rescue JSON::ParserError
1001
+ nil
1002
+ end
1003
+ if status != 200 || !parsed.is_a?(Hash)
1004
+ reason = parsed.is_a?(Hash) ? parsed['error'] : nil
1005
+ raise Error, "#{failure} (status=#{status}): #{reason || response_body}"
1006
+ end
1007
+
1008
+ parsed
1009
+ end
1010
+
931
1011
  # -------------------------------------------------------------------
932
1012
  # Stateful scenarios (state machine control plane)
933
1013
  # -------------------------------------------------------------------
@@ -31,20 +31,37 @@ module MockServer
31
31
  quoted[1...-1]
32
32
  end
33
33
 
34
+ VELOCITY_METACHARACTER_ESCAPES = { '$' => '${esc.d}', '#' => '${esc.h}' }.freeze
35
+ private_constant :VELOCITY_METACHARACTER_ESCAPES
36
+
34
37
  # Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
38
+ # Velocity consumes backslashes written before a reference or directive, so
39
+ # a run of backslashes followed by +$+, +#+ or the end of the value is
40
+ # escaped too; other backslashes are literal to Velocity and left as-is.
35
41
  # @api private
36
42
  def self.escape_velocity(value)
37
43
  return value if value.nil?
38
44
 
39
- value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
45
+ # group 2 consumes the metacharacter after a run, is empty at the end and
46
+ # unset (empty alternative) otherwise, so no lookaround is needed
47
+ value.to_s.gsub(/(\\+)(?:([$#]|\z)|)|[$#]/) do |match|
48
+ run = Regexp.last_match(1)
49
+ next VELOCITY_METACHARACTER_ESCAPES[match] if run.nil?
50
+
51
+ following = Regexp.last_match(2)
52
+ next run if following.nil?
53
+
54
+ ('${esc.b}' * run.length) + VELOCITY_METACHARACTER_ESCAPES.fetch(following, '')
55
+ end
40
56
  end
41
57
 
42
- # Escape single quotes for safe inclusion inside a JSONPath string literal.
58
+ # Escape backslashes, then single quotes, for safe inclusion inside a
59
+ # single-quoted JSONPath string literal.
43
60
  # @api private
44
61
  def self.escape_json_path(value)
45
62
  return '' if value.nil?
46
63
 
47
- value.to_s.gsub("'", "\\\\'")
64
+ value.to_s.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }
48
65
  end
49
66
 
50
67
  # Parse + re-serialize JSON (compact) to validate it. Raises on invalid JSON.