forked from
anil.recoil.org/ocaml-requests
A batteries included HTTP/1.1 client in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** HTTP Cache-Control header parsing per RFC 9111 (HTTP Caching)
7
8 This module provides parsing and representation of Cache-Control directives
9 for both requests and responses. It supports all standard directives from
10 RFC 9111 Section 5.2.
11
12 Per Recommendation #17: Response Caching with RFC 7234/9111 Compliance *)
13
14let src = Logs.Src.create "requests.cache_control" ~doc:"HTTP Cache-Control"
15module Log = (val Logs.src_log src : Logs.LOG)
16
17(** {1 Response Cache-Control Directives}
18
19 RFC 9111 Section 5.2.2: Cache-Control Response Directives *)
20
21type response_directive =
22 | Max_age of int (** max-age=N - response is fresh for N seconds *)
23 | S_maxage of int (** s-maxage=N - shared cache max-age *)
24 | No_cache of string list (** no-cache[=headers] - must revalidate *)
25 | No_store (** no-store - must not be stored *)
26 | No_transform (** no-transform - must not be transformed *)
27 | Must_revalidate (** must-revalidate - stale must be revalidated *)
28 | Proxy_revalidate (** proxy-revalidate - shared caches must revalidate *)
29 | Must_understand (** must-understand - RFC 9111 *)
30 | Private of string list (** private[=headers] - only private cache *)
31 | Public (** public - can be stored by any cache *)
32 | Immutable (** immutable - will not change during freshness *)
33 | Stale_while_revalidate of int (** stale-while-revalidate=N *)
34 | Stale_if_error of int (** stale-if-error=N *)
35 | Response_extension of string * string option (** Unknown directive *)
36
37(** {1 Request Cache-Control Directives}
38
39 RFC 9111 Section 5.2.1: Cache-Control Request Directives *)
40
41type request_directive =
42 | Req_max_age of int (** max-age=N *)
43 | Req_max_stale of int option (** max-stale[=N] *)
44 | Req_min_fresh of int (** min-fresh=N *)
45 | Req_no_cache (** no-cache *)
46 | Req_no_store (** no-store *)
47 | Req_no_transform (** no-transform *)
48 | Req_only_if_cached (** only-if-cached *)
49 | Request_extension of string * string option (** Unknown directive *)
50
51(** Parsed response Cache-Control header *)
52type response = {
53 max_age : int option;
54 s_maxage : int option;
55 no_cache : string list option; (** None = not present, Some [] = present without headers *)
56 no_store : bool;
57 no_transform : bool;
58 must_revalidate : bool;
59 proxy_revalidate : bool;
60 must_understand : bool;
61 private_ : string list option; (** None = not present, Some [] = present without headers *)
62 public : bool;
63 immutable : bool;
64 stale_while_revalidate : int option;
65 stale_if_error : int option;
66 extensions : (string * string option) list;
67}
68
69(** Parsed request Cache-Control header *)
70type request = {
71 req_max_age : int option;
72 req_max_stale : int option option; (** None = not present, Some None = present without value *)
73 req_min_fresh : int option;
74 req_no_cache : bool;
75 req_no_store : bool;
76 req_no_transform : bool;
77 req_only_if_cached : bool;
78 req_extensions : (string * string option) list;
79}
80
81(** {1 Parsing Functions} *)
82
83let empty_response = {
84 max_age = None;
85 s_maxage = None;
86 no_cache = None;
87 no_store = false;
88 no_transform = false;
89 must_revalidate = false;
90 proxy_revalidate = false;
91 must_understand = false;
92 private_ = None;
93 public = false;
94 immutable = false;
95 stale_while_revalidate = None;
96 stale_if_error = None;
97 extensions = [];
98}
99
100let empty_request = {
101 req_max_age = None;
102 req_max_stale = None;
103 req_min_fresh = None;
104 req_no_cache = false;
105 req_no_store = false;
106 req_no_transform = false;
107 req_only_if_cached = false;
108 req_extensions = [];
109}
110
111(** Parse a single token (alphanumeric + some punctuation) *)
112let parse_token s start =
113 let len = String.length s in
114 let rec find_end i =
115 if i >= len then i
116 else match s.[i] with
117 | 'a'..'z' | 'A'..'Z' | '0'..'9' | '-' | '_' | '.' | '!' | '#' | '$'
118 | '%' | '&' | '\'' | '*' | '+' | '^' | '`' | '|' | '~' -> find_end (i + 1)
119 | _ -> i
120 in
121 let end_pos = find_end start in
122 if end_pos = start then None
123 else Some (String.sub s start (end_pos - start), end_pos)
124
125(** Parse a quoted string starting at position (after opening quote) *)
126let parse_quoted_string s start =
127 let buf = Buffer.create 32 in
128 let len = String.length s in
129 let rec loop i =
130 if i >= len then None (* Unterminated quote *)
131 else match s.[i] with
132 | '"' -> Some (Buffer.contents buf, i + 1)
133 | '\\' when i + 1 < len ->
134 Buffer.add_char buf s.[i + 1];
135 loop (i + 2)
136 | c ->
137 Buffer.add_char buf c;
138 loop (i + 1)
139 in
140 loop start
141
142(** Parse a directive value (token or quoted-string) *)
143let parse_value s start =
144 let len = String.length s in
145 if start >= len then None
146 else if s.[start] = '"' then
147 parse_quoted_string s (start + 1)
148 else
149 parse_token s start
150
151(** Parse comma-separated header list (for no-cache=, private=) *)
152let parse_header_list s =
153 (* Handle quoted list like "Accept, Accept-Encoding" *)
154 let s = String.trim s in
155 let s = if String.length s >= 2 && s.[0] = '"' && s.[String.length s - 1] = '"'
156 then String.sub s 1 (String.length s - 2)
157 else s
158 in
159 String.split_on_char ',' s
160 |> List.map String.trim
161 |> List.filter (fun s -> s <> "")
162
163(** Skip whitespace and optional comma *)
164let skip_ws_comma s start =
165 let len = String.length s in
166 let rec loop i =
167 if i >= len then i
168 else match s.[i] with
169 | ' ' | '\t' | ',' -> loop (i + 1)
170 | _ -> i
171 in
172 loop start
173
174(** Parse all directives from a Cache-Control header value *)
175let parse_directives s =
176 let s = String.trim s in
177 let len = String.length s in
178 let rec loop i acc =
179 if i >= len then List.rev acc
180 else
181 let i = skip_ws_comma s i in
182 if i >= len then List.rev acc
183 else match parse_token s i with
184 | None -> List.rev acc (* Invalid, stop parsing *)
185 | Some (name, next_pos) ->
186 let name_lower = String.lowercase_ascii name in
187 (* Check for =value *)
188 let next_pos = skip_ws_comma s next_pos in
189 if next_pos < len && s.[next_pos] = '=' then
190 let value_start = skip_ws_comma s (next_pos + 1) in
191 match parse_value s value_start with
192 | Some (value, end_pos) ->
193 loop (skip_ws_comma s end_pos) ((name_lower, Some value) :: acc)
194 | None ->
195 loop (skip_ws_comma s (next_pos + 1)) ((name_lower, None) :: acc)
196 else
197 loop next_pos ((name_lower, None) :: acc)
198 in
199 loop 0 []
200
201(** Parse response Cache-Control header *)
202let parse_response header_value =
203 let directives = parse_directives header_value in
204 Log.debug (fun m -> m "Parsing response Cache-Control: %s" header_value);
205 List.fold_left (fun acc (name, value) ->
206 match name, value with
207 | "max-age", Some v ->
208 (try { acc with max_age = Some (int_of_string v) }
209 with _ -> acc)
210 | "s-maxage", Some v ->
211 (try { acc with s_maxage = Some (int_of_string v) }
212 with _ -> acc)
213 | "no-cache", None ->
214 { acc with no_cache = Some [] }
215 | "no-cache", Some v ->
216 { acc with no_cache = Some (parse_header_list v) }
217 | "no-store", _ ->
218 { acc with no_store = true }
219 | "no-transform", _ ->
220 { acc with no_transform = true }
221 | "must-revalidate", _ ->
222 { acc with must_revalidate = true }
223 | "proxy-revalidate", _ ->
224 { acc with proxy_revalidate = true }
225 | "must-understand", _ ->
226 { acc with must_understand = true }
227 | "private", None ->
228 { acc with private_ = Some [] }
229 | "private", Some v ->
230 { acc with private_ = Some (parse_header_list v) }
231 | "public", _ ->
232 { acc with public = true }
233 | "immutable", _ ->
234 { acc with immutable = true }
235 | "stale-while-revalidate", Some v ->
236 (try { acc with stale_while_revalidate = Some (int_of_string v) }
237 with _ -> acc)
238 | "stale-if-error", Some v ->
239 (try { acc with stale_if_error = Some (int_of_string v) }
240 with _ -> acc)
241 | other, v ->
242 { acc with extensions = (other, v) :: acc.extensions }
243 ) empty_response directives
244
245(** Parse request Cache-Control header *)
246let parse_request header_value =
247 let directives = parse_directives header_value in
248 Log.debug (fun m -> m "Parsing request Cache-Control: %s" header_value);
249 List.fold_left (fun acc (name, value) ->
250 match name, value with
251 | "max-age", Some v ->
252 (try { acc with req_max_age = Some (int_of_string v) }
253 with _ -> acc)
254 | "max-stale", None ->
255 { acc with req_max_stale = Some None }
256 | "max-stale", Some v ->
257 (try { acc with req_max_stale = Some (Some (int_of_string v)) }
258 with _ -> { acc with req_max_stale = Some None })
259 | "min-fresh", Some v ->
260 (try { acc with req_min_fresh = Some (int_of_string v) }
261 with _ -> acc)
262 | "no-cache", _ ->
263 { acc with req_no_cache = true }
264 | "no-store", _ ->
265 { acc with req_no_store = true }
266 | "no-transform", _ ->
267 { acc with req_no_transform = true }
268 | "only-if-cached", _ ->
269 { acc with req_only_if_cached = true }
270 | other, v ->
271 { acc with req_extensions = (other, v) :: acc.req_extensions }
272 ) empty_request directives
273
274(** {1 Freshness Calculation}
275
276 RFC 9111 Section 4.2: Freshness *)
277
278(** Calculate freshness lifetime from response directives and headers.
279 Returns freshness lifetime in seconds, or None if not cacheable. *)
280let freshness_lifetime ~response_cc ?expires ?date () =
281 (* RFC 9111 Section 4.2.1: Priority:
282 1. s-maxage (shared caches only, we skip this)
283 2. max-age
284 3. Expires - Date
285 4. Heuristic (we return None, let caller decide) *)
286 let ( let* ) = Option.bind in
287 match response_cc.max_age with
288 | Some age -> Some age
289 | None ->
290 match expires, date with
291 | Some exp_str, Some date_str ->
292 (* Use Http_date.parse to parse HTTP dates *)
293 let* exp_time = Http_date.parse exp_str in
294 let* date_time = Http_date.parse date_str in
295 let diff = Ptime.diff exp_time date_time in
296 Ptime.Span.to_int_s diff
297 | _ -> None
298
299(** Check if a response is cacheable based on Cache-Control directives *)
300let is_cacheable ~response_cc ~status =
301 (* RFC 9111 Section 3: A response is cacheable if:
302 - no-store is not present
303 - status is cacheable by default OR explicit caching directive present *)
304 if response_cc.no_store then false
305 else
306 (* Default cacheable statuses per RFC 9110 Section 15.1 *)
307 let default_cacheable = List.mem status [200; 203; 204; 206; 300; 301; 308; 404; 405; 410; 414; 501] in
308 default_cacheable || Option.is_some response_cc.max_age || Option.is_some response_cc.s_maxage
309
310(** Check if response requires revalidation before use *)
311let must_revalidate ~response_cc =
312 response_cc.must_revalidate || response_cc.proxy_revalidate ||
313 Option.is_some response_cc.no_cache
314
315(** Check if response can be stored in shared caches *)
316let is_public ~response_cc =
317 response_cc.public && not (Option.is_some response_cc.private_)
318
319(** Check if response can only be stored in private caches *)
320let is_private ~response_cc =
321 Option.is_some response_cc.private_
322
323(** {1 Pretty Printers} *)
324
325let pp_response ppf r =
326 let items = [] in
327 let items = match r.max_age with Some a -> Printf.sprintf "max-age=%d" a :: items | None -> items in
328 let items = match r.s_maxage with Some a -> Printf.sprintf "s-maxage=%d" a :: items | None -> items in
329 let items = match r.no_cache with
330 | Some [] -> "no-cache" :: items
331 | Some hs -> Printf.sprintf "no-cache=\"%s\"" (String.concat ", " hs) :: items
332 | None -> items
333 in
334 let items = if r.no_store then "no-store" :: items else items in
335 let items = if r.no_transform then "no-transform" :: items else items in
336 let items = if r.must_revalidate then "must-revalidate" :: items else items in
337 let items = if r.proxy_revalidate then "proxy-revalidate" :: items else items in
338 let items = if r.must_understand then "must-understand" :: items else items in
339 let items = match r.private_ with
340 | Some [] -> "private" :: items
341 | Some hs -> Printf.sprintf "private=\"%s\"" (String.concat ", " hs) :: items
342 | None -> items
343 in
344 let items = if r.public then "public" :: items else items in
345 let items = if r.immutable then "immutable" :: items else items in
346 let items = match r.stale_while_revalidate with
347 | Some s -> Printf.sprintf "stale-while-revalidate=%d" s :: items | None -> items
348 in
349 let items = match r.stale_if_error with
350 | Some s -> Printf.sprintf "stale-if-error=%d" s :: items | None -> items
351 in
352 Format.fprintf ppf "%s" (String.concat ", " (List.rev items))
353
354let pp_request ppf r =
355 let items = [] in
356 let items = match r.req_max_age with Some a -> Printf.sprintf "max-age=%d" a :: items | None -> items in
357 let items = match r.req_max_stale with
358 | Some None -> "max-stale" :: items
359 | Some (Some s) -> Printf.sprintf "max-stale=%d" s :: items
360 | None -> items
361 in
362 let items = match r.req_min_fresh with Some s -> Printf.sprintf "min-fresh=%d" s :: items | None -> items in
363 let items = if r.req_no_cache then "no-cache" :: items else items in
364 let items = if r.req_no_store then "no-store" :: items else items in
365 let items = if r.req_no_transform then "no-transform" :: items else items in
366 let items = if r.req_only_if_cached then "only-if-cached" :: items else items in
367 Format.fprintf ppf "%s" (String.concat ", " (List.rev items))