mockserver-client 7.1.0 → 7.3.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.
@@ -204,6 +204,319 @@ module MockServer
204
204
  response_body && !response_body.empty? ? JSON.parse(response_body) : {}
205
205
  end
206
206
 
207
+ # -------------------------------------------------------------------
208
+ # Metrics
209
+ # -------------------------------------------------------------------
210
+
211
+ # Retrieve the JSON metric counter snapshot
212
+ # (PUT /mockserver/retrieve?type=METRICS).
213
+ #
214
+ # Returns a Hash mapping each metric name to its long value. When metrics
215
+ # are disabled on the server the snapshot is an empty Hash.
216
+ #
217
+ # @return [Hash] metric name => value
218
+ def retrieve_metrics
219
+ status, response_body = do_request(
220
+ 'PUT', '/mockserver/retrieve', '', { 'type' => 'METRICS' }
221
+ )
222
+ if status >= 400
223
+ raise Error, "Failed to retrieve metrics (status=#{status}): #{response_body}"
224
+ end
225
+
226
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
227
+ end
228
+
229
+ # Scrape the Prometheus exposition text (GET /mockserver/metrics).
230
+ #
231
+ # Returns the raw Prometheus/OpenMetrics exposition text. When metrics are
232
+ # disabled on the server this endpoint returns 404 and an {Error} is raised.
233
+ #
234
+ # @return [String] the Prometheus exposition text
235
+ def scrape_metrics
236
+ status, response_body = request('GET', '/mockserver/metrics')
237
+ if status == 404
238
+ raise Error, 'Failed to scrape metrics (status=404): metrics are disabled ' \
239
+ '(set metricsEnabled=true on the server to enable them)'
240
+ end
241
+ if status >= 400
242
+ raise Error, "Failed to scrape metrics (status=#{status}): #{response_body}"
243
+ end
244
+
245
+ response_body || ''
246
+ end
247
+
248
+ # -------------------------------------------------------------------
249
+ # Configuration
250
+ # -------------------------------------------------------------------
251
+
252
+ # Read the effective live configuration (GET /mockserver/configuration).
253
+ #
254
+ # @return [String] the serialized configuration JSON
255
+ def retrieve_configuration
256
+ status, response_body = request('GET', '/mockserver/configuration')
257
+ if status >= 400
258
+ raise Error, "Failed to retrieve configuration (status=#{status}): #{response_body}"
259
+ end
260
+
261
+ response_body || ''
262
+ end
263
+
264
+ # Update the live configuration (PUT /mockserver/configuration).
265
+ #
266
+ # +config_json+ is a ConfigurationDTO JSON document; only the fields present
267
+ # are applied (partial update). A nil value is sent as an empty body.
268
+ #
269
+ # @param config_json [String, nil] the ConfigurationDTO JSON
270
+ # @return [String] the serialized updated configuration JSON
271
+ def update_configuration(config_json)
272
+ body = config_json.nil? ? '' : config_json.to_s
273
+ status, response_body = request('PUT', '/mockserver/configuration', body)
274
+ if status >= 400
275
+ raise Error, "Failed to update configuration (status=#{status}): #{response_body}"
276
+ end
277
+
278
+ response_body || ''
279
+ end
280
+
281
+ # -------------------------------------------------------------------
282
+ # Pact (import / export / verify)
283
+ # -------------------------------------------------------------------
284
+
285
+ # Import a Pact v3 contract as expectations (PUT /mockserver/pact/import).
286
+ #
287
+ # @param json [String] the Pact v3 contract JSON (must not be blank)
288
+ # @return [String] the JSON array of upserted expectations
289
+ # @raise [ArgumentError] if +json+ is nil or blank
290
+ def pact_import(json)
291
+ if json.nil? || json.to_s.strip.empty?
292
+ raise ArgumentError, 'pact JSON must not be empty'
293
+ end
294
+
295
+ status, response_body = request('PUT', '/mockserver/pact/import', json.to_s)
296
+ if status >= 400
297
+ raise Error, "Failed to import pact (status=#{status}): #{response_body}"
298
+ end
299
+
300
+ response_body || ''
301
+ end
302
+
303
+ # Export the active expectations as a Pact v3 contract
304
+ # (PUT /mockserver/pact?consumer=&provider=).
305
+ #
306
+ # A query param is only added when its value is non-blank; otherwise the
307
+ # server defaults are used.
308
+ #
309
+ # @param consumer [String, nil] the Pact consumer name
310
+ # @param provider [String, nil] the Pact provider name
311
+ # @return [String] the generated Pact v3 contract JSON
312
+ def pact_export(consumer: nil, provider: nil)
313
+ query_params = {}
314
+ query_params['consumer'] = consumer if consumer && !consumer.to_s.empty?
315
+ query_params['provider'] = provider if provider && !provider.to_s.empty?
316
+ status, response_body = do_request(
317
+ 'PUT', '/mockserver/pact', nil, query_params.empty? ? nil : query_params
318
+ )
319
+ if status >= 400
320
+ raise Error, "Failed to export pact (status=#{status}): #{response_body}"
321
+ end
322
+
323
+ response_body || ''
324
+ end
325
+
326
+ # Verify a Pact v3 contract against the active expectations
327
+ # (PUT /mockserver/pact/verify).
328
+ #
329
+ # The server encodes the verdict in the HTTP status: 202 when every
330
+ # interaction matches (PASS), 406 when verification fails (FAIL). Both
331
+ # carry the verification report JSON, which is returned verbatim in both
332
+ # cases (a FAIL does not raise) so callers can inspect the report.
333
+ #
334
+ # @param json [String] the Pact v3 contract JSON to verify (must not be blank)
335
+ # @return [String] the verification report JSON
336
+ # @raise [ArgumentError] if +json+ is nil or blank
337
+ def pact_verify(json)
338
+ if json.nil? || json.to_s.strip.empty?
339
+ raise ArgumentError, 'pact JSON must not be empty'
340
+ end
341
+
342
+ status, response_body = request('PUT', '/mockserver/pact/verify', json.to_s)
343
+ if status == 202 || status == 406
344
+ return response_body || ''
345
+ end
346
+ if status >= 400
347
+ raise Error, "Failed to verify pact (status=#{status}): #{response_body}"
348
+ end
349
+
350
+ response_body || ''
351
+ end
352
+
353
+ # -------------------------------------------------------------------
354
+ # File store (store / retrieve / list / delete)
355
+ # -------------------------------------------------------------------
356
+
357
+ # Store a file in the in-memory file store (PUT /mockserver/files/store).
358
+ #
359
+ # @param name [String] the file name/key
360
+ # @param content [String] the UTF-8 file content
361
+ # @return [Hash] response of the form { "name" => ..., "size" => ... }
362
+ def store_file(name, content)
363
+ body = JSON.generate({ 'name' => name, 'content' => content })
364
+ status, response_body = request('PUT', '/mockserver/files/store', body)
365
+ if status >= 400
366
+ raise Error, "Failed to store file (status=#{status}): #{response_body}"
367
+ end
368
+
369
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
370
+ end
371
+
372
+ # Retrieve a file's raw content (PUT /mockserver/files/retrieve).
373
+ #
374
+ # Returns the raw 200 body verbatim. An unknown file yields a 404 which
375
+ # raises an {Error} (there is no null-on-404 fallback).
376
+ #
377
+ # @param name [String] the file name/key
378
+ # @return [String] the raw file content
379
+ def retrieve_file(name)
380
+ body = JSON.generate({ 'name' => name })
381
+ status, response_body = request('PUT', '/mockserver/files/retrieve', body)
382
+ if status == 404
383
+ raise Error, "File not found (status=404): #{name}"
384
+ end
385
+ if status >= 400
386
+ raise Error, "Failed to retrieve file (status=#{status}): #{response_body}"
387
+ end
388
+
389
+ response_body || ''
390
+ end
391
+
392
+ # List the names of all stored files (PUT /mockserver/files/list).
393
+ #
394
+ # @return [Array<String>] the file names
395
+ def list_files
396
+ status, response_body = request('PUT', '/mockserver/files/list')
397
+ if status >= 400
398
+ raise Error, "Failed to list files (status=#{status}): #{response_body}"
399
+ end
400
+
401
+ if response_body && !response_body.empty?
402
+ parsed = JSON.parse(response_body)
403
+ return parsed if parsed.is_a?(Array)
404
+ end
405
+ []
406
+ end
407
+
408
+ # Delete a stored file (PUT /mockserver/files/delete).
409
+ #
410
+ # An unknown file yields a 404 which raises an {Error}.
411
+ #
412
+ # @param name [String] the file name/key
413
+ # @return [nil]
414
+ def delete_file(name)
415
+ body = JSON.generate({ 'name' => name })
416
+ status, response_body = request('PUT', '/mockserver/files/delete', body)
417
+ if status == 404
418
+ raise Error, "File not found (status=404): #{name}"
419
+ end
420
+ if status >= 400
421
+ raise Error, "Failed to delete file (status=#{status}): #{response_body}"
422
+ end
423
+
424
+ nil
425
+ end
426
+
427
+ # -------------------------------------------------------------------
428
+ # Import (HAR / Postman)
429
+ # -------------------------------------------------------------------
430
+
431
+ # Import a HAR document as expectations (PUT /mockserver/import?format=har).
432
+ #
433
+ # @param har_json [String] the HAR JSON document (must not be blank)
434
+ # @return [Array<Expectation>] the upserted expectations
435
+ # @raise [ArgumentError] if +har_json+ is nil or blank
436
+ def import_har(har_json)
437
+ import_document(har_json, 'har')
438
+ end
439
+
440
+ # Import a Postman collection as expectations
441
+ # (PUT /mockserver/import?format=postman).
442
+ #
443
+ # @param collection_json [String] the Postman collection JSON (must not be blank)
444
+ # @return [Array<Expectation>] the upserted expectations
445
+ # @raise [ArgumentError] if +collection_json+ is nil or blank
446
+ def import_postman_collection(collection_json)
447
+ import_document(collection_json, 'postman')
448
+ end
449
+
450
+ # -------------------------------------------------------------------
451
+ # Operating mode (SIMULATE / SPY / CAPTURE)
452
+ # -------------------------------------------------------------------
453
+
454
+ # Valid operating modes (parallels +org.mockserver.mock.MockMode+).
455
+ MODE_SIMULATE = 'SIMULATE'
456
+ MODE_SPY = 'SPY'
457
+ MODE_CAPTURE = 'CAPTURE'
458
+
459
+ # Set the high-level operating mode (PUT /mockserver/mode?mode=<MODE>).
460
+ #
461
+ # +mode+ may be a Symbol or String (case-insensitive), one of
462
+ # +:simulate+/+:spy+/+:capture+ (or the {MODE_SIMULATE}, {MODE_SPY},
463
+ # {MODE_CAPTURE} constants). Setting the mode also flips the
464
+ # proxy-on-no-match behaviour on the server.
465
+ #
466
+ # @param mode [String, Symbol] the operating mode
467
+ # @return [Hash] response of the form
468
+ # { "mode" => ..., "proxyUnmatchedRequests" => ... }
469
+ def set_mode(mode)
470
+ mode_value = mode.to_s.upcase
471
+ status, response_body = do_request(
472
+ 'PUT', '/mockserver/mode', nil, { 'mode' => mode_value }
473
+ )
474
+ if status >= 400
475
+ raise Error, "Failed to set mode (status=#{status}): #{response_body}"
476
+ end
477
+
478
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
479
+ end
480
+
481
+ # Read the current operating mode (GET /mockserver/mode).
482
+ #
483
+ # @return [Hash] response of the form
484
+ # { "mode" => ..., "proxyUnmatchedRequests" => ... }
485
+ def retrieve_mode
486
+ status, response_body = request('GET', '/mockserver/mode')
487
+ if status >= 400
488
+ raise Error, "Failed to retrieve mode (status=#{status}): #{response_body}"
489
+ end
490
+
491
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
492
+ end
493
+
494
+ # -------------------------------------------------------------------
495
+ # WSDL -> expectations
496
+ # -------------------------------------------------------------------
497
+
498
+ # Generate expectations from a WSDL document (PUT /mockserver/wsdl).
499
+ #
500
+ # The WSDL XML is sent as the raw request body with an XML content type.
501
+ #
502
+ # @param wsdl [String] the WSDL document (XML, must not be blank)
503
+ # @return [Array<Expectation>] the generated (upserted) expectations
504
+ # @raise [ArgumentError] if +wsdl+ is nil or blank
505
+ def wsdl_expectation(wsdl)
506
+ if wsdl.nil? || wsdl.to_s.strip.empty?
507
+ raise ArgumentError, 'WSDL must not be empty'
508
+ end
509
+
510
+ status, response_body = request(
511
+ 'PUT', '/mockserver/wsdl', wsdl.to_s, content_type: 'application/xml'
512
+ )
513
+ if status >= 400
514
+ raise Error, "Failed to generate WSDL expectations (status=#{status}): #{response_body}"
515
+ end
516
+
517
+ expectations_from_response(response_body)
518
+ end
519
+
207
520
  # Register a service-scoped HTTP chaos profile for an upstream host. The profile
