···11+# This file is generated by dune.
22+opam-version: "2.0"
33+name: "http"
44+synopsis: "HTTP types: headers, status codes, methods, bodies, MIME types"
+398
lib/body.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "requests.body" ~doc:"HTTP Request/Response Body"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type 'a part = {
1111+ name : string;
1212+ filename : string option;
1313+ content_type : Mime.t;
1414+ content :
1515+ [ `String of string
1616+ | `Stream of Eio.Flow.source_ty Eio.Resource.t
1717+ | `File of 'a Eio.Path.t ];
1818+}
1919+2020+type t =
2121+ | Empty
2222+ | String of { content : string; mime : Mime.t }
2323+ | Stream of {
2424+ source : Eio.Flow.source_ty Eio.Resource.t;
2525+ mime : Mime.t;
2626+ length : int64 option;
2727+ }
2828+ | File : { file : 'a Eio.Path.t; mime : Mime.t } -> t
2929+ | Multipart : { parts : 'a part list; boundary : string } -> t
3030+3131+let empty = Empty
3232+let of_string mime content = String { content; mime }
3333+let of_stream ?length mime source = Stream { source; mime; length }
3434+3535+let of_file ?mime file =
3636+ let path = Eio.Path.native_exn file in
3737+ let mime =
3838+ Option.value mime
3939+ ~default:
4040+ ((* Use magic-mime library to guess MIME type from file extension *)
4141+ let guessed_str = Magic_mime.lookup path in
4242+ let guessed = Mime.of_string guessed_str in
4343+ Log.debug (fun m ->
4444+ m "Guessed MIME type %s for file %s" (Mime.to_string guessed) path);
4545+ guessed)
4646+ in
4747+ Log.debug (fun m ->
4848+ m "Creating file body from %s with MIME type %s" path
4949+ (Mime.to_string mime));
5050+ File { file; mime }
5151+5252+let json_encoding_error e =
5353+ let msg = Jsont.Error.to_string e in
5454+ raise (Error.err (Error.Json_encode_error { reason = msg }))
5555+5656+(* For simple JSON encoding, we just take a Jsont.json value and encode it *)
5757+let json (json_value : Jsont.json) =
5858+ let content =
5959+ match
6060+ Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value
6161+ with
6262+ | Ok s -> s
6363+ | Error e -> json_encoding_error e
6464+ in
6565+ String { content; mime = Mime.json }
6666+6767+(* Typed JSON encoding using a Jsont.t codec *)
6868+let jsonv (type a) (codec : a Jsont.t) (value : a) =
6969+ let content =
7070+ match Jsont_bytesrw.encode_string' ~format:Jsont.Minify codec value with
7171+ | Ok s -> s
7272+ | Error e -> json_encoding_error e
7373+ in
7474+ String { content; mime = Mime.json }
7575+7676+(* JSON streaming using jsont - we encode the value to string and stream it *)
7777+module Json_stream_source = struct
7878+ type t = { content : string; mutable offset : int }
7979+8080+ let single_read t dst =
8181+ if t.offset >= String.length t.content then raise End_of_file
8282+ else begin
8383+ let available = String.length t.content - t.offset in
8484+ let to_copy = min (Cstruct.length dst) available in
8585+ Cstruct.blit_from_string t.content t.offset dst 0 to_copy;
8686+ t.offset <- t.offset + to_copy;
8787+ to_copy
8888+ end
8989+9090+ let read_methods = []
9191+end
9292+9393+let json_stream_source_create json_value =
9494+ (* Encode the entire JSON value to string with minified format *)
9595+ let content =
9696+ match
9797+ Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value
9898+ with
9999+ | Ok s -> s
100100+ | Error e -> json_encoding_error e
101101+ in
102102+ let t = { Json_stream_source.content; offset = 0 } in
103103+ let ops = Eio.Flow.Pi.source (module Json_stream_source) in
104104+ Eio.Resource.T (t, ops)
105105+106106+let json_stream json_value =
107107+ let source = json_stream_source_create json_value in
108108+ Stream { source; mime = Mime.json; length = None }
109109+110110+let text content = String { content; mime = Mime.text }
111111+112112+let form params =
113113+ let encode_param (k, v) =
114114+ Fmt.str "%s=%s"
115115+ (Uri.pct_encode ~component:`Query_value k)
116116+ (Uri.pct_encode ~component:`Query_value v)
117117+ in
118118+ let content = String.concat "&" (List.map encode_param params) in
119119+ String { content; mime = Mime.form }
120120+121121+let generate_boundary () =
122122+ let random_bytes = Crypto_rng.generate 16 in
123123+ let random_part =
124124+ let buf = Buffer.create (String.length random_bytes * 2) in
125125+ String.iter
126126+ (fun c -> Buffer.add_string buf (Printf.sprintf "%02x" (Char.code c)))
127127+ random_bytes;
128128+ Buffer.contents buf
129129+ in
130130+ Fmt.str "----WebKitFormBoundary%s" random_part
131131+132132+let multipart parts =
133133+ let boundary = generate_boundary () in
134134+ Multipart { parts; boundary }
135135+136136+let content_type = function
137137+ | Empty -> None
138138+ | String { mime; _ } -> Some mime
139139+ | Stream { mime; _ } -> Some mime
140140+ | File { mime; _ } -> Some mime
141141+ | Multipart { boundary; _ } ->
142142+ Some (Mime.multipart_form |> Mime.with_param "boundary" boundary)
143143+144144+let content_length = function
145145+ | Empty -> Some 0L
146146+ | String { content; _ } -> Some (Int64.of_int (String.length content))
147147+ | Stream { length; _ } -> length
148148+ | File { file; _ } -> (
149149+ (* Try to get file size *)
150150+ try
151151+ let stat = Eio.Path.stat ~follow:true file in
152152+ Some (Optint.Int63.to_int64 stat.size)
153153+ with Eio.Io _ -> None)
154154+ | Multipart _ ->
155155+ (* Complex to calculate, handled during sending *)
156156+ None
157157+158158+(* Strings_source - A flow source that streams from a doubly-linked list of strings/flows *)
159159+module Strings_source = struct
160160+ type element = String of string | Flow of Eio.Flow.source_ty Eio.Resource.t
161161+162162+ type t = {
163163+ dllist : element Lwt_dllist.t;
164164+ mutable current_element : element option;
165165+ mutable string_offset : int;
166166+ }
167167+168168+ let rec single_read t dst =
169169+ match t.current_element with
170170+ | None ->
171171+ (* Try to get the first element from the list *)
172172+ if Lwt_dllist.is_empty t.dllist then raise End_of_file
173173+ else begin
174174+ t.current_element <- Some (Lwt_dllist.take_l t.dllist);
175175+ single_read t dst
176176+ end
177177+ | Some (String s) when t.string_offset >= String.length s ->
178178+ (* Current string exhausted, move to next element *)
179179+ t.current_element <- None;
180180+ t.string_offset <- 0;
181181+ single_read t dst
182182+ | Some (String s) ->
183183+ (* Read from current string *)
184184+ let available = String.length s - t.string_offset in
185185+ let to_read = min (Cstruct.length dst) available in
186186+ Cstruct.blit_from_string s t.string_offset dst 0 to_read;
187187+ t.string_offset <- t.string_offset + to_read;
188188+ to_read
189189+ | Some (Flow flow) -> (
190190+ (* Read from flow *)
191191+ try
192192+ let n = Eio.Flow.single_read flow dst in
193193+ if n = 0 then begin
194194+ (* Flow exhausted, move to next element *)
195195+ t.current_element <- None;
196196+ single_read t dst
197197+ end
198198+ else n
199199+ with End_of_file ->
200200+ t.current_element <- None;
201201+ single_read t dst)
202202+203203+ let read_methods = [] (* No special read methods *)
204204+205205+ let create () =
206206+ { dllist = Lwt_dllist.create (); current_element = None; string_offset = 0 }
207207+208208+ let add_string t s = ignore (Lwt_dllist.add_r (String s) t.dllist)
209209+ let add_flow t flow = ignore (Lwt_dllist.add_r (Flow flow) t.dllist)
210210+end
211211+212212+let strings_source_create () =
213213+ let t = Strings_source.create () in
214214+ let ops = Eio.Flow.Pi.source (module Strings_source) in
215215+ (t, Eio.Resource.T (t, ops))
216216+217217+let to_flow_source ~sw = function
218218+ | Empty -> None
219219+ | String { content; _ } -> Some (Eio.Flow.string_source content)
220220+ | Stream { source; _ } -> Some source
221221+ | File { file; _ } ->
222222+ (* Open file and stream it directly without loading into memory *)
223223+ let flow = Eio.Path.open_in ~sw file in
224224+ Some (flow :> Eio.Flow.source_ty Eio.Resource.t)
225225+ | Multipart { parts; boundary } ->
226226+ (* Create a single strings_source with dllist for streaming *)
227227+ let source, flow = strings_source_create () in
228228+229229+ List.iter
230230+ (fun part ->
231231+ (* Add boundary *)
232232+ Strings_source.add_string source "--";
233233+ Strings_source.add_string source boundary;
234234+ Strings_source.add_string source "\r\n";
235235+236236+ (* Add Content-Disposition header *)
237237+ Strings_source.add_string source
238238+ "Content-Disposition: form-data; name=\"";
239239+ Strings_source.add_string source part.name;
240240+ Strings_source.add_string source "\"";
241241+ (match part.filename with
242242+ | Some f ->
243243+ Strings_source.add_string source "; filename=\"";
244244+ Strings_source.add_string source f;
245245+ Strings_source.add_string source "\""
246246+ | None -> ());
247247+ Strings_source.add_string source "\r\n";
248248+249249+ (* Add Content-Type header *)
250250+ Strings_source.add_string source "Content-Type: ";
251251+ Strings_source.add_string source (Mime.to_string part.content_type);
252252+ Strings_source.add_string source "\r\n\r\n";
253253+254254+ (* Add content *)
255255+ (match part.content with
256256+ | `String s -> Strings_source.add_string source s
257257+ | `File file ->
258258+ (* Open file and add as flow *)
259259+ let file_flow = Eio.Path.open_in ~sw file in
260260+ Strings_source.add_flow source
261261+ (file_flow :> Eio.Flow.source_ty Eio.Resource.t)
262262+ | `Stream stream ->
263263+ (* Add stream directly *)
264264+ Strings_source.add_flow source stream);
265265+266266+ (* Add trailing newline *)
267267+ Strings_source.add_string source "\r\n")
268268+ parts;
269269+270270+ (* Add final boundary *)
271271+ Strings_source.add_string source "--";
272272+ Strings_source.add_string source boundary;
273273+ Strings_source.add_string source "--\r\n";
274274+275275+ Some flow
276276+277277+(* Private module *)
278278+module Private = struct
279279+ let to_flow_source = to_flow_source
280280+281281+ let to_string = function
282282+ | Empty -> ""
283283+ | String { content; _ } -> content
284284+ | Stream _ ->
285285+ invalid_arg
286286+ "Body.Private.to_string: cannot convert streaming body (must be \
287287+ materialized first)"
288288+ | File _ ->
289289+ invalid_arg
290290+ "Body.Private.to_string: cannot convert file body (must be read \
291291+ first)"
292292+ | Multipart _ ->
293293+ invalid_arg
294294+ "Body.Private.to_string: cannot convert multipart body (must be \
295295+ encoded first)"
296296+297297+ let is_empty = function Empty -> true | _ -> false
298298+299299+ let is_chunked = function
300300+ | Empty -> false
301301+ | String _ -> false
302302+ | Stream { length = Some _; _ } -> false
303303+ | Stream { length = None; _ } -> true
304304+ | File _ -> false
305305+ | Multipart _ -> true
306306+307307+ module Write = Eio.Buf_write
308308+309309+ let crlf w = Write.string w "\r\n"
310310+311311+ (** Copy from a flow source to the writer *)
312312+ let write_stream w source =
313313+ let cs = Cstruct.create 8192 in
314314+ let rec copy () =
315315+ match Eio.Flow.single_read source cs with
316316+ | n ->
317317+ Write.bytes w ~off:0 ~len:n (Cstruct.to_bytes cs);
318318+ copy ()
319319+ | exception End_of_file -> ()
320320+ in
321321+ copy ()
322322+323323+ (** Write a chunk with hex size prefix *)
324324+ let write_chunk w cs len =
325325+ Write.printf w "%x" len;
326326+ crlf w;
327327+ Write.bytes w ~off:0 ~len (Cstruct.to_bytes cs);
328328+ crlf w
329329+330330+ (** Copy from a flow source using chunked transfer encoding *)
331331+ let write_stream_chunked w source =
332332+ let cs = Cstruct.create 8192 in
333333+ let rec copy () =
334334+ match Eio.Flow.single_read source cs with
335335+ | n ->
336336+ write_chunk w cs n;
337337+ copy ()
338338+ | exception End_of_file ->
339339+ (* Final chunk *)
340340+ Write.string w "0";
341341+ crlf w;
342342+ crlf w
343343+ in
344344+ copy ()
345345+346346+ let write ~sw w = function
347347+ | Empty -> ()
348348+ | String { content; _ } -> if content <> "" then Write.string w content
349349+ | Stream { source; _ } -> write_stream w source
350350+ | File { file; _ } ->
351351+ let flow = Eio.Path.open_in ~sw file in
352352+ write_stream w (flow :> Eio.Flow.source_ty Eio.Resource.t)
353353+ | Multipart _ as body -> (
354354+ (* For multipart, get the flow source and write it *)
355355+ match to_flow_source ~sw body with
356356+ | Some source -> write_stream w source
357357+ | None -> ())
358358+359359+ let write_chunked ~sw w = function
360360+ | Empty ->
361361+ (* Empty body with chunked encoding is just final chunk *)
362362+ Write.string w "0";
363363+ crlf w;
364364+ crlf w
365365+ | String { content; _ } ->
366366+ if content <> "" then begin
367367+ Write.printf w "%x" (String.length content);
368368+ crlf w;
369369+ Write.string w content;
370370+ crlf w
371371+ end;
372372+ Write.string w "0";
373373+ crlf w;
374374+ crlf w
375375+ | Stream { source; _ } -> write_stream_chunked w source
376376+ | File { file; _ } ->
377377+ let flow = Eio.Path.open_in ~sw file in
378378+ write_stream_chunked w (flow :> Eio.Flow.source_ty Eio.Resource.t)
379379+ | Multipart _ as body -> (
380380+ match to_flow_source ~sw body with
381381+ | Some source -> write_stream_chunked w source
382382+ | None ->
383383+ Write.string w "0";
384384+ crlf w;
385385+ crlf w)
386386+end
387387+388388+let pp fmt = function
389389+ | Empty -> Format.pp_print_string fmt "Empty"
390390+ | String { content; mime } ->
391391+ Fmt.pf fmt "String(%s, %d bytes)" (Mime.to_string mime)
392392+ (String.length content)
393393+ | Stream { mime; length; _ } ->
394394+ Fmt.pf fmt "Stream(%s, %s)" (Mime.to_string mime)
395395+ (Option.fold ~none:"unknown" ~some:Int64.to_string length)
396396+ | File { mime; _ } -> Fmt.pf fmt "File(%s)" (Mime.to_string mime)
397397+ | Multipart { parts; _ } ->
398398+ Fmt.pf fmt "Multipart(%d parts)" (List.length parts)
+220
lib/body.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP request body construction
77+88+ This module provides various ways to construct HTTP request bodies,
99+ including strings, files, streams, forms, and multipart data.
1010+1111+ {2 Examples}
1212+1313+ {[
1414+ (* Simple text body *)
1515+ let body = Body.text "Hello, World!"
1616+1717+ (* JSON body *)
1818+ let body = Body.json {|{"name": "Alice", "age": 30}|}
1919+2020+ (* Form data *)
2121+ let body = Body.form [ ("username", "alice"); ("password", "secret") ]
2222+2323+ (* File upload *)
2424+ let body = Body.of_file ~mime:Mime.pdf Eio.Path.(fs / "document.pdf")
2525+2626+ (* Multipart form with file *)
2727+ let body =
2828+ Body.multipart
2929+ [
3030+ {
3131+ name = "field";
3232+ filename = None;
3333+ content_type = Mime.text_plain;
3434+ content = `String "value";
3535+ };
3636+ {
3737+ name = "file";
3838+ filename = Some "photo.jpg";
3939+ content_type = Mime.jpeg;
4040+ content = `File Eio.Path.(fs / "photo.jpg");
4141+ };
4242+ ]
4343+ ]} *)
4444+4545+val src : Logs.Src.t
4646+(** Log source for body operations. *)
4747+4848+type t
4949+(** Abstract body type representing HTTP request body content. *)
5050+5151+val pp : Format.formatter -> t -> unit
5252+(** [pp fmt body] pretty-prints [body]. *)
5353+5454+(** {1 Basic Constructors} *)
5555+5656+val empty : t
5757+(** [empty] creates an empty body (no content). *)
5858+5959+val of_string : Mime.t -> string -> t
6060+(** [of_string mime content] creates a body from a string with the specified
6161+ MIME type. Example: [of_string Mime.json {|{"key": "value"}|}]. *)
6262+6363+val of_stream :
6464+ ?length:int64 -> Mime.t -> Eio.Flow.source_ty Eio.Resource.t -> t
6565+(** [of_stream ?length mime stream] creates a streaming body. If [length] is
6666+ provided, it will be used for the Content-Length header, otherwise chunked
6767+ encoding is used. *)
6868+6969+val of_file : ?mime:Mime.t -> _ Eio.Path.t -> t
7070+(** [of_file ?mime path] creates a body from a file. If [mime] is not provided,
7171+ the MIME type is automatically detected from the file extension using the
7272+ {{:https://github.com/mirage/ocaml-magic-mime}magic-mime} library, which
7373+ provides accurate MIME type mappings for hundreds of file extensions. *)
7474+7575+(** {1 Convenience Constructors} *)
7676+7777+val json : Jsont.json -> t
7878+(** [json value] creates a JSON body from a Jsont.json value. The value is
7979+ encoded to a JSON string with Content-Type: application/json.
8080+8181+ Example:
8282+ {[
8383+ let body =
8484+ Body.json
8585+ (Jsont.Object
8686+ ( [
8787+ ("status", Jsont.String "success");
8888+ ("count", Jsont.Number 42.);
8989+ ( "items",
9090+ Jsont.Array
9191+ ( [ Jsont.String "first"; Jsont.String "second" ],
9292+ Jsont.Meta.none ) );
9393+ ],
9494+ Jsont.Meta.none ))
9595+ ]} *)
9696+9797+val jsonv : 'a Jsont.t -> 'a -> t
9898+(** [jsonv codec value] creates a JSON body by encoding [value] using the typed
9999+ [codec]. The value is encoded to a minified JSON string with Content-Type:
100100+ application/json.
101101+102102+ This is the preferred way to create JSON bodies from typed OCaml values, as
103103+ it provides type safety and works with custom record types.
104104+105105+ Example:
106106+ {[
107107+ (* Define a codec for your type *)
108108+ type user = { name : string; age : int }
109109+110110+ let user_codec =
111111+ Jsont.Obj.map ~kind:"user" (fun name age -> { name; age })
112112+ |> Jsont.Obj.mem "name" Jsont.string ~enc:(fun u -> u.name)
113113+ |> Jsont.Obj.mem "age" Jsont.int ~enc:(fun u -> u.age)
114114+ |> Jsont.Obj.finish
115115+116116+ (* Create a JSON body from a typed value *)
117117+ let body = Body.jsonv user_codec { name = "Alice"; age = 30 }
118118+ ]}
119119+120120+ @raise Eio.Io with {!Error.Json_encode_error} if encoding fails. *)
121121+122122+val json_stream : Jsont.json -> t
123123+(** [json_stream json_value] creates a streaming JSON body from a Jsont.json
124124+ value. The JSON value will be encoded to a minified JSON string and
125125+ streamed.
126126+127127+ Example:
128128+ {[
129129+ let large_data = Jsont.Object ([
130130+ ("users", Jsont.Array ([...], Jsont.Meta.none))
131131+ ], Jsont.Meta.none) in
132132+ let body = Body.json_stream large_data
133133+ ]} *)
134134+135135+val text : string -> t
136136+(** [text str] creates a plain text body with Content-Type: text/plain. *)
137137+138138+val form : (string * string) list -> t
139139+(** [form fields] creates a URL-encoded form body with Content-Type:
140140+ application/x-www-form-urlencoded. Example:
141141+ [form [("username", "alice"); ("password", "secret")]]. *)
142142+143143+(** {1 Multipart Support} *)
144144+145145+type 'a part = {
146146+ name : string; (** Form field name *)
147147+ filename : string option; (** Optional filename for file uploads *)
148148+ content_type : Mime.t; (** MIME type of this part *)
149149+ content :
150150+ [ `String of string (** String content *)
151151+ | `Stream of Eio.Flow.source_ty Eio.Resource.t (** Streaming content *)
152152+ | `File of 'a Eio.Path.t (** File content *) ];
153153+}
154154+(** A single part in a multipart body. *)
155155+156156+val multipart : _ part list -> t
157157+(** [multipart parts] creates a multipart/form-data body from a list of parts.
158158+ This is commonly used for file uploads and complex form submissions.
159159+160160+ Example:
161161+ {[
162162+ let body =
163163+ Body.multipart
164164+ [
165165+ {
166166+ name = "username";
167167+ filename = None;
168168+ content_type = Mime.text_plain;
169169+ content = `String "alice";
170170+ };
171171+ {
172172+ name = "avatar";
173173+ filename = Some "photo.jpg";
174174+ content_type = Mime.jpeg;
175175+ content = `File Eio.Path.(fs / "photo.jpg");
176176+ };
177177+ ]
178178+ ]} *)
179179+180180+(** {1 Properties} *)
181181+182182+val content_type : t -> Mime.t option
183183+(** [content_type body] returns the MIME type of the body, if set. *)
184184+185185+val content_length : t -> int64 option
186186+(** [content_length body] returns the content length in bytes, if known. Returns
187187+ [None] for streaming bodies without a predetermined length. *)
188188+189189+(** {1 Private API} *)
190190+191191+(** Internal functions exposed for use by other modules in the library. These
192192+ are not part of the public API and may change between versions. *)
193193+module Private : sig
194194+ val to_flow_source :
195195+ sw:Eio.Switch.t -> t -> Eio.Flow.source_ty Eio.Resource.t option
196196+ (** [to_flow_source ~sw body] converts the body to an Eio flow source. Uses
197197+ the switch to manage resources like file handles. This function is used
198198+ internally by the Client module. *)
199199+200200+ val to_string : t -> string
201201+ (** [to_string body] converts the body to a string for HTTP/1.1 requests. Only
202202+ works for materialized bodies (String type). Raises Failure for
203203+ streaming/file/multipart bodies. *)
204204+205205+ val is_empty : t -> bool
206206+ (** [is_empty body] returns true if the body is empty. *)
207207+208208+ val is_chunked : t -> bool
209209+ (** [is_chunked body] returns true if the body should use chunked transfer
210210+ encoding (i.e., it's a stream without known length or a multipart body).
211211+ *)
212212+213213+ val write : sw:Eio.Switch.t -> Eio.Buf_write.t -> t -> unit
214214+ (** [write ~sw w body] writes the body content to the buffer writer. Uses the
215215+ switch to manage resources like file handles. *)
216216+217217+ val write_chunked : sw:Eio.Switch.t -> Eio.Buf_write.t -> t -> unit
218218+ (** [write_chunked ~sw w body] writes the body content using HTTP chunked
219219+ transfer encoding. Each chunk is prefixed with its hex size. *)
220220+end
+495
lib/cache_control.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP Cache-Control header parsing per RFC 9111 (HTTP Caching)
77+88+ This module provides parsing and representation of Cache-Control directives
99+ for both requests and responses. It supports all standard directives from
1010+ RFC 9111 Section 5.2.
1111+1212+ Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance *)
1313+1414+let src = Logs.Src.create "requests.cache_control" ~doc:"HTTP Cache-Control"
1515+1616+module Log = (val Logs.src_log src : Logs.LOG)
1717+1818+(** {1 Response Cache-Control Directives}
1919+2020+ RFC 9111 Section 5.2.2: Cache-Control Response Directives *)
2121+2222+type response_directive =
2323+ | Max_age of int (** max-age=N - response is fresh for N seconds *)
2424+ | S_maxage of int (** s-maxage=N - shared cache max-age *)
2525+ | No_cache of string list (** no-cache[=headers] - must revalidate *)
2626+ | No_store (** no-store - must not be stored *)
2727+ | No_transform (** no-transform - must not be transformed *)
2828+ | Must_revalidate (** must-revalidate - stale must be revalidated *)
2929+ | Proxy_revalidate (** proxy-revalidate - shared caches must revalidate *)
3030+ | Must_understand (** must-understand - RFC 9111 *)
3131+ | Private of string list (** private[=headers] - only private cache *)
3232+ | Public (** public - can be stored by any cache *)
3333+ | Immutable (** immutable - will not change during freshness *)
3434+ | Stale_while_revalidate of int (** stale-while-revalidate=N *)
3535+ | Stale_if_error of int (** stale-if-error=N *)
3636+ | Response_extension of string * string option (** Unknown directive *)
3737+3838+(** {1 Request Cache-Control Directives}
3939+4040+ RFC 9111 Section 5.2.1: Cache-Control Request Directives *)
4141+4242+type request_directive =
4343+ | Req_max_age of int (** max-age=N *)
4444+ | Req_max_stale of int option (** max-stale[=N] *)
4545+ | Req_min_fresh of int (** min-fresh=N *)
4646+ | Req_no_cache (** no-cache *)
4747+ | Req_no_store (** no-store *)
4848+ | Req_no_transform (** no-transform *)
4949+ | Req_only_if_cached (** only-if-cached *)
5050+ | Request_extension of string * string option (** Unknown directive *)
5151+5252+type response = {
5353+ max_age : int option;
5454+ s_maxage : int option;
5555+ no_cache : string list option;
5656+ (** None = not present, Some [] = present without headers *)
5757+ no_store : bool;
5858+ no_transform : bool;
5959+ must_revalidate : bool;
6060+ proxy_revalidate : bool;
6161+ must_understand : bool;
6262+ private_ : string list option;
6363+ (** None = not present, Some [] = present without headers *)
6464+ public : bool;
6565+ immutable : bool;
6666+ stale_while_revalidate : int option;
6767+ stale_if_error : int option;
6868+ extensions : (string * string option) list;
6969+}
7070+(** Parsed response Cache-Control header *)
7171+7272+type request = {
7373+ req_max_age : int option;
7474+ req_max_stale : int option option;
7575+ (** None = not present, Some None = present without value *)
7676+ req_min_fresh : int option;
7777+ req_no_cache : bool;
7878+ req_no_store : bool;
7979+ req_no_transform : bool;
8080+ req_only_if_cached : bool;
8181+ req_extensions : (string * string option) list;
8282+}
8383+(** Parsed request Cache-Control header *)
8484+8585+(** {1 Parsing Functions} *)
8686+8787+let empty_response =
8888+ {
8989+ max_age = None;
9090+ s_maxage = None;
9191+ no_cache = None;
9292+ no_store = false;
9393+ no_transform = false;
9494+ must_revalidate = false;
9595+ proxy_revalidate = false;
9696+ must_understand = false;
9797+ private_ = None;
9898+ public = false;
9999+ immutable = false;
100100+ stale_while_revalidate = None;
101101+ stale_if_error = None;
102102+ extensions = [];
103103+ }
104104+105105+let empty_request =
106106+ {
107107+ req_max_age = None;
108108+ req_max_stale = None;
109109+ req_min_fresh = None;
110110+ req_no_cache = false;
111111+ req_no_store = false;
112112+ req_no_transform = false;
113113+ req_only_if_cached = false;
114114+ req_extensions = [];
115115+ }
116116+117117+(** Parse a single token (alphanumeric + some punctuation) *)
118118+let parse_token s start =
119119+ let len = String.length s in
120120+ let rec find_end i =
121121+ if i >= len then i
122122+ else
123123+ match s.[i] with
124124+ | 'a' .. 'z'
125125+ | 'A' .. 'Z'
126126+ | '0' .. '9'
127127+ | '-' | '_' | '.' | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '^'
128128+ | '`' | '|' | '~' ->
129129+ find_end (i + 1)
130130+ | _ -> i
131131+ in
132132+ let end_pos = find_end start in
133133+ if end_pos = start then None
134134+ else Some (String.sub s start (end_pos - start), end_pos)
135135+136136+(** Parse a quoted string starting at position (after opening quote) *)
137137+let parse_quoted_string s start =
138138+ let buf = Buffer.create 32 in
139139+ let len = String.length s in
140140+ let rec loop i =
141141+ if i >= len then None (* Unterminated quote *)
142142+ else
143143+ match s.[i] with
144144+ | '"' -> Some (Buffer.contents buf, i + 1)
145145+ | '\\' when i + 1 < len ->
146146+ Buffer.add_char buf s.[i + 1];
147147+ loop (i + 2)
148148+ | c ->
149149+ Buffer.add_char buf c;
150150+ loop (i + 1)
151151+ in
152152+ loop start
153153+154154+(** Parse a directive value (token or quoted-string) *)
155155+let parse_value s start =
156156+ let len = String.length s in
157157+ if start >= len then None
158158+ else if s.[start] = '"' then parse_quoted_string s (start + 1)
159159+ else parse_token s start
160160+161161+(** Parse comma-separated header list (for no-cache=, private=) *)
162162+let parse_header_list s =
163163+ (* Handle quoted list like "Accept, Accept-Encoding" *)
164164+ let s = String.trim s in
165165+ let s =
166166+ if String.length s >= 2 && s.[0] = '"' && s.[String.length s - 1] = '"' then
167167+ String.sub s 1 (String.length s - 2)
168168+ else s
169169+ in
170170+ String.split_on_char ',' s |> List.map String.trim
171171+ |> List.filter (fun s -> s <> "")
172172+173173+(** Skip whitespace and optional comma *)
174174+let skip_ws_comma s start =
175175+ let len = String.length s in
176176+ let rec loop i =
177177+ if i >= len then i
178178+ else match s.[i] with ' ' | '\t' | ',' -> loop (i + 1) | _ -> i
179179+ in
180180+ loop start
181181+182182+(** Parse all directives from a Cache-Control header value *)
183183+let parse_directives s =
184184+ let s = String.trim s in
185185+ let len = String.length s in
186186+ let rec loop i acc =
187187+ if i >= len then List.rev acc
188188+ else
189189+ let i = skip_ws_comma s i in
190190+ if i >= len then List.rev acc
191191+ else
192192+ match parse_token s i with
193193+ | None -> List.rev acc (* Invalid, stop parsing *)
194194+ | Some (name, next_pos) ->
195195+ let name_lower = String.lowercase_ascii name in
196196+ (* Check for =value *)
197197+ let next_pos = skip_ws_comma s next_pos in
198198+ if next_pos < len && s.[next_pos] = '=' then
199199+ let value_start = skip_ws_comma s (next_pos + 1) in
200200+ match parse_value s value_start with
201201+ | Some (value, end_pos) ->
202202+ loop (skip_ws_comma s end_pos)
203203+ ((name_lower, Some value) :: acc)
204204+ | None ->
205205+ loop
206206+ (skip_ws_comma s (next_pos + 1))
207207+ ((name_lower, None) :: acc)
208208+ else loop next_pos ((name_lower, None) :: acc)
209209+ in
210210+ loop 0 []
211211+212212+(** Parse response Cache-Control header *)
213213+let parse_response header_value =
214214+ let directives = parse_directives header_value in
215215+ Log.debug (fun m -> m "Parsing response Cache-Control: %s" header_value);
216216+ List.fold_left
217217+ (fun acc (name, value) ->
218218+ match (name, value) with
219219+ | "max-age", Some v -> (
220220+ try { acc with max_age = Some (int_of_string v) }
221221+ with Failure _ -> acc)
222222+ | "s-maxage", Some v -> (
223223+ try { acc with s_maxage = Some (int_of_string v) }
224224+ with Failure _ -> acc)
225225+ | "no-cache", None -> { acc with no_cache = Some [] }
226226+ | "no-cache", Some v -> { acc with no_cache = Some (parse_header_list v) }
227227+ | "no-store", _ -> { acc with no_store = true }
228228+ | "no-transform", _ -> { acc with no_transform = true }
229229+ | "must-revalidate", _ -> { acc with must_revalidate = true }
230230+ | "proxy-revalidate", _ -> { acc with proxy_revalidate = true }
231231+ | "must-understand", _ -> { acc with must_understand = true }
232232+ | "private", None -> { acc with private_ = Some [] }
233233+ | "private", Some v -> { acc with private_ = Some (parse_header_list v) }
234234+ | "public", _ -> { acc with public = true }
235235+ | "immutable", _ -> { acc with immutable = true }
236236+ | "stale-while-revalidate", Some v -> (
237237+ try { acc with stale_while_revalidate = Some (int_of_string v) }
238238+ with Failure _ -> acc)
239239+ | "stale-if-error", Some v -> (
240240+ try { acc with stale_if_error = Some (int_of_string v) }
241241+ with Failure _ -> acc)
242242+ | other, v -> { acc with extensions = (other, v) :: acc.extensions })
243243+ empty_response directives
244244+245245+(** Parse request Cache-Control header *)
246246+let parse_request header_value =
247247+ let directives = parse_directives header_value in
248248+ Log.debug (fun m -> m "Parsing request Cache-Control: %s" header_value);
249249+ List.fold_left
250250+ (fun acc (name, value) ->
251251+ match (name, value) with
252252+ | "max-age", Some v -> (
253253+ try { acc with req_max_age = Some (int_of_string v) }
254254+ with Failure _ -> acc)
255255+ | "max-stale", None -> { acc with req_max_stale = Some None }
256256+ | "max-stale", Some v -> (
257257+ try { acc with req_max_stale = Some (Some (int_of_string v)) }
258258+ with Failure _ -> { acc with req_max_stale = Some None })
259259+ | "min-fresh", Some v -> (
260260+ try { acc with req_min_fresh = Some (int_of_string v) }
261261+ with Failure _ -> acc)
262262+ | "no-cache", _ -> { acc with req_no_cache = true }
263263+ | "no-store", _ -> { acc with req_no_store = true }
264264+ | "no-transform", _ -> { acc with req_no_transform = true }
265265+ | "only-if-cached", _ -> { acc with req_only_if_cached = true }
266266+ | other, v ->
267267+ { acc with req_extensions = (other, v) :: acc.req_extensions })
268268+ empty_request directives
269269+270270+(** {1 Freshness Calculation}
271271+272272+ RFC 9111 Section 4.2: Freshness *)
273273+274274+(** Calculate freshness lifetime from response directives and headers. Returns
275275+ freshness lifetime in seconds, or None if not cacheable. *)
276276+let freshness_lifetime ~response_cc ?expires ?date () =
277277+ (* RFC 9111 Section 4.2.1: Priority:
278278+ 1. s-maxage (shared caches only, we skip this)
279279+ 2. max-age
280280+ 3. Expires - Date
281281+ 4. Heuristic (we return None, let caller decide) *)
282282+ let ( let* ) = Option.bind in
283283+ match response_cc.max_age with
284284+ | Some age -> Some age
285285+ | None -> (
286286+ match (expires, date) with
287287+ | Some exp_str, Some date_str ->
288288+ (* Use Http_date.parse to parse HTTP dates *)
289289+ let* exp_time = Http_date.parse exp_str in
290290+ let* date_time = Http_date.parse date_str in
291291+ let diff = Ptime.diff exp_time date_time in
292292+ Ptime.Span.to_int_s diff
293293+ | _ -> None)
294294+295295+(** {1 Age Calculation}
296296+297297+ RFC 9111 Section 4.2.3: Calculating Age *)
298298+299299+type age_inputs = {
300300+ date_value : Ptime.t option;
301301+ (** Value of Date header (when response was generated) *)
302302+ age_value : int; (** Value of Age header in seconds (0 if not present) *)
303303+ request_time : Ptime.t; (** Time when the request was initiated *)
304304+ response_time : Ptime.t; (** Time when the response was received *)
305305+}
306306+(** Age calculation inputs *)
307307+308308+(** Calculate the current age of a cached response. Per RFC 9111 Section 4.2.3:
309309+310310+ {v
311311+ apparent_age = max(0, response_time - date_value)
312312+ response_delay = response_time - request_time
313313+ corrected_age_value = age_value + response_delay
314314+ corrected_initial_age = max(apparent_age, corrected_age_value)
315315+ resident_time = now - response_time
316316+ current_age = corrected_initial_age + resident_time
317317+ v}
318318+319319+ @param inputs Age calculation inputs
320320+ @param now Current time
321321+ @return Current age in seconds *)
322322+let calculate_age ~inputs ~now =
323323+ (* apparent_age = max(0, response_time - date_value) *)
324324+ let apparent_age =
325325+ match inputs.date_value with
326326+ | Some date ->
327327+ let diff = Ptime.diff inputs.response_time date in
328328+ max 0 (Option.value ~default:0 (Ptime.Span.to_int_s diff))
329329+ | None -> 0
330330+ in
331331+ (* response_delay = response_time - request_time *)
332332+ let response_delay =
333333+ let diff = Ptime.diff inputs.response_time inputs.request_time in
334334+ max 0 (Option.value ~default:0 (Ptime.Span.to_int_s diff))
335335+ in
336336+ (* corrected_age_value = age_value + response_delay *)
337337+ let corrected_age_value = inputs.age_value + response_delay in
338338+ (* corrected_initial_age = max(apparent_age, corrected_age_value) *)
339339+ let corrected_initial_age = max apparent_age corrected_age_value in
340340+ (* resident_time = now - response_time *)
341341+ let resident_time =
342342+ let diff = Ptime.diff now inputs.response_time in
343343+ max 0 (Option.value ~default:0 (Ptime.Span.to_int_s diff))
344344+ in
345345+ (* current_age = corrected_initial_age + resident_time *)
346346+ corrected_initial_age + resident_time
347347+348348+(** {1 Heuristic Freshness}
349349+350350+ RFC 9111 Section 4.2.2: Calculating Heuristic Freshness *)
351351+352352+(** Default heuristic fraction: 10% of time since Last-Modified. RFC 9111
353353+ recommends this as a typical value. *)
354354+let default_heuristic_fraction = 0.10
355355+356356+(** Maximum heuristic freshness lifetime: 1 day (86400 seconds). This prevents
357357+ excessively long heuristic caching. *)
358358+let default_max_heuristic_age = 86400
359359+360360+(** Calculate heuristic freshness lifetime when no explicit caching info
361361+ provided. Per RFC 9111 Section 4.2.2, caches MAY use heuristics when
362362+ explicit freshness is not available.
363363+364364+ @param last_modified Value of Last-Modified header
365365+ @param response_time When the response was received
366366+ @param fraction Fraction of (now - last_modified) to use (default 10%)
367367+ @param max_age Maximum heuristic age in seconds (default 1 day)
368368+ @return Heuristic freshness lifetime in seconds, or None *)
369369+let heuristic_freshness ?last_modified ~response_time
370370+ ?(fraction = default_heuristic_fraction)
371371+ ?(max_age = default_max_heuristic_age) () =
372372+ match last_modified with
373373+ | Some lm_str -> (
374374+ match Http_date.parse lm_str with
375375+ | Some lm_time ->
376376+ let age_since_modified =
377377+ let diff = Ptime.diff response_time lm_time in
378378+ max 0 (Option.value ~default:0 (Ptime.Span.to_int_s diff))
379379+ in
380380+ let heuristic =
381381+ int_of_float (float_of_int age_since_modified *. fraction)
382382+ in
383383+ Some (min heuristic max_age)
384384+ | None ->
385385+ Log.debug (fun m -> m "Failed to parse Last-Modified: %s" lm_str);
386386+ None)
387387+ | None -> None
388388+389389+(** Check if a cached response is fresh.
390390+391391+ @param current_age Current age from calculate_age
392392+ @param freshness_lifetime From freshness_lifetime or heuristic_freshness
393393+ @return true if the response is still fresh *)
394394+let is_fresh ~current_age ~freshness_lifetime = current_age < freshness_lifetime
395395+396396+(** Check if a stale response can still be served based on request directives.
397397+398398+ @param request_cc Parsed request Cache-Control
399399+ @param current_age Current age of the cached response
400400+ @param freshness_lifetime Freshness lifetime of the cached response
401401+ @return true if the stale response can be served *)
402402+let can_serve_stale ~request_cc ~current_age ~freshness_lifetime =
403403+ let staleness = current_age - freshness_lifetime in
404404+ if staleness <= 0 then true (* Not stale *)
405405+ else
406406+ match request_cc.req_max_stale with
407407+ | Some None -> true (* max-stale without value: accept any staleness *)
408408+ | Some (Some allowed_stale) -> staleness <= allowed_stale
409409+ | None -> false (* No max-stale: don't serve stale *)
410410+411411+(** Check if a response is cacheable based on Cache-Control directives *)
412412+let is_cacheable ~response_cc ~status =
413413+ (* RFC 9111 Section 3: A response is cacheable if:
414414+ - no-store is not present
415415+ - status is cacheable by default OR explicit caching directive present *)
416416+ if response_cc.no_store then false
417417+ else
418418+ (* Default cacheable statuses per RFC 9110 Section 15.1 *)
419419+ let default_cacheable =
420420+ List.mem status
421421+ [ 200; 203; 204; 206; 300; 301; 308; 404; 405; 410; 414; 501 ]
422422+ in
423423+ default_cacheable
424424+ || Option.is_some response_cc.max_age
425425+ || Option.is_some response_cc.s_maxage
426426+427427+(** Check if response requires revalidation before use *)
428428+let must_revalidate ~response_cc =
429429+ response_cc.must_revalidate || response_cc.proxy_revalidate
430430+ || Option.is_some response_cc.no_cache
431431+432432+(** Check if response can be stored in shared caches *)
433433+let is_public ~response_cc =
434434+ response_cc.public && not (Option.is_some response_cc.private_)
435435+436436+(** Check if response can only be stored in private caches *)
437437+let is_private ~response_cc = Option.is_some response_cc.private_
438438+439439+(** {1 Pretty Printers} *)
440440+441441+let add_opt_int key v acc =
442442+ match v with Some n -> Fmt.str "%s=%d" key n :: acc | None -> acc
443443+444444+let add_flag key b acc = if b then key :: acc else acc
445445+446446+let add_opt_field key v acc =
447447+ match v with
448448+ | Some [] -> key :: acc
449449+ | Some hs -> Fmt.str "%s=\"%s\"" key (String.concat ", " hs) :: acc
450450+ | None -> acc
451451+452452+let pp_response ppf r =
453453+ let items =
454454+ []
455455+ |> add_opt_int "max-age" r.max_age
456456+ |> add_opt_int "s-maxage" r.s_maxage
457457+ |> add_opt_field "no-cache" r.no_cache
458458+ |> add_flag "no-store" r.no_store
459459+ |> add_flag "no-transform" r.no_transform
460460+ |> add_flag "must-revalidate" r.must_revalidate
461461+ |> add_flag "proxy-revalidate" r.proxy_revalidate
462462+ |> add_flag "must-understand" r.must_understand
463463+ |> add_opt_field "private" r.private_
464464+ |> add_flag "public" r.public
465465+ |> add_flag "immutable" r.immutable
466466+ |> add_opt_int "stale-while-revalidate" r.stale_while_revalidate
467467+ |> add_opt_int "stale-if-error" r.stale_if_error
468468+ in
469469+ Fmt.pf ppf "%s" (String.concat ", " (List.rev items))
470470+471471+let pp_request ppf r =
472472+ let items = [] in
473473+ let items =
474474+ match r.req_max_age with
475475+ | Some a -> Fmt.str "max-age=%d" a :: items
476476+ | None -> items
477477+ in
478478+ let items =
479479+ match r.req_max_stale with
480480+ | Some None -> "max-stale" :: items
481481+ | Some (Some s) -> Fmt.str "max-stale=%d" s :: items
482482+ | None -> items
483483+ in
484484+ let items =
485485+ match r.req_min_fresh with
486486+ | Some s -> Fmt.str "min-fresh=%d" s :: items
487487+ | None -> items
488488+ in
489489+ let items = if r.req_no_cache then "no-cache" :: items else items in
490490+ let items = if r.req_no_store then "no-store" :: items else items in
491491+ let items = if r.req_no_transform then "no-transform" :: items else items in
492492+ let items =
493493+ if r.req_only_if_cached then "only-if-cached" :: items else items
494494+ in
495495+ Fmt.pf ppf "%s" (String.concat ", " (List.rev items))
+253
lib/cache_control.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP Cache-Control header parsing per RFC 9111 (HTTP Caching)
77+88+ This module provides parsing and representation of Cache-Control directives
99+ for both requests and responses. It supports all standard directives from
1010+ RFC 9111 Section 5.2.
1111+1212+ Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance
1313+1414+ {2 Examples}
1515+1616+ {[
1717+ (* Parse response Cache-Control *)
1818+ let cc = Cache_control.parse_response "max-age=3600, public" in
1919+ Printf.printf "Max age: %d\n" (Option.get cc.max_age);
2020+2121+ (* Check if cacheable *)
2222+ if Cache_control.is_cacheable ~response_cc:cc ~status:200 then
2323+ Printf.printf "Response is cacheable\n"
2424+ ]} *)
2525+2626+val src : Logs.Src.t
2727+(** Log source for cache control operations. *)
2828+2929+(** {1 Response Cache-Control Directives}
3030+3131+ RFC 9111 Section 5.2.2: Cache-Control Response Directives *)
3232+3333+type response_directive =
3434+ | Max_age of int (** max-age=N - response is fresh for N seconds *)
3535+ | S_maxage of int (** s-maxage=N - shared cache max-age *)
3636+ | No_cache of string list (** no-cache[=headers] - must revalidate *)
3737+ | No_store (** no-store - must not be stored *)
3838+ | No_transform (** no-transform - must not be transformed *)
3939+ | Must_revalidate (** must-revalidate - stale must be revalidated *)
4040+ | Proxy_revalidate (** proxy-revalidate - shared caches must revalidate *)
4141+ | Must_understand (** must-understand - RFC 9111 *)
4242+ | Private of string list (** private[=headers] - only private cache *)
4343+ | Public (** public - can be stored by any cache *)
4444+ | Immutable (** immutable - will not change during freshness *)
4545+ | Stale_while_revalidate of int (** stale-while-revalidate=N *)
4646+ | Stale_if_error of int (** stale-if-error=N *)
4747+ | Response_extension of string * string option (** Unknown directive *)
4848+4949+(** {1 Request Cache-Control Directives}
5050+5151+ RFC 9111 Section 5.2.1: Cache-Control Request Directives *)
5252+5353+type request_directive =
5454+ | Req_max_age of int (** max-age=N *)
5555+ | Req_max_stale of int option (** max-stale[=N] *)
5656+ | Req_min_fresh of int (** min-fresh=N *)
5757+ | Req_no_cache (** no-cache *)
5858+ | Req_no_store (** no-store *)
5959+ | Req_no_transform (** no-transform *)
6060+ | Req_only_if_cached (** only-if-cached *)
6161+ | Request_extension of string * string option (** Unknown directive *)
6262+6363+(** {1 Parsed Cache-Control Types} *)
6464+6565+type response = {
6666+ max_age : int option; (** max-age directive value in seconds *)
6767+ s_maxage : int option; (** s-maxage directive value for shared caches *)
6868+ no_cache : string list option;
6969+ (** [None] = not present, [Some []] = present without headers,
7070+ [Some headers] = must revalidate for these headers *)
7171+ no_store : bool; (** If true, the response must not be stored *)
7272+ no_transform : bool;
7373+ (** If true, intermediaries must not transform the response *)
7474+ must_revalidate : bool; (** If true, stale responses must be revalidated *)
7575+ proxy_revalidate : bool;
7676+ (** Like must_revalidate but only for shared caches *)
7777+ must_understand : bool;
7878+ (** If true, cache must understand the caching rules *)
7979+ private_ : string list option;
8080+ (** [None] = not present, [Some []] = entirely private, [Some headers] =
8181+ these headers are private *)
8282+ public : bool; (** If true, response may be stored by any cache *)
8383+ immutable : bool;
8484+ (** If true, response will not change during freshness lifetime *)
8585+ stale_while_revalidate : int option;
8686+ (** Seconds stale responses may be served while revalidating *)
8787+ stale_if_error : int option;
8888+ (** Seconds stale responses may be served on error *)
8989+ extensions : (string * string option) list;
9090+ (** Unknown directives for forward compatibility *)
9191+}
9292+(** Parsed response Cache-Control header *)
9393+9494+type request = {
9595+ req_max_age : int option;
9696+ (** max-age directive - maximum age client will accept *)
9797+ req_max_stale : int option option;
9898+ (** [None] = not present, [Some None] = accept any stale, [Some (Some n)]
9999+ = accept stale up to n seconds *)
100100+ req_min_fresh : int option;
101101+ (** min-fresh directive - response must be fresh for at least n more
102102+ seconds *)
103103+ req_no_cache : bool; (** If true, force revalidation with origin server *)
104104+ req_no_store : bool; (** If true, response must not be stored *)
105105+ req_no_transform : bool; (** If true, intermediaries must not transform *)
106106+ req_only_if_cached : bool;
107107+ (** If true, return cached response or 504 Gateway Timeout *)
108108+ req_extensions : (string * string option) list;
109109+ (** Unknown directives for forward compatibility *)
110110+}
111111+(** Parsed request Cache-Control header *)
112112+113113+(** {1 Empty Values} *)
114114+115115+val empty_response : response
116116+(** An empty response Cache-Control (no directives set). *)
117117+118118+val empty_request : request
119119+(** An empty request Cache-Control (no directives set). *)
120120+121121+(** {1 Parsing Functions} *)
122122+123123+val parse_response : string -> response
124124+(** [parse_response header_value] parses a response Cache-Control header value.
125125+ Unknown directives are preserved in [extensions] for forward compatibility.
126126+*)
127127+128128+val parse_request : string -> request
129129+(** [parse_request header_value] parses a request Cache-Control header value.
130130+ Unknown directives are preserved in [req_extensions] for forward
131131+ compatibility. *)
132132+133133+(** {1 Freshness Calculation}
134134+135135+ RFC 9111 Section 4.2: Freshness *)
136136+137137+val freshness_lifetime :
138138+ response_cc:response -> ?expires:string -> ?date:string -> unit -> int option
139139+(** [freshness_lifetime ~response_cc ?expires ?date ()] calculates the freshness
140140+ lifetime of a response in seconds, based on Cache-Control directives and
141141+ optional Expires/Date headers.
142142+143143+ Priority (per RFC 9111 Section 4.2.1): 1. max-age directive 2. Expires
144144+ header minus Date header 3. Returns [None] if no explicit freshness (caller
145145+ should use heuristics).
146146+147147+ @param response_cc Parsed Cache-Control from response.
148148+ @param expires Optional Expires header value (HTTP-date format).
149149+ @param date Optional Date header value (HTTP-date format). *)
150150+151151+(** {1 Age Calculation}
152152+153153+ Per RFC 9111 Section 4.2.3: Calculating Age. *)
154154+155155+type age_inputs = {
156156+ date_value : Ptime.t option;
157157+ (** Value of Date header (when response was generated) *)
158158+ age_value : int; (** Value of Age header in seconds (0 if not present) *)
159159+ request_time : Ptime.t; (** Time when the request was initiated *)
160160+ response_time : Ptime.t; (** Time when the response was received *)
161161+}
162162+(** Inputs required for age calculation per RFC 9111 Section 4.2.3. *)
163163+164164+val calculate_age : inputs:age_inputs -> now:Ptime.t -> int
165165+(** [calculate_age ~inputs ~now] calculates the current age of a cached
166166+ response.
167167+168168+ Per RFC 9111 Section 4.2.3:
169169+ {v
170170+ apparent_age = max(0, response_time - date_value)
171171+ response_delay = response_time - request_time
172172+ corrected_age_value = age_value + response_delay
173173+ corrected_initial_age = max(apparent_age, corrected_age_value)
174174+ resident_time = now - response_time
175175+ current_age = corrected_initial_age + resident_time
176176+ v}
177177+178178+ @return Current age in seconds. *)
179179+180180+(** {1 Heuristic Freshness}
181181+182182+ Per RFC 9111 Section 4.2.2: Calculating Heuristic Freshness. *)
183183+184184+val default_heuristic_fraction : float
185185+(** Default heuristic fraction: 10% of time since Last-Modified. RFC 9111
186186+ recommends this as a typical value. *)
187187+188188+val default_max_heuristic_age : int
189189+(** Maximum heuristic freshness lifetime: 1 day (86400 seconds). *)
190190+191191+val heuristic_freshness :
192192+ ?last_modified:string ->
193193+ response_time:Ptime.t ->
194194+ ?fraction:float ->
195195+ ?max_age:int ->
196196+ unit ->
197197+ int option
198198+(** [heuristic_freshness ?last_modified ~response_time ?fraction ?max_age ()]
199199+ calculates heuristic freshness lifetime when no explicit caching info
200200+ provided.
201201+202202+ Per RFC 9111 Section 4.2.2, caches MAY use heuristics when explicit
203203+ freshness is not available. The typical heuristic is 10% of time since
204204+ Last-Modified.
205205+206206+ @param last_modified Value of Last-Modified header
207207+ @param response_time When the response was received
208208+ @param fraction Fraction of (now - last_modified) to use (default 10%)
209209+ @param max_age Maximum heuristic age in seconds (default 1 day)
210210+ @return Heuristic freshness lifetime in seconds, or None. *)
211211+212212+val is_fresh : current_age:int -> freshness_lifetime:int -> bool
213213+(** [is_fresh ~current_age ~freshness_lifetime] returns true if a cached
214214+ response is still fresh (current_age < freshness_lifetime). *)
215215+216216+val can_serve_stale :
217217+ request_cc:request -> current_age:int -> freshness_lifetime:int -> bool
218218+(** [can_serve_stale ~request_cc ~current_age ~freshness_lifetime] returns true
219219+ if a stale response can still be served based on request Cache-Control
220220+ directives (specifically max-stale). *)
221221+222222+(** {1 Cacheability Checks} *)
223223+224224+val is_cacheable : response_cc:response -> status:int -> bool
225225+(** [is_cacheable ~response_cc ~status] returns true if the response may be
226226+ cached based on its Cache-Control directives and HTTP status code.
227227+228228+ A response is cacheable if:
229229+ - no-store is NOT present
230230+ - Status is cacheable by default (200, 203, 204, 206, 300, 301, 308, 404,
231231+ 405, 410, 414, 501) OR explicit caching directive is present. *)
232232+233233+val must_revalidate : response_cc:response -> bool
234234+(** [must_revalidate ~response_cc] returns true if cached response must be
235235+ revalidated with the origin server before use.
236236+237237+ True if any of: must-revalidate, proxy-revalidate, or no-cache is set. *)
238238+239239+val is_public : response_cc:response -> bool
240240+(** [is_public ~response_cc] returns true if the response may be stored in
241241+ shared caches (CDNs, proxies). *)
242242+243243+val is_private : response_cc:response -> bool
244244+(** [is_private ~response_cc] returns true if the response may only be stored in
245245+ private caches (browser cache). *)
246246+247247+(** {1 Pretty Printers} *)
248248+249249+val pp_response : Format.formatter -> response -> unit
250250+(** Pretty print a parsed response Cache-Control. *)
251251+252252+val pp_request : Format.formatter -> request -> unit
253253+(** Pretty print a parsed request Cache-Control. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Centralized error handling for the Requests library using Eio.Io exceptions.
77+88+ This module follows the Eio.Io exception pattern for structured error
99+ handling, providing granular error types and query functions for smart retry
1010+ logic.
1111+1212+ {2 Usage}
1313+1414+ Errors are raised using the Eio.Io pattern:
1515+ {[
1616+ raise
1717+ (Error.err
1818+ (Error.Timeout { operation = "connect"; duration = Some 30.0 }))
1919+ ]}
2020+2121+ To catch and handle errors:
2222+ {[
2323+ try
2424+ (* ... HTTP request ... *)
2525+ with
2626+ | Eio.Io (Error.E e, _) when Error.is_retryable e ->
2727+ (* Retry the request *)
2828+ | Eio.Io (Error.E e, _) ->
2929+ Printf.eprintf "Request failed: %s\n" (Error.to_string e)
3030+ ]} *)
3131+3232+val src : Logs.Src.t
3333+(** Log source for error reporting. *)
3434+3535+(** {1 Error Type}
3636+3737+ Granular error variants with contextual information. Each variant contains a
3838+ record with relevant details. *)
3939+4040+type t =
4141+ (* Timeout errors *)
4242+ | Timeout of { operation : string; duration : float option }
4343+ (* Redirect errors *)
4444+ | Too_many_redirects of { url : string; count : int; max : int }
4545+ | Invalid_redirect of { url : string; reason : string }
4646+ (* HTTP response errors *)
4747+ (* Note: headers stored as list to avoid dependency cycle with Headers module *)
4848+ | Http_error of {
4949+ url : string;
5050+ status : int;
5151+ reason : string;
5252+ body_preview : string option;
5353+ headers : (string * string) list;
5454+ }
5555+ (* Authentication errors *)
5656+ | Authentication_failed of { url : string; reason : string }
5757+ (* Connection errors - granular breakdown *)
5858+ | Dns_resolution_failed of { hostname : string }
5959+ | Tcp_connect_failed of { host : string; port : int; reason : string }
6060+ | Tls_handshake_failed of { host : string; reason : string }
6161+ (* Security-related errors *)
6262+ | Invalid_header of { name : string; reason : string }
6363+ | Body_too_large of { limit : int64; actual : int64 option }
6464+ | Headers_too_large of { limit : int; actual : int }
6565+ | Decompression_bomb of { limit : int64; ratio : float }
6666+ | Content_length_mismatch of { expected : int64; actual : int64 }
6767+ | Insecure_auth of { url : string; auth_type : string }
6868+ (** Per RFC 7617 Section 4 and RFC 6750 Section 5.1: Basic, Bearer, and
6969+ Digest authentication over unencrypted HTTP exposes credentials to
7070+ eavesdropping. Raised when attempting to use these auth methods over
7171+ HTTP without explicit opt-out. *)
7272+ (* JSON errors *)
7373+ | Json_parse_error of { body_preview : string; reason : string }
7474+ | Json_encode_error of { reason : string }
7575+ (* Other errors *)
7676+ | Proxy_error of { host : string; reason : string }
7777+ | Encoding_error of { encoding : string; reason : string }
7878+ | Invalid_url of { url : string; reason : string }
7979+ | Invalid_request of { reason : string }
8080+ (* OAuth 2.0 errors - per RFC 6749 Section 5.2 *)
8181+ | Oauth_error of {
8282+ error_code : string;
8383+ description : string option;
8484+ uri : string option;
8585+ }
8686+ (** OAuth 2.0 error response from authorization server. Per
8787+ {{:https://datatracker.ietf.org/doc/html/rfc6749#section-5.2}RFC 6749
8888+ Section 5.2}. *)
8989+ | Token_refresh_failed of { reason : string }
9090+ (** Token refresh operation failed. *)
9191+ | Token_expired
9292+ (** Access token has expired and no refresh token is available. *)
9393+ (* HTTP/2 protocol errors - per RFC 9113 *)
9494+ | H2_protocol_error of { code : int32; message : string }
9595+ (** HTTP/2 connection error per
9696+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.1}RFC
9797+ 9113 Section 5.4.1}. Error codes are defined in RFC 9113 Section 7.
9898+ *)
9999+ | H2_stream_error of { stream_id : int32; code : int32; message : string }
100100+ (** HTTP/2 stream error per
101101+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.2}RFC
102102+ 9113 Section 5.4.2}. *)
103103+ | H2_flow_control_error of { stream_id : int32 option }
104104+ (** Flow control window exceeded per
105105+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.2}RFC 9113
106106+ Section 5.2}. *)
107107+ | H2_compression_error of { message : string }
108108+ (** HPACK decompression failed per
109109+ {{:https://datatracker.ietf.org/doc/html/rfc7541}RFC 7541}. *)
110110+ | H2_settings_timeout
111111+ (** SETTINGS acknowledgment timeout per
112112+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.3}RFC
113113+ 9113 Section 6.5.3}. *)
114114+ | H2_goaway of { last_stream_id : int32; code : int32; debug : string }
115115+ (** Server sent GOAWAY frame per
116116+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.8}RFC 9113
117117+ Section 6.8}. *)
118118+ | H2_frame_error of { frame_type : int; message : string }
119119+ (** Invalid frame received per RFC 9113 Section 4-6. *)
120120+ | H2_header_validation_error of { message : string }
121121+ (** HTTP/2 header validation failed per RFC 9113 Section 8.2-8.3. *)
122122+123123+(** {1 Eio.Exn Integration} *)
124124+125125+(** Extension of [Eio.Exn.err] for Requests errors *)
126126+type Eio.Exn.err += E of t
127127+128128+val err : t -> exn
129129+(** [err e] creates an Eio exception from an error. *)
130130+131131+(** {1 URL and Credential Sanitization} *)
132132+133133+val sanitize_url : string -> string
134134+(** Remove userinfo (username:password) from a URL for safe logging. *)
135135+136136+val sanitize_headers : (string * string) list -> (string * string) list
137137+(** Redact sensitive headers (Authorization, Cookie, etc.) for safe logging.
138138+ Takes and returns a list of (name, value) pairs. *)
139139+140140+val is_sensitive_header : string -> bool
141141+(** Check if a header name is sensitive (case-insensitive). *)
142142+143143+(** {1 Pretty Printing} *)
144144+145145+val pp_error : Format.formatter -> t -> unit
146146+(** Pretty printer for error values. *)
147147+148148+(** {1 Query Functions}
149149+150150+ These functions enable smart error handling without pattern matching. *)
151151+152152+val is_timeout : t -> bool
153153+(** [is_timeout e] returns [true] if [e] is a timeout. *)
154154+155155+val is_dns : t -> bool
156156+(** [is_dns e] returns [true] if [e] is a DNS resolution failure. *)
157157+158158+val is_tls : t -> bool
159159+(** [is_tls e] returns [true] if [e] is a TLS handshake failure. *)
160160+161161+val is_connection : t -> bool
162162+(** [is_connection e] returns [true] if [e] is any connection-related failure
163163+ (DNS, TCP connect, or TLS handshake). *)
164164+165165+val is_http_error : t -> bool
166166+(** [is_http_error e] returns [true] if [e] is an HTTP response error. *)
167167+168168+val is_client_error : t -> bool
169169+(** [is_client_error e] returns [true] if [e] is a client error (4xx status or
170170+ similar). *)
171171+172172+val is_server_error : t -> bool
173173+(** [is_server_error e] returns [true] if [e] is a server error (5xx status). *)
174174+175175+val is_retryable : t -> bool
176176+(** [is_retryable e] returns [true] if [e] is typically retryable. Retryable
177177+ errors include: timeouts, connection errors, and certain HTTP status codes
178178+ (408, 429, 500, 502, 503, 504). *)
179179+180180+val is_security_error : t -> bool
181181+(** [is_security_error e] returns [true] if [e] is security-related (header
182182+ injection, body too large, decompression bomb, etc.). *)
183183+184184+val is_json_error : t -> bool
185185+(** [is_json_error e] returns [true] if [e] is a JSON parsing or encoding error.
186186+*)
187187+188188+val is_oauth_error : t -> bool
189189+(** [is_oauth_error e] returns [true] if [e] is an OAuth-related error
190190+ (Oauth_error, Token_refresh_failed, Token_expired). *)
191191+192192+(** {1 Error Extraction} *)
193193+194194+val of_eio_exn : exn -> t option
195195+(** Extract error from an Eio.Io exception, if it's a Requests error. *)
196196+197197+(** {1 HTTP Status Helpers} *)
198198+199199+val http_status : t -> int option
200200+(** Get the HTTP status code from an error, if applicable. *)
201201+202202+val url : t -> string option
203203+(** Get the URL associated with an error, if applicable. *)
204204+205205+(** {1 String Conversion} *)
206206+207207+val pp : Format.formatter -> t -> unit
208208+(** [pp ppf e] pretty-prints the error. *)
209209+210210+val to_string : t -> string
211211+(** Convert error to human-readable string. *)
212212+213213+(** {1 Convenience Constructors}
214214+215215+ These functions provide a more concise way to create error exceptions
216216+ compared to the verbose [err (Error_type { field = value; ... })] pattern.
217217+218218+ Example:
219219+ {[
220220+ (* Instead of: *)
221221+ raise
222222+ (err (Invalid_request { reason = "missing host" }))
223223+ (* Use: *)
224224+ raise
225225+ (invalid_request ~reason:"missing host")
226226+ ]} *)
227227+228228+val invalid_request : reason:string -> exn
229229+(** [invalid_request ~reason] creates an [Invalid_request] exception. *)
230230+231231+val invalid_redirect : url:string -> reason:string -> exn
232232+(** [invalid_redirect ~url ~reason] creates an [Invalid_redirect] exception. *)
233233+234234+val invalid_url : url:string -> reason:string -> exn
235235+(** [invalid_url ~url ~reason] creates an [Invalid_url] exception. *)
236236+237237+val timeout : operation:string -> ?duration:float -> unit -> exn
238238+(** [timeout ~operation ?duration ()] creates a [Timeout] exception. *)
239239+240240+val body_too_large : limit:int64 -> ?actual:int64 -> unit -> exn
241241+(** [body_too_large ~limit ?actual ()] creates a [Body_too_large] exception. *)
242242+243243+val headers_too_large : limit:int -> actual:int -> exn
244244+(** [headers_too_large ~limit ~actual] creates a [Headers_too_large] exception.
245245+*)
246246+247247+val proxy_error : host:string -> reason:string -> exn
248248+(** [proxy_error ~host ~reason] creates a [Proxy_error] exception. *)
249249+250250+val tls_handshake_failed : host:string -> reason:string -> exn
251251+(** [tls_handshake_failed ~host ~reason] creates a [Tls_handshake_failed]
252252+ exception. *)
253253+254254+val tcp_connect_failed : host:string -> port:int -> reason:string -> exn
255255+(** [tcp_connect_failed ~host ~port ~reason] creates a [Tcp_connect_failed]
256256+ exception. *)
257257+258258+(** {1 Format String Constructors}
259259+260260+ These functions accept printf-style format strings for the reason field,
261261+ making error construction more concise when messages need interpolation.
262262+263263+ Example:
264264+ {[
265265+ (* Instead of: *)
266266+ raise
267267+ (Error.err
268268+ (Error.Invalid_request
269269+ { reason = Fmt.str "Invalid status code: %s" code_str }))
270270+ (* Use: *)
271271+ raise
272272+ (Error.invalid_requestf "Invalid status code: %s" code_str)
273273+ ]} *)
274274+275275+val invalid_requestf : ('a, Format.formatter, unit, exn) format4 -> 'a
276276+(** [invalid_requestf fmt] creates an [Invalid_request] exception with a format
277277+ string. *)
278278+279279+val invalid_redirectf :
280280+ url:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
281281+(** [invalid_redirectf ~url fmt] creates an [Invalid_redirect] exception with a
282282+ format string. *)
283283+284284+val invalid_urlf : url:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
285285+(** [invalid_urlf ~url fmt] creates an [Invalid_url] exception with a format
286286+ string. *)
287287+288288+val proxy_errorf :
289289+ host:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
290290+(** [proxy_errorf ~host fmt] creates a [Proxy_error] exception with a format
291291+ string. *)
292292+293293+val tls_handshake_failedf :
294294+ host:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
295295+(** [tls_handshake_failedf ~host fmt] creates a [Tls_handshake_failed] exception
296296+ with a format string. *)
297297+298298+val tcp_connect_failedf :
299299+ host:string -> port:int -> ('a, Format.formatter, unit, exn) format4 -> 'a
300300+(** [tcp_connect_failedf ~host ~port fmt] creates a [Tcp_connect_failed]
301301+ exception with a format string. *)
302302+303303+(** {1 OAuth Error Constructors} *)
304304+305305+val oauth_error :
306306+ error_code:string -> ?description:string -> ?uri:string -> unit -> exn
307307+(** [oauth_error ~error_code ?description ?uri ()] creates an [Oauth_error]
308308+ exception. *)
309309+310310+val token_refresh_failed : reason:string -> exn
311311+(** [token_refresh_failed ~reason] creates a [Token_refresh_failed] exception.
312312+*)
313313+314314+val token_expired : unit -> exn
315315+(** [token_expired ()] creates a [Token_expired] exception. *)
316316+317317+(** {1 HTTP/2 Error Query Functions}
318318+319319+ Query functions for HTTP/2 specific errors per
320320+ {{:https://datatracker.ietf.org/doc/html/rfc9113}RFC 9113}. *)
321321+322322+val is_h2_error : t -> bool
323323+(** [is_h2_error e] returns [true] if [e] is any HTTP/2 protocol error. *)
324324+325325+val is_h2_connection_error : t -> bool
326326+(** [is_h2_connection_error e] returns [true] if [e] is an HTTP/2
327327+ connection-level error. Connection errors terminate the entire HTTP/2
328328+ connection. *)
329329+330330+val is_h2_stream_error : t -> bool
331331+(** [is_h2_stream_error e] returns [true] if [e] is an HTTP/2 stream-level
332332+ error. Stream errors only affect a single stream. *)
333333+334334+val is_h2_retryable : t -> bool
335335+(** [is_h2_retryable e] returns [true] if the HTTP/2 error is typically
336336+ retryable. Retryable errors include:
337337+ - GOAWAY with NO_ERROR (graceful shutdown)
338338+ - REFUSED_STREAM (server didn't process the request)
339339+ - ENHANCE_YOUR_CALM (after backoff). *)
340340+341341+val h2_error_code : t -> int32 option
342342+(** Get the HTTP/2 error code from an error, if applicable. Error codes are
343343+ defined in RFC 9113 Section 7. *)
344344+345345+val h2_stream_id : t -> int32 option
346346+(** Get the stream ID associated with an HTTP/2 error, if applicable. *)
347347+348348+(** {1 HTTP/2 Error Constructors}
349349+350350+ Convenience constructors for HTTP/2 errors per
351351+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-7}RFC 9113 Section
352352+ 7}. *)
353353+354354+val h2_protocol_error : code:int32 -> message:string -> exn
355355+(** [h2_protocol_error ~code ~message] creates an [H2_protocol_error] exception.
356356+*)
357357+358358+val h2_stream_error : stream_id:int32 -> code:int32 -> message:string -> exn
359359+(** [h2_stream_error ~stream_id ~code ~message] creates an [H2_stream_error]
360360+ exception. *)
361361+362362+val h2_flow_control_error : ?stream_id:int32 -> unit -> exn
363363+(** [h2_flow_control_error ?stream_id ()] creates an [H2_flow_control_error]
364364+ exception. If [stream_id] is provided, it's a stream-level error; otherwise,
365365+ it's a connection-level error. *)
366366+367367+val h2_compression_error : message:string -> exn
368368+(** [h2_compression_error ~message] creates an [H2_compression_error] exception.
369369+*)
370370+371371+val h2_settings_timeout : unit -> exn
372372+(** [h2_settings_timeout ()] creates an [H2_settings_timeout] exception. *)
373373+374374+val h2_goaway : last_stream_id:int32 -> code:int32 -> debug:string -> exn
375375+(** [h2_goaway ~last_stream_id ~code ~debug] creates an [H2_goaway] exception.
376376+*)
377377+378378+val h2_frame_error : frame_type:int -> message:string -> exn
379379+(** [h2_frame_error ~frame_type ~message] creates an [H2_frame_error] exception.
380380+*)
381381+382382+val h2_header_validation_error : message:string -> exn
383383+(** [h2_header_validation_error ~message] creates an
384384+ [H2_header_validation_error] exception. *)
385385+386386+(** {2 HTTP/2 Error Code Names} *)
387387+388388+val h2_error_code_name : int32 -> string
389389+(** [h2_error_code_name code] returns the name of an HTTP/2 error code. Per RFC
390390+ 9113 Section 7:
391391+ - 0x0: NO_ERROR
392392+ - 0x1: PROTOCOL_ERROR
393393+ - 0x2: INTERNAL_ERROR
394394+ - 0x3: FLOW_CONTROL_ERROR
395395+ - 0x4: SETTINGS_TIMEOUT
396396+ - 0x5: STREAM_CLOSED
397397+ - 0x6: FRAME_SIZE_ERROR
398398+ - 0x7: REFUSED_STREAM
399399+ - 0x8: CANCEL
400400+ - 0x9: COMPRESSION_ERROR
401401+ - 0xa: CONNECT_ERROR
402402+ - 0xb: ENHANCE_YOUR_CALM
403403+ - 0xc: INADEQUATE_SECURITY
404404+ - 0xd: HTTP_1_1_REQUIRED. *)
+61
lib/expect_continue.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP 100-Continue configuration
77+88+ Configuration for the HTTP 100-Continue protocol, which allows clients to
99+ check if the server will accept a request before sending a large body. Per
1010+ RFC 9110 Section 10.1.1 (Expect) and Section 15.2.1 (100 Continue). *)
1111+1212+type config =
1313+ [ `Disabled (** Never use 100-continue *)
1414+ | `Always (** Always use 100-continue regardless of body size *)
1515+ | `Threshold of int64 (** Use 100-continue for bodies >= threshold bytes *)
1616+ ]
1717+(** User-facing configuration as a polymorphic variant *)
1818+1919+type t = { enabled : bool; threshold : int64; timeout : float }
2020+(** Internal representation *)
2121+2222+let default_threshold = 1_048_576L (* 1MB *)
2323+2424+let default =
2525+ {
2626+ enabled = true;
2727+ threshold = default_threshold;
2828+ timeout = 1.0;
2929+ (* 1 second *)
3030+ }
3131+3232+let of_config ?(timeout = 1.0) (config : config) : t =
3333+ match config with
3434+ | `Disabled -> { enabled = false; threshold = 0L; timeout }
3535+ | `Always -> { enabled = true; threshold = 0L; timeout }
3636+ | `Threshold n -> { enabled = true; threshold = n; timeout }
3737+3838+let v ?(enabled = true) ?(threshold = 1_048_576L) ?(timeout = 1.0) () =
3939+ { enabled; threshold; timeout }
4040+4141+let disabled = { enabled = false; threshold = 0L; timeout = 0.0 }
4242+let enabled t = t.enabled
4343+let threshold t = t.threshold
4444+let timeout t = t.timeout
4545+let should_use t body_size = t.enabled && body_size >= t.threshold
4646+4747+let pp fmt t =
4848+ if not t.enabled then Fmt.pf fmt "100-continue: disabled"
4949+ else if t.threshold = 0L then
5050+ Fmt.pf fmt "100-continue: always (timeout: %.2fs)" t.timeout
5151+ else
5252+ Fmt.pf fmt "100-continue: threshold %Ld bytes (timeout: %.2fs)" t.threshold
5353+ t.timeout
5454+5555+let to_string t = Fmt.str "%a" pp t
5656+5757+let pp_config fmt (config : config) =
5858+ match config with
5959+ | `Disabled -> Fmt.pf fmt "`Disabled"
6060+ | `Always -> Fmt.pf fmt "`Always"
6161+ | `Threshold n -> Fmt.pf fmt "`Threshold %Ld" n
+89
lib/expect_continue.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP 100-Continue configuration
77+88+ Configuration for the HTTP 100-Continue protocol, which allows clients to
99+ check if the server will accept a request before sending a large body. Per
1010+ RFC 9110 Section 10.1.1 (Expect) and Section 15.2.1 (100 Continue).
1111+1212+ {2 Usage}
1313+1414+ The simplest way to configure 100-continue is with the polymorphic variant:
1515+ {[
1616+ (* Use 100-continue for bodies >= 1MB (default) *)
1717+ let session =
1818+ Requests.v ~sw ~expect_100_continue:(`Threshold 1_048_576L) env
1919+2020+ (* Always use 100-continue *)
2121+ let session = Requests.v ~sw ~expect_100_continue:`Always env
2222+2323+ (* Disable 100-continue *)
2424+ let session = Requests.v ~sw ~expect_100_continue:`Disabled env
2525+ ]} *)
2626+2727+(** {1 Configuration Types} *)
2828+2929+type config =
3030+ [ `Disabled (** Never use 100-continue *)
3131+ | `Always (** Always use 100-continue regardless of body size *)
3232+ | `Threshold of int64 (** Use 100-continue for bodies >= threshold bytes *)
3333+ ]
3434+(** User-facing configuration as a polymorphic variant.
3535+3636+ - [`Disabled]: Never send Expect: 100-continue header
3737+ - [`Always]: Always send Expect: 100-continue for requests with bodies
3838+ - [`Threshold n]: Send Expect: 100-continue for bodies >= n bytes *)
3939+4040+type t
4141+(** Internal configuration type with timeout. *)
4242+4343+(** {1 Default Values} *)
4444+4545+val default_threshold : int64
4646+(** Default threshold: 1MB (1_048_576 bytes). *)
4747+4848+val default : t
4949+(** Default configuration: [`Threshold 1_048_576L] with 1.0s timeout. *)
5050+5151+val disabled : t
5252+(** Configuration with 100-Continue disabled. *)
5353+5454+(** {1 Construction} *)
5555+5656+val of_config : ?timeout:float -> config -> t
5757+(** [of_config ?timeout config] creates internal configuration from user-facing
5858+ config. Timeout defaults to 1.0s. *)
5959+6060+val v : ?enabled:bool -> ?threshold:int64 -> ?timeout:float -> unit -> t
6161+(** [v ?enabled ?threshold ?timeout ()] creates custom 100-Continue
6262+ configuration. All parameters are optional and default to the values in
6363+ {!default}. *)
6464+6565+(** {1 Accessors} *)
6666+6767+val enabled : t -> bool
6868+(** Whether 100-continue is enabled. *)
6969+7070+val threshold : t -> int64
7171+(** Body size threshold in bytes to trigger 100-continue. *)
7272+7373+val timeout : t -> float
7474+(** Timeout in seconds to wait for 100 response. *)
7575+7676+val should_use : t -> int64 -> bool
7777+(** [should_use t body_size] returns [true] if 100-continue should be used for a
7878+ request with the given [body_size]. *)
7979+8080+(** {1 Pretty Printing} *)
8181+8282+val pp : Format.formatter -> t -> unit
8383+(** Pretty-printer for 100-Continue configuration. *)
8484+8585+val to_string : t -> string
8686+(** Convert configuration to a human-readable string. *)
8787+8888+val pp_config : Format.formatter -> config -> unit
8989+(** Pretty-printer for the user-facing config variant. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP Header Names as Polymorphic Variants
77+88+ This module provides type-safe HTTP header names using polymorphic variants.
99+ All standard headers have dedicated variants, with [`Other] for non-standard
1010+ or unknown headers.
1111+1212+ {2 Usage}
1313+1414+ {[
1515+ (* Use standard headers directly *)
1616+ let headers = Headers.empty
1717+ |> Headers.set `Content_type "application/json"
1818+ |> Headers.set `Accept "text/html"
1919+2020+ (* Use custom headers with `Other *)
2121+ let headers = headers
2222+ |> Headers.set (`Other "X-Custom-Header") "value"
2323+2424+ (* Pattern match on headers *)
2525+ match Headers.find `Content_type headers with
2626+ | Some ct -> print_endline ct
2727+ | None -> ()
2828+ ]}
2929+3030+ Header names are case-insensitive per
3131+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-5.1}RFC 9110
3232+ Section 5.1}.
3333+3434+ Header definitions are based on the IANA HTTP Field Name Registry:
3535+ {{:https://www.iana.org/assignments/http-fields/http-fields.xhtml}IANA HTTP
3636+ Field Name Registry} *)
3737+3838+(** {1 Types} *)
3939+4040+type standard =
4141+ [ (* RFC 9110: HTTP Semantics - Content Headers *)
4242+ `Accept
4343+ | `Accept_encoding
4444+ | `Accept_language
4545+ | `Accept_ranges
4646+ | `Allow
4747+ | `Content_encoding
4848+ | `Content_language
4949+ | `Content_length
5050+ | `Content_location
5151+ | `Content_range
5252+ | `Content_type
5353+ | (* RFC 9110: HTTP Semantics - Request Context *)
5454+ `Expect
5555+ | `From
5656+ | `Host
5757+ | `Max_forwards
5858+ | `Range
5959+ | `Referer
6060+ | `Te
6161+ | `User_agent
6262+ | (* RFC 9110: HTTP Semantics - Response Context *)
6363+ `Location
6464+ | `Retry_after
6565+ | `Server
6666+ | (* RFC 9110: HTTP Semantics - Validators *)
6767+ `Etag
6868+ | `Last_modified
6969+ | `Vary
7070+ | (* RFC 9110: HTTP Semantics - Conditional Requests *)
7171+ `If_match
7272+ | `If_modified_since
7373+ | `If_none_match
7474+ | `If_range
7575+ | `If_unmodified_since
7676+ | (* RFC 9110: HTTP Semantics - Authentication *)
7777+ `Authorization
7878+ | `Authentication_info
7979+ | `Proxy_authenticate
8080+ | `Proxy_authentication_info
8181+ | `Proxy_authorization
8282+ | `Www_authenticate
8383+ | (* RFC 9110: HTTP Semantics - Connection Management *)
8484+ `Connection
8585+ | `Upgrade
8686+ | `Via
8787+ | (* RFC 9110: HTTP Semantics - Date *)
8888+ `Date
8989+ | (* RFC 9111: HTTP Caching *)
9090+ `Age
9191+ | `Cache_control
9292+ | `Expires
9393+ | `Pragma
9494+ | `Cache_status
9595+ | (* RFC 9112: HTTP/1.1 *)
9696+ `Keep_alive
9797+ | `Trailer
9898+ | `Transfer_encoding
9999+ | (* Cookies - RFC 6265bis *)
100100+ `Cookie
101101+ | `Set_cookie
102102+ | (* Link Relations - RFC 8288 *)
103103+ `Link
104104+ | (* CORS Headers - Fetch Standard *)
105105+ `Access_control_allow_credentials
106106+ | `Access_control_allow_headers
107107+ | `Access_control_allow_methods
108108+ | `Access_control_allow_origin
109109+ | `Access_control_expose_headers
110110+ | `Access_control_max_age
111111+ | `Access_control_request_headers
112112+ | `Access_control_request_method
113113+ | `Origin
114114+ | (* Cross-Origin Policy Headers - HTML Standard *)
115115+ `Cross_origin_embedder_policy
116116+ | `Cross_origin_embedder_policy_report_only
117117+ | `Cross_origin_opener_policy
118118+ | `Cross_origin_opener_policy_report_only
119119+ | `Cross_origin_resource_policy
120120+ | (* Fetch Metadata Headers - W3C *)
121121+ `Sec_fetch_dest
122122+ | `Sec_fetch_mode
123123+ | `Sec_fetch_site
124124+ | `Sec_fetch_user
125125+ | (* Security Headers *)
126126+ `Content_security_policy
127127+ | `Content_security_policy_report_only
128128+ | `Strict_transport_security
129129+ | `X_content_type_options
130130+ | `X_frame_options
131131+ | `Referrer_policy
132132+ | (* RFC 8053: Interactive Authentication *)
133133+ `Optional_www_authenticate
134134+ | `Authentication_control
135135+ | (* RFC 9449: OAuth 2.0 DPoP *)
136136+ `Dpop
137137+ | `Dpop_nonce
138138+ | (* RFC 9530: Digest Fields *)
139139+ `Content_digest
140140+ | `Repr_digest
141141+ | `Want_content_digest
142142+ | `Want_repr_digest
143143+ | (* RFC 9421: HTTP Message Signatures *)
144144+ `Signature
145145+ | `Signature_input
146146+ | `Accept_signature
147147+ | (* RFC 6455: WebSocket Protocol *)
148148+ `Sec_websocket_key
149149+ | `Sec_websocket_accept
150150+ | `Sec_websocket_protocol
151151+ | `Sec_websocket_version
152152+ | `Sec_websocket_extensions ]
153153+(** Standard HTTP header names.
154154+155155+ These cover headers defined in:
156156+ - {{:https://datatracker.ietf.org/doc/html/rfc9110}RFC 9110} (HTTP
157157+ Semantics)
158158+ - {{:https://datatracker.ietf.org/doc/html/rfc9111}RFC 9111} (HTTP Caching)
159159+ - {{:https://datatracker.ietf.org/doc/html/rfc9112}RFC 9112} (HTTP/1.1)
160160+ - {{:https://datatracker.ietf.org/doc/html/rfc6455}RFC 6455} (WebSocket
161161+ Protocol)
162162+ - {{:https://datatracker.ietf.org/doc/html/rfc9421}RFC 9421} (HTTP Message
163163+ Signatures)
164164+ - {{:https://datatracker.ietf.org/doc/html/rfc9530}RFC 9530} (Digest Fields)
165165+ - {{:https://fetch.spec.whatwg.org/}Fetch Standard} (CORS and Security)
166166+ - Various other RFCs as noted *)
167167+168168+type t = [ standard | `Other of string ]
169169+(** Complete header name type including non-standard headers.
170170+171171+ Use [`Other name] for headers not in the standard set. The name should be
172172+ provided in its canonical form (e.g., "X-Custom-Header"). *)
173173+174174+(** {1 Conversion} *)
175175+176176+val to_string : t -> string
177177+(** [to_string name] converts a header name to its canonical wire format.
178178+179179+ Standard headers use their canonical capitalization (e.g., [`Content_type]
180180+ becomes ["Content-Type"]). [`Other] headers are returned as-is. *)
181181+182182+val of_string : string -> t
183183+(** [of_string s] parses a string into a header name.
184184+185185+ Performs case-insensitive matching against known headers. Unknown headers
186186+ are wrapped in [`Other]. *)
187187+188188+val to_lowercase_string : t -> string
189189+(** [to_lowercase_string name] returns the lowercase form for internal use. *)
190190+191191+(** {1 Comparison} *)
192192+193193+val compare : t -> t -> int
194194+(** [compare a b] compares two header names case-insensitively. *)
195195+196196+val equal : t -> t -> bool
197197+(** [equal a b] checks equality of two header names case-insensitively. *)
198198+199199+(** {1 Pretty Printing} *)
200200+201201+val pp : Format.formatter -> t -> unit
202202+(** [pp ppf name] pretty-prints a header name. *)
203203+204204+(** {1 Header Categories} *)
205205+206206+val hop_by_hop_headers : t list
207207+(** Default hop-by-hop headers per
208208+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1}RFC 9110
209209+ Section 7.6.1}.
210210+211211+ These headers MUST be removed before forwarding a message: Connection,
212212+ Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailer,
213213+ Transfer-Encoding, Upgrade, Via. *)
214214+215215+val forbidden_trailer_headers : t list
216216+(** Headers that MUST NOT appear in trailers per
217217+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-6.5.1}RFC 9110
218218+ Section 6.5.1}.
219219+220220+ Includes: Transfer-Encoding, Content-Length, Host, Content-Encoding,
221221+ Content-Type, Trailer. *)
222222+223223+val cors_response_headers : t list
224224+(** CORS response headers that control cross-origin access.
225225+ @see <https://fetch.spec.whatwg.org/#http-responses> Fetch Standard. *)
226226+227227+val cors_request_headers : t list
228228+(** CORS request headers used in preflight requests.
229229+ @see <https://fetch.spec.whatwg.org/#http-requests> Fetch Standard. *)
230230+231231+val security_headers : t list
232232+(** Headers related to web security policies. *)
233233+234234+val fetch_metadata_headers : t list
235235+(** Browser-set headers providing request context.
236236+ @see <https://www.w3.org/TR/fetch-metadata/> Fetch Metadata. *)
237237+238238+val websocket_headers : t list
239239+(** Headers used during WebSocket upgrade.
240240+ @see <https://www.rfc-editor.org/rfc/rfc6455> RFC 6455. *)
241241+242242+val is_hop_by_hop : t -> bool
243243+(** [is_hop_by_hop name] returns [true] if [name] is a hop-by-hop header. *)
244244+245245+val is_forbidden_trailer : t -> bool
246246+(** [is_forbidden_trailer name] returns [true] if [name] is forbidden in
247247+ trailers. *)
248248+249249+val is_cors_response : t -> bool
250250+(** [is_cors_response name] returns [true] if [name] is a CORS response header.
251251+*)
252252+253253+val is_cors_request : t -> bool
254254+(** [is_cors_request name] returns [true] if [name] is a CORS request header. *)
255255+256256+val is_security : t -> bool
257257+(** [is_security name] returns [true] if [name] is a security header. *)
258258+259259+val is_fetch_metadata : t -> bool
260260+(** [is_fetch_metadata name] returns [true] if [name] is a fetch metadata
261261+ header. *)
262262+263263+val is_websocket : t -> bool
264264+(** [is_websocket name] returns [true] if [name] is a WebSocket header. *)
+665
lib/headers.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "requests.headers" ~doc:"HTTP Headers"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+(* Use a map with lowercase keys for case-insensitive lookup *)
1111+module String_map = Map.Make (String)
1212+1313+type t = (string * string list) String_map.t
1414+(** The internal representation stores: (canonical_name, values) *)
1515+1616+let empty = String_map.empty
1717+1818+(** {1 Header Injection Prevention}
1919+2020+ Per Recommendation #3: Validate that header names and values do not contain
2121+ newlines (CR/LF) which could enable HTTP request smuggling attacks.
2222+2323+ Note: We use Invalid_argument here to avoid a dependency cycle with Error
2424+ module. The error will be caught and wrapped appropriately by higher-level
2525+ code. *)
2626+2727+exception Invalid_header of { name : string; reason : string }
2828+2929+(** {1 Basic Auth Credential Validation}
3030+3131+ Per RFC 7617 Section 2:
3232+ - Username must not contain a colon character
3333+ - Neither username nor password may contain control characters (0x00-0x1F,
3434+ 0x7F) *)
3535+3636+exception Invalid_basic_auth of { reason : string }
3737+3838+let contains_control_chars s =
3939+ String.exists
4040+ (fun c ->
4141+ let code = Char.code c in
4242+ code <= 0x1F || code = 0x7F)
4343+ s
4444+4545+let validate_basic_auth_credentials ~username ~password =
4646+ (* RFC 7617 Section 2: "a user-id containing a colon character is invalid" *)
4747+ if String.contains username ':' then
4848+ raise
4949+ (Invalid_basic_auth
5050+ { reason = "Username contains colon character (RFC 7617 Section 2)" });
5151+ (* RFC 7617 Section 2: "The user-id and password MUST NOT contain any control characters" *)
5252+ if contains_control_chars username then
5353+ raise
5454+ (Invalid_basic_auth
5555+ {
5656+ reason = "Username contains control characters (RFC 7617 Section 2)";
5757+ });
5858+ if contains_control_chars password then
5959+ raise
6060+ (Invalid_basic_auth
6161+ {
6262+ reason = "Password contains control characters (RFC 7617 Section 2)";
6363+ })
6464+6565+let validate_header_name_str name =
6666+ if String.contains name '\r' || String.contains name '\n' then
6767+ raise
6868+ (Invalid_header
6969+ {
7070+ name;
7171+ reason =
7272+ "Header name contains CR/LF characters (potential HTTP smuggling)";
7373+ })
7474+7575+let validate_header_value name value =
7676+ if String.contains value '\r' || String.contains value '\n' then
7777+ raise
7878+ (Invalid_header
7979+ {
8080+ name;
8181+ reason =
8282+ "Header value contains CR/LF characters (potential HTTP smuggling)";
8383+ })
8484+8585+(** {1 Core Operations with Typed Header Names} *)
8686+8787+let add (name : Header_name.t) value t =
8888+ (* Store header names in lowercase for HTTP/2 compatibility.
8989+ HTTP/1.x headers are case-insensitive per RFC 9110. *)
9090+ let canonical = Header_name.to_lowercase_string name in
9191+ let nkey = canonical in
9292+ validate_header_value canonical value;
9393+ let existing =
9494+ match String_map.find_opt nkey t with
9595+ | Some (_, values) -> values
9696+ | None -> []
9797+ in
9898+ (* Append to maintain order, avoiding reversal on retrieval *)
9999+ String_map.add nkey (canonical, existing @ [ value ]) t
100100+101101+let set (name : Header_name.t) value t =
102102+ (* Store header names in lowercase for HTTP/2 compatibility.
103103+ HTTP/1.x headers are case-insensitive per RFC 9110. *)
104104+ let canonical = Header_name.to_lowercase_string name in
105105+ let nkey = canonical in
106106+ validate_header_value canonical value;
107107+ String_map.add nkey (canonical, [ value ]) t
108108+109109+let find (name : Header_name.t) t =
110110+ let nkey = Header_name.to_lowercase_string name in
111111+ match String_map.find_opt nkey t with
112112+ | Some (_, values) -> List.nth_opt values 0
113113+ | None -> None
114114+115115+let all (name : Header_name.t) t =
116116+ let nkey = Header_name.to_lowercase_string name in
117117+ match String_map.find_opt nkey t with
118118+ | Some (_, values) -> values
119119+ | None -> []
120120+121121+let remove (name : Header_name.t) t =
122122+ let nkey = Header_name.to_lowercase_string name in
123123+ String_map.remove nkey t
124124+125125+let mem (name : Header_name.t) t =
126126+ let nkey = Header_name.to_lowercase_string name in
127127+ String_map.mem nkey t
128128+129129+(** {1 String-based Operations for Wire Format Compatibility}
130130+131131+ These are used internally when parsing HTTP messages from the wire, where
132132+ header names come as strings. *)
133133+134134+let add_string key value t =
135135+ validate_header_name_str key;
136136+ validate_header_value key value;
137137+ let nkey = String.lowercase_ascii key in
138138+ let existing =
139139+ match String_map.find_opt nkey t with
140140+ | Some (_, values) -> values
141141+ | None -> []
142142+ in
143143+ String_map.add nkey (key, existing @ [ value ]) t
144144+145145+let set_string key value t =
146146+ validate_header_name_str key;
147147+ validate_header_value key value;
148148+ let nkey = String.lowercase_ascii key in
149149+ String_map.add nkey (key, [ value ]) t
150150+151151+let string key t =
152152+ let nkey = String.lowercase_ascii key in
153153+ match String_map.find_opt nkey t with
154154+ | Some (_, values) -> List.nth_opt values 0
155155+ | None -> None
156156+157157+let all_string key t =
158158+ let nkey = String.lowercase_ascii key in
159159+ match String_map.find_opt nkey t with
160160+ | Some (_, values) -> values
161161+ | None -> []
162162+163163+let remove_string key t =
164164+ let nkey = String.lowercase_ascii key in
165165+ String_map.remove nkey t
166166+167167+let mem_string key t =
168168+ let nkey = String.lowercase_ascii key in
169169+ String_map.mem nkey t
170170+171171+(** {1 Conversion} *)
172172+173173+let of_list lst =
174174+ List.fold_left (fun acc (k, v) -> add_string k v acc) empty lst
175175+176176+let to_list t =
177177+ String_map.fold
178178+ (fun _ (orig_key, values) acc ->
179179+ (* Values are already in correct order, build list in reverse then reverse at end *)
180180+ List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values)
181181+ t []
182182+ |> List.rev
183183+184184+let merge t1 t2 = String_map.union (fun _ _ v2 -> Some v2) t1 t2
185185+186186+(** {1 Common Header Builders} *)
187187+188188+let content_type mime t = set `Content_type (Mime.to_string mime) t
189189+let content_length len t = set `Content_length (Int64.to_string len) t
190190+let accept mime t = set `Accept (Mime.to_string mime) t
191191+let accept_language lang t = set `Accept_language lang t
192192+let authorization value t = set `Authorization value t
193193+let bearer token t = set `Authorization (Fmt.str "Bearer %s" token) t
194194+195195+let basic ~username ~password t =
196196+ validate_basic_auth_credentials ~username ~password;
197197+ let credentials = Fmt.str "%s:%s" username password in
198198+ let encoded = Base64.encode_exn credentials in
199199+ set `Authorization (Fmt.str "Basic %s" encoded) t
200200+201201+let user_agent ua t = set `User_agent ua t
202202+let host h t = set `Host h t
203203+let cookie name value t = add `Cookie (Fmt.str "%s=%s" name value) t
204204+205205+let range ~start ?end_ () t =
206206+ let range_value =
207207+ match end_ with
208208+ | None -> Fmt.str "bytes=%Ld-" start
209209+ | Some e -> Fmt.str "bytes=%Ld-%Ld" start e
210210+ in
211211+ set `Range range_value t
212212+213213+(** {1 HTTP 100-Continue Support}
214214+215215+ Per Recommendation #7: Expect: 100-continue protocol for large uploads. RFC
216216+ 9110 Section 10.1.1 (Expect) *)
217217+218218+let expect value t = set `Expect value t
219219+let expect_100_continue t = set `Expect "100-continue" t
220220+221221+(** {1 TE Header Support}
222222+223223+ Per RFC 9110 Section 10.1.4: The TE header indicates what transfer codings
224224+ the client is willing to accept in the response, and whether the client is
225225+ willing to accept trailer fields in a chunked transfer coding. *)
226226+227227+let te value t = set `Te value t
228228+let te_trailers t = set `Te "trailers" t
229229+230230+(** {1 Cache Control Headers}
231231+232232+ Per Recommendation #17 and #19: Response caching and conditional requests.
233233+ RFC 9111 (HTTP Caching), RFC 9110 Section 8.8.2-8.8.3 (Last-Modified, ETag)
234234+*)
235235+236236+let if_none_match etag t = set `If_none_match etag t
237237+let if_match etag t = set `If_match etag t
238238+let if_modified_since date t = set `If_modified_since date t
239239+let if_unmodified_since date t = set `If_unmodified_since date t
240240+241241+(** Format a Ptime.t as an HTTP-date (RFC 9110 Section 5.6.7) *)
242242+let http_date_of_ptime time =
243243+ (* HTTP-date format: "Sun, 06 Nov 1994 08:49:37 GMT" *)
244244+ let (year, month, day), ((hour, min, sec), _tz_offset) =
245245+ Ptime.to_date_time time
246246+ in
247247+ let weekday =
248248+ match Ptime.weekday time with
249249+ | `Sun -> "Sun"
250250+ | `Mon -> "Mon"
251251+ | `Tue -> "Tue"
252252+ | `Wed -> "Wed"
253253+ | `Thu -> "Thu"
254254+ | `Fri -> "Fri"
255255+ | `Sat -> "Sat"
256256+ in
257257+ let month_name =
258258+ [|
259259+ "";
260260+ "Jan";
261261+ "Feb";
262262+ "Mar";
263263+ "Apr";
264264+ "May";
265265+ "Jun";
266266+ "Jul";
267267+ "Aug";
268268+ "Sep";
269269+ "Oct";
270270+ "Nov";
271271+ "Dec";
272272+ |].(month)
273273+ in
274274+ Fmt.str "%s, %02d %s %04d %02d:%02d:%02d GMT" weekday day month_name year hour
275275+ min sec
276276+277277+let if_modified_since_ptime time t =
278278+ if_modified_since (http_date_of_ptime time) t
279279+280280+let if_unmodified_since_ptime time t =
281281+ if_unmodified_since (http_date_of_ptime time) t
282282+283283+let cache_control directives t = set `Cache_control directives t
284284+285285+(** Build Cache-Control header from common directive components. For max_stale:
286286+ [None] = not present, [Some None] = any staleness, [Some (Some n)] = n
287287+ seconds *)
288288+let cache_control_directives :
289289+ ?max_age:int ->
290290+ ?max_stale:int option option ->
291291+ ?min_fresh:int ->
292292+ ?no_cache:bool ->
293293+ ?no_store:bool ->
294294+ ?no_transform:bool ->
295295+ ?only_if_cached:bool ->
296296+ unit ->
297297+ t ->
298298+ t =
299299+ fun ?max_age ?max_stale ?min_fresh ?(no_cache = false) ?(no_store = false)
300300+ ?(no_transform = false) ?(only_if_cached = false) () t ->
301301+ let directives = [] in
302302+ let directives =
303303+ match max_age with
304304+ | Some age -> Fmt.str "max-age=%d" age :: directives
305305+ | None -> directives
306306+ in
307307+ let directives =
308308+ match max_stale with
309309+ | Some (Some None) -> "max-stale" :: directives
310310+ | Some (Some (Some secs)) -> Fmt.str "max-stale=%d" secs :: directives
311311+ | Some None | None -> directives
312312+ in
313313+ let directives =
314314+ match min_fresh with
315315+ | Some secs -> Fmt.str "min-fresh=%d" secs :: directives
316316+ | None -> directives
317317+ in
318318+ let directives = if no_cache then "no-cache" :: directives else directives in
319319+ let directives = if no_store then "no-store" :: directives else directives in
320320+ let directives =
321321+ if no_transform then "no-transform" :: directives else directives
322322+ in
323323+ let directives =
324324+ if only_if_cached then "only-if-cached" :: directives else directives
325325+ in
326326+ match directives with
327327+ | [] -> t
328328+ | _ -> set `Cache_control (String.concat ", " (List.rev directives)) t
329329+330330+let etag value t = set `Etag value t
331331+let last_modified date t = set `Last_modified date t
332332+let last_modified_ptime time t = last_modified (http_date_of_ptime time) t
333333+334334+(* Additional helper for getting multiple header values *)
335335+let multi name t = all name t
336336+337337+(** {1 Connection Header Handling}
338338+339339+ Per RFC 9110 Section 7.6.1: The Connection header field lists hop-by-hop
340340+ header fields that MUST be removed before forwarding the message. *)
341341+342342+(** Parse Connection header value into list of header names. The Connection
343343+ header lists additional hop-by-hop headers. *)
344344+let parse_connection_header t =
345345+ match find `Connection t with
346346+ | None -> []
347347+ | Some value ->
348348+ String.split_on_char ',' value
349349+ |> List.map (fun s -> Header_name.of_string (String.trim s))
350350+ |> List.filter (fun n -> not (Header_name.equal n (`Other "")))
351351+352352+(** Get all hop-by-hop headers from a response. Returns the union of default
353353+ hop-by-hop headers and any headers listed in the Connection header. *)
354354+let hop_by_hop_headers t =
355355+ let connection_headers = parse_connection_header t in
356356+ Header_name.hop_by_hop_headers @ connection_headers
357357+ |> List.sort_uniq Header_name.compare
358358+359359+(** Remove hop-by-hop headers from a header collection. This should be called
360360+ before caching or forwarding a response. Per RFC 9110 Section 7.6.1. *)
361361+let remove_hop_by_hop t =
362362+ let hop_by_hop = hop_by_hop_headers t in
363363+ List.fold_left (fun headers name -> remove name headers) t hop_by_hop
364364+365365+(** Check if a response indicates the connection should be closed. Returns true
366366+ if Connection: close is present. *)
367367+let connection_close t =
368368+ match find `Connection t with
369369+ | Some value ->
370370+ String.split_on_char ',' value
371371+ |> List.exists (fun s -> String.trim (String.lowercase_ascii s) = "close")
372372+ | None -> false
373373+374374+(** Check if a response indicates the connection should be kept alive. Returns
375375+ true if Connection: keep-alive is present (HTTP/1.0 behavior). *)
376376+let connection_keep_alive t =
377377+ match find `Connection t with
378378+ | Some value ->
379379+ String.split_on_char ',' value
380380+ |> List.exists (fun s ->
381381+ String.trim (String.lowercase_ascii s) = "keep-alive")
382382+ | None -> false
383383+384384+(* Pretty printer for headers *)
385385+let pp ppf t =
386386+ Fmt.pf ppf "@[<v>Headers:@,";
387387+ let headers = to_list t in
388388+ List.iter (fun (k, v) -> Fmt.pf ppf " %s: %s@," k v) headers;
389389+ Fmt.pf ppf "@]"
390390+391391+let pp_brief ppf t =
392392+ let headers = to_list t in
393393+ let count = List.length headers in
394394+ Fmt.pf ppf "Headers(%d entries)" count
395395+396396+(** {1 HTTP/2 Pseudo-Header Support}
397397+398398+ Per
399399+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-8.3}RFC 9113
400400+ Section 8.3}. *)
401401+402402+let is_pseudo_header name = String.length name > 0 && name.[0] = ':'
403403+404404+let pseudo name t =
405405+ let key = ":" ^ name in
406406+ string key t
407407+408408+let set_pseudo name value t =
409409+ let key = ":" ^ name in
410410+ set_string key value t
411411+412412+let remove_pseudo name t =
413413+ let key = ":" ^ name in
414414+ remove_string key t
415415+416416+let mem_pseudo name t =
417417+ let key = ":" ^ name in
418418+ mem_string key t
419419+420420+let has_pseudo_headers t =
421421+ String_map.exists (fun key _ -> String.length key > 0 && key.[0] = ':') t
422422+423423+let pseudo_headers t =
424424+ String_map.fold
425425+ (fun key (orig_key, values) acc ->
426426+ if is_pseudo_header key then
427427+ (* Remove the colon prefix for the returned name *)
428428+ let name = String.sub orig_key 1 (String.length orig_key - 1) in
429429+ List.fold_left (fun acc v -> (name, v) :: acc) acc values
430430+ else acc)
431431+ t []
432432+ |> List.rev
433433+434434+let regular_headers t =
435435+ String_map.fold
436436+ (fun key (orig_key, values) acc ->
437437+ if not (is_pseudo_header key) then
438438+ List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values
439439+ else acc)
440440+ t []
441441+ |> List.rev
442442+443443+let to_list_ordered t =
444444+ (* RFC 9113 Section 8.3: pseudo-headers MUST appear before regular headers *)
445445+ let pseudos =
446446+ String_map.fold
447447+ (fun key (orig_key, values) acc ->
448448+ if is_pseudo_header key then
449449+ List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values
450450+ else acc)
451451+ t []
452452+ |> List.rev
453453+ in
454454+ let regulars =
455455+ String_map.fold
456456+ (fun key (orig_key, values) acc ->
457457+ if not (is_pseudo_header key) then
458458+ List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values
459459+ else acc)
460460+ t []
461461+ |> List.rev
462462+ in
463463+ pseudos @ regulars
464464+465465+let h2_request ~meth ~scheme ?authority ~path t =
466466+ let t = set_pseudo "method" meth t in
467467+ let t = set_pseudo "scheme" scheme t in
468468+ let t =
469469+ match authority with
470470+ | Some auth -> set_pseudo "authority" auth t
471471+ | None -> t
472472+ in
473473+ set_pseudo "path" path t
474474+475475+(** {2 HTTP/2 Header Validation} *)
476476+477477+type h2_validation_error =
478478+ | Missing_pseudo of string
479479+ | Invalid_pseudo of string
480480+ | Pseudo_after_regular
481481+ | Invalid_header_name of string
482482+ | Uppercase_header_name of string
483483+ | Connection_header_forbidden
484484+ | Te_header_invalid
485485+486486+let pp_h2_validation_error ppf = function
487487+ | Missing_pseudo name -> Fmt.pf ppf "Missing required pseudo-header: :%s" name
488488+ | Invalid_pseudo name ->
489489+ Fmt.pf ppf "Invalid or unknown pseudo-header: :%s" name
490490+ | Pseudo_after_regular ->
491491+ Fmt.pf ppf "Pseudo-header appeared after regular header"
492492+ | Invalid_header_name name -> Fmt.pf ppf "Invalid header name: %s" name
493493+ | Uppercase_header_name name ->
494494+ Fmt.pf ppf "Header name contains uppercase (forbidden in HTTP/2): %s" name
495495+ | Connection_header_forbidden ->
496496+ Fmt.pf ppf "Connection-specific header forbidden in HTTP/2"
497497+ | Te_header_invalid ->
498498+ Fmt.pf ppf "TE header must only contain 'trailers' in HTTP/2"
499499+500500+(** HTTP/2 forbidden headers per RFC 9113 Section 8.2.2 *)
501501+let h2_forbidden_headers : Header_name.t list =
502502+ [
503503+ `Connection;
504504+ `Keep_alive;
505505+ `Other "Proxy-Connection";
506506+ `Transfer_encoding;
507507+ `Upgrade;
508508+ ]
509509+510510+let remove_h2_forbidden t =
511511+ List.fold_left
512512+ (fun headers name -> remove name headers)
513513+ t h2_forbidden_headers
514514+515515+(** Check if a string contains uppercase ASCII letters *)
516516+let contains_uppercase s = String.exists (fun c -> c >= 'A' && c <= 'Z') s
517517+518518+(** Valid request pseudo-headers per RFC 9113 Section 8.3.1 *)
519519+let valid_request_pseudos =
520520+ [ ":method"; ":scheme"; ":authority"; ":path"; ":protocol" ]
521521+522522+(** Valid response pseudo-headers per RFC 9113 Section 8.3.2 *)
523523+let valid_response_pseudos = [ ":status" ]
524524+525525+let rec check_pseudo_order seen_regular = function
526526+ | [] -> Ok ()
527527+ | (name, _) :: rest ->
528528+ if is_pseudo_header name then
529529+ if seen_regular then Error Pseudo_after_regular
530530+ else check_pseudo_order false rest
531531+ else check_pseudo_order true rest
532532+533533+let check_h2_request_pseudos t headers_list is_connect =
534534+ let has_protocol = mem_pseudo "protocol" t in
535535+ if not (mem_pseudo "method" t) then Error (Missing_pseudo "method")
536536+ else if has_protocol && not is_connect then
537537+ Error (Invalid_pseudo "protocol (requires CONNECT method)")
538538+ else if (not is_connect) && not (mem_pseudo "scheme" t) then
539539+ Error (Missing_pseudo "scheme")
540540+ else if (not is_connect) && not (mem_pseudo "path" t) then
541541+ Error (Missing_pseudo "path")
542542+ else
543543+ match
544544+ List.find_opt
545545+ (fun (name, _) ->
546546+ is_pseudo_header name && not (List.mem name valid_request_pseudos))
547547+ headers_list
548548+ with
549549+ | Some (name, _) ->
550550+ Error (Invalid_pseudo (String.sub name 1 (String.length name - 1)))
551551+ | None -> Ok ()
552552+553553+let check_h2_regular_headers t headers_list =
554554+ match
555555+ List.find_opt
556556+ (fun (name, _) ->
557557+ (not (is_pseudo_header name)) && contains_uppercase name)
558558+ headers_list
559559+ with
560560+ | Some (name, _) -> Error (Uppercase_header_name name)
561561+ | None -> (
562562+ if List.exists (fun h -> mem h t) h2_forbidden_headers then
563563+ Error Connection_header_forbidden
564564+ else
565565+ match find `Te t with
566566+ | Some te when String.lowercase_ascii (String.trim te) <> "trailers" ->
567567+ Error Te_header_invalid
568568+ | _ -> Ok ())
569569+570570+let validate_h2_request t =
571571+ let headers_list = to_list t in
572572+ match check_pseudo_order false headers_list with
573573+ | Error e -> Error e
574574+ | Ok () -> (
575575+ let is_connect = pseudo "method" t = Some "CONNECT" in
576576+ match check_h2_request_pseudos t headers_list is_connect with
577577+ | Error e -> Error e
578578+ | Ok () -> check_h2_regular_headers t headers_list)
579579+580580+let validate_h2_response t =
581581+ let headers_list = to_list t in
582582+583583+ (* Check ordering: pseudo-headers must come before regular headers *)
584584+ let rec check_order seen_regular = function
585585+ | [] -> Ok ()
586586+ | (name, _) :: rest ->
587587+ if is_pseudo_header name then
588588+ if seen_regular then Error Pseudo_after_regular
589589+ else check_order false rest
590590+ else check_order true rest
591591+ in
592592+593593+ match check_order false headers_list with
594594+ | Error e -> Error e
595595+ | Ok () -> (
596596+ if
597597+ (* Check for required :status pseudo-header *)
598598+ not (mem_pseudo "status" t)
599599+ then Error (Missing_pseudo "status")
600600+ else
601601+ (* Check all pseudo-headers are valid (only :status allowed) *)
602602+ let invalid_pseudo =
603603+ List.find_opt
604604+ (fun (name, _) ->
605605+ is_pseudo_header name
606606+ && not (List.mem name valid_response_pseudos))
607607+ headers_list
608608+ in
609609+ match invalid_pseudo with
610610+ | Some (name, _) ->
611611+ let name_without_colon =
612612+ String.sub name 1 (String.length name - 1)
613613+ in
614614+ Error (Invalid_pseudo name_without_colon)
615615+ | None -> (
616616+ (* Check for uppercase in regular header names *)
617617+ let uppercase_header =
618618+ List.find_opt
619619+ (fun (name, _) ->
620620+ (not (is_pseudo_header name)) && contains_uppercase name)
621621+ headers_list
622622+ in
623623+ match uppercase_header with
624624+ | Some (name, _) -> Error (Uppercase_header_name name)
625625+ | None ->
626626+ (* Check for forbidden connection-specific headers *)
627627+ let has_forbidden =
628628+ List.exists (fun h -> mem h t) h2_forbidden_headers
629629+ in
630630+ if has_forbidden then Error Connection_header_forbidden
631631+ else Ok ()))
632632+633633+let validate_h2_user_headers t =
634634+ (* Validate user-provided headers for HTTP/2 (before pseudo-headers are added).
635635+ Per RFC 9113 Section 8.2.2 and 8.3, validates:
636636+ - No pseudo-headers (user should not provide them)
637637+ - No connection-specific headers
638638+ - TE header only contains "trailers" if present
639639+640640+ Note: We don't reject uppercase header names here because the library
641641+ internally stores headers with canonical HTTP/1.x names (e.g., "Accept-Encoding").
642642+ The h2_adapter lowercases all header names before sending to HTTP/2. *)
643643+ let headers_list = to_list t in
644644+645645+ (* Check for any pseudo-headers (user should not provide them) *)
646646+ let pseudo =
647647+ List.find_opt (fun (name, _) -> is_pseudo_header name) headers_list
648648+ in
649649+ match pseudo with
650650+ | Some (name, _) ->
651651+ let name_without_colon = String.sub name 1 (String.length name - 1) in
652652+ Error
653653+ (Invalid_pseudo
654654+ (name_without_colon
655655+ ^ " (user-provided headers must not contain pseudo-headers)"))
656656+ | None -> (
657657+ (* Check for forbidden connection-specific headers *)
658658+ let has_forbidden = List.exists (fun h -> mem h t) h2_forbidden_headers in
659659+ if has_forbidden then Error Connection_header_forbidden
660660+ else
661661+ (* Check TE header - only "trailers" is allowed *)
662662+ match find `Te t with
663663+ | Some te when String.lowercase_ascii (String.trim te) <> "trailers" ->
664664+ Error Te_header_invalid
665665+ | _ -> Ok ())
+508
lib/headers.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP header field handling per
77+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-5}RFC 9110 Section
88+ 5}
99+1010+ This module provides an efficient implementation of HTTP headers with
1111+ case-insensitive field names per
1212+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-5.1}RFC 9110
1313+ Section 5.1}. Headers can have multiple values for the same field name
1414+ (e.g., Set-Cookie).
1515+1616+ {2 Type-Safe Header Names}
1717+1818+ Header names use the {!Header_name.t} type, providing compile-time safety
1919+ for standard headers while allowing custom headers via [`Other]:
2020+2121+ {[
2222+ let headers =
2323+ Headers.empty
2424+ |> Headers.set `Content_type "application/json"
2525+ |> Headers.set `Authorization "Bearer token"
2626+ |> Headers.set (`Other "X-Custom") "value"
2727+ ]}
2828+2929+ {2 Security}
3030+3131+ Header names and values are validated to prevent HTTP header injection
3232+ attacks. CR and LF characters are rejected per
3333+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-5.5}RFC 9110
3434+ Section 5.5}. *)
3535+3636+val src : Logs.Src.t
3737+(** Log source for header operations. *)
3838+3939+type t
4040+(** Abstract header collection type. Headers are stored with case-insensitive
4141+ keys and maintain insertion order. *)
4242+4343+(** {1 Creation and Conversion} *)
4444+4545+val empty : t
4646+(** [empty] creates an empty header collection. *)
4747+4848+val of_list : (string * string) list -> t
4949+(** [of_list pairs] creates headers from an association list of string pairs.
5050+ This is useful when parsing headers from the wire format. Later entries
5151+ override earlier ones for the same key. *)
5252+5353+val to_list : t -> (string * string) list
5454+(** [to_list headers] converts headers to an association list. The order of
5555+ headers is preserved. *)
5656+5757+(** {1 Header Injection Prevention} *)
5858+5959+exception Invalid_header of { name : string; reason : string }
6060+(** Raised when a header name or value contains invalid characters (CR/LF) that
6161+ could enable HTTP request smuggling attacks. *)
6262+6363+exception Invalid_basic_auth of { reason : string }
6464+(** Raised when Basic auth credentials contain invalid characters. Per
6565+ {{:https://datatracker.ietf.org/doc/html/rfc7617#section-2}RFC 7617 Section
6666+ 2}:
6767+ - Username must not contain colon characters
6868+ - Username and password must not contain control characters (0x00-0x1F,
6969+ 0x7F) *)
7070+7171+(** {1 Type-Safe Header Operations}
7272+7373+ These functions use {!Header_name.t} for compile-time type safety. *)
7474+7575+val add : Header_name.t -> string -> t -> t
7676+(** [add name value headers] adds a header value. Multiple values for the same
7777+ header name are allowed (e.g., for Set-Cookie).
7878+7979+ @raise Invalid_header
8080+ if the header value contains CR/LF characters (to prevent HTTP header
8181+ injection attacks). *)
8282+8383+val set : Header_name.t -> string -> t -> t
8484+(** [set name value headers] sets a header value, replacing any existing values
8585+ for that header name.
8686+8787+ @raise Invalid_header
8888+ if the header value contains CR/LF characters (to prevent HTTP header
8989+ injection attacks). *)
9090+9191+val find : Header_name.t -> t -> string option
9292+(** [find name headers] returns the first value for a header name, or [None] if
9393+ the header doesn't exist. *)
9494+9595+val all : Header_name.t -> t -> string list
9696+(** [all name headers] returns all values for a header name. Returns an empty
9797+ list if the header doesn't exist. *)
9898+9999+val remove : Header_name.t -> t -> t
100100+(** [remove name headers] removes all values for a header name. *)
101101+102102+val mem : Header_name.t -> t -> bool
103103+(** [mem name headers] checks if a header name exists. *)
104104+105105+(** {1 String-Based Header Operations}
106106+107107+ These functions accept string header names for wire format compatibility.
108108+ Use these when parsing HTTP messages where header names arrive as strings.
109109+*)
110110+111111+val add_string : string -> string -> t -> t
112112+(** [add_string name value headers] adds a header using a string name. Use this
113113+ when parsing headers from the wire.
114114+115115+ @raise Invalid_header if the header name or value contains CR/LF characters.
116116+*)
117117+118118+val set_string : string -> string -> t -> t
119119+(** [set_string name value headers] sets a header using a string name.
120120+121121+ @raise Invalid_header if the header name or value contains CR/LF characters.
122122+*)
123123+124124+val string : string -> t -> string option
125125+(** [string name headers] gets a header using a string name. *)
126126+127127+val all_string : string -> t -> string list
128128+(** [all_string name headers] gets all values for a string header name. *)
129129+130130+val remove_string : string -> t -> t
131131+(** [remove_string name headers] removes a header using a string name. *)
132132+133133+val mem_string : string -> t -> bool
134134+(** [mem_string name headers] checks if a header exists using a string name. *)
135135+136136+(** {1 Merging} *)
137137+138138+val merge : t -> t -> t
139139+(** [merge base override] merges two header collections. Headers from [override]
140140+ replace those in [base]. *)
141141+142142+(** {1 Common Header Builders}
143143+144144+ Convenience functions for setting common HTTP headers. *)
145145+146146+val content_type : Mime.t -> t -> t
147147+(** [content_type mime headers] sets the Content-Type header. *)
148148+149149+val content_length : int64 -> t -> t
150150+(** [content_length length headers] sets the Content-Length header. *)
151151+152152+val accept : Mime.t -> t -> t
153153+(** [accept mime headers] sets the Accept header. *)
154154+155155+val accept_language : string -> t -> t
156156+(** [accept_language lang headers] sets the Accept-Language header. Per
157157+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-12.5.4}RFC 9110
158158+ Section 12.5.4}.
159159+160160+ Examples:
161161+ {[
162162+ headers
163163+ |> Headers.accept_language "en-US" headers
164164+ |> Headers.accept_language "en-US, en;q=0.9, de;q=0.8" headers
165165+ |> Headers.accept_language "*"
166166+ ]} *)
167167+168168+val authorization : string -> t -> t
169169+(** [authorization value headers] sets the Authorization header with a raw
170170+ value. *)
171171+172172+val bearer : string -> t -> t
173173+(** [bearer token headers] sets the Authorization header with a Bearer token.
174174+ Example: [bearer "abc123"] sets ["Authorization: Bearer abc123"]. *)
175175+176176+val basic : username:string -> password:string -> t -> t
177177+(** [basic ~username ~password headers] sets the Authorization header with HTTP
178178+ Basic authentication (base64-encoded username:password).
179179+180180+ @raise Invalid_basic_auth
181181+ if the username contains a colon character or if either username or
182182+ password contains control characters (RFC 7617 Section 2). *)
183183+184184+val user_agent : string -> t -> t
185185+(** [user_agent ua headers] sets the User-Agent header. *)
186186+187187+val host : string -> t -> t
188188+(** [host hostname headers] sets the Host header. *)
189189+190190+val cookie : string -> string -> t -> t
191191+(** [cookie name value headers] adds a cookie to the Cookie header. Multiple
192192+ cookies can be added by calling this function multiple times. *)
193193+194194+val range : start:int64 -> ?end_:int64 -> unit -> t -> t
195195+(** [range ~start ?end_ () headers] sets the Range header for partial content.
196196+ Example: [range ~start:0L ~end_:999L ()] requests the first 1000 bytes. *)
197197+198198+(** {1 HTTP 100-Continue Support}
199199+200200+ Per Recommendation #7: Expect: 100-continue protocol for large uploads. RFC
201201+ 9110 Section 10.1.1 (Expect) *)
202202+203203+val expect : string -> t -> t
204204+(** [expect value headers] sets the Expect header. Example:
205205+ [expect "100-continue"] for large request bodies. *)
206206+207207+val expect_100_continue : t -> t
208208+(** [expect_100_continue headers] sets [Expect: 100-continue]. Use this for
209209+ large uploads to allow the server to reject the request before the body is
210210+ sent, saving bandwidth. *)
211211+212212+(** {1 TE Header Support}
213213+214214+ Per RFC 9110 Section 10.1.4: The TE header indicates what transfer codings
215215+ the client is willing to accept in the response, and whether the client is
216216+ willing to accept trailer fields in a chunked transfer coding. *)
217217+218218+val te : string -> t -> t
219219+(** [te value headers] sets the TE header to indicate accepted transfer codings.
220220+ Example: [te "trailers, deflate"]. *)
221221+222222+val te_trailers : t -> t
223223+(** [te_trailers headers] sets [TE: trailers] to indicate the client accepts
224224+ trailer fields in chunked transfer coding. Per RFC 9110 Section 10.1.4, a
225225+ client MUST send this if it wishes to receive trailers. *)
226226+227227+(** {1 Cache Control Headers}
228228+229229+ Per Recommendation #17 and #19: Response caching and conditional requests.
230230+ RFC 9111 (HTTP Caching), RFC 9110 Section 8.8.2-8.8.3 (Last-Modified, ETag)
231231+*)
232232+233233+val if_none_match : string -> t -> t
234234+(** [if_none_match etag headers] sets the If-None-Match header for conditional
235235+ requests. The request succeeds only if the resource's ETag does NOT match.
236236+ Used with GET/HEAD to implement efficient caching (returns 304 Not Modified
237237+ if matches). *)
238238+239239+val if_match : string -> t -> t
240240+(** [if_match etag headers] sets the If-Match header for conditional requests.
241241+ The request succeeds only if the resource's ETag matches. Used with
242242+ PUT/DELETE for optimistic concurrency (prevents lost updates). *)
243243+244244+val if_modified_since : string -> t -> t
245245+(** [if_modified_since date headers] sets the If-Modified-Since header. The date
246246+ should be in HTTP-date format (RFC 9110 Section 5.6.7). Example:
247247+ ["Sun, 06 Nov 1994 08:49:37 GMT"]. *)
248248+249249+val if_unmodified_since : string -> t -> t
250250+(** [if_unmodified_since date headers] sets the If-Unmodified-Since header. The
251251+ request succeeds only if the resource has NOT been modified since the date.
252252+*)
253253+254254+val http_date_of_ptime : Ptime.t -> string
255255+(** [http_date_of_ptime time] formats a Ptime.t as an HTTP-date. Format: "Sun,
256256+ 06 Nov 1994 08:49:37 GMT" (RFC 9110 Section 5.6.7). *)
257257+258258+val if_modified_since_ptime : Ptime.t -> t -> t
259259+(** [if_modified_since_ptime time headers] sets If-Modified-Since using a
260260+ Ptime.t value. *)
261261+262262+val if_unmodified_since_ptime : Ptime.t -> t -> t
263263+(** [if_unmodified_since_ptime time headers] sets If-Unmodified-Since using a
264264+ Ptime.t value. *)
265265+266266+val cache_control : string -> t -> t
267267+(** [cache_control directives headers] sets the Cache-Control header with a raw
268268+ directive string. Example: [cache_control "no-cache, max-age=3600"]. *)
269269+270270+val cache_control_directives :
271271+ ?max_age:int ->
272272+ ?max_stale:int option option ->
273273+ ?min_fresh:int ->
274274+ ?no_cache:bool ->
275275+ ?no_store:bool ->
276276+ ?no_transform:bool ->
277277+ ?only_if_cached:bool ->
278278+ unit ->
279279+ t ->
280280+ t
281281+(** [cache_control_directives ?max_age ?max_stale ?min_fresh ~no_cache ~no_store
282282+ ~no_transform ~only_if_cached () headers] builds a Cache-Control header
283283+ from individual directives (RFC 9111 request directives).
284284+285285+ - [max_age]: Maximum age in seconds the client is willing to accept
286286+ - [max_stale]: Accept stale responses:
287287+ - [None]: omit max_stale entirely
288288+ - [Some None]: "max-stale" (accept any staleness)
289289+ - [Some (Some n)]: "max-stale=N" (accept n seconds staleness)
290290+ - [min_fresh]: Response must be fresh for at least n more seconds
291291+ - [no_cache]: Force revalidation with origin server
292292+ - [no_store]: Response must not be stored in cache
293293+ - [no_transform]: Intermediaries must not transform the response
294294+ - [only_if_cached]: Only return cached response, 504 if not available. *)
295295+296296+val etag : string -> t -> t
297297+(** [etag value headers] sets the ETag header (for responses). Example:
298298+ [etag "\"abc123\""]. *)
299299+300300+val last_modified : string -> t -> t
301301+(** [last_modified date headers] sets the Last-Modified header (for responses).
302302+ The date should be in HTTP-date format. *)
303303+304304+val last_modified_ptime : Ptime.t -> t -> t
305305+(** [last_modified_ptime time headers] sets Last-Modified using a Ptime.t value.
306306+*)
307307+308308+(** {1 Connection Header Handling}
309309+310310+ Per
311311+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1}RFC 9110
312312+ Section 7.6.1}: The Connection header field lists hop-by-hop header fields
313313+ that MUST be removed before forwarding the message. *)
314314+315315+val parse_connection_header : t -> Header_name.t list
316316+(** [parse_connection_header headers] parses the Connection header value into a
317317+ list of header names. *)
318318+319319+val hop_by_hop_headers : t -> Header_name.t list
320320+(** [hop_by_hop_headers headers] returns all hop-by-hop headers. This is the
321321+ union of {!Header_name.hop_by_hop_headers} and any headers listed in the
322322+ Connection header. *)
323323+324324+val remove_hop_by_hop : t -> t
325325+(** [remove_hop_by_hop headers] removes all hop-by-hop headers. This should be
326326+ called before caching or forwarding a response. Per RFC 9110 Section 7.6.1.
327327+*)
328328+329329+val connection_close : t -> bool
330330+(** [connection_close headers] returns [true] if Connection: close is present.
331331+ This indicates the connection should be closed after the current message. *)
332332+333333+val connection_keep_alive : t -> bool
334334+(** [connection_keep_alive headers] returns [true] if Connection: keep-alive is
335335+ present. This is primarily used with HTTP/1.0 to request a persistent
336336+ connection. *)
337337+338338+(** {1 Aliases} *)
339339+340340+val multi : Header_name.t -> t -> string list
341341+(** [multi] is an alias for {!all}. *)
342342+343343+val pp : Format.formatter -> t -> unit
344344+(** Pretty printer for headers. *)
345345+346346+val pp_brief : Format.formatter -> t -> unit
347347+(** Brief pretty printer showing count only. *)
348348+349349+(** {1 HTTP/2 Pseudo-Header Support}
350350+351351+ HTTP/2 uses pseudo-header fields to convey information that was previously
352352+ carried in the request line (HTTP/1.1) or status line. Pseudo-headers start
353353+ with a colon character ([:]).
354354+355355+ Per
356356+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-8.3}RFC 9113
357357+ Section 8.3}:
358358+ - Pseudo-headers MUST appear before regular headers
359359+ - Pseudo-headers MUST NOT appear in trailers
360360+ - Unknown pseudo-headers MUST be treated as malformed
361361+362362+ {2 Request Pseudo-Headers}
363363+364364+ - [:method] - HTTP method (required)
365365+ - [:scheme] - URI scheme (required for non-CONNECT)
366366+ - [:authority] - Authority portion of URI (host:port)
367367+ - [:path] - Path and query (required for non-CONNECT)
368368+369369+ {2 Response Pseudo-Headers}
370370+371371+ - [:status] - HTTP status code (required) *)
372372+373373+val is_pseudo_header : string -> bool
374374+(** [is_pseudo_header name] returns [true] if the header name starts with [:].
375375+ Per RFC 9113 Section 8.3, pseudo-headers are identified by a colon prefix.
376376+*)
377377+378378+val pseudo : string -> t -> string option
379379+(** [pseudo name headers] retrieves a pseudo-header value. The [name] should NOT
380380+ include the colon prefix. Example: [pseudo "method" headers] retrieves
381381+ [:method]. *)
382382+383383+val set_pseudo : string -> string -> t -> t
384384+(** [set_pseudo name value headers] sets a pseudo-header value. The [name]
385385+ should NOT include the colon prefix. Pseudo-headers are stored with the
386386+ colon prefix internally. Example: [set_pseudo "method" "GET" headers] sets
387387+ [:method: GET].
388388+389389+ @raise Invalid_header if the value contains CR/LF characters. *)
390390+391391+val remove_pseudo : string -> t -> t
392392+(** [remove_pseudo name headers] removes a pseudo-header. The [name] should NOT
393393+ include the colon prefix. *)
394394+395395+val mem_pseudo : string -> t -> bool
396396+(** [mem_pseudo name headers] returns [true] if the pseudo-header exists. The
397397+ [name] should NOT include the colon prefix. *)
398398+399399+val has_pseudo_headers : t -> bool
400400+(** [has_pseudo_headers headers] returns [true] if any pseudo-headers are
401401+ present. *)
402402+403403+val pseudo_headers : t -> (string * string) list
404404+(** [pseudo_headers headers] returns all pseudo-headers as [(name, value)]
405405+ pairs. Names are returned WITHOUT the colon prefix. *)
406406+407407+val regular_headers : t -> (string * string) list
408408+(** [regular_headers headers] returns all non-pseudo headers as [(name, value)]
409409+ pairs. *)
410410+411411+val to_list_ordered : t -> (string * string) list
412412+(** [to_list_ordered headers] returns all headers with pseudo-headers first,
413413+ followed by regular headers, as required by RFC 9113 Section 8.3. *)
414414+415415+(** {2 HTTP/2 Request Header Construction} *)
416416+417417+val h2_request :
418418+ meth:string -> scheme:string -> ?authority:string -> path:string -> t -> t
419419+(** [h2_request ~meth ~scheme ?authority ~path headers] sets the required HTTP/2
420420+ request pseudo-headers.
421421+422422+ Per RFC 9113 Section 8.3.1:
423423+ - [:method] is required
424424+ - [:scheme] is required (except for CONNECT)
425425+ - [:path] is required (except for CONNECT, OPTIONS with empty path)
426426+ - [:authority] is optional but recommended
427427+428428+ Example:
429429+ {[
430430+ Headers.empty
431431+ |> Headers.h2_request ~meth:"GET" ~scheme:"https" ~authority:"example.com"
432432+ ~path:"/"
433433+ |> Headers.set `Accept "application/json"
434434+ ]} *)
435435+436436+(** {2 HTTP/2 Header Validation}
437437+438438+ Per
439439+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-8.2}RFC 9113
440440+ Section 8.2}. *)
441441+442442+type h2_validation_error =
443443+ | Missing_pseudo of string (** Required pseudo-header is missing *)
444444+ | Invalid_pseudo of string (** Unknown or misplaced pseudo-header *)
445445+ | Pseudo_after_regular (** Pseudo-header appeared after regular header *)
446446+ | Invalid_header_name of string
447447+ (** Header name contains invalid characters *)
448448+ | Uppercase_header_name of string
449449+ (** Header name contains uppercase (forbidden in HTTP/2) *)
450450+ | Connection_header_forbidden
451451+ (** Connection-specific headers are forbidden in HTTP/2 *)
452452+ | Te_header_invalid (** TE header with value other than "trailers" *)
453453+454454+val pp_h2_validation_error : Format.formatter -> h2_validation_error -> unit
455455+(** Pretty printer for validation errors. *)
456456+457457+val validate_h2_request : t -> (unit, h2_validation_error) result
458458+(** [validate_h2_request headers] validates headers for HTTP/2 request
459459+ constraints.
460460+461461+ Per RFC 9113 Section 8.3.1, validates:
462462+ - Required pseudo-headers are present ([:method], [:scheme], [:path])
463463+ - No unknown pseudo-headers
464464+ - Pseudo-headers appear before regular headers
465465+ - No uppercase letters in header names
466466+ - No connection-specific headers (Connection, Keep-Alive, etc.)
467467+ - TE header only contains "trailers" if present. *)
468468+469469+val validate_h2_response : t -> (unit, h2_validation_error) result
470470+(** [validate_h2_response headers] validates headers for HTTP/2 response
471471+ constraints.
472472+473473+ Per RFC 9113 Section 8.3.2, validates:
474474+ - [:status] pseudo-header is present
475475+ - No other pseudo-headers
476476+ - Pseudo-headers appear before regular headers
477477+ - No uppercase letters in header names
478478+ - No connection-specific headers. *)
479479+480480+val validate_h2_user_headers : t -> (unit, h2_validation_error) result
481481+(** [validate_h2_user_headers headers] validates user-provided headers for
482482+ HTTP/2.
483483+484484+ Unlike {!validate_h2_request}, this validates headers {i before}
485485+ pseudo-headers are added by the HTTP/2 layer. Use this in the HTTP adapter.
486486+487487+ Per RFC 9113 Section 8.2.2 and 8.3, validates:
488488+ - No pseudo-headers (user should not provide them)
489489+ - No uppercase letters in header names
490490+ - No connection-specific headers (Connection, Keep-Alive, etc.)
491491+ - TE header only contains "trailers" if present. *)
492492+493493+(** {2 HTTP/2 Forbidden Headers}
494494+495495+ Per RFC 9113 Section 8.2.2, certain headers are connection-specific and MUST
496496+ NOT appear in HTTP/2. *)
497497+498498+val h2_forbidden_headers : Header_name.t list
499499+(** Headers that MUST NOT appear in HTTP/2 messages:
500500+ - Connection
501501+ - Keep-Alive
502502+ - Proxy-Connection
503503+ - Transfer-Encoding
504504+ - Upgrade. *)
505505+506506+val remove_h2_forbidden : t -> t
507507+(** [remove_h2_forbidden headers] removes all HTTP/2 forbidden headers. Use this
508508+ when converting HTTP/1.1 headers for use with HTTP/2. *)
+81
lib/http_date.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP-date parsing per RFC 9110 Section 5.6.7 *)
77+88+let src = Logs.Src.create "requests.http_date" ~doc:"HTTP Date Parsing"
99+1010+module Log = (val Logs.src_log src : Logs.LOG)
1111+1212+(** Parse HTTP-date (RFC 9110 Section 5.6.7) to Ptime.t *)
1313+let parse s =
1414+ (* HTTP-date format: "Sun, 06 Nov 1994 08:49:37 GMT" (RFC 1123) *)
1515+ (* Also supports obsolete formats per RFC 9110 *)
1616+ let s = String.trim s in
1717+1818+ (* Helper to parse month name *)
1919+ let parse_month month_str =
2020+ match String.lowercase_ascii month_str with
2121+ | "jan" -> 1
2222+ | "feb" -> 2
2323+ | "mar" -> 3
2424+ | "apr" -> 4
2525+ | "may" -> 5
2626+ | "jun" -> 6
2727+ | "jul" -> 7
2828+ | "aug" -> 8
2929+ | "sep" -> 9
3030+ | "oct" -> 10
3131+ | "nov" -> 11
3232+ | "dec" -> 12
3333+ | _ -> failwith "invalid month"
3434+ in
3535+3636+ (* Validate time components per RFC 9110 Section 5.6.7 ABNF:
3737+ hour = 2DIGIT (00-23), minute = 2DIGIT (00-59), second = 2DIGIT (00-59).
3838+ Note: RFC 9110 inherits the 00-60 range from RFC 5234 to allow leap
3939+ seconds, but HTTP servers MUST NOT generate them and recipients SHOULD
4040+ treat second=60 as invalid. We reject second=60 for robustness. *)
4141+ let validate_time hour min sec =
4242+ hour >= 0 && hour <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
4343+ in
4444+4545+ let make_datetime year month day hour min sec =
4646+ if validate_time hour min sec then
4747+ Ptime.of_date_time ((year, month, day), ((hour, min, sec), 0))
4848+ else None
4949+ in
5050+5151+ (* Try different date formats in order of preference *)
5252+ let parsers =
5353+ [
5454+ (* RFC 1123 format: "Sun, 06 Nov 1994 08:49:37 GMT" *)
5555+ (fun () ->
5656+ Scanf.sscanf s "%_s %d %s %d %d:%d:%d GMT"
5757+ (fun day month_str year hour min sec ->
5858+ let month = parse_month month_str in
5959+ make_datetime year month day hour min sec));
6060+ (* RFC 850 format: "Sunday, 06-Nov-94 08:49:37 GMT" *)
6161+ (fun () ->
6262+ Scanf.sscanf s "%_s %d-%s@-%d %d:%d:%d GMT"
6363+ (fun day month_str year2 hour min sec ->
6464+ let year = if year2 >= 70 then 1900 + year2 else 2000 + year2 in
6565+ let month = parse_month month_str in
6666+ make_datetime year month day hour min sec));
6767+ (* ANSI C asctime() format: "Sun Nov 6 08:49:37 1994" *)
6868+ (fun () ->
6969+ Scanf.sscanf s "%_s %s %d %d:%d:%d %d"
7070+ (fun month_str day hour min sec year ->
7171+ let month = parse_month month_str in
7272+ make_datetime year month day hour min sec));
7373+ ]
7474+ in
7575+7676+ (* Try each parser until one succeeds *)
7777+ List.find_map
7878+ (fun parser ->
7979+ try parser ()
8080+ with Scanf.Scan_failure _ | Failure _ | End_of_file -> None)
8181+ parsers
+28
lib/http_date.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP-date parsing per RFC 9110 Section 5.6.7
77+88+ This module provides parsing of HTTP date strings as defined in RFC 9110. It
99+ supports three date formats:
1010+ - RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT" (preferred)
1111+ - RFC 850: "Sunday, 06-Nov-94 08:49:37 GMT" (obsolete)
1212+ - ANSI C asctime(): "Sun Nov 6 08:49:37 1994" (obsolete) *)
1313+1414+val src : Logs.Src.t
1515+(** Log source for HTTP date parsing. *)
1616+1717+val parse : string -> Ptime.t option
1818+(** Parse an HTTP-date string to Ptime.t.
1919+2020+ [parse s] attempts to parse the string [s] as an HTTP-date using the three
2121+ supported formats. Returns [None] if parsing fails.
2222+2323+ Examples:
2424+ {[
2525+ parse "Sun, 06 Nov 1994 08:49:37 GMT" (* RFC 1123 *) parse
2626+ "Sunday, 06-Nov-94 08:49:37 GMT" (* RFC 850 *) parse
2727+ "Sun Nov 6 08:49:37 1994" (* asctime *)
2828+ ]} *)
+61
lib/http_version.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP protocol version identification and ALPN support.
77+88+ Implements protocol identification per
99+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-3.1}RFC 9113
1010+ Section 3.1}. *)
1111+1212+type t = Http_1_0 | Http_1_1 | Http_2
1313+1414+let to_string = function
1515+ | Http_1_0 -> "HTTP/1.0"
1616+ | Http_1_1 -> "HTTP/1.1"
1717+ | Http_2 -> "HTTP/2"
1818+1919+let pp ppf v = Fmt.string ppf (to_string v)
2020+2121+let equal v1 v2 =
2222+ match (v1, v2) with
2323+ | Http_1_0, Http_1_0 -> true
2424+ | Http_1_1, Http_1_1 -> true
2525+ | Http_2, Http_2 -> true
2626+ | _ -> false
2727+2828+let compare v1 v2 =
2929+ let to_int = function Http_1_0 -> 0 | Http_1_1 -> 1 | Http_2 -> 2 in
3030+ Int.compare (to_int v1) (to_int v2)
3131+3232+(* ALPN Protocol Identifiers per RFC 9113 Section 3.1 *)
3333+3434+let alpn_h2 = "h2"
3535+let alpn_http_1_1 = "http/1.1"
3636+3737+let alpn_of_version = function
3838+ | Http_1_0 -> None (* HTTP/1.0 has no ALPN identifier *)
3939+ | Http_1_1 -> Some alpn_http_1_1
4040+ | Http_2 -> Some alpn_h2
4141+4242+let version_of_alpn = function
4343+ | "h2" -> Some Http_2
4444+ | "http/1.1" -> Some Http_1_1
4545+ | _ -> None
4646+4747+let alpn_protocols ~preferred = List.filter_map alpn_of_version preferred
4848+4949+(* Version capability detection *)
5050+5151+let supports_multiplexing = function
5252+ | Http_2 -> true
5353+ | Http_1_0 | Http_1_1 -> false
5454+5555+let supports_server_push = function
5656+ | Http_2 -> true
5757+ | Http_1_0 | Http_1_1 -> false
5858+5959+let supports_header_compression = function
6060+ | Http_2 -> true
6161+ | Http_1_0 | Http_1_1 -> false
+94
lib/http_version.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP protocol version identification and ALPN support.
77+88+ This module provides types for HTTP protocol versions and utilities for TLS
99+ Application-Layer Protocol Negotiation (ALPN) as specified in
1010+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-3.1}RFC 9113
1111+ Section 3.1}.
1212+1313+ {2 ALPN Protocol Identifiers}
1414+1515+ Per RFC 9113 Section 3.1:
1616+ - ["h2"] identifies HTTP/2 over TLS
1717+ - ["http/1.1"] identifies HTTP/1.1
1818+1919+ {2 Example}
2020+2121+ {[
2222+ (* Configure TLS with HTTP/2 preference *)
2323+ let alpn = Http_version.alpn_protocols ~preferred:[Http_2; Http_1_1] in
2424+ (* alpn = ["h2"; "http/1.1"] *)
2525+ ]} *)
2626+2727+(** {1 Version Type} *)
2828+2929+type t =
3030+ | Http_1_0 (** HTTP/1.0 *)
3131+ | Http_1_1 (** HTTP/1.1 *)
3232+ | Http_2 (** HTTP/2 per RFC 9113 *)
3333+3434+(** {1 Conversion} *)
3535+3636+val to_string : t -> string
3737+(** [to_string version] returns a human-readable string. Examples: ["HTTP/1.0"],
3838+ ["HTTP/1.1"], ["HTTP/2"]. *)
3939+4040+val pp : Format.formatter -> t -> unit
4141+(** Pretty printer for versions. *)
4242+4343+(** {1 Comparison} *)
4444+4545+val equal : t -> t -> bool
4646+(** [equal v1 v2] returns true if versions are equal. *)
4747+4848+val compare : t -> t -> int
4949+(** [compare v1 v2] compares versions. HTTP/2 > HTTP/1.1 > HTTP/1.0. *)
5050+5151+(** {1 ALPN Protocol Negotiation}
5252+5353+ Per
5454+ {{:https://datatracker.ietf.org/doc/html/rfc9113#section-3.1}RFC 9113
5555+ Section 3.1}. *)
5656+5757+val alpn_h2 : string
5858+(** ALPN protocol identifier for HTTP/2 over TLS, the string [h2]. Serialized as
5959+ the two-octet sequence: 0x68, 0x32. *)
6060+6161+val alpn_http_1_1 : string
6262+(** ALPN protocol identifier for HTTP/1.1, the string [http/1.1]. *)
6363+6464+val alpn_of_version : t -> string option
6565+(** [alpn_of_version version] returns the ALPN identifier for a version. Returns
6666+ [None] for HTTP/1.0 which has no ALPN identifier. *)
6767+6868+val version_of_alpn : string -> t option
6969+(** [version_of_alpn alpn] returns the version for an ALPN identifier. Returns
7070+ [None] for unrecognized identifiers. *)
7171+7272+val alpn_protocols : preferred:t list -> string list
7373+(** [alpn_protocols ~preferred] returns ALPN protocol identifiers in preference
7474+ order. HTTP/1.0 is filtered out (no ALPN identifier).
7575+7676+ Example:
7777+ {[
7878+ alpn_protocols ~preferred:[ Http_2; Http_1_1 ]
7979+ (* Returns: ["h2"; "http/1.1"] *)
8080+ ]} *)
8181+8282+(** {1 Version Detection} *)
8383+8484+val supports_multiplexing : t -> bool
8585+(** [supports_multiplexing version] returns true if the version supports request
8686+ multiplexing over a single connection. Only HTTP/2 supports this. *)
8787+8888+val supports_server_push : t -> bool
8989+(** [supports_server_push version] returns true if the version supports server
9090+ push. Only HTTP/2 supports this. *)
9191+9292+val supports_header_compression : t -> bool
9393+(** [supports_header_compression version] returns true if the version supports
9494+ header compression. Only HTTP/2 (HPACK) supports this. *)
+209
lib/huri.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2012-2014 Anil Madhavapeddy <anil@recoil.org>
33+ Copyright (c) 2012-2014 David Sheets <sheets@alum.mit.edu>
44+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
55+66+ Permission to use, copy, modify, and distribute this software for any
77+ purpose with or without fee is hereby granted, provided that the above
88+ copyright notice and this permission notice appear in all copies.
99+1010+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1111+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1212+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1313+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1414+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1515+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1616+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1717+ ---------------------------------------------------------------------------*)
1818+1919+(** URI Buf_write serialization for the requests library.
2020+2121+ This module provides efficient [Eio.Buf_write] serialization for [Uri.t]
2222+ values. For all other URI operations, use the [uri] opam library directly.
2323+2424+ {[
2525+ (* Use Uri for parsing and manipulation *)
2626+ let uri = Uri.of_string "https://example.com/path" in
2727+ let host = Uri.host uri in
2828+2929+ (* Use Huri.write for efficient serialization to Buf_write *)
3030+ Eio.Buf_write.with_flow flow (fun w -> Huri.write w uri)
3131+ ]} *)
3232+3333+(** {1 Type Alias} *)
3434+3535+type t = Uri.t
3636+(** [t] is an alias for [Uri.t]. Use the [uri] library for all operations except
3737+ [Buf_write] serialization. *)
3838+3939+(** {1 Buf_write Serialization} *)
4040+4141+(** Hex character lookup table for efficient percent-encoding *)
4242+let hex_chars = "0123456789ABCDEF"
4343+4444+(** Safe characters for different URI components per RFC 3986 *)
4545+module Safe_chars = struct
4646+ type safe_chars = bool array
4747+4848+ let sub_delims a =
4949+ let subd = "!$&'()*+,;=" in
5050+ for i = 0 to String.length subd - 1 do
5151+ a.(Char.code subd.[i]) <- true
5252+ done;
5353+ a
5454+5555+ let unreserved : safe_chars =
5656+ let a = Array.make 256 false in
5757+ let always_safe =
5858+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~"
5959+ in
6060+ for i = 0 to String.length always_safe - 1 do
6161+ a.(Char.code always_safe.[i]) <- true
6262+ done;
6363+ a
6464+6565+ let pchar : safe_chars =
6666+ let a = sub_delims (Array.copy unreserved) in
6767+ a.(Char.code ':') <- true;
6868+ a.(Char.code '@') <- true;
6969+ a
7070+7171+ let path : safe_chars =
7272+ let a = sub_delims (Array.copy pchar) in
7373+ a.(Char.code '/') <- false;
7474+ a
7575+7676+ let query : safe_chars =
7777+ let a = Array.copy pchar in
7878+ a.(Char.code '/') <- true;
7979+ a.(Char.code '?') <- true;
8080+ a.(Char.code '&') <- false;
8181+ a.(Char.code ';') <- false;
8282+ a.(Char.code '+') <- false;
8383+ a
8484+8585+ let query_key : safe_chars =
8686+ let a = Array.copy query in
8787+ a.(Char.code '=') <- false;
8888+ a
8989+9090+ let query_value : safe_chars =
9191+ let a = Array.copy query in
9292+ a.(Char.code ',') <- false;
9393+ a
9494+9595+ let fragment : safe_chars = query
9696+9797+ let userinfo : safe_chars =
9898+ let a = Array.copy unreserved in
9999+ a.(Char.code ':') <- false;
100100+ a
101101+end
102102+103103+module Writer = struct
104104+ module Write = Eio.Buf_write
105105+106106+ let write_pct_char w c =
107107+ Write.char w '%';
108108+ Write.char w hex_chars.[Char.code c lsr 4];
109109+ Write.char w hex_chars.[Char.code c land 0xf]
110110+111111+ let write_pct_encoded ~safe_chars w s =
112112+ for i = 0 to String.length s - 1 do
113113+ let c = s.[i] in
114114+ if safe_chars.(Char.code c) then Write.char w c else write_pct_char w c
115115+ done
116116+117117+ let write_path w path =
118118+ let len = String.length path in
119119+ let rec loop i =
120120+ if i >= len then ()
121121+ else if path.[i] = '/' then begin
122122+ Write.char w '/';
123123+ loop (i + 1)
124124+ end
125125+ else begin
126126+ let rec find_end j =
127127+ if j >= len || path.[j] = '/' then j else find_end (j + 1)
128128+ in
129129+ let j = find_end i in
130130+ write_pct_encoded ~safe_chars:Safe_chars.path w
131131+ (String.sub path i (j - i));
132132+ loop j
133133+ end
134134+ in
135135+ loop 0
136136+137137+ let write_values w vs =
138138+ Write.char w '=';
139139+ List.iteri
140140+ (fun j v ->
141141+ if j > 0 then Write.char w ',';
142142+ write_pct_encoded ~safe_chars:Safe_chars.query_value w v)
143143+ vs
144144+145145+ let write_query w query =
146146+ List.iteri
147147+ (fun i (k, vs) ->
148148+ if i > 0 then Write.char w '&';
149149+ write_pct_encoded ~safe_chars:Safe_chars.query_key w k;
150150+ if vs <> [] then write_values w vs)
151151+ query
152152+153153+ let write w uri =
154154+ (* Scheme *)
155155+ Option.iter
156156+ (fun s ->
157157+ Write.string w s;
158158+ Write.char w ':')
159159+ (Uri.scheme uri);
160160+ (* Authority *)
161161+ (match (Uri.userinfo uri, Uri.host uri, Uri.port uri) with
162162+ | Some _, _, _ | _, Some _, _ | _, _, Some _ -> Write.string w "//"
163163+ | _ -> ());
164164+ (* Userinfo *)
165165+ Option.iter
166166+ (fun ui ->
167167+ write_pct_encoded ~safe_chars:Safe_chars.userinfo w ui;
168168+ Write.char w '@')
169169+ (Uri.userinfo uri);
170170+ (* Host *)
171171+ Option.iter
172172+ (fun h ->
173173+ if String.length h > 0 && h.[0] = '[' then Write.string w h
174174+ else write_pct_encoded ~safe_chars:Safe_chars.unreserved w h)
175175+ (Uri.host uri);
176176+ (* Port *)
177177+ Option.iter
178178+ (fun p ->
179179+ Write.char w ':';
180180+ Write.string w (string_of_int p))
181181+ (Uri.port uri);
182182+ (* Path *)
183183+ let path = Uri.path uri in
184184+ if path <> "" then write_path w path;
185185+ (* Query *)
186186+ let query = Uri.query uri in
187187+ if query <> [] then begin
188188+ Write.char w '?';
189189+ write_query w query
190190+ end;
191191+ (* Fragment *)
192192+ Option.iter
193193+ (fun f ->
194194+ Write.char w '#';
195195+ write_pct_encoded ~safe_chars:Safe_chars.fragment w f)
196196+ (Uri.fragment uri)
197197+end
198198+199199+(** [write w uri] writes [uri] directly to the buffer [w]. This is more
200200+ efficient than [Uri.to_string] when writing to an I/O sink as it avoids
201201+ intermediate string allocation. *)
202202+let write = Writer.write
203203+204204+let pp fmt uri = Format.pp_print_string fmt (Uri.to_string uri)
205205+206206+(** {1 JSON Codec} *)
207207+208208+(** JSON codec for URIs. Encodes as a JSON string. *)
209209+let jsont = Jsont.string |> Jsont.map ~dec:Uri.of_string ~enc:Uri.to_string
+53
lib/huri.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2012-2013 Anil Madhavapeddy <anil@recoil.org>
33+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
44+55+ Permission to use, copy, modify, and distribute this software for any
66+ purpose with or without fee is hereby granted, provided that the above
77+ copyright notice and this permission notice appear in all copies.
88+99+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616+ ---------------------------------------------------------------------------*)
1717+1818+(** URI Buf_write serialization for the requests library.
1919+2020+ This module provides efficient [Eio.Buf_write] serialization for [Uri.t]
2121+ values. For all other URI operations, use the [uri] opam library directly.
2222+2323+ {2 Usage}
2424+2525+ {[
2626+ (* Use Uri for parsing and manipulation *)
2727+ let uri = Uri.of_string "https://example.com/path" in
2828+ let host = Uri.host uri in
2929+3030+ (* Use Huri.write for efficient serialization to Buf_write *)
3131+ Eio.Buf_write.with_flow flow (fun w -> Huri.write w uri)
3232+ ]} *)
3333+3434+(** {1 Type Alias} *)
3535+3636+type t = Uri.t
3737+(** [t] is an alias for [Uri.t]. Use the [uri] library for all operations except
3838+ [Buf_write] serialization. *)
3939+4040+val pp : Format.formatter -> t -> unit
4141+(** [pp fmt t] pretty-prints a URI. *)
4242+4343+(** {1 Buf_write Serialization} *)
4444+4545+val write : Eio.Buf_write.t -> t -> unit
4646+(** [write w uri] writes [uri] directly to the buffer [w]. This is more
4747+ efficient than [Uri.to_string] when writing to an I/O sink as it avoids
4848+ intermediate string allocation. *)
4949+5050+(** {1 JSON Codec} *)
5151+5252+val jsont : t Jsont.t
5353+(** JSON codec for URIs. Encodes as a JSON string. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP request methods per
77+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-9}RFC 9110 Section
88+ 9}
99+1010+ HTTP methods indicate the desired action to be performed on a resource. The
1111+ method token is case-sensitive.
1212+1313+ {2 Safe Methods}
1414+1515+ Methods are considered "safe" if their semantics are read-only (GET, HEAD,
1616+ OPTIONS, TRACE). Per
1717+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.1}RFC 9110
1818+ Section 9.2.1}.
1919+2020+ {2 Idempotent Methods}
2121+2222+ A method is "idempotent" if multiple identical requests have the same effect
2323+ as a single request (GET, HEAD, PUT, DELETE, OPTIONS, TRACE). Per
2424+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2}RFC 9110
2525+ Section 9.2.2}. *)
2626+2727+val src : Logs.Src.t
2828+(** Log source for method operations. *)
2929+3030+type t =
3131+ [ `GET (** Retrieve a resource *)
3232+ | `POST (** Submit data to be processed *)
3333+ | `PUT (** Replace a resource *)
3434+ | `DELETE (** Delete a resource *)
3535+ | `HEAD (** Retrieve headers only *)
3636+ | `OPTIONS (** Retrieve allowed methods *)
3737+ | `PATCH (** Partial resource modification *)
3838+ | `CONNECT (** Establish tunnel to server *)
3939+ | `TRACE (** Echo received request *)
4040+ | `Other of string (** Non-standard or extension method *) ]
4141+(** HTTP method type using polymorphic variants for better composability *)
4242+4343+(** {1 Conversion Functions} *)
4444+4545+val to_string : t -> string
4646+(** Convert method to uppercase string representation. *)
4747+4848+val of_string : string -> t
4949+(** [of_string s] parses a method from string (case-insensitive). Returns
5050+ [`Other s] for unrecognized methods. *)
5151+5252+val pp : Format.formatter -> t -> unit
5353+(** Pretty printer for methods. *)
5454+5555+(** {1 Method Properties} *)
5656+5757+val is_safe : t -> bool
5858+(** Returns true for safe methods (GET, HEAD, OPTIONS, TRACE). Safe methods
5959+ should not have side effects. *)
6060+6161+val is_idempotent : t -> bool
6262+(** Returns true for idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS,
6363+ TRACE). Idempotent methods can be called multiple times with the same
6464+ result. *)
6565+6666+(** Request body semantics per RFC 9110 Section 9.3 *)
6767+type body_semantics =
6868+ | Body_required (** Method requires a body (POST, PUT, PATCH) *)
6969+ | Body_optional (** Method MAY have a body (DELETE, OPTIONS, GET) *)
7070+ | Body_forbidden (** Method MUST NOT have a body (HEAD, TRACE, CONNECT) *)
7171+7272+val request_body_semantics : t -> body_semantics
7373+(** Returns the request body semantics for a method per RFC 9110.
7474+7575+ - {!Body_required}: POST, PUT, PATCH - body is expected
7676+ - {!Body_optional}: DELETE, OPTIONS, GET - body allowed but has no defined
7777+ semantics
7878+ - {!Body_forbidden}: HEAD, TRACE, CONNECT - body MUST NOT be sent. *)
7979+8080+val has_request_body : t -> bool
8181+(** Returns true for methods that typically have a request body (POST, PUT,
8282+ PATCH).
8383+ @deprecated
8484+ Use {!request_body_semantics} for more accurate RFC 9110 semantics. *)
8585+8686+val is_cacheable : t -> bool
8787+(** Returns true for methods whose responses are cacheable by default (GET,
8888+ HEAD, POST). Note: POST is only cacheable with explicit cache headers. *)
8989+9090+(** {1 Comparison} *)
9191+9292+val equal : t -> t -> bool
9393+(** Compare two methods for equality. *)
9494+9595+val compare : t -> t -> int
9696+(** Compare two methods for ordering. *)
+90
lib/mime.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "requests.mime" ~doc:"MIME Type Handling"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type t = {
1111+ type_ : string;
1212+ subtype : string;
1313+ parameters : (string * string) list;
1414+}
1515+1616+let v type_ subtype = { type_; subtype; parameters = [] }
1717+1818+let of_string s =
1919+ let parts = String.split_on_char ';' s in
2020+ match parts with
2121+ | [] -> v "text" "plain"
2222+ | mime :: params ->
2323+ let mime_parts = String.split_on_char '/' (String.trim mime) in
2424+ let type_, subtype =
2525+ match mime_parts with
2626+ | [ t; s ] -> (String.trim t, String.trim s)
2727+ | [ t ] -> (String.trim t, "*")
2828+ | _ -> ("text", "plain")
2929+ in
3030+ let parse_param p =
3131+ match String.split_on_char '=' (String.trim p) with
3232+ | [ k; v ] ->
3333+ let k = String.trim k in
3434+ let v = String.trim v in
3535+ let v =
3636+ if
3737+ String.length v >= 2
3838+ && v.[0] = '"'
3939+ && v.[String.length v - 1] = '"'
4040+ then String.sub v 1 (String.length v - 2)
4141+ else v
4242+ in
4343+ Some (String.lowercase_ascii k, v)
4444+ | _ -> None
4545+ in
4646+ let parameters = List.filter_map parse_param params in
4747+ { type_; subtype; parameters }
4848+4949+let to_string t =
5050+ let base = Fmt.str "%s/%s" t.type_ t.subtype in
5151+ match t.parameters with
5252+ | [] -> base
5353+ | params ->
5454+ let param_str =
5555+ List.map
5656+ (fun (k, v) ->
5757+ if String.contains v ' ' || String.contains v ';' then
5858+ Fmt.str "%s=\"%s\"" k v
5959+ else Fmt.str "%s=%s" k v)
6060+ params
6161+ |> String.concat "; "
6262+ in
6363+ Fmt.str "%s; %s" base param_str
6464+6565+let pp ppf t = Fmt.pf ppf "%s" (to_string t)
6666+let charset t = List.assoc_opt "charset" t.parameters
6767+6868+let with_charset charset t =
6969+ let parameters =
7070+ ("charset", charset)
7171+ :: List.filter (fun (k, _) -> k <> "charset") t.parameters
7272+ in
7373+ { t with parameters }
7474+7575+let with_param key value t =
7676+ let key_lower = String.lowercase_ascii key in
7777+ let parameters =
7878+ (key_lower, value)
7979+ :: List.filter (fun (k, _) -> k <> key_lower) t.parameters
8080+ in
8181+ { t with parameters }
8282+8383+(* Common MIME types *)
8484+let json = v "application" "json"
8585+let text = v "text" "plain"
8686+let html = v "text" "html"
8787+let xml = v "application" "xml"
8888+let form = v "application" "x-www-form-urlencoded"
8989+let octet_stream = v "application" "octet-stream"
9090+let multipart_form = v "multipart" "form-data"
+56
lib/mime.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** MIME type handling *)
77+88+val src : Logs.Src.t
99+(** Log source for MIME type operations. *)
1010+1111+type t
1212+(** Abstract MIME type *)
1313+1414+val of_string : string -> t
1515+(** Parse MIME type from string (e.g., "text/html; charset=utf-8"). *)
1616+1717+val to_string : t -> string
1818+(** Convert MIME type to string representation. *)
1919+2020+val pp : Format.formatter -> t -> unit
2121+(** Pretty printer for MIME types. *)
2222+2323+val json : t
2424+(** [json] is [application/json]. *)
2525+2626+val text : t
2727+(** [text] is [text/plain]. *)
2828+2929+val html : t
3030+(** [html] is [text/html]. *)
3131+3232+val xml : t
3333+(** [xml] is [application/xml]. *)
3434+3535+val form : t
3636+(** [form] is [application/x-www-form-urlencoded]. *)
3737+3838+val octet_stream : t
3939+(** [octet_stream] is [application/octet-stream]. *)
4040+4141+val multipart_form : t
4242+(** [multipart_form] is [multipart/form-data]. *)
4343+4444+val v : string -> string -> t
4545+(** [v type subtype] creates a MIME type. *)
4646+4747+val with_charset : string -> t -> t
4848+(** Add or update charset parameter. *)
4949+5050+val with_param : string -> string -> t -> t
5151+(** [with_param key value t] adds or updates a parameter in the MIME type.
5252+ Example: [with_param "boundary" "----WebKit123" multipart_form] produces
5353+ "multipart/form-data; boundary=----WebKit123". *)
5454+5555+val charset : t -> string option
5656+(** Extract charset parameter if present. *)
+235
lib/response.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "requests.response" ~doc:"HTTP Response"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type t = {
1111+ status : int;
1212+ headers : Headers.t;
1313+ body : Eio.Flow.source_ty Eio.Resource.t;
1414+ url : string;
1515+ elapsed : float;
1616+ mutable closed : bool;
1717+}
1818+1919+let v ~sw ~status ~headers ~body ~url ~elapsed =
2020+ Log.debug (fun m ->
2121+ m "Creating response: status=%d url=%s elapsed=%.3fs" status url elapsed);
2222+ let response = { status; headers; body; url; elapsed; closed = false } in
2323+2424+ (* Register cleanup with switch *)
2525+ Eio.Switch.on_release sw (fun () ->
2626+ if not response.closed then begin
2727+ Log.debug (fun m -> m "Auto-closing response for %s via switch" url);
2828+ response.closed <- true
2929+ (* Body cleanup happens automatically via Eio switch lifecycle.
3030+ The body flow (created via Eio.Flow.string_source) is a memory-backed
3131+ source that doesn't require explicit cleanup. File-based responses
3232+ would have their file handles cleaned up by the switch. *)
3333+ end);
3434+3535+ response
3636+3737+let status t = Status.of_int t.status
3838+let status_code t = t.status
3939+let ok t = Status.is_success (Status.of_int t.status)
4040+let headers t = t.headers
4141+let header name t = Headers.find name t.headers
4242+let header_string name t = Headers.string name t.headers
4343+4444+(** Option monad operators for cleaner code *)
4545+let ( let* ) = Option.bind
4646+4747+let ( let+ ) x f = Option.map f x
4848+4949+let content_type t =
5050+ let+ ct = Headers.find `Content_type t.headers in
5151+ Mime.of_string ct
5252+5353+let content_length t =
5454+ let* len = Headers.find `Content_length t.headers in
5555+ try Some (Int64.of_string len) with Failure _ -> None
5656+5757+let location t = Headers.find `Location t.headers
5858+5959+(** {1 Conditional Request / Caching Headers}
6060+6161+ Per Recommendation #19: Conditional Request Helpers (ETag/Last-Modified) RFC
6262+ 9110 Section 8.8.2-8.8.3 *)
6363+6464+let etag t = Headers.find `Etag t.headers
6565+let last_modified t = Headers.find `Last_modified t.headers
6666+let parse_http_date = Http_date.parse
6767+6868+let last_modified_ptime t =
6969+ let* lm = last_modified t in
7070+ Http_date.parse lm
7171+7272+let date t = Headers.find `Date t.headers
7373+7474+let date_ptime t =
7575+ let* d = date t in
7676+ Http_date.parse d
7777+7878+let expires t = Headers.find `Expires t.headers
7979+8080+let expires_ptime t =
8181+ let* exp = expires t in
8282+ Http_date.parse exp
8383+8484+let age t =
8585+ let* s = Headers.find `Age t.headers in
8686+ try Some (int_of_string s) with Failure _ -> None
8787+8888+(** {1 Cache-Control Parsing}
8989+9090+ Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance *)
9191+9292+let cache_control t =
9393+ Option.map Cache_control.parse_response
9494+ (Headers.find `Cache_control t.headers)
9595+9696+let cache_control_raw t = Headers.find `Cache_control t.headers
9797+9898+(** Check if response is cacheable based on status and Cache-Control *)
9999+let is_cacheable t =
100100+ match cache_control t with
101101+ | Some cc -> Cache_control.is_cacheable ~response_cc:cc ~status:t.status
102102+ | None ->
103103+ (* No Cache-Control - use default cacheability based on status *)
104104+ List.mem t.status
105105+ [ 200; 203; 204; 206; 300; 301; 308; 404; 405; 410; 414; 501 ]
106106+107107+(** Calculate freshness lifetime in seconds *)
108108+let freshness_lifetime t =
109109+ match cache_control t with
110110+ | Some cc ->
111111+ Cache_control.freshness_lifetime ~response_cc:cc ?expires:(expires t)
112112+ ?date:(date t) ()
113113+ | None -> None
114114+115115+(** Check if response requires revalidation before use *)
116116+let must_revalidate t =
117117+ match cache_control t with
118118+ | Some cc -> Cache_control.must_revalidate ~response_cc:cc
119119+ | None -> false
120120+121121+(** Check if response is stale (current time exceeds freshness) Requires the
122122+ current time as a parameter *)
123123+let is_stale ~now t =
124124+ match (freshness_lifetime t, date_ptime t) with
125125+ | Some lifetime, Some response_date ->
126126+ let response_age =
127127+ match age t with
128128+ | Some a -> a
129129+ | None ->
130130+ (* Calculate age from Date header *)
131131+ let diff = Ptime.diff now response_date in
132132+ Ptime.Span.to_int_s diff |> Option.value ~default:0
133133+ in
134134+ response_age > lifetime
135135+ | _ -> false (* Cannot determine staleness without freshness info *)
136136+137137+(** Check if this is a 304 Not Modified response *)
138138+let is_not_modified t = t.status = 304
139139+140140+(** Get the Vary header which indicates which request headers affect caching *)
141141+let vary t = Headers.find `Vary t.headers
142142+143143+(** Parse Vary header into list of header names *)
144144+let vary_headers t =
145145+ match vary t with
146146+ | None -> []
147147+ | Some v ->
148148+ String.split_on_char ',' v |> List.map String.trim
149149+ |> List.filter (fun s -> s <> "")
150150+151151+let url t = t.url
152152+let elapsed t = t.elapsed
153153+154154+let body t =
155155+ if t.closed then invalid_arg "Response.body: response has been closed"
156156+ else t.body
157157+158158+let text t =
159159+ if t.closed then invalid_arg "Response.text: response has been closed"
160160+ else Eio.Buf_read.of_flow t.body ~max_size:max_int |> Eio.Buf_read.take_all
161161+162162+let json t =
163163+ let body_str = text t in
164164+ match Jsont_bytesrw.decode_string' Jsont.json body_str with
165165+ | Ok json -> json
166166+ | Error e ->
167167+ let preview =
168168+ if String.length body_str > 200 then String.sub body_str 0 200
169169+ else body_str
170170+ in
171171+ raise
172172+ (Error.err
173173+ (Error.Json_parse_error
174174+ { body_preview = preview; reason = Jsont.Error.to_string e }))
175175+176176+let jsonv (type a) (codec : a Jsont.t) t =
177177+ let body_str = text t in
178178+ match Jsont_bytesrw.decode_string' codec body_str with
179179+ | Ok value -> value
180180+ | Error e ->
181181+ let preview =
182182+ if String.length body_str > 200 then String.sub body_str 0 200
183183+ else body_str
184184+ in
185185+ raise
186186+ (Error.err
187187+ (Error.Json_parse_error
188188+ { body_preview = preview; reason = Jsont.Error.to_string e }))
189189+190190+let raise_for_status t =
191191+ if t.status >= 400 then
192192+ raise
193193+ (Error.err
194194+ (Error.Http_error
195195+ {
196196+ url = t.url;
197197+ status = t.status;
198198+ reason = Status.reason_phrase (Status.of_int t.status);
199199+ body_preview = None;
200200+ headers = Headers.to_list t.headers;
201201+ (* Convert to list for error type *)
202202+ }))
203203+ else t
204204+205205+(** Result-based status check - per Recommendation #21. Returns Ok response for
206206+ 2xx success, Error for 4xx/5xx errors. Enables functional error handling
207207+ without exceptions. *)
208208+let check_status t =
209209+ if t.status >= 400 then
210210+ Error
211211+ (Error.Http_error
212212+ {
213213+ url = t.url;
214214+ status = t.status;
215215+ reason = Status.reason_phrase (Status.of_int t.status);
216216+ body_preview = None;
217217+ headers = Headers.to_list t.headers;
218218+ })
219219+ else Ok t
220220+221221+(* Pretty printers *)
222222+let pp ppf t =
223223+ Fmt.pf ppf
224224+ "@[<v>Response:@,status: %a@,url: %s@,elapsed: %.3fs@,headers: @[%a@]@]"
225225+ Status.pp (Status.of_int t.status) t.url t.elapsed Headers.pp_brief
226226+ t.headers
227227+228228+let pp_detailed ppf t =
229229+ Fmt.pf ppf "@[<v>Response:@,status: %a@,url: %s@,elapsed: %.3fs@,@[%a@]@]"
230230+ Status.pp_hum (Status.of_int t.status) t.url t.elapsed Headers.pp t.headers
231231+232232+(* Private module *)
233233+module Private = struct
234234+ let make = v
235235+end
+325
lib/response.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP response handling per
77+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-15}RFC 9110}
88+99+ This module represents HTTP responses and provides functions to access
1010+ status codes, headers, and response bodies. Responses support streaming to
1111+ efficiently handle large payloads.
1212+1313+ Caching semantics follow
1414+ {{:https://datatracker.ietf.org/doc/html/rfc9111}RFC 9111} (HTTP Caching).
1515+1616+ {2 Examples}
1717+1818+ {[
1919+ (* Check response status *)
2020+ if Response.ok response then
2121+ Printf.printf "Success!\n"
2222+ else
2323+ Printf.printf "Error: %d\n" (Response.status_code response);
2424+2525+ (* Access headers *)
2626+ match Response.content_type response with
2727+ | Some mime -> Printf.printf "Type: %s\n" (Mime.to_string mime)
2828+ | None -> ()
2929+3030+ (* Stream response body *)
3131+ let body = Response.body response in
3232+ Eio.Flow.copy body (Eio.Flow.buffer_sink buffer)
3333+3434+ (* Response automatically closes when the switch is released *)
3535+ ]}
3636+3737+ {b Note}: Responses are automatically closed when the switch they were
3838+ created with is released. Manual cleanup is not necessary. *)
3939+4040+open Eio
4141+4242+val src : Logs.Src.t
4343+(** Log source for response operations. *)
4444+4545+type t
4646+(** Abstract response type representing an HTTP response. *)
4747+4848+val v :
4949+ sw:Eio.Switch.t ->
5050+ status:int ->
5151+ headers:Headers.t ->
5252+ body:Eio.Flow.source_ty Eio.Resource.t ->
5353+ url:string ->
5454+ elapsed:float ->
5555+ t
5656+(** [v ~sw ~status ~headers ~body ~url ~elapsed] creates a response. Internal
5757+ function primarily used for caching. *)
5858+5959+(** {1 Status Information} *)
6060+6161+val status : t -> Status.t
6262+(** [status response] returns the HTTP status as a {!Status.t} value. *)
6363+6464+val status_code : t -> int
6565+(** [status_code response] returns the HTTP status code as an integer (e.g.,
6666+ 200, 404). *)
6767+6868+val ok : t -> bool
6969+(** [ok response] returns [true] if the status code is in the 2xx success range.
7070+ This is an alias for {!Status.is_success}. *)
7171+7272+(** {1 Header Access} *)
7373+7474+val headers : t -> Headers.t
7575+(** [headers response] returns all response headers. *)
7676+7777+val header : Header_name.t -> t -> string option
7878+(** [header name response] returns the value of a specific header, or [None] if
7979+ not present. Header names are case-insensitive.
8080+8181+ Example: [header `Content_type response]. *)
8282+8383+val header_string : string -> t -> string option
8484+(** [header_string name response] returns the value of a header by string name.
8585+ Use this when header names come from external sources (e.g., wire format).
8686+ Header names are case-insensitive. *)
8787+8888+val content_type : t -> Mime.t option
8989+(** [content_type response] returns the parsed Content-Type header as a MIME
9090+ type, or [None] if the header is not present or cannot be parsed. *)
9191+9292+val content_length : t -> int64 option
9393+(** [content_length response] returns the Content-Length in bytes, or [None] if
9494+ not specified or chunked encoding is used. *)
9595+9696+val location : t -> string option
9797+(** [location response] returns the Location header value, typically used in
9898+ redirects. Returns [None] if the header is not present. *)
9999+100100+(** {1 Conditional Request / Caching Headers}
101101+102102+ Per Recommendation #19: Conditional Request Helpers (ETag/Last-Modified) RFC
103103+ 9110 Section 8.8.2-8.8.3 *)
104104+105105+val etag : t -> string option
106106+(** [etag response] returns the ETag header value, which is an opaque identifier
107107+ for a specific version of a resource. Use with {!Headers.if_none_match} for
108108+ conditional requests. Example: ["\"abc123\""] or [W/"abc123"] (weak
109109+ validator). *)
110110+111111+val last_modified : t -> string option
112112+(** [last_modified response] returns the Last-Modified header as a raw string.
113113+ Format: HTTP-date (e.g., ["Sun, 06 Nov 1994 08:49:37 GMT"]). *)
114114+115115+val parse_http_date : string -> Ptime.t option
116116+(** [parse_http_date s] parses an HTTP-date string (RFC 9110 Section 5.6.7) to
117117+ Ptime.t. Supports RFC 1123, RFC 850, and ANSI C asctime() formats. Returns
118118+ [None] if parsing fails.
119119+120120+ This is exposed for use by other modules that need to parse HTTP dates. *)
121121+122122+val last_modified_ptime : t -> Ptime.t option
123123+(** [last_modified_ptime response] parses the Last-Modified header as a Ptime.t.
124124+ Returns [None] if the header is not present or cannot be parsed. *)
125125+126126+val date : t -> string option
127127+(** [date response] returns the Date header (time response was generated). *)
128128+129129+val date_ptime : t -> Ptime.t option
130130+(** [date_ptime response] parses the Date header as a Ptime.t. *)
131131+132132+val expires : t -> string option
133133+(** [expires response] returns the Expires header (HTTP/1.0 cache control).
134134+ Prefer using {!cache_control} for RFC 9111 compliant caching. *)
135135+136136+val expires_ptime : t -> Ptime.t option
137137+(** [expires_ptime response] parses the Expires header as a Ptime.t. *)
138138+139139+val age : t -> int option
140140+(** [age response] returns the Age header value in seconds. The Age header
141141+ indicates how long the response has been in a cache. *)
142142+143143+(** {1 Cache-Control Parsing}
144144+145145+ Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance *)
146146+147147+val cache_control : t -> Cache_control.response option
148148+(** [cache_control response] parses and returns the Cache-Control header
149149+ directives. Returns [None] if the header is not present.
150150+151151+ Example:
152152+ {[
153153+ match Response.cache_control response with
154154+ | Some cc when cc.Cache_control.no_store -> "Do not cache"
155155+ | Some cc -> Fmt.str "Max age: %d" (Option.get cc.max_age)
156156+ | None -> "No cache directives"
157157+ ]} *)
158158+159159+val cache_control_raw : t -> string option
160160+(** [cache_control_raw response] returns the raw Cache-Control header string
161161+ without parsing. Useful for debugging or custom parsing. *)
162162+163163+val is_cacheable : t -> bool
164164+(** [is_cacheable response] returns [true] if the response may be cached based
165165+ on its status code and Cache-Control directives. A response is cacheable if
166166+ no-store is not present and either:
167167+ - Status is cacheable by default (200, 203, 204, 206, 300, 301, 308, 404,
168168+ 405, 410, 414, 501)
169169+ - Explicit caching directive (max-age, s-maxage) is present. *)
170170+171171+val freshness_lifetime : t -> int option
172172+(** [freshness_lifetime response] calculates how long the response is fresh in
173173+ seconds, based on Cache-Control max-age or Expires header. Returns [None] if
174174+ freshness cannot be determined. *)
175175+176176+val must_revalidate : t -> bool
177177+(** [must_revalidate response] returns [true] if cached copies must be
178178+ revalidated with the origin server before use (must-revalidate,
179179+ proxy-revalidate, or no-cache directive present). *)
180180+181181+val is_stale : now:Ptime.t -> t -> bool
182182+(** [is_stale ~now response] returns [true] if the response's freshness lifetime
183183+ has expired. Requires the current time as [now]. Returns [false] if
184184+ staleness cannot be determined. *)
185185+186186+val is_not_modified : t -> bool
187187+(** [is_not_modified response] returns [true] if this is a 304 Not Modified
188188+ response, indicating the cached version is still valid. *)
189189+190190+val vary : t -> string option
191191+(** [vary response] returns the Vary header, which lists request headers that
192192+ affect the response (for cache key construction). *)
193193+194194+val vary_headers : t -> string list
195195+(** [vary_headers response] parses the Vary header into a list of header names.
196196+ Returns an empty list if Vary is not present. *)
197197+198198+(** {1 Response Metadata} *)
199199+200200+val url : t -> string
201201+(** [url response] returns the final URL after following any redirects. This may
202202+ differ from the originally requested URL. *)
203203+204204+val elapsed : t -> float
205205+(** [elapsed response] returns the time taken for the request in seconds,
206206+ including connection establishment, sending the request, and receiving
207207+ headers. *)
208208+209209+(** {1 Response Body} *)
210210+211211+val body : t -> Flow.source_ty Resource.t
212212+(** [body response] returns the response body as an Eio flow for streaming. This
213213+ allows efficient processing of large responses without loading them entirely
214214+ into memory.
215215+216216+ Example:
217217+ {[
218218+ let body = Response.body response in
219219+ let buffer = Buffer.create 4096 in
220220+ Eio.Flow.copy body (Eio.Flow.buffer_sink buffer);
221221+ Buffer.contents buffer
222222+ ]} *)
223223+224224+val text : t -> string
225225+(** [text response] reads and returns the entire response body as a string. The
226226+ response body is fully consumed by this operation.
227227+228228+ @raise Failure if the response has already been closed. *)
229229+230230+val json : t -> Jsont.json
231231+(** [json response] parses the response body as JSON. The response body is fully
232232+ consumed by this operation.
233233+234234+ Example:
235235+ {[
236236+ let json = Response.json response in
237237+ process_json json
238238+ ]}
239239+240240+ @raise Eio.Io with {!Error.Json_parse_error} if JSON parsing fails.
241241+ @raise Failure if the response has already been closed. *)
242242+243243+val jsonv : 'a Jsont.t -> t -> 'a
244244+(** [jsonv codec response] parses the response body as JSON and decodes it to a
245245+ typed value using the provided [codec]. The response body is fully consumed
246246+ by this operation.
247247+248248+ This is the preferred way to decode JSON responses into typed OCaml values,
249249+ as it provides type safety and works with custom record types.
250250+251251+ Example:
252252+ {[
253253+ (* Define a codec for your type *)
254254+ type user = { name : string; age : int }
255255+256256+ let user_codec =
257257+ Jsont.Obj.map ~kind:"user" (fun name age -> { name; age })
258258+ |> Jsont.Obj.mem "name" Jsont.string ~enc:(fun u -> u.name)
259259+ |> Jsont.Obj.mem "age" Jsont.int ~enc:(fun u -> u.age)
260260+ |> Jsont.Obj.finish
261261+262262+ (* Decode the response to a typed value *)
263263+ let user = Response.jsonv user_codec response in
264264+ Printf.printf "User: %s, age %d\n" user.name user.age
265265+ ]}
266266+267267+ @raise Eio.Io with {!Error.Json_parse_error} if JSON parsing fails.
268268+ @raise Failure if the response has already been closed. *)
269269+270270+val raise_for_status : t -> t
271271+(** [raise_for_status response] raises [Eio.Io] with [Error.Http_error] if the
272272+ response status code indicates an error (>= 400). Returns the response
273273+ unchanged if the status indicates success (< 400).
274274+275275+ This is useful for failing fast on HTTP errors:
276276+ {[
277277+ let response = Requests.get req url |> Response.raise_for_status in
278278+ (* Only reaches here if status < 400 *)
279279+ process_success response
280280+ ]}
281281+282282+ @raise Eio.Io with [Error.Http_error] if status code >= 400. *)
283283+284284+val check_status : t -> (t, Error.t) result
285285+(** [check_status response] returns [Ok response] if the status code is < 400,
286286+ or [Error error] if the status code indicates an error (>= 400).
287287+288288+ This provides functional error handling without exceptions, complementing
289289+ {!raise_for_status} for different coding styles.
290290+291291+ Example:
292292+ {[
293293+ match Response.check_status response with
294294+ | Ok resp -> process_success resp
295295+ | Error err -> handle_error err
296296+ ]}
297297+298298+ Per Recommendation #21: Provides a Result-based alternative to
299299+ raise_for_status. *)
300300+301301+(** {1 Pretty Printing} *)
302302+303303+val pp : Format.formatter -> t -> unit
304304+(** Pretty print a response summary. *)
305305+306306+val pp_detailed : Format.formatter -> t -> unit
307307+(** Pretty print a response with full headers. *)
308308+309309+(** {1 Private API} *)
310310+311311+(** Internal functions exposed for use by other modules in the library. These
312312+ are not part of the public API and may change between versions. *)
313313+module Private : sig
314314+ val make :
315315+ sw:Eio.Switch.t ->
316316+ status:int ->
317317+ headers:Headers.t ->
318318+ body:Flow.source_ty Resource.t ->
319319+ url:string ->
320320+ elapsed:float ->
321321+ t
322322+ (** [make ~sw ~status ~headers ~body ~url ~elapsed] constructs a response. The
323323+ response will be automatically closed when the switch is released. This
324324+ function is used internally by the Client module. *)
325325+end
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Response limits for HTTP protocol handling
77+88+ Configurable limits for response body size, header count, and header length
99+ to prevent DoS attacks. *)
1010+1111+type t
1212+(** Abstract type representing HTTP response limits. *)
1313+1414+val default : t
1515+(** Default limits:
1616+ - max_response_body_size: 100MB
1717+ - max_header_size: 16KB
1818+ - max_header_count: 100
1919+ - max_decompressed_size: 100MB
2020+ - max_compression_ratio: 100:1. *)
2121+2222+val v :
2323+ ?max_response_body_size:int64 ->
2424+ ?max_header_size:int ->
2525+ ?max_header_count:int ->
2626+ ?max_decompressed_size:int64 ->
2727+ ?max_compression_ratio:float ->
2828+ unit ->
2929+ t
3030+(** [v ?max_response_body_size ?max_header_size ?max_header_count
3131+ ?max_decompressed_size ?max_compression_ratio ()] creates custom response
3232+ limits. All parameters are optional and default to the values in {!default}.
3333+*)
3434+3535+val max_response_body_size : t -> int64
3636+(** Maximum response body size in bytes. *)
3737+3838+val max_header_size : t -> int
3939+(** Maximum size of a single header line in bytes. *)
4040+4141+val max_header_count : t -> int
4242+(** Maximum number of headers allowed. *)
4343+4444+val max_decompressed_size : t -> int64
4545+(** Maximum decompressed size in bytes. *)
4646+4747+val max_compression_ratio : t -> float
4848+(** Maximum compression ratio allowed (e.g., 100.0 means 100:1). *)
4949+5050+val pp : Format.formatter -> t -> unit
5151+(** Pretty-printer for response limits. *)
5252+5353+val to_string : t -> string
5454+(** Convert response limits to a human-readable string. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP status codes per
77+ {{:https://datatracker.ietf.org/doc/html/rfc9110#section-15}RFC 9110 Section
88+ 15}
99+1010+ This module provides types and functions for working with HTTP response
1111+ status codes. Status codes are three-digit integers that indicate the result
1212+ of an HTTP request.
1313+1414+ {2 Status Code Classes}
1515+1616+ - {b 1xx Informational}: Request received, continuing process
1717+ - {b 2xx Success}: Request successfully received, understood, and accepted
1818+ - {b 3xx Redirection}: Further action needed to complete the request
1919+ - {b 4xx Client Error}: Request contains bad syntax or cannot be fulfilled
2020+ - {b 5xx Server Error}: Server failed to fulfill a valid request *)
2121+2222+val src : Logs.Src.t
2323+(** Log source for status code operations. *)
2424+2525+(** {1 Status Categories} *)
2626+2727+type informational =
2828+ [ `Continue (** 100 - Client should continue with request *)
2929+ | `Switching_protocols (** 101 - Server is switching protocols *)
3030+ | `Processing (** 102 - Server has received and is processing the request *)
3131+ | `Early_hints
3232+ (** 103 - Used to return some response headers before final HTTP message *)
3333+ ]
3434+(** 1xx Informational responses *)
3535+3636+type success =
3737+ [ `OK (** 200 - Standard response for successful HTTP requests *)
3838+ | `Created (** 201 - Request has been fulfilled; new resource created *)
3939+ | `Accepted (** 202 - Request accepted, processing pending *)
4040+ | `Non_authoritative_information
4141+ (** 203 - Request processed, information may be from another source *)
4242+ | `No_content (** 204 - Request processed, no content returned *)
4343+ | `Reset_content
4444+ (** 205 - Request processed, no content returned, reset document view *)
4545+ | `Partial_content (** 206 - Partial resource return due to request header *)
4646+ | `Multi_status (** 207 - XML, can contain multiple separate responses *)
4747+ | `Already_reported (** 208 - Results previously returned *)
4848+ | `Im_used (** 226 - Request fulfilled, response is instance-manipulations *)
4949+ ]
5050+(** 2xx Success responses *)
5151+5252+type redirection =
5353+ [ `Multiple_choices (** 300 - Multiple options for the resource delivered *)
5454+ | `Moved_permanently
5555+ (** 301 - This and all future requests directed to the given URI *)
5656+ | `Found (** 302 - Temporary response to request found via alternative URI *)
5757+ | `See_other (** 303 - Response to request found via alternative URI *)
5858+ | `Not_modified
5959+ (** 304 - Resource has not been modified since last requested *)
6060+ | `Use_proxy
6161+ (** 305 - Content located elsewhere, retrieve from there (deprecated) *)
6262+ | `Temporary_redirect (** 307 - Connect again to different URI as provided *)
6363+ | `Permanent_redirect
6464+ (** 308 - Connect again to a different URI using the same method *) ]
6565+(** 3xx Redirection messages *)
6666+6767+type client_error =
6868+ [ `Bad_request (** 400 - Request cannot be fulfilled due to bad syntax *)
6969+ | `Unauthorized (** 401 - Authentication is possible but has failed *)
7070+ | `Payment_required (** 402 - Payment required, reserved for future use *)
7171+ | `Forbidden (** 403 - Server refuses to respond to request *)
7272+ | `Not_found (** 404 - Requested resource could not be found *)
7373+ | `Method_not_allowed
7474+ (** 405 - Request method not supported by that resource *)
7575+ | `Not_acceptable
7676+ (** 406 - Content not acceptable according to the Accept headers *)
7777+ | `Proxy_authentication_required
7878+ (** 407 - Client must first authenticate itself with the proxy *)
7979+ | `Request_timeout (** 408 - Server timed out waiting for the request *)
8080+ | `Conflict (** 409 - Request could not be processed because of conflict *)
8181+ | `Gone
8282+ (** 410 - Resource is no longer available and will not be available again *)
8383+ | `Length_required
8484+ (** 411 - Request did not specify the length of its content *)
8585+ | `Precondition_failed
8686+ (** 412 - Server does not meet request preconditions *)
8787+ | `Payload_too_large
8888+ (** 413 - Request is larger than the server is willing or able to process *)
8989+ | `Uri_too_long
9090+ (** 414 - URI provided was too long for the server to process *)
9191+ | `Unsupported_media_type (** 415 - Server does not support media type *)
9292+ | `Range_not_satisfiable
9393+ (** 416 - Client has asked for unprovidable portion of the file *)
9494+ | `Expectation_failed
9595+ (** 417 - Server cannot meet requirements of Expect request-header field *)
9696+ | `I_m_a_teapot (** 418 - I'm a teapot (RFC 2324) *)
9797+ | `Misdirected_request
9898+ (** 421 - Request was directed at a server that is not able to produce a
9999+ response *)
100100+ | `Unprocessable_entity
101101+ (** 422 - Request unable to be followed due to semantic errors *)
102102+ | `Locked (** 423 - Resource that is being accessed is locked *)
103103+ | `Failed_dependency
104104+ (** 424 - Request failed due to failure of a previous request *)
105105+ | `Too_early
106106+ (** 425 - Server is unwilling to risk processing a request that might be
107107+ replayed *)
108108+ | `Upgrade_required (** 426 - Client should switch to a different protocol *)
109109+ | `Precondition_required
110110+ (** 428 - Origin server requires the request to be conditional *)
111111+ | `Too_many_requests
112112+ (** 429 - User has sent too many requests in a given amount of time *)
113113+ | `Request_header_fields_too_large
114114+ (** 431 - Server is unwilling to process the request *)
115115+ | `Unavailable_for_legal_reasons
116116+ (** 451 - Resource unavailable for legal reasons *) ]
117117+(** 4xx Client error responses *)
118118+119119+type server_error =
120120+ [ `Internal_server_error (** 500 - Generic error message *)
121121+ | `Not_implemented
122122+ (** 501 - Server does not recognise method or lacks ability to fulfill *)
123123+ | `Bad_gateway
124124+ (** 502 - Server received an invalid response from upstream server *)
125125+ | `Service_unavailable (** 503 - Server is currently unavailable *)
126126+ | `Gateway_timeout
127127+ (** 504 - Gateway did not receive response from upstream server *)
128128+ | `Http_version_not_supported
129129+ (** 505 - Server does not support the HTTP protocol version *)
130130+ | `Variant_also_negotiates
131131+ (** 506 - Content negotiation for the request results in a circular
132132+ reference *)
133133+ | `Insufficient_storage
134134+ (** 507 - Server is unable to store the representation *)
135135+ | `Loop_detected
136136+ (** 508 - Server detected an infinite loop while processing the request *)
137137+ | `Not_extended (** 510 - Further extensions to the request are required *)
138138+ | `Network_authentication_required
139139+ (** 511 - Client needs to authenticate to gain network access *) ]
140140+(** 5xx Server error responses *)
141141+142142+type standard =
143143+ [ informational | success | redirection | client_error | server_error ]
144144+(** All standard HTTP status codes *)
145145+146146+type t = [ `Code of int (** Any status code as an integer *) | standard ]
147147+(** HTTP status type *)
148148+149149+(** {1 Conversion Functions} *)
150150+151151+val to_int : t -> int
152152+(** Convert status to its integer code. *)
153153+154154+val of_int : int -> t
155155+(** Convert an integer to a status. *)
156156+157157+val to_string : t -> string
158158+(** Get the string representation of a status code (e.g., "200", "404"). *)
159159+160160+val reason_phrase : t -> string
161161+(** Get the standard reason phrase for a status code (e.g., "OK", "Not Found").
162162+*)
163163+164164+(** {1 Classification Functions} *)
165165+166166+val is_informational : t -> bool
167167+(** Check if status code is informational (1xx). *)
168168+169169+val is_success : t -> bool
170170+(** Check if status code indicates success (2xx). *)
171171+172172+val is_redirection : t -> bool
173173+(** Check if status code indicates redirection (3xx). *)
174174+175175+val is_client_error : t -> bool
176176+(** Check if status code indicates client error (4xx). *)
177177+178178+val is_server_error : t -> bool
179179+(** Check if status code indicates server error (5xx). *)
180180+181181+val is_error : t -> bool
182182+(** Check if status code indicates any error (4xx or 5xx). *)
183183+184184+(** {1 Retry Policy} *)
185185+186186+val is_retryable : t -> bool
187187+(** Check if a status code suggests the request could be retried. Returns true
188188+ for:
189189+ - 408 Request Timeout
190190+ - 429 Too Many Requests
191191+ - 502 Bad Gateway
192192+ - 503 Service Unavailable
193193+ - 504 Gateway Timeout
194194+ - Any 5xx errors. *)
195195+196196+val should_retry_on_different_host : t -> bool
197197+(** Check if a status code suggests retrying on a different host might help.
198198+ Returns true for:
199199+ - 502 Bad Gateway
200200+ - 503 Service Unavailable
201201+ - 504 Gateway Timeout. *)
202202+203203+(** {1 Pretty Printing} *)
204204+205205+val pp : Format.formatter -> t -> unit
206206+(** Pretty printer for status codes. *)
207207+208208+val pp_hum : Format.formatter -> t -> unit
209209+(** Human-readable pretty printer that includes both code and reason phrase. *)