Skip to main content

netune_http1/
writer.rs

1//! Request serialization.
2
3use bytes::{BufMut, BytesMut};
4use tokio::io::{AsyncWrite, AsyncWriteExt};
5
6use crate::error::HttpError;
7use crate::head::RequestHead;
8
9/// Serialize `head` plus an optional length-delimited body onto `writer`.
10///
11/// A `Content-Length` is always written (0 for an absent body) unless the caller
12/// already supplied one, so every request is self-delimiting: a proxy or server
13/// never has to guess where the body ends.
14pub async fn write_request<W: AsyncWrite + Unpin>(
15    writer: &mut W,
16    head: &RequestHead,
17    body: Option<&[u8]>,
18) -> Result<(), HttpError> {
19    let mut out = BytesMut::with_capacity(512 + body.map_or(0, <[u8]>::len));
20
21    out.put_slice(head.method.as_str().as_bytes());
22    out.put_slice(b" ");
23    out.put_slice(head.target.as_bytes());
24    out.put_slice(b" HTTP/1.1\r\n");
25
26    let mut wrote_length = false;
27    for (name, value) in head.headers.iter() {
28        if name == http::header::CONTENT_LENGTH {
29            wrote_length = true;
30        }
31        out.put_slice(name.as_str().as_bytes());
32        out.put_slice(b": ");
33        out.put_slice(value.as_bytes());
34        out.put_slice(b"\r\n");
35    }
36    if !wrote_length {
37        let length = body.map_or(0, <[u8]>::len);
38        out.put_slice(b"content-length: ");
39        out.put_slice(length.to_string().as_bytes());
40        out.put_slice(b"\r\n");
41    }
42    out.put_slice(b"\r\n");
43    if let Some(body) = body {
44        out.put_slice(body);
45    }
46
47    writer.write_all(&out).await?;
48    writer.flush().await?;
49    Ok(())
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use http::Method;
56
57    async fn encode(head: &RequestHead, body: Option<&[u8]>) -> String {
58        let mut out = Vec::new();
59        write_request(&mut out, head, body).await.expect("write");
60        String::from_utf8(out).expect("utf-8")
61    }
62
63    #[tokio::test]
64    async fn a_request_is_self_delimiting() {
65        let head = RequestHead::new(Method::POST, "/v1/chat/completions")
66            .with_header("content-type", "application/json")
67            .with_header("authorization", "Bearer secret");
68        let text = encode(&head, Some(b"{\"stream\":true}")).await;
69        assert!(text.starts_with("POST /v1/chat/completions HTTP/1.1\r\n"));
70        assert!(text.contains("content-type: application/json\r\n"));
71        assert!(text.contains("authorization: Bearer secret\r\n"));
72        assert!(text.contains("content-length: 15\r\n"));
73        assert!(text.ends_with("\r\n\r\n{\"stream\":true}"));
74    }
75
76    #[tokio::test]
77    async fn an_absent_body_still_declares_zero_length() {
78        let head = RequestHead::new(Method::GET, "/models");
79        let text = encode(&head, None).await;
80        assert!(text.contains("content-length: 0\r\n"));
81        assert!(text.ends_with("\r\n\r\n"));
82    }
83
84    #[tokio::test]
85    async fn a_caller_supplied_length_is_not_duplicated() {
86        let head = RequestHead::new(Method::POST, "/x").with_header("content-length", "3");
87        let text = encode(&head, Some(b"abc")).await;
88        assert_eq!(text.matches("content-length").count(), 1);
89    }
90
91    // -- the HTTP-layer fingerprint surface -------------------------------
92    //
93    // Header order and casing are what a passive observer fingerprints at
94    // the HTTP layer (JA4H). `http::HeaderMap` preserves insertion order,
95    // and the writer spells each name exactly as it was inserted, so the
96    // caller — not the codec — owns both. These tests pin that: any change
97    // to the writer that reorders, recases, or normalizes headers shows up
98    // here as a fingerprint-relevant regression, not a silent drift.
99
100    #[tokio::test]
101    async fn header_order_is_what_the_caller_inserted() {
102        let head = RequestHead::new(Method::GET, "/models")
103            .with_header("user-agent", "netune/0.1")
104            .with_header("accept", "application/json")
105            .with_header("authorization", "Bearer t");
106        let text = encode(&head, None).await;
107        let body_start = text.find("\r\n\r\n").expect("head terminator");
108        let head_text = &text[..body_start];
109        let ua = head_text.find("user-agent").expect("user-agent");
110        let accept = head_text.find("accept").expect("accept");
111        let auth = head_text.find("authorization").expect("authorization");
112        assert!(
113            ua < accept && accept < auth,
114            "insertion order is wire order:\n{head_text}"
115        );
116    }
117
118    #[tokio::test]
119    async fn header_casing_is_lowercased_by_the_header_map() {
120        // A real, recorded limitation: `http::HeaderName::try_from`
121        // lowercases every name, so the wire casing is always lowercase —
122        // unlike some browsers that send title-cased names. Order is still
123        // ours (see `header_order_is_what_the_caller_inserted`), so the
124        // JA4H order signal is available while the casing signal is fixed
125        // lowercase. If a future profile needs arbitrary casing, the seam
126        // is here: the writer spells whatever name it is handed, so the
127        // change belongs in header construction, not serialization.
128        let head = RequestHead::new(Method::GET, "/x").with_header("X-API-Key", "k");
129        let text = encode(&head, None).await;
130        assert!(
131            text.contains("x-api-key: k\r\n"),
132            "HeaderMap lowercases; the writer does not re-case:\n{text}"
133        );
134    }
135
136    #[tokio::test]
137    async fn duplicate_headers_replace_their_predecessor_on_insert() {
138        // Another recorded limitation: `with_header` inserts, and a
139        // `HeaderMap` insert replaces a same-named value. A multi-value
140        // header (cookie, accept) therefore needs `append` semantics, which
141        // `RequestHead` does not yet expose. The writer, given such a map,
142        // would emit both values in order — the constraint is in the
143        // head builder, not the codec.
144        let head = RequestHead::new(Method::GET, "/x")
145            .with_header("accept", "text/html")
146            .with_header("accept", "application/json");
147        let text = encode(&head, None).await;
148        assert!(text.contains("accept: application/json\r\n"));
149        assert!(
150            !text.contains("accept: text/html"),
151            "insert replaced the earlier value:\n{text}"
152        );
153    }
154
155    #[tokio::test]
156    async fn the_content_length_fills_in_after_the_callers_headers() {
157        // The synthesized `content-length` must not jump ahead of the
158        // caller's headers: a passive observer counting "which header closes
159        // the block" would see a different fingerprint depending on where it
160        // landed.
161        let head = RequestHead::new(Method::POST, "/x")
162            .with_header("user-agent", "netune/0.1")
163            .with_header("accept", "application/json");
164        let text = encode(&head, Some(b"{}")).await;
165        let head_text = &text[..text.find("\r\n\r\n").expect("terminator")];
166        let user_agent = head_text.find("user-agent").expect("user-agent");
167        let accept = head_text.find("accept").expect("accept");
168        let length = head_text.find("content-length").expect("content-length");
169        assert!(
170            user_agent < accept && accept < length,
171            "the synthesized length closes the header block, after the caller's:\n{head_text}"
172        );
173    }
174}