208
521
  # is applied to every matched forward expectation to that host that does not
209
522
  # define its own chaos (an expectation's own chaos always wins). The host is
@@ -260,6 +573,419 @@ module MockServer
260
573
  response_body && !response_body.empty? ? JSON.parse(response_body) : {}
261
574
  end
262
575
 
576
+ # -------------------------------------------------------------------
577
+ # Load scenario registry (load injection)
578
+ # -------------------------------------------------------------------
579
+ #
580
+ # The load scenario registry decouples *registering* a scenario from
581
+ # *running* it:
582
+ #
583
+ # * registering (load_scenario) only stores the definition keyed by its
584
+ # unique +name+ and is allowed even when +loadGenerationEnabled+ is off;
585
+ # * starting (start_load_scenarios) is what actually drives traffic and
586
+ # requires +loadGenerationEnabled+ on the server (otherwise a 403).
587
+ #
588
+ # Scenario states are: LOADED, PENDING, RUNNING, COMPLETED, STOPPED.
589
+
590
+ # Register (load) a scenario into the registry without running it.
591
+ #
592
+ # +scenario+ may be a {LoadScenario} model (which responds to +to_h+) or a
593
+ # plain Hash already shaped to the +LoadScenario+ JSON contract. It must
594
+ # carry a unique +name+. Registering is permitted even when load generation
595
+ # is disabled on the server.
596
+ #
597
+ # @param scenario [LoadScenario, Hash] the scenario to register
598
+ # @return [Hash] parsed response of the form { "name" => ..., "state" => ... }
599
+ def load_scenario(scenario)
600
+ payload = scenario.respond_to?(:to_h) ? scenario.to_h : scenario
601
+ body = JSON.generate(payload)
602
+ status, response_body = request('PUT', '/mockserver/loadScenario', body)
603
+ if status >= 400
604
+ raise Error, "Failed to register load scenario (status=#{status}): #{response_body}"
605
+ end
606
+
607
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
608
+ end
609
+
610
+ # List all registered load scenarios.
611
+ #
612
+ # @return [Hash] parsed response of the form
613
+ # { "scenarios" => [ { "name" => ..., "state" => ..., "definition" => ..., "status" => ... }, ... ] }
614
+ def load_scenarios
615
+ status, response_body = request('GET', '/mockserver/loadScenario')
616
+ if status >= 400
617
+ raise Error, "Failed to list load scenarios (status=#{status}): #{response_body}"
618
+ end
619
+
620
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
621
+ end
622
+
623
+ # Fetch a single registered load scenario by name.
624
+ #
625
+ # @param name [String] the unique scenario name
626
+ # @return [Hash] parsed scenario entry { "name" => ..., "state" => ..., "definition" => ..., "status" => ... }
627
+ # @raise [Error] if the scenario does not exist (404) or another failure occurs
628
+ def get_load_scenario(name)
629
+ status, response_body = request('GET', "/mockserver/loadScenario/#{encode_path_segment(name)}")
630
+ if status == 404
631
+ raise Error, "Load scenario not found (status=404): #{name}"
632
+ end
633
+ if status >= 400
634
+ raise Error, "Failed to get load scenario (status=#{status}): #{response_body}"
635
+ end
636
+
637
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
638
+ end
639
+
640
+ # Remove a single registered load scenario by name.
641
+ #
642
+ # @param name [String] the unique scenario name
643
+ # @return [Hash] parsed response (may be empty)
644
+ def delete_load_scenario(name)
645
+ status, response_body = request('DELETE', "/mockserver/loadScenario/#{encode_path_segment(name)}")
646
+ if status >= 400
647
+ raise Error, "Failed to delete load scenario (status=#{status}): #{response_body}"
648
+ end
649
+
650
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
651
+ end
652
+
653
+ # Clear all registered load scenarios.
654
+ #
655
+ # @return [Hash] parsed response (may be empty)
656
+ def clear_load_scenarios
657
+ status, response_body = request('DELETE', '/mockserver/loadScenario')
658
+ if status >= 400
659
+ raise Error, "Failed to clear load scenarios (status=#{status}): #{response_body}"
660
+ end
661
+
662
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
663
+ end
664
+
665
+ # Start one or more registered scenarios.
666
+ #
667
+ # +names+ may be a single scenario name (String) or an Array of names; it is
668
+ # always sent as { "names" => [...] }. Honours each scenario's
669
+ # +startDelayMillis+. Requires +loadGenerationEnabled+ on the server; a 403
670
+ # response raises a clear error explaining the feature is disabled.
671
+ #
672
+ # @param names [String, Array<String>] scenario name(s) to start
673
+ # @return [Hash] parsed response of the form
674
+ # { "started" => [ { "name" => ..., "state" => ... }, ... ], "status" => ... }
675
+ def start_load_scenarios(names)
676
+ payload = { 'names' => Array(names) }
677
+ body = JSON.generate(payload)
678
+ status, response_body = request('PUT', '/mockserver/loadScenario/start', body)
679
+ if status == 403
680
+ raise Error, 'Load scenario start rejected (status=403): load generation is disabled ' \
681
+ '(set loadGenerationEnabled=true on the server to enable it)'
682
+ end
683
+ if status == 404
684
+ raise Error, "Load scenario not found (status=404): #{response_body}"
685
+ end
686
+ if status >= 400
687
+ raise Error, "Failed to start load scenarios (status=#{status}): #{response_body}"
688
+ end
689
+
690
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
691
+ end
692
+
693
+ # Stop running scenarios.
694
+ #
695
+ # +names+ may be:
696
+ # * a single scenario name (String) -> { "names" => ["a"] }
697
+ # * an Array of names -> { "names" => ["a", "b"] }
698
+ # * nil (the default) -> empty body, which stops all running scenarios
699
+ #
700
+ # @param names [String, Array<String>, nil] scenario name(s) to stop, or nil for all
701
+ # @return [Hash] parsed response of the form
702
+ # { "stopped" => [ ... ], "status" => ... }
703
+ def stop_load_scenarios(names = nil)
704
+ body = names.nil? ? nil : JSON.generate({ 'names' => Array(names) })
705
+ status, response_body = request('PUT', '/mockserver/loadScenario/stop', body)
706
+ if status >= 400
707
+ raise Error, "Failed to stop load scenarios (status=#{status}): #{response_body}"
708
+ end
709
+
710
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
711
+ end
712
+
713
+ # Convenience: register a scenario then immediately start it.
714
+ #
715
+ # Equivalent to calling {#load_scenario} followed by {#start_load_scenarios}
716
+ # for the scenario's name. Requires +loadGenerationEnabled+ on the server for
717
+ # the start step.
718
+ #
719
+ # @param scenario [LoadScenario, Hash] the scenario to register and run
720
+ # @return [Hash] parsed response from the start call
721
+ def run_load_scenario(scenario)
722
+ payload = scenario.respond_to?(:to_h) ? scenario.to_h : scenario
723
+ name = payload.respond_to?(:[]) ? (payload['name'] || payload[:name]) : nil
724
+ if name.nil? || name.to_s.empty?
725
+ raise ArgumentError, 'scenario must carry a non-empty name to run'
726
+ end
727
+
728
+ load_scenario(payload)
729
+ start_load_scenarios(name)
730
+ end
731
+
732
+ # Fetch the end-of-run summary report for a load scenario run.
733
+ #
734
+ # Returns the report derived from the run's status snapshot (live while
735
+ # running, or the retained terminal snapshot once finished). With no +format+
736
+ # (or any value other than +"junit"+) the JSON report is parsed and returned
737
+ # as a Hash carrying counts, latency percentiles, the threshold verdict and
738
+ # per-threshold results. With +format: "junit"+ the raw JUnit-XML
739
+ # +<testsuite>+ document is returned as a String so a load run becomes a
740
+ # first-class CI test artifact.
741
+ #
742
+ # @param name [String] the unique scenario name
743
+ # @param format [String, nil] +"junit"+ for the JUnit-XML report, otherwise JSON
744
+ # @return [Hash, String] parsed JSON report (default) or the raw JUnit-XML String
745
+ # @raise [Error] if the scenario never ran (404) or another failure occurs
746
+ def get_load_scenario_report(name, format = nil)
747
+ query_params = {}
748
+ query_params['format'] = format if format
749
+ status, response_body = do_request(
750
+ 'GET', "/mockserver/loadScenario/#{encode_path_segment(name)}/report", nil,
751
+ query_params.empty? ? nil : query_params
752
+ )
753
+ if status == 404
754
+ raise Error, "Load scenario report not found (status=404): #{name}"
755
+ end
756
+ if status >= 400
757
+ raise Error, "Failed to get load scenario report (status=#{status}): #{response_body}"
758
+ end
759
+
760
+ return response_body if format == 'junit'
761
+
762
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
763
+ end
764
+
765
+ # Generate (and register) a load scenario from an OpenAPI specification.
766
+ #
767
+ # Produces an editable scenario - one step per OpenAPI operation - and loads
768
+ # it into the registry under +name+ in the LOADED state; it generates no
769
+ # traffic and is allowed even when load generation is disabled.
770
+ #
771
+ # @param name [String] the generated scenario name (the unique registry key)
772
+ # @param spec_url_or_payload [String] OpenAPI spec as inline JSON/YAML, a URL,
773
+ # or a file/classpath reference
774
+ # @param target [Hash, nil] explicit network target for every generated step
775
+ # (e.g. { "host" => ..., "port" => ..., "scheme" => "http" })
776
+ # @param profile [LoadProfile, Hash, nil] optional traffic profile (a
777
+ # conservative default is applied when omitted)
778
+ # @return [Hash] parsed response of the form
779
+ # { "status" => "loaded", "name" => ..., "state" => ..., "scenario" => {...} }
780
+ def generate_load_scenario_from_openapi(name, spec_url_or_payload, target: nil, profile: nil)
781
+ payload = { 'name' => name, 'specUrlOrPayload' => spec_url_or_payload }
782
+ payload['target'] = target if target
783
+ payload['profile'] = profile.respond_to?(:to_h) ? profile.to_h : profile if profile
784
+ body = JSON.generate(payload)
785
+ status, response_body = request('PUT', '/mockserver/loadScenario/generateFromOpenAPI', body)
786
+ if status >= 400
787
+ raise Error, "Failed to generate load scenario from OpenAPI (status=#{status}): #{response_body}"
788
+ end
789
+
790
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
791
+ end
792
+
793
+ # Generate (and register) a load scenario from recorded proxy traffic.
794
+ #
795
+ # Converts requests previously recorded by MockServer in proxy/recording mode
796
+ # into an editable scenario and loads it into the registry under +name+ in the
797
+ # LOADED state; it generates no traffic and is allowed even when load
798
+ # generation is disabled.
799
+ #
800
+ # @param name [String] the generated scenario name (the unique registry key)
801
+ # @param mode [String, nil] +VERBATIM+ (default) or +TEMPLATIZED+
802
+ # @param request_filter [HttpRequest, Hash, nil] optional matcher selecting
803
+ # which recorded requests to include (absent means all)
804
+ # @param target [Hash, nil] explicit network target applied to every step
805
+ # @param max_steps [Integer, nil] optional cap on the number of VERBATIM steps
806
+ # @return [Hash] parsed response of the form
807
+ # { "status" => "loaded", "name" => ..., "state" => ..., "scenario" => {...} }
808
+ def generate_load_scenario_from_recording(name, mode: nil, request_filter: nil,
809
+ target: nil, max_steps: nil)
810
+ payload = { 'name' => name }
811
+ payload['mode'] = mode if mode
812
+ payload['requestFilter'] = request_filter.respond_to?(:to_h) ? request_filter.to_h : request_filter if request_filter
813
+ payload['target'] = target if target
814
+ payload['maxSteps'] = max_steps if max_steps
815
+ body = JSON.generate(payload)
816
+ status, response_body = request('PUT', '/mockserver/loadScenario/generateFromRecording', body)
817
+ if status >= 400
818
+ raise Error, "Failed to generate load scenario from recording (status=#{status}): #{response_body}"
819
+ end
820
+
821
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
822
+ end
823
+
824
+ # -------------------------------------------------------------------
825
+ # SRE control plane: SLO verification + chaos experiments
826
+ # -------------------------------------------------------------------
827
+
828
+ # Evaluate a set of service-level objectives (SLOs) over a window
829
+ # (PUT /mockserver/verifySLO).
830
+ #
831
+ # The server encodes the verdict in the HTTP status: 200 for PASS or
832
+ # INCONCLUSIVE, 406 for FAIL, 400 for malformed criteria or when SLO tracking
833
+ # is disabled (+sloTrackingEnabled=false+). The decoded verdict Hash carries
834
+ # the overall +result+ and the per-objective +objectiveResults+ so callers can
835
+ # inspect why an SLO failed.
836
+ #
837
+ # +criteria+ may be any Hash already shaped to the +SloCriteria+ JSON contract
838
+ # (+name+, +window+, +minimumSampleCount+, +upstreamHosts+,
839
+ # +objectives+[{+sli+, +comparator+, +threshold+, +scope+}]) or an object that
840
+ # responds to +to_h+.
841
+ #
842
+ # @param criteria [Hash, #to_h] the SLO criteria
843
+ # @return [Hash] the SLO verdict (result PASS or INCONCLUSIVE)
844
+ # @raise [VerificationError] if the verdict is FAIL (HTTP 406)
845
+ # @raise [Error] if criteria are malformed or SLO tracking is disabled (HTTP 400),
846
+ # or on any other failure
847
+ def verify_slo(criteria)
848
+ payload = criteria.respond_to?(:to_h) ? criteria.to_h : criteria
849
+ body = JSON.generate(payload)
850
+ status, response_body = request('PUT', '/mockserver/verifySLO', body)
851
+ if status == 406
852
+ raise VerificationError, (response_body && !response_body.empty? ? response_body : 'SLO verdict: FAIL')
853
+ end
854
+ if status == 400
855
+ raise Error, 'Invalid SLO criteria (or SLO tracking disabled — set ' \
856
+ "sloTrackingEnabled=true on the server): #{response_body}"
857
+ end
858
+ if status >= 400
859
+ raise Error, "Failed to verify SLO (status=#{status}): #{response_body}"
860
+ end
861
+
862
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
863
+ end
864
+
865
+ # Start a scheduled multi-stage chaos experiment
866
+ # (PUT /mockserver/chaosExperiment).
867
+ #
868
+ # The experiment is an ordered sequence of stages, each applying
869
+ # service-scoped chaos profiles to one or more hosts for a duration; stages
870
+ # progress automatically. Only one experiment may be active at a time;
871
+ # starting a new one stops the previous one.
872
+ #
873
+ # +experiment+ may be any Hash already shaped to the +ChaosExperiment+ JSON
874
+ # contract (+name+, +loop+, +stages+[{+durationMillis+, +profiles+{host:
875
+ # profile}}]) or an object that responds to +to_h+.
876
+ #
877
+ # @param experiment [Hash, #to_h] the experiment definition
878
+ # @return [Hash] the started status, e.g. { "status" => "started", "name" => ... }
879
+ # @raise [Error] if the experiment definition is invalid or chaos is disabled
880
+ # (HTTP 400/403), or on any other failure
881
+ def start_chaos_experiment(experiment)
882
+ payload = experiment.respond_to?(:to_h) ? experiment.to_h : experiment
883
+ body = JSON.generate(payload)
884
+ status, response_body = request('PUT', '/mockserver/chaosExperiment', body)
885
+ if status == 403
886
+ raise Error, 'Chaos experiment rejected (status=403): chaos is disabled on the server'
887
+ end
888
+ if status >= 400
889
+ raise Error, "Failed to start chaos experiment (status=#{status}): #{response_body}"
890
+ end
891
+
892
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
893
+ end
894
+
895
+ # -------------------------------------------------------------------
896
+ # Stateful scenarios (state machine control plane)
897
+ # -------------------------------------------------------------------
898
+
899
+ # Return a handle to the named stateful scenario, wrapping the
900
+ # +/mockserver/scenario/{name}+ control-plane endpoints.
901
+ #
902
+ # @param name [String] the scenario (state-machine) name
903
+ # @return [ScenarioHandle]
904
+ def scenario(name)
905
+ ScenarioHandle.new(self, name)
906
+ end
907
+
908
+ # List every known scenario and its current state.
909
+ #
910
+ # @return [Array<ScenarioState>] each with +scenario_name+ and +current_state+
911
+ def scenarios
912
+ result = scenario_request('GET', '/mockserver/scenario')
913
+ list = result.is_a?(Hash) ? (result['scenarios'] || []) : []
914
+ list.map { |s| ScenarioState.from_hash(s) }
915
+ end
916
+
917
+ # @api private
918
+ # Issue a control-plane scenario request, parsing the JSON response and
919
+ # raising {Error} on any >= 400 status. Reuses the same transport
920
+ # (+request+) as the other +/mockserver/...+ control endpoints.
921
+ def scenario_request(method, path, body = nil)
922
+ status, response_body = request(method, path, body)
923
+ if status >= 400
924
+ raise Error, "Scenario request failed (status=#{status}): #{response_body}"
925
+ end
926
+
927
+ response_body && !response_body.empty? ? JSON.parse(response_body) : {}
928
+ end
929
+
930
+ # -------------------------------------------------------------------
931
+ # gRPC descriptor management
932
+ # -------------------------------------------------------------------
933
+
934
+ # Upload a compiled protobuf descriptor set so gRPC requests can be matched.
935
+ #
936
+ # +descriptor_bytes+ must be the raw bytes of a +FileDescriptorSet+ (e.g. the
937
+ # output of +protoc --descriptor_set_out=... --include_imports+). The bytes are
938
+ # sent verbatim as +application/octet-stream+ (NOT base64-encoded).
939
+ #
940
+ # @param descriptor_bytes [String] raw descriptor set bytes (binary string)
941
+ # @return [nil]
942
+ def upload_grpc_descriptor(descriptor_bytes)
943
+ if descriptor_bytes.nil? || descriptor_bytes.empty?
944
+ raise ArgumentError, 'descriptor bytes must not be empty'
945
+ end
946
+
947
+ status, response_body = request(
948
+ 'PUT', '/mockserver/grpc/descriptors', descriptor_bytes,
949
+ content_type: 'application/octet-stream'
950
+ )
951
+ if status >= 400
952
+ raise Error, "Failed to upload gRPC descriptor (status=#{status}): #{response_body}"
953
+ end
954
+
955
+ nil
956
+ end
957
+
958
+ # Retrieve the gRPC services registered from uploaded descriptor sets.
959
+ #
960
+ # Returns an array of service hashes, each with a +"name"+ and a list of
961
+ # +"methods"+ (+"name"+, +"inputType"+, +"outputType"+, +"clientStreaming"+,
962
+ # +"serverStreaming"+).
963
+ #
964
+ # @return [Array<Hash>]
965
+ def retrieve_grpc_services
966
+ status, response_body = request('PUT', '/mockserver/grpc/services')
967
+ if status >= 400
968
+ raise Error, "Failed to retrieve gRPC services (status=#{status}): #{response_body}"
969
+ end
970
+
971
+ if response_body && !response_body.empty?
972
+ parsed = JSON.parse(response_body)
973
+ return parsed if parsed.is_a?(Array)
974
+ end
975
+ []
976
+ end
977
+
978
+ # Clear all uploaded gRPC descriptor sets and registered services.
979
+ # @return [nil]
980
+ def clear_grpc_descriptors
981
+ status, response_body = request('PUT', '/mockserver/grpc/clear')
982
+ if status >= 400
983
+ raise Error, "Failed to clear gRPC descriptors (status=#{status}): #{response_body}"
984
+ end
985
+
986
+ nil
987
+ end
988
+
263
989
  # Verify that a request (and optionally a response) was received.
