1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15pub static OPENAPI_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17 EventType::new(
18 "openapi_client_connected",
19 "OpenAPI client initialized with specification loaded",
20 json!({"type": "execute_operation", "operation_id": "listUsers", "path_params": {}, "query_params": {}}),
21 )
22 .with_parameters(vec![
23 Parameter {
24 name: "base_url".to_string(),
25 type_hint: "string".to_string(),
26 description: "Base URL for API requests".to_string(),
27 required: true,
28 },
29 Parameter {
30 name: "spec_title".to_string(),
31 type_hint: "string".to_string(),
32 description: "OpenAPI specification title".to_string(),
33 required: false,
34 },
35 Parameter {
36 name: "spec_version".to_string(),
37 type_hint: "string".to_string(),
38 description: "OpenAPI specification version".to_string(),
39 required: false,
40 },
41 Parameter {
42 name: "operation_count".to_string(),
43 type_hint: "number".to_string(),
44 description: "Number of operations in the spec".to_string(),
45 required: false,
46 },
47 Parameter {
48 name: "operations".to_string(),
49 type_hint: "array".to_string(),
50 description: "List of available operations with operation_id, method, path, summary".to_string(),
51 required: false,
52 },
53 ])
54});
55
56pub static OPENAPI_OPERATION_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58 EventType::new(
59 "openapi_operation_response",
60 "Response received from executing an OpenAPI operation",
61 json!({"type": "execute_operation", "operation_id": "getUser", "path_params": {"id": "123"}, "query_params": {}}),
62 )
63 .with_parameters(vec![
64 Parameter {
65 name: "operation_id".to_string(),
66 type_hint: "string".to_string(),
67 description: "Operation ID that was executed".to_string(),
68 required: true,
69 },
70 Parameter {
71 name: "method".to_string(),
72 type_hint: "string".to_string(),
73 description: "HTTP method used".to_string(),
74 required: true,
75 },
76 Parameter {
77 name: "path".to_string(),
78 type_hint: "string".to_string(),
79 description: "Path that was requested".to_string(),
80 required: true,
81 },
82 Parameter {
83 name: "status_code".to_string(),
84 type_hint: "number".to_string(),
85 description: "HTTP status code".to_string(),
86 required: true,
87 },
88 Parameter {
89 name: "status_text".to_string(),
90 type_hint: "string".to_string(),
91 description: "HTTP status text".to_string(),
92 required: true,
93 },
94 Parameter {
95 name: "headers".to_string(),
96 type_hint: "object".to_string(),
97 description: "Response headers".to_string(),
98 required: true,
99 },
100 Parameter {
101 name: "body".to_string(),
102 type_hint: "string".to_string(),
103 description: "Response body".to_string(),
104 required: true,
105 },
106 ])
107});
108
109pub struct OpenApiClientProtocol;
111
112impl Default for OpenApiClientProtocol {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118impl OpenApiClientProtocol {
119 pub fn new() -> Self {
120 Self
121 }
122}
123
124impl Protocol for OpenApiClientProtocol {
126 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
127 vec![
128 ParameterDefinition {
129 name: "spec".to_string(),
130 description: "OpenAPI 3.x specification in YAML or JSON format (inline)"
131 .to_string(),
132 type_hint: "string".to_string(),
133 required: false,
134 example: json!(
135 "openapi: 3.1.0\ninfo:\n title: My API\n version: 1.0.0\npaths:\n /users:\n get:\n operationId: listUsers\n responses:\n '200':\n description: List users"
136 ),
137 default: None,
138 },
139 #[cfg(not(target_arch = "wasm32"))]
140 ParameterDefinition {
141 name: "spec_file".to_string(),
142 description: "Path to OpenAPI specification file (YAML or JSON)".to_string(),
143 type_hint: "string".to_string(),
144 required: false,
145 example: json!("/path/to/openapi.yaml"),
146 default: None,
147 },
148 ParameterDefinition {
149 name: "base_url".to_string(),
150 description:
151 "Override base URL (default: first server in spec or http://remote_addr)"
152 .to_string(),
153 type_hint: "string".to_string(),
154 required: false,
155 example: json!("https://api.example.com"),
156 default: None,
157 },
158 ]
159 }
160
161 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
162 vec![
163 ActionDefinition {
164 name: "execute_operation".to_string(),
165 description: "Execute an OpenAPI operation by operation ID".to_string(),
166 parameters: vec![
167 Parameter {
168 name: "operation_id".to_string(),
169 type_hint: "string".to_string(),
170 description:
171 "Operation ID from OpenAPI spec (e.g., 'listUsers', 'createTodo')"
172 .to_string(),
173 required: true,
174 },
175 Parameter {
176 name: "path_params".to_string(),
177 type_hint: "object".to_string(),
178 description: "Path parameters (e.g., {\"id\": \"123\"} for /users/{id})"
179 .to_string(),
180 required: false,
181 },
182 Parameter {
183 name: "query_params".to_string(),
184 type_hint: "object".to_string(),
185 description:
186 "Query parameters (e.g., {\"page\": \"1\", \"limit\": \"10\"})"
187 .to_string(),
188 required: false,
189 },
190 Parameter {
191 name: "headers".to_string(),
192 type_hint: "object".to_string(),
193 description: "Override request headers (merged with spec defaults)"
194 .to_string(),
195 required: false,
196 },
197 Parameter {
198 name: "body".to_string(),
199 type_hint: "object".to_string(),
200 description: "Request body (JSON object matching spec schema)".to_string(),
201 required: false,
202 },
203 ],
204 example: json!({
205 "type": "execute_operation",
206 "operation_id": "listUsers",
207 "path_params": {},
208 "query_params": {"page": "1", "limit": "10"},
209 "headers": {"Authorization": "Bearer token123"},
210 "body": null
211 }),
212 log_template: None,
213 },
214 ActionDefinition {
215 name: "list_operations".to_string(),
216 description: "List all available operations from the OpenAPI spec".to_string(),
217 parameters: vec![],
218 example: json!({
219 "type": "list_operations"
220 }),
221 log_template: None,
222 },
223 ActionDefinition {
224 name: "get_operation_details".to_string(),
225 description: "Get detailed information about a specific operation".to_string(),
226 parameters: vec![Parameter {
227 name: "operation_id".to_string(),
228 type_hint: "string".to_string(),
229 description: "Operation ID to inspect".to_string(),
230 required: true,
231 }],
232 example: json!({
233 "type": "get_operation_details",
234 "operation_id": "listUsers"
235 }),
236 log_template: None,
237 },
238 ActionDefinition {
239 name: "disconnect".to_string(),
240 description: "Stop the OpenAPI client".to_string(),
241 parameters: vec![],
242 example: json!({
243 "type": "disconnect"
244 }),
245 log_template: None,
246 },
247 ]
248 }
249
250 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
251 vec![ActionDefinition {
252 name: "execute_operation".to_string(),
253 description: "Execute another OpenAPI operation in response to received data"
254 .to_string(),
255 parameters: vec![
256 Parameter {
257 name: "operation_id".to_string(),
258 type_hint: "string".to_string(),
259 description: "Operation ID from OpenAPI spec".to_string(),
260 required: true,
261 },
262 Parameter {
263 name: "path_params".to_string(),
264 type_hint: "object".to_string(),
265 description: "Path parameters".to_string(),
266 required: false,
267 },
268 Parameter {
269 name: "query_params".to_string(),
270 type_hint: "object".to_string(),
271 description: "Query parameters".to_string(),
272 required: false,
273 },
274 Parameter {
275 name: "headers".to_string(),
276 type_hint: "object".to_string(),
277 description: "Request headers".to_string(),
278 required: false,
279 },
280 Parameter {
281 name: "body".to_string(),
282 type_hint: "object".to_string(),
283 description: "Request body".to_string(),
284 required: false,
285 },
286 ],
287 example: json!({
288 "type": "execute_operation",
289 "operation_id": "createUser",
290 "body": {"name": "Alice", "email": "alice@example.com"}
291 }),
292 log_template: Some(
293 LogTemplate::new()
294 .with_info("-> OpenAPI operation {operation_id}")
295 .with_debug("OpenAPI execute_operation: operation_id={operation_id}"),
296 ),
297 }]
298 }
299
300 fn protocol_name(&self) -> &'static str {
301 "OpenAPI"
302 }
303
304 fn get_event_types(&self) -> Vec<EventType> {
305 vec![
306 EventType::new(
307 "openapi_client_connected",
308 "Triggered when OpenAPI client is initialized with spec",
309 json!({"type": "placeholder", "event_id": "openapi_client_connected"}),
310 ),
311 EventType::new(
312 "openapi_operation_response",
313 "Triggered when OpenAPI operation response is received",
314 json!({"type": "placeholder", "event_id": "openapi_operation_response"}),
315 ),
316 ]
317 }
318
319 fn stack_name(&self) -> &'static str {
320 "ETH>IP>TCP>HTTP>OPENAPI"
321 }
322
323 fn keywords(&self) -> Vec<&'static str> {
324 vec![
325 "openapi",
326 "openapi client",
327 "rest",
328 "rest api",
329 "api client",
330 "swagger",
331 ]
332 }
333
334 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
335 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
336
337 ProtocolMetadataV2::builder()
338 .state(DevelopmentState::Experimental)
339 .implementation("openapi-rs parser, reqwest HTTP client (HTTP/1.1, HTTP/2)")
340 .llm_control("Operation selection, parameter provision, spec-driven requests")
341 .e2e_testing(
342 "tests/client/openapi/. target_precedence_test pins that remote_addr wins \
343 over the spec's servers[0] - the spec must not be able to retarget the \
344 client - and that base_url overrides both; command_channel_test drives the \
345 injected-operation path against a loopback stub; e2e_test runs the real \
346 binary against a NetGet OpenAPI server. The peer is always netget or a stub \
347 written in the test, never a third-party OpenAPI implementation.",
348 )
349 .notes("Spec-driven request construction, path parameter substitution")
350 .build()
351 }
352
353 fn description(&self) -> &'static str {
354 "OpenAPI specification-driven HTTP client"
355 }
356
357 fn example_prompt(&self) -> &'static str {
358 "Connect to https://api.example.com with OpenAPI spec and test all operations"
359 }
360
361 fn group_name(&self) -> &'static str {
362 "AI & API"
363 }
364 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
365 use crate::llm::actions::StartupExamples;
366 use serde_json::json;
367
368 StartupExamples::new(
369 json!({
371 "type": "open_client",
372 "remote_addr": "https://api.example.com",
373 "base_stack": "openapi",
374 "instruction": "List available operations and test the listUsers endpoint",
375 "startup_params": {
376 "spec_file": "/path/to/openapi.yaml"
377 }
378 }),
379 json!({
381 "type": "open_client",
382 "remote_addr": "https://api.example.com",
383 "base_stack": "openapi",
384 "startup_params": {
385 "spec_file": "/path/to/openapi.yaml"
386 },
387 "event_handlers": [{
388 "event_pattern": "openapi_operation_response",
389 "handler": {
390 "type": "script",
391 "language": "python",
392 "code": "<openapi_client_handler>"
393 }
394 }]
395 }),
396 json!({
398 "type": "open_client",
399 "remote_addr": "https://api.example.com",
400 "base_stack": "openapi",
401 "startup_params": {
402 "spec_file": "/path/to/openapi.yaml"
403 },
404 "event_handlers": [
405 {
406 "event_pattern": "openapi_client_connected",
407 "handler": {
408 "type": "static",
409 "actions": [{
410 "type": "execute_operation",
411 "operation_id": "listUsers",
412 "path_params": {},
413 "query_params": {"page": "1", "limit": "10"}
414 }]
415 }
416 },
417 {
418 "event_pattern": "openapi_operation_response",
419 "handler": {
420 "type": "static",
421 "actions": [{
422 "type": "disconnect"
423 }]
424 }
425 }
426 ]
427 }),
428 )
429 }
430}
431
432impl Client for OpenApiClientProtocol {
434 fn connect(
435 &self,
436 ctx: crate::protocol::ConnectContext,
437 ) -> std::pin::Pin<
438 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
439 > {
440 Box::pin(async move {
441 use crate::client::openapi::OpenApiClient;
442 let mut params = serde_json::Map::new();
444 if let Some(sp) = &ctx.startup_params {
445 if let Some(spec) = sp.get_optional_string("spec")? {
446 params.insert("spec".to_string(), serde_json::json!(spec));
447 }
448 #[cfg(not(target_arch = "wasm32"))]
449 if let Some(spec_file) = sp.get_optional_string("spec_file")? {
450 params.insert("spec_file".to_string(), serde_json::json!(spec_file));
451 }
452 if let Some(base_url) = sp.get_optional_string("base_url")? {
453 params.insert("base_url".to_string(), serde_json::json!(base_url));
454 }
455 }
456 let startup_params = serde_json::Value::Object(params);
457
458 OpenApiClient::connect_with_llm_actions(
459 ctx.remote_addr,
460 ctx.llm_client,
461 ctx.state,
462 ctx.status_tx,
463 ctx.client_id,
464 startup_params,
465 )
466 .await
467 })
468 }
469
470 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
471 let action_type = action
472 .get("type")
473 .and_then(|v| v.as_str())
474 .context("Missing 'type' field in action")?;
475
476 match action_type {
477 "execute_operation" => {
478 let operation_id = action
479 .get("operation_id")
480 .and_then(|v| v.as_str())
481 .context("Missing 'operation_id' field")?
482 .to_string();
483
484 let path_params = action
485 .get("path_params")
486 .and_then(|v| v.as_object())
487 .cloned()
488 .unwrap_or_default();
489
490 let query_params = action
491 .get("query_params")
492 .and_then(|v| v.as_object())
493 .cloned()
494 .unwrap_or_default();
495
496 let headers = action.get("headers").and_then(|v| v.as_object()).cloned();
497
498 let body = action.get("body").cloned().unwrap_or(json!(null));
499
500 Ok(ClientActionResult::Custom {
502 name: "openapi_operation".to_string(),
503 data: json!({
504 "operation_id": operation_id,
505 "path_params": path_params,
506 "query_params": query_params,
507 "headers": headers,
508 "body": body,
509 }),
510 })
511 }
512 "list_operations" => {
513 Ok(ClientActionResult::NoAction)
516 }
517 "get_operation_details" => {
518 Ok(ClientActionResult::NoAction)
520 }
521 "disconnect" => Ok(ClientActionResult::Disconnect),
522 _ => Err(anyhow::anyhow!(
523 "Unknown OpenAPI client action: {}",
524 action_type
525 )),
526 }
527 }
528}