mockserver-client 6.1.0 → 7.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.
@@ -148,13 +148,126 @@ module MockServer
148
148
  close
149
149
  end
150
150
 
151
- # Verify that a request was received.
152
- # @param request [HttpRequest]
151
+ # -------------------------------------------------------------------
152
+ # Clock Control
153
+ # -------------------------------------------------------------------
154
+
155
+ # Freeze the server clock at the given ISO-8601 instant.
156
+ # If +instant+ is nil, the clock freezes at the current real time.
157
+ # @param instant [String, nil] ISO-8601 instant (e.g. "2025-01-15T09:30:00Z")
158
+ # @return [Hash] response with status, currentInstant, currentEpochMillis
159
+ def freeze_clock(instant = nil)
160
+ payload = { 'action' => 'freeze' }
161
+ payload['instant'] = instant if instant
162
+ body = JSON.generate(payload)
163
+ status, response_body = request('PUT', '/mockserver/clock', body)
164
+ if status >= 400
165
+ raise Error, "Failed to freeze clock (status=#{status}): #{response_body}"
166
+ end
167
+
168
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
169
+ end
170
+
171
+ # Advance the frozen clock by +duration_millis+ milliseconds.
172
+ # @param duration_millis [Integer]
173
+ # @return [Hash] response with status, currentInstant, currentEpochMillis
174
+ def advance_clock(duration_millis)
175
+ body = JSON.generate({ 'action' => 'advance', 'durationMillis' => duration_millis })
176
+ status, response_body = request('PUT', '/mockserver/clock', body)
177
+ if status >= 400
178
+ raise Error, "Failed to advance clock (status=#{status}): #{response_body}"
179
+ end
180
+
181
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
182
+ end
183
+
184
+ # Reset the server clock to real wall-clock time.
185
+ # @return [Hash] response with status, currentInstant, currentEpochMillis
186
+ def reset_clock
187
+ body = JSON.generate({ 'action' => 'reset' })
188
+ status, response_body = request('PUT', '/mockserver/clock', body)
189
+ if status >= 400
190
+ raise Error, "Failed to reset clock (status=#{status}): #{response_body}"
191
+ end
192
+
193
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
194
+ end
195
+
196
+ # Query the current clock status.
197
+ # @return [Hash] with currentInstant, currentEpochMillis, frozen
198
+ def clock_status
199
+ status, response_body = request('GET', '/mockserver/clock')
200
+ if status >= 400
201
+ raise Error, "Failed to get clock status (status=#{status}): #{response_body}"
202
+ end
203
+
204
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
205
+ end
206
+
207
+ # Register a service-scoped HTTP chaos profile for an upstream host. The profile
208
+ # is applied to every matched forward expectation to that host that does not
209
+ # define its own chaos (an expectation's own chaos always wins). The host is
210
+ # matched case-insensitively, ignoring any +:port+.
211
+ # @param host [String] the upstream host to break
212
+ # @param chaos [HttpChaosProfile] the chaos profile to apply
213
+ # @param ttl_millis [Integer, nil] if set, the chaos auto-reverts after this many ms
214
+ # @return [Hash] response with status and host
215
+ def set_service_chaos(host, chaos, ttl_millis = nil)
216
+ payload = { 'host' => host, 'chaos' => chaos.to_h }
217
+ payload['ttlMillis'] = ttl_millis unless ttl_millis.nil?
218
+ body = JSON.generate(payload)
219
+ status, response_body = request('PUT', '/mockserver/serviceChaos', body)
220
+ if status >= 400
221
+ raise Error, "Failed to set service chaos (status=#{status}): #{response_body}"
222
+ end
223
+
224
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
225
+ end
226
+
227
+ # Remove the service-scoped chaos profile registered for +host+.
228
+ # @param host [String]
229
+ # @return [Hash]
230
+ def remove_service_chaos(host)
231
+ body = JSON.generate({ 'host' => host, 'remove' => true })
232
+ status, response_body = request('PUT', '/mockserver/serviceChaos', body)
233
+ if status >= 400
234
+ raise Error, "Failed to remove service chaos (status=#{status}): #{response_body}"
235
+ end
236
+
237
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
238
+ end
239
+
240
+ # Clear all service-scoped chaos profiles.
241
+ # @return [Hash]
242
+ def clear_service_chaos
243
+ body = JSON.generate({ 'clear' => true })
244
+ status, response_body = request('PUT', '/mockserver/serviceChaos', body)
245
+ if status >= 400
246
+ raise Error, "Failed to clear service chaos (status=#{status}): #{response_body}"
247
+ end
248
+
249
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
250
+ end
251
+
252
+ # Query the current service-scoped chaos registrations.
253
+ # @return [Hash] of the form { "services" => { host => profile, ... } }
254
+ def service_chaos_status
255
+ status, response_body = request('GET', '/mockserver/serviceChaos')
256
+ if status >= 400
257
+ raise Error, "Failed to get service chaos (status=#{status}): #{response_body}"
258
+ end
259
+
260
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
261
+ end
262
+
263
+ # Verify that a request (and optionally a response) was received.
264
+ # @param request [HttpRequest, nil]
153
265
  # @param times [VerificationTimes, nil]
