Skip to main content

netget/server/http/
actions.rs

1//! HTTP protocol actions implementation
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::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// HTTP protocol action handler
15pub struct HttpProtocol;
16
17impl Default for HttpProtocol {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl HttpProtocol {
24    pub fn new() -> Self {
25        Self
26    }
27}
28
29// Implement Protocol trait (common functionality)
30impl Protocol for HttpProtocol {
31    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
32        let mut params = crate::server::tls_cert_manager::get_tls_startup_parameters();
33        params.extend(crate::server::http_common::handler::request_handling_startup_parameters());
34        params
35    }
36    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
37        // HTTP has no async actions - it's purely request-response
38        Vec::new()
39    }
40    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
41        vec![send_http_response_action()]
42    }
43    fn protocol_name(&self) -> &'static str {
44        "HTTP"
45    }
46    fn get_event_types(&self) -> Vec<EventType> {
47        get_http_event_types()
48    }
49    fn stack_name(&self) -> &'static str {
50        "ETH>IP>TCP>HTTP"
51    }
52    fn keywords(&self) -> Vec<&'static str> {
53        vec!["http", "http server", "http stack", "via http", "hyper"]
54    }
55    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
56        use crate::protocol::metadata::{
57            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
58        };
59
60        ProtocolMetadataV2::builder()
61            .state(DevelopmentState::Beta)
62            // Default HTTP port is privileged; the preflight check only fires
63            // when the requested port is actually < 1024.
64            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(80))
65            .well_known_port(80)
66            .implementation("hyper v1.0 HTTP/1.1 server, optional TLS via rustls")
67            .llm_control("Response content (status, headers, text body) — one response per request")
68            .e2e_testing(
69                "THREE independent clients, none #[ignore]d and none able to skip. \
70                 (1) reqwest (hyper) in tests/server/http/test.rs, 7 scenarios. \
71                 (2) the real curl binary and (3) Python's http.client, both in \
72                 tests/server/http/real_client_test.rs, which FAILS naming the install when \
73                 either is missing. curl is libcurl, C, and parses the status line itself -- \
74                 `--fail` on a 404 is asserted, so a 200 carrying the words `no such thing` \
75                 would not pass. http.client is a third implementation in Python's standard \
76                 library, needing no install anywhere, and it exposes the raw header list and \
77                 the reason phrase, neither of which reqwest surfaces here: a custom header, a \
78                 case-insensitive lookup of it, the Content-Type and a non-empty reason phrase \
79                 are all asserted. Verified by changing the header value the model sets, which \
80                 the test then reports. \
81                 The rating rested on reqwest ALONE until September 2026, which is the shape \
82                 that turned out to be hiding a total failure in etcd, grpc and mysql. \
83                 Note the generic-HTTP-client caveat in the project CLAUDE.md does NOT apply \
84                 here: it is about protocols layered OVER http, where reqwest proves only that \
85                 an HTTP server answered. Here HTTP is the protocol. \
86                 UNPROVEN: TLS (the server supports it; no test drives a real client through \
87                 it), the h2c upgrade path, chunked or streaming responses, and binary bodies.",
88            )
89            .notes(
90                "Text bodies only: no binary response bodies, no chunked/streaming responses, \
91                 and request bodies are fully buffered before the LLM sees them",
92            )
93            .max_inbound_bytes(crate::server::http_common::MAX_REQUEST_BODY_BYTES)
94            .request_only(
95                "HTTP only answers requests; a server cannot send a peer anything unprompted",
96            )
97            .build()
98    }
99    fn description(&self) -> &'static str {
100        "Web server serving HTTP traffic"
101    }
102    fn example_prompt(&self) -> &'static str {
103        "Pretend to be a sassy HTTP server on port 8080 serving cooking recipes"
104    }
105    fn group_name(&self) -> &'static str {
106        "Core"
107    }
108
109    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
110        use crate::llm::actions::StartupExamples;
111        use serde_json::json;
112
113        // Deterministic path routing: a canned health check and a 404 for
114        // everything else, with no LLM call. Reads the event from stdin and
115        // switches on event_type_id, as every handler should.
116        let script = r#"import json, sys
117data = json.load(sys.stdin)
118event = data["event"]
119if data["event_type_id"] == "http_request":
120    if event.get("path", "/") == "/health":
121        actions = [{"type": "send_http_response", "status": 200,
122                    "headers": {"Content-Type": "application/json"},
123                    "body": '{"status": "ok"}'}]
124    else:
125        actions = [{"type": "send_http_response", "status": 404,
126                    "headers": {"Content-Type": "text/plain"},
127                    "body": "Not Found"}]
128else:
129    actions = []
130print(json.dumps({"actions": actions}))"#;
131
132        StartupExamples::new(
133            // LLM mode: dynamic reasoning per request path.
134            json!({
135                "type": "open_server",
136                "port": 8080,
137                "base_stack": "http",
138                "instruction": "Serve a REST API for a blog. For GET /posts return a JSON array of posts; for GET /posts/{id} return that single post, inventing a plausible title and body from the id; return 404 with a JSON error object for any unknown path. Reason about the method and path of each request."
139            }),
140            // Script mode: deterministic canned response + routing (no LLM call).
141            json!({
142                "type": "open_server",
143                "port": 8080,
144                "base_stack": "http",
145                "event_handlers": [{
146                    "event_pattern": "http_request",
147                    "handler": {
148                        "type": "script",
149                        "language": "python",
150                        "code": script
151                    }
152                }]
153            }),
154            // Static mode: Fixed responses
155            json!({
156                "type": "open_server",
157                "port": 8080,
158                "base_stack": "http",
159                "event_handlers": [{
160                    "event_pattern": "http_request",
161                    "handler": {
162                        "type": "static",
163                        "actions": [{
164                            "type": "send_http_response",
165                            "status": 200,
166                            "headers": {"Content-Type": "text/plain"},
167                            "body": "Hello World"
168                        }]
169                    }
170                }]
171            }),
172        )
173    }
174}
175
176// Implement Server trait (server-specific functionality)
177impl Server for HttpProtocol {
178    fn spawn(
179        &self,
180        ctx: crate::protocol::SpawnContext,
181    ) -> std::pin::Pin<
182        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
183    > {
184        Box::pin(async move {
185            use crate::server::http::HttpServer;
186
187            // Parse TLS configuration from startup_params
188            let tls_config = if let Some(ref params) = ctx.startup_params {
189                match crate::server::tls_cert_manager::extract_tls_config_from_params(params) {
190                    Ok(config) => config,
191                    Err(e) => {
192                        return Err(anyhow::anyhow!("Failed to create TLS config: {}", e));
193                    }
194                }
195            } else {
196                None
197            };
198
199            HttpServer::spawn_with_llm_actions(
200                ctx.legacy_listen_addr(),
201                ctx.llm_client,
202                ctx.state,
203                ctx.status_tx,
204                ctx.server_id,
205                tls_config,
206            )
207            .await
208        })
209    }
210    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
211        let action_type = action
212            .get("type")
213            .and_then(|v| v.as_str())
214            .context("Missing 'type' field in action")?;
215
216        match action_type {
217            "send_http_response" => self.execute_send_http_response(action),
218            _ => Err(anyhow::anyhow!("Unknown HTTP action: {action_type}")),
219        }
220    }
221}
222
223impl HttpProtocol {
224    /// Execute send_http_response sync action
225    fn execute_send_http_response(&self, action: serde_json::Value) -> Result<ActionResult> {
226        // Use shared action execution logic
227        crate::server::http_common::execute_http_response_action(action)
228    }
229}
230
231/// Action definition for send_http_response (sync)
232fn send_http_response_action() -> ActionDefinition {
233    ActionDefinition {
234        name: "send_http_response".to_string(),
235        description: "Respond to the HTTP request that triggered this event. This is the ONLY \
236            action that produces an HTTP response - do NOT use generic 'send_data' or \
237            'show_message' for that. ALWAYS emit exactly one send_http_response for every request \
238            — never return an empty action list, or the client gets a blank page. \
239            HONOR THE CLIENT'S `Accept` REQUEST HEADER (in the event's headers.accept): return a \
240            `Content-Type` the client accepts, and a body in that format. If Accept is \
241            `text/html`, return HTML; `application/json`, return JSON; `text/plain`, return text. \
242            If the client asks ONLY for a type you cannot produce — most commonly an image \
243            (`Accept: image/*`, e.g. a browser fetching /favicon.ico) or any other binary type — \
244            respond `404` (or `204`) rather than sending text mislabeled as an image: the body is \
245            UTF-8 text only, so binary payloads (images, gzip, protobuf) cannot be produced at \
246            all. The response is sent complete, in one piece, as soon as you return; there is no \
247            way to stream or chunk it, to send it in parts, or to keep the request open."
248            .to_string(),
249        parameters: vec![
250            Parameter {
251                name: "status".to_string(),
252                type_hint: "number".to_string(),
253                description:
254                    "HTTP status code as a number between 100 and 599 (e.g. 200, 404, 500)."
255                        .to_string(),
256                required: true,
257            },
258            Parameter {
259                name: "headers".to_string(),
260                type_hint: "object".to_string(),
261                description: "Optional response headers as a flat name->value object (e.g. \
262                    {\"Content-Type\": \"text/html\"}). Set Content-Type yourself; Content-Length \
263                    and Date are added automatically and must not be set here. Headers whose name \
264                    or value is not legal HTTP (for example one containing a newline) are dropped."
265                    .to_string(),
266                required: false,
267            },
268            Parameter {
269                name: "body".to_string(),
270                type_hint: "string".to_string(),
271                description: "Response body as text (HTML, JSON, plain text). Optional: omit it \
272                    for an empty body, which is what 204 and 304 require. A JSON object or array \
273                    is serialized to compact JSON text. Bytes cannot be sent - text only."
274                    .to_string(),
275                required: false,
276            },
277        ],
278        example: json!({
279            "type": "send_http_response",
280            "status": 200,
281            "headers": {
282                "Content-Type": "text/html"
283            },
284            "body": "<html><body>Hello World</body></html>"
285        }),
286        log_template: Some(
287            LogTemplate::new()
288                .with_info("-> {status} ({output_bytes}B)")
289                .with_debug("HTTP response: status={status}, body={output_bytes}B")
290                .with_trace("HTTP response: {json_pretty(.)}"),
291        ),
292    }
293}
294
295// ============================================================================
296// HTTP Action Constants
297// ============================================================================
298
299pub static SEND_HTTP_RESPONSE_ACTION: LazyLock<ActionDefinition> =
300    LazyLock::new(send_http_response_action);
301
302// ============================================================================
303// HTTP Event Type Constants
304// ============================================================================
305
306/// HTTP request event - triggered when client sends an HTTP request
307pub static HTTP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
308    EventType::new(
309        "http_request",
310        "HTTP request received from client. Always answer with exactly one send_http_response, \
311         and match the response Content-Type to the client's Accept header (headers.accept): \
312         HTML for text/html, JSON for application/json; if the client only accepts an image or \
313         other binary type you cannot produce (e.g. a browser fetching /favicon.ico with \
314         Accept: image/*), answer 404 rather than sending text as an image.",
315        serde_json::json!({
316            "type": "send_http_response",
317            "status": 200,
318            "headers": {
319                "Content-Type": "text/html"
320            },
321            "body": "<html><body>Hello World</body></html>"
322        }),
323    )
324    .with_parameters(vec![
325        Parameter {
326            name: "method".to_string(),
327            type_hint: "string".to_string(),
328            description: "HTTP method (GET, POST, etc.)".to_string(),
329            required: true,
330        },
331        Parameter {
332            name: "path".to_string(),
333            type_hint: "string".to_string(),
334            description: "Request path without query string (e.g., '/api/users')".to_string(),
335            required: true,
336        },
337        Parameter {
338            name: "query_string".to_string(),
339            type_hint: "string".to_string(),
340            description: "Raw query string if present (e.g., 'x=5&y=3')".to_string(),
341            required: false,
342        },
343        Parameter {
344            name: "query".to_string(),
345            type_hint: "object".to_string(),
346            description:
347                "Parsed query parameters as key-value pairs (e.g., {\"x\": \"5\", \"y\": \"3\"})"
348                    .to_string(),
349            required: false,
350        },
351        Parameter {
352            name: "headers".to_string(),
353            type_hint: "object".to_string(),
354            description: "Request headers as key-value pairs".to_string(),
355            required: true,
356        },
357        Parameter {
358            name: "body".to_string(),
359            type_hint: "string".to_string(),
360            description:
361                "Request body decoded as UTF-8 text (empty string when there is no body). \
362                Bytes that are not valid UTF-8 are replaced with U+FFFD, so when body_is_binary is \
363                true this field is lossy and must not be treated as the exact request payload."
364                    .to_string(),
365            required: false,
366        },
367        Parameter {
368            name: "body_bytes".to_string(),
369            type_hint: "number".to_string(),
370            description: "Size of the request body in bytes, before UTF-8 decoding.".to_string(),
371            required: false,
372        },
373        Parameter {
374            name: "body_is_binary".to_string(),
375            type_hint: "boolean".to_string(),
376            description: "Present and true only when the request body is not valid UTF-8. The \
377                body field is then a lossy decoding; the raw bytes are not available to you."
378                .to_string(),
379            required: false,
380        },
381    ])
382    .with_actions(vec![SEND_HTTP_RESPONSE_ACTION.clone()])
383    .with_alternative_example(serde_json::json!({
384        "type": "send_http_response",
385        "status": 404,
386        "headers": {
387            "Content-Type": "text/plain"
388        },
389        "body": "Not Found"
390    }))
391    .with_alternative_example(serde_json::json!({
392        "type": "send_http_response",
393        "status": 201,
394        "headers": {
395            "Content-Type": "application/json"
396        },
397        "body": "{\"status\": \"created\"}"
398    }))
399    .with_log_template(
400        LogTemplate::new()
401            .with_info(
402                "{client_ip} {method} {path} -> {status} ({response_bytes}B, {duration_ms}ms)",
403            )
404            .with_debug("HTTP {method} {path} from {client_ip}:{client_port}")
405            .with_trace("HTTP request: {json_pretty(.)}"),
406    )
407});
408
409/// Get HTTP event types
410pub fn get_http_event_types() -> Vec<EventType> {
411    vec![HTTP_REQUEST_EVENT.clone()]
412}