A batteries included HTTP/1.1 client in OCaml
at claude-test 270 lines 8.2 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** HTTP request serialization using Eio.Buf_write 7 8 This module provides efficient HTTP/1.1 request serialization using Eio's 9 buffered write API. It avoids intermediate string allocations by writing 10 directly to the output buffer. *) 11 12let src = Logs.Src.create "requests.http_write" ~doc:"HTTP request serialization" 13module Log = (val Logs.src_log src : Logs.LOG) 14 15module Write = Eio.Buf_write 16 17(** {1 Low-level Writers} *) 18 19let crlf w = 20 Write.string w "\r\n" 21 22let sp w = 23 Write.char w ' ' 24 25(** {1 Request Line} *) 26 27let request_line w ~method_ ~uri = 28 let path = Uri.path uri in 29 let path = if path = "" then "/" else path in 30 let query = Uri.query uri in 31 let path_with_query = 32 if query = [] then path 33 else path ^ "?" ^ (Uri.encoded_of_query query) 34 in 35 Write.string w method_; 36 sp w; 37 Write.string w path_with_query; 38 Write.string w " HTTP/1.1"; 39 crlf w 40 41(** {1 Header Writing} *) 42 43let header w ~name ~value = 44 Write.string w name; 45 Write.string w ": "; 46 Write.string w value; 47 crlf w 48 49let headers w hdrs = 50 Headers.to_list hdrs 51 |> List.iter (fun (name, value) -> header w ~name ~value); 52 crlf w 53 54(** Build Host header value from URI *) 55let host_value uri = 56 let host = match Uri.host uri with 57 | Some h -> h 58 | None -> raise (Error.err (Error.Invalid_url { 59 url = Uri.to_string uri; 60 reason = "URI must have a host" 61 })) 62 in 63 (* RFC 7230: default ports should be omitted from Host header *) 64 match Uri.port uri, Uri.scheme uri with 65 | Some p, Some "https" when p <> 443 -> host ^ ":" ^ string_of_int p 66 | Some p, Some "http" when p <> 80 -> host ^ ":" ^ string_of_int p 67 | Some p, _ -> host ^ ":" ^ string_of_int p 68 | None, _ -> host 69 70let request_headers w ~method_ ~uri ~headers:hdrs ~content_length = 71 (* Write request line *) 72 request_line w ~method_ ~uri; 73 74 (* Ensure Host header is present *) 75 let hdrs = if not (Headers.mem "host" hdrs) then 76 Headers.add "host" (host_value uri) hdrs 77 else hdrs in 78 79 (* Ensure Connection header for keep-alive *) 80 let hdrs = if not (Headers.mem "connection" hdrs) then 81 Headers.add "connection" "keep-alive" hdrs 82 else hdrs in 83 84 (* Add Content-Length if we have a body length *) 85 let hdrs = match content_length with 86 | Some len when len > 0L && not (Headers.mem "content-length" hdrs) -> 87 Headers.add "content-length" (Int64.to_string len) hdrs 88 | _ -> hdrs 89 in 90 91 (* Write all headers *) 92 headers w hdrs 93 94(** {1 Body Writing} *) 95 96let body_string w s = 97 if s <> "" then 98 Write.string w s 99 100(** Copy from a flow source to the writer, chunk by chunk *) 101let body_stream w source = 102 let buf = Cstruct.create 8192 in 103 let rec copy () = 104 match Eio.Flow.single_read source buf with 105 | n -> 106 Write.cstruct w (Cstruct.sub buf 0 n); 107 copy () 108 | exception End_of_file -> () 109 in 110 copy () 111 112(** Write body using chunked transfer encoding *) 113let body_chunked w source = 114 let buf = Cstruct.create 8192 in 115 let rec copy () = 116 match Eio.Flow.single_read source buf with 117 | n -> 118 (* Write chunk size in hex *) 119 Write.printf w "%x" n; 120 crlf w; 121 (* Write chunk data *) 122 Write.cstruct w (Cstruct.sub buf 0 n); 123 crlf w; 124 copy () 125 | exception End_of_file -> 126 (* Write final chunk *) 127 Write.string w "0"; 128 crlf w; 129 crlf w 130 in 131 copy () 132 133(** {1 High-level Request Writing} *) 134 135let request w ~sw ~method_ ~uri ~headers:hdrs ~body = 136 let method_str = Method.to_string method_ in 137 138 (* Get content type and length from body *) 139 let content_type = Body.content_type body in 140 let content_length = Body.content_length body in 141 142 (* Add Content-Type header if body has one *) 143 let hdrs = match content_type with 144 | Some mime when not (Headers.mem "content-type" hdrs) -> 145 Headers.add "content-type" (Mime.to_string mime) hdrs 146 | _ -> hdrs 147 in 148 149 (* Determine if we need chunked encoding *) 150 let use_chunked = Body.Private.is_chunked body in 151 152 let hdrs = if use_chunked && not (Headers.mem "transfer-encoding" hdrs) then 153 Headers.add "transfer-encoding" "chunked" hdrs 154 else hdrs in 155 156 (* Write request line and headers *) 157 request_headers w ~method_:method_str ~uri ~headers:hdrs ~content_length; 158 159 (* Write body *) 160 if Body.Private.is_empty body then 161 () 162 else if use_chunked then 163 Body.Private.write_chunked ~sw w body 164 else 165 Body.Private.write ~sw w body 166 167(** {1 Headers-Only Writing (for 100-continue)} *) 168 169let request_headers_only w ~method_ ~uri ~headers:hdrs ~content_length = 170 let method_str = Method.to_string method_ in 171 request_headers w ~method_:method_str ~uri ~headers:hdrs ~content_length 172 173(** {1 Convenience Wrappers} *) 174 175let with_flow ?initial_size flow fn = 176 Write.with_flow ?initial_size flow fn 177 178(** Write and flush directly to flow without creating a nested switch. 179 This is a simpler alternative to [with_flow] that avoids potential 180 issues with nested switches in the Eio fiber system. *) 181let write_and_flush ?(initial_size=0x1000) flow fn = 182 (* Create a writer without attaching to a switch *) 183 let w = Write.create initial_size in 184 (* Execute the writing function *) 185 fn w; 186 (* Serialize to string and copy to flow *) 187 let data = Write.serialize_to_string w in 188 if String.length data > 0 then 189 Eio.Flow.copy_string data flow 190 191(** {1 Proxy Request Writing} *) 192 193(** Write request line using absolute-URI form for proxy requests. 194 Per RFC 9112 Section 3.2.2 *) 195let request_line_absolute w ~method_ ~uri = 196 Write.string w method_; 197 sp w; 198 (* Use full absolute URI *) 199 Write.string w (Uri.to_string uri); 200 Write.string w " HTTP/1.1"; 201 crlf w 202 203(** Write request headers for proxy request with absolute-URI *) 204let request_headers_proxy w ~method_ ~uri ~headers:hdrs ~content_length ~proxy_auth = 205 (* Write request line with absolute URI *) 206 request_line_absolute w ~method_ ~uri; 207 208 (* Ensure Host header is present *) 209 let hdrs = if not (Headers.mem "host" hdrs) then 210 Headers.add "host" (host_value uri) hdrs 211 else hdrs in 212 213 (* Ensure Connection header for keep-alive *) 214 let hdrs = if not (Headers.mem "connection" hdrs) then 215 Headers.add "connection" "keep-alive" hdrs 216 else hdrs in 217 218 (* Add Content-Length if we have a body length *) 219 let hdrs = match content_length with 220 | Some len when len > 0L && not (Headers.mem "content-length" hdrs) -> 221 Headers.add "content-length" (Int64.to_string len) hdrs 222 | _ -> hdrs 223 in 224 225 (* Add Proxy-Authorization if configured *) 226 let hdrs = match proxy_auth with 227 | Some auth -> 228 let auth_headers = Auth.apply auth Headers.empty in 229 (match Headers.get "authorization" auth_headers with 230 | Some value -> Headers.add "proxy-authorization" value hdrs 231 | None -> hdrs) 232 | None -> hdrs 233 in 234 235 (* Write all headers *) 236 headers w hdrs 237 238(** Write complete HTTP request via proxy using absolute-URI form *) 239let request_via_proxy w ~sw ~method_ ~uri ~headers:hdrs ~body ~proxy_auth = 240 let method_str = Method.to_string method_ in 241 242 (* Get content type and length from body *) 243 let content_type = Body.content_type body in 244 let content_length = Body.content_length body in 245 246 (* Add Content-Type header if body has one *) 247 let hdrs = match content_type with 248 | Some mime when not (Headers.mem "content-type" hdrs) -> 249 Headers.add "content-type" (Mime.to_string mime) hdrs 250 | _ -> hdrs 251 in 252 253 (* Determine if we need chunked encoding *) 254 let use_chunked = Body.Private.is_chunked body in 255 256 let hdrs = if use_chunked && not (Headers.mem "transfer-encoding" hdrs) then 257 Headers.add "transfer-encoding" "chunked" hdrs 258 else hdrs in 259 260 (* Write request line and headers *) 261 request_headers_proxy w ~method_:method_str ~uri ~headers:hdrs 262 ~content_length ~proxy_auth; 263 264 (* Write body *) 265 if Body.Private.is_empty body then 266 () 267 else if use_chunked then 268 Body.Private.write_chunked ~sw w body 269 else 270 Body.Private.write ~sw w body