Skip to main content

netget/server/openapi/
actions.rs

1//! OpenAPI protocol actions for LLM integration
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{anyhow, Result};
11use serde_json::{json, Value as JsonValue};
12use std::sync::LazyLock;
13use tracing::{debug, error, warn};
14
15/// OpenAPI request event - triggered when client sends an HTTP request to OpenAPI server
16pub static OPENAPI_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "openapi_request",
19        "HTTP request received by OpenAPI server",
20        json!({
21            "type": "send_openapi_response",
22            "status_code": 200,
23            "headers": {"content-type": "application/json"},
24            "body": "[{\"id\": 1, \"title\": \"Buy milk\", \"done\": false}]"
25        }),
26    )
27    .with_alternative_example(json!({
28        "type": "send_validation_error",
29        "status_code": 400,
30        "message": "Field 'title' is required"
31    }))
32    // `call_llm` builds the model's tool list from `event.event_type.actions`, not from
33    // `get_sync_actions()`. With this list empty the model was told "No specific actions
34    // available for this event" and had no way to answer a request at all.
35    .with_actions(OpenApiProtocol.get_sync_actions())
36    .with_parameters(vec![
37        Parameter {
38            name: "method".to_string(),
39            type_hint: "string".to_string(),
40            description: "HTTP method (GET, POST, etc.)".to_string(),
41            required: true,
42        },
43        Parameter {
44            name: "path".to_string(),
45            type_hint: "string".to_string(),
46            description: "Request path".to_string(),
47            required: true,
48        },
49        Parameter {
50            name: "uri".to_string(),
51            type_hint: "string".to_string(),
52            description: "Full request URI".to_string(),
53            required: true,
54        },
55        Parameter {
56            name: "headers".to_string(),
57            type_hint: "object".to_string(),
58            description: "Request headers as key-value pairs".to_string(),
59            required: false,
60        },
61        Parameter {
62            name: "body".to_string(),
63            type_hint: "string".to_string(),
64            description: "Request body".to_string(),
65            required: false,
66        },
67        Parameter {
68            name: "spec_info".to_string(),
69            type_hint: "object".to_string(),
70            description: "Information about loaded OpenAPI spec".to_string(),
71            required: false,
72        },
73        Parameter {
74            name: "matched_route".to_string(),
75            type_hint: "object".to_string(),
76            description: "Matched route information (only present if route matched). Contains: operation_id, path_template, path_params (extracted parameters like {id}), and operation (full OpenAPI operation spec)".to_string(),
77            required: false,
78        },
79    ])
80});
81
82/// Read a model-supplied HTTP status, refusing anything that is not one.
83///
84/// This was `as_i64()? as u16`, which narrows in silence in both directions: `65736` is
85/// `200` and `-1` is `65535`. `send_validation_error` is how the model reports that a request
86/// failed schema validation, so the one field carrying that refusal could arrive as the
87/// success it was refusing. Refusing beats clamping — a clamp to 599 is not what the model
88/// asked for either, and the message is what the repair loop reads.
89fn required_status(action: &serde_json::Value, action_name: &str) -> Result<u16> {
90    let value = action
91        .get("status_code")
92        .ok_or_else(|| anyhow!("Missing status_code parameter for {action_name}"))?;
93    let raw = value
94        .as_i64()
95        .ok_or_else(|| anyhow!("{action_name} status_code must be a number, got {value}"))?;
96    if !(100..=599).contains(&raw) {
97        return Err(anyhow!(
98            "{action_name} status_code {raw} is not an HTTP status code; use 100-599 \
99             (400 for a bad request, 404 for an unmatched route, 500 for a server fault)"
100        ));
101    }
102    Ok(raw as u16)
103}
104
105/// OpenAPI protocol implementation
106#[derive(Clone)]
107pub struct OpenApiProtocol;
108
109impl OpenApiProtocol {
110    pub fn new() -> Self {
111        Self
112    }
113}
114
115impl Default for OpenApiProtocol {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121// Implement Protocol trait (common functionality)
122impl Protocol for OpenApiProtocol {
123    fn protocol_name(&self) -> &'static str {
124        "OpenAPI"
125    }
126    fn stack_name(&self) -> &'static str {
127        "ETH>IP>TCP>HTTP>OPENAPI"
128    }
129    fn keywords(&self) -> Vec<&'static str> {
130        vec!["openapi", "rest", "rest api", "api", "swagger"]
131    }
132    fn get_event_types(&self) -> Vec<EventType> {
133        vec![OPENAPI_REQUEST_EVENT.clone()]
134    }
135    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
136        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
137
138        ProtocolMetadataV2::builder()
139            .state(DevelopmentState::Experimental)
140            .implementation("openapi-rs parser, matchit router, hyper HTTP")
141            .llm_control("All API responses, spec compliance/violations")
142            .e2e_testing("reqwest HTTP client")
143            .notes("Dynamic spec loading, intentional violations for testing")
144            .max_inbound_bytes(crate::server::openapi::MAX_REQUEST_BODY_BYTES)
145            .request_only("An OpenAPI service is HTTP request/response; a server cannot send a peer anything unprompted")
146            .build()
147    }
148    fn description(&self) -> &'static str {
149        "OpenAPI specification server"
150    }
151    fn example_prompt(&self) -> &'static str {
152        "Start an OpenAPI server for a TODO API on port 8080"
153    }
154    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
155        vec![
156                ActionDefinition {
157                    name: "reload_spec".to_string(),
158                    description: "Reload or update the OpenAPI specification at runtime".to_string(),
159                    parameters: vec![
160                        Parameter {
161                            name: "spec".to_string(),
162                            type_hint: "string".to_string(),
163                            description: "OpenAPI 3.1 specification in YAML or JSON format".to_string(),
164                            required: true,
165                        },
166                    ],
167                    example: serde_json::json!({"type": "reload_spec", "spec": "openapi: 3.1.0\ninfo:\n  title: My API\n  version: 1.0.0\npaths:\n  /users:\n    get:\n      responses:\n        '200':\n          description: List users"}),
168                log_template: Some(
169                    LogTemplate::new()
170                        .with_info("-> OpenAPI spec reloaded")
171                        .with_debug("OpenAPI reload_spec: spec_len={spec_len}"),
172                ),
173                },
174                ActionDefinition {
175                    name: "get_spec_info".to_string(),
176                    description: "Get summary information about the loaded OpenAPI specification".to_string(),
177                    parameters: vec![],
178                    example: serde_json::json!({"type": "get_spec_info"}),
179                log_template: Some(
180                    LogTemplate::new()
181                        .with_info("-> OpenAPI spec info requested")
182                        .with_debug("OpenAPI get_spec_info"),
183                ),
184                },
185                ActionDefinition {
186                    name: "configure_error_handling".to_string(),
187                    description: "Configure whether to ask LLM for invalid requests (404/405/400). By default, these errors are handled immediately without LLM. Enable this to let LLM customize error responses for honeypot purposes.".to_string(),
188                    parameters: vec![
189                        Parameter {
190                            name: "llm_on_invalid".to_string(),
191                            type_hint: "boolean".to_string(),
192                            description: "If true, ask LLM for 404/405/400 errors. If false (default), return immediate RFC-compliant error responses.".to_string(),
193                            required: true,
194                        },
195                    ],
196                    example: serde_json::json!({"type": "configure_error_handling", "llm_on_invalid": false}),
197                log_template: Some(
198                    LogTemplate::new()
199                        .with_info("-> OpenAPI error handling: llm_on_invalid={llm_on_invalid}")
200                        .with_debug("OpenAPI configure_error_handling: llm_on_invalid={llm_on_invalid}"),
201                ),
202                },
203            ]
204    }
205    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
206        vec![
207                ActionDefinition {
208                    name: "provide_openapi_spec".to_string(),
209                    description: "Provide the OpenAPI specification (used during server startup)".to_string(),
210                    parameters: vec![
211                        Parameter {
212                            name: "spec".to_string(),
213                            type_hint: "string".to_string(),
214                            description: "OpenAPI 3.1 specification in YAML or JSON format".to_string(),
215                            required: true,
216                        },
217                    ],
218                    example: serde_json::json!({"type": "provide_openapi_spec", "spec": "openapi: 3.1.0\ninfo:\n  title: TODO API\n  version: 1.0.0\npaths:\n  /todos:\n    get:\n      operationId: listTodos\n      responses:\n        '200':\n          description: List of todos\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  type: object\n                  properties:\n                    id:\n                      type: integer\n                    title:\n                      type: string\n                    done:\n                      type: boolean"}),
219                log_template: Some(
220                    LogTemplate::new()
221                        .with_info("-> OpenAPI spec provided")
222                        .with_debug("OpenAPI provide_openapi_spec: spec_len={spec_len}"),
223                ),
224                },
225                ActionDefinition {
226                    name: "send_openapi_response".to_string(),
227                    description: "Send an HTTP response for an OpenAPI request".to_string(),
228                    parameters: vec![
229                        Parameter {
230                            name: "status_code".to_string(),
231                            type_hint: "number".to_string(),
232                            description: "HTTP status code (e.g., 200, 404, 500)".to_string(),
233                            required: true,
234                        },
235                        Parameter {
236                            name: "headers".to_string(),
237                            type_hint: "object".to_string(),
238                            description: "HTTP response headers as key-value pairs".to_string(),
239                            required: false,
240                        },
241                        Parameter {
242                            name: "body".to_string(),
243                            type_hint: "string".to_string(),
244                            description: "Response body (JSON, XML, plain text, etc.)".to_string(),
245                            required: false,
246                        },
247                        Parameter {
248                            name: "spec_compliant".to_string(),
249                            type_hint: "boolean".to_string(),
250                            description: "Set false to record that this response deliberately violates the loaded spec (for testing how a client handles a misbehaving server). Only affects logging; the response is sent either way. Default true.".to_string(),
251                            required: false,
252                        },
253                    ],
254                    example: serde_json::json!({"type": "send_openapi_response", "status_code": 200, "headers": {"content-type": "application/json"}, "body": "[{\"id\": 1, \"title\": \"Buy milk\", \"done\": false}]"}),
255                log_template: Some(
256                    LogTemplate::new()
257                        .with_info("-> OpenAPI {status_code} ({body_len}B)")
258                        .with_debug("OpenAPI send_openapi_response: status={status_code} body_len={body_len}"),
259                ),
260                },
261                ActionDefinition {
262                    name: "send_validation_error".to_string(),
263                    description: "Send an HTTP error response for invalid requests (400, 405, 415, etc.)".to_string(),
264                    parameters: vec![
265                        Parameter {
266                            name: "status_code".to_string(),
267                            type_hint: "number".to_string(),
268                            description: "HTTP error status code (400=Bad Request, 405=Method Not Allowed, 415=Unsupported Media Type, etc.)".to_string(),
269                            required: true,
270                        },
271                        Parameter {
272                            name: "message".to_string(),
273                            type_hint: "string".to_string(),
274                            description: "Error message explaining the validation failure".to_string(),
275                            required: true,
276                        },
277                    ],
278                    example: serde_json::json!({"type": "send_validation_error", "status_code": 405, "message": "Method GET not allowed for path /users, expected POST"}),
279                log_template: Some(
280                    LogTemplate::new()
281                        .with_info("-> OpenAPI {status_code}: {message}")
282                        .with_debug("OpenAPI send_validation_error: status={status_code} message={message}"),
283                ),
284                },
285            ]
286    }
287    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
288        use crate::llm::actions::ParameterDefinition;
289        // `spec_file` used to be declared here (and documented on
290        // `OpenApiServer::spawn_with_llm_actions`) but nothing ever read it: spawn only looks
291        // at `spec`, and errors out with "requires 'spec' parameter" when it is absent. A
292        // caller passing only `spec_file` therefore got a hard startup failure. Removed
293        // rather than implemented — the server deliberately wants inline content, and giving
294        // model-supplied input an arbitrary-file-read into an auth-adjacent server is not a
295        // trade worth making. Read the file and pass its contents as `spec`.
296        vec![ParameterDefinition {
297            name: "spec".to_string(),
298            type_hint: "string".to_string(),
299            description:
300                "OpenAPI 3.x specification in YAML or JSON format. Inline content only — to serve \
301                 a spec that lives on disk, read the file and pass its contents here."
302                    .to_string(),
303            required: false,
304            example: serde_json::json!("openapi: 3.1.0\ninfo:\n  title: My API\n  version: 1.0.0"),
305            default: None,
306        }]
307    }
308    fn group_name(&self) -> &'static str {
309        "AI & API"
310    }
311    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
312        use crate::llm::actions::StartupExamples;
313
314        StartupExamples::new(
315            // LLM mode: instruction-based
316            json!({
317                "type": "open_server",
318                "port": 8080,
319                "base_stack": "openapi",
320                "instruction": "OpenAPI server for TODO API. GET /todos returns list, POST /todos creates item, GET /todos/{id} returns single item",
321                "startup_params": {
322                    "spec": "openapi: 3.1.0\ninfo:\n  title: TODO API\n  version: 1.0.0\npaths:\n  /todos:\n    get:\n      operationId: listTodos\n      responses:\n        '200':\n          description: List of todos"
323                }
324            }),
325            // Script mode: event_handlers with script handler
326            json!({
327                "type": "open_server",
328                "port": 8080,
329                "base_stack": "openapi",
330                "startup_params": {
331                    "spec": "openapi: 3.1.0\ninfo:\n  title: API\n  version: 1.0.0\npaths:\n  /items:\n    get:\n      operationId: listItems\n      responses:\n        '200':\n          description: OK"
332                },
333                "event_handlers": [{
334                    "event_pattern": "openapi_request",
335                    "handler": {
336                        "type": "script",
337                        "language": "python",
338                        "code": "path = event.get('path', '')\nif path == '/items':\n    action('send_openapi_response', status_code=200, headers={'content-type': 'application/json'}, body='[{\"id\": 1}]')\nelse:\n    action('send_openapi_response', status_code=404, body='{\"error\": \"Not found\"}')"
339                    }
340                }]
341            }),
342            // Static mode: event_handlers with static actions
343            json!({
344                "type": "open_server",
345                "port": 8080,
346                "base_stack": "openapi",
347                "startup_params": {
348                    "spec": "openapi: 3.1.0\ninfo:\n  title: Health API\n  version: 1.0.0\npaths:\n  /health:\n    get:\n      operationId: healthCheck\n      responses:\n        '200':\n          description: OK"
349                },
350                "event_handlers": [{
351                    "event_pattern": "openapi_request",
352                    "handler": {
353                        "type": "static",
354                        "actions": [{
355                            "type": "send_openapi_response",
356                            "status_code": 200,
357                            "headers": {"content-type": "application/json"},
358                            "body": "{\"status\": \"healthy\"}"
359                        }]
360                    }
361                }]
362            }),
363        )
364    }
365}
366
367// Implement Server trait (server-specific functionality)
368impl Server for OpenApiProtocol {
369    fn spawn(
370        &self,
371        ctx: crate::protocol::SpawnContext,
372    ) -> std::pin::Pin<
373        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
374    > {
375        Box::pin(async move {
376            use crate::server::openapi::OpenApiServer;
377            OpenApiServer::spawn_with_llm_actions(
378                ctx.legacy_listen_addr(),
379                ctx.llm_client,
380                ctx.state,
381                ctx.status_tx,
382                ctx.server_id,
383                ctx.startup_params,
384            )
385            .await
386        })
387    }
388    fn execute_action(&self, action: JsonValue) -> Result<ActionResult> {
389        let action_type = action["type"]
390            .as_str()
391            .ok_or_else(|| anyhow!("Missing action type"))?;
392
393        match action_type {
394            "provide_openapi_spec" => {
395                let spec = action["spec"]
396                    .as_str()
397                    .ok_or_else(|| anyhow!("Missing spec parameter"))?;
398
399                debug!("OpenAPI spec provided: {} bytes", spec.len());
400
401                Ok(ActionResult::Custom {
402                    name: "load_openapi_spec".to_string(),
403                    data: serde_json::json!({
404                        "spec": spec
405                    }),
406                })
407            }
408            "send_openapi_response" => {
409                let status_code = required_status(&action, "send_openapi_response")?;
410
411                let headers = action["headers"].as_object().cloned().unwrap_or_default();
412
413                let body = action["body"].as_str().unwrap_or("").to_string();
414
415                let spec_compliant = action["spec_compliant"].as_bool().unwrap_or(true);
416
417                let compliance_status = if spec_compliant {
418                    "compliant"
419                } else {
420                    "non-compliant (intentional)"
421                };
422                debug!(
423                    "OpenAPI response: {} {} bytes, spec: {}",
424                    status_code,
425                    body.len(),
426                    compliance_status
427                );
428
429                Ok(ActionResult::Custom {
430                    name: "send_openapi_response".to_string(),
431                    data: serde_json::json!({
432                        "status_code": status_code,
433                        "headers": headers,
434                        "body": body,
435                        "spec_compliant": spec_compliant
436                    }),
437                })
438            }
439            "send_validation_error" => {
440                let status_code = required_status(&action, "send_validation_error")?;
441
442                let message = action["message"]
443                    .as_str()
444                    .ok_or_else(|| anyhow!("Missing message parameter"))?;
445
446                warn!("OpenAPI validation error: {} - {}", status_code, message);
447
448                Ok(ActionResult::Custom {
449                    name: "send_validation_error".to_string(),
450                    data: serde_json::json!({
451                        "status_code": status_code,
452                        "message": message
453                    }),
454                })
455            }
456            "reload_spec" => {
457                let spec = action["spec"]
458                    .as_str()
459                    .ok_or_else(|| anyhow!("Missing spec parameter"))?;
460
461                debug!("OpenAPI spec reload requested: {} bytes", spec.len());
462
463                Ok(ActionResult::Custom {
464                    name: "reload_spec".to_string(),
465                    data: serde_json::json!({
466                        "spec": spec,
467                        "reload": true
468                    }),
469                })
470            }
471            "get_spec_info" => {
472                debug!("OpenAPI spec info requested");
473                Ok(ActionResult::NoAction)
474            }
475            "configure_error_handling" => {
476                let llm_on_invalid = action["llm_on_invalid"]
477                    .as_bool()
478                    .ok_or_else(|| anyhow!("Missing llm_on_invalid parameter"))?;
479
480                debug!(
481                    "OpenAPI error handling configured: llm_on_invalid={}",
482                    llm_on_invalid
483                );
484
485                Ok(ActionResult::Custom {
486                    name: "configure_error_handling".to_string(),
487                    data: serde_json::json!({
488                        "llm_on_invalid": llm_on_invalid
489                    }),
490                })
491            }
492            _ => {
493                error!("Unknown OpenAPI action: {}", action_type);
494                Err(anyhow!("Unknown action type: {}", action_type))
495            }
496        }
497    }
498}