mockserver-client 7.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b853b4ba04e3473e6e664a0f1a2f6b4f0a8146f624a2308a19349fe94f536d9e
4
- data.tar.gz: 848eedd40df3e43ac6299250c92d4f30ee1b7fbeedb15699cd7254af20fcb6a4
3
+ metadata.gz: f1a47589dbc527bf772961f968c2235d9b64ebc13fd8a1f963e143e50319db9f
4
+ data.tar.gz: 85aa291fefefdce077d8f4eaca22a5fd56d473628ec819c2029fae9f8e91aa4e
5
5
  SHA512:
6
- metadata.gz: a2c283e6f9ed9fb935ff296a84298abc7c73768609c0601b8079e20441ebd25ca1029256a7c21a968578ef3f9ea28777b7d07dd20c7d8b4387031298389c4eb8
7
- data.tar.gz: 77bc8cf3f5e4e919ba758c4c720cbf9795600094d7bfc41faa66b5571c0ea2b7ee39fa3a4f6871726ffc3a6468f84defb34afce05b33203b734e1cc1ac1c3c53
6
+ metadata.gz: 1b5adcd45c05bc417a62ad3a6fab6e00b42ef1180b7a89cc0084f972bb83aac5d68832d2e02da83d899a7fb511a71fb2ff87576eba4487f51a0bfafc546d5c06
7
+ data.tar.gz: dd83794b0fd9ad0ffc7439b505bc7ff3c8461e076d6acf209ce8b273b6ca4d233c19638ece936faaacf43e196d39a1a9220206c1a559dbe86dd2154a15b078e8
data/README.md CHANGED
@@ -99,6 +99,90 @@ All 25 domain model classes are available under the `MockServer` module:
99
99
  - `Ports`
100
100
  - `RequestDefinition` (alias for `HttpRequest`)
101
101
 
102
+ ## LLM Mocking
103
+
104
+ Fluent builders under `MockServer::LLM` mock LLM provider endpoints. They produce
105
+ expectations whose action is carried in the `httpLlmResponse` field; MockServer
106
+ re-encodes the provider-agnostic completion into the configured provider's wire
107
+ shape (OpenAI / Anthropic / Gemini / Bedrock / ...) when serving the request.
108
+
109
+ ```ruby
110
+ require 'mockserver-client'
111
+
112
+ client = MockServer::Client.new('localhost', 1080)
113
+
114
+ # A single completion mock
115
+ MockServer::LLM.llm_mock('/v1/chat/completions')
116
+ .with_provider(MockServer::LLM::Provider::OPENAI)
117
+ .with_model('gpt-4o')
118
+ .responding_with(
119
+ MockServer::LLM.completion
120
+ .with_text('Hello!')
121
+ .with_usage(MockServer::LLM.usage.with_input_tokens(10).with_output_tokens(5))
122
+ )
123
+ .apply_to(client)
124
+ ```
125
+
126
+ Multi-turn **conversations** use MockServer scenario-state advancement so each
127
+ turn is served once, in order. Add per-turn predicates with `when_*` methods and
128
+ optionally isolate per session with `isolate_by`:
129
+
130
+ ```ruby
131
+ MockServer::LLM.conversation
132
+ .with_path('/v1/chat/completions')
133
+ .with_provider(MockServer::LLM::Provider::OPENAI)
134
+ .isolate_by(MockServer::LLM.header('x-session-id'))
135
+ .turn.when_latest_message_contains('weather')
136
+ .responding_with(MockServer::LLM.completion.with_text('It is sunny.'))
137
+ .turn.responding_with(MockServer::LLM.completion.with_text('Anything else?'))
138
+ .apply_to(client)
139
+ ```
140
+
141
+ **Failover** scenarios script N upstream failures (consecutive identical failures
142
+ are coalesced) followed by a success; default JSON error bodies are generated per
143
+ status code unless a custom body is supplied:
144
+
145
+ ```ruby
146
+ MockServer::LLM.llm_failover
147
+ .with_path('/v1/chat/completions')
148
+ .with_provider(MockServer::LLM::Provider::OPENAI)
149
+ .fail_with(429, 2) # two rate-limit failures
150
+ .fail_with(503) # then one service-unavailable
151
+ .then_respond_with(MockServer::LLM.completion.with_text('Recovered'))
152
+ .apply_to(client)
153
+ ```
154
+
155
+ Each builder also exposes `build` to obtain the raw expectation Hash(es) without
156
+ registering them.
157
+
158
+ ## MCP Mocking
159
+
160
+ `MockServer::MCP.mcp_mock` builds the set of expectations needed to emulate a
161
+ Streamable-HTTP MCP (Model Context Protocol) server speaking JSON-RPC 2.0. It
162
+ generates `initialize`, `ping`, `notifications/initialized`, and per-capability
163
+ `tools`, `resources`, and `prompts` handlers.
164
+
165
+ ```ruby
166
+ MockServer::MCP.mcp_mock('/mcp')
167
+ .with_server_name('MyServer')
168
+ .with_tool('get_weather')
169
+ .with_description('Get the weather for a city')
170
+ .with_input_schema('{"type":"object","properties":{"city":{"type":"string"}}}')
171
+ .responding_with('72F and sunny')
172
+ .and_then
173
+ .with_resource('file:///config.json')
174
+ .with_name('config')
175
+ .with_content('{"debug":true}')
176
+ .and_then
177
+ .with_prompt('greet')
178
+ .with_argument('name', 'who to greet', true)
179
+ .responding_with('user', 'Hello there')
180
+ .and_then
181
+ .apply_to(client)
182
+ ```
183
+
184
+ `build` returns the raw expectation Hashes if you prefer to register them yourself.
185
+
102
186
  ## Interactive Breakpoints
