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