A batteries included HTTP/1.1 client in OCaml
at main 325 lines 12 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 response handling per 7 {{:https://datatracker.ietf.org/doc/html/rfc9110#section-15}RFC 9110} 8 9 This module represents HTTP responses and provides functions to access 10 status codes, headers, and response bodies. Responses support streaming to 11 efficiently handle large payloads. 12 13 Caching semantics follow 14 {{:https://datatracker.ietf.org/doc/html/rfc9111}RFC 9111} (HTTP Caching). 15 16 {2 Examples} 17 18 {[ 19 (* Check response status *) 20 if Response.ok response then 21 Printf.printf "Success!\n" 22 else 23 Printf.printf "Error: %d\n" (Response.status_code response); 24 25 (* Access headers *) 26 match Response.content_type response with 27 | Some mime -> Printf.printf "Type: %s\n" (Mime.to_string mime) 28 | None -> () 29 30 (* Stream response body *) 31 let body = Response.body response in 32 Eio.Flow.copy body (Eio.Flow.buffer_sink buffer) 33 34 (* Response automatically closes when the switch is released *) 35 ]} 36 37 {b Note}: Responses are automatically closed when the switch they were 38 created with is released. Manual cleanup is not necessary. *) 39 40open Eio 41 42val src : Logs.Src.t 43(** Log source for response operations. *) 44 45type t 46(** Abstract response type representing an HTTP response. *) 47 48val v : 49 sw:Eio.Switch.t -> 50 status:int -> 51 headers:Headers.t -> 52 body:Eio.Flow.source_ty Eio.Resource.t -> 53 url:string -> 54 elapsed:float -> 55 t 56(** [v ~sw ~status ~headers ~body ~url ~elapsed] creates a response. Internal 57 function primarily used for caching. *) 58 59(** {1 Status Information} *) 60 61val status : t -> Status.t 62(** [status response] returns the HTTP status as a {!Status.t} value. *) 63 64val status_code : t -> int 65(** [status_code response] returns the HTTP status code as an integer (e.g., 66 200, 404). *) 67 68val ok : t -> bool 69(** [ok response] returns [true] if the status code is in the 2xx success range. 70 This is an alias for {!Status.is_success}. *) 71 72(** {1 Header Access} *) 73 74val headers : t -> Headers.t 75(** [headers response] returns all response headers. *) 76 77val header : Header_name.t -> t -> string option 78(** [header name response] returns the value of a specific header, or [None] if 79 not present. Header names are case-insensitive. 80 81 Example: [header `Content_type response]. *) 82 83val header_string : string -> t -> string option 84(** [header_string name response] returns the value of a header by string name. 85 Use this when header names come from external sources (e.g., wire format). 86 Header names are case-insensitive. *) 87 88val content_type : t -> Mime.t option 89(** [content_type response] returns the parsed Content-Type header as a MIME 90 type, or [None] if the header is not present or cannot be parsed. *) 91 92val content_length : t -> int64 option 93(** [content_length response] returns the Content-Length in bytes, or [None] if 94 not specified or chunked encoding is used. *) 95 96val location : t -> string option 97(** [location response] returns the Location header value, typically used in 98 redirects. Returns [None] if the header is not present. *) 99 100(** {1 Conditional Request / Caching Headers} 101 102 Per Recommendation #19: Conditional Request Helpers (ETag/Last-Modified) RFC 103 9110 Section 8.8.2-8.8.3 *) 104 105val etag : t -> string option 106(** [etag response] returns the ETag header value, which is an opaque identifier 107 for a specific version of a resource. Use with {!Headers.if_none_match} for 108 conditional requests. Example: ["\"abc123\""] or [W/"abc123"] (weak 109 validator). *) 110 111val last_modified : t -> string option 112(** [last_modified response] returns the Last-Modified header as a raw string. 113 Format: HTTP-date (e.g., ["Sun, 06 Nov 1994 08:49:37 GMT"]). *) 114 115val parse_http_date : string -> Ptime.t option 116(** [parse_http_date s] parses an HTTP-date string (RFC 9110 Section 5.6.7) to 117 Ptime.t. Supports RFC 1123, RFC 850, and ANSI C asctime() formats. Returns 118 [None] if parsing fails. 119 120 This is exposed for use by other modules that need to parse HTTP dates. *) 121 122val last_modified_ptime : t -> Ptime.t option 123(** [last_modified_ptime response] parses the Last-Modified header as a Ptime.t. 124 Returns [None] if the header is not present or cannot be parsed. *) 125 126val date : t -> string option 127(** [date response] returns the Date header (time response was generated). *) 128 129val date_ptime : t -> Ptime.t option 130(** [date_ptime response] parses the Date header as a Ptime.t. *) 131 132val expires : t -> string option 133(** [expires response] returns the Expires header (HTTP/1.0 cache control). 134 Prefer using {!cache_control} for RFC 9111 compliant caching. *) 135 136val expires_ptime : t -> Ptime.t option 137(** [expires_ptime response] parses the Expires header as a Ptime.t. *) 138 139val age : t -> int option 140(** [age response] returns the Age header value in seconds. The Age header 141 indicates how long the response has been in a cache. *) 142 143(** {1 Cache-Control Parsing} 144 145 Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance *) 146 147val cache_control : t -> Cache_control.response option 148(** [cache_control response] parses and returns the Cache-Control header 149 directives. Returns [None] if the header is not present. 150 151 Example: 152 {[ 153 match Response.cache_control response with 154 | Some cc when cc.Cache_control.no_store -> "Do not cache" 155 | Some cc -> Fmt.str "Max age: %d" (Option.get cc.max_age) 156 | None -> "No cache directives" 157 ]} *) 158 159val cache_control_raw : t -> string option 160(** [cache_control_raw response] returns the raw Cache-Control header string 161 without parsing. Useful for debugging or custom parsing. *) 162 163val is_cacheable : t -> bool 164(** [is_cacheable response] returns [true] if the response may be cached based 165 on its status code and Cache-Control directives. A response is cacheable if 166 no-store is not present and either: 167 - Status is cacheable by default (200, 203, 204, 206, 300, 301, 308, 404, 168 405, 410, 414, 501) 169 - Explicit caching directive (max-age, s-maxage) is present. *) 170 171val freshness_lifetime : t -> int option 172(** [freshness_lifetime response] calculates how long the response is fresh in 173 seconds, based on Cache-Control max-age or Expires header. Returns [None] if 174 freshness cannot be determined. *) 175 176val must_revalidate : t -> bool 177(** [must_revalidate response] returns [true] if cached copies must be 178 revalidated with the origin server before use (must-revalidate, 179 proxy-revalidate, or no-cache directive present). *) 180 181val is_stale : now:Ptime.t -> t -> bool 182(** [is_stale ~now response] returns [true] if the response's freshness lifetime 183 has expired. Requires the current time as [now]. Returns [false] if 184 staleness cannot be determined. *) 185 186val is_not_modified : t -> bool 187(** [is_not_modified response] returns [true] if this is a 304 Not Modified 188 response, indicating the cached version is still valid. *) 189 190val vary : t -> string option 191(** [vary response] returns the Vary header, which lists request headers that 192 affect the response (for cache key construction). *) 193 194val vary_headers : t -> string list 195(** [vary_headers response] parses the Vary header into a list of header names. 196 Returns an empty list if Vary is not present. *) 197 198(** {1 Response Metadata} *) 199 200val url : t -> string 201(** [url response] returns the final URL after following any redirects. This may 202 differ from the originally requested URL. *) 203 204val elapsed : t -> float 205(** [elapsed response] returns the time taken for the request in seconds, 206 including connection establishment, sending the request, and receiving 207 headers. *) 208 209(** {1 Response Body} *) 210 211val body : t -> Flow.source_ty Resource.t 212(** [body response] returns the response body as an Eio flow for streaming. This 213 allows efficient processing of large responses without loading them entirely 214 into memory. 215 216 Example: 217 {[ 218 let body = Response.body response in 219 let buffer = Buffer.create 4096 in 220 Eio.Flow.copy body (Eio.Flow.buffer_sink buffer); 221 Buffer.contents buffer 222 ]} *) 223 224val text : t -> string 225(** [text response] reads and returns the entire response body as a string. The 226 response body is fully consumed by this operation. 227 228 @raise Failure if the response has already been closed. *) 229 230val json : t -> Jsont.json 231(** [json response] parses the response body as JSON. The response body is fully 232 consumed by this operation. 233 234 Example: 235 {[ 236 let json = Response.json response in 237 process_json json 238 ]} 239 240 @raise Eio.Io with {!Error.Json_parse_error} if JSON parsing fails. 241 @raise Failure if the response has already been closed. *) 242 243val jsonv : 'a Jsont.t -> t -> 'a 244(** [jsonv codec response] parses the response body as JSON and decodes it to a 245 typed value using the provided [codec]. The response body is fully consumed 246 by this operation. 247 248 This is the preferred way to decode JSON responses into typed OCaml values, 249 as it provides type safety and works with custom record types. 250 251 Example: 252 {[ 253 (* Define a codec for your type *) 254 type user = { name : string; age : int } 255 256 let user_codec = 257 Jsont.Obj.map ~kind:"user" (fun name age -> { name; age }) 258 |> Jsont.Obj.mem "name" Jsont.string ~enc:(fun u -> u.name) 259 |> Jsont.Obj.mem "age" Jsont.int ~enc:(fun u -> u.age) 260 |> Jsont.Obj.finish 261 262 (* Decode the response to a typed value *) 263 let user = Response.jsonv user_codec response in 264 Printf.printf "User: %s, age %d\n" user.name user.age 265 ]} 266 267 @raise Eio.Io with {!Error.Json_parse_error} if JSON parsing fails. 268 @raise Failure if the response has already been closed. *) 269 270val raise_for_status : t -> t 271(** [raise_for_status response] raises [Eio.Io] with [Error.Http_error] if the 272 response status code indicates an error (>= 400). Returns the response 273 unchanged if the status indicates success (< 400). 274 275 This is useful for failing fast on HTTP errors: 276 {[ 277 let response = Requests.get req url |> Response.raise_for_status in 278 (* Only reaches here if status < 400 *) 279 process_success response 280 ]} 281 282 @raise Eio.Io with [Error.Http_error] if status code >= 400. *) 283 284val check_status : t -> (t, Error.t) result 285(** [check_status response] returns [Ok response] if the status code is < 400, 286 or [Error error] if the status code indicates an error (>= 400). 287 288 This provides functional error handling without exceptions, complementing 289 {!raise_for_status} for different coding styles. 290 291 Example: 292 {[ 293 match Response.check_status response with 294 | Ok resp -> process_success resp 295 | Error err -> handle_error err 296 ]} 297 298 Per Recommendation #21: Provides a Result-based alternative to 299 raise_for_status. *) 300 301(** {1 Pretty Printing} *) 302 303val pp : Format.formatter -> t -> unit 304(** Pretty print a response summary. *) 305 306val pp_detailed : Format.formatter -> t -> unit 307(** Pretty print a response with full headers. *) 308 309(** {1 Private API} *) 310 311(** Internal functions exposed for use by other modules in the library. These 312 are not part of the public API and may change between versions. *) 313module Private : sig 314 val make : 315 sw:Eio.Switch.t -> 316 status:int -> 317 headers:Headers.t -> 318 body:Flow.source_ty Resource.t -> 319 url:string -> 320 elapsed:float -> 321 t 322 (** [make ~sw ~status ~headers ~body ~url ~elapsed] constructs a response. The 323 response will be automatically closed when the switch is released. This 324 function is used internally by the Client module. *) 325end