264
990
  # @param request [HttpRequest, nil]
265
991
  # @param times [VerificationTimes, nil]
@@ -370,6 +1096,44 @@ module MockServer
370
1096
  []
371
1097
  end
372
1098
 
1099
+ # Retrieve the active expectations as MockServer SDK setup code (the builder
1100
+ # code that recreates the expectations) in the requested language.
1101
+ # @param format [String] one of "java", "javascript", "python", "go",
1102
+ # "csharp", "ruby", "rust" or "php" (case-insensitive)
1103
+ # @param request [HttpRequest, nil]
1104
+ # @return [String] the generated code
1105
+ def retrieve_expectations_as_code(format: 'java', request: nil)
1106
+ body = request ? JSON.generate(request.to_h) : ''
1107
+ status, response_body = do_request(
1108
+ 'PUT', '/mockserver/retrieve', body,
1109
+ { 'type' => 'ACTIVE_EXPECTATIONS', 'format' => format.to_s.upcase }
1110
+ )
1111
+ if status >= 400
1112
+ raise Error, "Failed to retrieve expectations as code (status=#{status}): #{response_body}"
1113
+ end
1114
+
1115
+ response_body || ''
1116
+ end
1117
+
1118
+ # Retrieve the recorded (proxied) request/response pairs as MockServer SDK
1119
+ # setup code in the requested language.
1120
+ # @param format [String] one of "java", "javascript", "python", "go",
1121
+ # "csharp", "ruby", "rust" or "php" (case-insensitive)
1122
+ # @param request [HttpRequest, nil]
1123
+ # @return [String] the generated code
1124
+ def retrieve_recorded_expectations_as_code(format: 'java', request: nil)
1125
+ body = request ? JSON.generate(request.to_h) : ''
1126
+ status, response_body = do_request(
1127
+ 'PUT', '/mockserver/retrieve', body,
1128
+ { 'type' => 'RECORDED_EXPECTATIONS', 'format' => format.to_s.upcase }
1129
+ )
1130
+ if status >= 400
1131
+ raise Error, "Failed to retrieve recorded expectations as code (status=#{status}): #{response_body}"
1132
+ end
1133
+
1134
+ response_body || ''
1135
+ end
1136
+
373
1137
  # Retrieve recorded requests and responses.
