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.
- checksums.yaml +4 -4
- data/README.md +173 -1
- data/lib/mockserver/a2a.rb +529 -0
- data/lib/mockserver/binary_launcher.rb +31 -1
- data/lib/mockserver/client.rb +815 -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 +681 -11
- data/lib/mockserver/rspec.rb +56 -0
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver-client.rb +3 -0
- metadata +6 -2
|
@@ -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
|
-
|
|
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
|