···11+open Eio
22+open Cmdliner
33+44+(* Command-line options *)
55+let http_method =
66+ let methods = [
77+ ("GET", `GET);
88+ ("POST", `POST);
99+ ("PUT", `PUT);
1010+ ("DELETE", `DELETE);
1111+ ("HEAD", `HEAD);
1212+ ("OPTIONS", `OPTIONS);
1313+ ("PATCH", `PATCH);
1414+ ] in
1515+ let doc = "HTTP method to use" in
1616+ let env_info = Cmdliner.Cmd.Env.info "OCURL_METHOD" in
1717+ Arg.(value & opt (enum methods) `GET & info ["X"; "request"] ~env:env_info ~docv:"METHOD" ~doc)
1818+1919+let urls =
2020+ let doc = "URL(s) to fetch" in
2121+ Arg.(non_empty & pos_all string [] & info [] ~docv:"URL" ~doc)
2222+2323+let headers =
2424+ let doc = "Add custom HTTP header (can be used multiple times)" in
2525+ Arg.(value & opt_all string [] & info ["H"; "header"] ~docv:"HEADER" ~doc)
2626+2727+let data =
2828+ let doc = "HTTP POST/PUT data" in
2929+ Arg.(value & opt (some string) None & info ["d"; "data"] ~docv:"DATA" ~doc)
3030+3131+let json_data =
3232+ let doc = "HTTP POST/PUT JSON data" in
3333+ Arg.(value & opt (some string) None & info ["json"] ~docv:"JSON" ~doc)
3434+3535+let output_file =
3636+ let doc = "Write output to file instead of stdout" in
3737+ Arg.(value & opt (some string) None & info ["o"; "output"] ~docv:"FILE" ~doc)
3838+3939+let include_headers =
4040+ let doc = "Include response headers in output" in
4141+ Arg.(value & flag & info ["i"; "include"] ~doc)
4242+4343+let head =
4444+ let doc = "Show only response headers (no body)" in
4545+ Arg.(value & flag & info ["I"; "head"] ~doc)
4646+4747+let auth =
4848+ let doc = "Basic authentication in USER:PASSWORD format" in
4949+ Arg.(value & opt (some string) None & info ["u"; "user"] ~docv:"USER:PASS" ~doc)
5050+5151+let show_progress =
5252+ let doc = "Show progress bar for downloads" in
5353+ Arg.(value & flag & info ["progress-bar"] ~doc)
5454+5555+(* Logging setup *)
5656+(* Setup logging using Logs_cli for standard logging options *)
5757+let setup_log app_name =
5858+ let setup style_renderer level verbose_http =
5959+ Fmt_tty.setup_std_outputs ?style_renderer ();
6060+ Logs.set_level level;
6161+ Logs.set_reporter (Logs_fmt.reporter ());
6262+ Requests.Cmd.setup_log_sources ~verbose_http level
6363+ in
6464+ Term.(const setup $ Fmt_cli.style_renderer () $ Logs_cli.level () $
6565+ Requests.Cmd.verbose_http_term app_name)
6666+6767+(* Parse authentication *)
6868+let parse_auth auth_str =
6969+ match String.split_on_char ':' auth_str with
7070+ | [user; pass] -> Some (user, pass)
7171+ | _ -> None
7272+7373+(* Parse headers *)
7474+let parse_header header_str =
7575+ match String.split_on_char ':' header_str with
7676+ | [] -> None
7777+ | [name] -> Some (String.trim name, "")
7878+ | name :: rest ->
7979+ Some (String.trim name, String.trim (String.concat ":" rest))
8080+8181+(* Pretty print response *)
8282+let pp_response ppf response =
8383+ let status = Requests.Response.status response in
8484+ let status_code = Requests.Response.status_code response in
8585+ let headers = Requests.Response.headers response in
8686+8787+ (* Color code status *)
8888+ let status_style =
8989+ if Requests.Status.is_success status then Fmt.(styled `Green)
9090+ else if Requests.Status.is_client_error status then Fmt.(styled `Yellow)
9191+ else if Requests.Status.is_server_error status then Fmt.(styled `Red)
9292+ else Fmt.(styled `Blue)
9393+ in
9494+9595+ (* Print status line *)
9696+ Fmt.pf ppf "@[<v>HTTP/1.1 %d %a@]@."
9797+ status_code
9898+ (status_style Fmt.string) (Requests.Status.reason_phrase status);
9999+100100+ (* Print headers *)
101101+ let header_list = Requests.Headers.to_list headers in
102102+ List.iter (fun (k, v) ->
103103+ Fmt.pf ppf "@[<h>%a: %s@]@."
104104+ Fmt.(styled `Cyan string) k v
105105+ ) header_list;
106106+107107+ Fmt.pf ppf "@."
108108+109109+(* Process a single URL and return result *)
110110+let process_url env req method_ headers body include_headers head output url_str =
111111+ let quiet = match Logs.level () with Some (Logs.Error | Logs.Warning) -> true | _ -> false in
112112+ let uri = Uri.of_string url_str in
113113+114114+ if not quiet then begin
115115+ let method_str = Requests.Method.to_string (method_ :> Requests.Method.t) in
116116+ Fmt.pr "@[<v>%a %a@]@."
117117+ Fmt.(styled `Bold string) method_str
118118+ Fmt.(styled `Underline Uri.pp) uri;
119119+ end;
120120+ try
121121+ (* Make request *)
122122+ let response =
123123+ match method_ with
124124+ | `GET -> Requests.get req ~headers url_str
125125+ | `POST -> Requests.post req ~headers ?body url_str
126126+ | `PUT -> Requests.put req ~headers ?body url_str
127127+ | `DELETE -> Requests.delete req ~headers url_str
128128+ | `HEAD -> Requests.head req ~headers url_str
129129+ | `OPTIONS -> Requests.options req ~headers url_str
130130+ | `PATCH -> Requests.patch req ~headers ?body url_str
131131+ in
132132+133133+ (* Print response headers if requested *)
134134+ if (include_headers || head) && not quiet then
135135+ pp_response Fmt.stdout response;
136136+137137+ (* If head flag is set, skip body processing *)
138138+ if head then
139139+ Ok (url_str, response)
140140+ else begin
141141+ (* Handle output *)
142142+ let body_flow = Requests.Response.body response in
143143+144144+ begin match output with
145145+ | Some file -> begin
146146+ let filename =
147147+ if List.length [url_str] > 1 then begin
148148+ let base = Filename.remove_extension file in
149149+ let ext = Filename.extension file in
150150+ let url_hash =
151151+ let full_hash = Digest.string url_str |> Digest.to_hex in
152152+ String.sub full_hash (String.length full_hash - 8) 8 in
153153+ Printf.sprintf "%s-%s%s" base url_hash ext
154154+ end else file
155155+ in
156156+ let () =
157157+ Eio.Path.with_open_out ~create:(`Or_truncate 0o644)
158158+ Eio.Path.(env#fs / filename) @@ fun sink ->
159159+ Eio.Flow.copy body_flow sink in
160160+ let () = if not quiet then
161161+ Fmt.pr "[%s] Saved to %s@." url_str filename else () in
162162+ Ok (url_str, response)
163163+ end
164164+ | None ->
165165+ (* Write to stdout *)
166166+ let buf = Buffer.create 1024 in
167167+ Eio.Flow.copy body_flow (Eio.Flow.buffer_sink buf);
168168+ let body_str = Buffer.contents buf in
169169+170170+ (* Pretty-print JSON if applicable *)
171171+ if String.length body_str > 0 &&
172172+ (body_str.[0] = '{' || body_str.[0] = '[') then
173173+ try
174174+ match Jsont_bytesrw.decode_string' Jsont.json body_str with
175175+ | Ok json ->
176176+ (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jsont.json json with
177177+ | Ok pretty ->
178178+ if not quiet then Fmt.pr "[%s]:@." url_str;
179179+ print_string pretty
180180+ | Error _ ->
181181+ if not quiet then Fmt.pr "[%s]:@." url_str;
182182+ print_string body_str)
183183+ | Error _ ->
184184+ if not quiet then Fmt.pr "[%s]:@." url_str;
185185+ print_string body_str
186186+ with _ ->
187187+ if not quiet then Fmt.pr "[%s]:@." url_str;
188188+ print_string body_str
189189+ else begin
190190+ if not quiet then Fmt.pr "[%s]:@." url_str;
191191+ print_string body_str
192192+ end;
193193+194194+ if not quiet && Requests.Response.ok response then
195195+ Logs.app (fun m -> m "✓ Success for %s" url_str);
196196+197197+ Ok (url_str, response)
198198+ end
199199+ end
200200+ with
201201+ | exn ->
202202+ if not quiet then
203203+ Logs.err (fun m -> m "Request failed for %s: %s" url_str (Printexc.to_string exn));
204204+ Error (url_str, exn)
205205+206206+(* Main function using Requests with concurrent fetching *)
207207+let run_request env sw persist_cookies verify_tls timeout follow_redirects max_redirects
208208+ method_ urls headers data json_data output include_headers head
209209+ auth _show_progress () =
210210+211211+ (* Log levels are already set by setup_log via Logs_cli *)
212212+213213+ (* Create XDG paths *)
214214+ let xdg = Xdge.create env#fs "ocurl" in
215215+216216+ (* Create requests instance with configuration *)
217217+ let timeout_obj = Option.map (fun t -> Requests.Timeout.create ~total:t ()) timeout in
218218+ let req = Requests.create ~sw ~xdg ~persist_cookies ~verify_tls
219219+ ~follow_redirects ~max_redirects ?timeout:timeout_obj env in
220220+221221+ (* Set authentication if provided *)
222222+ let req = match auth with
223223+ | Some auth_str ->
224224+ (match parse_auth auth_str with
225225+ | Some (user, pass) ->
226226+ Requests.set_auth req
227227+ (Requests.Auth.basic ~username:user ~password:pass)
228228+ | None ->
229229+ Logs.warn (fun m -> m "Invalid auth format, ignoring");
230230+ req)
231231+ | None -> req
232232+ in
233233+234234+ (* Build headers from command line *)
235235+ let cmd_headers = List.fold_left (fun hdrs header_str ->
236236+ match parse_header header_str with
237237+ | Some (k, v) -> Requests.Headers.add k v hdrs
238238+ | None -> hdrs
239239+ ) Requests.Headers.empty headers in
240240+241241+ (* Prepare body based on data/json options *)
242242+ let body = match json_data, data with
243243+ | Some json_str, _ ->
244244+ (* Use of_string with JSON mime type for raw JSON string *)
245245+ Some (Requests.Body.of_string Requests.Mime.json json_str)
246246+ | None, Some d -> Some (Requests.Body.text d)
247247+ | None, None -> None
248248+ in
249249+250250+ (* Process URLs concurrently or sequentially based on count *)
251251+ match urls with
252252+ | [] -> ()
253253+ | [single_url] ->
254254+ (* Single URL - process directly *)
255255+ let _ = process_url env req method_ cmd_headers body include_headers head output single_url in
256256+ ()
257257+ | multiple_urls ->
258258+ (* Multiple URLs - process concurrently *)
259259+ let verbose = Logs.level () = Some Logs.Debug || Logs.level () = Some Logs.Info in
260260+ if verbose then
261261+ Fmt.pr "@[<v>Processing %d URLs concurrently...@]@." (List.length multiple_urls);
262262+263263+ (* Create promises for each URL *)
264264+ let results =
265265+ List.map (fun url_str ->
266266+ let promise, resolver = Eio.Promise.create () in
267267+ (* Fork a fiber for each URL *)
268268+ Fiber.fork ~sw (fun () ->
269269+ let result = process_url env req method_ cmd_headers body include_headers head output url_str in
270270+ Eio.Promise.resolve resolver result
271271+ );
272272+ promise
273273+ ) multiple_urls
274274+ in
275275+276276+ (* Wait for all promises to complete *)
277277+ let completed_results = List.map Eio.Promise.await results in
278278+279279+ (* Report summary *)
280280+ let quiet = match Logs.level () with Some (Logs.Error | Logs.Warning) -> true | _ -> false in
281281+ if not quiet then begin
282282+ let successes = List.filter Result.is_ok completed_results |> List.length in
283283+ let failures = List.filter Result.is_error completed_results |> List.length in
284284+ Fmt.pr "@[<v>@.Summary: %d successful, %d failed out of %d total@]@."
285285+ successes failures (List.length completed_results);
286286+287287+ (* Print failed URLs *)
288288+ if failures > 0 then begin
289289+ Fmt.pr "@[<v>Failed URLs:@]@.";
290290+ List.iter (function
291291+ | Error (url, _) -> Fmt.pr " - %s@." url
292292+ | Ok _ -> ()
293293+ ) completed_results
294294+ end
295295+ end
296296+297297+(* Main entry point *)
298298+let main method_ urls headers data json_data output include_headers head
299299+ auth show_progress persist_cookies verify_tls
300300+ timeout follow_redirects max_redirects () =
301301+302302+ Eio_main.run @@ fun env ->
303303+ Mirage_crypto_rng_unix.use_default ();
304304+ Switch.run @@ fun sw ->
305305+306306+ run_request env sw persist_cookies verify_tls timeout follow_redirects max_redirects
307307+ method_ urls headers data json_data output include_headers head auth
308308+ show_progress ()
309309+310310+(* Command-line interface *)
311311+let cmd =
312312+ let doc = "OCaml HTTP client with concurrent fetching using the Requests library" in
313313+ let man = [
314314+ `S Manpage.s_description;
315315+ `P "$(tname) is a command-line HTTP client written in OCaml that uses the \
316316+ Requests library with stateful request management. It supports various HTTP methods, \
317317+ custom headers, authentication, cookies, and JSON data. When multiple URLs are provided, \
318318+ they are fetched concurrently using Eio fibers for maximum performance.";
319319+ `S Manpage.s_examples;
320320+ `P "Fetch a URL:";
321321+ `Pre " $(tname) https://api.github.com";
322322+ `P "Fetch multiple URLs concurrently:";
323323+ `Pre " $(tname) https://api.github.com https://httpbin.org/get https://example.com";
324324+ `P "Show only response headers (like HEAD request):";
325325+ `Pre " $(tname) -I https://api.github.com";
326326+ `P "Include response headers with body:";
327327+ `Pre " $(tname) -i https://api.github.com";
328328+ `P "POST JSON data:";
329329+ `Pre " $(tname) -X POST --json '{\"key\":\"value\"}' https://httpbin.org/post";
330330+ `P "Download file:";
331331+ `Pre " $(tname) -o file.zip https://example.com/file.zip";
332332+ `P "Download multiple files concurrently:";
333333+ `Pre " $(tname) -o output.json https://api1.example.com https://api2.example.com https://api3.example.com";
334334+ `P "Basic authentication:";
335335+ `Pre " $(tname) -u user:pass https://httpbin.org/basic-auth/user/pass";
336336+ `P "Custom headers:";
337337+ `Pre " $(tname) -H 'Accept: application/json' -H 'X-Api-Key: secret' https://api.example.com";
338338+ `P "With persistent cookies:";
339339+ `Pre " $(tname) --persist-cookies https://example.com";
340340+ `P "Disable TLS verification (insecure):";
341341+ `Pre " $(tname) --no-verify-tls https://self-signed.example.com";
342342+ `S "LOGGING OPTIONS";
343343+ `P "Control logging verbosity using standard options:";
344344+ `P "Enable verbose logging (can be repeated):";
345345+ `Pre " $(tname) -v https://api.github.com # info level";
346346+ `Pre " $(tname) -vv https://api.github.com # debug level (application-level)";
347347+ `P "Enable HTTP protocol-level verbose logging:";
348348+ `Pre " $(tname) -vv --verbose-http https://api.github.com # includes TLS/TCP details";
349349+ `P "Suppress output:";
350350+ `Pre " $(tname) -q https://api.github.com # warnings and errors only";
351351+ `P "Set specific log level:";
352352+ `Pre " $(tname) --verbosity=info https://api.github.com";
353353+ `Pre " $(tname) --verbosity=debug https://api.github.com";
354354+ `Pre " $(tname) --verbosity=error https://api.github.com";
355355+ `P "Available verbosity levels: quiet, error, warning, info, debug";
356356+ `P "The logging system provides detailed information about:";
357357+ `P "- HTTP requests and responses (use -v or -vv for application-level logs)";
358358+ `P "- Authentication and cookie handling";
359359+ `P "- Retry attempts and backoff calculations";
360360+ `P "- TLS/TCP connection details (use --verbose-http with -vv for protocol-level logs)";
361361+ ] in
362362+363363+ (* Build the term with Requests configuration options *)
364364+ let app_name = "ocurl" in
365365+ let combined_term =
366366+ Term.(const main $ http_method $ urls $ headers $ data $ json_data $
367367+ output_file $ include_headers $ head $ auth $
368368+ show_progress $
369369+ Requests.Cmd.persist_cookies_term app_name $
370370+ Requests.Cmd.verify_tls_term app_name $
371371+ Requests.Cmd.timeout_term app_name $
372372+ Requests.Cmd.follow_redirects_term app_name $
373373+ Requests.Cmd.max_redirects_term app_name $
374374+ setup_log app_name)
375375+ in
376376+377377+ let info = Cmd.info "ocurl" ~version:"2.0.0" ~doc ~man in
378378+ Cmd.v info combined_term
379379+380380+let () = exit (Cmd.eval cmd)
+30
dune-project
···11+(lang dune 3.0)
22+(name requests)
33+44+(generate_opam_files true)
55+66+(source
77+ (github username/requests))
88+99+(authors "Your Name")
1010+1111+(maintainers "Your Name")
1212+1313+(license MIT)
1414+1515+(package
1616+ (name requests)
1717+ (synopsis "Clean Eio-style HTTPS client library for OCaml")
1818+ (description "A modern HTTP(S) client library for OCaml with Eio support, providing a clean API for making web requests with automatic TLS/CA certificate handling")
1919+ (depends
2020+ ocaml
2121+ (dune (>= 3.0))
2222+ eio
2323+ cohttp-eio
2424+ tls-eio
2525+ ca-certs
2626+ mirage-crypto-rng-eio
2727+ uri
2828+ digestif
2929+ base64
3030+ logs))
+150
examples/session_example.ml
···11+open Eio
22+open Requests
33+44+let () =
55+ Eio_main.run @@ fun env ->
66+ Mirage_crypto_rng_unix.use_default ();
77+ Switch.run @@ fun sw ->
88+99+ (* Example 1: Basic session usage with cookies *)
1010+ Printf.printf "\n=== Example 1: Basic Session with Cookies ===\n";
1111+ let session = Session.create ~sw env in
1212+1313+ (* First request sets a cookie *)
1414+ let resp1 = Session.get session "https://httpbin.org/cookies/set?session_id=abc123" in
1515+ Printf.printf "Set cookie response: %d\n" (Response.status resp1);
1616+1717+ (* Second request automatically includes the cookie *)
1818+ let resp2 = Session.get session "https://httpbin.org/cookies" in
1919+ let body2 = Response.body resp2 |> Buf_read.take_all in
2020+ Printf.printf "Cookies seen by server: %s\n" body2;
2121+2222+ (* Example 2: Session with default headers and auth *)
2323+ Printf.printf "\n=== Example 2: Session with Default Configuration ===\n";
2424+ let github_session = Session.create ~sw env in
2525+2626+ (* Set default headers that apply to all requests *)
2727+ Session.set_default_header github_session "User-Agent" "OCaml-Requests-Example/1.0";
2828+ Session.set_default_header github_session "Accept" "application/vnd.github.v3+json";
2929+3030+ (* Set authentication (if you have a token) *)
3131+ (* Session.set_auth github_session (Auth.bearer "your_github_token"); *)
3232+3333+ (* All requests will use these defaults *)
3434+ let user = Session.get github_session "https://api.github.com/users/ocaml" in
3535+ Printf.printf "GitHub user status: %d\n" (Response.status user);
3636+3737+ (* Example 3: Session with retry logic *)
3838+ Printf.printf "\n=== Example 3: Session with Retry Logic ===\n";
3939+ let retry_config = Retry.create_config
4040+ ~max_retries:3
4141+ ~backoff_factor:0.5
4242+ ~status_forcelist:[429; 500; 502; 503; 504]
4343+ () in
4444+4545+ let robust_session = Session.create ~sw ~retry:retry_config env in
4646+ Session.set_timeout robust_session (Timeout.create ~total:30.0 ());
4747+4848+ (* This request will automatically retry on failures *)
4949+ let result = Session.get robust_session "https://httpbin.org/status/503" in
5050+ Printf.printf "Request status (might retry): %d\n" (Response.status result);
5151+5252+ (* Example 4: Persistent cookies *)
5353+ Printf.printf "\n=== Example 4: Persistent Cookies ===\n";
5454+ let persistent_session = Session.create ~sw
5555+ ~persist_cookies:true
5656+ ~app_name:"ocaml_example"
5757+ env in
5858+5959+ (* Login and save cookies *)
6060+ let _login = Session.post persistent_session
6161+ ~form:["username", "demo"; "password", "demo"]
6262+ "https://httpbin.org/post" in
6363+6464+ (* Cookies will be saved to ~/.config/ocaml_example/cookies.txt *)
6565+ Session.save_cookies persistent_session;
6666+ Printf.printf "Cookies saved to disk\n";
6767+6868+ (* Example 5: Concurrent requests with the same session *)
6969+ Printf.printf "\n=== Example 5: Concurrent Requests ===\n";
7070+ let urls = [
7171+ "https://httpbin.org/delay/1";
7272+ "https://httpbin.org/delay/1";
7373+ "https://httpbin.org/delay/1";
7474+ ] in
7575+7676+ let start_time = Unix.gettimeofday () in
7777+ let responses = Session.map_concurrent session ~max_concurrent:3
7878+ ~f:(fun sess url ->
7979+ let resp = Session.get sess url in
8080+ Response.status resp
8181+ ) urls in
8282+8383+ let elapsed = Unix.gettimeofday () -. start_time in
8484+ Printf.printf "Concurrent requests completed in %.2fs\n" elapsed;
8585+ List.iter (Printf.printf "Status: %d\n") responses;
8686+8787+ (* Example 6: Prepared requests *)
8888+ Printf.printf "\n=== Example 6: Prepared Requests ===\n";
8989+ let prepared = Session.Prepared.create
9090+ ~session
9191+ ~method_:Method.POST
9292+ "https://httpbin.org/post" in
9393+9494+ (* Inspect and modify the prepared request *)
9595+ let prepared = Session.Prepared.set_header prepared "X-Custom" "Header" in
9696+ let prepared = Session.Prepared.set_body prepared (Body.text "Hello, World!") in
9797+9898+ Format.printf "Prepared request:@.%a@." Session.Prepared.pp prepared;
9999+100100+ (* Send when ready *)
101101+ let resp = Session.Prepared.send prepared in
102102+ Printf.printf "Prepared request sent, status: %d\n" (Response.status resp);
103103+104104+ (* Example 7: Hooks *)
105105+ Printf.printf "\n=== Example 7: Request/Response Hooks ===\n";
106106+ let hook_session = Session.create ~sw env in
107107+108108+ (* Add a request hook to log all requests *)
109109+ Session.Hooks.add_request_hook hook_session (fun headers method_ url ->
110110+ Printf.printf "-> Request: %s %s\n" (Method.to_string method_) url;
111111+ headers
112112+ );
113113+114114+ (* Add a response hook to log all responses *)
115115+ Session.Hooks.add_response_hook hook_session (fun response ->
116116+ Printf.printf "<- Response: %d\n" (Response.status response)
117117+ );
118118+119119+ (* All requests will trigger hooks *)
120120+ let _ = Session.get hook_session "https://httpbin.org/get" in
121121+ let _ = Session.post hook_session "https://httpbin.org/post" in
122122+123123+ (* Example 8: Session statistics *)
124124+ Printf.printf "\n=== Example 8: Session Statistics ===\n";
125125+ let stats = Session.stats session in
126126+ Printf.printf "Total requests: %d\n" stats#requests_made;
127127+ Printf.printf "Total time: %.3fs\n" stats#total_time;
128128+ Printf.printf "Average time per request: %.3fs\n"
129129+ (stats#total_time /. float_of_int stats#requests_made);
130130+131131+ (* Pretty print session info *)
132132+ Format.printf "@.Session info:@.%a@." Session.pp session;
133133+134134+ (* Example 9: Download file *)
135135+ Printf.printf "\n=== Example 9: Download File ===\n";
136136+ let download_session = Session.create ~sw env in
137137+ let temp_file = Path.(env#fs / "/tmp/example_download.json") in
138138+139139+ Session.download_file download_session
140140+ ~on_progress:(fun ~received ~total ->
141141+ match total with
142142+ | Some t -> Printf.printf "Downloaded %Ld/%Ld bytes\r%!" received t
143143+ | None -> Printf.printf "Downloaded %Ld bytes\r%!" received
144144+ )
145145+ "https://httpbin.org/json"
146146+ temp_file;
147147+148148+ Printf.printf "\nFile downloaded to /tmp/example_download.json\n";
149149+150150+ Printf.printf "\n=== All examples completed successfully! ===\n"
+36
lib/auth.ml
···11+let src = Logs.Src.create "requests.auth" ~doc:"HTTP Authentication"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+type t =
55+ | None
66+ | Basic of { username : string; password : string }
77+ | Bearer of { token : string }
88+ | Digest of { username : string; password : string }
99+ | Custom of (Headers.t -> Headers.t)
1010+1111+let none = None
1212+1313+let basic ~username ~password = Basic { username; password }
1414+1515+let bearer ~token = Bearer { token }
1616+1717+let digest ~username ~password = Digest { username; password }
1818+1919+let custom f = Custom f
2020+2121+let apply auth headers =
2222+ match auth with
2323+ | None -> headers
2424+ | Basic { username; password } ->
2525+ Log.debug (fun m -> m "Applying basic authentication for user: %s" username);
2626+ Headers.basic ~username ~password headers
2727+ | Bearer { token } ->
2828+ Log.debug (fun m -> m "Applying bearer token authentication");
2929+ Headers.bearer token headers
3030+ | Digest { username; password = _ } ->
3131+ Log.debug (fun m -> m "Digest auth configured for user: %s (requires server challenge)" username);
3232+ (* Digest auth requires server challenge first, handled elsewhere *)
3333+ headers
3434+ | Custom f ->
3535+ Log.debug (fun m -> m "Applying custom authentication handler");
3636+ f headers
+25
lib/auth.mli
···11+(** Authentication mechanisms *)
22+33+(** Log source for authentication operations *)
44+val src : Logs.Src.t
55+66+type t
77+(** Abstract authentication type *)
88+99+val none : t
1010+(** No authentication *)
1111+1212+val basic : username:string -> password:string -> t
1313+(** HTTP Basic authentication *)
1414+1515+val bearer : token:string -> t
1616+(** Bearer token authentication (e.g., OAuth 2.0) *)
1717+1818+val digest : username:string -> password:string -> t
1919+(** HTTP Digest authentication *)
2020+2121+val custom : (Headers.t -> Headers.t) -> t
2222+(** Custom authentication handler *)
2323+2424+val apply : t -> Headers.t -> Headers.t
2525+(** Apply authentication to headers *)
+276
lib/body.ml
···11+let src = Logs.Src.create "requests.body" ~doc:"HTTP Request/Response Body"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+type 'a part = {
55+ name : string;
66+ filename : string option;
77+ content_type : Mime.t;
88+ content : [`String of string | `Stream of Eio.Flow.source_ty Eio.Resource.t | `File of 'a Eio.Path.t];
99+}
1010+1111+type t =
1212+ | Empty
1313+ | String of { content : string; mime : Mime.t }
1414+ | Stream of { source : Eio.Flow.source_ty Eio.Resource.t; mime : Mime.t; length : int64 option }
1515+ | File : { file : 'a Eio.Path.t; mime : Mime.t } -> t
1616+ | Multipart : { parts : 'a part list; boundary : string } -> t
1717+1818+let empty = Empty
1919+2020+let of_string mime content =
2121+ String { content; mime }
2222+2323+let of_stream ?length mime source =
2424+ Stream { source; mime; length }
2525+2626+let of_file ?mime file =
2727+ let mime = match mime with
2828+ | Some m -> m
2929+ | None ->
3030+ (* Guess MIME type from filename if available *)
3131+ let path = Eio.Path.native_exn file in
3232+ let guessed =
3333+ if String.ends_with ~suffix:".json" path then Mime.json
3434+ else if String.ends_with ~suffix:".html" path then Mime.html
3535+ else if String.ends_with ~suffix:".xml" path then Mime.xml
3636+ else if String.ends_with ~suffix:".txt" path then Mime.text
3737+ else Mime.octet_stream
3838+ in
3939+ Log.debug (fun m -> m "Guessed MIME type %s for file %s" (Mime.to_string guessed) path);
4040+ guessed
4141+ in
4242+ Log.debug (fun m -> m "Creating file body from %s with MIME type %s"
4343+ (Eio.Path.native_exn file) (Mime.to_string mime));
4444+ File { file; mime }
4545+4646+(* For simple JSON encoding, we just take a Jsont.json value and encode it *)
4747+let json (json_value : Jsont.json) =
4848+ let content = match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value with
4949+ | Ok s -> s
5050+ | Error e ->
5151+ let msg = Jsont.Error.to_string e in
5252+ failwith (Printf.sprintf "Failed to encode JSON: %s" msg)
5353+ in
5454+ String { content; mime = Mime.json }
5555+5656+(* JSON streaming using jsont - we encode the value to string and stream it *)
5757+module Json_stream_source = struct
5858+ type t = {
5959+ mutable content : string;
6060+ mutable offset : int;
6161+ }
6262+6363+ let single_read t dst =
6464+ if t.offset >= String.length t.content then
6565+ raise End_of_file
6666+ else begin
6767+ let available = String.length t.content - t.offset in
6868+ let to_copy = min (Cstruct.length dst) available in
6969+ Cstruct.blit_from_string t.content t.offset dst 0 to_copy;
7070+ t.offset <- t.offset + to_copy;
7171+ to_copy
7272+ end
7373+7474+ let read_methods = []
7575+end
7676+7777+let json_stream_source_create json_value =
7878+ (* Encode the entire JSON value to string with minified format *)
7979+ let content = match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value with
8080+ | Ok s -> s
8181+ | Error e ->
8282+ let msg = Jsont.Error.to_string e in
8383+ failwith (Printf.sprintf "Failed to encode JSON stream: %s" msg)
8484+ in
8585+ let t = { Json_stream_source.content; offset = 0 } in
8686+ let ops = Eio.Flow.Pi.source (module Json_stream_source) in
8787+ Eio.Resource.T (t, ops)
8888+8989+let json_stream json_value =
9090+ let source = json_stream_source_create json_value in
9191+ Stream { source; mime = Mime.json; length = None }
9292+9393+let text content =
9494+ String { content; mime = Mime.text }
9595+9696+let form params =
9797+ let encode_param (k, v) =
9898+ Printf.sprintf "%s=%s"
9999+ (Uri.pct_encode ~component:`Query_value k)
100100+ (Uri.pct_encode ~component:`Query_value v)
101101+ in
102102+ let content = String.concat "&" (List.map encode_param params) in
103103+ String { content; mime = Mime.form }
104104+105105+let generate_boundary () =
106106+ let random_bytes = Mirage_crypto_rng.generate 16 in
107107+ let random_part =
108108+ Cstruct.to_hex_string (Cstruct.of_string random_bytes)
109109+ in
110110+ Printf.sprintf "----WebKitFormBoundary%s" random_part
111111+112112+let multipart parts =
113113+ let boundary = generate_boundary () in
114114+ Multipart { parts; boundary }
115115+116116+let content_type = function
117117+ | Empty -> None
118118+ | String { mime; _ } -> Some mime
119119+ | Stream { mime; _ } -> Some mime
120120+ | File { mime; _ } -> Some mime
121121+ | Multipart { boundary; _ } ->
122122+ let mime = Mime.make "multipart" "form-data" in
123123+ Some (Mime.with_charset boundary mime)
124124+125125+let content_length = function
126126+ | Empty -> Some 0L
127127+ | String { content; _ } -> Some (Int64.of_int (String.length content))
128128+ | Stream { length; _ } -> length
129129+ | File { file; _ } ->
130130+ (* Try to get file size *)
131131+ (try
132132+ let stat = Eio.Path.stat ~follow:true file in
133133+ Some (Optint.Int63.to_int64 stat.size)
134134+ with _ -> None)
135135+ | Multipart _ ->
136136+ (* Complex to calculate, handled during sending *)
137137+ None
138138+139139+(* Strings_source - A flow source that streams from a doubly-linked list of strings/flows *)
140140+module Strings_source = struct
141141+ type element =
142142+ | String of string
143143+ | Flow of Eio.Flow.source_ty Eio.Resource.t
144144+145145+ type t = {
146146+ dllist : element Lwt_dllist.t;
147147+ mutable current_element : element option;
148148+ mutable string_offset : int;
149149+ }
150150+151151+ let rec single_read t dst =
152152+ match t.current_element with
153153+ | None ->
154154+ (* Try to get the first element from the list *)
155155+ if Lwt_dllist.is_empty t.dllist then
156156+ raise End_of_file
157157+ else begin
158158+ t.current_element <- Some (Lwt_dllist.take_l t.dllist);
159159+ single_read t dst
160160+ end
161161+ | Some (String s) when t.string_offset >= String.length s ->
162162+ (* Current string exhausted, move to next element *)
163163+ t.current_element <- None;
164164+ t.string_offset <- 0;
165165+ single_read t dst
166166+ | Some (String s) ->
167167+ (* Read from current string *)
168168+ let available = String.length s - t.string_offset in
169169+ let to_read = min (Cstruct.length dst) available in
170170+ Cstruct.blit_from_string s t.string_offset dst 0 to_read;
171171+ t.string_offset <- t.string_offset + to_read;
172172+ to_read
173173+ | Some (Flow flow) ->
174174+ (* Read from flow *)
175175+ (try
176176+ let n = Eio.Flow.single_read flow dst in
177177+ if n = 0 then begin
178178+ (* Flow exhausted, move to next element *)
179179+ t.current_element <- None;
180180+ single_read t dst
181181+ end else n
182182+ with End_of_file ->
183183+ t.current_element <- None;
184184+ single_read t dst)
185185+186186+ let read_methods = [] (* No special read methods *)
187187+188188+ let create () = {
189189+ dllist = Lwt_dllist.create ();
190190+ current_element = None;
191191+ string_offset = 0;
192192+ }
193193+194194+ let add_string t s =
195195+ ignore (Lwt_dllist.add_r (String s) t.dllist)
196196+197197+ let add_flow t flow =
198198+ ignore (Lwt_dllist.add_r (Flow flow) t.dllist)
199199+end
200200+201201+let strings_source_create () =
202202+ let t = Strings_source.create () in
203203+ let ops = Eio.Flow.Pi.source (module Strings_source) in
204204+ (t, Eio.Resource.T (t, ops))
205205+206206+let to_cohttp_body ~sw = function
207207+ | Empty -> None
208208+ | String { content; _ } -> Some (Cohttp_eio.Body.of_string content)
209209+ | Stream { source; _ } -> Some source
210210+ | File { file; _ } ->
211211+ (* Open file and stream it directly without loading into memory *)
212212+ let flow = Eio.Path.open_in ~sw file in
213213+ Some (flow :> Eio.Flow.source_ty Eio.Resource.t)
214214+ | Multipart { parts; boundary } ->
215215+ (* Create a single strings_source with dllist for streaming *)
216216+ let source, flow = strings_source_create () in
217217+218218+ List.iter (fun part ->
219219+ (* Add boundary *)
220220+ Strings_source.add_string source "--";
221221+ Strings_source.add_string source boundary;
222222+ Strings_source.add_string source "\r\n";
223223+224224+ (* Add Content-Disposition header *)
225225+ Strings_source.add_string source "Content-Disposition: form-data; name=\"";
226226+ Strings_source.add_string source part.name;
227227+ Strings_source.add_string source "\"";
228228+ (match part.filename with
229229+ | Some f ->
230230+ Strings_source.add_string source "; filename=\"";
231231+ Strings_source.add_string source f;
232232+ Strings_source.add_string source "\""
233233+ | None -> ());
234234+ Strings_source.add_string source "\r\n";
235235+236236+ (* Add Content-Type header *)
237237+ Strings_source.add_string source "Content-Type: ";
238238+ Strings_source.add_string source (Mime.to_string part.content_type);
239239+ Strings_source.add_string source "\r\n\r\n";
240240+241241+ (* Add content *)
242242+ (match part.content with
243243+ | `String s ->
244244+ Strings_source.add_string source s
245245+ | `File file ->
246246+ (* Open file and add as flow *)
247247+ let file_flow = Eio.Path.open_in ~sw file in
248248+ Strings_source.add_flow source (file_flow :> Eio.Flow.source_ty Eio.Resource.t)
249249+ | `Stream stream ->
250250+ (* Add stream directly *)
251251+ Strings_source.add_flow source stream);
252252+253253+ (* Add trailing newline *)
254254+ Strings_source.add_string source "\r\n"
255255+ ) parts;
256256+257257+ (* Add final boundary *)
258258+ Strings_source.add_string source "--";
259259+ Strings_source.add_string source boundary;
260260+ Strings_source.add_string source "--\r\n";
261261+262262+ Some flow
263263+264264+(* Private module *)
265265+module Private = struct
266266+ let to_cohttp_body = to_cohttp_body
267267+268268+ let to_string = function
269269+ | Empty -> ""
270270+ | String { content; _ } -> content
271271+ | Stream _ -> failwith "Cannot convert streaming body to string for connection pooling (body must be materialized first)"
272272+ | File _ -> failwith "Cannot convert file body to string for connection pooling (file must be read first)"
273273+ | Multipart _ -> failwith "Cannot convert multipart body to string for connection pooling (must be encoded first)"
274274+275275+ let _ = to_string (* Use to avoid warning *)
276276+end
+149
lib/body.mli
···11+(** HTTP request body construction
22+33+ This module provides various ways to construct HTTP request bodies,
44+ including strings, files, streams, forms, and multipart data.
55+66+ {2 Examples}
77+88+ {[
99+ (* Simple text body *)
1010+ let body = Body.text "Hello, World!"
1111+1212+ (* JSON body *)
1313+ let body = Body.json {|{"name": "Alice", "age": 30}|}
1414+1515+ (* Form data *)
1616+ let body = Body.form [
1717+ ("username", "alice");
1818+ ("password", "secret")
1919+ ]
2020+2121+ (* File upload *)
2222+ let body = Body.of_file ~mime:Mime.pdf (Eio.Path.(fs / "document.pdf"))
2323+2424+ (* Multipart form with file *)
2525+ let body = Body.multipart [
2626+ { name = "field"; filename = None;
2727+ content_type = Mime.text_plain;
2828+ content = `String "value" };
2929+ { name = "file"; filename = Some "photo.jpg";
3030+ content_type = Mime.jpeg;
3131+ content = `File (Eio.Path.(fs / "photo.jpg")) }
3232+ ]
3333+ ]}
3434+*)
3535+3636+(** Log source for body operations *)
3737+val src : Logs.Src.t
3838+3939+type t
4040+(** Abstract body type representing HTTP request body content. *)
4141+4242+(** {1 Basic Constructors} *)
4343+4444+val empty : t
4545+(** [empty] creates an empty body (no content). *)
4646+4747+val of_string : Mime.t -> string -> t
4848+(** [of_string mime content] creates a body from a string with the specified MIME type.
4949+ Example: [of_string Mime.json {|{"key": "value"}|}] *)
5050+5151+val of_stream : ?length:int64 -> Mime.t -> Eio.Flow.source_ty Eio.Resource.t -> t
5252+(** [of_stream ?length mime stream] creates a streaming body. If [length] is provided,
5353+ it will be used for the Content-Length header, otherwise chunked encoding is used. *)
5454+5555+val of_file : ?mime:Mime.t -> _ Eio.Path.t -> t
5656+(** [of_file ?mime path] creates a body from a file. The MIME type is inferred from
5757+ the file extension if not provided. *)
5858+5959+(** {1 Convenience Constructors} *)
6060+6161+val json : Jsont.json -> t
6262+(** [json value] creates a JSON body from a Jsont.json value.
6363+ The value is encoded to a JSON string with Content-Type: application/json.
6464+6565+ Example:
6666+ {[
6767+ let body = Body.json (Jsont.Object ([
6868+ ("status", Jsont.String "success");
6969+ ("count", Jsont.Number 42.);
7070+ ("items", Jsont.Array ([Jsont.String "first"; Jsont.String "second"], Jsont.Meta.none))
7171+ ], Jsont.Meta.none))
7272+ ]}
7373+*)
7474+7575+val json_stream : Jsont.json -> t
7676+(** [json_stream json_value] creates a streaming JSON body from a Jsont.json value.
7777+ The JSON value will be encoded to a minified JSON string and streamed.
7878+7979+ Example:
8080+ {[
8181+ let large_data = Jsont.Object ([
8282+ ("users", Jsont.Array ([...], Jsont.Meta.none))
8383+ ], Jsont.Meta.none) in
8484+ let body = Body.json_stream large_data
8585+ ]}
8686+*)
8787+8888+val text : string -> t
8989+(** [text str] creates a plain text body with Content-Type: text/plain. *)
9090+9191+val form : (string * string) list -> t
9292+(** [form fields] creates a URL-encoded form body with Content-Type: application/x-www-form-urlencoded.
9393+ Example: [form [("username", "alice"); ("password", "secret")]] *)
9494+9595+(** {1 Multipart Support} *)
9696+9797+type 'a part = {
9898+ name : string; (** Form field name *)
9999+ filename : string option; (** Optional filename for file uploads *)
100100+ content_type : Mime.t; (** MIME type of this part *)
101101+ content : [
102102+ | `String of string (** String content *)
103103+ | `Stream of Eio.Flow.source_ty Eio.Resource.t (** Streaming content *)
104104+ | `File of 'a Eio.Path.t (** File content *)
105105+ ];
106106+}
107107+(** A single part in a multipart body. *)
108108+109109+val multipart : _ part list -> t
110110+(** [multipart parts] creates a multipart/form-data body from a list of parts.
111111+ This is commonly used for file uploads and complex form submissions.
112112+113113+ Example:
114114+ {[
115115+ let body = Body.multipart [
116116+ { name = "username"; filename = None;
117117+ content_type = Mime.text_plain;
118118+ content = `String "alice" };
119119+ { name = "avatar"; filename = Some "photo.jpg";
120120+ content_type = Mime.jpeg;
121121+ content = `File (Eio.Path.(fs / "photo.jpg")) }
122122+ ]
123123+ ]}
124124+*)
125125+126126+(** {1 Properties} *)
127127+128128+val content_type : t -> Mime.t option
129129+(** [content_type body] returns the MIME type of the body, if set. *)
130130+131131+val content_length : t -> int64 option
132132+(** [content_length body] returns the content length in bytes, if known.
133133+ Returns [None] for streaming bodies without a predetermined length. *)
134134+135135+(** {1 Private API} *)
136136+137137+(** Internal functions exposed for use by other modules in the library.
138138+ These are not part of the public API and may change between versions. *)
139139+module Private : sig
140140+ val to_cohttp_body : sw:Eio.Switch.t -> t -> Cohttp_eio.Body.t option
141141+ (** [to_cohttp_body ~sw body] converts the body to cohttp-eio format.
142142+ Uses the switch to manage resources like file handles.
143143+ This function is used internally by the Client module. *)
144144+145145+ val to_string : t -> string
146146+ (** [to_string body] converts the body to a string for HTTP/1.1 requests.
147147+ Only works for materialized bodies (String type).
148148+ Raises Failure for streaming/file/multipart bodies. *)
149149+end
+291
lib/digest_auth.ml
···11+(** RFC 2617 HTTP Digest Authentication implementation *)
22+33+module Log = (val Logs.src_log (Logs.Src.create "requests.digest_auth" ~doc:"HTTP Digest Authentication") : Logs.LOG)
44+55+(** Digest auth challenge parameters from WWW-Authenticate header *)
66+type challenge = {
77+ realm : string;
88+ domain : string option;
99+ nonce : string;
1010+ opaque : string option;
1111+ stale : bool;
1212+ algorithm : [`MD5 | `MD5_sess | `SHA256 | `SHA256_sess];
1313+ qop : [`Auth | `Auth_int] list option; (* quality of protection *)
1414+ charset : string option;
1515+ userhash : bool;
1616+}
1717+1818+(** Client's chosen parameters for response *)
1919+type client_data = {
2020+ username : string;
2121+ password : string;
2222+ nc : int; (* nonce count *)
2323+ cnonce : string; (* client nonce *)
2424+ qop_chosen : [`Auth | `Auth_int] option;
2525+}
2626+2727+(** Parse WWW-Authenticate header for Digest challenge *)
2828+let parse_challenge header_value =
2929+ (* Remove "Digest " prefix if present *)
3030+ let value =
3131+ if String.starts_with ~prefix:"Digest " header_value then
3232+ String.sub header_value 7 (String.length header_value - 7)
3333+ else header_value
3434+ in
3535+3636+ (* Parse comma-separated key=value pairs *)
3737+ let parse_params str =
3838+ let rec parse_one pos acc =
3939+ if pos >= String.length str then acc
4040+ else
4141+ (* Skip whitespace *)
4242+ let pos = ref pos in
4343+ while !pos < String.length str && str.[!pos] = ' ' do incr pos done;
4444+ if !pos >= String.length str then acc
4545+ else
4646+ (* Find key *)
4747+ let key_start = !pos in
4848+ while !pos < String.length str && str.[!pos] <> '=' do incr pos done;
4949+ if !pos >= String.length str then acc
5050+ else
5151+ let key = String.trim (String.sub str key_start (!pos - key_start)) in
5252+ incr pos; (* Skip '=' *)
5353+5454+ (* Parse value - may be quoted *)
5555+ let value, next_pos =
5656+ if !pos < String.length str && str.[!pos] = '"' then begin
5757+ (* Quoted value *)
5858+ incr pos;
5959+ let value_start = !pos in
6060+ while !pos < String.length str && str.[!pos] <> '"' do
6161+ if str.[!pos] = '\\' && !pos + 1 < String.length str then
6262+ pos := !pos + 2 (* Skip escaped character *)
6363+ else
6464+ incr pos
6565+ done;
6666+ let value = String.sub str value_start (!pos - value_start) in
6767+ if !pos < String.length str then incr pos; (* Skip closing quote *)
6868+ (* Skip to next comma *)
6969+ while !pos < String.length str && str.[!pos] <> ',' do incr pos done;
7070+ if !pos < String.length str then incr pos; (* Skip comma *)
7171+ (value, !pos)
7272+ end else begin
7373+ (* Unquoted value *)
7474+ let value_start = !pos in
7575+ while !pos < String.length str && str.[!pos] <> ',' do incr pos done;
7676+ let value = String.trim (String.sub str value_start (!pos - value_start)) in
7777+ if !pos < String.length str then incr pos; (* Skip comma *)
7878+ (value, !pos)
7979+ end
8080+ in
8181+ parse_one next_pos ((key, value) :: acc)
8282+ in
8383+ List.rev (parse_one 0 [])
8484+ in
8585+8686+ let params = parse_params value in
8787+8888+ (* Extract required and optional parameters *)
8989+ let get_param name = List.assoc_opt name params in
9090+ let get_param_req name =
9191+ match get_param name with
9292+ | Some v -> v
9393+ | None -> failwith (Printf.sprintf "Missing required Digest parameter: %s" name)
9494+ in
9595+9696+ try
9797+ let realm = get_param_req "realm" in
9898+ let nonce = get_param_req "nonce" in
9999+100100+ let algorithm = match get_param "algorithm" with
101101+ | Some "MD5" | None -> `MD5
102102+ | Some "MD5-sess" -> `MD5_sess
103103+ | Some "SHA-256" -> `SHA256
104104+ | Some "SHA-256-sess" -> `SHA256_sess
105105+ | Some a ->
106106+ Log.warn (fun m -> m "Unknown digest algorithm: %s, using MD5" a);
107107+ `MD5
108108+ in
109109+110110+ let qop = match get_param "qop" with
111111+ | None -> None
112112+ | Some qop_str ->
113113+ let qops = String.split_on_char ',' qop_str |> List.map String.trim in
114114+ Some (List.filter_map (function
115115+ | "auth" -> Some `Auth
116116+ | "auth-int" -> Some `Auth_int
117117+ | _ -> None
118118+ ) qops)
119119+ in
120120+121121+ Some {
122122+ realm;
123123+ domain = get_param "domain";
124124+ nonce;
125125+ opaque = get_param "opaque";
126126+ stale = (match get_param "stale" with
127127+ | Some "true" | Some "TRUE" -> true
128128+ | _ -> false);
129129+ algorithm;
130130+ qop;
131131+ charset = get_param "charset";
132132+ userhash = (match get_param "userhash" with
133133+ | Some "true" | Some "TRUE" -> true
134134+ | _ -> false);
135135+ }
136136+ with
137137+ | Failure msg ->
138138+ Log.warn (fun m -> m "Failed to parse Digest challenge: %s" msg);
139139+ None
140140+ | Not_found -> None
141141+142142+(** Generate client nonce *)
143143+let generate_cnonce () =
144144+ let rand_bytes = Mirage_crypto_rng.generate 16 in
145145+ Base64.encode_string rand_bytes
146146+147147+(** Hash function based on algorithm *)
148148+let hash_function = function
149149+ | `MD5 | `MD5_sess ->
150150+ fun s -> Digestif.MD5.(to_hex (digest_string s))
151151+ | `SHA256 | `SHA256_sess ->
152152+ fun s -> Digestif.SHA256.(to_hex (digest_string s))
153153+154154+(** Calculate H(A1) according to RFC 2617 *)
155155+let calculate_ha1 ~algorithm ~username ~realm ~password ~nonce ~cnonce =
156156+ let hash = hash_function algorithm in
157157+ match algorithm with
158158+ | `MD5 | `SHA256 ->
159159+ hash (Printf.sprintf "%s:%s:%s" username realm password)
160160+ | `MD5_sess | `SHA256_sess ->
161161+ let ha1_base = hash (Printf.sprintf "%s:%s:%s" username realm password) in
162162+ hash (Printf.sprintf "%s:%s:%s" ha1_base nonce cnonce)
163163+164164+(** Calculate H(A2) according to RFC 2617 *)
165165+let calculate_ha2 ~algorithm ~meth ~uri ~qop ~body =
166166+ let hash = hash_function algorithm in
167167+ let method_str = match meth with
168168+ | `GET -> "GET" | `POST -> "POST" | `PUT -> "PUT"
169169+ | `DELETE -> "DELETE" | `HEAD -> "HEAD" | `OPTIONS -> "OPTIONS"
170170+ | `PATCH -> "PATCH" | `TRACE -> "TRACE" | `CONNECT -> "CONNECT"
171171+ | `Other s -> s
172172+ in
173173+ match qop with
174174+ | None | Some `Auth ->
175175+ hash (Printf.sprintf "%s:%s" method_str (Uri.path_and_query uri))
176176+ | Some `Auth_int ->
177177+ (* For auth-int, include hash of entity body *)
178178+ let body_hash = match body with
179179+ | None -> hash ""
180180+ | Some b -> hash b
181181+ in
182182+ hash (Printf.sprintf "%s:%s:%s" method_str (Uri.path_and_query uri) body_hash)
183183+184184+(** Calculate the response hash *)
185185+let calculate_response ~ha1 ~ha2 ~nonce ~nc ~cnonce ~qop =
186186+ let hash = hash_function `MD5 in (* Response always uses the same hash as HA1 *)
187187+ match qop with
188188+ | None ->
189189+ hash (Printf.sprintf "%s:%s:%s" ha1 nonce ha2)
190190+ | Some qop_value ->
191191+ let qop_str = match qop_value with
192192+ | `Auth -> "auth"
193193+ | `Auth_int -> "auth-int"
194194+ in
195195+ let nc_str = Printf.sprintf "%08x" nc in
196196+ hash (Printf.sprintf "%s:%s:%s:%s:%s:%s" ha1 nonce nc_str cnonce qop_str ha2)
197197+198198+(** Generate Authorization header value for Digest auth *)
199199+let generate_auth_header ~challenge ~client_data ~meth ~uri ~body =
200200+ let { username; password; nc; cnonce; qop_chosen } = client_data in
201201+ let { realm; nonce; opaque; algorithm; _ } = challenge in
202202+203203+ (* Calculate hashes *)
204204+ let ha1 = calculate_ha1 ~algorithm ~username ~realm ~password ~nonce ~cnonce in
205205+ let ha2 = calculate_ha2 ~algorithm ~meth ~uri ~qop:qop_chosen ~body in
206206+ let response = calculate_response ~ha1 ~ha2 ~nonce ~nc ~cnonce ~qop:qop_chosen in
207207+208208+ (* Build Authorization header *)
209209+ let params = [
210210+ ("username", Printf.sprintf "\"%s\"" username);
211211+ ("realm", Printf.sprintf "\"%s\"" realm);
212212+ ("nonce", Printf.sprintf "\"%s\"" nonce);
213213+ ("uri", Printf.sprintf "\"%s\"" (Uri.path_and_query uri));
214214+ ("response", Printf.sprintf "\"%s\"" response);
215215+ ] in
216216+217217+ let params = match algorithm with
218218+ | `MD5 -> params (* MD5 is default, don't need to specify *)
219219+ | `MD5_sess -> ("algorithm", "MD5-sess") :: params
220220+ | `SHA256 -> ("algorithm", "SHA-256") :: params
221221+ | `SHA256_sess -> ("algorithm", "SHA-256-sess") :: params
222222+ in
223223+224224+ let params = match opaque with
225225+ | Some o -> ("opaque", Printf.sprintf "\"%s\"" o) :: params
226226+ | None -> params
227227+ in
228228+229229+ let params = match qop_chosen with
230230+ | None -> params
231231+ | Some qop ->
232232+ let qop_str = match qop with `Auth -> "auth" | `Auth_int -> "auth-int" in
233233+ let nc_str = Printf.sprintf "%08x" nc in
234234+ ("qop", qop_str) ::
235235+ ("nc", nc_str) ::
236236+ ("cnonce", Printf.sprintf "\"%s\"" cnonce) ::
237237+ params
238238+ in
239239+240240+ "Digest " ^ String.concat ", " (List.map (fun (k, v) -> k ^ "=" ^ v) params)
241241+242242+(** Nonce counter storage - in production should be persistent *)
243243+module NonceCounter = struct
244244+ let table = Hashtbl.create 16
245245+246246+ let get_and_increment ~nonce =
247247+ let current = try Hashtbl.find table nonce with Not_found -> 0 in
248248+ Hashtbl.replace table nonce (current + 1);
249249+ current + 1
250250+251251+ let reset ~nonce =
252252+ Hashtbl.remove table nonce
253253+end
254254+255255+(** Apply Digest authentication to a request *)
256256+let apply_digest_auth ~username ~password ~meth ~uri ~headers ~body ~challenge_header =
257257+ match parse_challenge challenge_header with
258258+ | None ->
259259+ Log.warn (fun m -> m "Failed to parse Digest challenge");
260260+ headers
261261+ | Some challenge ->
262262+ (* Choose QOP if server offers options *)
263263+ let qop_chosen = match challenge.qop with
264264+ | None -> None
265265+ | Some qops ->
266266+ (* Prefer auth over auth-int for simplicity *)
267267+ if List.mem `Auth qops then Some `Auth
268268+ else if List.mem `Auth_int qops then Some `Auth_int
269269+ else None
270270+ in
271271+272272+ (* Get or generate client nonce *)
273273+ let cnonce = generate_cnonce () in
274274+275275+ (* Get and increment nonce counter *)
276276+ let nc = NonceCounter.get_and_increment ~nonce:challenge.nonce in
277277+278278+ let client_data = { username; password; nc; cnonce; qop_chosen } in
279279+ let auth_value = generate_auth_header ~challenge ~client_data ~meth ~uri ~body in
280280+281281+ Cohttp.Header.add headers "Authorization" auth_value
282282+283283+(** Check if a response requires digest auth *)
284284+let is_digest_challenge response =
285285+ let status = Cohttp.Response.status response in
286286+ match Cohttp.Code.code_of_status status with
287287+ | 401 ->
288288+ (match Cohttp.Header.get (Cohttp.Response.headers response) "www-authenticate" with
289289+ | Some header when String.starts_with ~prefix:"Digest" header -> Some header
290290+ | _ -> None)
291291+ | _ -> None
···11+(** Centralized error handling for the Requests library *)
22+33+let src = Logs.Src.create "requests.error" ~doc:"HTTP Request Errors"
44+module Log = (val Logs.src_log src : Logs.LOG)
55+66+(** {1 Exception Types} *)
77+88+exception Timeout
99+exception TooManyRedirects of { url: string; count: int; max: int }
1010+exception ConnectionError of string
1111+exception HTTPError of {
1212+ url: string;
1313+ status: int;
1414+ reason: string;
1515+ body: string option;
1616+ headers: Headers.t
1717+}
1818+exception AuthenticationError of string
1919+exception SSLError of string
2020+exception ProxyError of string
2121+exception EncodingError of string
2222+exception InvalidURL of string
2323+exception InvalidRequest of string
2424+2525+(** {1 Error Type} *)
2626+2727+type t = [
2828+ | `Timeout
2929+ | `TooManyRedirects of string * int * int (* url, count, max *)
3030+ | `ConnectionError of string
3131+ | `HTTPError of string * int * string * string option * Headers.t (* url, status, reason, body, headers *)
3232+ | `AuthenticationError of string
3333+ | `SSLError of string
3434+ | `ProxyError of string
3535+ | `EncodingError of string
3636+ | `InvalidURL of string
3737+ | `InvalidRequest of string
3838+ | `UnknownError of string
3939+]
4040+4141+(** {1 Conversion Functions} *)
4242+4343+let of_exn = function
4444+ | Timeout -> Some `Timeout
4545+ | TooManyRedirects { url; count; max } ->
4646+ Some (`TooManyRedirects (url, count, max))
4747+ | ConnectionError msg -> Some (`ConnectionError msg)
4848+ | HTTPError { url; status; reason; body; headers } ->
4949+ Some (`HTTPError (url, status, reason, body, headers))
5050+ | AuthenticationError msg -> Some (`AuthenticationError msg)
5151+ | SSLError msg -> Some (`SSLError msg)
5252+ | ProxyError msg -> Some (`ProxyError msg)
5353+ | EncodingError msg -> Some (`EncodingError msg)
5454+ | InvalidURL msg -> Some (`InvalidURL msg)
5555+ | InvalidRequest msg -> Some (`InvalidRequest msg)
5656+ | _ -> None
5757+5858+let to_exn = function
5959+ | `Timeout -> Timeout
6060+ | `TooManyRedirects (url, count, max) ->
6161+ TooManyRedirects { url; count; max }
6262+ | `ConnectionError msg -> ConnectionError msg
6363+ | `HTTPError (url, status, reason, body, headers) ->
6464+ HTTPError { url; status; reason; body; headers }
6565+ | `AuthenticationError msg -> AuthenticationError msg
6666+ | `SSLError msg -> SSLError msg
6767+ | `ProxyError msg -> ProxyError msg
6868+ | `EncodingError msg -> EncodingError msg
6969+ | `InvalidURL msg -> InvalidURL msg
7070+ | `InvalidRequest msg -> InvalidRequest msg
7171+ | `UnknownError msg -> Failure msg
7272+7373+let raise error = Stdlib.raise (to_exn error)
7474+7575+(** {1 Combinators} *)
7676+7777+let catch f =
7878+ try Ok (f ())
7979+ with
8080+ | exn ->
8181+ match of_exn exn with
8282+ | Some err -> Error err
8383+ | None -> Error (`UnknownError (Printexc.to_string exn))
8484+8585+let catch_async f = catch f (* In Eio, regular catch works for async too *)
8686+8787+let map f = function
8888+ | Ok x -> Ok (f x)
8989+ | Error e -> Error e
9090+9191+let bind f = function
9292+ | Ok x -> f x
9393+ | Error e -> Error e
9494+9595+let both a b =
9696+ match a, b with
9797+ | Ok x, Ok y -> Ok (x, y)
9898+ | Error e, _ -> Error e
9999+ | _, Error e -> Error e
100100+101101+let get_exn = function
102102+ | Ok x -> x
103103+ | Error e -> raise e
104104+105105+let get_or ~default = function
106106+ | Ok x -> x
107107+ | Error _ -> default
108108+109109+let is_retryable = function
110110+ | `Timeout -> true
111111+ | `ConnectionError _ -> true
112112+ | `HTTPError (_, status, _, _, _) -> Status.is_retryable (Status.of_int status)
113113+ | `SSLError _ -> true
114114+ | `ProxyError _ -> true
115115+ | _ -> false
116116+117117+let is_client_error = function
118118+ | `HTTPError (_, status, _, _, _) -> Status.is_client_error (Status.of_int status)
119119+ | `AuthenticationError _
120120+ | `InvalidURL _
121121+ | `InvalidRequest _ -> true
122122+ | _ -> false
123123+124124+let is_server_error = function
125125+ | `HTTPError (_, status, _, _, _) -> Status.is_server_error (Status.of_int status)
126126+ | _ -> false
127127+128128+129129+(** {1 Pretty Printing} *)
130130+131131+let pp ppf = function
132132+ | `Timeout ->
133133+ Format.fprintf ppf "@[<2>Request Timeout:@ The request timed out@]"
134134+ | `TooManyRedirects (url, count, max) ->
135135+ Format.fprintf ppf "@[<2>Too Many Redirects:@ Exceeded maximum redirects (%d/%d) for URL: %s@]"
136136+ count max url
137137+ | `ConnectionError msg ->
138138+ Format.fprintf ppf "@[<2>Connection Error:@ %s@]" msg
139139+ | `HTTPError (url, status, reason, body, _headers) ->
140140+ Format.fprintf ppf "@[<v>@[<2>HTTP Error %d (%s):@ URL: %s@]" status reason url;
141141+ Option.iter (fun b ->
142142+ Format.fprintf ppf "@,@[<2>Response Body:@ %s@]" b
143143+ ) body;
144144+ Format.fprintf ppf "@]"
145145+ | `AuthenticationError msg ->
146146+ Format.fprintf ppf "@[<2>Authentication Error:@ %s@]" msg
147147+ | `SSLError msg ->
148148+ Format.fprintf ppf "@[<2>SSL/TLS Error:@ %s@]" msg
149149+ | `ProxyError msg ->
150150+ Format.fprintf ppf "@[<2>Proxy Error:@ %s@]" msg
151151+ | `EncodingError msg ->
152152+ Format.fprintf ppf "@[<2>Encoding Error:@ %s@]" msg
153153+ | `InvalidURL msg ->
154154+ Format.fprintf ppf "@[<2>Invalid URL:@ %s@]" msg
155155+ | `InvalidRequest msg ->
156156+ Format.fprintf ppf "@[<2>Invalid Request:@ %s@]" msg
157157+ | `UnknownError msg ->
158158+ Format.fprintf ppf "@[<2>Unknown Error:@ %s@]" msg
159159+160160+let pp_exn ppf exn =
161161+ match of_exn exn with
162162+ | Some err -> pp ppf err
163163+ | None -> Format.fprintf ppf "%s" (Printexc.to_string exn)
164164+165165+let to_string error =
166166+ Format.asprintf "%a" pp error
167167+168168+(** {1 Syntax Module} *)
169169+170170+module Syntax = struct
171171+ let ( let* ) x f = bind f x
172172+ let ( let+ ) x f = map f x
173173+ let ( and* ) = both
174174+end
+127
lib/error.mli
···11+(** Centralized error handling for the Requests library *)
22+33+(** Log source for error reporting *)
44+val src : Logs.Src.t
55+66+(** {1 Exception Types} *)
77+88+(** Raised when a request times out *)
99+exception Timeout
1010+1111+(** Raised when too many redirects are encountered *)
1212+exception TooManyRedirects of { url: string; count: int; max: int }
1313+1414+(** Raised when a connection error occurs *)
1515+exception ConnectionError of string
1616+1717+(** Raised when an HTTP error response is received *)
1818+exception HTTPError of {
1919+ url: string;
2020+ status: int;
2121+ reason: string;
2222+ body: string option;
2323+ headers: Headers.t
2424+}
2525+2626+(** Raised when authentication fails *)
2727+exception AuthenticationError of string
2828+2929+(** Raised when there's an SSL/TLS error *)
3030+exception SSLError of string
3131+3232+(** Raised when proxy connection fails *)
3333+exception ProxyError of string
3434+3535+(** Raised when content encoding/decoding fails *)
3636+exception EncodingError of string
3737+3838+(** Raised when an invalid URL is provided *)
3939+exception InvalidURL of string
4040+4141+(** Raised when request is invalid *)
4242+exception InvalidRequest of string
4343+4444+(** {1 Error Type} *)
4545+4646+(** Unified error type for result-based error handling *)
4747+type t = [
4848+ | `Timeout
4949+ | `TooManyRedirects of string * int * int (* url, count, max *)
5050+ | `ConnectionError of string
5151+ | `HTTPError of string * int * string * string option * Headers.t (* url, status, reason, body, headers *)
5252+ | `AuthenticationError of string
5353+ | `SSLError of string
5454+ | `ProxyError of string
5555+ | `EncodingError of string
5656+ | `InvalidURL of string
5757+ | `InvalidRequest of string
5858+ | `UnknownError of string
5959+]
6060+6161+(** {1 Conversion Functions} *)
6262+6363+(** Convert an exception to an error type *)
6464+val of_exn : exn -> t option
6565+6666+(** Convert an error type to an exception *)
6767+val to_exn : t -> exn
6868+6969+(** Raise an error as an exception *)
7070+val raise : t -> 'a
7171+7272+(** {1 Combinators} *)
7373+7474+(** Wrap a function that may raise exceptions into a result type *)
7575+val catch : (unit -> 'a) -> ('a, t) result
7676+7777+(** Wrap an async function that may raise exceptions *)
7878+val catch_async : (unit -> 'a) -> ('a, t) result
7979+8080+(** Map over the success case of a result *)
8181+val map : ('a -> 'b) -> ('a, t) result -> ('b, t) result
8282+8383+(** Bind for result types with error *)
8484+val bind : ('a -> ('b, t) result) -> ('a, t) result -> ('b, t) result
8585+8686+(** Applicative operator for combining results *)
8787+val both : ('a, t) result -> ('b, t) result -> ('a * 'b, t) result
8888+8989+(** Get value or raise the error *)
9090+val get_exn : ('a, t) result -> 'a
9191+9292+(** Get value or use default *)
9393+val get_or : default:'a -> ('a, t) result -> 'a
9494+9595+(** Check if error is retryable *)
9696+val is_retryable : t -> bool
9797+9898+(** Check if error is a client error (4xx) *)
9999+val is_client_error : t -> bool
100100+101101+(** Check if error is a server error (5xx) *)
102102+val is_server_error : t -> bool
103103+104104+(** {1 Pretty Printing} *)
105105+106106+(** Pretty printer for errors *)
107107+val pp : Format.formatter -> t -> unit
108108+109109+(** Pretty printer for exceptions (falls back to Printexc if not a known exception) *)
110110+val pp_exn : Format.formatter -> exn -> unit
111111+112112+(** Convert error to string *)
113113+val to_string : t -> string
114114+115115+(** {1 Syntax Module} *)
116116+117117+(** Syntax module for let-operators *)
118118+module Syntax : sig
119119+ (** Bind operator for result types *)
120120+ val ( let* ) : ('a, t) result -> ('a -> ('b, t) result) -> ('b, t) result
121121+122122+ (** Map operator for result types *)
123123+ val ( let+ ) : ('a, t) result -> ('a -> 'b) -> ('b, t) result
124124+125125+ (** Both operator for combining results *)
126126+ val ( and* ) : ('a, t) result -> ('b, t) result -> ('a * 'b, t) result
127127+end
+110
lib/headers.ml
···11+let src = Logs.Src.create "requests.headers" ~doc:"HTTP Headers"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+(* Use a map with lowercase keys for case-insensitive lookup *)
55+module StringMap = Map.Make(String)
66+77+type t = (string * string list) StringMap.t
88+99+let empty = StringMap.empty
1010+1111+let normalize_key k = String.lowercase_ascii k
1212+1313+let add key value t =
1414+ let nkey = normalize_key key in
1515+ let existing =
1616+ match StringMap.find_opt nkey t with
1717+ | Some (_, values) -> values
1818+ | None -> []
1919+ in
2020+ StringMap.add nkey (key, value :: existing) t
2121+2222+let set key value t =
2323+ let nkey = normalize_key key in
2424+ StringMap.add nkey (key, [value]) t
2525+2626+let get key t =
2727+ let nkey = normalize_key key in
2828+ match StringMap.find_opt nkey t with
2929+ | Some (_, values) -> List.nth_opt values 0
3030+ | None -> None
3131+3232+let get_all key t =
3333+ let nkey = normalize_key key in
3434+ match StringMap.find_opt nkey t with
3535+ | Some (_, values) -> List.rev values
3636+ | None -> []
3737+3838+let remove key t =
3939+ let nkey = normalize_key key in
4040+ StringMap.remove nkey t
4141+4242+let mem key t =
4343+ let nkey = normalize_key key in
4444+ StringMap.mem nkey t
4545+4646+let of_list lst =
4747+ List.fold_left (fun acc (k, v) -> add k v acc) empty lst
4848+4949+let to_list t =
5050+ StringMap.fold (fun _ (orig_key, values) acc ->
5151+ List.fold_left (fun acc v -> (orig_key, v) :: acc) acc (List.rev values)
5252+ ) t []
5353+5454+let merge t1 t2 =
5555+ StringMap.union (fun _ _ v2 -> Some v2) t1 t2
5656+5757+(* Common header builders *)
5858+5959+let content_type mime t =
6060+ set "Content-Type" (Mime.to_string mime) t
6161+6262+let content_length len t =
6363+ set "Content-Length" (Int64.to_string len) t
6464+6565+let accept mime t =
6666+ set "Accept" (Mime.to_string mime) t
6767+6868+let authorization value t =
6969+ set "Authorization" value t
7070+7171+let bearer token t =
7272+ set "Authorization" (Printf.sprintf "Bearer %s" token) t
7373+7474+let basic ~username ~password t =
7575+ let credentials = Printf.sprintf "%s:%s" username password in
7676+ let encoded = Base64.encode_exn credentials in
7777+ set "Authorization" (Printf.sprintf "Basic %s" encoded) t
7878+7979+let user_agent ua t =
8080+ set "User-Agent" ua t
8181+8282+let host h t =
8383+ set "Host" h t
8484+8585+let cookie name value t =
8686+ add "Cookie" (Printf.sprintf "%s=%s" name value) t
8787+8888+let range ~start ?end_ () t =
8989+ let range_value = match end_ with
9090+ | None -> Printf.sprintf "bytes=%Ld-" start
9191+ | Some e -> Printf.sprintf "bytes=%Ld-%Ld" start e
9292+ in
9393+ set "Range" range_value t
9494+9595+(* Additional helper for getting multiple header values *)
9696+let get_multi key t = get_all key t
9797+9898+(* Pretty printer for headers *)
9999+let pp ppf t =
100100+ Format.fprintf ppf "@[<v>Headers:@,";
101101+ let headers = to_list t in
102102+ List.iter (fun (k, v) ->
103103+ Format.fprintf ppf " %s: %s@," k v
104104+ ) headers;
105105+ Format.fprintf ppf "@]"
106106+107107+let pp_brief ppf t =
108108+ let headers = to_list t in
109109+ let count = List.length headers in
110110+ Format.fprintf ppf "Headers(%d entries)" count
+114
lib/headers.mli
···11+(** HTTP headers management with case-insensitive keys
22+33+ This module provides an efficient implementation of HTTP headers with
44+ case-insensitive header names as per RFC 7230. Headers can have multiple
55+ values for the same key (e.g., multiple Set-Cookie headers).
66+77+ {2 Examples}
88+99+ {[
1010+ let headers =
1111+ Headers.empty
1212+ |> Headers.content_type Mime.json
1313+ |> Headers.bearer "token123"
1414+ |> Headers.set "X-Custom" "value"
1515+ ]}
1616+*)
1717+1818+(** Log source for header operations *)
1919+val src : Logs.Src.t
2020+2121+type t
2222+(** Abstract header collection type. Headers are stored with case-insensitive
2323+ keys and maintain insertion order. *)
2424+2525+(** {1 Creation and Conversion} *)
2626+2727+val empty : t
2828+(** [empty] creates an empty header collection. *)
2929+3030+val of_list : (string * string) list -> t
3131+(** [of_list pairs] creates headers from an association list.
3232+ Later entries override earlier ones for the same key. *)
3333+3434+val to_list : t -> (string * string) list
3535+(** [to_list headers] converts headers to an association list.
3636+ The order of headers is preserved. *)
3737+3838+(** {1 Manipulation} *)
3939+4040+val add : string -> string -> t -> t
4141+(** [add name value headers] adds a header value. Multiple values
4242+ for the same header name are allowed (e.g., for Set-Cookie). *)
4343+4444+val set : string -> string -> t -> t
4545+(** [set name value headers] sets a header value, replacing any
4646+ existing values for that header name. *)
4747+4848+val get : string -> t -> string option
4949+(** [get name headers] returns the first value for a header name,
5050+ or [None] if the header doesn't exist. *)
5151+5252+val get_all : string -> t -> string list
5353+(** [get_all name headers] returns all values for a header name.
5454+ Returns an empty list if the header doesn't exist. *)
5555+5656+val remove : string -> t -> t
5757+(** [remove name headers] removes all values for a header name. *)
5858+5959+val mem : string -> t -> bool
6060+(** [mem name headers] checks if a header name exists. *)
6161+6262+val merge : t -> t -> t
6363+(** [merge base override] merges two header collections.
6464+ Headers from [override] replace those in [base]. *)
6565+6666+(** {1 Common Header Builders}
6767+6868+ Convenience functions for setting common HTTP headers.
6969+*)
7070+7171+val content_type : Mime.t -> t -> t
7272+(** [content_type mime headers] sets the Content-Type header. *)
7373+7474+val content_length : int64 -> t -> t
7575+(** [content_length length headers] sets the Content-Length header. *)
7676+7777+val accept : Mime.t -> t -> t
7878+(** [accept mime headers] sets the Accept header. *)
7979+8080+val authorization : string -> t -> t
8181+(** [authorization value headers] sets the Authorization header with a raw value. *)
8282+8383+val bearer : string -> t -> t
8484+(** [bearer token headers] sets the Authorization header with a Bearer token.
8585+ Example: [bearer "abc123"] sets ["Authorization: Bearer abc123"] *)
8686+8787+val basic : username:string -> password:string -> t -> t
8888+(** [basic ~username ~password headers] sets the Authorization header with
8989+ HTTP Basic authentication (base64-encoded username:password). *)
9090+9191+val user_agent : string -> t -> t
9292+(** [user_agent ua headers] sets the User-Agent header. *)
9393+9494+val host : string -> t -> t
9595+(** [host hostname headers] sets the Host header. *)
9696+9797+val cookie : string -> string -> t -> t
9898+(** [cookie name value headers] adds a cookie to the Cookie header.
9999+ Multiple cookies can be added by calling this function multiple times. *)
100100+101101+val range : start:int64 -> ?end_:int64 -> unit -> t -> t
102102+(** [range ~start ?end_ () headers] sets the Range header for partial content.
103103+ Example: [range ~start:0L ~end_:999L ()] requests the first 1000 bytes. *)
104104+105105+(** {1 Aliases} *)
106106+107107+val get_multi : string -> t -> string list
108108+(** [get_multi] is an alias for {!get_all}. *)
109109+110110+(** Pretty printer for headers *)
111111+val pp : Format.formatter -> t -> unit
112112+113113+(** Brief pretty printer showing count only *)
114114+val pp_brief : Format.formatter -> t -> unit
+165
lib/http_client.ml
···11+(** Low-level HTTP/1.1 client over raw TCP connections for connection pooling *)
22+33+let src = Logs.Src.create "requests.http_client" ~doc:"Low-level HTTP client"
44+module Log = (val Logs.src_log src : Logs.LOG)
55+66+(** Build HTTP/1.1 request as a string *)
77+let build_request ~method_ ~uri ~headers ~body_str =
88+ let path = Uri.path uri in
99+ let path = if path = "" then "/" else path in
1010+ let query = Uri.query uri in
1111+ let path_with_query =
1212+ if query = [] then path
1313+ else path ^ "?" ^ (Uri.encoded_of_query query)
1414+ in
1515+1616+ let host = match Uri.host uri with
1717+ | Some h -> h
1818+ | None -> failwith "URI must have a host"
1919+ in
2020+2121+ let port = match Uri.port uri with
2222+ | Some p -> ":" ^ string_of_int p
2323+ | None ->
2424+ match Uri.scheme uri with
2525+ | Some "https" -> ":443"
2626+ | Some "http" -> ":80"
2727+ | _ -> ""
2828+ in
2929+3030+ (* Build request line *)
3131+ let request_line = Printf.sprintf "%s %s HTTP/1.1\r\n" method_ path_with_query in
3232+3333+ (* Ensure Host header is present *)
3434+ let headers = if not (Headers.mem "host" headers) then
3535+ Headers.add "host" (host ^ port) headers
3636+ else headers in
3737+3838+ (* Ensure Connection header for keep-alive *)
3939+ let headers = if not (Headers.mem "connection" headers) then
4040+ Headers.add "connection" "keep-alive" headers
4141+ else headers in
4242+4343+ (* Add Content-Length if we have a body *)
4444+ let headers =
4545+ if body_str <> "" && not (Headers.mem "content-length" headers) then
4646+ let len = String.length body_str in
4747+ Headers.add "content-length" (string_of_int len) headers
4848+ else headers
4949+ in
5050+5151+ (* Build headers section *)
5252+ let headers_str =
5353+ Headers.to_list headers
5454+ |> List.map (fun (k, v) -> Printf.sprintf "%s: %s\r\n" k v)
5555+ |> String.concat ""
5656+ in
5757+5858+ request_line ^ headers_str ^ "\r\n" ^ body_str
5959+6060+(** Parse HTTP response status line *)
6161+let parse_status_line line =
6262+ match String.split_on_char ' ' line with
6363+ | "HTTP/1.1" :: code :: _ | "HTTP/1.0" :: code :: _ ->
6464+ (try int_of_string code
6565+ with _ -> failwith ("Invalid status code: " ^ code))
6666+ | _ -> failwith ("Invalid status line: " ^ line)
6767+6868+(** Parse HTTP headers from buffer reader *)
6969+let parse_headers buf_read =
7070+ let rec read_headers acc =
7171+ let line = Eio.Buf_read.line buf_read in
7272+ if line = "" then List.rev acc
7373+ else begin
7474+ match String.index_opt line ':' with
7575+ | None -> read_headers acc
7676+ | Some idx ->
7777+ let name = String.sub line 0 idx |> String.trim |> String.lowercase_ascii in
7878+ let value = String.sub line (idx + 1) (String.length line - idx - 1) |> String.trim in
7979+ read_headers ((name, value) :: acc)
8080+ end
8181+ in
8282+ read_headers [] |> Headers.of_list
8383+8484+(** Read body with Content-Length *)
8585+let read_fixed_body buf_read length =
8686+ let buf = Buffer.create (Int64.to_int length) in
8787+ let rec read_n remaining =
8888+ if remaining > 0L then begin
8989+ let to_read = min 8192 (Int64.to_int remaining) in
9090+ let chunk = Eio.Buf_read.take to_read buf_read in
9191+ Buffer.add_string buf chunk;
9292+ read_n (Int64.sub remaining (Int64.of_int (String.length chunk)))
9393+ end
9494+ in
9595+ read_n length;
9696+ Buffer.contents buf
9797+9898+(** Read chunked body *)
9999+let read_chunked_body buf_read =
100100+ let buf = Buffer.create 4096 in
101101+ let rec read_chunks () =
102102+ let size_line = Eio.Buf_read.line buf_read in
103103+ (* Parse hex chunk size, ignore extensions after ';' *)
104104+ let size_str = match String.index_opt size_line ';' with
105105+ | Some idx -> String.sub size_line 0 idx
106106+ | None -> size_line
107107+ in
108108+ let chunk_size = int_of_string ("0x" ^ size_str) in
109109+ if chunk_size = 0 then begin
110110+ (* Read trailing headers (if any) until empty line *)
111111+ let rec skip_trailers () =
112112+ let line = Eio.Buf_read.line buf_read in
113113+ if line <> "" then skip_trailers ()
114114+ in
115115+ skip_trailers ()
116116+ end else begin
117117+ let chunk = Eio.Buf_read.take chunk_size buf_read in
118118+ Buffer.add_string buf chunk;
119119+ let _crlf = Eio.Buf_read.line buf_read in (* Read trailing CRLF *)
120120+ read_chunks ()
121121+ end
122122+ in
123123+ read_chunks ();
124124+ Buffer.contents buf
125125+126126+(** Make HTTP request over a pooled connection *)
127127+let make_request ~method_ ~uri ~headers ~body_str flow =
128128+ Log.debug (fun m -> m "Making %s request to %s" method_ (Uri.to_string uri));
129129+130130+ (* Build and send request *)
131131+ let request_str = build_request ~method_ ~uri ~headers ~body_str in
132132+ Eio.Flow.copy_string request_str flow;
133133+134134+ (* Read and parse response *)
135135+ let buf_read = Eio.Buf_read.of_flow flow ~max_size:max_int in
136136+137137+ (* Parse status line *)
138138+ let status_line = Eio.Buf_read.line buf_read in
139139+ let status = parse_status_line status_line in
140140+141141+ Log.debug (fun m -> m "Received response status: %d" status);
142142+143143+ (* Parse headers *)
144144+ let resp_headers = parse_headers buf_read in
145145+146146+ (* Determine how to read body *)
147147+ let transfer_encoding = Headers.get "transfer-encoding" resp_headers in
148148+ let content_length = Headers.get "content-length" resp_headers |> Option.map Int64.of_string in
149149+150150+ let body_str = match transfer_encoding, content_length with
151151+ | Some te, _ when String.lowercase_ascii te |> String.trim = "chunked" ->
152152+ Log.debug (fun m -> m "Reading chunked response body");
153153+ read_chunked_body buf_read
154154+ | _, Some len ->
155155+ Log.debug (fun m -> m "Reading fixed-length response body (%Ld bytes)" len);
156156+ read_fixed_body buf_read len
157157+ | Some other_te, None ->
158158+ Log.warn (fun m -> m "Unsupported transfer-encoding: %s, assuming no body" other_te);
159159+ ""
160160+ | None, None ->
161161+ Log.debug (fun m -> m "No body indicated");
162162+ ""
163163+ in
164164+165165+ (status, resp_headers, body_str)
···11+(** HTTP methods following RFC 7231 *)
22+33+(** Log source for method operations *)
44+val src : Logs.Src.t
55+66+(** HTTP method type using polymorphic variants for better composability *)
77+type t = [
88+ | `GET (** Retrieve a resource *)
99+ | `POST (** Submit data to be processed *)
1010+ | `PUT (** Replace a resource *)
1111+ | `DELETE (** Delete a resource *)
1212+ | `HEAD (** Retrieve headers only *)
1313+ | `OPTIONS (** Retrieve allowed methods *)
1414+ | `PATCH (** Partial resource modification *)
1515+ | `CONNECT (** Establish tunnel to server *)
1616+ | `TRACE (** Echo received request *)
1717+ | `Other of string (** Non-standard or extension method *)
1818+]
1919+2020+(** {1 Conversion Functions} *)
2121+2222+val to_string : t -> string
2323+(** Convert method to uppercase string representation *)
2424+2525+val of_string : string -> t
2626+(** Parse method from string (case-insensitive).
2727+ Returns [`Other s] for unrecognized methods. *)
2828+2929+val pp : Format.formatter -> t -> unit
3030+(** Pretty printer for methods *)
3131+3232+(** {1 Method Properties} *)
3333+3434+val is_safe : t -> bool
3535+(** Returns true for safe methods (GET, HEAD, OPTIONS, TRACE).
3636+ Safe methods should not have side effects. *)
3737+3838+val is_idempotent : t -> bool
3939+(** Returns true for idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE).
4040+ Idempotent methods can be called multiple times with the same result. *)
4141+4242+val has_request_body : t -> bool
4343+(** Returns true for methods that typically have a request body (POST, PUT, PATCH) *)
4444+4545+val is_cacheable : t -> bool
4646+(** Returns true for methods whose responses are cacheable by default (GET, HEAD, POST).
4747+ Note: POST is only cacheable with explicit cache headers. *)
4848+4949+(** {1 Comparison} *)
5050+5151+val equal : t -> t -> bool
5252+(** Compare two methods for equality *)
5353+5454+val compare : t -> t -> int
5555+(** Compare two methods for ordering *)
+77
lib/mime.ml
···11+let src = Logs.Src.create "requests.mime" ~doc:"MIME Type Handling"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+type t = {
55+ type_ : string;
66+ subtype : string;
77+ parameters : (string * string) list;
88+}
99+1010+let make type_ subtype = {
1111+ type_;
1212+ subtype;
1313+ parameters = [];
1414+}
1515+1616+let of_string s =
1717+ let parts = String.split_on_char ';' s in
1818+ match parts with
1919+ | [] -> make "text" "plain"
2020+ | mime :: params ->
2121+ let mime_parts = String.split_on_char '/' (String.trim mime) in
2222+ let type_, subtype = match mime_parts with
2323+ | [t; s] -> String.trim t, String.trim s
2424+ | [t] -> String.trim t, "*"
2525+ | _ -> "text", "plain"
2626+ in
2727+ let parse_param p =
2828+ match String.split_on_char '=' (String.trim p) with
2929+ | [k; v] ->
3030+ let k = String.trim k in
3131+ let v = String.trim v in
3232+ let v =
3333+ if String.length v >= 2 && v.[0] = '"' && v.[String.length v - 1] = '"'
3434+ then String.sub v 1 (String.length v - 2)
3535+ else v
3636+ in
3737+ Some (String.lowercase_ascii k, v)
3838+ | _ -> None
3939+ in
4040+ let parameters = List.filter_map parse_param params in
4141+ { type_; subtype; parameters }
4242+4343+let to_string t =
4444+ let base = Printf.sprintf "%s/%s" t.type_ t.subtype in
4545+ match t.parameters with
4646+ | [] -> base
4747+ | params ->
4848+ let param_str =
4949+ List.map (fun (k, v) ->
5050+ if String.contains v ' ' || String.contains v ';'
5151+ then Printf.sprintf "%s=\"%s\"" k v
5252+ else Printf.sprintf "%s=%s" k v
5353+ ) params
5454+ |> String.concat "; "
5555+ in
5656+ Printf.sprintf "%s; %s" base param_str
5757+5858+let pp ppf t = Format.fprintf ppf "%s" (to_string t)
5959+6060+let charset t =
6161+ List.assoc_opt "charset" t.parameters
6262+6363+let with_charset charset t =
6464+ let parameters =
6565+ ("charset", charset) ::
6666+ List.filter (fun (k, _) -> k <> "charset") t.parameters
6767+ in
6868+ { t with parameters }
6969+7070+(* Common MIME types *)
7171+let json = make "application" "json"
7272+let text = make "text" "plain"
7373+let html = make "text" "html"
7474+let xml = make "application" "xml"
7575+let form = make "application" "x-www-form-urlencoded"
7676+let octet_stream = make "application" "octet-stream"
7777+let multipart_form = make "multipart" "form-data"
+34
lib/mime.mli
···11+(** MIME type handling *)
22+33+(** Log source for MIME type operations *)
44+val src : Logs.Src.t
55+66+type t
77+(** Abstract MIME type *)
88+99+val of_string : string -> t
1010+(** Parse MIME type from string (e.g., "text/html; charset=utf-8") *)
1111+1212+val to_string : t -> string
1313+(** Convert MIME type to string representation *)
1414+1515+val pp : Format.formatter -> t -> unit
1616+(** Pretty printer for MIME types *)
1717+1818+(** Common MIME types *)
1919+val json : t
2020+val text : t
2121+val html : t
2222+val xml : t
2323+val form : t
2424+val octet_stream : t
2525+val multipart_form : t
2626+2727+val make : string -> string -> t
2828+(** [make type subtype] creates a MIME type *)
2929+3030+val with_charset : string -> t -> t
3131+(** Add or update charset parameter *)
3232+3333+val charset : t -> string option
3434+(** Extract charset parameter if present *)
+257
lib/one.ml
···11+let src = Logs.Src.create "requests.one" ~doc:"One-shot HTTP Requests"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+(* Helper to create TCP connection to host:port *)
55+let connect_tcp ~sw ~net ~host ~port =
66+ Log.debug (fun m -> m "Connecting to %s:%d" host port);
77+ (* Resolve hostname to IP address *)
88+ let addrs = Eio.Net.getaddrinfo_stream net host ~service:(string_of_int port) in
99+ match addrs with
1010+ | addr :: _ ->
1111+ Log.debug (fun m -> m "Resolved %s, connecting..." host);
1212+ Eio.Net.connect ~sw net addr
1313+ | [] ->
1414+ let msg = Printf.sprintf "Failed to resolve hostname: %s" host in
1515+ Log.err (fun m -> m "%s" msg);
1616+ failwith msg
1717+1818+(* Helper to wrap connection with TLS if needed *)
1919+let wrap_tls flow ~host ~verify_tls ~tls_config =
2020+ Log.debug (fun m -> m "Wrapping connection with TLS for %s (verify=%b)" host verify_tls);
2121+2222+ (* Get or create TLS config *)
2323+ let tls_cfg = match tls_config, verify_tls with
2424+ | Some cfg, _ -> cfg
2525+ | None, true ->
2626+ (* Use CA certificates for verification *)
2727+ (match Ca_certs.authenticator () with
2828+ | Ok authenticator ->
2929+ (match Tls.Config.client ~authenticator () with
3030+ | Ok cfg -> cfg
3131+ | Error (`Msg msg) ->
3232+ Log.err (fun m -> m "Failed to create TLS config: %s" msg);
3333+ failwith ("TLS config error: " ^ msg))
3434+ | Error (`Msg msg) ->
3535+ Log.err (fun m -> m "Failed to load CA certificates: %s" msg);
3636+ failwith ("CA certificates error: " ^ msg))
3737+ | None, false ->
3838+ (* No verification *)
3939+ match Tls.Config.client ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None) () with
4040+ | Ok cfg -> cfg
4141+ | Error (`Msg msg) -> failwith ("TLS config error: " ^ msg)
4242+ in
4343+4444+ (* Get domain name for SNI *)
4545+ let domain = match Domain_name.of_string host with
4646+ | Ok dn -> (match Domain_name.host dn with
4747+ | Ok d -> d
4848+ | Error (`Msg msg) ->
4949+ Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg);
5050+ failwith ("Invalid hostname: " ^ msg))
5151+ | Error (`Msg msg) ->
5252+ Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg);
5353+ failwith ("Invalid hostname: " ^ msg)
5454+ in
5555+5656+ (Tls_eio.client_of_flow ~host:domain tls_cfg flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
5757+5858+(* Parse URL and connect directly (no pooling) *)
5959+let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config =
6060+ let uri = Uri.of_string url in
6161+6262+ (* Extract host and port *)
6363+ let host = match Uri.host uri with
6464+ | Some h -> h
6565+ | None -> failwith ("URL must contain a host: " ^ url)
6666+ in
6767+6868+ let is_https = Uri.scheme uri = Some "https" in
6969+ let default_port = if is_https then 443 else 80 in
7070+ let port = Option.value (Uri.port uri) ~default:default_port in
7171+7272+ (* Apply connection timeout if specified *)
7373+ let connect_fn () =
7474+ let tcp_flow = connect_tcp ~sw ~net ~host ~port in
7575+ if is_https then
7676+ wrap_tls tcp_flow ~host ~verify_tls ~tls_config
7777+ else
7878+ (tcp_flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
7979+ in
8080+8181+ match timeout with
8282+ | Some t ->
8383+ let timeout_seconds = Timeout.total t in
8484+ (match timeout_seconds with
8585+ | Some seconds ->
8686+ Log.debug (fun m -> m "Setting connection timeout: %.2f seconds" seconds);
8787+ Eio.Time.with_timeout_exn clock seconds connect_fn
8888+ | None -> connect_fn ())
8989+ | None -> connect_fn ()
9090+9191+(* Main request implementation - completely stateless *)
9292+let request ~sw ~clock ~net ?headers ?body ?auth ?timeout
9393+ ?(follow_redirects = true) ?(max_redirects = 10)
9494+ ?(verify_tls = true) ?tls_config ~method_ url =
9595+9696+ let start_time = Unix.gettimeofday () in
9797+ let method_str = Method.to_string method_ in
9898+ Log.debug (fun m -> m "[One] Executing %s request to %s" method_str url);
9999+100100+ (* Prepare headers *)
101101+ let headers = Option.value headers ~default:Headers.empty in
102102+103103+ (* Apply auth *)
104104+ let headers = match auth with
105105+ | Some a ->
106106+ Log.debug (fun m -> m "Applying authentication");
107107+ Auth.apply a headers
108108+ | None -> headers
109109+ in
110110+111111+ (* Add content type from body *)
112112+ let headers = match body with
113113+ | Some b -> (match Body.content_type b with
114114+ | Some mime -> Headers.content_type mime headers
115115+ | None -> headers)
116116+ | None -> headers
117117+ in
118118+119119+ (* Convert body to string for sending *)
120120+ let request_body_str = match body with
121121+ | None -> ""
122122+ | Some b -> Body.Private.to_string b
123123+ in
124124+125125+ (* Execute request with redirects *)
126126+ let rec make_with_redirects url_to_fetch redirects_left =
127127+ let uri_to_fetch = Uri.of_string url_to_fetch in
128128+129129+ (* Connect to URL (opens new TCP connection) *)
130130+ let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
131131+ ~timeout ~verify_tls ~tls_config in
132132+133133+ (* Make HTTP request using low-level client *)
134134+ let status, resp_headers, response_body_str =
135135+ Http_client.make_request ~method_:method_str ~uri:uri_to_fetch
136136+ ~headers ~body_str:request_body_str flow
137137+ in
138138+139139+ Log.info (fun m -> m "Received response: status=%d" status);
140140+141141+ (* Handle redirects if enabled *)
142142+ if follow_redirects && (status >= 300 && status < 400) then begin
143143+ if redirects_left <= 0 then begin
144144+ Log.err (fun m -> m "Too many redirects (%d) for %s" max_redirects url);
145145+ raise (Error.TooManyRedirects { url; count = max_redirects; max = max_redirects })
146146+ end;
147147+148148+ match Headers.get "location" resp_headers with
149149+ | None ->
150150+ Log.debug (fun m -> m "Redirect response missing Location header");
151151+ (status, resp_headers, response_body_str, url_to_fetch)
152152+ | Some location ->
153153+ Log.info (fun m -> m "Following redirect to %s (%d remaining)" location redirects_left);
154154+ make_with_redirects location (redirects_left - 1)
155155+ end else
156156+ (status, resp_headers, response_body_str, url_to_fetch)
157157+ in
158158+159159+ let final_status, final_headers, final_body_str, final_url =
160160+ make_with_redirects url max_redirects
161161+ in
162162+163163+ let elapsed = Unix.gettimeofday () -. start_time in
164164+ Log.info (fun m -> m "Request completed in %.3f seconds" elapsed);
165165+166166+ (* Create a flow from the body string *)
167167+ let body_flow = Eio.Flow.string_source final_body_str in
168168+169169+ Response.Private.make
170170+ ~sw
171171+ ~status:final_status
172172+ ~headers:final_headers
173173+ ~body:body_flow
174174+ ~url:final_url
175175+ ~elapsed
176176+177177+(* Convenience methods *)
178178+let get ~sw ~clock ~net ?headers ?auth ?timeout
179179+ ?follow_redirects ?max_redirects ?verify_tls ?tls_config url =
180180+ request ~sw ~clock ~net ?headers ?auth ?timeout
181181+ ?follow_redirects ?max_redirects ?verify_tls ?tls_config
182182+ ~method_:`GET url
183183+184184+let post ~sw ~clock ~net ?headers ?body ?auth ?timeout
185185+ ?verify_tls ?tls_config url =
186186+ request ~sw ~clock ~net ?headers ?body ?auth ?timeout
187187+ ?verify_tls ?tls_config ~method_:`POST url
188188+189189+let put ~sw ~clock ~net ?headers ?body ?auth ?timeout
190190+ ?verify_tls ?tls_config url =
191191+ request ~sw ~clock ~net ?headers ?body ?auth ?timeout
192192+ ?verify_tls ?tls_config ~method_:`PUT url
193193+194194+let delete ~sw ~clock ~net ?headers ?auth ?timeout
195195+ ?verify_tls ?tls_config url =
196196+ request ~sw ~clock ~net ?headers ?auth ?timeout
197197+ ?verify_tls ?tls_config ~method_:`DELETE url
198198+199199+let head ~sw ~clock ~net ?headers ?auth ?timeout
200200+ ?verify_tls ?tls_config url =
201201+ request ~sw ~clock ~net ?headers ?auth ?timeout
202202+ ?verify_tls ?tls_config ~method_:`HEAD url
203203+204204+let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout
205205+ ?verify_tls ?tls_config url =
206206+ request ~sw ~clock ~net ?headers ?body ?auth ?timeout
207207+ ?verify_tls ?tls_config ~method_:`PATCH url
208208+209209+let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length
210210+ ?on_progress ?verify_tls ?tls_config ~source url =
211211+ let method_ = Option.value method_ ~default:`POST in
212212+ let mime = Option.value mime ~default:Mime.octet_stream in
213213+214214+ (* Wrap source with progress tracking if callback provided *)
215215+ let tracked_source = match on_progress with
216216+ | None -> source
217217+ | Some callback ->
218218+ (* For now, progress tracking is not implemented for uploads
219219+ due to complexity of wrapping Eio.Flow.source.
220220+ This would require creating a custom flow wrapper. *)
221221+ let _ = callback in
222222+ source
223223+ in
224224+225225+ let body = Body.of_stream ?length mime tracked_source in
226226+ request ~sw ~clock ~net ?headers ~body ?auth ?timeout
227227+ ?verify_tls ?tls_config ~method_ url
228228+229229+let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress
230230+ ?verify_tls ?tls_config url ~sink =
231231+ let response = get ~sw ~clock ~net ?headers ?auth ?timeout
232232+ ?verify_tls ?tls_config url in
233233+234234+ try
235235+ (* Get content length for progress tracking *)
236236+ let total = Response.content_length response in
237237+238238+ let body = Response.body response in
239239+240240+ (* Stream data to sink with optional progress *)
241241+ match on_progress with
242242+ | None ->
243243+ (* No progress tracking, just copy directly *)
244244+ Eio.Flow.copy body sink
245245+ | Some progress_fn ->
246246+ (* Copy with progress tracking *)
247247+ (* We need to intercept the flow to track bytes *)
248248+ (* For now, just do a simple copy - proper progress tracking needs flow wrapper *)
249249+ progress_fn ~received:0L ~total;
250250+ Eio.Flow.copy body sink;
251251+ progress_fn ~received:(Option.value total ~default:0L) ~total;
252252+253253+ (* Response auto-closes with switch *)
254254+ ()
255255+ with e ->
256256+ (* Response auto-closes with switch *)
257257+ raise e
+200
lib/one.mli
···11+(** One-shot HTTP client for stateless requests
22+33+ The One module provides a stateless HTTP client for single requests without
44+ session state like cookies, connection pooling, or persistent configuration.
55+ Each request opens a new connection that is closed after use.
66+77+ For stateful requests with automatic cookie handling, connection pooling,
88+ and persistent configuration, use the main {!Requests} module instead.
99+1010+ {2 Examples}
1111+1212+ {[
1313+ open Eio_main
1414+1515+ let () = run @@ fun env ->
1616+ Switch.run @@ fun sw ->
1717+1818+ (* Simple GET request *)
1919+ let response = One.get ~sw
2020+ ~clock:env#clock ~net:env#net
2121+ "https://example.com" in
2222+ Printf.printf "Status: %d\n" (Response.status_code response);
2323+ Response.close response;
2424+2525+ (* POST with JSON body *)
2626+ let response = One.post ~sw
2727+ ~clock:env#clock ~net:env#net
2828+ ~body:(Body.json {|{"key": "value"}|})
2929+ ~headers:(Headers.empty |> Headers.content_type Mime.json)
3030+ "https://api.example.com/data" in
3131+ Response.close response;
3232+3333+ (* Download file with streaming *)
3434+ One.download ~sw
3535+ ~clock:env#clock ~net:env#net
3636+ "https://example.com/large-file.zip"
3737+ ~sink:(Eio.Path.(fs / "download.zip" |> sink))
3838+ ]}
3939+*)
4040+4141+(** Log source for one-shot request operations *)
4242+val src : Logs.Src.t
4343+4444+(** {1 HTTP Request Methods}
4545+4646+ All functions are stateless - they open a new TCP connection for each request
4747+ and close it when the switch closes. No connection pooling or reuse. *)
4848+4949+val request :
5050+ sw:Eio.Switch.t ->
5151+ clock:_ Eio.Time.clock ->
5252+ net:_ Eio.Net.t ->
5353+ ?headers:Headers.t ->
5454+ ?body:Body.t ->
5555+ ?auth:Auth.t ->
5656+ ?timeout:Timeout.t ->
5757+ ?follow_redirects:bool ->
5858+ ?max_redirects:int ->
5959+ ?verify_tls:bool ->
6060+ ?tls_config:Tls.Config.client ->
6161+ method_:Method.t ->
6262+ string ->
6363+ Response.t
6464+(** [request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?follow_redirects
6565+ ?max_redirects ?verify_tls ?tls_config ~method_ url] makes a single HTTP
6666+ request without connection pooling.
6767+6868+ Each call opens a new TCP connection (with TLS if https://), makes the
6969+ request, and closes the connection when the switch closes.
7070+7171+ @param sw Switch for resource management (response/connection bound to this)
7272+ @param clock Clock for timeouts
7373+ @param net Network interface for TCP connections
7474+ @param headers Request headers (default: empty)
7575+ @param body Request body (default: none)
7676+ @param auth Authentication to apply (default: none)
7777+ @param timeout Request timeout (default: 30s connect, 60s read)
7878+ @param follow_redirects Whether to follow HTTP redirects (default: true)
7979+ @param max_redirects Maximum redirects to follow (default: 10)
8080+ @param verify_tls Whether to verify TLS certificates (default: true)
8181+ @param tls_config Custom TLS configuration (default: system CA certs)
8282+ @param method_ HTTP method (GET, POST, etc.)
8383+ @param url URL to request
8484+*)
8585+8686+val get :
8787+ sw:Eio.Switch.t ->
8888+ clock:_ Eio.Time.clock ->
8989+ net:_ Eio.Net.t ->
9090+ ?headers:Headers.t ->
9191+ ?auth:Auth.t ->
9292+ ?timeout:Timeout.t ->
9393+ ?follow_redirects:bool ->
9494+ ?max_redirects:int ->
9595+ ?verify_tls:bool ->
9696+ ?tls_config:Tls.Config.client ->
9797+ string ->
9898+ Response.t
9999+(** GET request. See {!request} for parameter details. *)
100100+101101+val post :
102102+ sw:Eio.Switch.t ->
103103+ clock:_ Eio.Time.clock ->
104104+ net:_ Eio.Net.t ->
105105+ ?headers:Headers.t ->
106106+ ?body:Body.t ->
107107+ ?auth:Auth.t ->
108108+ ?timeout:Timeout.t ->
109109+ ?verify_tls:bool ->
110110+ ?tls_config:Tls.Config.client ->
111111+ string ->
112112+ Response.t
113113+(** POST request. See {!request} for parameter details. *)
114114+115115+val put :
116116+ sw:Eio.Switch.t ->
117117+ clock:_ Eio.Time.clock ->
118118+ net:_ Eio.Net.t ->
119119+ ?headers:Headers.t ->
120120+ ?body:Body.t ->
121121+ ?auth:Auth.t ->
122122+ ?timeout:Timeout.t ->
123123+ ?verify_tls:bool ->
124124+ ?tls_config:Tls.Config.client ->
125125+ string ->
126126+ Response.t
127127+(** PUT request. See {!request} for parameter details. *)
128128+129129+val delete :
130130+ sw:Eio.Switch.t ->
131131+ clock:_ Eio.Time.clock ->
132132+ net:_ Eio.Net.t ->
133133+ ?headers:Headers.t ->
134134+ ?auth:Auth.t ->
135135+ ?timeout:Timeout.t ->
136136+ ?verify_tls:bool ->
137137+ ?tls_config:Tls.Config.client ->
138138+ string ->
139139+ Response.t
140140+(** DELETE request. See {!request} for parameter details. *)
141141+142142+val head :
143143+ sw:Eio.Switch.t ->
144144+ clock:_ Eio.Time.clock ->
145145+ net:_ Eio.Net.t ->
146146+ ?headers:Headers.t ->
147147+ ?auth:Auth.t ->
148148+ ?timeout:Timeout.t ->
149149+ ?verify_tls:bool ->
150150+ ?tls_config:Tls.Config.client ->
151151+ string ->
152152+ Response.t
153153+(** HEAD request. See {!request} for parameter details. *)
154154+155155+val patch :
156156+ sw:Eio.Switch.t ->
157157+ clock:_ Eio.Time.clock ->
158158+ net:_ Eio.Net.t ->
159159+ ?headers:Headers.t ->
160160+ ?body:Body.t ->
161161+ ?auth:Auth.t ->
162162+ ?timeout:Timeout.t ->
163163+ ?verify_tls:bool ->
164164+ ?tls_config:Tls.Config.client ->
165165+ string ->
166166+ Response.t
167167+(** PATCH request. See {!request} for parameter details. *)
168168+169169+val upload :
170170+ sw:Eio.Switch.t ->
171171+ clock:_ Eio.Time.clock ->
172172+ net:_ Eio.Net.t ->
173173+ ?headers:Headers.t ->
174174+ ?auth:Auth.t ->
175175+ ?timeout:Timeout.t ->
176176+ ?method_:Method.t ->
177177+ ?mime:Mime.t ->
178178+ ?length:int64 ->
179179+ ?on_progress:(sent:int64 -> total:int64 option -> unit) ->
180180+ ?verify_tls:bool ->
181181+ ?tls_config:Tls.Config.client ->
182182+ source:Eio.Flow.source_ty Eio.Resource.t ->
183183+ string ->
184184+ Response.t
185185+(** Upload from stream. See {!request} for parameter details. *)
186186+187187+val download :
188188+ sw:Eio.Switch.t ->
189189+ clock:_ Eio.Time.clock ->
190190+ net:_ Eio.Net.t ->
191191+ ?headers:Headers.t ->
192192+ ?auth:Auth.t ->
193193+ ?timeout:Timeout.t ->
194194+ ?on_progress:(received:int64 -> total:int64 option -> unit) ->
195195+ ?verify_tls:bool ->
196196+ ?tls_config:Tls.Config.client ->
197197+ string ->
198198+ sink:Eio.Flow.sink_ty Eio.Resource.t ->
199199+ unit
200200+(** Download to stream. See {!request} for parameter details. *)
+691
lib/requests.ml
···11+(** OCaml HTTP client library with streaming support *)
22+33+let src = Logs.Src.create "requests" ~doc:"HTTP Client Library"
44+module Log = (val Logs.src_log src : Logs.LOG)
55+66+module Method = Method
77+module Mime = Mime
88+module Headers = Headers
99+module Auth = Auth
1010+module Timeout = Timeout
1111+module Body = Body
1212+module Response = Response
1313+module One = One
1414+module Http_client = Http_client
1515+module Status = Status
1616+module Error = Error
1717+module Retry = Retry
1818+1919+(* Note: RNG initialization should be done by the application using
2020+ Mirage_crypto_rng_unix.initialize before calling Eio_main.run.
2121+ We don't call use_default() here as it spawns background threads
2222+ that are incompatible with Eio's structured concurrency. *)
2323+2424+(* Main API - Session functionality with connection pooling *)
2525+2626+type ('clock, 'net) t = {
2727+ sw : Eio.Switch.t;
2828+ clock : 'clock;
2929+ net : 'net;
3030+ http_pool : ('clock, 'net) Conpool.t;
3131+ https_pool : ('clock, 'net) Conpool.t;
3232+ cookie_jar : Cookeio_jar.t;
3333+ cookie_mutex : Eio.Mutex.t;
3434+ default_headers : Headers.t;
3535+ auth : Auth.t option;
3636+ timeout : Timeout.t;
3737+ follow_redirects : bool;
3838+ max_redirects : int;
3939+ verify_tls : bool;
4040+ tls_config : Tls.Config.client option;
4141+ retry : Retry.config option;
4242+ persist_cookies : bool;
4343+ xdg : Xdge.t option;
4444+4545+ (* Statistics - mutable for tracking across all derived sessions *)
4646+ mutable requests_made : int;
4747+ mutable total_time : float;
4848+ mutable retries_count : int;
4949+}
5050+5151+let create
5252+ ~sw
5353+ ?http_pool
5454+ ?https_pool
5555+ ?cookie_jar
5656+ ?(default_headers = Headers.empty)
5757+ ?auth
5858+ ?(timeout = Timeout.default)
5959+ ?(follow_redirects = true)
6060+ ?(max_redirects = 10)
6161+ ?(verify_tls = true)
6262+ ?tls_config
6363+ ?(max_connections_per_host = 10)
6464+ ?(connection_idle_timeout = 60.0)
6565+ ?(connection_lifetime = 300.0)
6666+ ?retry
6767+ ?(persist_cookies = false)
6868+ ?xdg
6969+ env =
7070+7171+ let clock = env#clock in
7272+ let net = env#net in
7373+7474+ let xdg = match xdg, persist_cookies with
7575+ | Some x, _ -> Some x
7676+ | None, true -> Some (Xdge.create env#fs "requests")
7777+ | None, false -> None
7878+ in
7979+8080+ (* Create TLS config for HTTPS pool if needed *)
8181+ let tls_config = match tls_config, verify_tls with
8282+ | Some cfg, _ -> Some cfg
8383+ | None, true ->
8484+ (* Use CA certificates for verification *)
8585+ (match Ca_certs.authenticator () with
8686+ | Ok authenticator ->
8787+ (match Tls.Config.client ~authenticator () with
8888+ | Ok cfg -> Some cfg
8989+ | Error (`Msg msg) ->
9090+ Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
9191+ None)
9292+ | Error (`Msg msg) ->
9393+ Log.warn (fun m -> m "Failed to load CA certificates: %s" msg);
9494+ None)
9595+ | None, false -> None
9696+ in
9797+9898+ (* Create connection pools if not provided *)
9999+ let pool_config = Conpool.Config.make
100100+ ~max_connections_per_endpoint:max_connections_per_host
101101+ ~max_idle_time:connection_idle_timeout
102102+ ~max_connection_lifetime:connection_lifetime
103103+ ()
104104+ in
105105+106106+ (* HTTP pool - plain TCP connections *)
107107+ let http_pool = match http_pool with
108108+ | Some p -> p
109109+ | None ->
110110+ Conpool.create ~sw ~net ~clock ~config:pool_config ()
111111+ in
112112+113113+ (* HTTPS pool - TLS-wrapped connections *)
114114+ let https_pool = match https_pool with
115115+ | Some p -> p
116116+ | None ->
117117+ let https_tls_config = Option.map (fun cfg ->
118118+ Conpool.Tls_config.make ~config:cfg ()
119119+ ) tls_config in
120120+ Conpool.create ~sw ~net ~clock ?tls:https_tls_config ~config:pool_config ()
121121+ in
122122+123123+ Log.info (fun m -> m "Created Requests session with connection pools (max_per_host=%d, TLS=%b)"
124124+ max_connections_per_host (Option.is_some tls_config));
125125+126126+ let cookie_jar = match cookie_jar, persist_cookies, xdg with
127127+ | Some jar, _, _ -> jar
128128+ | None, true, Some xdg_ctx ->
129129+ let data_dir = Xdge.data_dir xdg_ctx in
130130+ let cookie_file = Eio.Path.(data_dir / "cookies.txt") in
131131+ Cookeio_jar.load ~clock cookie_file
132132+ | None, _, _ ->
133133+ Cookeio_jar.create ()
134134+ in
135135+136136+ {
137137+ sw;
138138+ clock;
139139+ net;
140140+ http_pool;
141141+ https_pool;
142142+ cookie_jar;
143143+ cookie_mutex = Eio.Mutex.create ();
144144+ default_headers;
145145+ auth;
146146+ timeout;
147147+ follow_redirects;
148148+ max_redirects;
149149+ verify_tls;
150150+ tls_config;
151151+ retry;
152152+ persist_cookies;
153153+ xdg;
154154+ requests_made = 0;
155155+ total_time = 0.0;
156156+ retries_count = 0;
157157+ }
158158+159159+let set_default_header t key value =
160160+ { t with default_headers = Headers.set key value t.default_headers }
161161+162162+let remove_default_header t key =
163163+ { t with default_headers = Headers.remove key t.default_headers }
164164+165165+let set_auth t auth =
166166+ Log.debug (fun m -> m "Setting authentication method");
167167+ { t with auth = Some auth }
168168+169169+let clear_auth t =
170170+ Log.debug (fun m -> m "Clearing authentication");
171171+ { t with auth = None }
172172+173173+let set_timeout t timeout =
174174+ Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout);
175175+ { t with timeout }
176176+177177+let set_retry t config =
178178+ Log.debug (fun m -> m "Setting retry config: max_retries=%d" config.Retry.max_retries);
179179+ { t with retry = Some config }
180180+181181+let cookies t = t.cookie_jar
182182+let clear_cookies t = Cookeio_jar.clear t.cookie_jar
183183+184184+(* Internal request function using connection pools *)
185185+let make_request_internal t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
186186+ let start_time = Unix.gettimeofday () in
187187+ let method_str = Method.to_string method_ in
188188+189189+ Log.info (fun m -> m "Making %s request to %s" method_str url);
190190+191191+ (* Merge headers *)
192192+ let headers = match headers with
193193+ | Some h -> Headers.merge t.default_headers h
194194+ | None -> t.default_headers
195195+ in
196196+197197+ (* Use provided auth or default *)
198198+ let auth = match auth with
199199+ | Some a -> Some a
200200+ | None -> t.auth
201201+ in
202202+203203+ (* Apply auth *)
204204+ let headers = match auth with
205205+ | Some a ->
206206+ Log.debug (fun m -> m "Applying authentication");
207207+ Auth.apply a headers
208208+ | None -> headers
209209+ in
210210+211211+ (* Add content type from body *)
212212+ let base_headers = match body with
213213+ | Some b -> (match Body.content_type b with
214214+ | Some mime -> Headers.content_type mime headers
215215+ | None -> headers)
216216+ | None -> headers
217217+ in
218218+219219+ (* Convert body to string for sending *)
220220+ let request_body_str = match body with
221221+ | None -> ""
222222+ | Some b -> Body.Private.to_string b
223223+ in
224224+225225+ (* Helper to extract and store cookies from response headers *)
226226+ let extract_cookies_from_headers resp_headers url_str =
227227+ let uri = Uri.of_string url_str in
228228+ let cookie_domain = Uri.host uri |> Option.value ~default:"" in
229229+ let cookie_path = Uri.path uri in
230230+ Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () ->
231231+ match Headers.get_all "Set-Cookie" resp_headers with
232232+ | [] -> ()
233233+ | cookie_headers ->
234234+ Log.debug (fun m -> m "Received %d Set-Cookie headers" (List.length cookie_headers));
235235+ List.iter (fun cookie_str ->
236236+ let now = fun () -> Ptime.of_float_s (Eio.Time.now t.clock) |> Option.get in
237237+ match Cookeio.of_set_cookie_header ~now ~domain:cookie_domain ~path:cookie_path cookie_str with
238238+ | Some cookie ->
239239+ Log.debug (fun m -> m "Storing cookie: %s" (Cookeio.name cookie));
240240+ Cookeio_jar.add_cookie t.cookie_jar cookie
241241+ | None ->
242242+ Log.warn (fun m -> m "Failed to parse cookie: %s" cookie_str)
243243+ ) cookie_headers
244244+ )
245245+ in
246246+247247+ let response =
248248+249249+ (* Execute request with redirect handling *)
250250+ let rec make_with_redirects url_to_fetch redirects_left =
251251+ let uri_to_fetch = Uri.of_string url_to_fetch in
252252+253253+ (* Parse the redirect URL to get correct host and port *)
254254+ let redirect_host = match Uri.host uri_to_fetch with
255255+ | Some h -> h
256256+ | None -> failwith "Redirect URL must contain a host"
257257+ in
258258+ let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with
259259+ | Some "https", None -> 443
260260+ | Some "https", Some p -> p
261261+ | Some "http", None -> 80
262262+ | Some "http", Some p -> p
263263+ | _, Some p -> p
264264+ | _ -> 80
265265+ in
266266+267267+ (* Create endpoint for this specific URL *)
268268+ let redirect_endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in
269269+270270+ (* Determine if we need TLS based on this URL's scheme *)
271271+ let redirect_is_https = match Uri.scheme uri_to_fetch with
272272+ | Some "https" -> true
273273+ | _ -> false
274274+ in
275275+276276+ (* Choose the appropriate connection pool for this URL *)
277277+ let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in
278278+279279+ (* Get cookies for this specific URL *)
280280+ let fetch_domain = redirect_host in
281281+ let fetch_path = Uri.path uri_to_fetch in
282282+ let fetch_is_secure = redirect_is_https in
283283+ let headers_with_cookies =
284284+ Eio.Mutex.use_ro t.cookie_mutex (fun () ->
285285+ let cookies = Cookeio_jar.get_cookies t.cookie_jar ~clock:t.clock
286286+ ~domain:fetch_domain ~path:fetch_path ~is_secure:fetch_is_secure in
287287+ match cookies with
288288+ | [] ->
289289+ Log.debug (fun m -> m "No cookies found for %s%s" fetch_domain fetch_path);
290290+ base_headers
291291+ | cookies ->
292292+ let cookie_header = Cookeio.make_cookie_header cookies in
293293+ Log.debug (fun m -> m "Adding %d cookies for %s%s: Cookie: %s"
294294+ (List.length cookies) fetch_domain fetch_path cookie_header);
295295+ Headers.set "Cookie" cookie_header base_headers
296296+ )
297297+ in
298298+299299+ (* Log the request being made at Info level *)
300300+ Log.info (fun m -> m "");
301301+ Log.info (fun m -> m "=== Request to %s ===" url_to_fetch);
302302+ Log.info (fun m -> m "> %s %s HTTP/1.1" method_str (Uri.to_string uri_to_fetch));
303303+ Log.info (fun m -> m "> Request Headers:");
304304+ Headers.to_list headers_with_cookies |> List.iter (fun (k, v) ->
305305+ Log.info (fun m -> m "> %s: %s" k v)
306306+ );
307307+ Log.info (fun m -> m "");
308308+309309+ let make_request_fn () =
310310+ Conpool.with_connection redirect_pool redirect_endpoint (fun flow ->
311311+ (* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *)
312312+ (* Use our low-level HTTP client *)
313313+ Http_client.make_request ~method_:method_str ~uri:uri_to_fetch
314314+ ~headers:headers_with_cookies ~body_str:request_body_str flow
315315+ )
316316+ in
317317+318318+ (* Apply timeout if specified *)
319319+ let status, resp_headers, response_body_str =
320320+ let timeout_val = Option.value timeout ~default:t.timeout in
321321+ match Timeout.total timeout_val with
322322+ | Some seconds ->
323323+ Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
324324+ Eio.Time.with_timeout_exn t.clock seconds make_request_fn
325325+ | None -> make_request_fn ()
326326+ in
327327+328328+ (* Log response headers at Info level *)
329329+ Log.info (fun m -> m "< HTTP/1.1 %d" status);
330330+ Log.info (fun m -> m "< Response Headers:");
331331+ Headers.to_list resp_headers |> List.iter (fun (k, v) ->
332332+ Log.info (fun m -> m "< %s: %s" k v)
333333+ );
334334+ Log.info (fun m -> m "");
335335+336336+ (* Extract and store cookies from this response (including redirect responses) *)
337337+ extract_cookies_from_headers resp_headers url_to_fetch;
338338+339339+ (* Handle redirects if enabled *)
340340+ let follow = Option.value follow_redirects ~default:t.follow_redirects in
341341+ let max_redir = Option.value max_redirects ~default:t.max_redirects in
342342+343343+ if follow && (status >= 300 && status < 400) then begin
344344+ if redirects_left <= 0 then begin
345345+ Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url);
346346+ raise (Error.TooManyRedirects { url; count = max_redir; max = max_redir })
347347+ end;
348348+349349+ match Headers.get "location" resp_headers with
350350+ | None ->
351351+ Log.debug (fun m -> m "Redirect response missing Location header");
352352+ (status, resp_headers, response_body_str, url_to_fetch)
353353+ | Some location ->
354354+ (* Resolve relative redirects against the current URL *)
355355+ let location_uri = Uri.of_string location in
356356+ let absolute_location =
357357+ match Uri.host location_uri with
358358+ | Some _ -> location (* Already absolute *)
359359+ | None ->
360360+ (* Relative redirect - resolve against current URL *)
361361+ let base_uri = uri_to_fetch in
362362+ let scheme = Option.value (Uri.scheme base_uri) ~default:"http" in
363363+ let resolved = Uri.resolve scheme base_uri location_uri in
364364+ Uri.to_string resolved
365365+ in
366366+ Log.info (fun m -> m "Following redirect to %s (%d remaining)" absolute_location redirects_left);
367367+ make_with_redirects absolute_location (redirects_left - 1)
368368+ end else
369369+ (status, resp_headers, response_body_str, url_to_fetch)
370370+ in
371371+372372+ let max_redir = Option.value max_redirects ~default:t.max_redirects in
373373+ let final_status, final_headers, final_body_str, final_url =
374374+ make_with_redirects url max_redir
375375+ in
376376+377377+ let elapsed = Unix.gettimeofday () -. start_time in
378378+ Log.info (fun m -> m "Request completed in %.3f seconds" elapsed);
379379+380380+ (* Create a flow from the body string *)
381381+ let body_flow = Eio.Flow.string_source final_body_str in
382382+383383+ Response.Private.make
384384+ ~sw:t.sw
385385+ ~status:final_status
386386+ ~headers:final_headers
387387+ ~body:body_flow
388388+ ~url:final_url
389389+ ~elapsed
390390+ in
391391+392392+ (* Cookies are extracted and stored during the redirect loop for each response,
393393+ including the final response, so no additional extraction needed here *)
394394+395395+ (* Update statistics *)
396396+ t.requests_made <- t.requests_made + 1;
397397+ t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time);
398398+ Log.info (fun m -> m "Request completed with status %d" (Response.status_code response));
399399+400400+ (* Save cookies to disk if persistence is enabled *)
401401+ (match t.persist_cookies, t.xdg with
402402+ | true, Some xdg_ctx ->
403403+ let data_dir = Xdge.data_dir xdg_ctx in
404404+ let cookie_file = Eio.Path.(data_dir / "cookies.txt") in
405405+ Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () ->
406406+ Cookeio_jar.save cookie_file t.cookie_jar;
407407+ Log.debug (fun m -> m "Saved cookies to %a" Eio.Path.pp cookie_file)
408408+ )
409409+ | _ -> ());
410410+411411+ response
412412+413413+(* Public request function - executes synchronously *)
414414+let request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
415415+ make_request_internal t ?headers ?body ?auth ?timeout
416416+ ?follow_redirects ?max_redirects ~method_ url
417417+418418+(* Convenience methods *)
419419+let get t ?headers ?auth ?timeout ?params url =
420420+ let url = match params with
421421+ | Some p ->
422422+ let uri = Uri.of_string url in
423423+ let uri = List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)) uri p in
424424+ Uri.to_string uri
425425+ | None -> url
426426+ in
427427+ request t ?headers ?auth ?timeout ~method_:`GET url
428428+429429+let post t ?headers ?body ?auth ?timeout url =
430430+ request t ?headers ?body ?auth ?timeout ~method_:`POST url
431431+432432+let put t ?headers ?body ?auth ?timeout url =
433433+ request t ?headers ?body ?auth ?timeout ~method_:`PUT url
434434+435435+let patch t ?headers ?body ?auth ?timeout url =
436436+ request t ?headers ?body ?auth ?timeout ~method_:`PATCH url
437437+438438+let delete t ?headers ?auth ?timeout url =
439439+ request t ?headers ?auth ?timeout ~method_:`DELETE url
440440+441441+let head t ?headers ?auth ?timeout url =
442442+ request t ?headers ?auth ?timeout ~method_:`HEAD url
443443+444444+let options t ?headers ?auth ?timeout url =
445445+ request t ?headers ?auth ?timeout ~method_:`OPTIONS url
446446+447447+(* Cmdliner integration module *)
448448+module Cmd = struct
449449+ open Cmdliner
450450+451451+ type config = {
452452+ xdg : Xdge.t * Xdge.Cmd.t;
453453+ persist_cookies : bool;
454454+ verify_tls : bool;
455455+ timeout : float option;
456456+ max_retries : int;
457457+ retry_backoff : float;
458458+ follow_redirects : bool;
459459+ max_redirects : int;
460460+ user_agent : string option;
461461+ verbose_http : bool;
462462+ }
463463+464464+ let create config env sw =
465465+ let xdg, _xdg_cmd = config.xdg in
466466+ let retry = if config.max_retries > 0 then
467467+ Some (Retry.create_config
468468+ ~max_retries:config.max_retries
469469+ ~backoff_factor:config.retry_backoff ())
470470+ else None in
471471+472472+ let timeout = match config.timeout with
473473+ | Some t -> Timeout.create ~total:t ()
474474+ | None -> Timeout.default in
475475+476476+ let req = create ~sw
477477+ ~xdg
478478+ ~persist_cookies:config.persist_cookies
479479+ ~verify_tls:config.verify_tls
480480+ ~timeout
481481+ ?retry
482482+ ~follow_redirects:config.follow_redirects
483483+ ~max_redirects:config.max_redirects
484484+ env in
485485+486486+ (* Set user agent if provided *)
487487+ let req = match config.user_agent with
488488+ | Some ua -> set_default_header req "User-Agent" ua
489489+ | None -> req
490490+ in
491491+492492+ req
493493+494494+ (* Individual terms - parameterized by app_name *)
495495+496496+ let persist_cookies_term app_name =
497497+ let doc = "Persist cookies to disk between sessions" in
498498+ let env_name = String.uppercase_ascii app_name ^ "_PERSIST_COOKIES" in
499499+ let env_info = Cmdliner.Cmd.Env.info env_name in
500500+ Arg.(value & flag & info ["persist-cookies"] ~env:env_info ~doc)
501501+502502+ let verify_tls_term app_name =
503503+ let doc = "Skip TLS certificate verification (insecure)" in
504504+ let env_name = String.uppercase_ascii app_name ^ "_NO_VERIFY_TLS" in
505505+ let env_info = Cmdliner.Cmd.Env.info env_name in
506506+ Term.(const (fun no_verify -> not no_verify) $
507507+ Arg.(value & flag & info ["no-verify-tls"] ~env:env_info ~doc))
508508+509509+ let timeout_term app_name =
510510+ let doc = "Request timeout in seconds" in
511511+ let env_name = String.uppercase_ascii app_name ^ "_TIMEOUT" in
512512+ let env_info = Cmdliner.Cmd.Env.info env_name in
513513+ Arg.(value & opt (some float) None & info ["timeout"] ~env:env_info ~docv:"SECONDS" ~doc)
514514+515515+ let retries_term app_name =
516516+ let doc = "Maximum number of request retries" in
517517+ let env_name = String.uppercase_ascii app_name ^ "_MAX_RETRIES" in
518518+ let env_info = Cmdliner.Cmd.Env.info env_name in
519519+ Arg.(value & opt int 3 & info ["max-retries"] ~env:env_info ~docv:"N" ~doc)
520520+521521+ let retry_backoff_term app_name =
522522+ let doc = "Retry backoff factor for exponential delay" in
523523+ let env_name = String.uppercase_ascii app_name ^ "_RETRY_BACKOFF" in
524524+ let env_info = Cmdliner.Cmd.Env.info env_name in
525525+ Arg.(value & opt float 0.3 & info ["retry-backoff"] ~env:env_info ~docv:"FACTOR" ~doc)
526526+527527+ let follow_redirects_term app_name =
528528+ let doc = "Don't follow HTTP redirects" in
529529+ let env_name = String.uppercase_ascii app_name ^ "_NO_FOLLOW_REDIRECTS" in
530530+ let env_info = Cmdliner.Cmd.Env.info env_name in
531531+ Term.(const (fun no_follow -> not no_follow) $
532532+ Arg.(value & flag & info ["no-follow-redirects"] ~env:env_info ~doc))
533533+534534+ let max_redirects_term app_name =
535535+ let doc = "Maximum number of redirects to follow" in
536536+ let env_name = String.uppercase_ascii app_name ^ "_MAX_REDIRECTS" in
537537+ let env_info = Cmdliner.Cmd.Env.info env_name in
538538+ Arg.(value & opt int 10 & info ["max-redirects"] ~env:env_info ~docv:"N" ~doc)
539539+540540+ let user_agent_term app_name =
541541+ let doc = "User-Agent header to send with requests" in
542542+ let env_name = String.uppercase_ascii app_name ^ "_USER_AGENT" in
543543+ let env_info = Cmdliner.Cmd.Env.info env_name in
544544+ Arg.(value & opt (some string) None & info ["user-agent"] ~env:env_info ~docv:"STRING" ~doc)
545545+546546+ let verbose_http_term app_name =
547547+ let doc = "Enable verbose HTTP-level logging (hexdumps, TLS details)" in
548548+ let env_name = String.uppercase_ascii app_name ^ "_VERBOSE_HTTP" in
549549+ let env_info = Cmdliner.Cmd.Env.info env_name in
550550+ Arg.(value & flag & info ["verbose-http"] ~env:env_info ~doc)
551551+552552+ (* Combined terms *)
553553+554554+ let config_term app_name fs =
555555+ let xdg_term = Xdge.Cmd.term app_name fs
556556+ ~dirs:[`Config; `Data; `Cache] () in
557557+ Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua verbose ->
558558+ { xdg; persist_cookies = persist; verify_tls = verify;
559559+ timeout; max_retries = retries; retry_backoff = backoff;
560560+ follow_redirects = follow; max_redirects = max_redir;
561561+ user_agent = ua; verbose_http = verbose })
562562+ $ xdg_term
563563+ $ persist_cookies_term app_name
564564+ $ verify_tls_term app_name
565565+ $ timeout_term app_name
566566+ $ retries_term app_name
567567+ $ retry_backoff_term app_name
568568+ $ follow_redirects_term app_name
569569+ $ max_redirects_term app_name
570570+ $ user_agent_term app_name
571571+ $ verbose_http_term app_name)
572572+573573+ let requests_term app_name eio_env sw =
574574+ let config_t = config_term app_name eio_env#fs in
575575+ Term.(const (fun config -> create config eio_env sw) $ config_t)
576576+577577+ let minimal_term app_name fs =
578578+ let xdg_term = Xdge.Cmd.term app_name fs
579579+ ~dirs:[`Data; `Cache] () in
580580+ Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist))
581581+ $ xdg_term
582582+ $ persist_cookies_term app_name)
583583+584584+ let env_docs app_name =
585585+ let app_upper = String.uppercase_ascii app_name in
586586+ Printf.sprintf
587587+ "## ENVIRONMENT\n\n\
588588+ The following environment variables affect %s:\n\n\
589589+ **%s_CONFIG_DIR**\n\
590590+ : Override configuration directory location\n\n\
591591+ **%s_DATA_DIR**\n\
592592+ : Override data directory location (for cookies)\n\n\
593593+ **%s_CACHE_DIR**\n\
594594+ : Override cache directory location\n\n\
595595+ **XDG_CONFIG_HOME**\n\
596596+ : Base directory for user configuration files (default: ~/.config)\n\n\
597597+ **XDG_DATA_HOME**\n\
598598+ : Base directory for user data files (default: ~/.local/share)\n\n\
599599+ **XDG_CACHE_HOME**\n\
600600+ : Base directory for user cache files (default: ~/.cache)\n\n\
601601+ **%s_PERSIST_COOKIES**\n\
602602+ : Set to '1' to persist cookies by default\n\n\
603603+ **%s_NO_VERIFY_TLS**\n\
604604+ : Set to '1' to disable TLS verification (insecure)\n\n\
605605+ **%s_TIMEOUT**\n\
606606+ : Default request timeout in seconds\n\n\
607607+ **%s_MAX_RETRIES**\n\
608608+ : Maximum number of retries (default: 3)\n\n\
609609+ **%s_RETRY_BACKOFF**\n\
610610+ : Retry backoff factor (default: 0.3)\n\n\
611611+ **%s_NO_FOLLOW_REDIRECTS**\n\
612612+ : Set to '1' to disable redirect following\n\n\
613613+ **%s_MAX_REDIRECTS**\n\
614614+ : Maximum redirects to follow (default: 10)\n\n\
615615+ **%s_USER_AGENT**\n\
616616+ : User-Agent header to send with requests\n\n\
617617+ **%s_VERBOSE_HTTP**\n\
618618+ : Set to '1' to enable verbose HTTP-level logging\
619619+ "
620620+ app_name app_upper app_upper app_upper
621621+ app_upper app_upper app_upper app_upper
622622+ app_upper app_upper app_upper app_upper app_upper
623623+624624+ let pp_config ppf config =
625625+ let _xdg, xdg_cmd = config.xdg in
626626+ Format.fprintf ppf "@[<v>Configuration:@,\
627627+ @[<v 2>XDG:@,%a@]@,\
628628+ persist_cookies: %b@,\
629629+ verify_tls: %b@,\
630630+ timeout: %a@,\
631631+ max_retries: %d@,\
632632+ retry_backoff: %.2f@,\
633633+ follow_redirects: %b@,\
634634+ max_redirects: %d@,\
635635+ user_agent: %a@,\
636636+ verbose_http: %b@]"
637637+ Xdge.Cmd.pp xdg_cmd
638638+ config.persist_cookies
639639+ config.verify_tls
640640+ (Format.pp_print_option Format.pp_print_float) config.timeout
641641+ config.max_retries
642642+ config.retry_backoff
643643+ config.follow_redirects
644644+ config.max_redirects
645645+ (Format.pp_print_option Format.pp_print_string) config.user_agent
646646+ config.verbose_http
647647+648648+ (* Logging configuration *)
649649+ let setup_log_sources ?(verbose_http = false) level =
650650+ (* Helper to set TLS tracing level by finding the source by name *)
651651+ let set_tls_tracing_level lvl =
652652+ match List.find_opt (fun s -> Logs.Src.name s = "tls.tracing") (Logs.Src.list ()) with
653653+ | Some tls_src -> Logs.Src.set_level tls_src (Some lvl)
654654+ | None -> () (* TLS not loaded yet, ignore *)
655655+ in
656656+ match level with
657657+ | Some Logs.Debug ->
658658+ (* Enable debug logging for application-level requests modules *)
659659+ Logs.Src.set_level src (Some Logs.Debug);
660660+ Logs.Src.set_level Auth.src (Some Logs.Debug);
661661+ Logs.Src.set_level Body.src (Some Logs.Debug);
662662+ Logs.Src.set_level Response.src (Some Logs.Debug);
663663+ Logs.Src.set_level Retry.src (Some Logs.Debug);
664664+ Logs.Src.set_level Headers.src (Some Logs.Debug);
665665+ Logs.Src.set_level Error.src (Some Logs.Debug);
666666+ Logs.Src.set_level Method.src (Some Logs.Debug);
667667+ Logs.Src.set_level Mime.src (Some Logs.Debug);
668668+ Logs.Src.set_level Status.src (Some Logs.Debug);
669669+ Logs.Src.set_level Timeout.src (Some Logs.Debug);
670670+ (* Only enable HTTP-level debug if verbose_http is set *)
671671+ if verbose_http then begin
672672+ Logs.Src.set_level One.src (Some Logs.Debug);
673673+ Logs.Src.set_level Http_client.src (Some Logs.Debug);
674674+ Logs.Src.set_level Conpool.src (Some Logs.Debug);
675675+ set_tls_tracing_level Logs.Debug
676676+ end else begin
677677+ Logs.Src.set_level One.src (Some Logs.Info);
678678+ Logs.Src.set_level Http_client.src (Some Logs.Info);
679679+ Logs.Src.set_level Conpool.src (Some Logs.Info);
680680+ set_tls_tracing_level Logs.Warning
681681+ end
682682+ | Some Logs.Info ->
683683+ (* Set info level for main modules *)
684684+ Logs.Src.set_level src (Some Logs.Info);
685685+ Logs.Src.set_level Response.src (Some Logs.Info);
686686+ Logs.Src.set_level One.src (Some Logs.Info);
687687+ set_tls_tracing_level Logs.Warning
688688+ | _ ->
689689+ (* Suppress TLS debug output by default *)
690690+ set_tls_tracing_level Logs.Warning
691691+end
+625
lib/requests.mli
···11+(** Requests - A modern HTTP client library for OCaml
22+33+ Requests is an HTTP client library for OCaml inspired by Python's requests
44+ and urllib3 libraries. It provides a simple, intuitive API for making HTTP
55+ requests while handling complexities like TLS configuration, connection
66+ pooling, retries, and cookie management.
77+88+ {2 High-Level API}
99+1010+ The Requests library offers two main ways to make HTTP requests:
1111+1212+ {b 1. Main API} (Recommended for most use cases)
1313+1414+ The main API maintains state across requests, handles cookies automatically,
1515+ spawns requests in concurrent fibers, and provides a simple interface:
1616+1717+ {[
1818+ open Eio_main
1919+2020+ let () = run @@ fun env ->
2121+ Switch.run @@ fun sw ->
2222+2323+ (* Create a requests instance *)
2424+ let req = Requests.create ~sw env in
2525+2626+ (* Configure authentication once *)
2727+ Requests.set_auth req (Requests.Auth.bearer "your-token");
2828+2929+ (* Make concurrent requests using Fiber.both *)
3030+ let (user, repos) = Eio.Fiber.both
3131+ (fun () -> Requests.get req "https://api.github.com/user")
3232+ (fun () -> Requests.get req "https://api.github.com/user/repos") in
3333+3434+ (* Process responses *)
3535+ let user_data = Response.body user |> Eio.Flow.read_all in
3636+ let repos_data = Response.body repos |> Eio.Flow.read_all in
3737+3838+ (* No cleanup needed - responses auto-close with the switch *)
3939+ ]}
4040+4141+ {b 2. One-shot requests} (For stateless operations)
4242+4343+ The One module provides lower-level control for stateless,
4444+ one-off requests without session state:
4545+4646+ {[
4747+ (* Create a one-shot client *)
4848+ let client = Requests.One.create ~clock:env#clock ~net:env#net () in
4949+5050+ (* Make a simple GET request *)
5151+ let response = Requests.One.get ~sw ~client "https://api.github.com" in
5252+ Printf.printf "Status: %d\n" (Requests.Response.status_code response);
5353+5454+ (* POST with custom headers and body *)
5555+ let response = Requests.One.post ~sw ~client
5656+ ~headers:(Requests.Headers.empty
5757+ |> Requests.Headers.content_type Requests.Mime.json
5858+ |> Requests.Headers.set "X-API-Key" "secret")
5959+ ~body:(Requests.Body.json {|{"name": "Alice"}|})
6060+ "https://api.example.com/users"
6161+6262+ (* No cleanup needed - responses auto-close with the switch *)
6363+ ]}
6464+6565+ {2 Features}
6666+6767+ - {b Simple API}: Intuitive functions for GET, POST, PUT, DELETE, etc.
6868+ - {b Authentication}: Built-in support for Basic, Bearer, Digest, and OAuth
6969+ - {b Streaming}: Upload and download large files efficiently
7070+ - {b Retries}: Automatic retry with exponential backoff
7171+ - {b Timeouts}: Configurable connection and read timeouts
7272+ - {b Cookie Management}: Automatic cookie handling with persistence
7373+ - {b TLS/SSL}: Secure connections with certificate verification
7474+ - {b Error Handling}: Comprehensive error types and recovery
7575+7676+ {2 Common Use Cases}
7777+7878+ {b Working with JSON APIs:}
7979+ {[
8080+ let response = Requests.post req "https://api.example.com/data"
8181+ ~body:(Requests.Body.json {|{"key": "value"}|}) in
8282+ let body_text =
8383+ Requests.Response.body response
8484+ |> Eio.Flow.read_all in
8585+ print_endline body_text
8686+ (* Response auto-closes with switch *)
8787+ ]}
8888+8989+ {b File uploads:}
9090+ {[
9191+ let body = Requests.Body.multipart [
9292+ { name = "file"; filename = Some "document.pdf";
9393+ content_type = Requests.Mime.pdf;
9494+ content = `File (Eio.Path.(fs / "document.pdf")) };
9595+ { name = "description"; filename = None;
9696+ content_type = Requests.Mime.text_plain;
9797+ content = `String "Important document" }
9898+ ] in
9999+ let response = Requests.post req "https://example.com/upload"
100100+ ~body
101101+ (* Response auto-closes with switch *)
102102+ ]}
103103+104104+ {b Streaming downloads:}
105105+ {[
106106+ Requests.One.download ~sw ~client
107107+ "https://example.com/large-file.zip"
108108+ ~sink:(Eio.Path.(fs / "download.zip" |> sink))
109109+ ]}
110110+111111+ {2 Choosing Between Main API and One}
112112+113113+ Use the {b main API (Requests.t)} when you need:
114114+ - Cookie persistence across requests
115115+ - Automatic retry handling
116116+ - Shared authentication across requests
117117+ - Request/response history tracking
118118+ - Configuration persistence to disk
119119+120120+ Use {b One} when you need:
121121+ - One-off stateless requests
122122+ - Fine-grained control over connections
123123+ - Minimal overhead
124124+ - Custom connection pooling
125125+ - Direct streaming without cookies
126126+*)
127127+128128+(** {1 Main API}
129129+130130+ The main Requests API provides stateful HTTP clients with automatic cookie
131131+ management and persistent configuration. Requests execute synchronously by default.
132132+ Use Eio.Fiber.both or Eio.Fiber.all for concurrent execution.
133133+*)
134134+135135+type ('clock, 'net) t
136136+(** A stateful HTTP client that maintains cookies, auth, configuration, and
137137+ connection pools across requests. *)
138138+139139+(** {2 Creation and Configuration} *)
140140+141141+val create :
142142+ sw:Eio.Switch.t ->
143143+ ?http_pool:('clock Eio.Time.clock, 'net Eio.Net.t) Conpool.t ->
144144+ ?https_pool:('clock Eio.Time.clock, 'net Eio.Net.t) Conpool.t ->
145145+ ?cookie_jar:Cookeio_jar.t ->
146146+ ?default_headers:Headers.t ->
147147+ ?auth:Auth.t ->
148148+ ?timeout:Timeout.t ->
149149+ ?follow_redirects:bool ->
150150+ ?max_redirects:int ->
151151+ ?verify_tls:bool ->
152152+ ?tls_config:Tls.Config.client ->
153153+ ?max_connections_per_host:int ->
154154+ ?connection_idle_timeout:float ->
155155+ ?connection_lifetime:float ->
156156+ ?retry:Retry.config ->
157157+ ?persist_cookies:bool ->
158158+ ?xdg:Xdge.t ->
159159+ < clock: 'clock Eio.Resource.t; net: 'net Eio.Resource.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
160160+ ('clock Eio.Resource.t, 'net Eio.Resource.t) t
161161+(** Create a new requests instance with persistent state and connection pooling.
162162+ All resources are bound to the provided switch and will be cleaned up automatically.
163163+164164+ @param sw Switch for resource management
165165+ @param http_pool Optional pre-configured HTTP connection pool (creates new if not provided)
166166+ @param https_pool Optional pre-configured HTTPS connection pool (creates new if not provided)
167167+ @param cookie_jar Cookie storage (default: empty in-memory jar)
168168+ @param default_headers Headers included in every request
169169+ @param auth Default authentication
170170+ @param timeout Default timeout configuration
171171+ @param follow_redirects Whether to follow HTTP redirects (default: true)
172172+ @param max_redirects Maximum redirects to follow (default: 10)
173173+ @param verify_tls Whether to verify TLS certificates (default: true)
174174+ @param tls_config Custom TLS configuration for HTTPS pool (default: system CA certs)
175175+ @param max_connections_per_host Maximum pooled connections per host:port (default: 10)
176176+ @param connection_idle_timeout Max idle time before closing pooled connection (default: 60s)
177177+ @param connection_lifetime Max lifetime of any pooled connection (default: 300s)
178178+ @param retry Retry configuration for failed requests
179179+ @param persist_cookies Whether to persist cookies to disk (default: false)
180180+ @param xdg XDG directory context for cookies (required if persist_cookies=true)
181181+182182+ {b Note:} HTTP caching has been disabled for simplicity. See CACHEIO.md for integration notes
183183+ if you need to restore caching functionality in the future.
184184+*)
185185+186186+(** {2 Configuration Management} *)
187187+188188+val set_default_header : ('clock, 'net) t -> string -> string -> ('clock, 'net) t
189189+(** Add or update a default header. Returns a new session with the updated header.
190190+ The original session's connection pools are shared. *)
191191+192192+val remove_default_header : ('clock, 'net) t -> string -> ('clock, 'net) t
193193+(** Remove a default header. Returns a new session without the header. *)
194194+195195+val set_auth : ('clock, 'net) t -> Auth.t -> ('clock, 'net) t
196196+(** Set default authentication. Returns a new session with auth configured. *)
197197+198198+val clear_auth : ('clock, 'net) t -> ('clock, 'net) t
199199+(** Clear authentication. Returns a new session without auth. *)
200200+201201+val set_timeout : ('clock, 'net) t -> Timeout.t -> ('clock, 'net) t
202202+(** Set default timeout. Returns a new session with the timeout configured. *)
203203+204204+val set_retry : ('clock, 'net) t -> Retry.config -> ('clock, 'net) t
205205+(** Set retry configuration. Returns a new session with retry configured. *)
206206+207207+(** {2 Request Methods}
208208+209209+ All request methods execute synchronously. To make concurrent requests,
210210+ you must explicitly use Eio.Fiber.both or Eio.Fiber.all.
211211+ The response will auto-close when the parent switch closes.
212212+213213+ Example of concurrent requests using Fiber.both:
214214+ {[
215215+ let req = Requests.create ~sw env in
216216+217217+ (* Use Fiber.both for two concurrent requests *)
218218+ let (r1, r2) = Eio.Fiber.both
219219+ (fun () -> Requests.get req "https://api1.example.com")
220220+ (fun () -> Requests.post req "https://api2.example.com" ~body)
221221+ in
222222+223223+ (* Process responses *)
224224+ let body1 = Response.body r1 |> Eio.Flow.read_all in
225225+ let body2 = Response.body r2 |> Eio.Flow.read_all in
226226+ ]}
227227+228228+ Example using Fiber.all for multiple requests:
229229+ {[
230230+ let req = Requests.create ~sw env in
231231+232232+ (* Use Fiber.all for multiple concurrent requests *)
233233+ let urls = [
234234+ "https://api1.example.com";
235235+ "https://api2.example.com";
236236+ "https://api3.example.com";
237237+ ] in
238238+239239+ let responses = ref [] in
240240+ Eio.Fiber.all [
241241+ (fun () -> responses := Requests.get req (List.nth urls 0) :: !responses);
242242+ (fun () -> responses := Requests.get req (List.nth urls 1) :: !responses);
243243+ (fun () -> responses := Requests.get req (List.nth urls 2) :: !responses);
244244+ ];
245245+246246+ (* Process all responses *)
247247+ List.iter (fun r ->
248248+ let body = Response.body r |> Eio.Flow.read_all in
249249+ print_endline body
250250+ ) !responses
251251+ ]}
252252+253253+ Example using Promise for concurrent requests with individual control:
254254+ {[
255255+ let req = Requests.create ~sw env in
256256+257257+ (* Start requests in parallel using promises *)
258258+ let p1, r1 = Eio.Promise.create () in
259259+ let p2, r2 = Eio.Promise.create () in
260260+ let p3, r3 = Eio.Promise.create () in
261261+262262+ Eio.Fiber.fork ~sw (fun () ->
263263+ Eio.Promise.resolve r1 (Requests.get req "https://api1.example.com")
264264+ );
265265+ Eio.Fiber.fork ~sw (fun () ->
266266+ Eio.Promise.resolve r2 (Requests.post req "https://api2.example.com" ~body)
267267+ );
268268+ Eio.Fiber.fork ~sw (fun () ->
269269+ Eio.Promise.resolve r3 (Requests.get req "https://api3.example.com")
270270+ );
271271+272272+ (* Wait for all promises and process *)
273273+ let resp1 = Eio.Promise.await p1 in
274274+ let resp2 = Eio.Promise.await p2 in
275275+ let resp3 = Eio.Promise.await p3 in
276276+277277+ (* Process responses *)
278278+ let body1 = Response.body resp1 |> Eio.Flow.read_all in
279279+ let body2 = Response.body resp2 |> Eio.Flow.read_all in
280280+ let body3 = Response.body resp3 |> Eio.Flow.read_all in
281281+ ]}
282282+*)
283283+284284+val request :
285285+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
286286+ ?headers:Headers.t ->
287287+ ?body:Body.t ->
288288+ ?auth:Auth.t ->
289289+ ?timeout:Timeout.t ->
290290+ ?follow_redirects:bool ->
291291+ ?max_redirects:int ->
292292+ method_:Method.t ->
293293+ string ->
294294+ Response.t
295295+(** Make a concurrent HTTP request *)
296296+297297+val get :
298298+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
299299+ ?headers:Headers.t ->
300300+ ?auth:Auth.t ->
301301+ ?timeout:Timeout.t ->
302302+ ?params:(string * string) list ->
303303+ string ->
304304+ Response.t
305305+(** Concurrent GET request *)
306306+307307+val post :
308308+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
309309+ ?headers:Headers.t ->
310310+ ?body:Body.t ->
311311+ ?auth:Auth.t ->
312312+ ?timeout:Timeout.t ->
313313+ string ->
314314+ Response.t
315315+(** Concurrent POST request *)
316316+317317+val put :
318318+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
319319+ ?headers:Headers.t ->
320320+ ?body:Body.t ->
321321+ ?auth:Auth.t ->
322322+ ?timeout:Timeout.t ->
323323+ string ->
324324+ Response.t
325325+(** Concurrent PUT request *)
326326+327327+val patch :
328328+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
329329+ ?headers:Headers.t ->
330330+ ?body:Body.t ->
331331+ ?auth:Auth.t ->
332332+ ?timeout:Timeout.t ->
333333+ string ->
334334+ Response.t
335335+(** Concurrent PATCH request *)
336336+337337+val delete :
338338+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
339339+ ?headers:Headers.t ->
340340+ ?auth:Auth.t ->
341341+ ?timeout:Timeout.t ->
342342+ string ->
343343+ Response.t
344344+(** Concurrent DELETE request *)
345345+346346+val head :
347347+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
348348+ ?headers:Headers.t ->
349349+ ?auth:Auth.t ->
350350+ ?timeout:Timeout.t ->
351351+ string ->
352352+ Response.t
353353+(** Concurrent HEAD request *)
354354+355355+val options :
356356+ (_ Eio.Time.clock, _ Eio.Net.t) t ->
357357+ ?headers:Headers.t ->
358358+ ?auth:Auth.t ->
359359+ ?timeout:Timeout.t ->
360360+ string ->
361361+ Response.t
362362+(** Concurrent OPTIONS request *)
363363+364364+(** {2 Cookie Management} *)
365365+366366+val cookies : ('clock, 'net) t -> Cookeio_jar.t
367367+(** Get the cookie jar for direct manipulation *)
368368+369369+val clear_cookies : ('clock, 'net) t -> unit
370370+(** Clear all cookies *)
371371+372372+(** {1 Cmdliner Integration} *)
373373+374374+module Cmd : sig
375375+ (** Cmdliner integration for Requests configuration.
376376+377377+ This module provides command-line argument handling for configuring
378378+ HTTP requests, including XDG directory paths, timeouts, retries,
379379+ and other parameters. *)
380380+381381+ (** Configuration from command line and environment *)
382382+ type config = {
383383+ xdg : Xdge.t * Xdge.Cmd.t; (** XDG paths and their sources *)
384384+ persist_cookies : bool; (** Whether to persist cookies *)
385385+ verify_tls : bool; (** Whether to verify TLS certificates *)
386386+ timeout : float option; (** Request timeout in seconds *)
387387+ max_retries : int; (** Maximum number of retries *)
388388+ retry_backoff : float; (** Retry backoff factor *)
389389+ follow_redirects : bool; (** Whether to follow redirects *)
390390+ max_redirects : int; (** Maximum number of redirects *)
391391+ user_agent : string option; (** User-Agent header *)
392392+ verbose_http : bool; (** Enable verbose HTTP-level logging *)
393393+ }
394394+395395+ val create : config -> < clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t; net: ([> [>`Generic] Eio.Net.ty ] as 'net) Eio.Resource.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > -> Eio.Switch.t -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
396396+ (** [create config env sw] creates a requests instance from command-line configuration *)
397397+398398+ (** {2 Individual Terms} *)
399399+400400+ val persist_cookies_term : string -> bool Cmdliner.Term.t
401401+ (** Term for [--persist-cookies] flag with app-specific env var *)
402402+403403+ val verify_tls_term : string -> bool Cmdliner.Term.t
404404+ (** Term for [--no-verify-tls] flag with app-specific env var *)
405405+406406+ val timeout_term : string -> float option Cmdliner.Term.t
407407+ (** Term for [--timeout SECONDS] option with app-specific env var *)
408408+409409+ val retries_term : string -> int Cmdliner.Term.t
410410+ (** Term for [--max-retries N] option with app-specific env var *)
411411+412412+ val retry_backoff_term : string -> float Cmdliner.Term.t
413413+ (** Term for [--retry-backoff FACTOR] option with app-specific env var *)
414414+415415+ val follow_redirects_term : string -> bool Cmdliner.Term.t
416416+ (** Term for [--no-follow-redirects] flag with app-specific env var *)
417417+418418+ val max_redirects_term : string -> int Cmdliner.Term.t
419419+ (** Term for [--max-redirects N] option with app-specific env var *)
420420+421421+ val user_agent_term : string -> string option Cmdliner.Term.t
422422+ (** Term for [--user-agent STRING] option with app-specific env var *)
423423+424424+ val verbose_http_term : string -> bool Cmdliner.Term.t
425425+ (** Term for [--verbose-http] flag with app-specific env var.
426426+427427+ Enables verbose HTTP-level logging including hexdumps, TLS details,
428428+ and low-level protocol information. Typically used in conjunction
429429+ with debug-level logging. *)
430430+431431+ (** {2 Combined Terms} *)
432432+433433+ val config_term : string -> Eio.Fs.dir_ty Eio.Path.t -> config Cmdliner.Term.t
434434+ (** [config_term app_name fs] creates a complete configuration term.
435435+436436+ This combines all individual terms plus XDG configuration into
437437+ a single term that can be used to configure requests.
438438+439439+ {b Generated Flags:}
440440+ - [--config-dir DIR]: Configuration directory
441441+ - [--data-dir DIR]: Data directory
442442+ - [--cache-dir DIR]: Cache directory
443443+ - [--persist-cookies]: Enable cookie persistence
444444+ - [--no-verify-tls]: Disable TLS verification
445445+ - [--timeout SECONDS]: Request timeout
446446+ - [--max-retries N]: Maximum retries
447447+ - [--retry-backoff FACTOR]: Retry backoff multiplier
448448+ - [--no-follow-redirects]: Disable redirect following
449449+ - [--max-redirects N]: Maximum redirects to follow
450450+ - [--user-agent STRING]: User-Agent header
451451+ - [--verbose-http]: Enable verbose HTTP-level logging
452452+453453+ {b Example:}
454454+ {[
455455+ let open Cmdliner in
456456+ let config_t = Requests.Cmd.config_term "myapp" env#fs in
457457+ let main config =
458458+ Eio.Switch.run @@ fun sw ->
459459+ let req = Requests.Cmd.create config env sw in
460460+ (* Use requests *)
461461+ in
462462+ let cmd = Cmd.v info Term.(const main $ config_t) in
463463+ Cmd.eval cmd
464464+ ]} *)
465465+466466+ val requests_term : string -> < clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t; net: ([> [>`Generic] Eio.Net.ty ] as 'net) Eio.Resource.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > -> Eio.Switch.t -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t Cmdliner.Term.t
467467+ (** [requests_term app_name env sw] creates a term that directly produces a requests instance.
468468+469469+ This is a convenience function that combines configuration parsing
470470+ with requests creation.
471471+472472+ {b Example:}
473473+ {[
474474+ let open Cmdliner in
475475+ let main req =
476476+ (* Use requests directly *)
477477+ let resp = Requests.get req "https://example.com" in
478478+ (* ... *)
479479+ in
480480+ Eio.Switch.run @@ fun sw ->
481481+ let req_t = Requests.Cmd.requests_term "myapp" env sw in
482482+ let cmd = Cmd.v info Term.(const main $ req_t) in
483483+ Cmd.eval cmd
484484+ ]} *)
485485+486486+ val minimal_term : string -> Eio.Fs.dir_ty Eio.Path.t -> (Xdge.t * bool) Cmdliner.Term.t
487487+ (** [minimal_term app_name fs] creates a minimal configuration term.
488488+489489+ This only provides:
490490+ - [--cache-dir DIR]: Cache directory for responses
491491+ - [--persist-cookies]: Cookie persistence flag
492492+493493+ Returns the XDG context and persist_cookies boolean.
494494+495495+ {b Example:}
496496+ {[
497497+ let open Cmdliner in
498498+ let minimal_t = Requests.Cmd.minimal_term "myapp" env#fs in
499499+ let main (xdg, persist) =
500500+ Eio.Switch.run @@ fun sw ->
501501+ let req = Requests.create ~sw ~xdg ~persist_cookies:persist env in
502502+ (* Use requests *)
503503+ in
504504+ let cmd = Cmd.v info Term.(const main $ minimal_t) in
505505+ Cmd.eval cmd
506506+ ]} *)
507507+508508+ (** {2 Documentation} *)
509509+510510+ val env_docs : string -> string
511511+ (** [env_docs app_name] generates environment variable documentation.
512512+513513+ Returns formatted documentation for all environment variables that
514514+ affect requests configuration, including XDG variables.
515515+516516+ {b Included Variables:}
517517+ - [${APP_NAME}_CONFIG_DIR]: Configuration directory
518518+ - [${APP_NAME}_DATA_DIR]: Data directory
519519+ - [${APP_NAME}_CACHE_DIR]: Cache directory
520520+ - [${APP_NAME}_STATE_DIR]: State directory
521521+ - [XDG_CONFIG_HOME], [XDG_DATA_HOME], [XDG_CACHE_HOME], [XDG_STATE_HOME]
522522+ - [HTTP_PROXY], [HTTPS_PROXY], [NO_PROXY] (when proxy support is added)
523523+524524+ {b Example:}
525525+ {[
526526+ let env_info = Cmdliner.Cmd.Env.info
527527+ ~docs:Cmdliner.Manpage.s_environment
528528+ ~doc:(Requests.Cmd.env_docs "myapp")
529529+ ()
530530+ ]} *)
531531+532532+ val pp_config : Format.formatter -> config -> unit
533533+ (** Pretty print configuration for debugging *)
534534+535535+ (** {2 Logging Configuration} *)
536536+537537+ val setup_log_sources : ?verbose_http:bool -> Logs.level option -> unit
538538+ (** [setup_log_sources ~verbose_http level] configures Requests library log sources.
539539+540540+ This helper function configures all Requests logging sources based on
541541+ the specified log level and verbose_http flag. It's designed to work
542542+ with Logs_cli and provides fine-grained control over HTTP-level logging.
543543+544544+ {b Log Level Behavior:}
545545+ - [Some Debug]: Enables debug logging for all application-level modules
546546+ (Auth, Body, Response, Retry, Headers, Error, Method, Mime, Status, Timeout).
547547+ If [verbose_http] is true, also enables debug logging for protocol-level
548548+ modules (One, Http_client, Conpool, and TLS tracing). If [verbose_http]
549549+ is false, TLS tracing is set to Warning level to suppress hexdumps.
550550+ - [Some Info]: Enables info logging for main modules (src, Response, One).
551551+ TLS tracing is set to Warning level.
552552+ - [None] or other levels: TLS tracing is set to Warning level to suppress
553553+ verbose protocol output.
554554+555555+ {b Example with Logs_cli:}
556556+ {[
557557+ let setup_logging =
558558+ let open Cmdliner.Term in
559559+ const (fun style level verbose_http ->
560560+ Fmt_tty.setup_std_outputs ?style_renderer:style ();
561561+ Logs.set_level level;
562562+ Logs.set_reporter (Logs_fmt.reporter ());
563563+ Requests.Cmd.setup_log_sources ~verbose_http level)
564564+ $ Fmt_cli.style_renderer ()
565565+ $ Logs_cli.level ()
566566+ $ Requests.Cmd.verbose_http_term "myapp"
567567+ ]} *)
568568+end
569569+570570+(** Retry policies and backoff strategies *)
571571+module Retry = Retry
572572+573573+(** {1 One-Shot API}
574574+575575+ The One module provides direct control over HTTP requests without
576576+ session state. Use this for stateless operations or when you need
577577+ fine-grained control.
578578+*)
579579+580580+(** One-shot HTTP client for stateless requests *)
581581+module One = One
582582+583583+(** Low-level HTTP client over pooled connections *)
584584+module Http_client = Http_client
585585+586586+(** {1 Core Types}
587587+588588+ These modules define the fundamental types used throughout the library.
589589+*)
590590+591591+(** HTTP response handling *)
592592+module Response = Response
593593+594594+(** Request body construction and encoding *)
595595+module Body = Body
596596+597597+(** HTTP headers manipulation *)
598598+module Headers = Headers
599599+600600+(** Authentication schemes (Basic, Bearer, OAuth, etc.) *)
601601+module Auth = Auth
602602+603603+(** Error types and exception handling *)
604604+module Error = Error
605605+606606+(** {1 Supporting Types} *)
607607+608608+(** HTTP status codes and reason phrases *)
609609+module Status = Status
610610+611611+(** HTTP request methods (GET, POST, etc.) *)
612612+module Method = Method
613613+614614+(** MIME types for content negotiation *)
615615+module Mime = Mime
616616+617617+(** Timeout configuration for requests *)
618618+module Timeout = Timeout
619619+620620+(** {2 Logging} *)
621621+622622+(** Log source for the requests library.
623623+ Use [Logs.Src.set_level src] to control logging verbosity.
624624+ Example: [Logs.Src.set_level Requests.src (Some Logs.Debug)] *)
625625+val src : Logs.Src.t
+85
lib/response.ml
···11+let src = Logs.Src.create "requests.response" ~doc:"HTTP Response"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+type t = {
55+ status : int;
66+ headers : Headers.t;
77+ body : Eio.Flow.source_ty Eio.Resource.t;
88+ url : string;
99+ elapsed : float;
1010+ mutable closed : bool;
1111+}
1212+1313+let make ~sw ~status ~headers ~body ~url ~elapsed =
1414+ Log.debug (fun m -> m "Creating response: status=%d url=%s elapsed=%.3fs" status url elapsed);
1515+ let response = { status; headers; body; url; elapsed; closed = false } in
1616+1717+ (* Register cleanup with switch *)
1818+ Eio.Switch.on_release sw (fun () ->
1919+ if not response.closed then begin
2020+ Log.debug (fun m -> m "Auto-closing response for %s via switch" url);
2121+ response.closed <- true;
2222+ (* TODO Body cleanup is handled by the underlying HTTP library but test this *)
2323+ end
2424+ );
2525+2626+ response
2727+2828+let status t = Status.of_int t.status
2929+3030+let status_code t = t.status
3131+3232+let ok t = Status.is_success (Status.of_int t.status)
3333+3434+let headers t = t.headers
3535+3636+let header name t = Headers.get name t.headers
3737+3838+let content_type t =
3939+ match Headers.get "content-type" t.headers with
4040+ | None -> None
4141+ | Some ct -> Some (Mime.of_string ct)
4242+4343+let content_length t =
4444+ match Headers.get "content-length" t.headers with
4545+ | None -> None
4646+ | Some len ->
4747+ try Some (Int64.of_string len)
4848+ with _ -> None
4949+5050+let location t = Headers.get "location" t.headers
5151+5252+let url t = t.url
5353+5454+let elapsed t = t.elapsed
5555+5656+let body t =
5757+ if t.closed then
5858+ failwith "Response has been closed"
5959+ else
6060+ t.body
6161+6262+6363+(* Pretty printers *)
6464+let pp ppf t =
6565+ Format.fprintf ppf "@[<v>Response:@,\
6666+ status: %a@,\
6767+ url: %s@,\
6868+ elapsed: %.3fs@,\
6969+ headers: @[%a@]@]"
7070+ Status.pp (Status.of_int t.status) t.url t.elapsed
7171+ Headers.pp_brief t.headers
7272+7373+let pp_detailed ppf t =
7474+ Format.fprintf ppf "@[<v>Response:@,\
7575+ status: %a@,\
7676+ url: %s@,\
7777+ elapsed: %.3fs@,\
7878+ @[%a@]@]"
7979+ Status.pp_hum (Status.of_int t.status) t.url t.elapsed
8080+ Headers.pp t.headers
8181+8282+(* Private module *)
8383+module Private = struct
8484+ let make = make
8585+end
+129
lib/response.mli
···11+(** HTTP response handling
22+33+ This module represents HTTP responses and provides functions to access
44+ status codes, headers, and response bodies. Responses support streaming
55+ to efficiently handle large payloads.
66+77+ {2 Examples}
88+99+ {[
1010+ (* Check response status *)
1111+ if Response.ok response then
1212+ Printf.printf "Success!\n"
1313+ else
1414+ Printf.printf "Error: %d\n" (Response.status_code response);
1515+1616+ (* Access headers *)
1717+ match Response.content_type response with
1818+ | Some mime -> Printf.printf "Type: %s\n" (Mime.to_string mime)
1919+ | None -> ()
2020+2121+ (* Stream response body *)
2222+ let body = Response.body response in
2323+ Eio.Flow.copy body (Eio.Flow.buffer_sink buffer)
2424+2525+ (* Response automatically closes when the switch is released *)
2626+ ]}
2727+2828+ {b Note}: Responses are automatically closed when the switch they were
2929+ created with is released. Manual cleanup is not necessary.
3030+*)
3131+3232+open Eio
3333+3434+(** Log source for response operations *)
3535+val src : Logs.Src.t
3636+3737+type t
3838+(** Abstract response type representing an HTTP response. *)
3939+4040+val make : sw:Eio.Switch.t -> status:int -> headers:Headers.t ->
4141+ body:Eio.Flow.source_ty Eio.Resource.t -> url:string -> elapsed:float -> t
4242+(** [make ~sw ~status ~headers ~body ~url ~elapsed] creates a response.
4343+ Internal function primarily used for caching. *)
4444+4545+(** {1 Status Information} *)
4646+4747+val status : t -> Status.t
4848+(** [status response] returns the HTTP status as a {!Status.t} value. *)
4949+5050+val status_code : t -> int
5151+(** [status_code response] returns the HTTP status code as an integer (e.g., 200, 404). *)
5252+5353+val ok : t -> bool
5454+(** [ok response] returns [true] if the status code is in the 2xx success range.
5555+ This is an alias for {!Status.is_success}. *)
5656+5757+(** {1 Header Access} *)
5858+5959+val headers : t -> Headers.t
6060+(** [headers response] returns all response headers. *)
6161+6262+val header : string -> t -> string option
6363+(** [header name response] returns the value of a specific header, or [None] if not present.
6464+ Header names are case-insensitive. *)
6565+6666+val content_type : t -> Mime.t option
6767+(** [content_type response] returns the parsed Content-Type header as a MIME type,
6868+ or [None] if the header is not present or cannot be parsed. *)
6969+7070+val content_length : t -> int64 option
7171+(** [content_length response] returns the Content-Length in bytes,
7272+ or [None] if not specified or chunked encoding is used. *)
7373+7474+val location : t -> string option
7575+(** [location response] returns the Location header value, typically used in redirects.
7676+ Returns [None] if the header is not present. *)
7777+7878+(** {1 Response Metadata} *)
7979+8080+val url : t -> string
8181+(** [url response] returns the final URL after following any redirects.
8282+ This may differ from the originally requested URL. *)
8383+8484+val elapsed : t -> float
8585+(** [elapsed response] returns the time taken for the request in seconds,
8686+ including connection establishment, sending the request, and receiving headers. *)
8787+8888+(** {1 Response Body} *)
8989+9090+val body : t -> Flow.source_ty Resource.t
9191+(** [body response] returns the response body as an Eio flow for streaming.
9292+ This allows efficient processing of large responses without loading them
9393+ entirely into memory.
9494+9595+ Example:
9696+ {[
9797+ let body = Response.body response in
9898+ let buffer = Buffer.create 4096 in
9999+ Eio.Flow.copy body (Eio.Flow.buffer_sink buffer);
100100+ Buffer.contents buffer
101101+ ]}
102102+*)
103103+104104+105105+(** {1 Pretty Printing} *)
106106+107107+val pp : Format.formatter -> t -> unit
108108+(** Pretty print a response summary *)
109109+110110+val pp_detailed : Format.formatter -> t -> unit
111111+(** Pretty print a response with full headers *)
112112+113113+(** {1 Private API} *)
114114+115115+(** Internal functions exposed for use by other modules in the library.
116116+ These are not part of the public API and may change between versions. *)
117117+module Private : sig
118118+ val make :
119119+ sw:Eio.Switch.t ->
120120+ status:int ->
121121+ headers:Headers.t ->
122122+ body:Flow.source_ty Resource.t ->
123123+ url:string ->
124124+ elapsed:float ->
125125+ t
126126+ (** [make ~sw ~status ~headers ~body ~url ~elapsed] constructs a response.
127127+ The response will be automatically closed when the switch is released.
128128+ This function is used internally by the Client module. *)
129129+end
+142
lib/retry.ml
···11+let src = Logs.Src.create "requests.retry" ~doc:"HTTP Request Retry Logic"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+type config = {
55+ max_retries : int;
66+ backoff_factor : float;
77+ backoff_max : float;
88+ status_forcelist : int list;
99+ allowed_methods : Method.t list;
1010+ respect_retry_after : bool;
1111+ jitter : bool;
1212+}
1313+1414+let default_config = {
1515+ max_retries = 3;
1616+ backoff_factor = 0.3;
1717+ backoff_max = 120.0;
1818+ status_forcelist = [408; 429; 500; 502; 503; 504];
1919+ allowed_methods = [`GET; `HEAD; `PUT; `DELETE; `OPTIONS; `TRACE];
2020+ respect_retry_after = true;
2121+ jitter = true;
2222+}
2323+2424+let create_config
2525+ ?(max_retries = 3)
2626+ ?(backoff_factor = 0.3)
2727+ ?(backoff_max = 120.0)
2828+ ?(status_forcelist = [408; 429; 500; 502; 503; 504])
2929+ ?(allowed_methods = [`GET; `HEAD; `PUT; `DELETE; `OPTIONS; `TRACE])
3030+ ?(respect_retry_after = true)
3131+ ?(jitter = true)
3232+ () =
3333+ Log.debug (fun m -> m "Creating retry config: max_retries=%d backoff_factor=%.2f"
3434+ max_retries backoff_factor);
3535+ {
3636+ max_retries;
3737+ backoff_factor;
3838+ backoff_max;
3939+ status_forcelist;
4040+ allowed_methods;
4141+ respect_retry_after;
4242+ jitter;
4343+ }
4444+4545+let should_retry ~config ~method_ ~status =
4646+ let should =
4747+ List.mem method_ config.allowed_methods &&
4848+ List.mem status config.status_forcelist
4949+ in
5050+ Log.debug (fun m -> m "Should retry? method=%s status=%d -> %b"
5151+ (Method.to_string method_) status should);
5252+ should
5353+5454+let calculate_backoff ~config ~attempt =
5555+ let base_delay = config.backoff_factor *. (2.0 ** float_of_int attempt) in
5656+ let delay =
5757+ if config.jitter then
5858+ (* Add random jitter between 0 and base_delay *)
5959+ base_delay +. Random.float base_delay
6060+ else
6161+ base_delay
6262+ in
6363+ let final_delay = min delay config.backoff_max in
6464+ Log.debug (fun m -> m "Backoff calculation: attempt=%d base=%.2f jitter=%b -> %.2f seconds"
6565+ attempt base_delay config.jitter final_delay);
6666+ final_delay
6767+6868+let parse_retry_after value =
6969+ Log.debug (fun m -> m "Parsing Retry-After header: %s" value);
7070+7171+ (* First try to parse as integer (delay in seconds) *)
7272+ match int_of_string_opt value with
7373+ | Some seconds ->
7474+ Log.debug (fun m -> m "Retry-After is %d seconds" seconds);
7575+ Some (float_of_int seconds)
7676+ | None ->
7777+ (* Try to parse as HTTP date *)
7878+ (* This is simplified - real implementation would use a proper HTTP date parser *)
7979+ try
8080+ let time, _tz_offset, _tz_string = Ptime.of_rfc3339 value |> Result.get_ok in
8181+ let now = Unix.time () in
8282+ let target = Ptime.to_float_s time in
8383+ let delay = max 0.0 (target -. now) in
8484+ Log.debug (fun m -> m "Retry-After is HTTP date, delay=%.2f seconds" delay);
8585+ Some delay
8686+ with _ ->
8787+ Log.warn (fun m -> m "Failed to parse Retry-After header: %s" value);
8888+ None
8989+9090+let with_retry ~sw:_ ~clock ~config ~f ~should_retry_exn =
9191+ let rec attempt_with_retry attempt =
9292+ Log.info (fun m -> m "Attempt %d/%d" attempt (config.max_retries + 1));
9393+9494+ match f () with
9595+ | result ->
9696+ if attempt > 1 then
9797+ Log.info (fun m -> m "Request succeeded after %d attempts" attempt);
9898+ result
9999+ | exception exn when attempt <= config.max_retries && should_retry_exn exn ->
100100+ let delay = calculate_backoff ~config ~attempt in
101101+102102+ Log.warn (fun m -> m "Request failed (attempt %d/%d): %s. Retrying in %.2f seconds..."
103103+ attempt (config.max_retries + 1) (Printexc.to_string exn) delay);
104104+105105+ (* Sleep for the backoff duration *)
106106+ Eio.Time.sleep clock delay;
107107+108108+ attempt_with_retry (attempt + 1)
109109+ | exception exn ->
110110+ if attempt > config.max_retries then
111111+ Log.err (fun m -> m "Request failed after %d attempts: %s"
112112+ attempt (Printexc.to_string exn))
113113+ else
114114+ Log.err (fun m -> m "Request failed and won't be retried: %s"
115115+ (Printexc.to_string exn));
116116+ raise exn
117117+ in
118118+ attempt_with_retry 1
119119+120120+let pp_config ppf config =
121121+ Format.fprintf ppf "@[<v>Retry Configuration:@,\
122122+ @[<v 2>\
123123+ max_retries: %d@,\
124124+ backoff_factor: %.2f@,\
125125+ backoff_max: %.1f seconds@,\
126126+ status_forcelist: [%a]@,\
127127+ allowed_methods: [%a]@,\
128128+ respect_retry_after: %b@,\
129129+ jitter: %b\
130130+ @]@]"
131131+ config.max_retries
132132+ config.backoff_factor
133133+ config.backoff_max
134134+ Format.(pp_print_list ~pp_sep:(fun ppf () -> fprintf ppf ", ") pp_print_int) config.status_forcelist
135135+ Format.(pp_print_list ~pp_sep:(fun ppf () -> fprintf ppf ", ")
136136+ (fun ppf m -> pp_print_string ppf (Method.to_string m))) config.allowed_methods
137137+ config.respect_retry_after
138138+ config.jitter
139139+140140+let log_retry ~attempt ~delay ~reason =
141141+ Log.info (fun m -> m "Retry attempt %d scheduled in %.2f seconds. Reason: %s"
142142+ attempt delay reason)
+55
lib/retry.mli
···11+(** HTTP request retry logic with exponential backoff *)
22+33+open Eio
44+55+(** Log source for retry operations *)
66+val src : Logs.Src.t
77+88+(** Retry configuration *)
99+type config = {
1010+ max_retries : int; (** Maximum number of retry attempts *)
1111+ backoff_factor : float; (** Exponential backoff multiplier *)
1212+ backoff_max : float; (** Maximum backoff time in seconds *)
1313+ status_forcelist : int list; (** HTTP status codes to retry *)
1414+ allowed_methods : Method.t list; (** Methods safe to retry *)
1515+ respect_retry_after : bool; (** Honor Retry-After response header *)
1616+ jitter : bool; (** Add randomness to prevent thundering herd *)
1717+}
1818+1919+(** Default retry configuration *)
2020+val default_config : config
2121+2222+(** Create a custom retry configuration *)
2323+val create_config :
2424+ ?max_retries:int ->
2525+ ?backoff_factor:float ->
2626+ ?backoff_max:float ->
2727+ ?status_forcelist:int list ->
2828+ ?allowed_methods:Method.t list ->
2929+ ?respect_retry_after:bool ->
3030+ ?jitter:bool ->
3131+ unit -> config
3232+3333+(** Check if a request should be retried *)
3434+val should_retry : config:config -> method_:Method.t -> status:int -> bool
3535+3636+(** Calculate backoff delay for a given attempt *)
3737+val calculate_backoff : config:config -> attempt:int -> float
3838+3939+(** Parse Retry-After header value (seconds or HTTP date) *)
4040+val parse_retry_after : string -> float option
4141+4242+(** Execute a request with retry logic *)
4343+val with_retry :
4444+ sw:Switch.t ->
4545+ clock:_ Time.clock ->
4646+ config:config ->
4747+ f:(unit -> 'a) ->
4848+ should_retry_exn:(exn -> bool) ->
4949+ 'a
5050+5151+(** Pretty print retry configuration *)
5252+val pp_config : Format.formatter -> config -> unit
5353+5454+(** Log retry attempt information *)
5555+val log_retry : attempt:int -> delay:float -> reason:string -> unit
···11+(** HTTP status codes following RFC 7231 and extensions *)
22+33+(** Log source for status code operations *)
44+val src : Logs.Src.t
55+66+(** {1 Status Categories} *)
77+88+type informational = [
99+ | `Continue (** 100 - Client should continue with request *)
1010+ | `Switching_protocols (** 101 - Server is switching protocols *)
1111+ | `Processing (** 102 - Server has received and is processing the request *)
1212+ | `Early_hints (** 103 - Used to return some response headers before final HTTP message *)
1313+]
1414+(** 1xx Informational responses *)
1515+1616+type success = [
1717+ | `OK (** 200 - Standard response for successful HTTP requests *)
1818+ | `Created (** 201 - Request has been fulfilled; new resource created *)
1919+ | `Accepted (** 202 - Request accepted, processing pending *)
2020+ | `Non_authoritative_information (** 203 - Request processed, information may be from another source *)
2121+ | `No_content (** 204 - Request processed, no content returned *)
2222+ | `Reset_content (** 205 - Request processed, no content returned, reset document view *)
2323+ | `Partial_content (** 206 - Partial resource return due to request header *)
2424+ | `Multi_status (** 207 - XML, can contain multiple separate responses *)
2525+ | `Already_reported (** 208 - Results previously returned *)
2626+ | `Im_used (** 226 - Request fulfilled, response is instance-manipulations *)
2727+]
2828+(** 2xx Success responses *)
2929+3030+type redirection = [
3131+ | `Multiple_choices (** 300 - Multiple options for the resource delivered *)
3232+ | `Moved_permanently (** 301 - This and all future requests directed to the given URI *)
3333+ | `Found (** 302 - Temporary response to request found via alternative URI *)
3434+ | `See_other (** 303 - Response to request found via alternative URI *)
3535+ | `Not_modified (** 304 - Resource has not been modified since last requested *)
3636+ | `Use_proxy (** 305 - Content located elsewhere, retrieve from there (deprecated) *)
3737+ | `Temporary_redirect (** 307 - Connect again to different URI as provided *)
3838+ | `Permanent_redirect (** 308 - Connect again to a different URI using the same method *)
3939+]
4040+(** 3xx Redirection messages *)
4141+4242+type client_error = [
4343+ | `Bad_request (** 400 - Request cannot be fulfilled due to bad syntax *)
4444+ | `Unauthorized (** 401 - Authentication is possible but has failed *)
4545+ | `Payment_required (** 402 - Payment required, reserved for future use *)
4646+ | `Forbidden (** 403 - Server refuses to respond to request *)
4747+ | `Not_found (** 404 - Requested resource could not be found *)
4848+ | `Method_not_allowed (** 405 - Request method not supported by that resource *)
4949+ | `Not_acceptable (** 406 - Content not acceptable according to the Accept headers *)
5050+ | `Proxy_authentication_required (** 407 - Client must first authenticate itself with the proxy *)
5151+ | `Request_timeout (** 408 - Server timed out waiting for the request *)
5252+ | `Conflict (** 409 - Request could not be processed because of conflict *)
5353+ | `Gone (** 410 - Resource is no longer available and will not be available again *)
5454+ | `Length_required (** 411 - Request did not specify the length of its content *)
5555+ | `Precondition_failed (** 412 - Server does not meet request preconditions *)
5656+ | `Payload_too_large (** 413 - Request is larger than the server is willing or able to process *)
5757+ | `Uri_too_long (** 414 - URI provided was too long for the server to process *)
5858+ | `Unsupported_media_type (** 415 - Server does not support media type *)
5959+ | `Range_not_satisfiable (** 416 - Client has asked for unprovidable portion of the file *)
6060+ | `Expectation_failed (** 417 - Server cannot meet requirements of Expect request-header field *)
6161+ | `I_m_a_teapot (** 418 - I'm a teapot (RFC 2324) *)
6262+ | `Misdirected_request (** 421 - Request was directed at a server that is not able to produce a response *)
6363+ | `Unprocessable_entity (** 422 - Request unable to be followed due to semantic errors *)
6464+ | `Locked (** 423 - Resource that is being accessed is locked *)
6565+ | `Failed_dependency (** 424 - Request failed due to failure of a previous request *)
6666+ | `Too_early (** 425 - Server is unwilling to risk processing a request that might be replayed *)
6767+ | `Upgrade_required (** 426 - Client should switch to a different protocol *)
6868+ | `Precondition_required (** 428 - Origin server requires the request to be conditional *)
6969+ | `Too_many_requests (** 429 - User has sent too many requests in a given amount of time *)
7070+ | `Request_header_fields_too_large (** 431 - Server is unwilling to process the request *)
7171+ | `Unavailable_for_legal_reasons (** 451 - Resource unavailable for legal reasons *)
7272+]
7373+(** 4xx Client error responses *)
7474+7575+type server_error = [
7676+ | `Internal_server_error (** 500 - Generic error message *)
7777+ | `Not_implemented (** 501 - Server does not recognise method or lacks ability to fulfill *)
7878+ | `Bad_gateway (** 502 - Server received an invalid response from upstream server *)
7979+ | `Service_unavailable (** 503 - Server is currently unavailable *)
8080+ | `Gateway_timeout (** 504 - Gateway did not receive response from upstream server *)
8181+ | `Http_version_not_supported (** 505 - Server does not support the HTTP protocol version *)
8282+ | `Variant_also_negotiates (** 506 - Content negotiation for the request results in a circular reference *)
8383+ | `Insufficient_storage (** 507 - Server is unable to store the representation *)
8484+ | `Loop_detected (** 508 - Server detected an infinite loop while processing the request *)
8585+ | `Not_extended (** 510 - Further extensions to the request are required *)
8686+ | `Network_authentication_required (** 511 - Client needs to authenticate to gain network access *)
8787+]
8888+(** 5xx Server error responses *)
8989+9090+type standard = [
9191+ | informational
9292+ | success
9393+ | redirection
9494+ | client_error
9595+ | server_error
9696+]
9797+(** All standard HTTP status codes *)
9898+9999+type t = [
100100+ | `Code of int (** Any status code as an integer *)
101101+ | standard
102102+]
103103+(** HTTP status type *)
104104+105105+(** {1 Conversion Functions} *)
106106+107107+val to_int : t -> int
108108+(** Convert status to its integer code *)
109109+110110+val of_int : int -> t
111111+(** Convert an integer to a status *)
112112+113113+val to_string : t -> string
114114+(** Get the string representation of a status code (e.g., "200", "404") *)
115115+116116+val reason_phrase : t -> string
117117+(** Get the standard reason phrase for a status code (e.g., "OK", "Not Found") *)
118118+119119+(** {1 Classification Functions} *)
120120+121121+val is_informational : t -> bool
122122+(** Check if status code is informational (1xx) *)
123123+124124+val is_success : t -> bool
125125+(** Check if status code indicates success (2xx) *)
126126+127127+val is_redirection : t -> bool
128128+(** Check if status code indicates redirection (3xx) *)
129129+130130+val is_client_error : t -> bool
131131+(** Check if status code indicates client error (4xx) *)
132132+133133+val is_server_error : t -> bool
134134+(** Check if status code indicates server error (5xx) *)
135135+136136+val is_error : t -> bool
137137+(** Check if status code indicates any error (4xx or 5xx) *)
138138+139139+(** {1 Retry Policy} *)
140140+141141+val is_retryable : t -> bool
142142+(** Check if a status code suggests the request could be retried.
143143+ Returns true for:
144144+ - 408 Request Timeout
145145+ - 429 Too Many Requests
146146+ - 502 Bad Gateway
147147+ - 503 Service Unavailable
148148+ - 504 Gateway Timeout
149149+ - Any 5xx errors *)
150150+151151+val should_retry_on_different_host : t -> bool
152152+(** Check if a status code suggests retrying on a different host might help.
153153+ Returns true for:
154154+ - 502 Bad Gateway
155155+ - 503 Service Unavailable
156156+ - 504 Gateway Timeout *)
157157+158158+(** {1 Pretty Printing} *)
159159+160160+val pp : Format.formatter -> t -> unit
161161+(** Pretty printer for status codes *)
162162+163163+val pp_hum : Format.formatter -> t -> unit
164164+(** Human-readable pretty printer that includes both code and reason phrase *)
+48
lib/timeout.ml
···11+let src = Logs.Src.create "requests.timeout" ~doc:"HTTP Request Timeouts"
22+module Log = (val Logs.src_log src : Logs.LOG)
33+44+type t = {
55+ connect : float option;
66+ read : float option;
77+ total : float option;
88+}
99+1010+let none = {
1111+ connect = None;
1212+ read = None;
1313+ total = None;
1414+}
1515+1616+let create ?connect ?read ?total () = {
1717+ connect;
1818+ read;
1919+ total;
2020+}
2121+2222+let default = {
2323+ connect = Some 10.0;
2424+ read = Some 30.0;
2525+ total = None;
2626+}
2727+2828+let connect t = t.connect
2929+let read t = t.read
3030+let total t = t.total
3131+3232+let pp ppf t =
3333+ let items = [] in
3434+ let items = match t.connect with
3535+ | Some c -> (Printf.sprintf "connect:%.1fs" c) :: items
3636+ | None -> items
3737+ in
3838+ let items = match t.read with
3939+ | Some r -> (Printf.sprintf "read:%.1fs" r) :: items
4040+ | None -> items
4141+ in
4242+ let items = match t.total with
4343+ | Some tot -> (Printf.sprintf "total:%.1fs" tot) :: items
4444+ | None -> items
4545+ in
4646+ match items with
4747+ | [] -> Format.fprintf ppf "no timeouts"
4848+ | _ -> Format.fprintf ppf "%s" (String.concat ", " (List.rev items))
+28
lib/timeout.mli
···11+(** Timeout configuration *)
22+33+(** Log source for timeout operations *)
44+val src : Logs.Src.t
55+66+type t
77+(** Timeout configuration *)
88+99+val none : t
1010+(** No timeouts *)
1111+1212+val create : ?connect:float -> ?read:float -> ?total:float -> unit -> t
1313+(** Create timeout configuration with optional connect, read, and total timeouts in seconds *)
1414+1515+val default : t
1616+(** Sensible defaults: 10s connect, 30s read, no total limit *)
1717+1818+val connect : t -> float option
1919+(** Get connection timeout *)
2020+2121+val read : t -> float option
2222+(** Get read timeout *)
2323+2424+val total : t -> float option
2525+(** Get total request timeout *)
2626+2727+val pp : Format.formatter -> t -> unit
2828+(** Pretty printer for timeout configuration *)
+39
requests.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "Clean Eio-style HTTPS client library for OCaml"
44+description:
55+ "A modern HTTP(S) client library for OCaml with Eio support, providing a clean API for making web requests with automatic TLS/CA certificate handling"
66+maintainer: ["Your Name"]
77+authors: ["Your Name"]
88+license: "MIT"
99+homepage: "https://github.com/username/requests"
1010+bug-reports: "https://github.com/username/requests/issues"
1111+depends: [
1212+ "ocaml"
1313+ "dune" {>= "3.0" & >= "3.0"}
1414+ "eio"
1515+ "cohttp-eio"
1616+ "tls-eio"
1717+ "ca-certs"
1818+ "mirage-crypto-rng-eio"
1919+ "uri"
2020+ "digestif"
2121+ "base64"
2222+ "logs"
2323+ "odoc" {with-doc}
2424+]
2525+build: [
2626+ ["dune" "subst"] {dev}
2727+ [
2828+ "dune"
2929+ "build"
3030+ "-p"
3131+ name
3232+ "-j"
3333+ jobs
3434+ "@install"
3535+ "@runtest" {with-test}
3636+ "@doc" {with-doc}
3737+ ]
3838+]
3939+dev-repo: "git+https://github.com/username/requests.git"