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 +4 -4
- data/README.md +113 -1
- data/lib/mockserver/binary_launcher.rb +31 -1
- data/lib/mockserver/client.rb +311 -7
- 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 +427 -11
- data/lib/mockserver/rspec.rb +56 -0
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver-client.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1a47589dbc527bf772961f968c2235d9b64ebc13fd8a1f963e143e50319db9f
|
|
4
|
+
data.tar.gz: 85aa291fefefdce077d8f4eaca22a5fd56d473628ec819c2029fae9f8e91aa4e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/mockserver/client.rb
CHANGED
|
@@ -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
|
-
|
|
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'] =
|
|
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
|
-
|
|
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
|
|
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
|