374
1138
  # @param request [HttpRequest, nil]
375
1139
  # @return [Array<HttpRequestAndHttpResponse>]
@@ -700,7 +1464,8 @@ module MockServer
700
1464
 
701
1465
  # Perform an HTTP request with optional query parameters.
702
1466
  # @api private
703
- def do_request(method, path, body = nil, query_params = nil)
1467
+ def do_request(method, path, body = nil, query_params = nil,
1468
+ content_type: 'application/json; charset=utf-8')
704
1469
  url = "#{@base_url}#{path}"
705
1470
  if query_params && !query_params.empty?
706
1471
  url = "#{url}?#{URI.encode_www_form(query_params)}"
@@ -709,14 +1474,52 @@ module MockServer
709
1474
  uri = URI.parse(url)
710
1475
  http = build_http(uri)
711
1476
 
712
- req = build_request(method, uri, body)
1477
+ req = build_request(method, uri, body, content_type)
713
1478
  execute_request(http, req)
714
1479
  end
715
1480
 
716
1481
  # Perform an HTTP request (no query params).
717
1482
  # @api private
718
- def request(method, path, body = nil)
719
- do_request(method, path, body, nil)
1483
+ def request(method, path, body = nil,
1484
+ content_type: 'application/json; charset=utf-8')
1485
+ do_request(method, path, body, nil, content_type: content_type)
1486
+ end
1487
+
1488
+ # @api private
1489
+ # Percent-encode a single URL path segment (e.g. a scenario name) so that
1490
+ # spaces, slashes, and other reserved characters are transmitted safely.
1491
+ def encode_path_segment(value)
1492
+ raise ArgumentError, 'name must not be nil or empty' if value.nil? || value.to_s.empty?
1493
+
1494
+ URI.encode_www_form_component(value.to_s).gsub('+', '%20')
1495
+ end
1496
+
1497
+ # @api private
1498
+ # Import a document (HAR/Postman/Pact) via PUT /mockserver/import?format=<format>
1499
+ # and return the upserted expectations.
1500
+ def import_document(json, format)
1501
+ if json.nil? || json.to_s.strip.empty?
1502
+ raise ArgumentError, "import #{format} document must not be empty"
1503
+ end
1504
+
1505
+ status, response_body = do_request(
1506
+ 'PUT', '/mockserver/import', json.to_s, { 'format' => format }
1507
+ )
1508
+ if status >= 400
1509
+ raise Error, "Failed to import #{format} (status=#{status}): #{response_body}"
1510
+ end
1511
+
1512
+ expectations_from_response(response_body)
1513
+ end
1514
+
1515
+ # @api private
1516
+ # Parse a JSON array of expectations from a control-plane response body.
1517
+ def expectations_from_response(response_body)
1518
+ if response_body && !response_body.empty?
1519
+ parsed = JSON.parse(response_body)
1520
+ return parsed.map { |e| Expectation.from_hash(e) } if parsed.is_a?(Array)
1521
+ end
1522
+ []
720
1523
  end