266
+ # @param response [HttpResponse, nil]
154
267
  # @return [nil]
155
268
  # @raise [VerificationError] if verification fails (HTTP 406)
156
- def verify(request, times: nil)
157
- verification = Verification.new(http_request: request, times: times)
269
+ def verify(request = nil, times: nil, response: nil)
270
+ verification = Verification.new(http_request: request, http_response: response, times: times)
158
271
  body = JSON.generate(verification.to_h)
159
272
  status, response_body = do_request('PUT', '/mockserver/verify', body)
160
273
  if status == 406
@@ -170,10 +283,14 @@ module MockServer
170
283
 
171
284
  # Verify that requests were received in sequence.
172
285
  # @param requests [Array<HttpRequest>]
286
+ # @param responses [Array<HttpResponse>, nil] index-aligned response matchers
173
287
  # @return [nil]
174
288
  # @raise [VerificationError] if verification fails (HTTP 406)
175
- def verify_sequence(*requests)
176
- verification = VerificationSequence.new(http_requests: requests.to_a)
289
+ def verify_sequence(*requests, responses: nil)
290
+ verification = VerificationSequence.new(
291
+ http_requests: requests.empty? ? nil : requests.to_a,
292
+ http_responses: responses
293
+ )
177
294
  body = JSON.generate(verification.to_h)
178
295
  status, response_body = request('PUT', '/mockserver/verifySequence', body)
179
296
  if status == 406
@@ -364,6 +481,120 @@ module MockServer
364
481
  ForwardChainExpectation.new(self, expectation)
365
482
  end
366
483
 