103
187
 
104
188
  The client supports matcher-driven interactive breakpoints over the callback WebSocket. Register a breakpoint matcher to pause forwarded/proxied exchanges at specific phases and inspect/modify/continue them via callback handlers.
@@ -172,7 +256,7 @@ launcher_path = MockServer::BinaryLauncher.ensure_launcher
172
256
  ### Specify a version
173
257
 
174
258
  ```ruby
175
- handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.1.0')
259
+ handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.2.0')
176
260
  ```
177
261
 
178
262
  ### API reference
@@ -203,6 +287,34 @@ handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.1.0')
203
287
 
204
288
  By default the launcher downloads the MockServer version matching this client gem (currently `MockServer::VERSION` from `lib/mockserver/version.rb`). Pass an explicit `version:` keyword to override.
205
289
 
290
+ ## Using in tests (RSpec)
291
+
292
+ Require `mockserver/rspec` to get a shared context that provides a fresh
293
+ `MockServer::Client` per example and resets the server before and after each
294
+ example, so recorded requests, expectations and logs never leak between
295
+ examples:
296
+
297
+ ```ruby
298
+ # spec_helper.rb
299
+ require 'mockserver/rspec'
300
+
301
+ # any spec tagged :mockserver gets a reset `mockserver` client
302
+ RSpec.describe 'my integration', :mockserver do
303
+ it 'records the request' do
304
+ # `mockserver` is the shared, reset client
305
+ mockserver.when(
306
+ MockServer::HttpRequest.request(path: '/hello')
307
+ ).respond(
308
+ MockServer::HttpResponse.response(body: 'world', status_code: 200)
309
+ )
310
+ end
311
+ end
312
+ ```
313
+
314
+ Host and port default to `127.0.0.1:1080` and can be overridden with the
315
+ `MOCKSERVER_HOST` / `MOCKSERVER_PORT` environment variables, or by defining a
316
+ `mockserver_host` / `mockserver_port` `let` in your example group.
317
+
206
318
  ## License
207
319
 
208
320
  Apache-2.0
@@ -32,6 +32,11 @@ module MockServer
32
32
  # @example Just ensure the binary is present
33
33
  # path = MockServer::BinaryLauncher.ensure_launcher
34
34
  class BinaryLauncher
35
+ # Raised internally when a download returns HTTP 404, so the caller can
36
+ # distinguish "no bundle published for this version" from other transport
37
+ # errors and emit actionable guidance.
38
+ class NotFoundError < StandardError; end
39
+
35
40
  REPO = 'mock-server/mockserver-monorepo'
36
41
 
37
42
  # CDN base URL used for SNAPSHOT version downloads.
@@ -171,7 +176,14 @@ module MockServer
171
176
  # Download to a temp file
172
177
  url = asset_url(version, archive_file)
173
178
  log.info("Downloading #{url}")
174
- download_file(url, partial)
179
+ begin
180
+ download_file(url, partial)
181
+ rescue NotFoundError
182
+ # A 404 means the release tag exists but ships no bundle for this
183
+ # version (or the tag does not exist). Emit actionable guidance
184
+ # instead of an opaque HTTP error.
185
+ raise Error, no_bundle_message(version)
186
+ end
175
187
 
