Skip to main content

netget/client/http3/
actions.rs

1//! HTTP/3 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/// HTTP/3 client connected event
16pub static HTTP3_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "http3_connected",
19        "HTTP/3 client connected via QUIC and ready to send requests",
20        json!({
21            "type": "send_http3_request",
22            "method": "GET",
23            "path": "/api/status",
24            "priority": 5
25        }),
26    )
27    .with_parameters(vec![
28        Parameter {
29            name: "base_url".to_string(),
30            type_hint: "string".to_string(),
31            description: "Base URL for HTTP/3 requests".to_string(),
32            required: true,
33        },
34        Parameter {
35            name: "remote_addr".to_string(),
36            type_hint: "string".to_string(),
37            description: "Authenticated target of this reusable QUIC session".to_string(),
38            required: true,
39        },
40    ])
41    .with_actions(Http3ClientProtocol::new().get_sync_actions())
42});
43
44/// HTTP/3 client response received event
45pub static HTTP3_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
46    EventType::new(
47        "http3_response_received",
48        "HTTP/3 response received from server",
49        json!({
50            "type": "send_http3_request",
51            "method": "POST",
52            "path": "/api/data",
53            "body": "{\"key\": \"value\"}",
54            "priority": 3
55        }),
56    )
57    .with_parameters(vec![
58        Parameter {
59            name: "status_code".to_string(),
60            type_hint: "number".to_string(),
61            description: "HTTP status code".to_string(),
62            required: true,
63        },
64        Parameter {
65            name: "headers".to_string(),
66            type_hint: "object".to_string(),
67            description: "Response headers".to_string(),
68            required: true,
69        },
70        Parameter {
71            name: "body".to_string(),
72            type_hint: "string".to_string(),
73            description: "Complete UTF-8 response body, at most 8 MiB".to_string(),
74            required: true,
75        },
76        Parameter {
77            name: "trailers".into(),
78            type_hint: "object".into(),
79            description: "Trailing response headers".into(),
80            required: true,
81        },
82        Parameter {
83            name: "stream_id".to_string(),
84            type_hint: "number".to_string(),
85            description: "Index of this request stream on the reusable QUIC connection."
86                .to_string(),
87            required: true,
88        },
89    ])
90    .with_actions(Http3ClientProtocol::new().get_sync_actions())
91});
92
93pub static HTTP3_CLIENT_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
94    EventType::new(
95        "http3_request_failed",
96        "HTTP/3 exchange failed",
97        json!({"type":"wait_for_more"}),
98    )
99    .with_parameters(vec![Parameter {
100        name: "error".into(),
101        type_hint: "string".into(),
102        description: "Local failure description".into(),
103        required: true,
104    }])
105    .with_actions(Http3ClientProtocol::new().get_sync_actions())
106});
107
108/// HTTP/3 client protocol action handler
109#[derive(Default)]
110pub struct Http3ClientProtocol;
111
112impl Http3ClientProtocol {
113    pub fn new() -> Self {
114        Self
115    }
116}
117
118// Implement Protocol trait (common functionality)
119impl Protocol for Http3ClientProtocol {
120    /// The reusable session uses authenticated TLS without 0-RTT or migration.
121    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
122        let mut parameters = crate::utils::quic::client_parameters();
123        parameters.push(ParameterDefinition {
124            name: "default_headers".to_string(),
125            description: "Default headers to include in all requests".to_string(),
126            type_hint: "object".to_string(),
127            required: false,
128            example: json!({
129                "User-Agent": "NetGet-HTTP3/1.0",
130                "Accept": "application/json"
131            }),
132            default: None,
133        });
134        parameters
135    }
136    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
137        self.get_sync_actions()
138    }
139    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
140        vec![
141            ActionDefinition {
142                name: "send_http3_request".to_string(),
143                description: "Send another HTTP/3 request in response to received data".to_string(),
144                parameters: vec![
145                    Parameter {
146                        name: "method".to_string(),
147                        type_hint: "string".to_string(),
148                        description: "HTTP request method, such as GET or POST".to_string(),
149                        required: true,
150                    },
151                    Parameter {
152                        name: "path".to_string(),
153                        type_hint: "string".to_string(),
154                        description: "Origin-form path and optional query string".to_string(),
155                        required: true,
156                    },
157                    Parameter {
158                        name: "headers".to_string(),
159                        type_hint: "object".to_string(),
160                        description: "Request headers".to_string(),
161                        required: false,
162                    },
163                    Parameter {
164                        name: "trailers".into(),
165                        type_hint: "object".into(),
166                        description: "Trailing request headers".into(),
167                        required: false,
168                    },
169                    Parameter {
170                        name: "body".to_string(),
171                        type_hint: "string".to_string(),
172                        description: "UTF-8 request body, at most 8 MiB".to_string(),
173                        required: false,
174                    },
175                    Parameter {
176                        name: "priority".to_string(),
177                        type_hint: "number".to_string(),
178                        description: "RFC 9218 urgency, 0-7, sent as the `priority: u=N` request \
179                            header. **Lower is more urgent** (0 = most urgent, 7 = least); \
180                            the RFC default is 3, and omitting this sends no header at all, \
181                            which is not the same as sending u=3. It is a hint the server \
182                            uses when scheduling responses; a server may ignore it."
183                            .to_string(),
184                        required: false,
185                    },
186                ],
187                example: json!({
188                    "type": "send_http3_request",
189                    "method": "POST",
190                    "path": "/api/data",
191                    "body": "{\"key\": \"value\"}",
192                    "priority": 3
193                }),
194                log_template: Some(
195                    LogTemplate::new().with_info("-> HTTP3 {method} {path} body_bytes={body_len}"),
196                ),
197            },
198            ActionDefinition {
199                name: "disconnect".into(),
200                description: "Close the QUIC session and cancel active requests".into(),
201                parameters: vec![],
202                example: json!({"type":"disconnect"}),
203                log_template: Some(LogTemplate::new().with_info("-> HTTP3 disconnect")),
204            },
205            ActionDefinition {
206                name: "wait_for_more".to_string(),
207                description: "Take no action and wait for the next response. Use when nothing \
208                should be sent yet."
209                    .to_string(),
210                parameters: vec![],
211                example: json!({ "type": "wait_for_more" }),
212                log_template: Some(
213                    LogTemplate::new().with_info("HTTP3 waiting for another response"),
214                ),
215            },
216        ]
217    }
218    fn protocol_name(&self) -> &'static str {
219        "HTTP3"
220    }
221    fn get_event_types(&self) -> Vec<EventType> {
222        vec![
223            HTTP3_CLIENT_CONNECTED_EVENT.clone(),
224            HTTP3_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
225            HTTP3_CLIENT_ERROR_EVENT.clone(),
226        ]
227    }
228    fn stack_name(&self) -> &'static str {
229        "ETH>IP>UDP>QUIC>HTTP3"
230    }
231    fn keywords(&self) -> Vec<&'static str> {
232        vec!["http3", "http/3", "quic", "h3", "connect to http3"]
233    }
234    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
235        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
236
237        ProtocolMetadataV2::builder()
238            .state(DevelopmentState::Experimental)
239            .implementation("quinn 0.11 + rustls 0.23 + h3 0.0.8, authenticated reusable RFC 9114 session with an owned control/QPACK driver. h3-quinn 0.0.10 has a local pending-read cancellation patch.")
240            .llm_control("Method, origin-form path, headers, UTF-8 body, request trailers and RFC 9218 urgency 0..7. Injected and handler requests multiplex concurrently; up to 32 active exchanges and handlers, with four follow-up levels.")
241            .e2e_testing("Independent pinned aioquic 1.3.0 server tests GET/POST/header/priority/trailers, multiplexing, response bounds, trust/name failures, timeout and disconnect/removal. NetGet pair and real server tests cover both roles. No tests are ignored or skip missing peers.")
242            .notes("32 KiB field sections, 8 MiB UTF-8 bodies, bounded handshake/exchange/idle deadlines. System roots plus explicit ca_cert_path; optional server_name. No 0-RTT, server push, DATAGRAM, WebTransport or migration. Experimental: no second independent implementation, pcap oracle or fuzz target.")
243            .build()
244    }
245    fn description(&self) -> &'static str {
246        "HTTP/3 client for making web requests over QUIC"
247    }
248    fn example_prompt(&self) -> &'static str {
249        "Connect to localhost:4433 using a trusted certificate and fetch /api/status over HTTP/3"
250    }
251    fn group_name(&self) -> &'static str {
252        "Core"
253    }
254
255    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
256        use crate::llm::actions::StartupExamples;
257        use serde_json::json;
258
259        StartupExamples::new(
260            // LLM mode: LLM handles HTTP/3 client
261            json!({
262                "type": "open_client",
263                "remote_addr": "localhost:4433",
264                "startup_params": {"ca_cert_path":"cert.pem","server_name":"localhost"},
265                "base_stack": "http3",
266                "instruction": "Fetch /api/status and display QUIC connection info"
267            }),
268            // Script mode: Code-based HTTP/3 handling
269            json!({
270                "type": "open_client",
271                "remote_addr": "localhost:4433",
272                "startup_params": {"ca_cert_path":"cert.pem","server_name":"localhost"},
273                "base_stack": "http3",
274                "event_handlers": [{
275                    "event_pattern": "http3_response_received",
276                    "handler": {
277                        "type": "script",
278                        "language": "python",
279                        "code": "<http3_handler>"
280                    }
281                }]
282            }),
283            // Static mode: Fixed HTTP/3 request
284            json!({
285                "type": "open_client",
286                "remote_addr": "localhost:4433",
287                "startup_params": {"ca_cert_path":"cert.pem","server_name":"localhost"},
288                "base_stack": "http3",
289                "event_handlers": [{
290                    "event_pattern": "http3_connected",
291                    "handler": {
292                        "type": "static",
293                        "actions": [{
294                            "type": "send_http3_request",
295                            "method": "GET",
296                            "path": "/api/status",
297                            "priority": 5
298                        }]
299                    }
300                }]
301            }),
302        )
303    }
304}
305
306// Implement Client trait (client-specific functionality)
307impl Client for Http3ClientProtocol {
308    fn connect(
309        &self,
310        ctx: crate::protocol::ConnectContext,
311    ) -> std::pin::Pin<
312        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
313    > {
314        Box::pin(async move {
315            use crate::client::http3::Http3Client;
316            Http3Client::connect(ctx).await
317        })
318    }
319    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
320        let action_type = action
321            .get("type")
322            .and_then(|v| v.as_str())
323            .context("Missing 'type' field in action")?;
324
325        match action_type {
326            "send_http3_request" => {
327                let method = action
328                    .get("method")
329                    .and_then(|v| v.as_str())
330                    .context("Missing 'method' field")?
331                    .to_string();
332
333                let path = action
334                    .get("path")
335                    .and_then(|v| v.as_str())
336                    .context("Missing 'path' field")?
337                    .to_string();
338
339                let headers = match action.get("headers") {
340                    None | Some(serde_json::Value::Null) => None,
341                    Some(value) => Some(
342                        value
343                            .as_object()
344                            .context("headers must be an object")?
345                            .clone(),
346                    ),
347                };
348
349                let body = match action.get("body") {
350                    None | Some(serde_json::Value::Null) => None,
351                    Some(value) => {
352                        Some(value.as_str().context("body must be a string")?.to_owned())
353                    }
354                };
355
356                let priority = action
357                    .get("priority")
358                    .filter(|value| !value.is_null())
359                    .map(|_| crate::client::wire_values::number::<u8>(&action, "priority", 0))
360                    .transpose()?;
361                anyhow::ensure!(priority.is_none_or(|p| p <= 7), "priority must be 0..7");
362
363                // Return custom result with request data
364                Ok(ClientActionResult::Custom {
365                    name: "http3_request".to_string(),
366                    data: json!({
367                        "method": method,
368                        "path": path,
369                        "headers": headers,
370                        "body": body,
371                        "priority": priority,
372                        "trailers": action.get("trailers"),
373                    }),
374                })
375            }
376            "disconnect" => Ok(ClientActionResult::Disconnect),
377            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
378            _ => Err(anyhow::anyhow!(
379                "Unknown HTTP/3 client action: {}",
380                action_type
381            )),
382        }
383    }
384}