Skip to main content

netget/client/openapi/
actions.rs

1//! OpenAPI client protocol actions implementation
2
3use 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
15/// OpenAPI client connected event
16pub 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
56/// OpenAPI operation response event
57pub 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
109/// OpenAPI client protocol action handler
110pub 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
124// Implement Protocol trait (common functionality)
125impl 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            // LLM mode: LLM controls OpenAPI operations
370            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            // Script mode: Code-based operation response handling
380            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            // Static mode: Fixed OpenAPI operation execution
397            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
432// Implement Client trait (client-specific functionality)
433impl 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            // Extract startup parameters into JSON for compatibility
443            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                // Return custom result with operation data
501                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                // This is handled by returning NoAction - the operation list is already sent
514                // in the connected event
515                Ok(ClientActionResult::NoAction)
516            }
517            "get_operation_details" => {
518                // For now, return NoAction - could enhance to return operation details
519                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}