My aggregated monorepo of OCaml code, automaintained
at doc-fixes 1323 lines 55 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** OCaml HTTP client library with streaming support *) 7 8let src = Logs.Src.create "requests" ~doc:"HTTP Client Library" 9module Log = (val Logs.src_log src : Logs.LOG) 10 11module Method = Method 12module Mime = Mime 13module Headers = Headers 14module Http_date = Http_date 15module Auth = Auth 16module Proxy = Proxy 17module Proxy_tunnel = Proxy_tunnel 18module Timeout = Timeout 19module Body = Body 20module Response = Response 21module One = One 22module Http_client = Http_client 23module Status = Status 24module Error = Error 25module Retry = Retry 26module Cache_control = Cache_control 27module Response_limits = Response_limits 28module Expect_continue = Expect_continue 29module Version = Version 30module Link = Link 31module Timing = Timing 32module Header_name = Header_name 33module Header_parsing = Header_parsing 34module Websocket = Websocket 35 36(** Minimum TLS version configuration - re-exported from Tls_config. *) 37type tls_version = Tls_config.tls_version = 38 | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *) 39 | TLS_1_3 (** TLS 1.3 minimum (most secure, may not work with older servers) *) 40 41(* Main API - Session functionality with connection pooling *) 42 43type t = T : { 44 sw : Eio.Switch.t; 45 clock : [> float Eio.Time.clock_ty] Eio.Resource.t; 46 net : [> [> `Generic] Eio.Net.ty] Eio.Resource.t; 47 http_pool : Conpool.t; 48 https_pool : Conpool.t; 49 cookie_jar : Cookeio_jar.t; 50 cookie_mutex : Eio.Mutex.t; 51 default_headers : Headers.t; 52 auth : Auth.t option; 53 timeout : Timeout.t; 54 follow_redirects : bool; 55 max_redirects : int; 56 verify_tls : bool; 57 tls_config : Tls.Config.client option; 58 retry : Retry.config option; 59 persist_cookies : bool; 60 xdg : Xdge.t option; 61 auto_decompress : bool; 62 expect_100_continue : Expect_continue.t; (** 100-continue configuration *) 63 base_url : string option; (** Per Recommendation #11: Base URL for relative paths *) 64 xsrf_cookie_name : string option; (** Per Recommendation #24: XSRF cookie name *) 65 xsrf_header_name : string; (** Per Recommendation #24: XSRF header name *) 66 proxy : Proxy.config option; (** HTTP/HTTPS proxy configuration *) 67 allow_insecure_auth : bool; (** Allow auth over HTTP for dev/testing *) 68 nonce_counter : Auth.Nonce_counter.t; (** Digest auth nonce count tracker *) 69 70 (* Statistics - mutable but NOTE: when sessions are derived via record update 71 syntax ({t with field = value}), these are copied not shared. Each derived 72 session has independent statistics. Use the same session object to track 73 cumulative stats. *) 74 mutable requests_made : int; 75 mutable total_time : float; 76 mutable retries_count : int; 77} -> t 78 79let create 80 ~sw 81 ?http_pool 82 ?https_pool 83 ?cookie_jar 84 ?(default_headers = Headers.empty) 85 ?auth 86 ?(timeout = Timeout.default) 87 ?(follow_redirects = true) 88 ?(max_redirects = 10) 89 ?(verify_tls = true) 90 ?tls_config 91 ?(min_tls_version = TLS_1_2) 92 ?(max_connections_per_host = 10) 93 ?(connection_idle_timeout = 60.0) 94 ?(connection_lifetime = 300.0) 95 ?retry 96 ?(persist_cookies = false) 97 ?xdg 98 ?(auto_decompress = true) 99 ?(expect_100_continue = `Threshold Expect_continue.default_threshold) 100 ?base_url 101 ?(xsrf_cookie_name = Some "XSRF-TOKEN") (* Per Recommendation #24 *) 102 ?(xsrf_header_name = "X-XSRF-TOKEN") 103 ?proxy 104 ?(allow_insecure_auth = false) 105 env = 106 107 Mirage_crypto_rng_unix.use_default (); (* avsm: is this bad to do twice? very common footgun to forget to initialise *) 108 let clock = env#clock in 109 let net = env#net in 110 111 let xdg = match xdg, persist_cookies with 112 | Some x, _ -> Some x 113 | None, true -> Some (Xdge.create env#fs "requests") 114 | None, false -> None 115 in 116 117 (* Create TLS config for HTTPS pool if needed 118 Per Recommendation #6: Enforce minimum TLS version *) 119 let tls_config = Tls_config.create_client_opt 120 ?existing_config:tls_config 121 ~verify_tls 122 ~min_tls_version 123 ~host:"session-init" 124 () 125 in 126 127 (* Create connection pools if not provided *) 128 let pool_config = Conpool.Config.make 129 ~max_connections_per_endpoint:max_connections_per_host 130 ~max_idle_time:connection_idle_timeout 131 ~max_connection_lifetime:connection_lifetime 132 () 133 in 134 135 (* HTTP pool - plain TCP connections *) 136 let http_pool = match http_pool with 137 | Some p -> p 138 | None -> 139 Conpool.create ~sw ~net ~clock ~config:pool_config () 140 in 141 142 (* HTTPS pool - TLS-wrapped connections *) 143 let https_pool = match https_pool with 144 | Some p -> p 145 | None -> 146 Conpool.create ~sw ~net ~clock ?tls:tls_config ~config:pool_config () 147 in 148 149 Log.info (fun m -> m "Created Requests session with connection pools (max_per_host=%d, TLS=%b)" 150 max_connections_per_host (Option.is_some tls_config)); 151 152 let cookie_jar = match cookie_jar, persist_cookies, xdg with 153 | Some jar, _, _ -> jar 154 | None, true, Some xdg_ctx -> 155 let data_dir = Xdge.data_dir xdg_ctx in 156 let cookie_file = Eio.Path.(data_dir / "cookies.txt") in 157 Cookeio_jar.load ~clock cookie_file 158 | None, _, _ -> 159 Cookeio_jar.create () 160 in 161 162 (* Build expect_100_continue configuration from polymorphic variant *) 163 let expect_100_timeout = Timeout.expect_100_continue timeout |> Option.value ~default:1.0 in 164 let expect_100_config = Expect_continue.of_config ~timeout:expect_100_timeout expect_100_continue in 165 166 (* Normalize base_url: remove trailing slash for consistent path joining *) 167 let base_url = Option.map (fun url -> 168 if String.length url > 0 && url.[String.length url - 1] = '/' then 169 String.sub url 0 (String.length url - 1) 170 else url 171 ) base_url in 172 173 T { 174 sw; 175 clock; 176 net; 177 http_pool; 178 https_pool; 179 cookie_jar; 180 cookie_mutex = Eio.Mutex.create (); 181 default_headers; 182 auth; 183 timeout; 184 follow_redirects; 185 max_redirects; 186 verify_tls; 187 tls_config; 188 retry; 189 persist_cookies; 190 xdg; 191 auto_decompress; 192 expect_100_continue = expect_100_config; 193 base_url; 194 xsrf_cookie_name; 195 xsrf_header_name; 196 proxy; 197 allow_insecure_auth; 198 nonce_counter = Auth.Nonce_counter.create (); 199 requests_made = 0; 200 total_time = 0.0; 201 retries_count = 0; 202 } 203 204let set_default_header (T t) key value = 205 T { t with default_headers = Headers.set (Header_name.of_string key) value t.default_headers } 206 207let remove_default_header (T t) key = 208 T { t with default_headers = Headers.remove (Header_name.of_string key) t.default_headers } 209 210let set_auth (T t) auth = 211 Log.debug (fun m -> m "Setting authentication method"); 212 T { t with auth = Some auth } 213 214let clear_auth (T t) = 215 Log.debug (fun m -> m "Clearing authentication"); 216 T { t with auth = None } 217 218let set_timeout (T t) timeout = 219 Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout); 220 T { t with timeout } 221 222let set_retry (T t) config = 223 Log.debug (fun m -> m "Setting retry config: max_retries=%d" config.Retry.max_retries); 224 T { t with retry = Some config } 225 226let cookies (T t) = t.cookie_jar 227let clear_cookies (T t) = Cookeio_jar.clear t.cookie_jar 228 229let set_proxy (T t) config = 230 Log.debug (fun m -> m "Setting proxy: %s:%d" config.Proxy.host config.Proxy.port); 231 T { t with proxy = Some config } 232 233let clear_proxy (T t) = 234 Log.debug (fun m -> m "Clearing proxy configuration"); 235 T { t with proxy = None } 236 237let proxy (T t) = t.proxy 238 239(* Redirect handling - delegated to shared Redirect module *) 240 241(** {1 URL Resolution and Path Templating} 242 243 Per Recommendation #11: Base URL support with RFC 3986 resolution. 244 Per Recommendation #29: Path parameter templating. *) 245 246(** Check if a URL is relative (no scheme) *) 247let is_relative_url url = 248 let uri = Uri.of_string url in 249 Option.is_none (Uri.scheme uri) 250 251(** Resolve a URL against a base URL per RFC 3986 Section 5. 252 If the URL is already absolute, return it unchanged. 253 If base_url is None, return the original URL. *) 254let resolve_url ?base_url url = 255 match base_url with 256 | None -> url 257 | Some base -> 258 if is_relative_url url then begin 259 let base_uri = Uri.of_string base in 260 let rel_uri = Uri.of_string url in 261 let scheme = Uri.scheme base_uri |> Option.value ~default:"https" in 262 let resolved = Uri.resolve scheme base_uri rel_uri in 263 Log.debug (fun m -> m "Resolved relative URL %s against base %s -> %s" 264 url base (Uri.to_string resolved)); 265 Uri.to_string resolved 266 end else 267 url (* Already absolute *) 268 269(** Substitute path parameters in a URL template. 270 Per Recommendation #29 and RFC 6570 (simplified). 271 Template: "/users/{id}/posts/{post_id}" 272 Params: [("id", "123"); ("post_id", "456")] 273 Result: "/users/123/posts/456" 274 Values are automatically URL-encoded. *) 275let substitute_path_params url params = 276 List.fold_left (fun url (key, value) -> 277 let pattern = "{" ^ key ^ "}" in 278 let encoded_value = Uri.pct_encode value in 279 let rec replace s = 280 match String.split_on_char '{' s with 281 | [] -> "" 282 | [single] -> single 283 | before :: rest -> 284 let rest_str = String.concat "{" rest in 285 if String.length rest_str >= String.length key + 1 && 286 String.sub rest_str 0 (String.length key) = key && 287 rest_str.[String.length key] = '}' then 288 before ^ encoded_value ^ String.sub rest_str (String.length key + 1) 289 (String.length rest_str - String.length key - 1) 290 else 291 before ^ "{" ^ replace rest_str 292 in 293 if String.length pattern > 0 then replace url else url 294 ) url params 295 296(** {1 XSRF Token Handling} 297 298 Per Recommendation #24: Automatically inject XSRF tokens from cookies. *) 299 300(** Extract XSRF token from cookies and add to headers if: 301 1. xsrf_cookie_name is configured 302 2. The cookie exists 303 3. The request is same-origin (security) *) 304let apply_xsrf_token ~cookie_jar ~clock ~xsrf_cookie_name ~xsrf_header_name ~url headers = 305 match xsrf_cookie_name with 306 | None -> headers (* XSRF handling disabled *) 307 | Some cookie_name -> 308 let uri = Uri.of_string url in 309 let domain = Uri.host uri |> Option.value ~default:"" in 310 let path = Uri.path uri in 311 let is_secure = Uri.scheme uri = Some "https" in 312 313 (* Get cookies for this domain *) 314 let cookies = Cookeio_jar.get_cookies cookie_jar ~clock 315 ~domain ~path ~is_secure in 316 317 (* Find the XSRF token cookie *) 318 let xsrf_value = List.find_map (fun cookie -> 319 if Cookeio.name cookie = cookie_name then 320 Some (Cookeio.value cookie) 321 else 322 None 323 ) cookies in 324 325 match xsrf_value with 326 | Some token -> 327 Log.debug (fun m -> m "Adding XSRF token header: %s" xsrf_header_name); 328 (* XSRF header name is configurable, use string variant *) 329 Headers.set_string xsrf_header_name token headers 330 | None -> headers 331 332(* Internal request function using connection pools *) 333let make_request_internal (T t) ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects 334 ?(path_params=[]) ~method_ url = 335 let start_time = Unix.gettimeofday () in 336 let method_str = Method.to_string method_ in 337 338 (* Per Recommendation #29: Substitute path parameters first *) 339 let url = if path_params = [] then url else substitute_path_params url path_params in 340 341 (* Per Recommendation #11: Resolve relative URLs against base_url *) 342 let url = resolve_url ?base_url:t.base_url url in 343 344 Log.info (fun m -> m "Making %s request to %s" method_str url); 345 346 (* Merge headers *) 347 let headers = match headers with 348 | Some h -> Headers.merge t.default_headers h 349 | None -> t.default_headers 350 in 351 352 (* Add default User-Agent if not already set - per RFC 9110 Section 10.1.5 *) 353 let headers = 354 if not (Headers.mem `User_agent headers) then 355 Headers.set `User_agent Version.user_agent headers 356 else 357 headers 358 in 359 360 (* Per Recommendation #24: Apply XSRF token from cookies *) 361 let headers = Eio.Mutex.use_ro t.cookie_mutex (fun () -> 362 apply_xsrf_token 363 ~cookie_jar:t.cookie_jar 364 ~clock:t.clock 365 ~xsrf_cookie_name:t.xsrf_cookie_name 366 ~xsrf_header_name:t.xsrf_header_name 367 ~url 368 headers 369 ) in 370 371 (* Use provided auth or default *) 372 let auth = match auth with 373 | Some a -> Some a 374 | None -> t.auth 375 in 376 377 (* Apply auth with HTTPS validation per RFC 7617/6750 *) 378 let headers = match auth with 379 | Some a -> 380 Log.debug (fun m -> m "Applying authentication"); 381 Auth.apply_secure ~allow_insecure_auth:t.allow_insecure_auth ~url a headers 382 | None -> headers 383 in 384 385 (* Add content type from body *) 386 let base_headers = match body with 387 | Some b -> (match Body.content_type b with 388 | Some mime -> Headers.content_type mime headers 389 | None -> headers) 390 | None -> headers 391 in 392 393 (* Add Accept-Encoding header for auto-decompression if not already set *) 394 let base_headers = 395 if t.auto_decompress && not (Headers.mem `Accept_encoding base_headers) then 396 Headers.set `Accept_encoding "gzip, deflate" base_headers 397 else 398 base_headers 399 in 400 401 (* Get request body, defaulting to empty *) 402 let request_body = Option.value ~default:Body.empty body in 403 404 (* Helper to extract and store cookies from response headers *) 405 let extract_cookies_from_headers resp_headers url_str = 406 let uri = Uri.of_string url_str in 407 let cookie_domain = Uri.host uri |> Option.value ~default:"" in 408 let cookie_path = Uri.path uri in 409 Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () -> 410 match Headers.get_all `Set_cookie resp_headers with 411 | [] -> () 412 | cookie_headers -> 413 Log.debug (fun m -> m "Received %d Set-Cookie headers" (List.length cookie_headers)); 414 List.iter (fun cookie_str -> 415 let now = fun () -> Ptime.of_float_s (Eio.Time.now t.clock) |> Option.get in 416 match Cookeio.of_set_cookie_header ~now ~domain:cookie_domain ~path:cookie_path cookie_str with 417 | Ok cookie -> 418 Log.debug (fun m -> m "Storing cookie: %s" (Cookeio.name cookie)); 419 Cookeio_jar.add_cookie t.cookie_jar cookie 420 | Error msg -> 421 Log.warn (fun m -> m "Failed to parse cookie: %s (%s)" cookie_str msg) 422 ) cookie_headers 423 ) 424 in 425 426 (* Track the original URL for cross-origin redirect detection *) 427 let original_uri = Uri.of_string url in 428 429 let response = 430 431 (* Execute request with redirect handling 432 headers_for_request: the headers to use for this specific request (may have auth stripped) 433 ~method_: the HTTP method for this request (may be changed by 303 redirect) 434 ~body: the request body (may be stripped by 303 redirect) *) 435 let rec make_with_redirects ~headers_for_request ~method_ ~body url_to_fetch redirects_left = 436 let uri_to_fetch = Uri.of_string url_to_fetch in 437 438 (* Parse the redirect URL to get correct host and port *) 439 let redirect_host = match Uri.host uri_to_fetch with 440 | Some h -> h 441 | None -> raise (Error.err (Error.Invalid_redirect { url = url_to_fetch; reason = "URL must contain a host" })) 442 in 443 let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with 444 | Some "https", None -> 443 445 | Some "https", Some p -> p 446 | Some "http", None -> 80 447 | Some "http", Some p -> p 448 | _, Some p -> p 449 | _ -> 80 450 in 451 452 (* Create endpoint for this specific URL *) 453 let redirect_endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in 454 455 (* Determine if we need TLS based on this URL's scheme *) 456 let redirect_is_https = match Uri.scheme uri_to_fetch with 457 | Some "https" -> true 458 | _ -> false 459 in 460 461 (* Choose the appropriate connection pool for this URL *) 462 let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in 463 464 (* Get cookies for this specific URL *) 465 let fetch_domain = redirect_host in 466 let fetch_path = Uri.path uri_to_fetch in 467 let fetch_is_secure = redirect_is_https in 468 let headers_with_cookies = 469 Eio.Mutex.use_ro t.cookie_mutex (fun () -> 470 let cookies = Cookeio_jar.get_cookies t.cookie_jar ~clock:t.clock 471 ~domain:fetch_domain ~path:fetch_path ~is_secure:fetch_is_secure in 472 match cookies with 473 | [] -> 474 Log.debug (fun m -> m "No cookies found for %s%s" fetch_domain fetch_path); 475 headers_for_request 476 | cookies -> 477 let cookie_header = Cookeio.make_cookie_header cookies in 478 Log.debug (fun m -> m "Adding %d cookies for %s%s: Cookie: %s" 479 (List.length cookies) fetch_domain fetch_path cookie_header); 480 Headers.set `Cookie cookie_header headers_for_request 481 ) 482 in 483 484 (* Log the request being made at Info level *) 485 Log.info (fun m -> m ""); 486 Log.info (fun m -> m "=== Request to %s ===" url_to_fetch); 487 Log.info (fun m -> m "> %s %s HTTP/1.1" method_str (Uri.to_string uri_to_fetch)); 488 Log.info (fun m -> m "> Request Headers:"); 489 Headers.to_list headers_with_cookies |> List.iter (fun (k, v) -> 490 Log.info (fun m -> m "> %s: %s" k v) 491 ); 492 Log.info (fun m -> m ""); 493 494 (* Determine if we should use proxy for this URL *) 495 let use_proxy = match t.proxy with 496 | None -> false 497 | Some proxy -> not (Proxy.should_bypass proxy url_to_fetch) 498 in 499 500 let make_request_fn () = 501 match use_proxy, redirect_is_https, t.proxy with 502 | false, _, _ -> 503 (* Direct connection - use connection pool *) 504 Conpool.with_connection redirect_pool redirect_endpoint (fun flow -> 505 Http_client.make_request_100_continue_decompress 506 ~expect_100:t.expect_100_continue 507 ~clock:t.clock 508 ~sw:t.sw 509 ~method_ ~uri:uri_to_fetch 510 ~headers:headers_with_cookies ~body 511 ~auto_decompress:t.auto_decompress flow 512 ) 513 514 | true, false, Some proxy -> 515 (* HTTP via proxy - connect to proxy and use absolute-URI form *) 516 Log.debug (fun m -> m "Routing HTTP request via proxy %s:%d" 517 proxy.Proxy.host proxy.Proxy.port); 518 let proxy_endpoint = Conpool.Endpoint.make 519 ~host:proxy.Proxy.host ~port:proxy.Proxy.port in 520 Conpool.with_connection t.http_pool proxy_endpoint (fun flow -> 521 (* Write request using absolute-URI form *) 522 Http_write.write_and_flush flow (fun w -> 523 Http_write.request_via_proxy w ~sw:t.sw ~method_ ~uri:uri_to_fetch 524 ~headers:headers_with_cookies ~body 525 ~proxy_auth:proxy.Proxy.auth 526 ); 527 (* Read response *) 528 let limits = Response_limits.default in 529 let buf_read = Http_read.of_flow ~max_size:65536 flow in 530 let _version, status, resp_headers, body_str = 531 Http_read.response ~limits ~method_ buf_read in 532 (* Handle decompression if enabled *) 533 let body_str = match t.auto_decompress, Headers.get `Content_encoding resp_headers with 534 | true, Some encoding -> 535 Http_client.decompress_body ~limits ~content_encoding:encoding body_str 536 | _ -> body_str 537 in 538 (status, resp_headers, body_str) 539 ) 540 541 | true, true, Some proxy -> 542 (* HTTPS via proxy - establish CONNECT tunnel then TLS *) 543 Log.debug (fun m -> m "Routing HTTPS request via proxy %s:%d (CONNECT tunnel)" 544 proxy.Proxy.host proxy.Proxy.port); 545 (* Establish TLS tunnel through proxy *) 546 let tunnel_flow = Proxy_tunnel.connect_with_tls 547 ~sw:t.sw ~net:t.net ~clock:t.clock 548 ~proxy 549 ~target_host:redirect_host 550 ~target_port:redirect_port 551 ?tls_config:t.tls_config 552 () 553 in 554 (* Send request through tunnel using normal format (not absolute-URI) *) 555 Http_client.make_request_100_continue_decompress 556 ~expect_100:t.expect_100_continue 557 ~clock:t.clock 558 ~sw:t.sw 559 ~method_ ~uri:uri_to_fetch 560 ~headers:headers_with_cookies ~body 561 ~auto_decompress:t.auto_decompress tunnel_flow 562 563 | true, _, None -> 564 (* Should not happen due to use_proxy check *) 565 Conpool.with_connection redirect_pool redirect_endpoint (fun flow -> 566 Http_client.make_request_100_continue_decompress 567 ~expect_100:t.expect_100_continue 568 ~clock:t.clock 569 ~sw:t.sw 570 ~method_ ~uri:uri_to_fetch 571 ~headers:headers_with_cookies ~body 572 ~auto_decompress:t.auto_decompress flow 573 ) 574 in 575 576 (* Apply timeout if specified *) 577 let status, resp_headers, response_body_str = 578 let timeout_val = Option.value timeout ~default:t.timeout in 579 match Timeout.total timeout_val with 580 | Some seconds -> 581 Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds); 582 Eio.Time.with_timeout_exn t.clock seconds make_request_fn 583 | None -> make_request_fn () 584 in 585 586 (* Log response headers at Info level *) 587 Log.info (fun m -> m "< HTTP/1.1 %d" status); 588 Log.info (fun m -> m "< Response Headers:"); 589 Headers.to_list resp_headers |> List.iter (fun (k, v) -> 590 Log.info (fun m -> m "< %s: %s" k v) 591 ); 592 Log.info (fun m -> m ""); 593 594 (* Extract and store cookies from this response (including redirect responses) *) 595 extract_cookies_from_headers resp_headers url_to_fetch; 596 597 (* Handle redirects if enabled *) 598 let follow = Option.value follow_redirects ~default:t.follow_redirects in 599 let max_redir = Option.value max_redirects ~default:t.max_redirects in 600 601 if follow && (status >= 300 && status < 400) then begin 602 if redirects_left <= 0 then begin 603 Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url); 604 raise (Error.err (Error.Too_many_redirects { url; count = max_redir; max = max_redir })) 605 end; 606 607 match Headers.get `Location resp_headers with 608 | None -> 609 Log.debug (fun m -> m "Redirect response missing Location header"); 610 (status, resp_headers, response_body_str, url_to_fetch) 611 | Some location -> 612 (* Validate redirect URL scheme - Per Recommendation #5 *) 613 let _ = Redirect.validate_url location in 614 615 (* Resolve relative redirects against the current URL *) 616 let location_uri = Uri.of_string location in 617 let absolute_location = 618 match Uri.host location_uri with 619 | Some _ -> location (* Already absolute *) 620 | None -> 621 (* Relative redirect - resolve against current URL *) 622 let base_uri = uri_to_fetch in 623 let scheme = Option.value (Uri.scheme base_uri) ~default:"http" in 624 let resolved = Uri.resolve scheme base_uri location_uri in 625 Uri.to_string resolved 626 in 627 Log.info (fun m -> m "Following redirect to %s (%d remaining)" absolute_location redirects_left); 628 (* Strip sensitive headers on cross-origin redirects (security) 629 Per Recommendation #1: Strip auth headers to prevent credential leakage *) 630 let redirect_uri = Uri.of_string absolute_location in 631 let headers_for_redirect = 632 if Redirect.same_origin original_uri redirect_uri then 633 headers_for_request 634 else begin 635 Log.debug (fun m -> m "Cross-origin redirect detected: stripping sensitive headers"); 636 Redirect.strip_sensitive_headers headers_for_request 637 end 638 in 639 (* RFC 9110 Section 15.4.4: For 303 See Other, change method to GET 640 "A user agent can perform a retrieval request targeting that URI 641 (a GET or HEAD request if using HTTP)" *) 642 let redirect_method, redirect_body = 643 if status = 303 then begin 644 match method_ with 645 | `POST | `PUT | `DELETE | `PATCH -> 646 Log.debug (fun m -> m "303 redirect: changing %s to GET and stripping body" 647 (Method.to_string method_)); 648 (`GET, Body.empty) 649 | _ -> (method_, body) 650 end else 651 (method_, body) 652 in 653 make_with_redirects ~headers_for_request:headers_for_redirect 654 ~method_:redirect_method ~body:redirect_body 655 absolute_location (redirects_left - 1) 656 end else 657 (status, resp_headers, response_body_str, url_to_fetch) 658 in 659 660 let max_redir = Option.value max_redirects ~default:t.max_redirects in 661 let final_status, final_headers, final_body_str, final_url = 662 make_with_redirects ~headers_for_request:base_headers 663 ~method_ ~body:request_body url max_redir 664 in 665 666 let elapsed = Unix.gettimeofday () -. start_time in 667 Log.info (fun m -> m "Request completed in %.3f seconds" elapsed); 668 669 (* Create a flow from the body string *) 670 let body_flow = Eio.Flow.string_source final_body_str in 671 672 Response.Private.make 673 ~sw:t.sw 674 ~status:final_status 675 ~headers:final_headers 676 ~body:body_flow 677 ~url:final_url 678 ~elapsed 679 in 680 681 (* Cookies are extracted and stored during the redirect loop for each response, 682 including the final response, so no additional extraction needed here *) 683 684 (* Update statistics *) 685 t.requests_made <- t.requests_made + 1; 686 t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time); 687 Log.info (fun m -> m "Request completed with status %d" (Response.status_code response)); 688 689 (* Save cookies to disk if persistence is enabled *) 690 (match t.persist_cookies, t.xdg with 691 | true, Some xdg_ctx -> 692 let data_dir = Xdge.data_dir xdg_ctx in 693 let cookie_file = Eio.Path.(data_dir / "cookies.txt") in 694 Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () -> 695 Cookeio_jar.save cookie_file t.cookie_jar; 696 Log.debug (fun m -> m "Saved cookies to %a" Eio.Path.pp cookie_file) 697 ) 698 | _ -> ()); 699 700 response 701 702(* Helper to handle Digest authentication challenges (401 and 407). 703 Per RFC 7235: 401 uses WWW-Authenticate/Authorization headers, 704 407 uses Proxy-Authenticate/Proxy-Authorization headers. *) 705let handle_digest_auth (T t as wrapped_t) ~headers ~body ~auth ~timeout ~follow_redirects ~max_redirects ~method_ ~url response = 706 let status = Response.status_code response in 707 let auth_to_use = match auth with Some a -> a | None -> Option.value t.auth ~default:Auth.none in 708 (* Handle both 401 Unauthorized and 407 Proxy Authentication Required *) 709 let is_auth_challenge = (status = 401 || status = 407) && Auth.is_digest auth_to_use in 710 if is_auth_challenge then begin 711 match Auth.get_digest_credentials auth_to_use with 712 | Some (username, password) -> 713 (* RFC 7235: 401 uses WWW-Authenticate, 407 uses Proxy-Authenticate *) 714 let challenge_header : Header_name.t = if status = 401 then `Www_authenticate else `Proxy_authenticate in 715 let auth_header_name : Header_name.t = if status = 401 then `Authorization else `Proxy_authorization in 716 (match Response.header challenge_header response with 717 | Some www_auth -> 718 (match Auth.parse_www_authenticate www_auth with 719 | Some challenge -> 720 Log.info (fun m -> m "Received %s challenge (status %d), retrying with authentication" 721 (if status = 401 then "Digest" else "Proxy Digest") status); 722 let uri = Uri.of_string url in 723 let uri_path = Uri.path uri in 724 let uri_path = if uri_path = "" then "/" else uri_path in 725 (* Apply digest auth to headers with nonce counter for replay protection *) 726 let base_headers = Option.value headers ~default:Headers.empty in 727 (* Build the Authorization/Proxy-Authorization value manually *) 728 let auth_value = Auth.apply_digest 729 ~nonce_counter:t.nonce_counter 730 ~username ~password 731 ~method_:(Method.to_string method_) 732 ~uri:uri_path 733 ~challenge 734 Headers.empty 735 in 736 (* Get the auth value and set it on the correct header name *) 737 let auth_headers = match Headers.get `Authorization auth_value with 738 | Some v -> Headers.set auth_header_name v base_headers 739 | None -> base_headers 740 in 741 (* Retry with Digest auth - use Auth.none to prevent double-application *) 742 make_request_internal wrapped_t ~headers:auth_headers ?body ~auth:Auth.none ?timeout 743 ?follow_redirects ?max_redirects ~method_ url 744 | None -> 745 Log.warn (fun m -> m "Could not parse Digest challenge from %s" (Header_name.to_string challenge_header)); 746 response) 747 | None -> 748 Log.warn (fun m -> m "%d response has no %s header" status (Header_name.to_string challenge_header)); 749 response) 750 | None -> response 751 end else 752 response 753 754(* Public request function - executes synchronously with retry support *) 755let request (T t as wrapped_t) ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects 756 ?(path_params=[]) ~method_ url = 757 (* Helper to wrap response with Digest auth handling *) 758 let with_digest_handling response = 759 handle_digest_auth wrapped_t ~headers ~body ~auth ~timeout ~follow_redirects ~max_redirects ~method_ ~url response 760 in 761 match t.retry with 762 | None -> 763 (* No retry configured, execute directly *) 764 let response = make_request_internal wrapped_t ?headers ?body ?auth ?timeout 765 ?follow_redirects ?max_redirects ~path_params ~method_ url in 766 with_digest_handling response 767 | Some retry_config -> 768 (* Wrap in retry logic *) 769 (* Check if an Eio.Io exception is retryable using the new error types *) 770 let should_retry_exn = function 771 | Eio.Io (Error.E e, _) -> Error.is_retryable e 772 | Eio.Time.Timeout -> true 773 | _ -> false 774 in 775 776 let rec attempt_with_status_retry attempt = 777 if attempt > 1 then 778 Log.info (fun m -> m "Retry attempt %d/%d for %s %s" 779 attempt (retry_config.Retry.max_retries + 1) 780 (Method.to_string method_) url); 781 782 try 783 let response = make_request_internal wrapped_t ?headers ?body ?auth ?timeout 784 ?follow_redirects ?max_redirects ~path_params ~method_ url in 785 (* Handle Digest auth challenge if applicable *) 786 let response = with_digest_handling response in 787 let status = Response.status_code response in 788 789 (* Check if this status code should be retried *) 790 if attempt <= retry_config.Retry.max_retries && 791 Retry.should_retry ~config:retry_config ~method_ ~status 792 then begin 793 (* Per Recommendation #4: Use Retry-After header when available *) 794 let delay = 795 if retry_config.respect_retry_after && (status = 429 || status = 503) then 796 match Response.header `Retry_after response with 797 | Some value -> 798 Retry.parse_retry_after value 799 |> Option.value ~default:(Retry.calculate_backoff ~config:retry_config ~attempt) 800 | None -> Retry.calculate_backoff ~config:retry_config ~attempt 801 else 802 Retry.calculate_backoff ~config:retry_config ~attempt 803 in 804 Log.warn (fun m -> m "Request returned status %d (attempt %d/%d). Retrying in %.2f seconds..." 805 status attempt (retry_config.Retry.max_retries + 1) delay); 806 Eio.Time.sleep t.clock delay; 807 t.retries_count <- t.retries_count + 1; 808 attempt_with_status_retry (attempt + 1) 809 end else 810 response 811 with exn when attempt <= retry_config.Retry.max_retries && should_retry_exn exn -> 812 let delay = Retry.calculate_backoff ~config:retry_config ~attempt in 813 Log.warn (fun m -> m "Request failed (attempt %d/%d): %s. Retrying in %.2f seconds..." 814 attempt (retry_config.Retry.max_retries + 1) (Printexc.to_string exn) delay); 815 Eio.Time.sleep t.clock delay; 816 t.retries_count <- t.retries_count + 1; 817 attempt_with_status_retry (attempt + 1) 818 in 819 attempt_with_status_retry 1 820 821(* Convenience methods *) 822let get t ?headers ?auth ?timeout ?params ?(path_params=[]) url = 823 let url = match params with 824 | Some p -> 825 let uri = Uri.of_string url in 826 let uri = List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)) uri p in 827 Uri.to_string uri 828 | None -> url 829 in 830 request t ?headers ?auth ?timeout ~path_params ~method_:`GET url 831 832let post t ?headers ?body ?auth ?timeout ?(path_params=[]) url = 833 request t ?headers ?body ?auth ?timeout ~path_params ~method_:`POST url 834 835let put t ?headers ?body ?auth ?timeout ?(path_params=[]) url = 836 request t ?headers ?body ?auth ?timeout ~path_params ~method_:`PUT url 837 838let patch t ?headers ?body ?auth ?timeout ?(path_params=[]) url = 839 request t ?headers ?body ?auth ?timeout ~path_params ~method_:`PATCH url 840 841let delete t ?headers ?auth ?timeout ?(path_params=[]) url = 842 request t ?headers ?auth ?timeout ~path_params ~method_:`DELETE url 843 844let head t ?headers ?auth ?timeout ?(path_params=[]) url = 845 request t ?headers ?auth ?timeout ~path_params ~method_:`HEAD url 846 847let options t ?headers ?auth ?timeout ?(path_params=[]) url = 848 request t ?headers ?auth ?timeout ~path_params ~method_:`OPTIONS url 849 850(* Cmdliner integration module *) 851module Cmd = struct 852 open Cmdliner 853 854 (** Source tracking for configuration values. 855 Tracks where each configuration value came from for debugging 856 and transparency. *) 857 type source = 858 | Default (** Value from hardcoded default *) 859 | Env of string (** Value from environment variable (stores var name) *) 860 | Cmdline (** Value from command-line argument *) 861 862 (** Wrapper for values with source tracking *) 863 type 'a with_source = { 864 value : 'a; 865 source : source; 866 } 867 868 (** Proxy configuration from command line and environment *) 869 type proxy_config = { 870 proxy_url : string with_source option; (** Proxy URL (from HTTP_PROXY/HTTPS_PROXY/etc) *) 871 no_proxy : string with_source option; (** NO_PROXY patterns *) 872 } 873 874 type config = { 875 xdg : Xdge.t * Xdge.Cmd.t; 876 persist_cookies : bool with_source; 877 verify_tls : bool with_source; 878 timeout : float option with_source; 879 max_retries : int with_source; 880 retry_backoff : float with_source; 881 follow_redirects : bool with_source; 882 max_redirects : int with_source; 883 user_agent : string option with_source; 884 verbose_http : bool with_source; 885 proxy : proxy_config; 886 } 887 888 (** Helper to check environment variable and track source *) 889 let check_env_bool ~app_name ~suffix ~default = 890 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in 891 match Sys.getenv_opt env_var with 892 | Some v when String.lowercase_ascii v = "1" || String.lowercase_ascii v = "true" -> 893 { value = true; source = Env env_var } 894 | Some v when String.lowercase_ascii v = "0" || String.lowercase_ascii v = "false" -> 895 { value = false; source = Env env_var } 896 | Some _ | None -> { value = default; source = Default } 897 898 let check_env_string ~app_name ~suffix = 899 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in 900 match Sys.getenv_opt env_var with 901 | Some v when v <> "" -> Some { value = v; source = Env env_var } 902 | Some _ | None -> None 903 904 let check_env_float ~app_name ~suffix ~default = 905 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in 906 match Sys.getenv_opt env_var with 907 | Some v -> 908 (try { value = float_of_string v; source = Env env_var } 909 with _ -> { value = default; source = Default }) 910 | None -> { value = default; source = Default } 911 912 let check_env_int ~app_name ~suffix ~default = 913 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in 914 match Sys.getenv_opt env_var with 915 | Some v -> 916 (try { value = int_of_string v; source = Env env_var } 917 with _ -> { value = default; source = Default }) 918 | None -> { value = default; source = Default } 919 920 (** Parse proxy configuration from environment. 921 Follows standard HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY conventions. *) 922 let proxy_from_env () = 923 let proxy_url = 924 (* Check in order of preference *) 925 match Sys.getenv_opt "HTTP_PROXY" with 926 | Some v when v <> "" -> Some { value = v; source = Env "HTTP_PROXY" } 927 | _ -> 928 match Sys.getenv_opt "http_proxy" with 929 | Some v when v <> "" -> Some { value = v; source = Env "http_proxy" } 930 | _ -> 931 match Sys.getenv_opt "HTTPS_PROXY" with 932 | Some v when v <> "" -> Some { value = v; source = Env "HTTPS_PROXY" } 933 | _ -> 934 match Sys.getenv_opt "https_proxy" with 935 | Some v when v <> "" -> Some { value = v; source = Env "https_proxy" } 936 | _ -> 937 match Sys.getenv_opt "ALL_PROXY" with 938 | Some v when v <> "" -> Some { value = v; source = Env "ALL_PROXY" } 939 | _ -> 940 match Sys.getenv_opt "all_proxy" with 941 | Some v when v <> "" -> Some { value = v; source = Env "all_proxy" } 942 | _ -> None 943 in 944 let no_proxy = 945 match Sys.getenv_opt "NO_PROXY" with 946 | Some v when v <> "" -> Some { value = v; source = Env "NO_PROXY" } 947 | _ -> 948 match Sys.getenv_opt "no_proxy" with 949 | Some v when v <> "" -> Some { value = v; source = Env "no_proxy" } 950 | _ -> None 951 in 952 { proxy_url; no_proxy } 953 954 let create config env sw = 955 let xdg, _xdg_cmd = config.xdg in 956 let retry = if config.max_retries.value > 0 then 957 Some (Retry.create_config 958 ~max_retries:config.max_retries.value 959 ~backoff_factor:config.retry_backoff.value ()) 960 else None in 961 962 let timeout = match config.timeout.value with 963 | Some t -> Timeout.create ~total:t () 964 | None -> Timeout.default in 965 966 (* Build proxy config if URL is set *) 967 let proxy = match config.proxy.proxy_url with 968 | Some { value = url; _ } -> 969 let no_proxy = match config.proxy.no_proxy with 970 | Some { value = np; _ } -> 971 np |> String.split_on_char ',' 972 |> List.map String.trim 973 |> List.filter (fun s -> s <> "") 974 | None -> [] 975 in 976 (* Parse proxy URL to extract components *) 977 let uri = Uri.of_string url in 978 let host = Uri.host uri |> Option.value ~default:"localhost" in 979 let port = Uri.port uri |> Option.value ~default:8080 in 980 let auth = match Uri.userinfo uri with 981 | Some info -> 982 (match String.index_opt info ':' with 983 | Some idx -> 984 let username = String.sub info 0 idx in 985 let password = String.sub info (idx + 1) (String.length info - idx - 1) in 986 Some (Auth.basic ~username ~password) 987 | None -> Some (Auth.basic ~username:info ~password:"")) 988 | None -> None 989 in 990 Some (Proxy.http ~port ?auth ~no_proxy host) 991 | None -> None 992 in 993 994 let req = create ~sw 995 ~xdg 996 ~persist_cookies:config.persist_cookies.value 997 ~verify_tls:config.verify_tls.value 998 ~timeout 999 ?retry 1000 ~follow_redirects:config.follow_redirects.value 1001 ~max_redirects:config.max_redirects.value 1002 ?proxy 1003 env in 1004 1005 (* Set user agent if provided *) 1006 let req = match config.user_agent.value with 1007 | Some ua -> set_default_header req "User-Agent" ua 1008 | None -> req 1009 in 1010 1011 req 1012 1013 (* Individual terms - parameterized by app_name 1014 These terms return with_source wrapped values to track provenance *) 1015 1016 let persist_cookies_term app_name = 1017 let doc = "Persist cookies to disk between sessions" in 1018 let env_name = String.uppercase_ascii app_name ^ "_PERSIST_COOKIES" in 1019 let env_info = Cmdliner.Cmd.Env.info env_name in 1020 let cmdline_arg = Arg.(value & flag & info ["persist-cookies"] ~env:env_info ~doc) in 1021 Term.(const (fun cmdline -> 1022 if cmdline then 1023 { value = true; source = Cmdline } 1024 else 1025 check_env_bool ~app_name ~suffix:"PERSIST_COOKIES" ~default:false 1026 ) $ cmdline_arg) 1027 1028 let verify_tls_term app_name = 1029 let doc = "Skip TLS certificate verification (insecure)" in 1030 let env_name = String.uppercase_ascii app_name ^ "_NO_VERIFY_TLS" in 1031 let env_info = Cmdliner.Cmd.Env.info env_name in 1032 let cmdline_arg = Arg.(value & flag & info ["no-verify-tls"] ~env:env_info ~doc) in 1033 Term.(const (fun no_verify -> 1034 if no_verify then 1035 { value = false; source = Cmdline } 1036 else 1037 let env_val = check_env_bool ~app_name ~suffix:"NO_VERIFY_TLS" ~default:false in 1038 { value = not env_val.value; source = env_val.source } 1039 ) $ cmdline_arg) 1040 1041 let timeout_term app_name = 1042 let doc = "Request timeout in seconds" in 1043 let env_name = String.uppercase_ascii app_name ^ "_TIMEOUT" in 1044 let env_info = Cmdliner.Cmd.Env.info env_name in 1045 let cmdline_arg = Arg.(value & opt (some float) None & info ["timeout"] ~env:env_info ~docv:"SECONDS" ~doc) in 1046 Term.(const (fun cmdline -> 1047 match cmdline with 1048 | Some t -> { value = Some t; source = Cmdline } 1049 | None -> 1050 match check_env_string ~app_name ~suffix:"TIMEOUT" with 1051 | Some { value = v; source } -> 1052 (try { value = Some (float_of_string v); source } 1053 with _ -> { value = None; source = Default }) 1054 | None -> { value = None; source = Default } 1055 ) $ cmdline_arg) 1056 1057 let retries_term app_name = 1058 let doc = "Maximum number of request retries" in 1059 let env_name = String.uppercase_ascii app_name ^ "_MAX_RETRIES" in 1060 let env_info = Cmdliner.Cmd.Env.info env_name in 1061 let cmdline_arg = Arg.(value & opt (some int) None & info ["max-retries"] ~env:env_info ~docv:"N" ~doc) in 1062 Term.(const (fun cmdline -> 1063 match cmdline with 1064 | Some n -> { value = n; source = Cmdline } 1065 | None -> check_env_int ~app_name ~suffix:"MAX_RETRIES" ~default:3 1066 ) $ cmdline_arg) 1067 1068 let retry_backoff_term app_name = 1069 let doc = "Retry backoff factor for exponential delay" in 1070 let env_name = String.uppercase_ascii app_name ^ "_RETRY_BACKOFF" in 1071 let env_info = Cmdliner.Cmd.Env.info env_name in 1072 let cmdline_arg = Arg.(value & opt (some float) None & info ["retry-backoff"] ~env:env_info ~docv:"FACTOR" ~doc) in 1073 Term.(const (fun cmdline -> 1074 match cmdline with 1075 | Some f -> { value = f; source = Cmdline } 1076 | None -> check_env_float ~app_name ~suffix:"RETRY_BACKOFF" ~default:0.3 1077 ) $ cmdline_arg) 1078 1079 let follow_redirects_term app_name = 1080 let doc = "Don't follow HTTP redirects" in 1081 let env_name = String.uppercase_ascii app_name ^ "_NO_FOLLOW_REDIRECTS" in 1082 let env_info = Cmdliner.Cmd.Env.info env_name in 1083 let cmdline_arg = Arg.(value & flag & info ["no-follow-redirects"] ~env:env_info ~doc) in 1084 Term.(const (fun no_follow -> 1085 if no_follow then 1086 { value = false; source = Cmdline } 1087 else 1088 let env_val = check_env_bool ~app_name ~suffix:"NO_FOLLOW_REDIRECTS" ~default:false in 1089 { value = not env_val.value; source = env_val.source } 1090 ) $ cmdline_arg) 1091 1092 let max_redirects_term app_name = 1093 let doc = "Maximum number of redirects to follow" in 1094 let env_name = String.uppercase_ascii app_name ^ "_MAX_REDIRECTS" in 1095 let env_info = Cmdliner.Cmd.Env.info env_name in 1096 let cmdline_arg = Arg.(value & opt (some int) None & info ["max-redirects"] ~env:env_info ~docv:"N" ~doc) in 1097 Term.(const (fun cmdline -> 1098 match cmdline with 1099 | Some n -> { value = n; source = Cmdline } 1100 | None -> check_env_int ~app_name ~suffix:"MAX_REDIRECTS" ~default:10 1101 ) $ cmdline_arg) 1102 1103 let user_agent_term app_name = 1104 let doc = "User-Agent header to send with requests" in 1105 let env_name = String.uppercase_ascii app_name ^ "_USER_AGENT" in 1106 let env_info = Cmdliner.Cmd.Env.info env_name in 1107 let cmdline_arg = Arg.(value & opt (some string) None & info ["user-agent"] ~env:env_info ~docv:"STRING" ~doc) in 1108 Term.(const (fun cmdline -> 1109 match cmdline with 1110 | Some ua -> { value = Some ua; source = Cmdline } 1111 | None -> 1112 match check_env_string ~app_name ~suffix:"USER_AGENT" with 1113 | Some { value; source } -> { value = Some value; source } 1114 | None -> { value = None; source = Default } 1115 ) $ cmdline_arg) 1116 1117 let verbose_http_term app_name = 1118 let doc = "Enable verbose HTTP-level logging (hexdumps, TLS details)" in 1119 let env_name = String.uppercase_ascii app_name ^ "_VERBOSE_HTTP" in 1120 let env_info = Cmdliner.Cmd.Env.info env_name in 1121 let cmdline_arg = Arg.(value & flag & info ["verbose-http"] ~env:env_info ~doc) in 1122 Term.(const (fun cmdline -> 1123 if cmdline then 1124 { value = true; source = Cmdline } 1125 else 1126 check_env_bool ~app_name ~suffix:"VERBOSE_HTTP" ~default:false 1127 ) $ cmdline_arg) 1128 1129 let proxy_term _app_name = 1130 let doc = "HTTP/HTTPS proxy URL (e.g., http://proxy:8080)" in 1131 let cmdline_arg = Arg.(value & opt (some string) None & info ["proxy"] ~docv:"URL" ~doc) in 1132 let no_proxy_doc = "Comma-separated list of hosts to bypass proxy" in 1133 let no_proxy_arg = Arg.(value & opt (some string) None & info ["no-proxy"] ~docv:"HOSTS" ~doc:no_proxy_doc) in 1134 Term.(const (fun cmdline_proxy cmdline_no_proxy -> 1135 let proxy_url = match cmdline_proxy with 1136 | Some url -> Some { value = url; source = Cmdline } 1137 | None -> (proxy_from_env ()).proxy_url 1138 in 1139 let no_proxy = match cmdline_no_proxy with 1140 | Some np -> Some { value = np; source = Cmdline } 1141 | None -> (proxy_from_env ()).no_proxy 1142 in 1143 { proxy_url; no_proxy } 1144 ) $ cmdline_arg $ no_proxy_arg) 1145 1146 (* Combined terms *) 1147 1148 let config_term app_name fs = 1149 let xdg_term = Xdge.Cmd.term app_name fs 1150 ~dirs:[`Config; `Data; `Cache] () in 1151 Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua verbose proxy -> 1152 { xdg; persist_cookies = persist; verify_tls = verify; 1153 timeout; max_retries = retries; retry_backoff = backoff; 1154 follow_redirects = follow; max_redirects = max_redir; 1155 user_agent = ua; verbose_http = verbose; proxy }) 1156 $ xdg_term 1157 $ persist_cookies_term app_name 1158 $ verify_tls_term app_name 1159 $ timeout_term app_name 1160 $ retries_term app_name 1161 $ retry_backoff_term app_name 1162 $ follow_redirects_term app_name 1163 $ max_redirects_term app_name 1164 $ user_agent_term app_name 1165 $ verbose_http_term app_name 1166 $ proxy_term app_name) 1167 1168 let requests_term app_name eio_env sw = 1169 let config_t = config_term app_name eio_env#fs in 1170 Term.(const (fun config -> create config eio_env sw) $ config_t) 1171 1172 let minimal_term app_name fs = 1173 let xdg_term = Xdge.Cmd.term app_name fs 1174 ~dirs:[`Data; `Cache] () in 1175 Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist.value)) 1176 $ xdg_term 1177 $ persist_cookies_term app_name) 1178 1179 let env_docs app_name = 1180 let app_upper = String.uppercase_ascii app_name in 1181 Printf.sprintf 1182 "## ENVIRONMENT\n\n\ 1183 The following environment variables affect %s:\n\n\ 1184 ### XDG Directories\n\n\ 1185 **%s_CONFIG_DIR**\n\ 1186 : Override configuration directory location\n\n\ 1187 **%s_DATA_DIR**\n\ 1188 : Override data directory location (for cookies)\n\n\ 1189 **%s_CACHE_DIR**\n\ 1190 : Override cache directory location\n\n\ 1191 **XDG_CONFIG_HOME**\n\ 1192 : Base directory for user configuration files (default: ~/.config)\n\n\ 1193 **XDG_DATA_HOME**\n\ 1194 : Base directory for user data files (default: ~/.local/share)\n\n\ 1195 **XDG_CACHE_HOME**\n\ 1196 : Base directory for user cache files (default: ~/.cache)\n\n\ 1197 ### HTTP Settings\n\n\ 1198 **%s_PERSIST_COOKIES**\n\ 1199 : Set to '1' to persist cookies by default\n\n\ 1200 **%s_NO_VERIFY_TLS**\n\ 1201 : Set to '1' to disable TLS verification (insecure)\n\n\ 1202 **%s_TIMEOUT**\n\ 1203 : Default request timeout in seconds\n\n\ 1204 **%s_MAX_RETRIES**\n\ 1205 : Maximum number of retries (default: 3)\n\n\ 1206 **%s_RETRY_BACKOFF**\n\ 1207 : Retry backoff factor (default: 0.3)\n\n\ 1208 **%s_NO_FOLLOW_REDIRECTS**\n\ 1209 : Set to '1' to disable redirect following\n\n\ 1210 **%s_MAX_REDIRECTS**\n\ 1211 : Maximum redirects to follow (default: 10)\n\n\ 1212 **%s_USER_AGENT**\n\ 1213 : User-Agent header to send with requests\n\n\ 1214 **%s_VERBOSE_HTTP**\n\ 1215 : Set to '1' to enable verbose HTTP-level logging\n\n\ 1216 ### Proxy Configuration\n\n\ 1217 **HTTP_PROXY** / **http_proxy**\n\ 1218 : HTTP proxy URL (e.g., http://proxy:8080 or http://user:pass@proxy:8080)\n\n\ 1219 **HTTPS_PROXY** / **https_proxy**\n\ 1220 : HTTPS proxy URL (used for HTTPS requests)\n\n\ 1221 **ALL_PROXY** / **all_proxy**\n\ 1222 : Fallback proxy URL for all protocols\n\n\ 1223 **NO_PROXY** / **no_proxy**\n\ 1224 : Comma-separated list of hosts to bypass proxy (e.g., localhost,*.example.com)\ 1225 " 1226 app_name app_upper app_upper app_upper 1227 app_upper app_upper app_upper app_upper 1228 app_upper app_upper app_upper app_upper app_upper 1229 1230 (** Pretty-print source type *) 1231 let pp_source ppf = function 1232 | Default -> Format.fprintf ppf "default" 1233 | Env var -> Format.fprintf ppf "env(%s)" var 1234 | Cmdline -> Format.fprintf ppf "cmdline" 1235 1236 (** Pretty-print a value with its source *) 1237 let pp_with_source pp_val ppf ws = 1238 Format.fprintf ppf "%a [%a]" pp_val ws.value pp_source ws.source 1239 1240 let pp_config ?(show_sources = true) ppf config = 1241 let _xdg, xdg_cmd = config.xdg in 1242 let pp_bool = Format.pp_print_bool in 1243 let pp_float = Format.pp_print_float in 1244 let pp_int = Format.pp_print_int in 1245 let pp_string_opt = Format.pp_print_option Format.pp_print_string in 1246 let pp_float_opt = Format.pp_print_option Format.pp_print_float in 1247 1248 let pp_val pp = if show_sources then pp_with_source pp else fun ppf ws -> pp ppf ws.value in 1249 1250 Format.fprintf ppf "@[<v>Configuration:@,\ 1251 @[<v 2>XDG:@,%a@]@,\ 1252 persist_cookies: %a@,\ 1253 verify_tls: %a@,\ 1254 timeout: %a@,\ 1255 max_retries: %a@,\ 1256 retry_backoff: %a@,\ 1257 follow_redirects: %a@,\ 1258 max_redirects: %a@,\ 1259 user_agent: %a@,\ 1260 verbose_http: %a@,\ 1261 @[<v 2>Proxy:@,\ 1262 url: %a@,\ 1263 no_proxy: %a@]@]" 1264 Xdge.Cmd.pp xdg_cmd 1265 (pp_val pp_bool) config.persist_cookies 1266 (pp_val pp_bool) config.verify_tls 1267 (pp_val pp_float_opt) config.timeout 1268 (pp_val pp_int) config.max_retries 1269 (pp_val pp_float) config.retry_backoff 1270 (pp_val pp_bool) config.follow_redirects 1271 (pp_val pp_int) config.max_redirects 1272 (pp_val pp_string_opt) config.user_agent 1273 (pp_val pp_bool) config.verbose_http 1274 (Format.pp_print_option (pp_with_source Format.pp_print_string)) 1275 config.proxy.proxy_url 1276 (Format.pp_print_option (pp_with_source Format.pp_print_string)) 1277 config.proxy.no_proxy 1278 1279 (* Logging configuration *) 1280 let setup_log_sources ?(verbose_http = false) level = 1281 (* Helper to set TLS tracing level by finding the source by name *) 1282 let set_tls_tracing_level lvl = 1283 match List.find_opt (fun s -> Logs.Src.name s = "tls.tracing") (Logs.Src.list ()) with 1284 | Some tls_src -> Logs.Src.set_level tls_src (Some lvl) 1285 | None -> () (* TLS not loaded yet, ignore *) 1286 in 1287 match level with 1288 | Some Logs.Debug -> 1289 (* Enable debug logging for application-level requests modules *) 1290 Logs.Src.set_level src (Some Logs.Debug); 1291 Logs.Src.set_level Auth.src (Some Logs.Debug); 1292 Logs.Src.set_level Body.src (Some Logs.Debug); 1293 Logs.Src.set_level Response.src (Some Logs.Debug); 1294 Logs.Src.set_level Retry.src (Some Logs.Debug); 1295 Logs.Src.set_level Headers.src (Some Logs.Debug); 1296 Logs.Src.set_level Error.src (Some Logs.Debug); 1297 Logs.Src.set_level Method.src (Some Logs.Debug); 1298 Logs.Src.set_level Mime.src (Some Logs.Debug); 1299 Logs.Src.set_level Status.src (Some Logs.Debug); 1300 Logs.Src.set_level Timeout.src (Some Logs.Debug); 1301 (* Only enable HTTP-level debug if verbose_http is set *) 1302 if verbose_http then begin 1303 Logs.Src.set_level One.src (Some Logs.Debug); 1304 Logs.Src.set_level Http_client.src (Some Logs.Debug); 1305 Logs.Src.set_level Conpool.src (Some Logs.Debug); 1306 set_tls_tracing_level Logs.Debug 1307 end else begin 1308 Logs.Src.set_level One.src (Some Logs.Info); 1309 Logs.Src.set_level Http_client.src (Some Logs.Info); 1310 Logs.Src.set_level Conpool.src (Some Logs.Info); 1311 set_tls_tracing_level Logs.Warning 1312 end 1313 | Some Logs.Info -> 1314 (* Set info level for main modules *) 1315 Logs.Src.set_level src (Some Logs.Info); 1316 Logs.Src.set_level Response.src (Some Logs.Info); 1317 Logs.Src.set_level One.src (Some Logs.Info); 1318 set_tls_tracing_level Logs.Warning 1319 | _ -> 1320 (* Suppress TLS debug output by default *) 1321 set_tls_tracing_level Logs.Warning 1322end 1323