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.
@@ -0,0 +1,529 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module MockServer
7
+ # Fluent builder for mocking an A2A (Agent-to-Agent) agent.
8
+ #
9
+ # Mirrors the Java +A2aMockBuilder+ (and its Node/Python siblings). It produces
10
+ # the same wire-level expectation JSON: a set of HTTP expectations that emulate
11
+ # an A2A agent serving an agent card and speaking JSON-RPC 2.0 over +POST
12
+ # <path>+. The generated control-plane expectations are:
13
+ #
14
+ # * agent-card +GET <agent_card_path>+ (default +/.well-known/agent.json+);
15
+ # * JSON-RPC +tasks/send+, +tasks/get+, +tasks/cancel+;
16
+ # * optional SSE streaming (+with_streaming+);
17
+ # * optional push-notification config + delivery (+with_push_notifications+);
18
+ # * optional per-message custom task handlers (+on_task_send+);
19
+ # * optional advertised skills (+with_skill+).
20
+ #
21
+ # Each JSON-RPC response is a Velocity template that echoes the incoming
22
+ # JSON-RPC id back via +$!{request.jsonRpcRawId}+.
23
+ #
24
+ # @example
25
+ # MockServer::A2A.a2a_mock('/a2a')
26
+ # .with_agent_name('WeatherAgent')
27
+ # .with_skill('weather')
28
+ # .with_name('Weather lookup')
29
+ # .with_tag('forecast')
30
+ # .and_then
31
+ # .on_task_send
32
+ # .matching_message('forecast')
33
+ # .responding_with('Sunny, 25C')
34
+ # .and_then
35
+ # .apply_to(client)
36
+ module A2A
37
+ # JSON-escape a string the same way Jackson +writeValueAsString+ does, then
38
+ # strip the surrounding quotes — yielding only the escaped inner content.
39
+ # @api private
40
+ def self.escape_json(value)
41
+ return '' if value.nil?
42
+
43
+ quoted = JSON.generate(value.to_s)
44
+ quoted[1...-1]
45
+ end
46
+
47
+ # Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
48
+ # @api private
49
+ def self.escape_velocity(value)
50
+ return value if value.nil?
51
+
52
+ value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
53
+ end
54
+
55
+ # @api private
56
+ def self.velocity_json_rpc_response(result_json)
57
+ '{"statusCode": 200, ' \
58
+ '"headers": [{"name": "Content-Type", "values": ["application/json"]}], ' \
59
+ '"body": {"jsonrpc": "2.0", "result": ' + result_json + ', "id": $!{request.jsonRpcRawId}}}'
60
+ end
61
+
62
+ # @api private
63
+ # Wraps a Hash so it responds to +to_h+, allowing it to be passed to
64
+ # +Client#upsert+.
65
+ class RawExpectation
66
+ def initialize(hash)
67
+ @hash = hash
68
+ end
69
+
70
+ def to_h
71
+ @hash
72
+ end
73
+ end
74
+
75
+ # @api private
76
+ SkillDef = Struct.new(:id, :name, :description, :tags, :examples)
77
+ # @api private
78
+ TaskHandler = Struct.new(:message_pattern, :response_text, :is_error)
79
+
80
+ # Resolved components of a push-notification webhook URL.
81
+ # @api private
82
+ WebhookTarget = Struct.new(:host, :port, :secure, :path) do
83
+ def host_header
84
+ "#{host}:#{port}"
85
+ end
86
+
87
+ def self.parse(url)
88
+ uri = URI.parse(url)
89
+ secure = uri.scheme.to_s.downcase == 'https'
90
+ host = uri.host
91
+ raise ArgumentError, "Invalid push-notification webhook URL (no host): #{url}" if host.nil? || host.empty?
92
+
93
+ port = uri.port || (secure ? 443 : 80)
94
+ path = uri.path
95
+ path = '/' if path.nil? || path.empty?
96
+ new(host, port, secure, path)
97
+ end
98
+ end
99
+
100
+ class A2aMockBuilder
101
+ def initialize(path = '/a2a')
102
+ @path = path.is_a?(String) ? path : '/a2a'
103
+ @agent_card_path = '/.well-known/agent.json'
104
+ @agent_name = 'MockAgent'
105
+ @agent_description = 'A mock A2A agent'
106
+ @agent_version = '1.0.0'
107
+ @agent_url = nil
108
+ @skills = []
109
+ @task_handlers = []
110
+ @default_task_response = 'Task completed successfully'
111
+ @streaming = false
112
+ @streaming_method = 'message/stream'
113
+ @push_notification_url = nil
114
+ end
115
+
116
+ # @return [self]
117
+ def with_agent_name(name)
118
+ @agent_name = name
119
+ self
120
+ end
121
+
122
+ # @return [self]
123
+ def with_agent_description(description)
124
+ @agent_description = description
125
+ self
126
+ end
127
+
128
+ # @return [self]
129
+ def with_agent_version(version)
130
+ @agent_version = version
131
+ self
132
+ end
133
+
134
+ # @return [self]
135
+ def with_agent_url(url)
136
+ @agent_url = url
137
+ self
138
+ end
139
+
140
+ # @return [self]
141
+ def with_agent_card_path(path)
142
+ @agent_card_path = path
143
+ self
144
+ end
145
+
146
+ # @return [self]
147
+ def with_default_task_response(response)
148
+ @default_task_response = response
149
+ self
150
+ end
151
+
152
+ # Advertise + mock the A2A streaming capability. The agent card reports
153
+ # +capabilities.streaming: true+ and the streaming JSON-RPC method (default
154
+ # +message/stream+) returns an SSE stream of status/artifact update events.
155
+ # @return [self]
156
+ def with_streaming
157
+ @streaming = true
158
+ self
159
+ end
160
+
161
+ # Override the JSON-RPC method that triggers the streaming response.
162
+ # Implies {#with_streaming}.
163
+ # @return [self]
164
+ def with_streaming_method(method)
165
+ @streaming_method = method
166
+ @streaming = true
167
+ self
168
+ end
169
+
170
+ # Advertise + mock A2A push notifications. The agent card reports
171
+ # +capabilities.pushNotifications: true+, +tasks/pushNotificationConfig/set+
172
+ # echoes the registered config, and each +tasks/send+ additionally POSTs the
173
+ # completed task to +webhook_url+ while still returning the JSON-RPC task
174
+ # response to the caller.
175
+ # @return [self]
176
+ def with_push_notifications(webhook_url)
177
+ @push_notification_url = webhook_url
178
+ self
179
+ end
180
+
181
+ # @return [A2aSkillBuilder]
182
+ def with_skill(id)
183
+ A2aSkillBuilder.new(self, id)
184
+ end
185
+
186
+ # @return [A2aTaskHandlerBuilder]
187
+ def on_task_send
188
+ A2aTaskHandlerBuilder.new(self)
189
+ end
190
+
191
+ # @api private
192
+ def add_skill(skill)
193
+ @skills << skill
194
+ end
195
+
196
+ # @api private
197
+ def add_task_handler(handler)
198
+ @task_handlers << handler
199
+ end
200
+
201
+ # @return [Array<Hash>] the ordered list of expectations
202
+ def build
203
+ expectations = [build_agent_card_expectation]
204
+
205
+ @task_handlers.each { |handler| expectations << build_custom_task_handler(handler) }
206
+
207
+ expectations << build_streaming_expectation if @streaming
208
+
209
+ if @push_notification_url
210
+ expectations << build_push_notification_config_expectation
211
+ expectations << build_push_notification_delivery_expectation
212
+ else
213
+ expectations << build_tasks_send_expectation
214
+ end
215
+ expectations << build_tasks_get_expectation
216
+ expectations << build_tasks_cancel_expectation
217
+
218
+ expectations
219
+ end
220
+
221
+ # @return [Array<Expectation>]
222
+ def apply_to(client)
223
+ client.upsert(*build.map { |h| RawExpectation.new(h) })
224
+ end
225
+
226
+ private
227
+
228
+ def json_rpc_request(method)
229
+ { 'method' => 'POST', 'path' => @path, 'body' => { 'type' => 'JSON_RPC', 'method' => method } }
230
+ end
231
+
232
+ def json_path_request(json_path)
233
+ { 'method' => 'POST', 'path' => @path, 'body' => { 'type' => 'JSON_PATH', 'jsonPath' => json_path } }
234
+ end
235
+
236
+ def velocity_template_response_expectation(http_request, result_json)
237
+ {
238
+ 'httpRequest' => http_request,
239
+ 'httpResponseTemplate' => {
240
+ 'template' => A2A.velocity_json_rpc_response(result_json),
241
+ 'templateType' => 'VELOCITY'
242
+ }
243
+ }
244
+ end
245
+
246
+ def build_agent_card_expectation
247
+ skill_items = @skills.map do |skill|
248
+ parts = ['{"id": "' + esc_plain(skill.id) + '"']
249
+ parts << ', "name": "' + esc_plain(skill.name || skill.id) + '"'
250
+ parts << ', "description": "' + esc_plain(skill.description) + '"' unless skill.description.nil?
251
+ unless skill.tags.empty?
252
+ parts << ', "tags": [' + skill.tags.map { |t| '"' + esc_plain(t) + '"' }.join(', ') + ']'
253
+ end
254
+ unless skill.examples.empty?
255
+ parts << ', "examples": [' + skill.examples.map { |e| '"' + esc_plain(e) + '"' }.join(', ') + ']'
256
+ end
257
+ parts << '}'
258
+ parts.join
259
+ end
260
+ skills_json = '[' + skill_items.join(', ') + ']'
261
+
262
+ url = @agent_url || ('http://localhost' + @path)
263
+
264
+ agent_card_json =
265
+ '{' \
266
+ '"name": "' + esc_plain(@agent_name) + '", ' \
267
+ '"description": "' + esc_plain(@agent_description) + '", ' \
268
+ '"version": "' + esc_plain(@agent_version) + '", ' \
269
+ '"url": "' + esc_plain(url) + '", ' \
270
+ '"capabilities": {"streaming": ' + bool(@streaming) + ', ' \
271
+ '"pushNotifications": ' + bool(!@push_notification_url.nil?) + ', ' \
272
+ '"stateTransitionHistory": false}, ' \
273
+ '"skills": ' + skills_json + '}'
274
+
275
+ {
276
+ 'httpRequest' => { 'method' => 'GET', 'path' => @agent_card_path },
277
+ 'httpResponse' => {
278
+ 'statusCode' => 200,
279
+ 'headers' => [{ 'name' => 'Content-Type', 'values' => ['application/json'] }],
280
+ 'body' => agent_card_json
281
+ }
282
+ }
283
+ end
284
+
285
+ def build_tasks_send_expectation
286
+ velocity_template_response_expectation(
287
+ json_rpc_request('tasks/send'),
288
+ task_result_json(@default_task_response, false)
289
+ )
290
+ end
291
+
292
+ def build_tasks_get_expectation
293
+ velocity_template_response_expectation(
294
+ json_rpc_request('tasks/get'),
295
+ task_result_json(@default_task_response, false)
296
+ )
297
+ end
298
+
299
+ def build_tasks_cancel_expectation
300
+ result_json = '{"id": "mock-task-id", "status": {"state": "canceled"}}'
301
+ velocity_template_response_expectation(json_rpc_request('tasks/cancel'), result_json)
302
+ end
303
+
304
+ def build_custom_task_handler(handler)
305
+ escaped_pattern = escape_message_pattern(handler.message_pattern)
306
+ json_path = "$[?(@.method == 'tasks/send' && @.params.message.parts[0].text =~ /" + escaped_pattern + '/)]'
307
+ velocity_template_response_expectation(
308
+ json_path_request(json_path),
309
+ task_result_json(handler.response_text, handler.is_error)
310
+ )
311
+ end
312
+
313
+ # Neutralize only the regex-delimiter breakout while preserving every
314
+ # existing regex escape sequence. The user-supplied message_pattern is
315
+ # documented as a regular expression and is embedded between `/.../`
316
+ # delimiters inside a JSONPath. A single-pass scanner is required (rather
317
+ # than independent gsubs) so that a backslash and the character it escapes
318
+ # are consumed together — otherwise a trailing lone backslash, or an input
319
+ # containing `\/`, could escape the closing `/` delimiter and break out of
320
+ # the regex literal into the surrounding JSONPath/JSON.
321
+ # (CodeQL rb/incomplete-sanitization, alert #65.)
322
+ def escape_message_pattern(pattern)
323
+ chars = pattern.to_s.chars
324
+ out = +''
325
+ i = 0
326
+ while i < chars.length
327
+ c = chars[i]
328
+ case c
329
+ when '\\'
330
+ if i + 1 < chars.length
331
+ out << '\\' << chars[i + 1] # preserve escape sequence (\d, \/, \\)
332
+ i += 1 # extra advance past the escaped char
333
+ else
334
+ out << '\\\\' # trailing lone backslash -> literal backslash
335
+ end
336
+ when '/' then out << '\\/'
337
+ when "\n" then out << '\\n'
338
+ when "\r" then out << '\\r'
339
+ when "\0" then nil # strip NUL
340
+ else out << c
341
+ end
342
+ i += 1
343
+ end
344
+ out
345
+ end
346
+
347
+ def build_streaming_expectation
348
+ text = A2A.escape_json(@default_task_response)
349
+ task_id = 'mock-task-id'
350
+
351
+ events = [
352
+ { 'event' => 'message',
353
+ 'data' => '{"jsonrpc": "2.0", "id": "1", "result": ' \
354
+ '{"taskId": "' + task_id + '", "kind": "status-update", ' \
355
+ '"status": {"state": "working"}, "final": false}}' },
356
+ { 'event' => 'message',
357
+ 'data' => '{"jsonrpc": "2.0", "id": "1", "result": ' \
358
+ '{"taskId": "' + task_id + '", "kind": "artifact-update", ' \
359
+ '"artifact": {"parts": [{"type": "text", "text": "' + text + '"}]}}}' },
360
+ { 'event' => 'message',
361
+ 'data' => '{"jsonrpc": "2.0", "id": "1", "result": ' \
362
+ '{"taskId": "' + task_id + '", "kind": "status-update", ' \
363
+ '"status": {"state": "completed"}, "final": true}}' }
364
+ ]
365
+
366
+ {
367
+ 'httpRequest' => json_rpc_request(@streaming_method),
368
+ 'httpSseResponse' => {
369
+ 'statusCode' => 200,
370
+ 'events' => events,
371
+ 'closeConnection' => true
372
+ }
373
+ }
374
+ end
375
+
376
+ def build_push_notification_config_expectation
377
+ # Echo the registered push-notification config back as the JSON-RPC result.
378
+ result_json = '{"url": "' + A2A.escape_velocity(A2A.escape_json(@push_notification_url)) + '"}'
379
+ velocity_template_response_expectation(
380
+ json_rpc_request('tasks/pushNotificationConfig/set'),
381
+ result_json
382
+ )
383
+ end
384
+
385
+ def build_push_notification_delivery_expectation
386
+ # A tasks/send both returns the JSON-RPC task response to the caller AND
387
+ # POSTs the completed task to the configured webhook. Modelled with an
388
+ # override-forwarded-request: the request override targets the webhook
389
+ # (literal body, JSON-escaped only — no Velocity engine runs over a request
390
+ # override), and a Velocity response *template* produces the caller's
391
+ # JSON-RPC response so the request's id is echoed back.
392
+ target = WebhookTarget.parse(@push_notification_url)
393
+
394
+ push_body = '{"jsonrpc": "2.0", "result": ' + task_result_json_raw(@default_task_response, false) + '}'
395
+
396
+ webhook_request = {
397
+ 'method' => 'POST',
398
+ 'path' => target.path,
399
+ 'socketAddress' => {
400
+ 'host' => target.host,
401
+ 'port' => target.port,
402
+ 'scheme' => target.secure ? 'HTTPS' : 'HTTP'
403
+ },
404
+ 'secure' => target.secure,
405
+ 'headers' => [
406
+ { 'name' => 'Host', 'values' => [target.host_header] },
407
+ { 'name' => 'Content-Type', 'values' => ['application/json'] }
408
+ ],
409
+ 'body' => push_body
410
+ }
411
+
412
+ {
413
+ 'httpRequest' => json_rpc_request('tasks/send'),
414
+ 'httpOverrideForwardedRequest' => {
415
+ 'requestOverride' => webhook_request,
416
+ 'responseTemplate' => {
417
+ 'template' => A2A.velocity_json_rpc_response(task_result_json(@default_task_response, false)),
418
+ 'templateType' => 'VELOCITY'
419
+ }
420
+ }
421
+ }
422
+ end
423
+
424
+ # Velocity-templated result body: text must survive the Velocity engine, so
425
+ # both JSON and Velocity escaping are applied (Velocity un-escapes at render).
426
+ def task_result_json(response_text, is_error)
427
+ task_result_json_with_text(A2A.escape_velocity(A2A.escape_json(response_text)), is_error)
428
+ end
429
+
430
+ # Literal (non-templated) result body (e.g. the webhook payload): only JSON
431
+ # escaping — Velocity escaping would corrupt '$' / '#'.
432
+ def task_result_json_raw(response_text, is_error)
433
+ task_result_json_with_text(A2A.escape_json(response_text), is_error)
434
+ end
435
+
436
+ def task_result_json_with_text(escaped_text, is_error)
437
+ state = is_error ? 'failed' : 'completed'
438
+ '{"id": "mock-task-id", ' \
439
+ '"status": {"state": "' + state + '"}, ' \
440
+ '"artifacts": [{"parts": [{"type": "text", "text": "' + escaped_text + '"}]}]}'
441
+ end
442
+
443
+ def bool(value)
444
+ value ? 'true' : 'false'
445
+ end
446
+
447
+ # JSON-escape then Velocity-escape, matching the reference clients (used for
448
+ # the agent card, which is a literal — but kept Velocity-safe for parity with
449
+ # the Java builder's escapeJson-only card; here the card is a literal HTTP
450
+ # response body, so only JSON escaping is required).
451
+ def esc_plain(value)
452
+ A2A.escape_json(value)
453
+ end
454
+ end
455
+
456
+ class A2aSkillBuilder
457
+ def initialize(parent, id)
458
+ @parent = parent
459
+ @skill = SkillDef.new(id, nil, nil, [], [])
460
+ end
461
+
462
+ # @return [self]
463
+ def with_name(name)
464
+ @skill.name = name
465
+ self
466
+ end
467
+
468
+ # @return [self]
469
+ def with_description(description)
470
+ @skill.description = description
471
+ self
472
+ end
473
+
474
+ # @return [self]
475
+ def with_tag(tag)
476
+ @skill.tags << tag
477
+ self
478
+ end
479
+
480
+ # @return [self]
481
+ def with_example(example)
482
+ @skill.examples << example
483
+ self
484
+ end
485
+
486
+ # Commit the skill and return to the root builder.
487
+ # @return [A2aMockBuilder]
488
+ def and_then
489
+ @parent.add_skill(@skill)
490
+ @parent
491
+ end
492
+ alias and_ and_then
493
+ end
494
+
495
+ class A2aTaskHandlerBuilder
496
+ def initialize(parent)
497
+ @parent = parent
498
+ @handler = TaskHandler.new('.*', 'Task completed', false)
499
+ end
500
+
501
+ # @return [self]
502
+ def matching_message(pattern)
503
+ @handler.message_pattern = pattern
504
+ self
505
+ end
506
+
507
+ # @return [self]
508
+ def responding_with(text, is_error = false)
509
+ @handler.response_text = text
510
+ @handler.is_error = is_error
511
+ self
512
+ end
513
+
514
+ # Commit the handler and return to the root builder.
515
+ # @return [A2aMockBuilder]
516
+ def and_then
517
+ @parent.add_task_handler(@handler)
518
+ @parent
519
+ end
520
+ alias and_ and_then
521
+ end
522
+
523
+ # Create a new A2A mock builder. +path+ defaults to +/a2a+.
524
+ # @return [A2aMockBuilder]
525
+ def self.a2a_mock(path = '/a2a')
526
+ A2aMockBuilder.new(path)
527
+ end
528
+ end
529
+ end
@@ -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