(** OCaml HTTP client library with streaming support *) let src = Logs.Src.create "requests" ~doc:"HTTP Client Library" module Log = (val Logs.src_log src : Logs.LOG) module Method = Method module Mime = Mime module Headers = Headers module Auth = Auth module Timeout = Timeout module Body = Body module Response = Response module One = One module Status = Status module Error = Error module Retry = Retry (* Note: RNG initialization should be done by the application using Mirage_crypto_rng_unix.initialize before calling Eio_main.run. We don't call use_default() here as it spawns background threads that are incompatible with Eio's structured concurrency. *) (* Main API - Session functionality with connection pooling *) (* Internal session type with existential type parameters *) type ('clock, 'net) session = { sw : Eio.Switch.t; clock : 'clock; net : 'net; http_pool : ('clock, 'net) Conpool.t; https_pool : ('clock, 'net) Conpool.t; cookie_jar : Cookeio.jar; cookie_mutex : Eio.Mutex.t; default_headers : Headers.t; auth : Auth.t option; timeout : Timeout.t; follow_redirects : bool; max_redirects : int; verify_tls : bool; tls_config : Tls.Config.client option; retry : Retry.config option; persist_cookies : bool; xdg : Xdge.t option; (* Statistics - mutable for tracking across all derived sessions *) mutable requests_made : int; mutable total_time : float; mutable retries_count : int; } (* Public type that hides the existential type parameters. We constrain the existentials to ensure they satisfy the requirements of the internal functions. *) type t = T : ([> float Eio.Time.clock_ty] Eio.Resource.t, [> [> `Generic] Eio.Net.ty] Eio.Resource.t) session -> t let create ~sw ?http_pool ?https_pool ?cookie_jar ?(default_headers = Headers.empty) ?auth ?(timeout = Timeout.default) ?(follow_redirects = true) ?(max_redirects = 10) ?(verify_tls = true) ?tls_config ?(max_connections_per_host = 10) ?(connection_idle_timeout = 60.0) ?(connection_lifetime = 300.0) ?retry ?(persist_cookies = false) ?xdg env = let clock = env#clock in let net = env#net in let xdg = match xdg, persist_cookies with | Some x, _ -> Some x | None, true -> Some (Xdge.create env#fs "requests") | None, false -> None in (* Create TLS config for HTTPS pool if needed *) let tls_config = match tls_config, verify_tls with | Some cfg, _ -> Some cfg | None, true -> (* Use CA certificates for verification *) (match Ca_certs.authenticator () with | Ok authenticator -> (match Tls.Config.client ~authenticator () with | Ok cfg -> Some cfg | Error (`Msg msg) -> Log.warn (fun m -> m "Failed to create TLS config: %s" msg); None) | Error (`Msg msg) -> Log.warn (fun m -> m "Failed to load CA certificates: %s" msg); None) | None, false -> None in (* Create connection pools if not provided *) let pool_config = Conpool.Config.make ~max_connections_per_endpoint:max_connections_per_host ~max_idle_time:connection_idle_timeout ~max_connection_lifetime:connection_lifetime () in (* HTTP pool - plain TCP connections *) let http_pool = match http_pool with | Some p -> p | None -> Conpool.create ~sw ~net ~clock ~config:pool_config () in (* HTTPS pool - TLS-wrapped connections *) let https_pool = match https_pool with | Some p -> p | None -> let https_tls_config = Option.map (fun cfg -> Conpool.Tls_config.make ~config:cfg () ) tls_config in Conpool.create ~sw ~net ~clock ?tls:https_tls_config ~config:pool_config () in Log.info (fun m -> m "Created Requests session with connection pools (max_per_host=%d, TLS=%b)" max_connections_per_host (Option.is_some tls_config)); let cookie_jar = match cookie_jar, persist_cookies, xdg with | Some jar, _, _ -> jar | None, true, Some xdg_ctx -> let data_dir = Xdge.data_dir xdg_ctx in let cookie_file = Eio.Path.(data_dir / "cookies.txt") in Cookeio.load cookie_file | None, _, _ -> Cookeio.create () in T { sw; clock; net; http_pool; https_pool; cookie_jar; cookie_mutex = Eio.Mutex.create (); default_headers; auth; timeout; follow_redirects; max_redirects; verify_tls; tls_config; retry; persist_cookies; xdg; requests_made = 0; total_time = 0.0; retries_count = 0; } let set_default_header (T t) key value = T { t with default_headers = Headers.set key value t.default_headers } let remove_default_header (T t) key = T { t with default_headers = Headers.remove key t.default_headers } let set_auth (T t) auth = Log.debug (fun m -> m "Setting authentication method"); T { t with auth = Some auth } let clear_auth (T t) = Log.debug (fun m -> m "Clearing authentication"); T { t with auth = None } let set_timeout (T t) timeout = Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout); T { t with timeout } let set_retry (T t) config = Log.debug (fun m -> m "Setting retry config: max_retries=%d" config.Retry.max_retries); T { t with retry = Some config } let cookies (T t) = t.cookie_jar let clear_cookies (T t) = Cookeio.clear t.cookie_jar (* Internal request function using connection pools *) let make_request_internal t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url = let start_time = Unix.gettimeofday () in let method_str = Method.to_string method_ in Log.info (fun m -> m "Making %s request to %s" method_str url); (* Parse URL *) let uri = Uri.of_string url in let domain = Option.value ~default:"" (Uri.host uri) in let path = Uri.path uri in let is_secure = Uri.scheme uri = Some "https" in (* Merge headers *) let headers = match headers with | Some h -> Headers.merge t.default_headers h | None -> t.default_headers in (* Use provided auth or default *) let auth = match auth with | Some a -> Some a | None -> t.auth in (* Apply auth *) let headers = match auth with | Some a -> Log.debug (fun m -> m "Applying authentication"); Auth.apply a headers | None -> headers in (* Add content type from body *) let headers = match body with | Some b -> (match Body.content_type b with | Some mime -> Headers.content_type mime headers | None -> headers) | None -> headers in (* Get cookies for this URL *) let headers = Eio.Mutex.use_ro t.cookie_mutex (fun () -> let cookies = Cookeio.get_cookies t.cookie_jar ~domain ~path ~is_secure in match cookies with | [] -> headers | cookies -> Log.debug (fun m -> m "Adding %d cookies for %s%s" (List.length cookies) domain path); let cookie_header = Cookeio.make_cookie_header cookies in Headers.set "Cookie" cookie_header headers ) in (* Convert body to string for sending *) let request_body_str = match body with | None -> "" | Some b -> Body.Private.to_string b in let response = (* Execute request with redirect handling *) let rec make_with_redirects url_to_fetch redirects_left = let uri_to_fetch = Uri.of_string url_to_fetch in (* Parse the redirect URL to get correct host and port *) let redirect_host = match Uri.host uri_to_fetch with | Some h -> h | None -> failwith "Redirect URL must contain a host" in let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with | Some "https", None -> 443 | Some "https", Some p -> p | Some "http", None -> 80 | Some "http", Some p -> p | _, Some p -> p | _ -> 80 in (* Create endpoint for this specific URL *) let redirect_endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in (* Determine if we need TLS based on this URL's scheme *) let redirect_is_https = match Uri.scheme uri_to_fetch with | Some "https" -> true | _ -> false in (* Choose the appropriate connection pool for this URL *) let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in let make_request_fn () = Conpool.with_connection redirect_pool redirect_endpoint (fun flow -> (* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *) (* Use our low-level HTTP client *) Http_client.make_request ~method_:method_str ~uri:uri_to_fetch ~headers ~body_str:request_body_str flow ) in (* Apply timeout if specified *) let status, resp_headers, response_body_str = let timeout_val = Option.value timeout ~default:t.timeout in match Timeout.total timeout_val with | Some seconds -> Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds); Eio.Time.with_timeout_exn t.clock seconds make_request_fn | None -> make_request_fn () in Log.info (fun m -> m "Received response: status=%d" status); (* Handle redirects if enabled *) let follow = Option.value follow_redirects ~default:t.follow_redirects in let max_redir = Option.value max_redirects ~default:t.max_redirects in if follow && (status >= 300 && status < 400) then begin if redirects_left <= 0 then begin Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url); raise (Error.TooManyRedirects { url; count = max_redir; max = max_redir }) end; match Headers.get "location" resp_headers with | None -> Log.debug (fun m -> m "Redirect response missing Location header"); (status, resp_headers, response_body_str, url_to_fetch) | Some location -> Log.info (fun m -> m "Following redirect to %s (%d remaining)" location redirects_left); make_with_redirects location (redirects_left - 1) end else (status, resp_headers, response_body_str, url_to_fetch) in let max_redir = Option.value max_redirects ~default:t.max_redirects in let final_status, final_headers, final_body_str, final_url = make_with_redirects url max_redir in let elapsed = Unix.gettimeofday () -. start_time in Log.info (fun m -> m "Request completed in %.3f seconds" elapsed); (* Create a flow from the body string *) let body_flow = Eio.Flow.string_source final_body_str in Response.Private.make ~sw:t.sw ~status:final_status ~headers:final_headers ~body:body_flow ~url:final_url ~elapsed in (* Extract and store cookies from response *) let () = Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () -> match Response.headers response |> Headers.get_all "Set-Cookie" with | [] -> () | cookie_headers -> Log.debug (fun m -> m "Received %d Set-Cookie headers" (List.length cookie_headers)); List.iter (fun cookie_str -> match Cookeio.parse_set_cookie ~domain ~path cookie_str with | Some cookie -> Log.debug (fun m -> m "Storing cookie"); Cookeio.add_cookie t.cookie_jar cookie | None -> Log.warn (fun m -> m "Failed to parse cookie: %s" cookie_str) ) cookie_headers ) in (* Update statistics *) t.requests_made <- t.requests_made + 1; t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time); Log.info (fun m -> m "Request completed with status %d" (Response.status_code response)); response (* Public request function - executes synchronously *) let request (T t) ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url = (* Keep t in scope to preserve existential types *) make_request_internal t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url (* Convenience methods *) let get (T t) ?headers ?auth ?timeout ?params url = let url = match params with | Some p -> let uri = Uri.of_string url in let uri = List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)) uri p in Uri.to_string uri | None -> url in make_request_internal t ?headers ?auth ?timeout ~method_:`GET url let post (T t) ?headers ?body ?auth ?timeout url = make_request_internal t ?headers ?body ?auth ?timeout ~method_:`POST url let put (T t) ?headers ?body ?auth ?timeout url = make_request_internal t ?headers ?body ?auth ?timeout ~method_:`PUT url let patch (T t) ?headers ?body ?auth ?timeout url = make_request_internal t ?headers ?body ?auth ?timeout ~method_:`PATCH url let delete (T t) ?headers ?auth ?timeout url = make_request_internal t ?headers ?auth ?timeout ~method_:`DELETE url let head (T t) ?headers ?auth ?timeout url = make_request_internal t ?headers ?auth ?timeout ~method_:`HEAD url let options (T t) ?headers ?auth ?timeout url = make_request_internal t ?headers ?auth ?timeout ~method_:`OPTIONS url (* Cmdliner integration module *) module Cmd = struct open Cmdliner type config = { xdg : Xdge.t * Xdge.Cmd.t; persist_cookies : bool; verify_tls : bool; timeout : float option; max_retries : int; retry_backoff : float; follow_redirects : bool; max_redirects : int; user_agent : string option; } let create config env sw = let xdg, _xdg_cmd = config.xdg in let retry = if config.max_retries > 0 then Some (Retry.create_config ~max_retries:config.max_retries ~backoff_factor:config.retry_backoff ()) else None in let timeout = match config.timeout with | Some t -> Timeout.create ~total:t () | None -> Timeout.default in let req = create ~sw ~xdg ~persist_cookies:config.persist_cookies ~verify_tls:config.verify_tls ~timeout ?retry ~follow_redirects:config.follow_redirects ~max_redirects:config.max_redirects env in (* Set user agent if provided *) let req = match config.user_agent with | Some ua -> set_default_header req "User-Agent" ua | None -> req in req (* Individual terms - parameterized by app_name *) let persist_cookies_term app_name = let doc = "Persist cookies to disk between sessions" in let env_name = String.uppercase_ascii app_name ^ "_PERSIST_COOKIES" in let env_info = Cmdliner.Cmd.Env.info env_name in Arg.(value & flag & info ["persist-cookies"] ~env:env_info ~doc) let verify_tls_term app_name = let doc = "Skip TLS certificate verification (insecure)" in let env_name = String.uppercase_ascii app_name ^ "_NO_VERIFY_TLS" in let env_info = Cmdliner.Cmd.Env.info env_name in Term.(const (fun no_verify -> not no_verify) $ Arg.(value & flag & info ["no-verify-tls"] ~env:env_info ~doc)) let timeout_term app_name = let doc = "Request timeout in seconds" in let env_name = String.uppercase_ascii app_name ^ "_TIMEOUT" in let env_info = Cmdliner.Cmd.Env.info env_name in Arg.(value & opt (some float) None & info ["timeout"] ~env:env_info ~docv:"SECONDS" ~doc) let retries_term app_name = let doc = "Maximum number of request retries" in let env_name = String.uppercase_ascii app_name ^ "_MAX_RETRIES" in let env_info = Cmdliner.Cmd.Env.info env_name in Arg.(value & opt int 3 & info ["max-retries"] ~env:env_info ~docv:"N" ~doc) let retry_backoff_term app_name = let doc = "Retry backoff factor for exponential delay" in let env_name = String.uppercase_ascii app_name ^ "_RETRY_BACKOFF" in let env_info = Cmdliner.Cmd.Env.info env_name in Arg.(value & opt float 0.3 & info ["retry-backoff"] ~env:env_info ~docv:"FACTOR" ~doc) let follow_redirects_term app_name = let doc = "Don't follow HTTP redirects" in let env_name = String.uppercase_ascii app_name ^ "_NO_FOLLOW_REDIRECTS" in let env_info = Cmdliner.Cmd.Env.info env_name in Term.(const (fun no_follow -> not no_follow) $ Arg.(value & flag & info ["no-follow-redirects"] ~env:env_info ~doc)) let max_redirects_term app_name = let doc = "Maximum number of redirects to follow" in let env_name = String.uppercase_ascii app_name ^ "_MAX_REDIRECTS" in let env_info = Cmdliner.Cmd.Env.info env_name in Arg.(value & opt int 10 & info ["max-redirects"] ~env:env_info ~docv:"N" ~doc) let user_agent_term app_name = let doc = "User-Agent header to send with requests" in let env_name = String.uppercase_ascii app_name ^ "_USER_AGENT" in let env_info = Cmdliner.Cmd.Env.info env_name in Arg.(value & opt (some string) None & info ["user-agent"] ~env:env_info ~docv:"STRING" ~doc) (* Combined terms *) let config_term app_name fs = let xdg_term = Xdge.Cmd.term app_name fs ~dirs:[`Config; `Data; `Cache] () in Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua -> { xdg; persist_cookies = persist; verify_tls = verify; timeout; max_retries = retries; retry_backoff = backoff; follow_redirects = follow; max_redirects = max_redir; user_agent = ua }) $ xdg_term $ persist_cookies_term app_name $ verify_tls_term app_name $ timeout_term app_name $ retries_term app_name $ retry_backoff_term app_name $ follow_redirects_term app_name $ max_redirects_term app_name $ user_agent_term app_name) let requests_term app_name eio_env sw = let config_t = config_term app_name eio_env#fs in Term.(const (fun config -> create config eio_env sw) $ config_t) let minimal_term app_name fs = let xdg_term = Xdge.Cmd.term app_name fs ~dirs:[`Data; `Cache] () in Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist)) $ xdg_term $ persist_cookies_term app_name) let env_docs app_name = let app_upper = String.uppercase_ascii app_name in Printf.sprintf "## ENVIRONMENT\n\n\ The following environment variables affect %s:\n\n\ **%s_CONFIG_DIR**\n\ : Override configuration directory location\n\n\ **%s_DATA_DIR**\n\ : Override data directory location (for cookies)\n\n\ **%s_CACHE_DIR**\n\ : Override cache directory location\n\n\ **XDG_CONFIG_HOME**\n\ : Base directory for user configuration files (default: ~/.config)\n\n\ **XDG_DATA_HOME**\n\ : Base directory for user data files (default: ~/.local/share)\n\n\ **XDG_CACHE_HOME**\n\ : Base directory for user cache files (default: ~/.cache)\n\n\ **%s_PERSIST_COOKIES**\n\ : Set to '1' to persist cookies by default\n\n\ **%s_NO_VERIFY_TLS**\n\ : Set to '1' to disable TLS verification (insecure)\n\n\ **%s_TIMEOUT**\n\ : Default request timeout in seconds\n\n\ **%s_MAX_RETRIES**\n\ : Maximum number of retries (default: 3)\n\n\ **%s_RETRY_BACKOFF**\n\ : Retry backoff factor (default: 0.3)\n\n\ **%s_NO_FOLLOW_REDIRECTS**\n\ : Set to '1' to disable redirect following\n\n\ **%s_MAX_REDIRECTS**\n\ : Maximum redirects to follow (default: 10)\n\n\ **%s_USER_AGENT**\n\ : User-Agent header to send with requests\ " app_name app_upper app_upper app_upper app_upper app_upper app_upper app_upper app_upper app_upper app_upper app_upper let pp_config ppf config = let _xdg, xdg_cmd = config.xdg in Format.fprintf ppf "@[Configuration:@,\ @[XDG:@,%a@]@,\ persist_cookies: %b@,\ verify_tls: %b@,\ timeout: %a@,\ max_retries: %d@,\ retry_backoff: %.2f@,\ follow_redirects: %b@,\ max_redirects: %d@,\ user_agent: %a@]" Xdge.Cmd.pp xdg_cmd config.persist_cookies config.verify_tls (Format.pp_print_option Format.pp_print_float) config.timeout config.max_retries config.retry_backoff config.follow_redirects config.max_redirects (Format.pp_print_option Format.pp_print_string) config.user_agent end