721
1524
 
722
1525
  # @api private
@@ -741,7 +1544,7 @@ module MockServer
741
1544
  end
742
1545
 
743
1546
  # @api private
744
- def build_request(method, uri, body)
1547
+ def build_request(method, uri, body, content_type = 'application/json; charset=utf-8')
745
1548
  request_path = uri.request_uri
746
1549
  case method.upcase
747
1550
  when 'PUT'
@@ -755,14 +1558,19 @@ module MockServer
755
1558
  else
756
1559
  req = Net::HTTP::Put.new(request_path)
757
1560
  end
758
- req['Content-Type'] = 'application/json; charset=utf-8'
1561
+ req['Content-Type'] = content_type
759
1562
  req.body = body if body
760
1563
  req
761
1564
  end
762
1565
 
763
1566
  # @api private
764
1567
  def execute_request(http, req)
765
- response = http.request(req)
1568
+ # Use the block form of #start so the underlying TCP/TLS connection is
1569
+ # always closed (#finish) when the request completes, rather than being
1570
+ # left open until garbage collection. All connection options (use_ssl,
1571
+ # ca_file, verify_mode, read_timeout, open_timeout) configured on +http+
1572
+ # by #build_http are preserved because #start operates on this instance.
1573
+ response = http.start { |conn| conn.request(req) }
766
1574
  [response.code.to_i, response.body || '']
767
1575
  rescue Net::OpenTimeout, Net::ReadTimeout => e
768
1576
  raise ConnectionError, "Request to MockServer at #{@base_url} timed out: #{e.message}"