A batteries included HTTP/1.1 client in OCaml
at main 314 lines 10 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 27(** Build authority value (host:port) for CONNECT requests. 28 Per RFC 9110 Section 9.3.6: CONNECT uses authority-form as request-target. 29 The port is always included for CONNECT since it's establishing a tunnel. *) 30let authority_value uri = 31 let host = match Uri.host uri with 32 | Some h -> h 33 | None -> raise (Error.err (Error.Invalid_url { 34 url = Uri.to_string uri; 35 reason = "URI must have a host for CONNECT" 36 })) 37 in 38 let port = match Uri.port uri with 39 | Some p -> p 40 | None -> 41 (* Default to 443 for CONNECT (typically used for HTTPS tunneling) *) 42 match Uri.scheme uri with 43 | Some "https" -> 443 44 | Some "http" -> 80 45 | _ -> 443 (* Default to 443 for tunneling *) 46 in 47 host ^ ":" ^ string_of_int port 48 49let request_line w ~method_ ~uri = 50 (* RFC 9112 Section 3.2: Request target forms depend on method *) 51 let request_target = 52 if method_ = "CONNECT" then 53 (* RFC 9112 Section 3.2.3: CONNECT uses authority-form (host:port) *) 54 authority_value uri 55 else 56 let path = Uri.path uri in 57 (* RFC 9112 Section 3.2.4: asterisk-form for server-wide OPTIONS requests. 58 When path is "*", use asterisk-form instead of origin-form. 59 Example: OPTIONS * HTTP/1.1 *) 60 if path = "*" && method_ = "OPTIONS" then 61 "*" 62 else begin 63 let path = if path = "" then "/" else path in 64 let query = Uri.query uri in 65 if query = [] then path 66 else path ^ "?" ^ (Uri.encoded_of_query query) 67 end 68 in 69 Write.string w method_; 70 sp w; 71 Write.string w request_target; 72 Write.string w " HTTP/1.1"; 73 crlf w 74 75(** {1 Header Writing} *) 76 77let header w ~name ~value = 78 Write.string w name; 79 Write.string w ": "; 80 Write.string w value; 81 crlf w 82 83let headers w hdrs = 84 Headers.to_list hdrs 85 |> List.iter (fun (name, value) -> header w ~name ~value); 86 crlf w 87 88(** Build Host header value from URI *) 89let host_value uri = 90 let host = match Uri.host uri with 91 | Some h -> h 92 | None -> raise (Error.err (Error.Invalid_url { 93 url = Uri.to_string uri; 94 reason = "URI must have a host" 95 })) 96 in 97 (* RFC 7230: default ports should be omitted from Host header *) 98 match Uri.port uri, Uri.scheme uri with 99 | Some p, Some "https" when p <> 443 -> host ^ ":" ^ string_of_int p 100 | Some p, Some "http" when p <> 80 -> host ^ ":" ^ string_of_int p 101 | Some p, _ -> host ^ ":" ^ string_of_int p 102 | None, _ -> host 103 104let request_headers w ~method_ ~uri ~headers:hdrs ~content_length = 105 (* Write request line *) 106 request_line w ~method_ ~uri; 107 108 (* Per RFC 9110 Section 7.2: Host header handling. 109 For CONNECT requests (RFC 9110 Section 9.3.6), Host should be the authority (host:port). 110 For other requests, Host should match the URI authority. *) 111 let expected_host = 112 if method_ = "CONNECT" then authority_value uri 113 else host_value uri 114 in 115 let hdrs = match Headers.get `Host hdrs with 116 | None -> 117 (* Auto-add Host header from URI *) 118 Headers.add `Host expected_host hdrs 119 | Some provided_host -> 120 (* Validate provided Host matches expected value *) 121 if provided_host <> expected_host then 122 Log.warn (fun m -> m "Host header '%s' does not match expected '%s' \ 123 (RFC 9110 Section 7.2)" provided_host expected_host); 124 hdrs 125 in 126 127 (* Ensure Connection header for keep-alive *) 128 let hdrs = if not (Headers.mem `Connection hdrs) then 129 Headers.add `Connection "keep-alive" hdrs 130 else hdrs in 131 132 (* Add Content-Length if we have a body length *) 133 let hdrs = match content_length with 134 | Some len when len > 0L && not (Headers.mem `Content_length hdrs) -> 135 Headers.add `Content_length (Int64.to_string len) hdrs 136 | _ -> hdrs 137 in 138 139 (* Write all headers *) 140 headers w hdrs 141 142(** {1 Body Writing} *) 143 144let body_string w s = 145 if s <> "" then 146 Write.string w s 147 148(** Copy from a flow source to the writer, chunk by chunk *) 149let body_stream w source = 150 let buf = Cstruct.create 8192 in 151 let rec copy () = 152 match Eio.Flow.single_read source buf with 153 | n -> 154 Write.cstruct w (Cstruct.sub buf 0 n); 155 copy () 156 | exception End_of_file -> () 157 in 158 copy () 159 160(** Write body using chunked transfer encoding *) 161let body_chunked w source = 162 let buf = Cstruct.create 8192 in 163 let rec copy () = 164 match Eio.Flow.single_read source buf with 165 | n -> 166 (* Write chunk size in hex *) 167 Write.printf w "%x" n; 168 crlf w; 169 (* Write chunk data *) 170 Write.cstruct w (Cstruct.sub buf 0 n); 171 crlf w; 172 copy () 173 | exception End_of_file -> 174 (* Write final chunk *) 175 Write.string w "0"; 176 crlf w; 177 crlf w 178 in 179 copy () 180 181(** {1 High-level Request Writing} *) 182 183let request w ~sw ~method_ ~uri ~headers:hdrs ~body = 184 let method_str = Method.to_string method_ in 185 186 (* Get content type and length from body *) 187 let content_type = Body.content_type body in 188 let content_length = Body.content_length body in 189 190 (* Add Content-Type header if body has one *) 191 let hdrs = match content_type with 192 | Some mime when not (Headers.mem `Content_type hdrs) -> 193 Headers.add `Content_type (Mime.to_string mime) hdrs 194 | _ -> hdrs 195 in 196 197 (* Determine if we need chunked encoding *) 198 let use_chunked = Body.Private.is_chunked body in 199 200 let hdrs = if use_chunked && not (Headers.mem `Transfer_encoding hdrs) then 201 Headers.add `Transfer_encoding "chunked" hdrs 202 else hdrs in 203 204 (* Write request line and headers *) 205 request_headers w ~method_:method_str ~uri ~headers:hdrs ~content_length; 206 207 (* Write body *) 208 if Body.Private.is_empty body then 209 () 210 else if use_chunked then 211 Body.Private.write_chunked ~sw w body 212 else 213 Body.Private.write ~sw w body 214 215(** {1 Headers-Only Writing (for 100-continue)} *) 216 217let request_headers_only w ~method_ ~uri ~headers:hdrs ~content_length = 218 let method_str = Method.to_string method_ in 219 request_headers w ~method_:method_str ~uri ~headers:hdrs ~content_length 220 221(** {1 Convenience Wrappers} *) 222 223let with_flow ?initial_size flow fn = 224 Write.with_flow ?initial_size flow fn 225 226(** Write and flush directly to flow without creating a nested switch. 227 This is a simpler alternative to [with_flow] that avoids potential 228 issues with nested switches in the Eio fiber system. *) 229let write_and_flush ?(initial_size=0x1000) flow fn = 230 (* Create a writer without attaching to a switch *) 231 let w = Write.create initial_size in 232 (* Execute the writing function *) 233 fn w; 234 (* Serialize to string and copy to flow *) 235 let data = Write.serialize_to_string w in 236 if String.length data > 0 then 237 Eio.Flow.copy_string data flow 238 239(** {1 Proxy Request Writing} *) 240 241(** Write request line using absolute-URI form for proxy requests. 242 Per RFC 9112 Section 3.2.2 *) 243let request_line_absolute w ~method_ ~uri = 244 Write.string w method_; 245 sp w; 246 (* Use full absolute URI - write directly to buffer for efficiency *) 247 Huri.write w uri; 248 Write.string w " HTTP/1.1"; 249 crlf w 250 251(** Write request headers for proxy request with absolute-URI *) 252let request_headers_proxy w ~method_ ~uri ~headers:hdrs ~content_length ~proxy_auth = 253 (* Write request line with absolute URI *) 254 request_line_absolute w ~method_ ~uri; 255 256 (* Ensure Host header is present *) 257 let hdrs = if not (Headers.mem `Host hdrs) then 258 Headers.add `Host (host_value uri) hdrs 259 else hdrs in 260 261 (* Ensure Connection header for keep-alive *) 262 let hdrs = if not (Headers.mem `Connection hdrs) then 263 Headers.add `Connection "keep-alive" hdrs 264 else hdrs in 265 266 (* Add Content-Length if we have a body length *) 267 let hdrs = match content_length with 268 | Some len when len > 0L && not (Headers.mem `Content_length hdrs) -> 269 Headers.add `Content_length (Int64.to_string len) hdrs 270 | _ -> hdrs 271 in 272 273 (* Add Proxy-Authorization if configured *) 274 let hdrs = match proxy_auth with 275 | Some value -> Headers.add `Proxy_authorization value hdrs 276 | None -> hdrs 277 in 278 279 (* Write all headers *) 280 headers w hdrs 281 282(** Write complete HTTP request via proxy using absolute-URI form *) 283let request_via_proxy w ~sw ~method_ ~uri ~headers:hdrs ~body ~proxy_auth = 284 let method_str = Method.to_string method_ in 285 286 (* Get content type and length from body *) 287 let content_type = Body.content_type body in 288 let content_length = Body.content_length body in 289 290 (* Add Content-Type header if body has one *) 291 let hdrs = match content_type with 292 | Some mime when not (Headers.mem `Content_type hdrs) -> 293 Headers.add `Content_type (Mime.to_string mime) hdrs 294 | _ -> hdrs 295 in 296 297 (* Determine if we need chunked encoding *) 298 let use_chunked = Body.Private.is_chunked body in 299 300 let hdrs = if use_chunked && not (Headers.mem `Transfer_encoding hdrs) then 301 Headers.add `Transfer_encoding "chunked" hdrs 302 else hdrs in 303 304 (* Write request line and headers *) 305 request_headers_proxy w ~method_:method_str ~uri ~headers:hdrs 306 ~content_length ~proxy_auth; 307 308 (* Write body *) 309 if Body.Private.is_empty body then 310 () 311 else if use_chunked then 312 Body.Private.write_chunked ~sw w body 313 else 314 Body.Private.write ~sw w body