mockserver-client 7.2.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
@@ -416,6 +729,169 @@ module MockServer
416
729
  start_load_scenarios(name)
417
730
  end
418
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
+
419
895
  # -------------------------------------------------------------------
420
896
  # Stateful scenarios (state machine control plane)
421
897
  # -------------------------------------------------------------------
@@ -1018,6 +1494,34 @@ module MockServer
1018
1494
  URI.encode_www_form_component(value.to_s).gsub('+', '%20')
1019
1495
  end
1020
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
+ []
1523
+ end
1524
+
1021
1525
  # @api private
1022
1526
  def build_http(uri)
1023
1527
  http = Net::HTTP.new(uri.host, uri.port)