let src = Logs.Src.create "requests.one" ~doc:"One-shot HTTP Requests" module Log = (val Logs.src_log src : Logs.LOG) (* Helper to create TCP connection to host:port *) let connect_tcp ~sw ~net ~host ~port = Log.debug (fun m -> m "Connecting to %s:%d" host port); (* Resolve hostname to IP address *) let addrs = Eio.Net.getaddrinfo_stream net host ~service:(string_of_int port) in match addrs with | addr :: _ -> Log.debug (fun m -> m "Resolved %s, connecting..." host); Eio.Net.connect ~sw net addr | [] -> let msg = Printf.sprintf "Failed to resolve hostname: %s" host in Log.err (fun m -> m "%s" msg); failwith msg (* Helper to wrap connection with TLS if needed *) let wrap_tls flow ~host ~verify_tls ~tls_config = Log.debug (fun m -> m "Wrapping connection with TLS for %s (verify=%b)" host verify_tls); (* Get or create TLS config *) let tls_cfg = match tls_config, verify_tls with | Some cfg, _ -> cfg | None, true -> (* Use CA certificates for verification *) (match Ca_certs.authenticator () with | Ok authenticator -> (match Tls.Config.client ~authenticator () with | Ok cfg -> cfg | Error (`Msg msg) -> Log.err (fun m -> m "Failed to create TLS config: %s" msg); failwith ("TLS config error: " ^ msg)) | Error (`Msg msg) -> Log.err (fun m -> m "Failed to load CA certificates: %s" msg); failwith ("CA certificates error: " ^ msg)) | None, false -> (* No verification *) match Tls.Config.client ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None) () with | Ok cfg -> cfg | Error (`Msg msg) -> failwith ("TLS config error: " ^ msg) in (* Get domain name for SNI *) let domain = match Domain_name.of_string host with | Ok dn -> (match Domain_name.host dn with | Ok d -> d | Error (`Msg msg) -> Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg); failwith ("Invalid hostname: " ^ msg)) | Error (`Msg msg) -> Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg); failwith ("Invalid hostname: " ^ msg) in (Tls_eio.client_of_flow ~host:domain tls_cfg flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t) (* Parse URL and connect directly (no pooling) *) let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config = let uri = Uri.of_string url in (* Extract host and port *) let host = match Uri.host uri with | Some h -> h | None -> failwith ("URL must contain a host: " ^ url) in let is_https = Uri.scheme uri = Some "https" in let default_port = if is_https then 443 else 80 in let port = Option.value (Uri.port uri) ~default:default_port in (* Apply connection timeout if specified *) let connect_fn () = let tcp_flow = connect_tcp ~sw ~net ~host ~port in if is_https then wrap_tls tcp_flow ~host ~verify_tls ~tls_config else (tcp_flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t) in match timeout with | Some t -> let timeout_seconds = Timeout.total t in (match timeout_seconds with | Some seconds -> Log.debug (fun m -> m "Setting connection timeout: %.2f seconds" seconds); Eio.Time.with_timeout_exn clock seconds connect_fn | None -> connect_fn ()) | None -> connect_fn () (* Main request implementation - completely stateless *) let request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?(follow_redirects = true) ?(max_redirects = 10) ?(verify_tls = true) ?tls_config ~method_ url = let start_time = Unix.gettimeofday () in let method_str = Method.to_string method_ in Log.debug (fun m -> m "[One] Executing %s request to %s" method_str url); (* Prepare headers *) let headers = Option.value headers ~default:Headers.empty 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 (* Convert body to string for sending *) let request_body_str = match body with | None -> "" | Some b -> Body.Private.to_string b in (* Execute request with redirects *) let rec make_with_redirects url_to_fetch redirects_left = let uri_to_fetch = Uri.of_string url_to_fetch in (* Connect to URL (opens new TCP connection) *) let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch ~timeout ~verify_tls ~tls_config in (* Make HTTP request using low-level client *) let status, resp_headers, response_body_str = Http_client.make_request ~method_:method_str ~uri:uri_to_fetch ~headers ~body_str:request_body_str flow in Log.info (fun m -> m "Received response: status=%d" status); (* Handle redirects if enabled *) if follow_redirects && (status >= 300 && status < 400) then begin if redirects_left <= 0 then begin Log.err (fun m -> m "Too many redirects (%d) for %s" max_redirects url); raise (Error.TooManyRedirects { url; count = max_redirects; max = max_redirects }) 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 final_status, final_headers, final_body_str, final_url = make_with_redirects url max_redirects 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 ~status:final_status ~headers:final_headers ~body:body_flow ~url:final_url ~elapsed (* Convenience methods *) let get ~sw ~clock ~net ?headers ?auth ?timeout ?follow_redirects ?max_redirects ?verify_tls ?tls_config url = request ~sw ~clock ~net ?headers ?auth ?timeout ?follow_redirects ?max_redirects ?verify_tls ?tls_config ~method_:`GET url let post ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config url = request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config ~method_:`POST url let put ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config url = request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config ~method_:`PUT url let delete ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config url = request ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config ~method_:`DELETE url let head ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config url = request ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config ~method_:`HEAD url let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config url = request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config ~method_:`PATCH url let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length ?on_progress ?verify_tls ?tls_config ~source url = let method_ = Option.value method_ ~default:`POST in let mime = Option.value mime ~default:Mime.octet_stream in (* Wrap source with progress tracking if callback provided *) let tracked_source = match on_progress with | None -> source | Some callback -> (* For now, progress tracking is not implemented for uploads due to complexity of wrapping Eio.Flow.source. This would require creating a custom flow wrapper. *) let _ = callback in source in let body = Body.of_stream ?length mime tracked_source in request ~sw ~clock ~net ?headers ~body ?auth ?timeout ?verify_tls ?tls_config ~method_ url let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress ?verify_tls ?tls_config url ~sink = let response = get ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config url in try (* Get content length for progress tracking *) let total = Response.content_length response in let body = Response.body response in (* Stream data to sink with optional progress *) match on_progress with | None -> (* No progress tracking, just copy directly *) Eio.Flow.copy body sink | Some progress_fn -> (* Copy with progress tracking *) (* We need to intercept the flow to track bytes *) (* For now, just do a simple copy - proper progress tracking needs flow wrapper *) progress_fn ~received:0L ~total; Eio.Flow.copy body sink; progress_fn ~received:(Option.value total ~default:0L) ~total; (* Response auto-closes with switch *) () with e -> (* Response auto-closes with switch *) raise e