mockserver-client 7.0.0 → 7.2.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 +4 -4
- data/README.md +216 -0
- data/lib/mockserver/binary_launcher.rb +664 -0
- data/lib/mockserver/client.rb +462 -13
- data/lib/mockserver/forward_chain_expectation.rb +16 -6
- data/lib/mockserver/llm.rb +855 -0
- data/lib/mockserver/mcp.rb +453 -0
- data/lib/mockserver/models.rb +488 -37
- data/lib/mockserver/rspec.rb +56 -0
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver/websocket_client.rb +129 -2
- data/lib/mockserver-client.rb +3 -0
- metadata +6 -2
data/lib/mockserver/client.rb
CHANGED
|
@@ -260,13 +260,264 @@ module MockServer
|
|
|
260
260
|
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
261
261
|
end
|
|
262
262
|
|
|
263
|
-
#
|
|
264
|
-
#
|
|
263
|
+
# -------------------------------------------------------------------
|
|
264
|
+
# Load scenario registry (load injection)
|
|
265
|
+
# -------------------------------------------------------------------
|
|
266
|
+
#
|
|
267
|
+
# The load scenario registry decouples *registering* a scenario from
|
|
268
|
+
# *running* it:
|
|
269
|
+
#
|
|
270
|
+
# * registering (load_scenario) only stores the definition keyed by its
|
|
271
|
+
# unique +name+ and is allowed even when +loadGenerationEnabled+ is off;
|
|
272
|
+
# * starting (start_load_scenarios) is what actually drives traffic and
|
|
273
|
+
# requires +loadGenerationEnabled+ on the server (otherwise a 403).
|
|
274
|
+
#
|
|
275
|
+
# Scenario states are: LOADED, PENDING, RUNNING, COMPLETED, STOPPED.
|
|
276
|
+
|
|
277
|
+
# Register (load) a scenario into the registry without running it.
|
|
278
|
+
#
|
|
279
|
+
# +scenario+ may be a {LoadScenario} model (which responds to +to_h+) or a
|
|
280
|
+
# plain Hash already shaped to the +LoadScenario+ JSON contract. It must
|
|
281
|
+
# carry a unique +name+. Registering is permitted even when load generation
|
|
282
|
+
# is disabled on the server.
|
|
283
|
+
#
|
|
284
|
+
# @param scenario [LoadScenario, Hash] the scenario to register
|
|
285
|
+
# @return [Hash] parsed response of the form { "name" => ..., "state" => ... }
|
|
286
|
+
def load_scenario(scenario)
|
|
287
|
+
payload = scenario.respond_to?(:to_h) ? scenario.to_h : scenario
|
|
288
|
+
body = JSON.generate(payload)
|
|
289
|
+
status, response_body = request('PUT', '/mockserver/loadScenario', body)
|
|
290
|
+
if status >= 400
|
|
291
|
+
raise Error, "Failed to register load scenario (status=#{status}): #{response_body}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# List all registered load scenarios.
|
|
298
|
+
#
|
|
299
|
+
# @return [Hash] parsed response of the form
|
|
300
|
+
# { "scenarios" => [ { "name" => ..., "state" => ..., "definition" => ..., "status" => ... }, ... ] }
|
|
301
|
+
def load_scenarios
|
|
302
|
+
status, response_body = request('GET', '/mockserver/loadScenario')
|
|
303
|
+
if status >= 400
|
|
304
|
+
raise Error, "Failed to list load scenarios (status=#{status}): #{response_body}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Fetch a single registered load scenario by name.
|
|
311
|
+
#
|
|
312
|
+
# @param name [String] the unique scenario name
|
|
313
|
+
# @return [Hash] parsed scenario entry { "name" => ..., "state" => ..., "definition" => ..., "status" => ... }
|
|
314
|
+
# @raise [Error] if the scenario does not exist (404) or another failure occurs
|
|
315
|
+
def get_load_scenario(name)
|
|
316
|
+
status, response_body = request('GET', "/mockserver/loadScenario/#{encode_path_segment(name)}")
|
|
317
|
+
if status == 404
|
|
318
|
+
raise Error, "Load scenario not found (status=404): #{name}"
|
|
319
|
+
end
|
|
320
|
+
if status >= 400
|
|
321
|
+
raise Error, "Failed to get load scenario (status=#{status}): #{response_body}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Remove a single registered load scenario by name.
|
|
328
|
+
#
|
|
329
|
+
# @param name [String] the unique scenario name
|
|
330
|
+
# @return [Hash] parsed response (may be empty)
|
|
331
|
+
def delete_load_scenario(name)
|
|
332
|
+
status, response_body = request('DELETE', "/mockserver/loadScenario/#{encode_path_segment(name)}")
|
|
333
|
+
if status >= 400
|
|
334
|
+
raise Error, "Failed to delete load scenario (status=#{status}): #{response_body}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Clear all registered load scenarios.
|
|
341
|
+
#
|
|
342
|
+
# @return [Hash] parsed response (may be empty)
|
|
343
|
+
def clear_load_scenarios
|
|
344
|
+
status, response_body = request('DELETE', '/mockserver/loadScenario')
|
|
345
|
+
if status >= 400
|
|
346
|
+
raise Error, "Failed to clear load scenarios (status=#{status}): #{response_body}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Start one or more registered scenarios.
|
|
353
|
+
#
|
|
354
|
+
# +names+ may be a single scenario name (String) or an Array of names; it is
|
|
355
|
+
# always sent as { "names" => [...] }. Honours each scenario's
|
|
356
|
+
# +startDelayMillis+. Requires +loadGenerationEnabled+ on the server; a 403
|
|
357
|
+
# response raises a clear error explaining the feature is disabled.
|
|
358
|
+
#
|
|
359
|
+
# @param names [String, Array<String>] scenario name(s) to start
|
|
360
|
+
# @return [Hash] parsed response of the form
|
|
361
|
+
# { "started" => [ { "name" => ..., "state" => ... }, ... ], "status" => ... }
|
|
362
|
+
def start_load_scenarios(names)
|
|
363
|
+
payload = { 'names' => Array(names) }
|
|
364
|
+
body = JSON.generate(payload)
|
|
365
|
+
status, response_body = request('PUT', '/mockserver/loadScenario/start', body)
|
|
366
|
+
if status == 403
|
|
367
|
+
raise Error, 'Load scenario start rejected (status=403): load generation is disabled ' \
|
|
368
|
+
'(set loadGenerationEnabled=true on the server to enable it)'
|
|
369
|
+
end
|
|
370
|
+
if status == 404
|
|
371
|
+
raise Error, "Load scenario not found (status=404): #{response_body}"
|
|
372
|
+
end
|
|
373
|
+
if status >= 400
|
|
374
|
+
raise Error, "Failed to start load scenarios (status=#{status}): #{response_body}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Stop running scenarios.
|
|
381
|
+
#
|
|
382
|
+
# +names+ may be:
|
|
383
|
+
# * a single scenario name (String) -> { "names" => ["a"] }
|
|
384
|
+
# * an Array of names -> { "names" => ["a", "b"] }
|
|
385
|
+
# * nil (the default) -> empty body, which stops all running scenarios
|
|
386
|
+
#
|
|
387
|
+
# @param names [String, Array<String>, nil] scenario name(s) to stop, or nil for all
|
|
388
|
+
# @return [Hash] parsed response of the form
|
|
389
|
+
# { "stopped" => [ ... ], "status" => ... }
|
|
390
|
+
def stop_load_scenarios(names = nil)
|
|
391
|
+
body = names.nil? ? nil : JSON.generate({ 'names' => Array(names) })
|
|
392
|
+
status, response_body = request('PUT', '/mockserver/loadScenario/stop', body)
|
|
393
|
+
if status >= 400
|
|
394
|
+
raise Error, "Failed to stop load scenarios (status=#{status}): #{response_body}"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Convenience: register a scenario then immediately start it.
|
|
401
|
+
#
|
|
402
|
+
# Equivalent to calling {#load_scenario} followed by {#start_load_scenarios}
|
|
403
|
+
# for the scenario's name. Requires +loadGenerationEnabled+ on the server for
|
|
404
|
+
# the start step.
|
|
405
|
+
#
|
|
406
|
+
# @param scenario [LoadScenario, Hash] the scenario to register and run
|
|
407
|
+
# @return [Hash] parsed response from the start call
|
|
408
|
+
def run_load_scenario(scenario)
|
|
409
|
+
payload = scenario.respond_to?(:to_h) ? scenario.to_h : scenario
|
|
410
|
+
name = payload.respond_to?(:[]) ? (payload['name'] || payload[:name]) : nil
|
|
411
|
+
if name.nil? || name.to_s.empty?
|
|
412
|
+
raise ArgumentError, 'scenario must carry a non-empty name to run'
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
load_scenario(payload)
|
|
416
|
+
start_load_scenarios(name)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# -------------------------------------------------------------------
|
|
420
|
+
# Stateful scenarios (state machine control plane)
|
|
421
|
+
# -------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
# Return a handle to the named stateful scenario, wrapping the
|
|
424
|
+
# +/mockserver/scenario/{name}+ control-plane endpoints.
|
|
425
|
+
#
|
|
426
|
+
# @param name [String] the scenario (state-machine) name
|
|
427
|
+
# @return [ScenarioHandle]
|
|
428
|
+
def scenario(name)
|
|
429
|
+
ScenarioHandle.new(self, name)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# List every known scenario and its current state.
|
|
433
|
+
#
|
|
434
|
+
# @return [Array<ScenarioState>] each with +scenario_name+ and +current_state+
|
|
435
|
+
def scenarios
|
|
436
|
+
result = scenario_request('GET', '/mockserver/scenario')
|
|
437
|
+
list = result.is_a?(Hash) ? (result['scenarios'] || []) : []
|
|
438
|
+
list.map { |s| ScenarioState.from_hash(s) }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# @api private
|
|
442
|
+
# Issue a control-plane scenario request, parsing the JSON response and
|
|
443
|
+
# raising {Error} on any >= 400 status. Reuses the same transport
|
|
444
|
+
# (+request+) as the other +/mockserver/...+ control endpoints.
|
|
445
|
+
def scenario_request(method, path, body = nil)
|
|
446
|
+
status, response_body = request(method, path, body)
|
|
447
|
+
if status >= 400
|
|
448
|
+
raise Error, "Scenario request failed (status=#{status}): #{response_body}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# -------------------------------------------------------------------
|
|
455
|
+
# gRPC descriptor management
|
|
456
|
+
# -------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
# Upload a compiled protobuf descriptor set so gRPC requests can be matched.
|
|
459
|
+
#
|
|
460
|
+
# +descriptor_bytes+ must be the raw bytes of a +FileDescriptorSet+ (e.g. the
|
|
461
|
+
# output of +protoc --descriptor_set_out=... --include_imports+). The bytes are
|
|
462
|
+
# sent verbatim as +application/octet-stream+ (NOT base64-encoded).
|
|
463
|
+
#
|
|
464
|
+
# @param descriptor_bytes [String] raw descriptor set bytes (binary string)
|
|
465
|
+
# @return [nil]
|
|
466
|
+
def upload_grpc_descriptor(descriptor_bytes)
|
|
467
|
+
if descriptor_bytes.nil? || descriptor_bytes.empty?
|
|
468
|
+
raise ArgumentError, 'descriptor bytes must not be empty'
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
status, response_body = request(
|
|
472
|
+
'PUT', '/mockserver/grpc/descriptors', descriptor_bytes,
|
|
473
|
+
content_type: 'application/octet-stream'
|
|
474
|
+
)
|
|
475
|
+
if status >= 400
|
|
476
|
+
raise Error, "Failed to upload gRPC descriptor (status=#{status}): #{response_body}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
nil
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Retrieve the gRPC services registered from uploaded descriptor sets.
|
|
483
|
+
#
|
|
484
|
+
# Returns an array of service hashes, each with a +"name"+ and a list of
|
|
485
|
+
# +"methods"+ (+"name"+, +"inputType"+, +"outputType"+, +"clientStreaming"+,
|
|
486
|
+
# +"serverStreaming"+).
|
|
487
|
+
#
|
|
488
|
+
# @return [Array<Hash>]
|
|
489
|
+
def retrieve_grpc_services
|
|
490
|
+
status, response_body = request('PUT', '/mockserver/grpc/services')
|
|
491
|
+
if status >= 400
|
|
492
|
+
raise Error, "Failed to retrieve gRPC services (status=#{status}): #{response_body}"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
if response_body && !response_body.empty?
|
|
496
|
+
parsed = JSON.parse(response_body)
|
|
497
|
+
return parsed if parsed.is_a?(Array)
|
|
498
|
+
end
|
|
499
|
+
[]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Clear all uploaded gRPC descriptor sets and registered services.
|
|
503
|
+
# @return [nil]
|
|
504
|
+
def clear_grpc_descriptors
|
|
505
|
+
status, response_body = request('PUT', '/mockserver/grpc/clear')
|
|
506
|
+
if status >= 400
|
|
507
|
+
raise Error, "Failed to clear gRPC descriptors (status=#{status}): #{response_body}"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
nil
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Verify that a request (and optionally a response) was received.
|
|
514
|
+
# @param request [HttpRequest, nil]
|
|
265
515
|
# @param times [VerificationTimes, nil]
|
|
516
|
+
# @param response [HttpResponse, nil]
|
|
266
517
|
# @return [nil]
|
|
267
518
|
# @raise [VerificationError] if verification fails (HTTP 406)
|
|
268
|
-
def verify(request, times: nil)
|
|
269
|
-
verification = Verification.new(http_request: request, times: times)
|
|
519
|
+
def verify(request = nil, times: nil, response: nil)
|
|
520
|
+
verification = Verification.new(http_request: request, http_response: response, times: times)
|
|
270
521
|
body = JSON.generate(verification.to_h)
|
|
271
522
|
status, response_body = do_request('PUT', '/mockserver/verify', body)
|
|
272
523
|
if status == 406
|
|
@@ -282,10 +533,14 @@ module MockServer
|
|
|
282
533
|
|
|
283
534
|
# Verify that requests were received in sequence.
|
|
284
535
|
# @param requests [Array<HttpRequest>]
|
|
536
|
+
# @param responses [Array<HttpResponse>, nil] index-aligned response matchers
|
|
285
537
|
# @return [nil]
|
|
286
538
|
# @raise [VerificationError] if verification fails (HTTP 406)
|
|
287
|
-
def verify_sequence(*requests)
|
|
288
|
-
verification = VerificationSequence.new(
|
|
539
|
+
def verify_sequence(*requests, responses: nil)
|
|
540
|
+
verification = VerificationSequence.new(
|
|
541
|
+
http_requests: requests.empty? ? nil : requests.to_a,
|
|
542
|
+
http_responses: responses
|
|
543
|
+
)
|
|
289
544
|
body = JSON.generate(verification.to_h)
|
|
290
545
|
status, response_body = request('PUT', '/mockserver/verifySequence', body)
|
|
291
546
|
if status == 406
|
|
@@ -365,6 +620,44 @@ module MockServer
|
|
|
365
620
|
[]
|
|
366
621
|
end
|
|
367
622
|
|
|
623
|
+
# Retrieve the active expectations as MockServer SDK setup code (the builder
|
|
624
|
+
# code that recreates the expectations) in the requested language.
|
|
625
|
+
# @param format [String] one of "java", "javascript", "python", "go",
|
|
626
|
+
# "csharp", "ruby", "rust" or "php" (case-insensitive)
|
|
627
|
+
# @param request [HttpRequest, nil]
|
|
628
|
+
# @return [String] the generated code
|
|
629
|
+
def retrieve_expectations_as_code(format: 'java', request: nil)
|
|
630
|
+
body = request ? JSON.generate(request.to_h) : ''
|
|
631
|
+
status, response_body = do_request(
|
|
632
|
+
'PUT', '/mockserver/retrieve', body,
|
|
633
|
+
{ 'type' => 'ACTIVE_EXPECTATIONS', 'format' => format.to_s.upcase }
|
|
634
|
+
)
|
|
635
|
+
if status >= 400
|
|
636
|
+
raise Error, "Failed to retrieve expectations as code (status=#{status}): #{response_body}"
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
response_body || ''
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Retrieve the recorded (proxied) request/response pairs as MockServer SDK
|
|
643
|
+
# setup code in the requested language.
|
|
644
|
+
# @param format [String] one of "java", "javascript", "python", "go",
|
|
645
|
+
# "csharp", "ruby", "rust" or "php" (case-insensitive)
|
|
646
|
+
# @param request [HttpRequest, nil]
|
|
647
|
+
# @return [String] the generated code
|
|
648
|
+
def retrieve_recorded_expectations_as_code(format: 'java', request: nil)
|
|
649
|
+
body = request ? JSON.generate(request.to_h) : ''
|
|
650
|
+
status, response_body = do_request(
|
|
651
|
+
'PUT', '/mockserver/retrieve', body,
|
|
652
|
+
{ 'type' => 'RECORDED_EXPECTATIONS', 'format' => format.to_s.upcase }
|
|
653
|
+
)
|
|
654
|
+
if status >= 400
|
|
655
|
+
raise Error, "Failed to retrieve recorded expectations as code (status=#{status}): #{response_body}"
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
response_body || ''
|
|
659
|
+
end
|
|
660
|
+
|
|
368
661
|
# Retrieve recorded requests and responses.
|
|
369
662
|
# @param request [HttpRequest, nil]
|
|
370
663
|
# @return [Array<HttpRequestAndHttpResponse>]
|
|
@@ -476,6 +769,120 @@ module MockServer
|
|
|
476
769
|
ForwardChainExpectation.new(self, expectation)
|
|
477
770
|
end
|
|
478
771
|
|
|
772
|
+
# -------------------------------------------------------------------
|
|
773
|
+
# Breakpoint matcher management
|
|
774
|
+
# -------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
# Register a breakpoint matcher with callback handlers.
|
|
777
|
+
# The callback WebSocket is opened lazily and reused.
|
|
778
|
+
#
|
|
779
|
+
# @param matcher [HttpRequest] the request definition to match
|
|
780
|
+
# @param phases [Array<String>] e.g. ["REQUEST", "RESPONSE"]
|
|
781
|
+
# @param request_handler [Proc, nil] handler for REQUEST phase
|
|
782
|
+
# @param response_handler [Proc, nil] handler for RESPONSE phase
|
|
783
|
+
# @param stream_frame_handler [Proc, nil] handler for streaming phases
|
|
784
|
+
# @return [String] the server-assigned breakpoint matcher id
|
|
785
|
+
def add_breakpoint(matcher, phases,
|
|
786
|
+
request_handler: nil, response_handler: nil,
|
|
787
|
+
stream_frame_handler: nil)
|
|
788
|
+
raise ArgumentError, 'add_breakpoint requires a non-nil matcher' if matcher.nil?
|
|
789
|
+
raise ArgumentError, 'add_breakpoint requires a non-empty phases array' if phases.nil? || phases.empty?
|
|
790
|
+
|
|
791
|
+
ws_client = ensure_breakpoint_websocket
|
|
792
|
+
client_id = ws_client.client_id
|
|
793
|
+
|
|
794
|
+
body = JSON.generate({
|
|
795
|
+
'httpRequest' => matcher.to_h,
|
|
796
|
+
'phases' => phases,
|
|
797
|
+
'clientId' => client_id
|
|
798
|
+
})
|
|
799
|
+
status, response_body = request('PUT', '/mockserver/breakpoint/matcher', body)
|
|
800
|
+
if status >= 400
|
|
801
|
+
raise Error, "Failed to register breakpoint matcher (status=#{status}): #{response_body}"
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
parsed = response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
805
|
+
breakpoint_id = parsed['id']
|
|
806
|
+
raise Error, 'Server did not return a breakpoint id' unless breakpoint_id
|
|
807
|
+
|
|
808
|
+
# Install per-breakpoint-id handlers
|
|
809
|
+
ws_client.set_breakpoint_request_handler(breakpoint_id, request_handler) if request_handler
|
|
810
|
+
ws_client.set_breakpoint_response_handler(breakpoint_id, response_handler) if response_handler
|
|
811
|
+
ws_client.set_breakpoint_stream_frame_handler(breakpoint_id, stream_frame_handler) if stream_frame_handler
|
|
812
|
+
|
|
813
|
+
breakpoint_id
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Convenience: register a REQUEST-only breakpoint.
|
|
817
|
+
# @param matcher [HttpRequest]
|
|
818
|
+
# @param request_handler [Proc]
|
|
819
|
+
# @return [String]
|
|
820
|
+
def add_request_breakpoint(matcher, request_handler)
|
|
821
|
+
add_breakpoint(matcher, ['REQUEST'], request_handler: request_handler)
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Convenience: register a REQUEST+RESPONSE breakpoint.
|
|
825
|
+
# @param matcher [HttpRequest]
|
|
826
|
+
# @param request_handler [Proc]
|
|
827
|
+
# @param response_handler [Proc]
|
|
828
|
+
# @return [String]
|
|
829
|
+
def add_request_and_response_breakpoint(matcher, request_handler, response_handler)
|
|
830
|
+
add_breakpoint(matcher, %w[REQUEST RESPONSE],
|
|
831
|
+
request_handler: request_handler,
|
|
832
|
+
response_handler: response_handler)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# List all registered breakpoint matchers.
|
|
836
|
+
# @return [Hash] e.g. {"matchers" => [{...}, ...]}
|
|
837
|
+
def list_breakpoint_matchers
|
|
838
|
+
status, response_body = request('GET', '/mockserver/breakpoint/matchers')
|
|
839
|
+
if status >= 400
|
|
840
|
+
raise Error, "Failed to list breakpoint matchers (status=#{status}): #{response_body}"
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# Remove a breakpoint matcher by id.
|
|
847
|
+
# @param breakpoint_id [String]
|
|
848
|
+
# @return [Hash]
|
|
849
|
+
def remove_breakpoint_matcher(breakpoint_id)
|
|
850
|
+
raise ArgumentError, 'remove_breakpoint_matcher requires a non-empty id' if breakpoint_id.nil? || breakpoint_id.empty?
|
|
851
|
+
|
|
852
|
+
body = JSON.generate({ 'id' => breakpoint_id })
|
|
853
|
+
status, response_body = request('PUT', '/mockserver/breakpoint/matcher/remove', body)
|
|
854
|
+
if status >= 400
|
|
855
|
+
raise Error, "Failed to remove breakpoint matcher (status=#{status}): #{response_body}"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Remove client-side handlers
|
|
859
|
+
@websocket_mutex.synchronize do
|
|
860
|
+
@websocket_clients.each do |ws|
|
|
861
|
+
ws.remove_breakpoint_handlers(breakpoint_id) if ws.respond_to?(:remove_breakpoint_handlers)
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# Clear all registered breakpoint matchers.
|
|
869
|
+
# @return [Hash]
|
|
870
|
+
def clear_breakpoint_matchers
|
|
871
|
+
status, response_body = request('PUT', '/mockserver/breakpoint/matcher/clear')
|
|
872
|
+
if status >= 400
|
|
873
|
+
raise Error, "Failed to clear breakpoint matchers (status=#{status}): #{response_body}"
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Clear client-side handlers
|
|
877
|
+
@websocket_mutex.synchronize do
|
|
878
|
+
@websocket_clients.each do |ws|
|
|
879
|
+
ws.clear_breakpoint_handlers if ws.respond_to?(:clear_breakpoint_handlers)
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
884
|
+
end
|
|
885
|
+
|
|
479
886
|
# -------------------------------------------------------------------
|
|
480
887
|
# Callback methods
|
|
481
888
|
# -------------------------------------------------------------------
|
|
@@ -530,6 +937,32 @@ module MockServer
|
|
|
530
937
|
|
|
531
938
|
private
|
|
532
939
|
|
|
940
|
+
# @api private
|
|
941
|
+
# Ensure a callback WS is connected for breakpoint use, returning it.
|
|
942
|
+
def ensure_breakpoint_websocket
|
|
943
|
+
# Hold the mutex for the whole check-create-append so two concurrent
|
|
944
|
+
# add_breakpoint calls cannot both create a breakpoint WS (TOCTOU).
|
|
945
|
+
# Breakpoint WS creation is rare, so blocking on connect under the lock
|
|
946
|
+
# is acceptable.
|
|
947
|
+
@websocket_mutex.synchronize do
|
|
948
|
+
existing = @websocket_clients.find { |ws| ws.instance_variable_get(:@is_breakpoint_ws) }
|
|
949
|
+
return existing if existing
|
|
950
|
+
|
|
951
|
+
ws_client = WebSocketClient.new
|
|
952
|
+
ws_client.connect(
|
|
953
|
+
@host, @port,
|
|
954
|
+
context_path: @context_path,
|
|
955
|
+
secure: @secure,
|
|
956
|
+
ca_cert_path: @ca_cert_path,
|
|
957
|
+
tls_verify: @tls_verify
|
|
958
|
+
)
|
|
959
|
+
ws_client.instance_variable_set(:@is_breakpoint_ws, true)
|
|
960
|
+
ws_client.listen
|
|
961
|
+
@websocket_clients << ws_client
|
|
962
|
+
ws_client
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
|
|
533
966
|
# @api private
|
|
534
967
|
def register_websocket_callback(callback_type, callback_fn, forward_response_fn = nil)
|
|
535
968
|
ws_client = WebSocketClient.new
|
|
@@ -555,7 +988,8 @@ module MockServer
|
|
|
555
988
|
|
|
556
989
|
# Perform an HTTP request with optional query parameters.
|
|
557
990
|
# @api private
|
|
558
|
-
def do_request(method, path, body = nil, query_params = nil
|
|
991
|
+
def do_request(method, path, body = nil, query_params = nil,
|
|
992
|
+
content_type: 'application/json; charset=utf-8')
|
|
559
993
|
url = "#{@base_url}#{path}"
|
|
560
994
|
if query_params && !query_params.empty?
|
|
561
995
|
url = "#{url}?#{URI.encode_www_form(query_params)}"
|
|
@@ -564,14 +998,24 @@ module MockServer
|
|
|
564
998
|
uri = URI.parse(url)
|
|
565
999
|
http = build_http(uri)
|
|
566
1000
|
|
|
567
|
-
req = build_request(method, uri, body)
|
|
1001
|
+
req = build_request(method, uri, body, content_type)
|
|
568
1002
|
execute_request(http, req)
|
|
569
1003
|
end
|
|
570
1004
|
|
|
571
1005
|
# Perform an HTTP request (no query params).
|
|
572
1006
|
# @api private
|
|
573
|
-
def request(method, path, body = nil
|
|
574
|
-
|
|
1007
|
+
def request(method, path, body = nil,
|
|
1008
|
+
content_type: 'application/json; charset=utf-8')
|
|
1009
|
+
do_request(method, path, body, nil, content_type: content_type)
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# @api private
|
|
1013
|
+
# Percent-encode a single URL path segment (e.g. a scenario name) so that
|
|
1014
|
+
# spaces, slashes, and other reserved characters are transmitted safely.
|
|
1015
|
+
def encode_path_segment(value)
|
|
1016
|
+
raise ArgumentError, 'name must not be nil or empty' if value.nil? || value.to_s.empty?
|
|
1017
|
+
|
|
1018
|
+
URI.encode_www_form_component(value.to_s).gsub('+', '%20')
|
|
575
1019
|
end
|
|
576
1020
|
|
|
577
1021
|
# @api private
|
|
@@ -596,7 +1040,7 @@ module MockServer
|
|
|
596
1040
|
end
|
|
597
1041
|
|
|
598
1042
|
# @api private
|
|
599
|
-
def build_request(method, uri, body)
|
|
1043
|
+
def build_request(method, uri, body, content_type = 'application/json; charset=utf-8')
|
|
600
1044
|
request_path = uri.request_uri
|
|
601
1045
|
case method.upcase
|
|
602
1046
|
when 'PUT'
|
|
@@ -610,14 +1054,19 @@ module MockServer
|
|
|
610
1054
|
else
|
|
611
1055
|
req = Net::HTTP::Put.new(request_path)
|
|
612
1056
|
end
|
|
613
|
-
req['Content-Type'] =
|
|
1057
|
+
req['Content-Type'] = content_type
|
|
614
1058
|
req.body = body if body
|
|
615
1059
|
req
|
|
616
1060
|
end
|
|
617
1061
|
|
|
618
1062
|
# @api private
|
|
619
1063
|
def execute_request(http, req)
|
|
620
|
-
|
|
1064
|
+
# Use the block form of #start so the underlying TCP/TLS connection is
|
|
1065
|
+
# always closed (#finish) when the request completes, rather than being
|
|
1066
|
+
# left open until garbage collection. All connection options (use_ssl,
|
|
1067
|
+
# ca_file, verify_mode, read_timeout, open_timeout) configured on +http+
|
|
1068
|
+
# by #build_http are preserved because #start operates on this instance.
|
|
1069
|
+
response = http.start { |conn| conn.request(req) }
|
|
621
1070
|
[response.code.to_i, response.body || '']
|
|
622
1071
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
623
1072
|
raise ConnectionError, "Request to MockServer at #{@base_url} timed out: #{e.message}"
|
|
@@ -182,14 +182,24 @@ module MockServer
|
|
|
182
182
|
@client.upsert(@expectation)
|
|
183
183
|
end
|
|
184
184
|
|
|
185
|
-
# Set a
|
|
186
|
-
#
|
|
185
|
+
# Set a response class-callback action: the server invokes a server-side
|
|
186
|
+
# class implementing the response callback interface. Accepts either a
|
|
187
|
+
# fully-qualified class-name String (e.g. "com.example.MyResponseCallback")
|
|
188
|
+
# or a pre-built {HttpClassCallback} (carrying an optional +delay+ /
|
|
189
|
+
# +primary+).
|
|
190
|
+
# @param class_callback [String, HttpClassCallback]
|
|
191
|
+
# @return [Array<Expectation>]
|
|
192
|
+
def respond_with_class_callback(class_callback)
|
|
193
|
+
@expectation.http_response_class_callback = class_callback
|
|
194
|
+
@client.upsert(@expectation)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Set a forward class-callback action: the server invokes a server-side
|
|
198
|
+
# class implementing the forward callback interface. Accepts either a
|
|
199
|
+
# fully-qualified class-name String or a pre-built {HttpClassCallback}.
|
|
200
|
+
# @param class_callback [String, HttpClassCallback]
|
|
187
201
|
# @return [Array<Expectation>]
|
|
188
202
|
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
203
|
@expectation.http_forward_class_callback = class_callback
|
|
194
204
|
@client.upsert(@expectation)
|
|
195
205
|
end
|