A batteries included HTTP/1.1 client in OCaml
at main 235 lines 7.3 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.response" ~doc:"HTTP Response" 7 8module Log = (val Logs.src_log src : Logs.LOG) 9 10type t = { 11 status : int; 12 headers : Headers.t; 13 body : Eio.Flow.source_ty Eio.Resource.t; 14 url : string; 15 elapsed : float; 16 mutable closed : bool; 17} 18 19let v ~sw ~status ~headers ~body ~url ~elapsed = 20 Log.debug (fun m -> 21 m "Creating response: status=%d url=%s elapsed=%.3fs" status url elapsed); 22 let response = { status; headers; body; url; elapsed; closed = false } in 23 24 (* Register cleanup with switch *) 25 Eio.Switch.on_release sw (fun () -> 26 if not response.closed then begin 27 Log.debug (fun m -> m "Auto-closing response for %s via switch" url); 28 response.closed <- true 29 (* Body cleanup happens automatically via Eio switch lifecycle. 30 The body flow (created via Eio.Flow.string_source) is a memory-backed 31 source that doesn't require explicit cleanup. File-based responses 32 would have their file handles cleaned up by the switch. *) 33 end); 34 35 response 36 37let status t = Status.of_int t.status 38let status_code t = t.status 39let ok t = Status.is_success (Status.of_int t.status) 40let headers t = t.headers 41let header name t = Headers.find name t.headers 42let header_string name t = Headers.string name t.headers 43 44(** Option monad operators for cleaner code *) 45let ( let* ) = Option.bind 46 47let ( let+ ) x f = Option.map f x 48 49let content_type t = 50 let+ ct = Headers.find `Content_type t.headers in 51 Mime.of_string ct 52 53let content_length t = 54 let* len = Headers.find `Content_length t.headers in 55 try Some (Int64.of_string len) with Failure _ -> None 56 57let location t = Headers.find `Location t.headers 58 59(** {1 Conditional Request / Caching Headers} 60 61 Per Recommendation #19: Conditional Request Helpers (ETag/Last-Modified) RFC 62 9110 Section 8.8.2-8.8.3 *) 63 64let etag t = Headers.find `Etag t.headers 65let last_modified t = Headers.find `Last_modified t.headers 66let parse_http_date = Http_date.parse 67 68let last_modified_ptime t = 69 let* lm = last_modified t in 70 Http_date.parse lm 71 72let date t = Headers.find `Date t.headers 73 74let date_ptime t = 75 let* d = date t in 76 Http_date.parse d 77 78let expires t = Headers.find `Expires t.headers 79 80let expires_ptime t = 81 let* exp = expires t in 82 Http_date.parse exp 83 84let age t = 85 let* s = Headers.find `Age t.headers in 86 try Some (int_of_string s) with Failure _ -> None 87 88(** {1 Cache-Control Parsing} 89 90 Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance *) 91 92let cache_control t = 93 Option.map Cache_control.parse_response 94 (Headers.find `Cache_control t.headers) 95 96let cache_control_raw t = Headers.find `Cache_control t.headers 97 98(** Check if response is cacheable based on status and Cache-Control *) 99let is_cacheable t = 100 match cache_control t with 101 | Some cc -> Cache_control.is_cacheable ~response_cc:cc ~status:t.status 102 | None -> 103 (* No Cache-Control - use default cacheability based on status *) 104 List.mem t.status 105 [ 200; 203; 204; 206; 300; 301; 308; 404; 405; 410; 414; 501 ] 106 107(** Calculate freshness lifetime in seconds *) 108let freshness_lifetime t = 109 match cache_control t with 110 | Some cc -> 111 Cache_control.freshness_lifetime ~response_cc:cc ?expires:(expires t) 112 ?date:(date t) () 113 | None -> None 114 115(** Check if response requires revalidation before use *) 116let must_revalidate t = 117 match cache_control t with 118 | Some cc -> Cache_control.must_revalidate ~response_cc:cc 119 | None -> false 120 121(** Check if response is stale (current time exceeds freshness) Requires the 122 current time as a parameter *) 123let is_stale ~now t = 124 match (freshness_lifetime t, date_ptime t) with 125 | Some lifetime, Some response_date -> 126 let response_age = 127 match age t with 128 | Some a -> a 129 | None -> 130 (* Calculate age from Date header *) 131 let diff = Ptime.diff now response_date in 132 Ptime.Span.to_int_s diff |> Option.value ~default:0 133 in 134 response_age > lifetime 135 | _ -> false (* Cannot determine staleness without freshness info *) 136 137(** Check if this is a 304 Not Modified response *) 138let is_not_modified t = t.status = 304 139 140(** Get the Vary header which indicates which request headers affect caching *) 141let vary t = Headers.find `Vary t.headers 142 143(** Parse Vary header into list of header names *) 144let vary_headers t = 145 match vary t with 146 | None -> [] 147 | Some v -> 148 String.split_on_char ',' v |> List.map String.trim 149 |> List.filter (fun s -> s <> "") 150 151let url t = t.url 152let elapsed t = t.elapsed 153 154let body t = 155 if t.closed then invalid_arg "Response.body: response has been closed" 156 else t.body 157 158let text t = 159 if t.closed then invalid_arg "Response.text: response has been closed" 160 else Eio.Buf_read.of_flow t.body ~max_size:max_int |> Eio.Buf_read.take_all 161 162let json t = 163 let body_str = text t in 164 match Jsont_bytesrw.decode_string' Jsont.json body_str with 165 | Ok json -> json 166 | Error e -> 167 let preview = 168 if String.length body_str > 200 then String.sub body_str 0 200 169 else body_str 170 in 171 raise 172 (Error.err 173 (Error.Json_parse_error 174 { body_preview = preview; reason = Jsont.Error.to_string e })) 175 176let jsonv (type a) (codec : a Jsont.t) t = 177 let body_str = text t in 178 match Jsont_bytesrw.decode_string' codec body_str with 179 | Ok value -> value 180 | Error e -> 181 let preview = 182 if String.length body_str > 200 then String.sub body_str 0 200 183 else body_str 184 in 185 raise 186 (Error.err 187 (Error.Json_parse_error 188 { body_preview = preview; reason = Jsont.Error.to_string e })) 189 190let raise_for_status t = 191 if t.status >= 400 then 192 raise 193 (Error.err 194 (Error.Http_error 195 { 196 url = t.url; 197 status = t.status; 198 reason = Status.reason_phrase (Status.of_int t.status); 199 body_preview = None; 200 headers = Headers.to_list t.headers; 201 (* Convert to list for error type *) 202 })) 203 else t 204 205(** Result-based status check - per Recommendation #21. Returns Ok response for 206 2xx success, Error for 4xx/5xx errors. Enables functional error handling 207 without exceptions. *) 208let check_status t = 209 if t.status >= 400 then 210 Error 211 (Error.Http_error 212 { 213 url = t.url; 214 status = t.status; 215 reason = Status.reason_phrase (Status.of_int t.status); 216 body_preview = None; 217 headers = Headers.to_list t.headers; 218 }) 219 else Ok t 220 221(* Pretty printers *) 222let pp ppf t = 223 Fmt.pf ppf 224 "@[<v>Response:@,status: %a@,url: %s@,elapsed: %.3fs@,headers: @[%a@]@]" 225 Status.pp (Status.of_int t.status) t.url t.elapsed Headers.pp_brief 226 t.headers 227 228let pp_detailed ppf t = 229 Fmt.pf ppf "@[<v>Response:@,status: %a@,url: %s@,elapsed: %.3fs@,@[%a@]@]" 230 Status.pp_hum (Status.of_int t.status) t.url t.elapsed Headers.pp t.headers 231 232(* Private module *) 233module Private = struct 234 let make = v 235end