A batteries included HTTP/1.1 client in OCaml
at claude-test 247 lines 7.5 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6let src = Logs.Src.create "requests.headers" ~doc:"HTTP Headers" 7module Log = (val Logs.src_log src : Logs.LOG) 8 9(* Use a map with lowercase keys for case-insensitive lookup *) 10module StringMap = Map.Make(String) 11 12type t = (string * string list) StringMap.t 13 14let empty = StringMap.empty 15 16let normalize_key k = String.lowercase_ascii k 17 18(** {1 Header Injection Prevention} 19 20 Per Recommendation #3: Validate that header names and values do not contain 21 newlines (CR/LF) which could enable HTTP request smuggling attacks. 22 23 Note: We use Invalid_argument here to avoid a dependency cycle with Error module. 24 The error will be caught and wrapped appropriately by higher-level code. *) 25 26exception Invalid_header of { name: string; reason: string } 27 28let validate_header_name name = 29 if String.contains name '\r' || String.contains name '\n' then 30 raise (Invalid_header { 31 name; 32 reason = "Header name contains CR/LF characters (potential HTTP smuggling)" 33 }) 34 35let validate_header_value name value = 36 if String.contains value '\r' || String.contains value '\n' then 37 raise (Invalid_header { 38 name; 39 reason = "Header value contains CR/LF characters (potential HTTP smuggling)" 40 }) 41 42let add key value t = 43 validate_header_name key; 44 validate_header_value key value; 45 let nkey = normalize_key key in 46 let existing = 47 match StringMap.find_opt nkey t with 48 | Some (_, values) -> values 49 | None -> [] 50 in 51 (* Append to maintain order, avoiding reversal on retrieval *) 52 StringMap.add nkey (key, existing @ [value]) t 53 54let set key value t = 55 validate_header_name key; 56 validate_header_value key value; 57 let nkey = normalize_key key in 58 StringMap.add nkey (key, [value]) t 59 60let get key t = 61 let nkey = normalize_key key in 62 match StringMap.find_opt nkey t with 63 | Some (_, values) -> List.nth_opt values 0 64 | None -> None 65 66let get_all key t = 67 let nkey = normalize_key key in 68 match StringMap.find_opt nkey t with 69 | Some (_, values) -> values 70 | None -> [] 71 72let remove key t = 73 let nkey = normalize_key key in 74 StringMap.remove nkey t 75 76let mem key t = 77 let nkey = normalize_key key in 78 StringMap.mem nkey t 79 80let of_list lst = 81 List.fold_left (fun acc (k, v) -> add k v acc) empty lst 82 83let to_list t = 84 StringMap.fold (fun _ (orig_key, values) acc -> 85 (* Values are already in correct order, build list in reverse then reverse at end *) 86 List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values 87 ) t [] 88 |> List.rev 89 90let merge t1 t2 = 91 StringMap.union (fun _ _ v2 -> Some v2) t1 t2 92 93(* Common header builders *) 94 95let content_type mime t = 96 set "Content-Type" (Mime.to_string mime) t 97 98let content_length len t = 99 set "Content-Length" (Int64.to_string len) t 100 101let accept mime t = 102 set "Accept" (Mime.to_string mime) t 103 104let authorization value t = 105 set "Authorization" value t 106 107let bearer token t = 108 set "Authorization" (Printf.sprintf "Bearer %s" token) t 109 110let basic ~username ~password t = 111 let credentials = Printf.sprintf "%s:%s" username password in 112 let encoded = Base64.encode_exn credentials in 113 set "Authorization" (Printf.sprintf "Basic %s" encoded) t 114 115let user_agent ua t = 116 set "User-Agent" ua t 117 118let host h t = 119 set "Host" h t 120 121let cookie name value t = 122 add "Cookie" (Printf.sprintf "%s=%s" name value) t 123 124let range ~start ?end_ () t = 125 let range_value = match end_ with 126 | None -> Printf.sprintf "bytes=%Ld-" start 127 | Some e -> Printf.sprintf "bytes=%Ld-%Ld" start e 128 in 129 set "Range" range_value t 130 131(** {1 HTTP 100-Continue Support} 132 133 Per Recommendation #7: Expect: 100-continue protocol for large uploads. 134 RFC 9110 Section 10.1.1 (Expect) *) 135 136let expect value t = 137 set "Expect" value t 138 139let expect_100_continue t = 140 set "Expect" "100-continue" t 141 142(** {1 Cache Control Headers} 143 144 Per Recommendation #17 and #19: Response caching and conditional requests. 145 RFC 9111 (HTTP Caching), RFC 9110 Section 8.8.2-8.8.3 (Last-Modified, ETag) *) 146 147let if_none_match etag t = 148 set "If-None-Match" etag t 149 150let if_match etag t = 151 set "If-Match" etag t 152 153let if_modified_since date t = 154 set "If-Modified-Since" date t 155 156let if_unmodified_since date t = 157 set "If-Unmodified-Since" date t 158 159(** Format a Ptime.t as an HTTP-date (RFC 9110 Section 5.6.7) *) 160let http_date_of_ptime time = 161 (* HTTP-date format: "Sun, 06 Nov 1994 08:49:37 GMT" *) 162 let (year, month, day), ((hour, min, sec), _tz_offset) = Ptime.to_date_time time in 163 let weekday = match Ptime.weekday time with 164 | `Sun -> "Sun" | `Mon -> "Mon" | `Tue -> "Tue" | `Wed -> "Wed" 165 | `Thu -> "Thu" | `Fri -> "Fri" | `Sat -> "Sat" 166 in 167 let month_name = [| ""; "Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; 168 "Jul"; "Aug"; "Sep"; "Oct"; "Nov"; "Dec" |].(month) in 169 Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" 170 weekday day month_name year hour min sec 171 172let if_modified_since_ptime time t = 173 if_modified_since (http_date_of_ptime time) t 174 175let if_unmodified_since_ptime time t = 176 if_unmodified_since (http_date_of_ptime time) t 177 178let cache_control directives t = 179 set "Cache-Control" directives t 180 181(** Build Cache-Control header from common directive components. 182 For max_stale: [None] = not present, [Some None] = any staleness, [Some (Some n)] = n seconds *) 183let cache_control_directives 184 : ?max_age:int -> 185 ?max_stale:int option option -> 186 ?min_fresh:int -> 187 ?no_cache:bool -> 188 ?no_store:bool -> 189 ?no_transform:bool -> 190 ?only_if_cached:bool -> 191 unit -> t -> t 192 = fun 193 ?max_age 194 ?max_stale 195 ?min_fresh 196 ?(no_cache = false) 197 ?(no_store = false) 198 ?(no_transform = false) 199 ?(only_if_cached = false) 200 () t -> 201 let directives = [] in 202 let directives = match max_age with 203 | Some age -> Printf.sprintf "max-age=%d" age :: directives 204 | None -> directives 205 in 206 let directives = match max_stale with 207 | Some (Some None) -> "max-stale" :: directives 208 | Some (Some (Some secs)) -> Printf.sprintf "max-stale=%d" secs :: directives 209 | Some None | None -> directives 210 in 211 let directives = match min_fresh with 212 | Some secs -> Printf.sprintf "min-fresh=%d" secs :: directives 213 | None -> directives 214 in 215 let directives = if no_cache then "no-cache" :: directives else directives in 216 let directives = if no_store then "no-store" :: directives else directives in 217 let directives = if no_transform then "no-transform" :: directives else directives in 218 let directives = if only_if_cached then "only-if-cached" :: directives else directives in 219 match directives with 220 | [] -> t 221 | _ -> set "Cache-Control" (String.concat ", " (List.rev directives)) t 222 223let etag value t = 224 set "ETag" value t 225 226let last_modified date t = 227 set "Last-Modified" date t 228 229let last_modified_ptime time t = 230 last_modified (http_date_of_ptime time) t 231 232(* Additional helper for getting multiple header values *) 233let get_multi key t = get_all key t 234 235(* Pretty printer for headers *) 236let pp ppf t = 237 Format.fprintf ppf "@[<v>Headers:@,"; 238 let headers = to_list t in 239 List.iter (fun (k, v) -> 240 Format.fprintf ppf " %s: %s@," k v 241 ) headers; 242 Format.fprintf ppf "@]" 243 244let pp_brief ppf t = 245 let headers = to_list t in 246 let count = List.length headers in 247 Format.fprintf ppf "Headers(%d entries)" count