484
+ # -------------------------------------------------------------------
485
+ # Breakpoint matcher management
486
+ # -------------------------------------------------------------------
487
+
488
+ # Register a breakpoint matcher with callback handlers.
489
+ # The callback WebSocket is opened lazily and reused.
490
+ #
491
+ # @param matcher [HttpRequest] the request definition to match
492
+ # @param phases [Array<String>] e.g. ["REQUEST", "RESPONSE"]
493
+ # @param request_handler [Proc, nil] handler for REQUEST phase
494
+ # @param response_handler [Proc, nil] handler for RESPONSE phase
495
+ # @param stream_frame_handler [Proc, nil] handler for streaming phases
496
+ # @return [String] the server-assigned breakpoint matcher id
497
+ def add_breakpoint(matcher, phases,
498
+ request_handler: nil, response_handler: nil,
499
+ stream_frame_handler: nil)
500
+ raise ArgumentError, 'add_breakpoint requires a non-nil matcher' if matcher.nil?
501
+ raise ArgumentError, 'add_breakpoint requires a non-empty phases array' if phases.nil? || phases.empty?
502
+
503
+ ws_client = ensure_breakpoint_websocket
504
+ client_id = ws_client.client_id
505
+
506
+ body = JSON.generate({
507
+ 'httpRequest' => matcher.to_h,
508
+ 'phases' => phases,
509
+ 'clientId' => client_id
510
+ })
511
+ status, response_body = request('PUT', '/mockserver/breakpoint/matcher', body)
512
+ if status >= 400
513
+ raise Error, "Failed to register breakpoint matcher (status=#{status}): #{response_body}"
514
+ end
515
+
516
+ parsed = response_body && !response_body.empty? ? JSON.parse(response_body) : {}
517
+ breakpoint_id = parsed['id']
518
+ raise Error, 'Server did not return a breakpoint id' unless breakpoint_id
519
+
520
+ # Install per-breakpoint-id handlers
521
+ ws_client.set_breakpoint_request_handler(breakpoint_id, request_handler) if request_handler
522
+ ws_client.set_breakpoint_response_handler(breakpoint_id, response_handler) if response_handler
523
+ ws_client.set_breakpoint_stream_frame_handler(breakpoint_id, stream_frame_handler) if stream_frame_handler
524
+
525
+ breakpoint_id
526
+ end
527
+
528
+ # Convenience: register a REQUEST-only breakpoint.
529
+ # @param matcher [HttpRequest]
530
+ # @param request_handler [Proc]
531
+ # @return [String]
532
+ def add_request_breakpoint(matcher, request_handler)
533
+ add_breakpoint(matcher, ['REQUEST'], request_handler: request_handler)
534
+ end
535
+
536
+ # Convenience: register a REQUEST+RESPONSE breakpoint.
537
+ # @param matcher [HttpRequest]
538
+ # @param request_handler [Proc]
539
+ # @param response_handler [Proc]
540
+ # @return [String]
541
+ def add_request_and_response_breakpoint(matcher, request_handler, response_handler)
542
+ add_breakpoint(matcher, %w[REQUEST RESPONSE],
543
+ request_handler: request_handler,
544
+ response_handler: response_handler)
545
+ end
546
+
547
+ # List all registered breakpoint matchers.
548
+ # @return [Hash] e.g. {"matchers" => [{...}, ...]}
549
+ def list_breakpoint_matchers
550
+ status, response_body = request('GET', '/mockserver/breakpoint/matchers')
551
+ if status >= 400
552
+ raise Error, "Failed to list breakpoint matchers (status=#{status}): #{response_body}"
553
+ end
554
+
555
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
556
+ end
557
+
558
+ # Remove a breakpoint matcher by id.
559
+ # @param breakpoint_id [String]
560
+ # @return [Hash]
561
+ def remove_breakpoint_matcher(breakpoint_id)
562
+ raise ArgumentError, 'remove_breakpoint_matcher requires a non-empty id' if breakpoint_id.nil? || breakpoint_id.empty?
563
+
564
+ body = JSON.generate({ 'id' => breakpoint_id })
565
+ status, response_body = request('PUT', '/mockserver/breakpoint/matcher/remove', body)
566
+ if status >= 400
567
+ raise Error, "Failed to remove breakpoint matcher (status=#{status}): #{response_body}"
568
+ end
569
+
570
+ # Remove client-side handlers
571
+ @websocket_mutex.synchronize do
572
+ @websocket_clients.each do |ws|
573
+ ws.remove_breakpoint_handlers(breakpoint_id) if ws.respond_to?(:remove_breakpoint_handlers)
574
+ end
575
+ end
576
+
577
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
578
+ end
579
+
580
+ # Clear all registered breakpoint matchers.
581
+ # @return [Hash]
582
+ def clear_breakpoint_matchers
583
+ status, response_body = request('PUT', '/mockserver/breakpoint/matcher/clear')
584
+ if status >= 400
585
+ raise Error, "Failed to clear breakpoint matchers (status=#{status}): #{response_body}"
586
+ end
587
+
588
+ # Clear client-side handlers
589
+ @websocket_mutex.synchronize do
590
+ @websocket_clients.each do |ws|
591
+ ws.clear_breakpoint_handlers if ws.respond_to?(:clear_breakpoint_handlers)
592
+ end
593
+ end
594
+
595
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
596
+ end
597
+
367
598
  # -------------------------------------------------------------------
368
599
  # Callback methods
369
600
  # -------------------------------------------------------------------
@@ -418,6 +649,32 @@ module MockServer
418
649
 
419
650
  private
420
651
 
652
+ # @api private
653
+ # Ensure a callback WS is connected for breakpoint use, returning it.
654
+ def ensure_breakpoint_websocket
655
+ # Hold the mutex for the whole check-create-append so two concurrent
656
+ # add_breakpoint calls cannot both create a breakpoint WS (TOCTOU).
657
+ # Breakpoint WS creation is rare, so blocking on connect under the lock
658
+ # is acceptable.
659
+ @websocket_mutex.synchronize do
660
+ existing = @websocket_clients.find { |ws| ws.instance_variable_get(:@is_breakpoint_ws) }
661
+ return existing if existing
662
+
663
+ ws_client = WebSocketClient.new
664
+ ws_client.connect(
665
+ @host, @port,
666
+ context_path: @context_path,
667
+ secure: @secure,
668
+ ca_cert_path: @ca_cert_path,
669
+ tls_verify: @tls_verify
670
+ )
671
+ ws_client.instance_variable_set(:@is_breakpoint_ws, true)
672
+ ws_client.listen
673
+ @websocket_clients << ws_client
674
+ ws_client
675
+ end
676
+ end
677
+
421
678
  # @api private
422
679
  def register_websocket_callback(callback_type, callback_fn, forward_response_fn = nil)
423
680
  ws_client = WebSocketClient.new
