A batteries included HTTP/1.1 client in OCaml
at main 403 lines 16 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6open Eio 7open Cmdliner 8 9(* Command-line options *) 10let http_method = 11 let methods = [ 12 ("GET", `GET); 13 ("POST", `POST); 14 ("PUT", `PUT); 15 ("DELETE", `DELETE); 16 ("HEAD", `HEAD); 17 ("OPTIONS", `OPTIONS); 18 ("PATCH", `PATCH); 19 ] in 20 let doc = "HTTP method to use" in 21 let env_info = Cmdliner.Cmd.Env.info "OCURL_METHOD" in 22 Arg.(value & opt (enum methods) `GET & info ["X"; "request"] ~env:env_info ~docv:"METHOD" ~doc) 23 24let urls = 25 let doc = "URL(s) to fetch" in 26 Arg.(non_empty & pos_all string [] & info [] ~docv:"URL" ~doc) 27 28let headers = 29 let doc = "Add custom HTTP header (can be used multiple times)" in 30 Arg.(value & opt_all string [] & info ["H"; "header"] ~docv:"HEADER" ~doc) 31 32let data = 33 let doc = "HTTP POST/PUT data" in 34 Arg.(value & opt (some string) None & info ["d"; "data"] ~docv:"DATA" ~doc) 35 36let json_data = 37 let doc = "HTTP POST/PUT JSON data" in 38 Arg.(value & opt (some string) None & info ["json"] ~docv:"JSON" ~doc) 39 40let output_file = 41 let doc = "Write output to file instead of stdout" in 42 Arg.(value & opt (some string) None & info ["o"; "output"] ~docv:"FILE" ~doc) 43 44let include_headers = 45 let doc = "Include response headers in output" in 46 Arg.(value & flag & info ["i"; "include"] ~doc) 47 48let head = 49 let doc = "Show only response headers (no body)" in 50 Arg.(value & flag & info ["I"; "head"] ~doc) 51 52let auth = 53 let doc = "Basic authentication in USER:PASSWORD format" in 54 Arg.(value & opt (some string) None & info ["u"; "user"] ~docv:"USER:PASS" ~doc) 55 56let allow_insecure_auth = 57 let doc = "Allow basic authentication over HTTP (insecure, for testing only)" in 58 Arg.(value & flag & info ["allow-insecure-auth"] ~doc) 59 60let show_progress = 61 let doc = "Show progress bar for downloads" in 62 Arg.(value & flag & info ["progress-bar"] ~doc) 63 64(* Logging setup *) 65(* Setup logging using Logs_cli for standard logging options *) 66let setup_log app_name = 67 let setup style_renderer level verbose_http_ws = 68 Fmt_tty.setup_std_outputs ?style_renderer (); 69 Logs.set_level level; 70 Logs.set_reporter (Logs_fmt.reporter ()); 71 (* Extract value from with_source wrapper *) 72 Requests.Cmd.setup_log_sources ~verbose_http:verbose_http_ws.Requests.Cmd.value level 73 in 74 Term.(const setup $ Fmt_cli.style_renderer () $ Logs_cli.level () $ 75 Requests.Cmd.verbose_http_term app_name) 76 77(* Parse authentication *) 78let parse_auth auth_str = 79 match String.split_on_char ':' auth_str with 80 | [user; pass] -> Some (user, pass) 81 | _ -> None 82 83(* Parse headers *) 84let parse_header header_str = 85 match String.split_on_char ':' header_str with 86 | [] -> None 87 | [name] -> Some (String.trim name, "") 88 | name :: rest -> 89 Some (String.trim name, String.trim (String.concat ":" rest)) 90 91(* Pretty print response *) 92let pp_response ppf response = 93 let status = Requests.Response.status response in 94 let status_code = Requests.Response.status_code response in 95 let headers = Requests.Response.headers response in 96 97 (* Color code status *) 98 let status_style = 99 if Requests.Status.is_success status then Fmt.(styled `Green) 100 else if Requests.Status.is_client_error status then Fmt.(styled `Yellow) 101 else if Requests.Status.is_server_error status then Fmt.(styled `Red) 102 else Fmt.(styled `Blue) 103 in 104 105 (* Print status line *) 106 Fmt.pf ppf "@[<v>HTTP/1.1 %d %a@]@." 107 status_code 108 (status_style Fmt.string) (Requests.Status.reason_phrase status); 109 110 (* Print headers *) 111 let header_list = Requests.Headers.to_list headers in 112 List.iter (fun (k, v) -> 113 Fmt.pf ppf "@[<h>%a: %s@]@." 114 Fmt.(styled `Cyan string) k v 115 ) header_list; 116 117 Fmt.pf ppf "@." 118 119(* Normalize URL to ensure it has a scheme, defaulting to http:// *) 120let normalize_url url_str = 121 let uri = Uri.of_string url_str in 122 match Uri.scheme uri with 123 | Some _ -> url_str (* Already has a scheme *) 124 | None -> 125 (* No scheme - prepend http:// *) 126 "http://" ^ url_str 127 128(* Process a single URL and return result *) 129let process_url env req method_ headers body include_headers head output url_str = 130 let url_str = normalize_url url_str in 131 let quiet = match Logs.level () with Some (Logs.Error | Logs.Warning) -> true | _ -> false in 132 let uri = Uri.of_string url_str in 133 134 if not quiet then begin 135 let method_str = Requests.Method.to_string (method_ :> Requests.Method.t) in 136 Fmt.pr "@[<v>%a %a@]@." 137 Fmt.(styled `Bold string) method_str 138 Fmt.(styled `Underline Uri.pp) uri; 139 end; 140 try 141 (* Make request *) 142 let response = 143 match method_ with 144 | `GET -> Requests.get req ~headers url_str 145 | `POST -> Requests.post req ~headers ?body url_str 146 | `PUT -> Requests.put req ~headers ?body url_str 147 | `DELETE -> Requests.delete req ~headers url_str 148 | `HEAD -> Requests.head req ~headers url_str 149 | `OPTIONS -> Requests.options req ~headers url_str 150 | `PATCH -> Requests.patch req ~headers ?body url_str 151 in 152 153 (* Print response headers if requested *) 154 if (include_headers || head) && not quiet then 155 pp_response Fmt.stdout response; 156 157 (* If head flag is set, skip body processing *) 158 if head then 159 Ok (url_str, response) 160 else begin 161 (* Handle output *) 162 let body_flow = Requests.Response.body response in 163 164 begin match output with 165 | Some file -> begin 166 let filename = 167 if List.length [url_str] > 1 then begin 168 let base = Filename.remove_extension file in 169 let ext = Filename.extension file in 170 let url_hash = 171 let full_hash = Digest.string url_str |> Digest.to_hex in 172 String.sub full_hash (String.length full_hash - 8) 8 in 173 Printf.sprintf "%s-%s%s" base url_hash ext 174 end else file 175 in 176 let () = 177 Eio.Path.with_open_out ~create:(`Or_truncate 0o644) 178 Eio.Path.(env#fs / filename) @@ fun sink -> 179 Eio.Flow.copy body_flow sink in 180 let () = if not quiet then 181 Fmt.pr "[%s] Saved to %s@." url_str filename else () in 182 Ok (url_str, response) 183 end 184 | None -> 185 (* Write to stdout *) 186 let buf = Buffer.create 1024 in 187 Eio.Flow.copy body_flow (Eio.Flow.buffer_sink buf); 188 let body_str = Buffer.contents buf in 189 190 (* Pretty-print JSON if applicable *) 191 if String.length body_str > 0 && 192 (body_str.[0] = '{' || body_str.[0] = '[') then 193 try 194 match Jsont_bytesrw.decode_string' Jsont.json body_str with 195 | Ok json -> 196 (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jsont.json json with 197 | Ok pretty -> 198 if not quiet then Fmt.pr "[%s]:@." url_str; 199 print_string pretty 200 | Error _ -> 201 if not quiet then Fmt.pr "[%s]:@." url_str; 202 print_string body_str) 203 | Error _ -> 204 if not quiet then Fmt.pr "[%s]:@." url_str; 205 print_string body_str 206 with _ -> 207 if not quiet then Fmt.pr "[%s]:@." url_str; 208 print_string body_str 209 else begin 210 if not quiet then Fmt.pr "[%s]:@." url_str; 211 print_string body_str 212 end; 213 214 if not quiet && Requests.Response.ok response then 215 Logs.app (fun m -> m "✓ Success for %s" url_str); 216 217 Ok (url_str, response) 218 end 219 end 220 with 221 | exn -> 222 Logs.err (fun m -> m "Request failed for %s: %a" url_str Eio.Exn.pp exn); 223 Error (url_str, exn) 224 225(* Main function using Requests with concurrent fetching *) 226let run_request env sw persist_cookies verify_tls timeout follow_redirects max_redirects 227 method_ urls headers data json_data output include_headers head 228 auth allow_insecure_auth _show_progress () = 229 230 (* Log levels are already set by setup_log via Logs_cli *) 231 232 (* Create XDG paths *) 233 let xdg = Xdge.create env#fs "ocurl" in 234 235 (* Create requests instance with configuration *) 236 let timeout_obj = Option.map (fun t -> Requests.Timeout.create ~total:t ()) timeout in 237 let req = Requests.create ~sw ~xdg ~persist_cookies ~verify_tls 238 ~follow_redirects ~max_redirects ~allow_insecure_auth ?timeout:timeout_obj env in 239 240 (* Set authentication if provided *) 241 let req = match Option.bind auth parse_auth with 242 | Some (user, pass) -> 243 Requests.set_auth req (Requests.Auth.basic ~username:user ~password:pass) 244 | None -> 245 (if Option.is_some auth then 246 Logs.warn (fun m -> m "Invalid auth format, ignoring")); 247 req 248 in 249 250 (* Build headers from command line *) 251 let cmd_headers = List.fold_left (fun hdrs header_str -> 252 match parse_header header_str with 253 | Some (k, v) -> Requests.Headers.add_string k v hdrs 254 | None -> hdrs 255 ) Requests.Headers.empty headers in 256 257 (* Prepare body based on data/json options *) 258 let body = match json_data, data with 259 | Some json_str, _ -> 260 (* Use of_string with JSON mime type for raw JSON string *) 261 Some (Requests.Body.of_string Requests.Mime.json json_str) 262 | None, Some d -> Some (Requests.Body.text d) 263 | None, None -> None 264 in 265 266 (* Process URLs concurrently or sequentially based on count *) 267 match urls with 268 | [] -> () 269 | [single_url] -> 270 (* Single URL - process directly *) 271 let _ = process_url env req method_ cmd_headers body include_headers head output single_url in 272 () 273 | multiple_urls -> 274 (* Multiple URLs - process concurrently *) 275 let verbose = Logs.level () = Some Logs.Debug || Logs.level () = Some Logs.Info in 276 if verbose then 277 Fmt.pr "@[<v>Processing %d URLs concurrently...@]@." (List.length multiple_urls); 278 279 (* Create promises for each URL *) 280 let results = 281 List.map (fun url_str -> 282 let promise, resolver = Eio.Promise.create () in 283 (* Fork a fiber for each URL *) 284 Fiber.fork ~sw (fun () -> 285 let result = process_url env req method_ cmd_headers body include_headers head output url_str in 286 Eio.Promise.resolve resolver result 287 ); 288 promise 289 ) multiple_urls 290 in 291 292 (* Wait for all promises to complete *) 293 let completed_results = List.map Eio.Promise.await results in 294 295 (* Report summary *) 296 let quiet = match Logs.level () with Some (Logs.Error | Logs.Warning) -> true | _ -> false in 297 if not quiet then begin 298 let successes = List.filter Result.is_ok completed_results |> List.length in 299 let failures = List.filter Result.is_error completed_results |> List.length in 300 Fmt.pr "@[<v>@.Summary: %d successful, %d failed out of %d total@]@." 301 successes failures (List.length completed_results); 302 303 (* Print failed URLs *) 304 if failures > 0 then begin 305 Fmt.pr "@[<v>Failed URLs:@]@."; 306 List.iter (function 307 | Error (url, _) -> Fmt.pr " - %s@." url 308 | Ok _ -> () 309 ) completed_results 310 end 311 end 312 313(* Main entry point *) 314let main method_ urls headers data json_data output include_headers head 315 auth allow_insecure_auth show_progress persist_cookies_ws verify_tls_ws 316 timeout_ws follow_redirects_ws max_redirects_ws () = 317 318 (* Extract values from with_source wrappers *) 319 let persist_cookies = persist_cookies_ws.Requests.Cmd.value in 320 let verify_tls = verify_tls_ws.Requests.Cmd.value in 321 let timeout = timeout_ws.Requests.Cmd.value in 322 let follow_redirects = follow_redirects_ws.Requests.Cmd.value in 323 let max_redirects = max_redirects_ws.Requests.Cmd.value in 324 325 Eio_main.run @@ fun env -> 326 Mirage_crypto_rng_unix.use_default (); 327 Switch.run @@ fun sw -> 328 329 run_request env sw persist_cookies verify_tls timeout follow_redirects max_redirects 330 method_ urls headers data json_data output include_headers head auth 331 allow_insecure_auth show_progress () 332 333(* Command-line interface *) 334let cmd = 335 let doc = "OCaml HTTP client with concurrent fetching using the Requests library" in 336 let man = [ 337 `S Manpage.s_description; 338 `P "$(tname) is a command-line HTTP client written in OCaml that uses the \ 339 Requests library with stateful request management. It supports various HTTP methods, \ 340 custom headers, authentication, cookies, and JSON data. When multiple URLs are provided, \ 341 they are fetched concurrently using Eio fibers for maximum performance."; 342 `S Manpage.s_examples; 343 `P "Fetch a URL:"; 344 `Pre " $(tname) https://api.github.com"; 345 `P "Fetch multiple URLs concurrently:"; 346 `Pre " $(tname) https://api.github.com https://httpbin.org/get https://example.com"; 347 `P "Show only response headers (like HEAD request):"; 348 `Pre " $(tname) -I https://api.github.com"; 349 `P "Include response headers with body:"; 350 `Pre " $(tname) -i https://api.github.com"; 351 `P "POST JSON data:"; 352 `Pre " $(tname) -X POST --json '{\"key\":\"value\"}' https://httpbin.org/post"; 353 `P "Download file:"; 354 `Pre " $(tname) -o file.zip https://example.com/file.zip"; 355 `P "Download multiple files concurrently:"; 356 `Pre " $(tname) -o output.json https://api1.example.com https://api2.example.com https://api3.example.com"; 357 `P "Basic authentication:"; 358 `Pre " $(tname) -u user:pass https://httpbin.org/basic-auth/user/pass"; 359 `P "Custom headers:"; 360 `Pre " $(tname) -H 'Accept: application/json' -H 'X-Api-Key: secret' https://api.example.com"; 361 `P "With persistent cookies:"; 362 `Pre " $(tname) --persist-cookies https://example.com"; 363 `P "Disable TLS verification (insecure):"; 364 `Pre " $(tname) --no-verify-tls https://self-signed.example.com"; 365 `S "LOGGING OPTIONS"; 366 `P "Control logging verbosity using standard options:"; 367 `P "Enable verbose logging (can be repeated):"; 368 `Pre " $(tname) -v https://api.github.com # info level"; 369 `Pre " $(tname) -vv https://api.github.com # debug level (application-level)"; 370 `P "Enable HTTP protocol-level verbose logging:"; 371 `Pre " $(tname) -vv --verbose-http https://api.github.com # includes TLS/TCP details"; 372 `P "Suppress output:"; 373 `Pre " $(tname) -q https://api.github.com # warnings and errors only"; 374 `P "Set specific log level:"; 375 `Pre " $(tname) --verbosity=info https://api.github.com"; 376 `Pre " $(tname) --verbosity=debug https://api.github.com"; 377 `Pre " $(tname) --verbosity=error https://api.github.com"; 378 `P "Available verbosity levels: quiet, error, warning, info, debug"; 379 `P "The logging system provides detailed information about:"; 380 `P "- HTTP requests and responses (use -v or -vv for application-level logs)"; 381 `P "- Authentication and cookie handling"; 382 `P "- Retry attempts and backoff calculations"; 383 `P "- TLS/TCP connection details (use --verbose-http with -vv for protocol-level logs)"; 384 ] in 385 386 (* Build the term with Requests configuration options *) 387 let app_name = "ocurl" in 388 let combined_term = 389 Term.(const main $ http_method $ urls $ headers $ data $ json_data $ 390 output_file $ include_headers $ head $ auth $ 391 allow_insecure_auth $ show_progress $ 392 Requests.Cmd.persist_cookies_term app_name $ 393 Requests.Cmd.verify_tls_term app_name $ 394 Requests.Cmd.timeout_term app_name $ 395 Requests.Cmd.follow_redirects_term app_name $ 396 Requests.Cmd.max_redirects_term app_name $ 397 setup_log app_name) 398 in 399 400 let info = Cmd.info "ocurl" ~version:"2.0.0" ~doc ~man in 401 Cmd.v info combined_term 402 403let () = exit (Cmd.eval cmd)