176
188
  # Verify SHA-256 (fail-closed — always required, no bypass)
177
189
  sha_url = asset_url(version, "#{archive_file}.sha256")
@@ -317,6 +329,22 @@ module MockServer
317
329
 
318
330
  private
319
331
 
332
+ # Build a clear, actionable error message for a missing release bundle.
333
+ #
334
+ # Explains that no downloadable bundle exists for +version+ and lists the
335
+ # concrete alternatives. The wording is kept consistent across all client
336
+ # languages.
337
+ #
338
+ # @param version [String]
339
+ # @return [String]
340
+ def no_bundle_message(version)
341
+ "no MockServer release bundle is published for version #{version} " \
342
+ "(no downloadable asset at the GitHub release tag 'mockserver-#{version}'). " \
343
+ 'Use a MockServer version that ships self-contained bundles, ' \
344
+ "or run MockServer via Docker (docker run mockserver/mockserver:mockserver-#{version}), " \
345
+ "or use the Maven Central jar (org.mock-server:mockserver-netty:#{version})."
346
+ end
347
+
320
348
  # Validate the version string against the strict pattern (H1).
321
349
  #
322
350
  # @param version [String]
@@ -567,6 +595,8 @@ module MockServer
567
595
  response.read_body
568
596
  location = response['location']
569
597
  fetch_with_redirects(URI.parse(location), dest, max_redirects - 1)
598
+ when Net::HTTPNotFound
599
+ raise NotFoundError, "download #{uri} failed: HTTP 404"
570
600
  else
571
601
  raise Error, "download #{uri} failed: HTTP #{response.code}"
572
602
  end
@@ -260,6 +260,256 @@ module MockServer
260
260
  response_body && !response_body.empty? ? JSON.parse(response_body) : {}
261
261
  end
262
262
 
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
+
263
513
  # Verify that a request (and optionally a response) was received.
264
514
  # @param request [HttpRequest, nil]
265
515
  # @param times [VerificationTimes, nil]
@@ -370,6 +620,44 @@ module MockServer
370
620
  []
371
621
  end
372
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
+
373
661
  # Retrieve recorded requests and responses.
374
662
  # @param request [HttpRequest, nil]
375
663
  # @return [Array<HttpRequestAndHttpResponse>]
@@ -700,7 +988,8 @@ module MockServer
700
988
 
701
989
  # Perform an HTTP request with optional query parameters.
702
990
  # @api private
703
- 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')
704
993
  url = "#{@base_url}#{path}"
705
994
  if query_params && !query_params.empty?
706
995
  url = "#{url}?#{URI.encode_www_form(query_params)}"
@@ -709,14 +998,24 @@ module MockServer
709
998
  uri = URI.parse(url)
710
999
  http = build_http(uri)
711
1000
 
712
- req = build_request(method, uri, body)
1001
+ req = build_request(method, uri, body, content_type)
713
1002
  execute_request(http, req)
714
1003
  end
715
1004
 
716
1005
  # Perform an HTTP request (no query params).
717
1006
  # @api private
718
- def request(method, path, body = nil)
719
- do_request(method, path, body, nil)
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')
720
1019
  end
721
1020
 
722
1021
  # @api private
@@ -741,7 +1040,7 @@ module MockServer
741
1040
  end
742
1041
 
743
1042
  # @api private
744
- def build_request(method, uri, body)
1043
+ def build_request(method, uri, body, content_type = 'application/json; charset=utf-8')
745
1044
  request_path = uri.request_uri
746
1045
  case method.upcase
747
1046
  when 'PUT'
@@ -755,14 +1054,19 @@ module MockServer
755
1054
  else
756
1055
  req = Net::HTTP::Put.new(request_path)
757
1056
  end
758
- req['Content-Type'] = 'application/json; charset=utf-8'
1057
+ req['Content-Type'] = content_type
759
1058
  req.body = body if body
760
1059
  req
761
1060
  end
762
1061
 
763
1062
  # @api private
764
1063
  def execute_request(http, req)
765
- response = http.request(req)
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) }
766
1070
  [response.code.to_i, response.body || '']
767
1071
  rescue Net::OpenTimeout, Net::ReadTimeout => e
768
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 forward class callback action.
186
- # @param class_callback [HttpClassCallback]
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