A batteries included HTTP/1.1 client in OCaml

docs/fixes

+191 -43
+3
CHANGES.md
··· 1 + ## v1.0.0 (dev) 2 + 3 + - Initial public vibing.
+91 -24
README.md
··· 6 6 7 7 - **Clean Eio-style API**: Async I/O using OCaml 5's Eio library 8 8 - **Automatic TLS**: Built-in TLS support with automatic CA certificate handling 9 - - **Simple Interface**: Intuitive API similar to Python's requests library 10 - - **Type-safe**: Leverages OCaml's type system for safer HTTP operations 11 - - **Comprehensive**: Support for common HTTP methods, headers, authentication, retries, and timeouts 9 + - **Connection Pooling**: Efficient connection reuse via session API 10 + - **Authentication**: Basic, Bearer, and Digest authentication (RFC 7617, 6750, 7616) 11 + - **Cookies**: Automatic cookie handling with optional persistence 12 + - **Retries**: Exponential backoff with jitter 13 + - **Timeouts**: Configurable connection and read timeouts 14 + - **Proxy Support**: HTTP/HTTPS proxies with CONNECT tunneling 15 + 16 + ## Installation 17 + 18 + ``` 19 + opam install requests 20 + ``` 12 21 13 22 ## Usage 14 23 15 - Basic GET request: 24 + ### Session API (Recommended) 25 + 26 + Use sessions for connection pooling, cookie persistence, and shared configuration: 16 27 17 28 ```ocaml 18 29 let () = 30 + Mirage_crypto_rng_unix.use_default (); 19 31 Eio_main.run @@ fun env -> 20 - let response = Requests.get ~env (Uri.of_string "https://example.com") in 21 - print_endline (Requests.Response.body_string response) 32 + Eio.Switch.run @@ fun sw -> 33 + 34 + (* Create a session with connection pooling *) 35 + let session = Requests.create ~sw env in 36 + 37 + (* Make a GET request *) 38 + let response = Requests.get session "https://httpbin.org/get" in 39 + Printf.printf "Status: %d\n" (Requests.Response.status_code response); 40 + let body = Requests.Response.text response in 41 + Printf.printf "Body: %s\n" body 22 42 ``` 23 43 24 - POST request with JSON body: 44 + ### POST with JSON 25 45 26 46 ```ocaml 27 47 let () = 48 + Mirage_crypto_rng_unix.use_default (); 28 49 Eio_main.run @@ fun env -> 29 - let uri = Uri.of_string "https://api.example.com/data" in 30 - let body = {|{"key": "value"}|} in 31 - let headers = Requests.Headers.of_list [ 32 - ("content-type", "application/json") 33 - ] in 34 - let response = Requests.post ~env ~headers ~body uri in 50 + Eio.Switch.run @@ fun sw -> 51 + 52 + let session = Requests.create ~sw env in 53 + let headers = Requests.Headers.(empty |> content_type Requests.Mime.json) in 54 + let body = Requests.Body.string ~content_type:Requests.Mime.json 55 + {|{"key": "value"}|} in 56 + let response = Requests.post session ~headers ~body 57 + "https://httpbin.org/post" in 35 58 Printf.printf "Status: %d\n" (Requests.Response.status_code response) 36 59 ``` 37 60 38 - Using authentication and custom headers: 61 + ### Authentication 39 62 40 63 ```ocaml 41 64 let () = 65 + Mirage_crypto_rng_unix.use_default (); 42 66 Eio_main.run @@ fun env -> 67 + Eio.Switch.run @@ fun sw -> 68 + 69 + let session = Requests.create ~sw env in 70 + 71 + (* Basic authentication *) 43 72 let auth = Requests.Auth.basic ~username:"user" ~password:"pass" in 44 - let headers = Requests.Headers.add 45 - (Requests.Headers.init ()) 46 - "user-agent" "my-app/1.0" in 47 - let response = Requests.get ~env ~auth ~headers 48 - (Uri.of_string "https://api.example.com") in 49 - (* Process response *) 50 - () 73 + let response = Requests.get session ~auth "https://httpbin.org/basic-auth/user/pass" in 74 + Printf.printf "Status: %d\n" (Requests.Response.status_code response); 75 + 76 + (* Bearer token *) 77 + let auth = Requests.Auth.bearer ~token:"your-token" in 78 + let response = Requests.get session ~auth "https://api.example.com/resource" in 79 + ignore response 51 80 ``` 52 81 53 - ## Installation 82 + ### One-Shot API 83 + 84 + For simple, stateless requests without connection pooling: 54 85 86 + ```ocaml 87 + let () = 88 + Mirage_crypto_rng_unix.use_default (); 89 + Eio_main.run @@ fun env -> 90 + Eio.Switch.run @@ fun sw -> 91 + 92 + let response = Requests.One.get ~sw 93 + ~clock:env#clock ~net:env#net 94 + "https://httpbin.org/get" in 95 + Printf.printf "Status: %d\n" (Requests.Response.status_code response) 55 96 ``` 56 - opam install requests 97 + 98 + ## Command-Line Tool 99 + 100 + The library includes `ocurl`, a curl-like command-line tool: 101 + 102 + ```bash 103 + # GET request 104 + ocurl https://httpbin.org/get 105 + 106 + # POST with data 107 + ocurl -X POST -d '{"key":"value"}' -H "Content-Type: application/json" https://httpbin.org/post 108 + 109 + # With authentication 110 + ocurl -u user:pass https://httpbin.org/basic-auth/user/pass 57 111 ``` 58 112 59 113 ## Documentation 60 114 61 - API documentation is available at https://tangled.org/@anil.recoil.org/ocaml-requests or via: 115 + API documentation is available via: 62 116 63 117 ``` 64 118 opam install requests 65 119 odig doc requests 66 120 ``` 121 + 122 + Or build locally: 123 + 124 + ``` 125 + opam exec -- dune build @doc 126 + open _build/default/_doc/_html/index.html 127 + ``` 128 + 129 + ## Requirements 130 + 131 + - OCaml 5.1.0+ 132 + - Eio library 133 + - TLS support via tls-eio and ca-certs 67 134 68 135 ## License 69 136
+19 -2
TODO.md
··· 1 - - Auth.Digest seems incomplete 2 - - Body MIME guessing seems like it could 1 + # Future Work 2 + 3 + ## Not Yet Implemented 4 + 5 + - HTTP/2 support (RFC 9113 present in spec/) 6 + - Certificate/public key pinning 7 + - Request/response middleware system 8 + - Progress callbacks for uploads/downloads 9 + - Request cancellation 10 + - Unix domain socket support 11 + 12 + ## Testing 13 + 14 + - Expand unit test coverage for individual modules 15 + - Add more edge case tests for HTTP date parsing 16 + 17 + ## Documentation 18 + 19 + - Add troubleshooting guide to README
+12
dune-project
··· 26 26 (ocaml (>= 5.1.0)) 27 27 (dune (>= 3.20)) 28 28 eio 29 + tls 29 30 tls-eio 30 31 ca-certs 32 + mirage-crypto 31 33 mirage-crypto-rng 32 34 uri 35 + jsont 36 + cookeio 37 + xdge 38 + ptime 39 + cmdliner 33 40 digestif 34 41 base64 35 42 logs 43 + domain-name 44 + cstruct 45 + optint 46 + conpool 36 47 decompress 48 + bigstringaf 37 49 magic-mime 38 50 (odoc :with-doc) 39 51 (alcotest (and :with-test (>= 1.7.0)))
+3 -3
lib/body.ml
··· 264 264 let to_string = function 265 265 | Empty -> "" 266 266 | String { content; _ } -> content 267 - | Stream _ -> failwith "Cannot convert streaming body to string for connection pooling (body must be materialized first)" 268 - | File _ -> failwith "Cannot convert file body to string for connection pooling (file must be read first)" 269 - | Multipart _ -> failwith "Cannot convert multipart body to string for connection pooling (must be encoded first)" 267 + | Stream _ -> invalid_arg "Body.Private.to_string: cannot convert streaming body (must be materialized first)" 268 + | File _ -> invalid_arg "Body.Private.to_string: cannot convert file body (must be read first)" 269 + | Multipart _ -> invalid_arg "Body.Private.to_string: cannot convert multipart body (must be encoded first)" 270 270 271 271 let is_empty = function 272 272 | Empty -> true
+3 -4
lib/one.ml
··· 60 60 Log.debug (fun m -> m "Resolved %s, connecting..." host); 61 61 Eio.Net.connect ~sw net addr 62 62 | [] -> 63 - let msg = Printf.sprintf "Failed to resolve hostname: %s" host in 64 - Log.err (fun m -> m "%s" msg); 65 - failwith msg 63 + Log.err (fun m -> m "Failed to resolve hostname: %s" host); 64 + raise (Error.err (Error.Dns_resolution_failed { hostname = host })) 66 65 67 66 (** Minimum TLS version configuration. 68 67 Per Recommendation #6: Allow enforcing minimum TLS version. *) ··· 142 141 (* Extract host and port *) 143 142 let host = match Uri.host uri with 144 143 | Some h -> h 145 - | None -> failwith ("URL must contain a host: " ^ url) 144 + | None -> raise (Error.err (Error.Invalid_url { url; reason = "URL must contain a host" })) 146 145 in 147 146 148 147 let is_https = Uri.scheme uri = Some "https" in
+2 -6
lib/requests.ml
··· 40 40 | TLS_1_2 -> `TLS_1_2 41 41 | TLS_1_3 -> `TLS_1_3 42 42 43 - (* Note: RNG initialization should be done by the application using 44 - Mirage_crypto_rng_unix.initialize before calling Eio_main.run. 45 - We don't call use_default() here as it spawns background threads 46 - that are incompatible with Eio's structured concurrency. *) 47 - 48 43 (* Main API - Session functionality with connection pooling *) 49 44 50 45 type t = T : { ··· 111 106 ?(allow_insecure_auth = false) 112 107 env = 113 108 109 + Mirage_crypto_rng_unix.use_default (); (* avsm: is this bad to do twice? very common footgun to forget to initialise *) 114 110 let clock = env#clock in 115 111 let net = env#net in 116 112 ··· 515 511 (* Parse the redirect URL to get correct host and port *) 516 512 let redirect_host = match Uri.host uri_to_fetch with 517 513 | Some h -> h 518 - | None -> failwith "Redirect URL must contain a host" 514 + | None -> raise (Error.err (Error.Invalid_redirect { url = url_to_fetch; reason = "URL must contain a host" })) 519 515 in 520 516 let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with 521 517 | Some "https", None -> 443
+2 -2
lib/response.ml
··· 158 158 159 159 let body t = 160 160 if t.closed then 161 - failwith "Response has been closed" 161 + invalid_arg "Response.body: response has been closed" 162 162 else 163 163 t.body 164 164 165 165 let text t = 166 166 if t.closed then 167 - failwith "Response has been closed" 167 + invalid_arg "Response.text: response has been closed" 168 168 else 169 169 Eio.Buf_read.of_flow t.body ~max_size:max_int |> Eio.Buf_read.take_all 170 170
+12
requests.opam
··· 12 12 "ocaml" {>= "5.1.0"} 13 13 "dune" {>= "3.20" & >= "3.20"} 14 14 "eio" 15 + "tls" 15 16 "tls-eio" 16 17 "ca-certs" 18 + "mirage-crypto" 17 19 "mirage-crypto-rng" 18 20 "uri" 21 + "jsont" 22 + "cookeio" 23 + "xdge" 24 + "ptime" 25 + "cmdliner" 19 26 "digestif" 20 27 "base64" 21 28 "logs" 29 + "domain-name" 30 + "cstruct" 31 + "optint" 32 + "conpool" 22 33 "decompress" 34 + "bigstringaf" 23 35 "magic-mime" 24 36 "odoc" {with-doc} 25 37 "alcotest" {with-test & >= "1.7.0"}
+32
spec/README.md
··· 20 20 **Status**: Internet Standard (June 2022) 21 21 **Obsoletes**: RFC 7230 22 22 23 + ### RFC 9111 - HTTP Caching 24 + {{:https://datatracker.ietf.org/doc/html/rfc9111}RFC 9111} 25 + 26 + Defines the HTTP caching model, including Cache-Control directives, freshness calculations, validation, and cache invalidation. 27 + 28 + **Status**: Internet Standard (June 2022) 29 + **Obsoletes**: RFC 7234 30 + 23 31 ### RFC 9113 - HTTP/2 24 32 {{:https://datatracker.ietf.org/doc/html/rfc9113}RFC 9113} 25 33 ··· 61 69 Defines how to use bearer tokens in HTTP requests to access OAuth 2.0 protected resources. 62 70 63 71 **Status**: Proposed Standard (October 2012) 72 + 73 + ### RFC 7616 - HTTP Digest Access Authentication 74 + {{:https://datatracker.ietf.org/doc/html/rfc7616}RFC 7616} 75 + 76 + Specifies the "Digest" HTTP authentication scheme, providing challenge-response authentication with MD5 or SHA-256 hashing. 77 + 78 + **Status**: Proposed Standard (September 2015) 79 + **Obsoletes**: RFC 2617 64 80 65 81 ## State Management 66 82 ··· 88 104 89 105 **Status**: Proposed Standard (August 2018) 90 106 **Obsoletes**: RFC 5077, 5246 107 + 108 + ## Content Encoding 109 + 110 + ### RFC 1950 - ZLIB Compressed Data Format 111 + {{:https://datatracker.ietf.org/doc/html/rfc1950}RFC 1950} 112 + 113 + Defines the ZLIB compressed data format used for HTTP content encoding with the "deflate" Content-Encoding. 114 + 115 + **Status**: Informational (May 1996) 116 + 117 + ### RFC 1951 - DEFLATE Compressed Data Format 118 + {{:https://datatracker.ietf.org/doc/html/rfc1951}RFC 1951} 119 + 120 + Specifies the DEFLATE compression algorithm used in gzip and zlib compression. 121 + 122 + **Status**: Informational (May 1996) 91 123 92 124 ## Using These Specifications 93 125
+6 -1
test/test_one.ml
··· 1 - (* Test using One module directly without connection pooling *) 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Test using One module directly without connection pooling *) 2 7 let () = 3 8 Mirage_crypto_rng_unix.use_default (); 4 9 Eio_main.run @@ fun env ->
+6 -1
test/test_simple_head.ml
··· 1 - (* Simple test to isolate the issue - tests One module directly *) 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Simple test to isolate the issue - tests One module directly *) 2 7 let () = 3 8 Logs.set_level (Some Logs.Debug); 4 9 Logs.set_reporter (Logs_fmt.reporter ());