@@ -29,6 +29,14 @@ module MockServer
29
29
  self
30
30
  end
31
31
 
32
+ # Set a declarative HTTP chaos/fault injection profile.
33
+ # @param chaos [HttpChaosProfile]
34
+ # @return [self]
35
+ def with_chaos(chaos)
36
+ @expectation.chaos = chaos
37
+ self
38
+ end
39
+
32
40
  # Set the response action. Accepts an HttpResponse, HttpTemplate, or
33
41
  # a Proc/lambda callback.
34
42
  # @param response_or_callback [HttpResponse, HttpTemplate, Proc]
@@ -113,5 +121,92 @@ module MockServer
113
121
  @expectation.http_websocket_response = websocket_response
114
122
  @client.upsert(@expectation)
115
123
  end
124
+
125
+ # Set a gRPC stream response action.
126
+ # @param grpc_stream_response [GrpcStreamResponse]
127
+ # @return [Array<Expectation>]
128
+ def respond_with_grpc_stream(grpc_stream_response)
129
+ unless grpc_stream_response.is_a?(GrpcStreamResponse)
130
+ raise TypeError,
131
+ "Expected GrpcStreamResponse, got #{grpc_stream_response.class.name}"
132
+ end
133
+ @expectation.grpc_stream_response = grpc_stream_response
134
+ @client.upsert(@expectation)
135
+ end
136
+
137
+ # Set a gRPC bidi streaming response action.
138
+ # @param grpc_bidi_response [GrpcBidiResponse]
139
+ # @return [Array<Expectation>]
140
+ def respond_with_grpc_bidi(grpc_bidi_response)
141
+ unless grpc_bidi_response.is_a?(GrpcBidiResponse)
142
+ raise TypeError,
143
+ "Expected GrpcBidiResponse, got #{grpc_bidi_response.class.name}"
144
+ end
145
+ @expectation.grpc_bidi_response = grpc_bidi_response
146
+ @client.upsert(@expectation)
147
+ end
148
+
149
+ # Set a binary response action.
150
+ # @param binary_response [BinaryResponse]
151
+ # @return [Array<Expectation>]
152
+ def respond_with_binary(binary_response)
153
+ unless binary_response.is_a?(BinaryResponse)
154
+ raise TypeError,
155
+ "Expected BinaryResponse, got #{binary_response.class.name}"
156
+ end
157
+ @expectation.binary_response = binary_response
158
+ @client.upsert(@expectation)
159
+ end
160
+
161
+ # Set a DNS response action.
162
+ # @param dns_response [DnsResponse]
163
+ # @return [Array<Expectation>]
164
+ def respond_with_dns(dns_response)
165
+ unless dns_response.is_a?(DnsResponse)
166
+ raise TypeError,
167
+ "Expected DnsResponse, got #{dns_response.class.name}"
168
+ end
169
+ @expectation.dns_response = dns_response
170
+ @client.upsert(@expectation)
171
+ end
172
+
173
+ # Set a forward template action.
174
+ # @param template [HttpTemplate]
175
+ # @return [Array<Expectation>]
176
+ def forward_with_template(template)
177
+ unless template.is_a?(HttpTemplate)
178
+ raise TypeError,
179
+ "Expected HttpTemplate, got #{template.class.name}"
180
+ end
181
+ @expectation.http_forward_template = template
182
+ @client.upsert(@expectation)
183
+ end
184
+
185
+ # Set a forward class callback action.
186
+ # @param class_callback [HttpClassCallback]
187
+ # @return [Array<Expectation>]
188
+ def forward_with_class_callback(class_callback)
189
+ unless class_callback.is_a?(HttpClassCallback)
190
+ raise TypeError,
191
+ "Expected HttpClassCallback, got #{class_callback.class.name}"
192
+ end
193
+ @expectation.http_forward_class_callback = class_callback
194
+ @client.upsert(@expectation)
195
+ end
196
+
197
+ # Set an ordered multi-action pipeline of steps.
198
+ #
199
+ # Exactly one step must have +responder: true+; that step produces the
200
+ # HTTP response. All other steps are side-effects executed in order.
201
+ # @param steps [Array<ExpectationStep>]
202
+ # @return [Array<Expectation>]
203
+ def with_steps(steps)
204
+ unless steps.is_a?(Array) && steps.all? { |s| s.is_a?(ExpectationStep) }
205
+ raise TypeError, 'Expected an Array of ExpectationStep objects'
206
+ end
207
+
208
+ @expectation.steps = steps
209
+ @client.upsert(@expectation)
210
+ end
116
211
  end
117
212
  end