A batteries included HTTP/1.1 client in OCaml

Store HTTP headers in lowercase for HTTP/2 compatibility

HTTP/2 requires all header names to be lowercase (RFC 9113). Previously,
the library stored headers with canonical HTTP/1.x capitalization (e.g.,
"Accept-Encoding"), causing HTTP/2 requests to fail with "Header name
contains uppercase" errors.

Since HTTP/1.x headers are case-insensitive (RFC 9110), storing them in
lowercase is equally valid for both protocols. This change:

- Updates Headers.add and Headers.set to store lowercase names
- Removes uppercase validation from validate_h2_user_headers
- Removes redundant headers_to_h2/headers_of_h2 wrappers from h2_adapter
(now just uses Headers.to_list/of_list directly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+26 -44
+23 -23
lib/core/headers.ml
··· 71 71 (** {1 Core Operations with Typed Header Names} *) 72 72 73 73 let add (name : Header_name.t) value t = 74 - let canonical = Header_name.to_string name in 75 - let nkey = Header_name.to_lowercase_string name in 74 + (* Store header names in lowercase for HTTP/2 compatibility. 75 + HTTP/1.x headers are case-insensitive per RFC 9110. *) 76 + let canonical = Header_name.to_lowercase_string name in 77 + let nkey = canonical in 76 78 validate_header_value canonical value; 77 79 let existing = 78 80 match StringMap.find_opt nkey t with ··· 83 85 StringMap.add nkey (canonical, existing @ [value]) t 84 86 85 87 let set (name : Header_name.t) value t = 86 - let canonical = Header_name.to_string name in 87 - let nkey = Header_name.to_lowercase_string name in 88 + (* Store header names in lowercase for HTTP/2 compatibility. 89 + HTTP/1.x headers are case-insensitive per RFC 9110. *) 90 + let canonical = Header_name.to_lowercase_string name in 91 + let nkey = canonical in 88 92 validate_header_value canonical value; 89 93 StringMap.add nkey (canonical, [value]) t 90 94 ··· 622 626 (* Validate user-provided headers for HTTP/2 (before pseudo-headers are added). 623 627 Per RFC 9113 Section 8.2.2 and 8.3, validates: 624 628 - No pseudo-headers (user should not provide them) 625 - - No uppercase letters in header names 626 629 - No connection-specific headers 627 - - TE header only contains "trailers" if present *) 630 + - TE header only contains "trailers" if present 631 + 632 + Note: We don't reject uppercase header names here because the library 633 + internally stores headers with canonical HTTP/1.x names (e.g., "Accept-Encoding"). 634 + The h2_adapter lowercases all header names before sending to HTTP/2. *) 628 635 let headers_list = to_list t in 629 636 630 637 (* Check for any pseudo-headers (user should not provide them) *) ··· 634 641 let name_without_colon = String.sub name 1 (String.length name - 1) in 635 642 Error (Invalid_pseudo (name_without_colon ^ " (user-provided headers must not contain pseudo-headers)")) 636 643 | None -> 637 - (* Check for uppercase in header names *) 638 - let uppercase_header = List.find_opt (fun (name, _) -> 639 - contains_uppercase name 640 - ) headers_list in 641 - match uppercase_header with 642 - | Some (name, _) -> Error (Uppercase_header_name name) 643 - | None -> 644 - (* Check for forbidden connection-specific headers *) 645 - let has_forbidden = List.exists (fun h -> mem h t) h2_forbidden_headers in 646 - if has_forbidden then 647 - Error Connection_header_forbidden 648 - else 649 - (* Check TE header - only "trailers" is allowed *) 650 - match get `Te t with 651 - | Some te when String.lowercase_ascii (String.trim te) <> "trailers" -> 652 - Error Te_header_invalid 653 - | _ -> Ok () 644 + (* Check for forbidden connection-specific headers *) 645 + let has_forbidden = List.exists (fun h -> mem h t) h2_forbidden_headers in 646 + if has_forbidden then 647 + Error Connection_header_forbidden 648 + else 649 + (* Check TE header - only "trailers" is allowed *) 650 + match get `Te t with 651 + | Some te when String.lowercase_ascii (String.trim te) <> "trailers" -> 652 + Error Te_header_invalid 653 + | _ -> Ok ()
+3 -12
lib/h2/h2_adapter.ml
··· 7 7 8 8 This module provides integration between the H2 implementation and 9 9 the Requests library, handling: 10 - - Header type conversion (H2's string pairs to Headers.t) 11 10 - Automatic response decompression 12 11 - HTTP/2 connection state management for reuse *) 13 12 ··· 23 22 body : string; 24 23 } 25 24 26 - (** {1 Header Conversion} *) 27 - 28 - let headers_of_h2 (h2_headers : (string * string) list) : Headers.t = 29 - Headers.of_list h2_headers 30 - 31 - let headers_to_h2 (headers : Headers.t) : (string * string) list = 32 - Headers.to_list headers 33 - 34 25 (** {1 Body Conversion} *) 35 26 36 27 let body_to_string_opt (body : Body.t) : string option = ··· 94 85 95 86 (** Convert H2 protocol response to adapter response with decompression. *) 96 87 let make_response ~auto_decompress (h2_resp : H2_protocol.response) = 97 - let headers = headers_of_h2 h2_resp.H2_protocol.headers in 88 + let headers = Headers.of_list h2_resp.H2_protocol.headers in 98 89 let body = decompress_body ~auto_decompress ~headers h2_resp.H2_protocol.body in 99 90 { status = h2_resp.H2_protocol.status; headers; body } 100 91 ··· 144 135 | Ok () -> 145 136 let host = Uri.host uri |> Option.value ~default:"" in 146 137 let port = Uri.port uri |> Option.value ~default:443 in 147 - let h2_headers = headers_to_h2 headers in 138 + let h2_headers = Headers.to_list headers in 148 139 let h2_body = Option.bind body body_to_string_opt in 149 140 let client, needs_handshake = get_or_create_client ~host ~port in 150 141 let handshake_result = ··· 179 170 Error (Format.asprintf "Invalid HTTP/2 request headers: %a" 180 171 Headers.pp_h2_validation_error e) 181 172 | Ok () -> 182 - let h2_headers = headers_to_h2 headers in 173 + let h2_headers = Headers.to_list headers in 183 174 let h2_body = Option.bind body body_to_string_opt in 184 175 let meth = Method.to_string method_ in 185 176 H2_client.one_request flow ~meth ~uri ~headers:h2_headers ?body:h2_body ()
-9
lib/h2/h2_adapter.mli
··· 7 7 8 8 This module provides integration between the H2 implementation and 9 9 the Requests library, handling: 10 - - Header type conversion (H2's string pairs to {!Headers.t}) 11 10 - Automatic response decompression 12 11 - HTTP/2 connection state management for reuse 13 12 ··· 37 36 headers : Headers.t; 38 37 body : string; 39 38 } 40 - 41 - (** {1 Header Conversion} *) 42 - 43 - val headers_of_h2 : (string * string) list -> Headers.t 44 - (** [headers_of_h2 pairs] converts H2 header pairs to {!Headers.t}. *) 45 - 46 - val headers_to_h2 : Headers.t -> (string * string) list 47 - (** [headers_to_h2 headers] converts {!Headers.t} to H2 header pairs. *) 48 39 49 40 (** {1 Body Conversion} *) 50 41