diff --git a/.cursor/commands/gleam.md b/.cursor/commands/gleam.md new file mode 100644 index 0000000..147259d --- /dev/null +++ b/.cursor/commands/gleam.md @@ -0,0 +1,87 @@ +# Gleam on the BEAM + +This is a Gleam application running on the BEAM (Erlang VM). Every solution must use the correct patterns for this platform. Solutions designed for other architectures (Node.js event loops, Go goroutines, Rust async, JVM threading, etc.) are not acceptable, even if they "work." + +## The Standard + +There is one correct way to solve most problems on the BEAM. Find it. Use it. Do not settle for a solution that merely functions — it must be idiomatically correct for Gleam/Erlang/OTP. + +Before proposing any solution that involves concurrency, state management, fault tolerance, or inter-process communication, ask yourself: **"How would an experienced Erlang/OTP engineer solve this?"** Then find the Gleam equivalent. + +## Platform Primitives (Use These) + +### Processes and Actors + +The BEAM's unit of concurrency is the lightweight process. State lives in processes, not in shared mutable memory. + +- **Use `gleam_otp` actors** (`gleam/otp/actor`) for stateful services. Actors are the correct way to hold mutable state — not closures, not mutable references, not global variables. +- **Use `gleam_erlang` subjects and selectors** for message passing between processes. +- **Every long-lived stateful component should be an actor.** Caches, connection pools, rate limiters, session stores, background workers — these are all actors. +- **Never share state between processes via anything other than message passing.** No shared memory. No locks. No mutexes. This is not that kind of platform. + +### Supervision Trees + +Processes crash. That is expected and correct on the BEAM. + +- **Use supervisors** (`gleam/otp/supervisor`) to manage process lifecycles. Every actor that matters should be supervised. +- **Design for failure.** "Let it crash" is not negligence — it is the architecture. Supervisors restart failed processes with known-good state. +- **Think about restart strategies.** One-for-one, one-for-all, rest-for-one — pick the right one for the failure domain. +- **Never try/catch your way out of a process crash.** Let the supervisor handle it. Recovery logic belongs in `init`, not in error handlers wrapped around every call. + +### ETS (Erlang Term Storage) + +ETS tables are the correct solution for shared read-heavy state that multiple processes need to access concurrently. + +- **Use ETS for caches, lookup tables, and read-heavy shared state.** ETS provides concurrent reads without bottlenecking through a single actor's mailbox. +- **Do not use ETS as a general-purpose database.** It is in-memory and not persisted across restarts (unless you specifically set that up with DETS or manual persistence). +- **Owner process matters.** An ETS table is owned by the process that created it. If that process dies, the table is destroyed. Plan accordingly — typically the supervisor or a dedicated table-owner process should create tables. + +### Process Links and Monitors + +- **Use monitors** when you need to know if another process dies but don't want to die with it. +- **Use links** when processes should fail together. +- Understand the difference. Using the wrong one causes either silent failures or unnecessary cascading crashes. + +## Erlang FFI + +Gleam runs on the BEAM and can call Erlang directly. This is a strength, not a workaround. + +- **Use Erlang FFI when Gleam lacks a wrapper** for BEAM functionality you need (e.g., `:ets`, `:timer`, `:crypto`, specific OTP behaviors). +- **Write thin FFI wrappers** — the Erlang code should be minimal, with the logic and types living in Gleam. +- **Always provide type-safe Gleam interfaces** over raw FFI calls. Never expose raw Erlang terms to calling code. +- **FFI is for platform access, not for bypassing Gleam's type system.** If you find yourself using FFI to work around Gleam's constraints, you are likely solving the wrong problem. + +## Anti-Patterns (Never Do These) + +- **Never use polling loops** where you should use process messaging or monitors. +- **Never use a single process as a bottleneck** for state that could live in ETS for concurrent reads. +- **Never spawn unsupervised processes** for anything that matters. If a process does real work, it belongs in a supervision tree. +- **Never store state in module-level variables or closures** pretending to be singletons. State belongs in actors. +- **Never implement your own process registry** when Erlang's built-in registry or `gproc`-style solutions exist. +- **Never use GenServer-style synchronous calls** when a cast (async message) would suffice and the caller doesn't need the result. +- **Never treat BEAM processes like OS threads.** They are cheap. Spawn thousands. Don't pool them like heavyweight threads. +- **Never reach for an external dependency** (Redis, a message queue, an in-memory cache library) when the BEAM already provides the primitive. ETS is your cache. Processes are your workers. Message passing is your queue. + +## Decision Framework + +When solving a problem, evaluate in this order: + +1. **Can a pure function solve this?** No state, no processes needed. Just transform data. This is always preferred. +2. **Does this need state?** → Actor. Use `gleam/otp/actor`. +3. **Does this state need to survive crashes?** → Supervised actor with init-time recovery. +4. **Do multiple processes need to read this state concurrently?** → ETS table, owned by a supervised process. +5. **Does this need to react to process lifecycle events?** → Monitors or links. +6. **Does this need periodic work?** → Actor with `erlang.send_after` or `:timer` via FFI. Not a polling loop. +7. **Does this need to coordinate multiple actors?** → Supervisor tree with appropriate restart strategy. +8. **Does Gleam lack a wrapper for the BEAM feature I need?** → Erlang FFI with a type-safe Gleam wrapper. + +## What "Correct" Means + +A correct solution on this platform: + +- Uses processes for isolation and concurrency, not threads or async/await +- Uses message passing for communication, not shared memory +- Uses supervisors for fault tolerance, not try/catch +- Uses ETS for shared read-heavy state, not a cache actor bottleneck +- Uses the existing BEAM ecosystem before reaching for external tools +- Compiles to idiomatic BEAM bytecode that an Erlang veteran would recognize as reasonable diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab7bf5..9928702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Updated mist streaming adapter to use actor-based `mist.chunked()` API + instead of the removed `mist.Streaming` response variant. Dream's public + streaming API (`stream_response`, `sse_response`, `ResponseBody.Stream`) + is unchanged. + ## [2.4.1] - 2026-03-11 ### Fixed diff --git a/examples/custom_context/gleam.toml b/examples/custom_context/gleam.toml index 37f0696..13a68ed 100644 --- a/examples/custom_context/gleam.toml +++ b/examples/custom_context/gleam.toml @@ -7,5 +7,4 @@ licences = ["MIT"] dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = ">= 4.3.0 and < 5.0.0" -gleam_stdlib = ">= 0.44.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" +gleam_stdlib = ">= 0.44.0 and < 2.0.0" \ No newline at end of file diff --git a/examples/custom_context/manifest.toml b/examples/custom_context/manifest.toml index b2ed7c5..061c814 100644 --- a/examples/custom_context/manifest.toml +++ b/examples/custom_context/manifest.toml @@ -2,26 +2,27 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/http_client" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -29,4 +30,3 @@ dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = { version = ">= 4.3.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/custom_context/src/controllers/posts_controller.gleam b/examples/custom_context/src/controllers/posts_controller.gleam index 22466ae..92a426d 100644 --- a/examples/custom_context/src/controllers/posts_controller.gleam +++ b/examples/custom_context/src/controllers/posts_controller.gleam @@ -56,7 +56,10 @@ fn make_request_and_respond(user_id: String, post_id: String) -> Response { text_response(status.ok, post_view.format_show(user_id, post_id, body)) Error(client.ResponseError(response: client.HttpResponse(body: body, ..))) -> text_response(status.internal_server_error, post_view.format_error(body)) - Error(client.RequestError(message: error)) -> - text_response(status.internal_server_error, post_view.format_error(error)) + Error(client.RequestError(error: transport_error)) -> + text_response( + status.internal_server_error, + post_view.format_error(client.transport_error_to_string(transport_error)), + ) } } diff --git a/examples/database/gleam.toml b/examples/database/gleam.toml index fe53f52..18703e5 100644 --- a/examples/database/gleam.toml +++ b/examples/database/gleam.toml @@ -7,7 +7,6 @@ licences = ["MIT"] dream = { path = "../.." } dream_json = { path = "../../modules/json" } dream_postgres = { path = "../../modules/postgres" } -mist = ">= 5.0.3 and < 6.0.0" pog = ">= 4.0.0 and < 5.0.0" cigogne = ">= 5.0.0 and < 6.0.0" gleam_json = ">= 2.2.0 and < 4.0.0" diff --git a/examples/database/manifest.toml b/examples/database/manifest.toml index e03c06f..421607c 100644 --- a/examples/database/manifest.toml +++ b/examples/database/manifest.toml @@ -4,11 +4,11 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "cigogne", version = "5.0.4", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "2138362840E0773213C5811DD6FB43B59E44F0DA3DF9FF2882223C789207C201" }, - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_json", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time"], source = "local", path = "../../modules/json" }, - { name = "dream_postgres", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "cigogne", version = "5.0.5", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "C65012988BDFE143D78DDFE65B1DC6B720E8F6E2FC6CD87959376002886D78E8" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_json", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time"], source = "local", path = "../../modules/json" }, + { name = "dream_postgres", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -16,23 +16,22 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, ] [requirements] @@ -45,5 +44,4 @@ gleam_json = { version = ">= 2.2.0 and < 4.0.0" } gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } pog = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/examples/multi_format/gleam.toml b/examples/multi_format/gleam.toml index e4d6d4f..86f9099 100644 --- a/examples/multi_format/gleam.toml +++ b/examples/multi_format/gleam.toml @@ -6,7 +6,6 @@ licences = ["MIT"] [dependencies] dream = { path = "../.." } dream_postgres = { path = "../../modules/postgres" } -mist = ">= 5.0.3 and < 6.0.0" pog = ">= 4.0.0 and < 5.0.0" marceau = ">= 1.3.0 and < 2.0.0" cigogne = ">= 5.0.0 and < 6.0.0" diff --git a/examples/multi_format/manifest.toml b/examples/multi_format/manifest.toml index bbb3441..29ff60a 100644 --- a/examples/multi_format/manifest.toml +++ b/examples/multi_format/manifest.toml @@ -4,10 +4,10 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "cigogne", version = "5.0.4", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "2138362840E0773213C5811DD6FB43B59E44F0DA3DF9FF2882223C789207C201" }, - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_postgres", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "cigogne", version = "5.0.5", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "C65012988BDFE143D78DDFE65B1DC6B720E8F6E2FC6CD87959376002886D78E8" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_postgres", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -15,23 +15,22 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, ] [requirements] @@ -45,5 +44,4 @@ gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } pog = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/examples/rate_limiter/gleam.toml b/examples/rate_limiter/gleam.toml index e19824c..1e2ea7e 100644 --- a/examples/rate_limiter/gleam.toml +++ b/examples/rate_limiter/gleam.toml @@ -6,6 +6,5 @@ licences = ["MIT"] [dependencies] dream = { path = "../.." } dream_ets = { path = "../../modules/ets" } -mist = ">= 5.0.3 and < 6.0.0" gleam_time = ">= 1.5.0 and < 2.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" diff --git a/examples/rate_limiter/manifest.toml b/examples/rate_limiter/manifest.toml index a509154..5eaeaa8 100644 --- a/examples/rate_limiter/manifest.toml +++ b/examples/rate_limiter/manifest.toml @@ -2,26 +2,25 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_ets", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_stdlib"], source = "local", path = "../../modules/ets" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_ets", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_stdlib"], source = "local", path = "../../modules/ets" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -29,4 +28,3 @@ dream = { path = "../.." } dream_ets = { path = "../../modules/ets" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/simple/gleam.toml b/examples/simple/gleam.toml index dc47e1f..9458529 100644 --- a/examples/simple/gleam.toml +++ b/examples/simple/gleam.toml @@ -8,5 +8,3 @@ dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" - diff --git a/examples/simple/manifest.toml b/examples/simple/manifest.toml index 2e91421..c185b7a 100644 --- a/examples/simple/manifest.toml +++ b/examples/simple/manifest.toml @@ -2,26 +2,27 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/http_client" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -29,4 +30,3 @@ dream = { path = "../.." } dream_http_client = { path = "../../modules/http_client" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/simple/src/controllers/posts_controller.gleam b/examples/simple/src/controllers/posts_controller.gleam index fcab330..0f7ec04 100644 --- a/examples/simple/src/controllers/posts_controller.gleam +++ b/examples/simple/src/controllers/posts_controller.gleam @@ -55,7 +55,10 @@ fn make_request_and_respond(user_id: String, post_id: String) -> Response { text_response(status.ok, post_view.format_show(user_id, post_id, body)) Error(client.ResponseError(response: client.HttpResponse(body: body, ..))) -> text_response(status.internal_server_error, post_view.format_error(body)) - Error(client.RequestError(message: error)) -> - text_response(status.internal_server_error, post_view.format_error(error)) + Error(client.RequestError(error: transport_error)) -> + text_response( + status.internal_server_error, + post_view.format_error(client.transport_error_to_string(transport_error)), + ) } } diff --git a/examples/simplest/gleam.toml b/examples/simplest/gleam.toml index d7fa4fe..b2e8772 100644 --- a/examples/simplest/gleam.toml +++ b/examples/simplest/gleam.toml @@ -7,8 +7,6 @@ licences = ["MIT"] dream = { path = "../.." } gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_http = ">= 4.0.0 and < 5.0.0" -mist = ">= 5.0.3 and < 6.0.0" - diff --git a/examples/simplest/manifest.toml b/examples/simplest/manifest.toml index a57a2c4..89da81f 100644 --- a/examples/simplest/manifest.toml +++ b/examples/simplest/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -10,24 +10,19 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] dream = { path = "../.." } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } - - - diff --git a/examples/sse/gleam.toml b/examples/sse/gleam.toml index 9ca8963..f611f3d 100644 --- a/examples/sse/gleam.toml +++ b/examples/sse/gleam.toml @@ -9,7 +9,6 @@ gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_erlang = ">= 0.30.0 and < 2.0.0" gleam_otp = ">= 0.10.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" [dev-dependencies] diff --git a/examples/sse/manifest.toml b/examples/sse/manifest.toml index 5fe4037..1302e91 100644 --- a/examples/sse/manifest.toml +++ b/examples/sse/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -14,14 +14,13 @@ packages = [ { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "telemetry", version = "1.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "2172E05A27531D3D31DD9782841065C50DD5C3C7699D95266B2EDD54C2DAFA1C" }, ] [requirements] @@ -32,4 +31,3 @@ gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_otp = { version = ">= 0.10.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/static/gleam.toml b/examples/static/gleam.toml index 60c4e8a..0631610 100644 --- a/examples/static/gleam.toml +++ b/examples/static/gleam.toml @@ -5,7 +5,6 @@ licences = ["MIT"] [dependencies] dream = { path = "../.." } -mist = ">= 5.0.3 and < 6.0.0" marceau = ">= 1.3.0 and < 2.0.0" simplifile = ">= 2.3.0 and < 3.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" diff --git a/examples/static/manifest.toml b/examples/static/manifest.toml index 08e4aeb..9217de8 100644 --- a/examples/static/manifest.toml +++ b/examples/static/manifest.toml @@ -2,30 +2,28 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] dream = { path = "../.." } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } simplifile = { version = ">= 2.3.0 and < 3.0.0" } diff --git a/examples/streaming/gleam.toml b/examples/streaming/gleam.toml index 497223c..7184d19 100644 --- a/examples/streaming/gleam.toml +++ b/examples/streaming/gleam.toml @@ -10,6 +10,5 @@ dream_mock_server = { path = "../../modules/mock_server" } gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_yielder = ">= 1.1.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" gleam_erlang = ">= 1.3.0 and < 2.0.0" diff --git a/examples/streaming/manifest.toml b/examples/streaming/manifest.toml index 0d9cdff..d9a181f 100644 --- a/examples/streaming/manifest.toml +++ b/examples/streaming/manifest.toml @@ -2,9 +2,10 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], source = "local", path = "../../modules/http_client" }, - { name = "dream_mock_server", version = "1.1.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, + { name = "dream_mock_server", version = "1.1.1", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -12,17 +13,17 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -33,4 +34,3 @@ gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/examples/streaming/src/controllers/stream_controller.gleam b/examples/streaming/src/controllers/stream_controller.gleam index 5695e5a..8f300b9 100644 --- a/examples/streaming/src/controllers/stream_controller.gleam +++ b/examples/streaming/src/controllers/stream_controller.gleam @@ -88,20 +88,22 @@ pub fn new( status.internal_server_error, stream_view.format_error(body), ) - Error(client.RequestError(message: error)) -> + Error(client.RequestError(error: transport_error)) -> text_response( status.internal_server_error, - stream_view.format_error(error), + stream_view.format_error(client.transport_error_to_string( + transport_error, + )), ) } } fn convert_chunk_result( - result: Result(bytes_tree.BytesTree, String), + result: Result(bytes_tree.BytesTree, client.StreamFailure), ) -> Result(String, String) { case result { Ok(chunk) -> Ok(chunk_to_string(chunk)) - Error(err) -> Error(err) + Error(failure) -> Error(client.stream_failure_to_string(failure)) } } diff --git a/examples/streaming_capabilities/manifest.toml b/examples/streaming_capabilities/manifest.toml index 52e9b16..64374f9 100644 --- a/examples/streaming_capabilities/manifest.toml +++ b/examples/streaming_capabilities/manifest.toml @@ -2,9 +2,10 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_http_client", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], source = "local", path = "../../modules/http_client" }, - { name = "dream_mock_server", version = "1.1.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../../modules/http_client" }, + { name = "dream_mock_server", version = "1.1.1", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../../modules/mock_server" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -12,17 +13,17 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] diff --git a/examples/streaming_capabilities/src/controllers/stream_controller.gleam b/examples/streaming_capabilities/src/controllers/stream_controller.gleam index 6e3df44..7a6499f 100644 --- a/examples/streaming_capabilities/src/controllers/stream_controller.gleam +++ b/examples/streaming_capabilities/src/controllers/stream_controller.gleam @@ -96,7 +96,7 @@ fn create_line(n: Int) -> BitArray { } fn convert_to_bit_array( - result: Result(bytes_tree.BytesTree, String), + result: Result(bytes_tree.BytesTree, client.StreamFailure), ) -> Result(BitArray, Nil) { case result { Ok(chunk) -> Ok(bytes_tree.to_bit_array(chunk)) diff --git a/examples/tasks/gleam.toml b/examples/tasks/gleam.toml index 75c2a68..ba8ad5e 100644 --- a/examples/tasks/gleam.toml +++ b/examples/tasks/gleam.toml @@ -7,7 +7,6 @@ licences = ["MIT"] dream = { path = "../.." } dream_postgres = { path = "../../modules/postgres" } dream_config = { path = "../../modules/config" } -mist = ">= 5.0.3 and < 6.0.0" pog = ">= 4.0.0 and < 5.0.0" squirrel = ">= 4.0.0 and < 5.0.0" cigogne = ">= 5.0.0 and < 6.0.0" diff --git a/examples/tasks/manifest.toml b/examples/tasks/manifest.toml index 9d3d646..2a501cc 100644 --- a/examples/tasks/manifest.toml +++ b/examples/tasks/manifest.toml @@ -4,48 +4,47 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "cigogne", version = "5.0.4", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "2138362840E0773213C5811DD6FB43B59E44F0DA3DF9FF2882223C789207C201" }, + { name = "cigogne", version = "5.0.5", build_tools = ["gleam"], requirements = ["argv", "envoy", "gleam_crypto", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pog", "simplifile", "splitter", "tom"], otp_app = "cigogne", source = "hex", outer_checksum = "C65012988BDFE143D78DDFE65B1DC6B720E8F6E2FC6CD87959376002886D78E8" }, { name = "dot_env", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "simplifile"], otp_app = "dot_env", source = "hex", outer_checksum = "F2B4815F1B5AF8F20A6EADBB393E715C4C35203EBD5BE8200F766EA83A0B18DE" }, - { name = "dream", version = "0.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_config", version = "0.1.0", build_tools = ["gleam"], requirements = ["dot_env", "envoy", "gleam_stdlib"], source = "local", path = "../../modules/config" }, - { name = "dream_postgres", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_config", version = "1.0.2", build_tools = ["gleam"], requirements = ["dot_env", "envoy", "gleam_stdlib"], source = "local", path = "../../modules/config" }, + { name = "dream_postgres", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "pog"], source = "local", path = "../../modules/postgres" }, { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, - { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, - { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, - { name = "non_empty_list", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "1CA43D18C07E98E9ED5A60D9CB2FFE0FF40DEFFA45D58A3FF589589F05658F7B" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "non_empty_list", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "06F9D3AC751CF7853AD5D24B8139CEB30E42D8799DE8D49F966A6197DF0B01CC" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, { name = "tote", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tote", source = "hex", outer_checksum = "A249892E26A53C668897F8D47845B0007EEE07707A1A03437487F0CD5A452CA5" }, { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, ] @@ -64,6 +63,5 @@ gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } gleeunit = { version = ">= 1.9.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } pog = { version = ">= 4.0.0 and < 5.0.0" } squirrel = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/examples/websocket_chat/gleam.toml b/examples/websocket_chat/gleam.toml index 55b7ad9..6d44a84 100644 --- a/examples/websocket_chat/gleam.toml +++ b/examples/websocket_chat/gleam.toml @@ -9,7 +9,6 @@ gleam_http = ">= 4.0.0 and < 5.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_erlang = ">= 0.30.0 and < 2.0.0" gleam_otp = ">= 0.10.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" [dev-dependencies] diff --git a/examples/websocket_chat/manifest.toml b/examples/websocket_chat/manifest.toml index f623f1a..1302e91 100644 --- a/examples/websocket_chat/manifest.toml +++ b/examples/websocket_chat/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -10,18 +10,17 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, - { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "ae358876d2d6763e8febb977221797fc42bffcc0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -32,4 +31,3 @@ gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_otp = { version = ">= 0.10.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } diff --git a/gleam.toml b/gleam.toml index 3247ed6..da05a26 100644 --- a/gleam.toml +++ b/gleam.toml @@ -10,7 +10,7 @@ links = [ [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" -mist = ">= 5.0.3 and < 6.0.0" +mist = { git = "https://github.com/TrustBound/mist.git", ref = "fix/http2-support" } gleam_http = ">= 4.3.0 and < 5.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" gleam_erlang = ">= 1.0.0 and < 2.0.0" @@ -24,4 +24,4 @@ simplifile = ">= 2.3.0 and < 3.0.0" squirrel = ">= 4.0.0 and < 5.0.0" mockth = { git = "https://github.com/bondiano/mockth.git", ref = "master" } dream_test = ">= 1.0.3 and < 2.0.0" -dream_http_client = ">= 2.1.1 and < 3.0.0" +dream_http_client = { path = "modules/http_client" } diff --git a/manifest.toml b/manifest.toml index 0dd78c3..5c7f2c3 100644 --- a/manifest.toml +++ b/manifest.toml @@ -4,53 +4,54 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "dream_http_client", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], otp_app = "dream_http_client", source = "hex", outer_checksum = "D43BC0DFD411CAB707345A6DF4E6B1EA299DCDBFF9230CB124917D2D6FE1775A" }, - { name = "dream_test", version = "1.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "dream_test", source = "hex", outer_checksum = "7C8E31A5608960F284FC0156D108AD3024DB264CC2EAC41A916346AD22B98C4E" }, - { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "modules/http_client" }, + { name = "dream_test", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib"], otp_app = "dream_test", source = "hex", outer_checksum = "761B472ECF65785239611AE8E27104B57B8697018576060114574F5CF8DFE2A2" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, - { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, - { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, - { name = "gleeunit", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "7AE0F64B26CC065ED705FF7CA5F4EDAB8015E72A883736FE251E46FACCCE1E08" }, - { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], source = "git", repo = "https://github.com/TrustBound/mist.git", commit = "4335c5602f72f9c0366dd256cd1ada8ed7ce2c27" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, { name = "mug", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "C01279D98E40371DA23461774B63F0E3581B8F1396049D881B0C7EB32799D93F" }, - { name = "non_empty_list", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "3BF1496B475F3F2CE2EA18157587329812493369B2A7C5B9DCFEA5C007670A3B" }, - { name = "opentelemetry_api", version = "1.4.1", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "39BDB6AD740BC13B16215CB9F233D66796BBAE897F3BF6EB77ABB712E87C3C26" }, - { name = "pg_types", version = "0.5.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "A3023B464AA960BC1628635081E30CCA4F676F2D4C23CCD6179C1C11C9B4A642" }, - { name = "pgo", version = "0.15.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "4B883D751B8D4247F4D8A6FBAE58EDB6FA9DBC183371299BF84975EE36406388" }, + { name = "non_empty_list", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "non_empty_list", source = "hex", outer_checksum = "06F9D3AC751CF7853AD5D24B8139CEB30E42D8799DE8D49F966A6197DF0B01CC" }, + { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, + { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, - { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, - { name = "squirrel", version = "4.5.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "6B0F52F467BD0E5B7686F4D1AC04A221DB27B84CA0BECB4E806B82D7260A4BFD" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, - { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" }, { name = "tote", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tote", source = "hex", outer_checksum = "A249892E26A53C668897F8D47845B0007EEE07707A1A03437487F0CD5A452CA5" }, { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" }, ] [requirements] -dream_http_client = { version = ">= 2.1.1 and < 3.0.0" } +dream_http_client = { path = "modules/http_client" } dream_test = { version = ">= 1.0.3 and < 2.0.0" } gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } gleam_http = { version = ">= 4.3.0 and < 5.0.0" } @@ -60,7 +61,7 @@ gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_time = { version = ">= 1.5.0 and < 2.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } marceau = { version = ">= 1.3.0 and < 2.0.0" } -mist = { version = ">= 5.0.3 and < 6.0.0" } +mist = { git = "https://github.com/TrustBound/mist.git", ref = "fix/http2-support" } mockth = { git = "https://github.com/bondiano/mockth.git", ref = "master" } simplifile = { version = ">= 2.3.0 and < 3.0.0" } squirrel = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/modules/http_client/CHANGELOG.md b/modules/http_client/CHANGELOG.md index d3a02ac..4ce0790 100644 --- a/modules/http_client/CHANGELOG.md +++ b/modules/http_client/CHANGELOG.md @@ -5,6 +5,110 @@ All notable changes to `dream_http_client` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.0 - 2026-03-25 + +### Fixed + +- Fixed PUT, POST, and PATCH requests hanging when sent with an empty body. + The underlying gun dispatch now uses `gun:request/5` directly instead of + method-specific functions that behave inconsistently for empty bodies. + +### Changed + +- **HTTP backend replaced: `httpc` → `gun`.** The underlying HTTP client has + been swapped from Erlang's `httpc` to [gun](https://github.com/ninenines/gun), + a production-grade HTTP/1.1 and HTTP/2 client. This enables native HTTP/2 + multiplexing for high-concurrency workloads (10k+ concurrent requests over + a single TCP connection to HTTP/2 servers). All public API contracts are + preserved — `send()`, `stream_yielder()`, and `start_stream()` behave + identically from the caller's perspective. +- **`SendError.RequestError` from `message: String` to `error: TransportError`** + for structured transport error classification. Pattern match on `TransportError` + variants for programmatic error handling, or use `transport_error_to_string` + for the old string behavior. +- **`StreamMessage.StreamError` from `reason: String` to `error: StreamFailure`** + distinguishing HTTP failures (non-2xx response with headers/body) from + transport errors (connection drop, timeout, etc.). +- **`on_stream_error` callback from `fn(String) -> Nil` to `fn(StreamFailure) -> Nil`.** + The callback now receives a `StreamFailure` with full error context instead of + a formatted string. +- **`stream_yielder` error type from `String` to `StreamFailure`.** Error results + now carry structured failure information instead of formatted strings. +- **Logging uses OTP `logger` instead of `error_logger`/`io:format`.** Connection + events and decompression warnings now go through OTP's logger, enabling + level-based filtering. Use `log_level()` on `TransportConfig` to control + verbosity — defaults to `LogInfo`. + +### Added + +- **Per-request protocol preference.** `protocols(preference)` controls which + HTTP protocol version gun negotiates per connection. `Http1Only` forces + HTTP/1.1, `Http2Only` enables h2c (HTTP/2 over cleartext, RFC 7540 + Section 3.4) for plaintext connections or h2-only for TLS, `Http2Preferred` + prefers HTTP/2 with HTTP/1.1 fallback. Defaults to HTTP/2 preferred for + HTTPS (via ALPN) and HTTP/1.1 for HTTP when not set. +- **Per-request TCP connection timeout.** `connect_timeout(ms)` controls how long + to wait for the TCP connection to be established, separate from the existing + `timeout()` which controls the entire request/response cycle. Defaults to + 15000ms. Useful for failing fast against unreachable hosts without shortening + the overall request timeout. +- **Per-request redirect control.** `auto_redirect(enabled)` controls whether + 3xx redirects are followed automatically. When disabled, the 3xx response is + returned as `Ok(HttpResponse(...))` with the status code and `Location` header + visible, allowing manual redirect handling. Defaults to `True` (matching + previous behavior). Note: gun does not handle redirects natively; the shim + implements manual redirect following (up to 5 hops). +- **Global transport configuration.** `TransportConfig` opaque type with builder + functions for gun connection pool tuning (13 fields): + - `max_connections(count)` — TCP connections per host (default: 50) + - `idle_timeout(ms)` — idle connection lifetime (default: 60000ms) + - `default_connect_timeout(ms)` — TCP connect timeout (default: 15000ms) + - `domain_lookup_timeout(ms)` — DNS resolution timeout (default: 5000ms) + - `tls_handshake_timeout(ms)` — TLS negotiation timeout (default: 10000ms) + - `retry(count)` — connection retry attempts (default: 3) + - `retry_timeout(ms)` — delay between retries (default: 1000ms) + - `keepalive(ms)` — HTTP/2 PING interval (default: 30000ms) + - `keepalive_tolerance(count)` — missed PINGs before close (default: 3) + - `max_concurrent_streams(count)` — HTTP/2 streams per connection (default: 100) + - `initial_connection_window_size(bytes)` — HTTP/2 flow control (default: 65535) + - `initial_stream_window_size(bytes)` — HTTP/2 stream flow control (default: 65535) + - `closing_timeout(ms)` — graceful shutdown timeout (default: 15000ms) + + Create with `transport_config()`, configure with builders, apply with + `configure_transport()`. Settings are global and affect all subsequent + requests. Stored in ETS for concurrent read access. + +- **Connection pool manager.** `dream_http_conn_manager` gen_server manages + a per-host connection pool backed by an ETS `bag` table. Features round-robin + selection, automatic dead-connection cleanup, idle connection reaping, and + crash recovery on restart. +- **Stale connection retry.** Requests that hit a server-closed connection + (`{stream_error, closed}`) are automatically retried once on a fresh + connection, preventing spurious failures when connection pool entries outlive + the server-side keep-alive. +- **`TransportError` type (8 variants)** preserving all gun error details: + `StreamReset`, `Goaway`, `ConnectionError`, `RemoteClosed`, `TimedOut`, + `ProcessDown`, `ConnectFailed`, `Unexpected`. Each variant carries the full + structured information from gun (HTTP/2 error codes, human-readable + descriptions, GOAWAY fields) instead of flattening to formatted strings. +- **`StreamFailure` type** with `HttpFailure(response: HttpResponse)` and + `TransportFailure(error: TransportError)`. Distinguishes HTTP-level + rejections (where response headers like `retry-after` and `x-request-id` + are available) from transport-level errors (connection drops, timeouts). +- **`transport_error_to_string` and `stream_failure_to_string` helpers** for + converting structured errors to human-readable log strings. +- **Response headers preserved in non-2xx streaming errors.** When a streaming + request receives a non-2xx response, the full `HttpResponse` (status, headers, + body) is now available via `HttpFailure` instead of a formatted string. +- **`gun_down` connection events now logged.** The connection manager logs + `gun_down` events with connection PID, protocol, reason, and killed/unprocessed + stream counts via `error_logger:warning_msg`. +- **Getter functions** for all 13 `TransportConfig` fields. +- **24 new tests** covering structured error types, HttpFailure headers/body + preservation, ConnectFailed variant checks, helper function output, plus + builder/getter round-trips, default values, edge cases, builder chaining, + transport application, and concurrent streaming scenarios. + ## 5.1.3 - 2026-03-17 ### Fixed diff --git a/modules/http_client/README.md b/modules/http_client/README.md index 367467e..5685f8b 100644 --- a/modules/http_client/README.md +++ b/modules/http_client/README.md @@ -25,7 +25,7 @@ **Type-safe HTTP client for Gleam with recording + streaming support.** -A standalone HTTP/HTTPS client built on Erlang's battle-tested `httpc`. Supports blocking requests, yielder streaming, and process-based streaming via callbacks. Built with the same quality standards as [Dream](https://github.com/TrustBound/dream), but completely independent—use it in any Gleam project. +A standalone HTTP/HTTPS client built on [gun](https://github.com/ninenines/gun), supporting HTTP/1.1 and HTTP/2 with native multiplexing. Provides blocking requests, yielder streaming, and process-based streaming via callbacks. Built with the same quality standards as [Dream](https://github.com/TrustBound/dream), but completely independent—use it in any Gleam project. --- @@ -49,7 +49,7 @@ A standalone HTTP/HTTPS client built on Erlang's battle-tested `httpc`. Supports | **OTP-first design** | Process-based streams work great with OTP | | **Recording/playback** | Record HTTP calls for tests, debug production, work offline | | **Type-safe** | `Result` types force error handling—no silent failures | -| **Battle-tested** | Built on Erlang's `httpc`—proven in production for decades | +| **HTTP/2 ready** | Native HTTP/2 multiplexing via gun for high concurrency | | **Framework-independent** | Zero dependencies on Dream or other frameworks | | **Concurrent streams** | Handle multiple HTTP streams in a single actor | | **Stream cancellation** | Cancel in-flight requests cleanly | @@ -210,6 +210,70 @@ pub fn stream_and_print() -> Result(Nil, String) { --- +## Configuration + +### Per-Request Settings + +Configure connection behavior on individual requests: + +```gleam +import dream_http_client/client + +// Set TCP connection timeout (default: 15000ms) +client.new() +|> client.host("api.example.com") +|> client.connect_timeout(5000) +|> client.send() + +// Disable automatic redirect following (default: True) +client.new() +|> client.host("api.example.com") +|> client.path("/old-endpoint") +|> client.auto_redirect(False) +|> client.send() +``` + +Tested sources: [connect_timeout](test/snippets/connect_timeout_config.gleam), [auto_redirect](test/snippets/redirect_config.gleam) + +### Transport Settings + +Configure the gun connection pool (global, affects all requests): + +```gleam +import dream_http_client/client + +client.transport_config() +|> client.max_connections(200) +|> client.idle_timeout(120_000) +|> client.max_concurrent_streams(500) +|> client.configure_transport() +``` + +Tested source: [transport config](test/snippets/transport_config_example.gleam) + +### Defaults + +| Setting | Default | Scope | +| -------------------------------- | ------- | ----------- | +| `timeout` | 30000ms | Per-request | +| `connect_timeout` | 15000ms | Per-request | +| `auto_redirect` | True | Per-request | +| `max_connections` | 50 | Global | +| `idle_timeout` | 60000ms | Global | +| `default_connect_timeout` | 15000ms | Global | +| `domain_lookup_timeout` | 5000ms | Global | +| `tls_handshake_timeout` | 10000ms | Global | +| `retry` | 3 | Global | +| `retry_timeout` | 1000ms | Global | +| `keepalive` | 30000ms | Global | +| `keepalive_tolerance` | 3 | Global | +| `max_concurrent_streams` | 100 | Global | +| `initial_connection_window_size` | 65535 | Global | +| `initial_stream_window_size` | 65535 | Global | +| `closing_timeout` | 15000ms | Global | + +--- + ## Recording & Playback Record HTTP requests/responses for testing, debugging, and offline development. @@ -462,7 +526,8 @@ mocks/api/POST_localhost__text_c7d8e9_4f22bc.json ```gleam import dream_http_client/client.{ - add_header, body, host, method, path, port, query, scheme, send, timeout, + add_header, auto_redirect, body, connect_timeout, host, method, path, port, + query, scheme, send, timeout, } import gleam/http @@ -591,7 +656,7 @@ This module follows the same quality standards as [Dream](https://github.com/Tru - **Type safety** - `Result` types force error handling at compile time - **OTP-first design** - Process-based streaming designed for supervision trees - **Comprehensive testing** - Unit tests (no network) + integration tests (real HTTP) -- **Battle-tested foundation** - Built on Erlang's production-proven `httpc` +- **HTTP/2 ready** - Built on gun for native HTTP/2 multiplexing --- diff --git a/modules/http_client/gleam.toml b/modules/http_client/gleam.toml index a3f6580..944942e 100644 --- a/modules/http_client/gleam.toml +++ b/modules/http_client/gleam.toml @@ -1,5 +1,5 @@ name = "dream_http_client" -version = "5.1.3" +version = "5.2.0" description = "Type-safe HTTP client for Gleam with streaming support" licences = ["MIT"] repository = { type = "github", user = "TrustBound", repo = "dream" } @@ -17,6 +17,7 @@ gleam_yielder = ">= 1.1.0 and < 2.0.0" simplifile = ">= 2.3.0 and < 3.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" gleam_crypto = ">= 1.3.0 and < 2.0.0" +gun = ">= 2.2.0 and < 3.0.0" [erlang] application_start_module = "dream_http_client_app" diff --git a/modules/http_client/manifest.toml b/modules/http_client/manifest.toml index 15f2949..4523ec6 100644 --- a/modules/http_client/manifest.toml +++ b/modules/http_client/manifest.toml @@ -2,8 +2,9 @@ # You typically do not need to edit this file packages = [ - { name = "dream", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, - { name = "dream_mock_server", version = "1.0.0", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../mock_server" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream", version = "2.4.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_time", "gleam_yielder", "marceau", "mist", "simplifile"], source = "local", path = "../.." }, + { name = "dream_mock_server", version = "1.1.1", build_tools = ["gleam"], requirements = ["dream", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder"], source = "local", path = "../mock_server" }, { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, @@ -11,20 +12,20 @@ packages = [ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, - { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "glisten", version = "9.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "D92808C66F7D3F22F2289CD04CBA8151757AAE9CB3D86992F0C6DE32A41205E1" }, { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "meck", version = "1.0.0", build_tools = ["rebar3"], requirements = [], otp_app = "meck", source = "hex", outer_checksum = "680A9BCFE52764350BEB9FB0335FB75FEE8E7329821416CEE0A19FEC35433882" }, - { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "mist", version = "6.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], source = "local", path = "../../../mist" }, { name = "mockth", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "gleeunit", "meck"], source = "git", repo = "https://github.com/bondiano/mockth.git", commit = "bacecbc7cd7ffac806d84154b07360c627d235ec" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, - { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] @@ -38,5 +39,6 @@ gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } gleam_yielder = { version = ">= 1.1.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +gun = { version = ">= 2.2.0 and < 3.0.0" } mockth = { git = "https://github.com/bondiano/mockth.git", ref = "master" } simplifile = { version = ">= 2.3.0 and < 3.0.0" } diff --git a/modules/http_client/releases/release-5.2.0.md b/modules/http_client/releases/release-5.2.0.md new file mode 100644 index 0000000..b770591 --- /dev/null +++ b/modules/http_client/releases/release-5.2.0.md @@ -0,0 +1,381 @@ +# dream_http_client v5.2.0 + +**Release Date:** March 25, 2026 + +This release replaces the underlying HTTP backend from Erlang's `httpc` to +[gun](https://github.com/ninenines/gun), enabling native HTTP/2 multiplexing +for high-concurrency workloads. Per-request settings (`connect_timeout`, +`auto_redirect`, `protocols`) are added to the `ClientRequest` builder. +`protocols()` enables h2c (HTTP/2 over cleartext) and per-request protocol +preference. Connection pool settings are managed through a redesigned +`TransportConfig` type with 13 gun-native fields plus `log_level` for +controlling log verbosity. All defaults are production-reasonable — existing +code behaves identically without changes. + +**Breaking changes** to error types (see Structured Error Types below). +Minor version bump (5.1.3 → 5.2.0) since the version has not been published. + +--- + +## Structured Error Types + +All string-based error representations have been replaced with structured Gleam +types that preserve the full detail gun provides. + +### Motivation + +String errors (`"econnrefused"`, `"timeout"`, `"HTTP 500 Internal Server Error: ..."`) +hide production issues. Consumers cannot reliably pattern-match on error categories +for retry logic, alerting, or circuit breakers without fragile string parsing. + +### New types + +**`TransportError`** — 8 variants preserving all gun error details: + +```gleam +pub type TransportError { + StreamReset(code: String, description: String) + Goaway(code: String, last_stream_id: Int, debug_data: String) + ConnectionError(code: String, description: String) + RemoteClosed + TimedOut(timeout_ms: Int) + ProcessDown(reason: String) + ConnectFailed(reason: String) + Unexpected(raw: String) +} +``` + +**`StreamFailure`** — distinguishes HTTP failures from transport errors: + +```gleam +pub type StreamFailure { + HttpFailure(response: HttpResponse) + TransportFailure(error: TransportError) +} +``` + +### Breaking changes + +| Before | After | +|--------|-------| +| `RequestError(message: String)` | `RequestError(error: TransportError)` | +| `StreamError(request_id, reason: String)` | `StreamError(request_id, error: StreamFailure)` | +| `on_stream_error(fn(String) -> Nil)` | `on_stream_error(fn(StreamFailure) -> Nil)` | +| `stream_yielder() -> Yielder(Result(BytesTree, String))` | `stream_yielder() -> Yielder(Result(BytesTree, StreamFailure))` | + +### Migration guide + +**Quick migration** — use the helper functions for the old string behavior: + +```gleam +// Before: +Error(client.RequestError(message: msg)) -> + io.println("Failed: " <> msg) + +// After: +Error(client.RequestError(error: err)) -> + io.println("Failed: " <> client.transport_error_to_string(err)) +``` + +**Recommended** — pattern match on error variants for structured handling: + +```gleam +Error(client.RequestError(error: err)) -> + case err { + client.ConnectFailed(reason: reason) -> + io.println("Cannot connect: " <> reason) + client.TimedOut(timeout_ms: ms) -> + io.println("Timed out after " <> int.to_string(ms) <> "ms") + _ -> + io.println(client.transport_error_to_string(err)) + } +``` + +**Streaming errors** — distinguish HTTP failures from transport errors: + +```gleam +|> client.on_stream_error(fn(failure) { + case failure { + client.HttpFailure(response: resp) -> + io.println("HTTP " <> int.to_string(resp.status) <> ": " <> resp.body) + client.TransportFailure(error: err) -> + io.println(client.transport_error_to_string(err)) + } +}) +``` + +--- + +## Backend change: httpc → gun + +The HTTP client backend has been replaced from Erlang's `httpc` to +[gun](https://github.com/ninenines/gun). This brings: + +- **HTTP/2 support** — native multiplexing over a single TCP connection + when connecting to HTTP/2-capable servers (e.g., OpenAI, Anthropic) +- **Connection pooling** — managed by a new `dream_http_conn_manager` + gen_server with per-host multi-connection pools, round-robin selection, + and automatic dead-connection cleanup +- **Stale connection retry** — requests that hit a server-closed connection + are automatically retried once on a fresh connection +- **Protocol negotiation** — TLS connections negotiate HTTP/2 via ALPN, + falling back to HTTP/1.1; plain TCP uses HTTP/1.1 + +All public API contracts are preserved. `send()`, `stream_yielder()`, and +`start_stream()` behave identically from the caller's perspective. + +--- + +## Feature: Per-request connection timeout + +`connect_timeout(ms)` controls how long to wait for the TCP connection to be +established. This is separate from `timeout()`, which controls the entire +request/response cycle. + +```gleam +client.new() +|> client.host("api.example.com") +|> client.connect_timeout(5000) +|> client.send() +``` + +**Default:** 15000ms. + +**Use case:** Fail fast against unreachable hosts without shortening the +overall request timeout. + +--- + +## Feature: Per-request redirect control + +`auto_redirect(enabled)` controls whether 3xx redirects are followed +automatically. + +```gleam +client.new() +|> client.host("api.example.com") +|> client.path("/old-endpoint") +|> client.auto_redirect(False) +|> client.send() +``` + +**Default:** `True`. + +When disabled, the 3xx response is returned as `Ok(HttpResponse(...))` with +the redirect status code and `Location` header visible. gun does not handle +redirects natively; the shim implements manual redirect following (up to 5 +hops). + +--- + +## Feature: Global transport configuration + +`TransportConfig` is redesigned with 13 gun-native fields for full +connection pool control. + +```gleam +client.transport_config() +|> client.max_connections(200) +|> client.idle_timeout(120_000) +|> client.max_concurrent_streams(500) +|> client.configure_transport() +``` + +### Settings + +| Builder | Default | What it controls | +|---------|---------|-----------------| +| `max_connections(count)` | 50 | TCP connections per host | +| `idle_timeout(ms)` | 60000 | Idle connection lifetime before close | +| `default_connect_timeout(ms)` | 15000 | TCP connect timeout | +| `domain_lookup_timeout(ms)` | 5000 | DNS resolution timeout | +| `tls_handshake_timeout(ms)` | 10000 | TLS negotiation timeout | +| `retry(count)` | 3 | Connection retry attempts | +| `retry_timeout(ms)` | 1000 | Delay between retries | +| `keepalive(ms)` | 30000 | HTTP/2 PING interval | +| `keepalive_tolerance(count)` | 3 | Missed PINGs before close | +| `max_concurrent_streams(count)` | 100 | HTTP/2 streams per connection | +| `initial_connection_window_size(bytes)` | 65535 | HTTP/2 connection flow control | +| `initial_stream_window_size(bytes)` | 65535 | HTTP/2 stream flow control | +| `closing_timeout(ms)` | 15000 | Graceful shutdown timeout | + +### How it works + +`configure_transport()` writes the config to a named ETS table +(`dream_http_client_transport_config`). The shim reads from this table +before every connection, falling back to defaults if no config is stored. + +The connection pool is managed by `dream_http_conn_manager`, a gen_server +supervised by `dream_http_client_sup`. It uses an ETS `bag` table +(`dream_http_client_connections`) for per-host multi-connection pooling with: + +- Round-robin connection selection +- Proactive dead-connection cleanup +- Idle connection reaping +- Crash recovery (re-monitors connections on gen_server restart) + +--- + +## Feature: Per-request protocol preference + +Gun supports HTTP/2 over cleartext (h2c) but the connection manager previously +hardcoded HTTP/1.1 for all TCP connections. The new `protocols()` builder on +`ClientRequest` makes protocol preference configurable per-request. + +### Usage + +```gleam +import dream_http_client/client.{Http2Only} +import gleam/http + +// h2c: HTTP/2 over cleartext using "prior knowledge" mode (RFC 7540 Section 3.4) +client.new() + |> client.scheme(http.Http) + |> client.host("internal-service.local") + |> client.protocols(Http2Only) + |> client.send() +``` + +### Variants + +- `Http1Only` — forces HTTP/1.1 (`protocols => [http]`) +- `Http2Only` — forces HTTP/2 (`protocols => [http2]`); enables h2c for TCP, h2-only ALPN for TLS +- `Http2Preferred` — prefers HTTP/2 with fallback (`protocols => [http2, http]`) + +### Defaults + +When `protocols()` is not called, existing defaults apply: HTTP/2 preferred +(via ALPN) for HTTPS, HTTP/1.1 only for HTTP. No behavior change for +existing code. + +### Implementation + +ALPN advertisement is automatically aligned with the configured protocol +preference — if you set `Http2Only`, only `h2` is advertised during the TLS +handshake. Connections with different protocol preferences get separate pool +entries to prevent protocol mismatch. + +--- + +## Logging migration: OTP `logger` + +Internal logging has been migrated from `error_logger` and raw `io:format` to +OTP's `logger` module. Connection events (graceful closures at `info` level, +unexpected disconnects at `warning` level) and decompression warnings now go +through `logger`, enabling standard OTP log level filtering. + +A new `log_level` field on `TransportConfig` controls the minimum severity for +dream_http_client's log output. It uses `logger:set_module_level/2` to scope +filtering to dream's own modules without affecting the rest of your application. + +```gleam +import dream_http_client/client.{LogWarning} + +client.transport_config() +|> client.log_level(LogWarning) +|> client.configure_transport() +``` + +Defaults to `LogInfo`. Available levels: `LogDebug`, `LogInfo`, `LogWarning`, +`LogError`, `LogNone`. + +--- + +## Architecture + +### Connection lifecycle + +``` +request → get_or_open_connection → ensure_connection (gen_server) + → if below max_connections: gun:open + gun:await_up → new connection + → if at max: round-robin from pool + → if stale connection error: close + retry once with new connection +``` + +### Message routing + +- **Sync requests:** `gun:await` / `gun:await_body` in the calling process +- **Pull streaming:** Owner process receives `gun_data` messages directly +- **Message streaming:** Translator process receives gun messages, forwards + as `{http, ...}` tuples to the stream process + +### Auto-redirect + +gun does not handle HTTP redirects natively. The shim implements manual +redirect following for all three request paths, supporting up to 5 hops +with proper method/body handling for 301/302/303/307/308. + +--- + +## Test coverage + +235 tests (235 total across the module): + +All existing tests updated for structured error types. New tests cover: +- All 13 `TransportConfig` builder/getter round-trips +- Default values, edge cases (zero/one values), builder chaining +- `configure_transport` application +- Concurrent streaming scenarios with connection pool management +- `HttpFailure` carries response headers and body +- `ConnectFailed` variant with descriptive reason +- `transport_error_to_string` and `stream_failure_to_string` helper output + +--- + +## Files changed + +- `modules/http_client/gleam.toml` — Added `gun >= 2.2.0` dependency +- `modules/http_client/src/dream_http_client/dream_http_shim.erl` — New file: + gun-based FFI shim replacing `dream_httpc_shim.erl` +- `modules/http_client/src/dream_http_client/dream_http_conn_manager.erl` — + New file: connection pool gen_server +- `modules/http_client/src/dream_http_client/dream_httpc_shim.erl` — Deleted +- `modules/http_client/src/dream_http_client/client.gleam` — Redesigned + `TransportConfig` with 13 gun-native fields, updated FFI references +- `modules/http_client/src/dream_http_client/internal.gleam` — Updated + external function references from `dream_httpc_shim` to `dream_http_shim` +- `modules/http_client/src/dream_http_client/dream_http_client_app.erl` — + Added `dream_http_client_connections` ETS table creation +- `modules/http_client/src/dream_http_client/dream_http_client_sup.erl` — + Added `dream_http_conn_manager` as supervised child +- `modules/http_client/test/transport_config_test.gleam` — Rewritten for + 13 gun-native TransportConfig fields +- `modules/http_client/test/snippets/transport_config_example.gleam` — Updated +- `modules/http_client/CHANGELOG.md` — 5.2.0 entry +- `modules/http_client/README.md` — Updated for gun backend + +## Upgrading + +Update your dependency: + +```toml +[dependencies] +dream_http_client = ">= 5.2.0 and < 6.0.0" +``` + +Then run: + +```bash +gleam deps download +``` + +Error types have changed (see Structured Error Types above). The HTTP +backend has been swapped from `httpc` to `gun`. Use the migration guide +above to update error handling code. + +## Documentation + +- [dream_http_client hexdocs](https://hexdocs.pm/dream_http_client) -- v5.2.0 +- [README](https://github.com/TrustBound/dream/tree/main/modules/http_client) +- [CHANGELOG](https://github.com/TrustBound/dream/blob/main/modules/http_client/CHANGELOG.md) + +## Community + +- [Full Documentation](https://github.com/TrustBound/dream/tree/main/modules/http_client) +- [Discussions](https://github.com/TrustBound/dream/discussions) +- [Report Issues](https://github.com/TrustBound/dream/issues) +- [Contributing Guide](https://github.com/TrustBound/dream/blob/main/CONTRIBUTING.md) + +--- + +**Full Changelog:** [CHANGELOG.md](https://github.com/TrustBound/dream/blob/main/modules/http_client/CHANGELOG.md) diff --git a/modules/http_client/src/dream_http_client/client.gleam b/modules/http_client/src/dream_http_client/client.gleam index 8c3cff5..9f38c66 100644 --- a/modules/http_client/src/dream_http_client/client.gleam +++ b/modules/http_client/src/dream_http_client/client.gleam @@ -1,7 +1,8 @@ //// Type-safe HTTP client with recording + streaming support //// -//// Gleam doesn't ship with an HTTPS client, so this module wraps Erlang's -//// battle‑hardened `httpc` and adds a friendly builder API, streaming helpers, +//// Gleam doesn't ship with an HTTPS client, so this module wraps +//// [gun](https://github.com/ninenines/gun) (a high-performance Erlang HTTP/1.1 +//// and HTTP/2 client) and adds a friendly builder API, streaming helpers, //// and optional record/playback via `dream_http_client/recorder`. //// //// ## Quick Example — blocking request @@ -76,6 +77,7 @@ import dream_http_client/recorder import dream_http_client/recording import gleam/bit_array import gleam/bytes_tree +import gleam/dynamic import gleam/dynamic/decode as d import gleam/erlang/atom import gleam/erlang/process @@ -150,6 +152,151 @@ pub type HttpResponse { HttpResponse(status: Int, headers: List(Header), body: String) } +/// Transport-level error from the underlying gun HTTP client. +/// +/// Each variant preserves ALL structured information that gun provides for that +/// error class. Use `transport_error_to_string` for human-readable log output, +/// or pattern match on variants for programmatic error handling (retry logic, +/// alerting, circuit breakers). +/// +/// ## Variants +/// +/// - `StreamReset` — HTTP/2 RST_STREAM frame. The `code` field is the HTTP/2 +/// error code as a string (e.g. `"internal_error"`, `"cancel"`, +/// `"refused_stream"`, `"enhance_your_calm"`). The `description` field is +/// gun's human-readable explanation. +/// +/// - `Goaway` — HTTP/2 GOAWAY frame. The server is shutting down gracefully. +/// `last_stream_id` indicates the last stream the server will process. +/// `debug_data` may contain additional context from the server. +/// +/// - `ConnectionError` — Connection-level protocol error. The `code` field is +/// the error code and `description` is gun's human-readable explanation. +/// +/// - `RemoteClosed` — The remote peer closed the connection without sending +/// an explicit error code. +/// +/// - `TimedOut` — No response was received within the configured timeout. +/// `timeout_ms` is the value that was configured. +/// +/// - `ProcessDown` — The connection or stream owner process died unexpectedly. +/// `reason` describes why the process exited. +/// +/// - `ConnectFailed` — Could not establish a connection to the server. Common +/// causes include connection refused (server not running), DNS resolution +/// failure (NXDOMAIN), and TLS handshake errors. +/// +/// - `Unexpected` — An error that doesn't match any known pattern. `raw` +/// contains the Erlang term formatted as a string for debugging. +/// +/// ## Example +/// +/// ```gleam +/// case error { +/// ConnectFailed(reason: reason) -> +/// io.println("Cannot reach server: " <> reason) +/// TimedOut(timeout_ms: ms) -> +/// io.println("Timed out after " <> int.to_string(ms) <> "ms") +/// StreamReset(code: code, description: desc) -> +/// io.println("Stream reset: " <> code <> " — " <> desc) +/// _ -> +/// io.println(transport_error_to_string(error)) +/// } +/// ``` +pub type TransportError { + StreamReset(code: String, description: String) + Goaway(code: String, last_stream_id: Int, debug_data: String) + ConnectionError(code: String, description: String) + RemoteClosed + TimedOut(timeout_ms: Int) + ProcessDown(reason: String) + ConnectFailed(reason: String) + Unexpected(raw: String) +} + +/// Failure during streaming, distinguishing HTTP errors from transport errors. +/// +/// When a streaming request (`stream_yielder` or `start_stream`) fails, the +/// failure is either an HTTP-level rejection (the server responded with a +/// non-2xx status) or a transport-level error (the connection broke). +/// +/// ## Variants +/// +/// - `HttpFailure` — The server returned a non-2xx status code before streaming +/// began. The full `HttpResponse` is available including status, headers +/// (useful for `retry-after`, `x-request-id`), and body. +/// +/// - `TransportFailure` — A transport-level error occurred during connection +/// or streaming. See `TransportError` for the specific error variants. +/// +/// ## Example +/// +/// ```gleam +/// case failure { +/// HttpFailure(response: response) -> +/// io.println("HTTP " <> int.to_string(response.status)) +/// TransportFailure(error: ConnectFailed(reason: reason)) -> +/// io.println("Cannot connect: " <> reason) +/// TransportFailure(error: error) -> +/// io.println(transport_error_to_string(error)) +/// } +/// ``` +pub type StreamFailure { + HttpFailure(response: HttpResponse) + TransportFailure(error: TransportError) +} + +/// Convert a `TransportError` to a human-readable string for logging. +/// +/// Produces a single-line summary suitable for log messages. For programmatic +/// error handling, pattern match on the `TransportError` variants instead. +/// +/// ## Example +/// +/// ```gleam +/// let msg = transport_error_to_string(ConnectFailed(reason: "Connection refused")) +/// // => "Connection failed: Connection refused" +/// ``` +pub fn transport_error_to_string(error: TransportError) -> String { + case error { + StreamReset(code: code, description: description) -> + "Stream reset (" <> code <> "): " <> description + Goaway(code: code, last_stream_id: last_id, debug_data: debug) -> + "GOAWAY (" + <> code + <> ", last_stream_id=" + <> int.to_string(last_id) + <> "): " + <> debug + ConnectionError(code: code, description: description) -> + "Connection error (" <> code <> "): " <> description + RemoteClosed -> "Remote closed connection" + TimedOut(timeout_ms: ms) -> "Timed out after " <> int.to_string(ms) <> "ms" + ProcessDown(reason: reason) -> "Process down: " <> reason + ConnectFailed(reason: reason) -> "Connection failed: " <> reason + Unexpected(raw: raw) -> "Unexpected error: " <> raw + } +} + +/// Convert a `StreamFailure` to a human-readable string for logging. +/// +/// Produces a single-line summary suitable for log messages. For programmatic +/// error handling, pattern match on the `StreamFailure` variants instead. +/// +/// ## Example +/// +/// ```gleam +/// let msg = stream_failure_to_string(TransportFailure(error: TimedOut(timeout_ms: 5000))) +/// // => "Timed out after 5000ms" +/// ``` +pub fn stream_failure_to_string(failure: StreamFailure) -> String { + case failure { + HttpFailure(response: response) -> + "HTTP " <> int.to_string(response.status) <> ": " <> response.body + TransportFailure(error: error) -> transport_error_to_string(error) + } +} + /// Error types returned by `send()`. /// /// ## Variants @@ -159,32 +306,101 @@ pub type HttpResponse { /// headers, and body. The body typically contains error details (e.g. JSON /// error messages from an API, or HTML error pages). /// -/// - `RequestError(message: String)` — the request could not be completed. -/// The `message` describes what went wrong. Common causes: -/// - Connection refused (server not running) -/// - DNS resolution failure (hostname not found) -/// - Timeout (server did not respond in time) -/// - Recorder errors (ambiguous recording match, missing fixture) -/// - Streaming response found when blocking response expected +/// - `RequestError(error: TransportError)` — the request could not be +/// completed due to a transport-level failure. Pattern match on the +/// `TransportError` for the specific cause, or use +/// `transport_error_to_string` for logging. /// /// ## Example /// /// ```gleam /// case client.send(request) { /// Ok(response) -> -/// // Guaranteed status < 400 /// io.println("Got " <> int.to_string(response.status)) /// Error(ResponseError(response: response)) -> -/// // HTTP error — inspect response.status, response.body /// io.println("HTTP " <> int.to_string(response.status)) -/// Error(RequestError(message: message)) -> -/// // Transport failure — no HTTP response at all -/// io.println("Failed: " <> message) +/// Error(RequestError(error: ConnectFailed(reason: reason))) -> +/// io.println("Cannot connect: " <> reason) +/// Error(RequestError(error: error)) -> +/// io.println("Failed: " <> transport_error_to_string(error)) /// } /// ``` pub type SendError { ResponseError(response: HttpResponse) - RequestError(message: String) + RequestError(error: TransportError) +} + +/// Per-request protocol preference for HTTP connections. +/// +/// Controls which HTTP protocol version gun negotiates when opening a connection. +/// The connection manager uses this to set gun's `protocols` option and, for TLS +/// connections, to align ALPN advertisement accordingly. +/// +/// ## Variants +/// +/// - `Http1Only` — HTTP/1.1 only. Maps to gun `protocols => [http]`. +/// +/// - `Http2Only` — HTTP/2 only. For cleartext (TCP) connections this enables +/// h2c "prior knowledge" mode per RFC 7540 Section 3.4 — gun speaks HTTP/2 +/// directly without an upgrade dance. For TLS connections this advertises +/// only `h2` via ALPN. +/// +/// - `Http2Preferred` — Prefer HTTP/2, fall back to HTTP/1.1. Maps to gun +/// `protocols => [http2, http]`. This is the default for TLS connections +/// (ALPN negotiation). For TCP, this uses the HTTP/1.1 Upgrade mechanism +/// which most servers do not support — use `Http2Only` for cleartext +/// HTTP/2 instead. +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client.{Http2Only} +/// import gleam/http +/// +/// client.new() +/// |> client.scheme(http.Http) +/// |> client.host("internal-service.local") +/// |> client.protocols(Http2Only) +/// |> client.send() +/// ``` +pub type Protocols { + Http1Only + Http2Only + Http2Preferred +} + +/// Minimum log level for dream_http_client's internal log output. +/// +/// Controls which log messages are emitted by the library's connection manager +/// and HTTP shim. Uses OTP's `logger:set_module_level/2` under the hood, so +/// filtering is scoped to dream's own modules and does not affect the rest of +/// your application. +/// +/// ## Variants +/// +/// - `LogDebug` — All messages including debug-level detail. +/// - `LogInfo` — Informational messages and above (default). Includes graceful +/// connection closures. +/// - `LogWarning` — Warnings and above only. Unexpected disconnects, +/// decompression failures, unrecognized content encodings. +/// - `LogError` — Errors only. +/// - `LogNone` — Suppress all log output from dream_http_client. +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client.{LogWarning} +/// +/// client.transport_config() +/// |> client.log_level(LogWarning) +/// |> client.configure_transport() +/// ``` +pub type LogLevel { + LogDebug + LogInfo + LogWarning + LogError + LogNone } /// HTTP client request configuration @@ -221,11 +437,14 @@ pub opaque type ClientRequest { headers: List(Header), body: String, timeout: Option(Int), + connect_timeout: Option(Int), + auto_redirect: Option(Bool), recorder: Option(recorder.Recorder), on_stream_start: Option(fn(List(Header)) -> Nil), on_stream_chunk: Option(fn(BitArray) -> Nil), on_stream_end: Option(fn(List(Header)) -> Nil), - on_stream_error: Option(fn(String) -> Nil), + on_stream_error: Option(fn(StreamFailure) -> Nil), + protocols: Option(Protocols), ) } @@ -266,11 +485,14 @@ pub fn new() -> ClientRequest { headers: [], body: "", timeout: None, + connect_timeout: None, + auto_redirect: None, recorder: None, on_stream_start: None, on_stream_chunk: None, on_stream_end: None, on_stream_error: None, + protocols: None, ) } @@ -561,6 +783,484 @@ pub fn timeout(client_request: ClientRequest, timeout_ms: Int) -> ClientRequest ClientRequest(..client_request, timeout: option.Some(timeout_ms)) } +/// Set TCP connection timeout in milliseconds +/// +/// Controls how long to wait for the TCP connection to be established. +/// This is separate from the request `timeout()`, which controls the total +/// time for the entire HTTP request/response cycle. +/// +/// ## Parameters +/// +/// - `client_request`: The request to modify +/// - `ms`: Connection timeout in milliseconds (default: 15000) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.new() +/// |> client.host("api.example.com") +/// |> client.connect_timeout(5000) +/// |> client.send() +/// ``` +pub fn connect_timeout(client_request: ClientRequest, ms: Int) -> ClientRequest { + ClientRequest(..client_request, connect_timeout: option.Some(ms)) +} + +/// Set whether HTTP redirects are followed automatically +/// +/// When enabled (default), the client follows 3xx redirects automatically +/// and returns the final response. When disabled, the 3xx response is +/// returned as `Ok(HttpResponse(...))` with the redirect status code and +/// Location header visible, allowing manual redirect handling. +/// +/// ## Parameters +/// +/// - `client_request`: The request to modify +/// - `enabled`: Whether to follow redirects automatically (default: True) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// // Disable auto-redirect to inspect the 3xx response +/// client.new() +/// |> client.host("api.example.com") +/// |> client.path("/old-endpoint") +/// |> client.auto_redirect(False) +/// |> client.send() +/// ``` +pub fn auto_redirect( + client_request: ClientRequest, + enabled: Bool, +) -> ClientRequest { + ClientRequest(..client_request, auto_redirect: option.Some(enabled)) +} + +/// Set the protocol preference for the connection. +/// +/// Controls which HTTP protocol version gun negotiates when opening a connection. +/// When not set, defaults to HTTP/2 preferred (via ALPN) for HTTPS connections +/// and HTTP/1.1 only for HTTP connections. +/// +/// For HTTP/2 over cleartext (h2c), use `Http2Only` with `scheme(http.Http)`: +/// +/// ```gleam +/// import dream_http_client/client.{Http2Only} +/// import gleam/http +/// +/// client.new() +/// |> client.scheme(http.Http) +/// |> client.host("internal-service.local") +/// |> client.protocols(Http2Only) +/// |> client.send() +/// ``` +pub fn protocols( + client_request: ClientRequest, + preference: Protocols, +) -> ClientRequest { + ClientRequest(..client_request, protocols: option.Some(preference)) +} + +// ============================================================================ +// Transport Configuration +// ============================================================================ + +/// Configuration for the HTTP transport layer +/// +/// Controls connection pool behavior, gun client options, and log verbosity. +/// These settings are global and affect all subsequent HTTP requests. +/// Gun negotiates HTTP/2 automatically for TLS connections via ALPN, +/// falling back to HTTP/1.1. For cleartext (TCP) connections, the default +/// is HTTP/1.1 only; use `protocols(Http2Only)` on `ClientRequest` for +/// h2c (HTTP/2 over cleartext) per-request. +/// +/// Create with `transport_config()`, configure with builder functions, +/// and apply with `configure_transport()`. +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client +/// +/// client.transport_config() +/// |> client.max_connections(200) +/// |> client.idle_timeout(120_000) +/// |> client.configure_transport() +/// ``` +pub opaque type TransportConfig { + TransportConfig( + max_connections: Int, + idle_timeout: Int, + default_connect_timeout: Int, + domain_lookup_timeout: Int, + tls_handshake_timeout: Int, + retry: Int, + retry_timeout: Int, + keepalive: Int, + keepalive_tolerance: Int, + max_concurrent_streams: Int, + initial_connection_window_size: Int, + initial_stream_window_size: Int, + closing_timeout: Int, + log_level: LogLevel, + ) +} + +/// Create a transport configuration with default values +/// +/// Returns a `TransportConfig` with sensible defaults: +/// - max_connections: 50 (TCP connections per host) +/// - idle_timeout: 60_000ms (close idle connections after 60s) +/// - default_connect_timeout: 15_000ms (TCP connection timeout) +/// - domain_lookup_timeout: 5_000ms (DNS resolution timeout) +/// - tls_handshake_timeout: 10_000ms (TLS negotiation timeout) +/// - retry: 3 (reconnection attempts) +/// - retry_timeout: 1_000ms (between retries) +/// - keepalive: 30_000ms (HTTP/2 ping interval) +/// - keepalive_tolerance: 3 (unack'd pings before disconnect) +/// - max_concurrent_streams: 100 (HTTP/2 streams per connection) +/// - initial_connection_window_size: 65_535 (HTTP/2 connection flow control) +/// - initial_stream_window_size: 65_535 (HTTP/2 per-stream flow control) +/// - closing_timeout: 15_000ms (graceful shutdown wait) +/// - log_level: LogInfo (minimum severity for dream log output) +pub fn transport_config() -> TransportConfig { + TransportConfig( + max_connections: 50, + idle_timeout: 60_000, + default_connect_timeout: 15_000, + domain_lookup_timeout: 5000, + tls_handshake_timeout: 10_000, + retry: 3, + retry_timeout: 1000, + keepalive: 30_000, + keepalive_tolerance: 3, + max_concurrent_streams: 100, + initial_connection_window_size: 65_535, + initial_stream_window_size: 65_535, + closing_timeout: 15_000, + log_level: LogInfo, + ) +} + +/// Set maximum TCP connections per host +/// +/// Controls how many simultaneous TCP connections can be open to a single +/// host. With HTTP/2, a single connection supports multiplexed streams, +/// so fewer connections are needed than with HTTP/1.1. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Maximum connections per host (default: 50) +/// +/// ## Example +/// +/// ```gleam +/// client.transport_config() +/// |> client.max_connections(200) +/// |> client.configure_transport() +/// ``` +pub fn max_connections(config: TransportConfig, count: Int) -> TransportConfig { + TransportConfig(..config, max_connections: count) +} + +/// Set idle connection timeout in milliseconds +/// +/// Controls how long an idle connection is held open before being closed. +/// Longer timeouts improve connection reuse but consume resources. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Idle timeout in milliseconds (default: 60_000) +/// +/// ## Example +/// +/// ```gleam +/// client.transport_config() +/// |> client.idle_timeout(120_000) +/// |> client.configure_transport() +/// ``` +pub fn idle_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, idle_timeout: ms) +} + +/// Set default TCP connection timeout in milliseconds +/// +/// The default timeout for establishing a TCP connection when not +/// overridden per-request via `connect_timeout()`. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Connection timeout in milliseconds (default: 15_000) +pub fn default_connect_timeout( + config: TransportConfig, + ms: Int, +) -> TransportConfig { + TransportConfig(..config, default_connect_timeout: ms) +} + +/// Set DNS resolution timeout in milliseconds +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: DNS lookup timeout in milliseconds (default: 5_000) +pub fn domain_lookup_timeout( + config: TransportConfig, + ms: Int, +) -> TransportConfig { + TransportConfig(..config, domain_lookup_timeout: ms) +} + +/// Set TLS handshake timeout in milliseconds +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: TLS negotiation timeout in milliseconds (default: 10_000) +pub fn tls_handshake_timeout( + config: TransportConfig, + ms: Int, +) -> TransportConfig { + TransportConfig(..config, tls_handshake_timeout: ms) +} + +/// Set number of reconnection attempts +/// +/// How many times gun will attempt to reconnect after a connection is lost. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Number of retry attempts (default: 3) +pub fn retry(config: TransportConfig, count: Int) -> TransportConfig { + TransportConfig(..config, retry: count) +} + +/// Set delay between reconnection attempts in milliseconds +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Delay between retries in milliseconds (default: 1_000) +pub fn retry_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, retry_timeout: ms) +} + +/// Set HTTP/2 keepalive ping interval in milliseconds +/// +/// How often to send HTTP/2 PING frames to keep the connection alive +/// and detect dead connections. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Ping interval in milliseconds (default: 30_000) +pub fn keepalive(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, keepalive: ms) +} + +/// Set HTTP/2 keepalive tolerance +/// +/// How many unacknowledged PING frames are allowed before the connection +/// is considered dead and closed. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Max unacknowledged pings (default: 3) +pub fn keepalive_tolerance( + config: TransportConfig, + count: Int, +) -> TransportConfig { + TransportConfig(..config, keepalive_tolerance: count) +} + +/// Set maximum concurrent HTTP/2 streams per connection +/// +/// Controls the maximum number of concurrent streams allowed on a single +/// HTTP/2 connection. Increase for highly concurrent workloads. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `count`: Max concurrent streams (default: 100) +pub fn max_concurrent_streams( + config: TransportConfig, + count: Int, +) -> TransportConfig { + TransportConfig(..config, max_concurrent_streams: count) +} + +/// Set HTTP/2 connection-level flow control window size +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `size`: Window size in bytes (default: 65_535) +pub fn initial_connection_window_size( + config: TransportConfig, + size: Int, +) -> TransportConfig { + TransportConfig(..config, initial_connection_window_size: size) +} + +/// Set HTTP/2 per-stream flow control window size +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `size`: Window size in bytes (default: 65_535) +pub fn initial_stream_window_size( + config: TransportConfig, + size: Int, +) -> TransportConfig { + TransportConfig(..config, initial_stream_window_size: size) +} + +/// Set graceful shutdown timeout in milliseconds +/// +/// How long to wait for in-flight requests to complete when closing +/// a connection. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `ms`: Shutdown wait in milliseconds (default: 15_000) +pub fn closing_timeout(config: TransportConfig, ms: Int) -> TransportConfig { + TransportConfig(..config, closing_timeout: ms) +} + +/// Set the minimum log level for dream_http_client's internal log output +/// +/// Controls which log messages are emitted by the library's connection manager +/// and HTTP shim. Filtering is scoped to dream's own modules via OTP's +/// `logger:set_module_level/2` and does not affect the rest of your application. +/// +/// ## Parameters +/// +/// - `config`: The transport config to modify +/// - `level`: Minimum log level (default: `LogInfo`) +/// +/// ## Example +/// +/// ```gleam +/// import dream_http_client/client.{LogWarning} +/// +/// client.transport_config() +/// |> client.log_level(LogWarning) +/// |> client.configure_transport() +/// ``` +pub fn log_level(config: TransportConfig, level: LogLevel) -> TransportConfig { + TransportConfig(..config, log_level: level) +} + +/// Get the configured maximum TCP connections per host +pub fn get_max_connections(config: TransportConfig) -> Int { + config.max_connections +} + +/// Get the configured idle connection timeout in milliseconds +pub fn get_idle_timeout(config: TransportConfig) -> Int { + config.idle_timeout +} + +/// Get the configured default TCP connection timeout in milliseconds +pub fn get_default_connect_timeout(config: TransportConfig) -> Int { + config.default_connect_timeout +} + +/// Get the configured DNS resolution timeout in milliseconds +pub fn get_domain_lookup_timeout(config: TransportConfig) -> Int { + config.domain_lookup_timeout +} + +/// Get the configured TLS handshake timeout in milliseconds +pub fn get_tls_handshake_timeout(config: TransportConfig) -> Int { + config.tls_handshake_timeout +} + +/// Get the configured number of reconnection attempts +pub fn get_retry(config: TransportConfig) -> Int { + config.retry +} + +/// Get the configured delay between reconnection attempts in milliseconds +pub fn get_retry_timeout(config: TransportConfig) -> Int { + config.retry_timeout +} + +/// Get the configured HTTP/2 keepalive ping interval in milliseconds +pub fn get_keepalive(config: TransportConfig) -> Int { + config.keepalive +} + +/// Get the configured HTTP/2 keepalive tolerance +pub fn get_keepalive_tolerance(config: TransportConfig) -> Int { + config.keepalive_tolerance +} + +/// Get the configured maximum concurrent HTTP/2 streams per connection +pub fn get_max_concurrent_streams(config: TransportConfig) -> Int { + config.max_concurrent_streams +} + +/// Get the configured HTTP/2 connection-level flow control window size +pub fn get_initial_connection_window_size(config: TransportConfig) -> Int { + config.initial_connection_window_size +} + +/// Get the configured HTTP/2 per-stream flow control window size +pub fn get_initial_stream_window_size(config: TransportConfig) -> Int { + config.initial_stream_window_size +} + +/// Get the configured graceful shutdown timeout in milliseconds +pub fn get_closing_timeout(config: TransportConfig) -> Int { + config.closing_timeout +} + +/// Get the configured minimum log level +/// +/// Returns the `LogLevel` that controls which internal log messages are emitted. +/// Only messages at this level or above are output. +/// +/// ## Example +/// +/// ```gleam +/// let config = client.transport_config() +/// client.get_log_level(config) +/// // -> LogInfo +/// ``` +pub fn get_log_level(config: TransportConfig) -> LogLevel { + config.log_level +} + +/// Apply transport configuration to the HTTP client +/// +/// Stores transport settings for use by all subsequent HTTP requests. +/// Call this once during application startup. Can be called again to +/// update settings at runtime. +/// +/// ## Example +/// +/// ```gleam +/// client.transport_config() +/// |> client.max_connections(200) +/// |> client.configure_transport() +/// ``` +pub fn configure_transport(config: TransportConfig) -> Nil { + configure_transport_ffi(config) +} + +@external(erlang, "dream_http_shim", "configure_transport") +fn configure_transport_ffi(config: TransportConfig) -> Nil + /// Set callback for stream start event /// /// Sets a function to be called when a stream starts and headers are received. @@ -646,26 +1346,32 @@ pub fn on_stream_end( /// Set callback for stream error event /// /// Sets a function to be called if the stream fails with an error. -/// Handles both HTTP errors and network errors. +/// The callback receives a `StreamFailure` which distinguishes HTTP failures +/// (non-2xx response) from transport failures (connection errors, timeouts). /// /// ## Parameters /// /// - `client_request`: The request to modify -/// - `callback`: Function called with error reason if stream fails +/// - `callback`: Function called with a `StreamFailure` if stream fails /// /// ## Example /// /// ```gleam /// client.new() /// |> client.host("api.example.com") -/// |> client.on_stream_error(fn(reason) { -/// io.println_error("Stream failed: " <> reason) +/// |> client.on_stream_error(fn(failure) { +/// case failure { +/// client.HttpFailure(response: resp) -> +/// io.println_error("HTTP " <> int.to_string(resp.status)) +/// client.TransportFailure(error: err) -> +/// io.println_error("Transport: " <> client.transport_error_to_string(err)) +/// } /// }) /// |> client.start_stream() /// ``` pub fn on_stream_error( client_request: ClientRequest, - callback: fn(String) -> Nil, + callback: fn(StreamFailure) -> Nil, ) -> ClientRequest { ClientRequest(..client_request, on_stream_error: Some(callback)) } @@ -869,6 +1575,34 @@ pub fn get_timeout(client_request: ClientRequest) -> Option(Int) { client_request.timeout } +/// Get the configured TCP connection timeout +/// +/// Returns `None` if the default (15000ms) will be used. +pub fn get_connect_timeout(client_request: ClientRequest) -> Option(Int) { + client_request.connect_timeout +} + +/// Get the configured auto-redirect setting +/// +/// Returns `None` if the default (True) will be used. +pub fn get_auto_redirect(client_request: ClientRequest) -> Option(Bool) { + client_request.auto_redirect +} + +/// Get the configured protocol preference for the connection. +/// +/// Returns `None` if no explicit protocol preference has been set, +/// meaning transport-specific defaults will apply. +/// +/// ```gleam +/// let req = client.new() |> client.protocols(Http2Only) +/// client.get_protocols(req) +/// // -> Some(Http2Only) +/// ``` +pub fn get_protocols(client_request: ClientRequest) -> Option(Protocols) { + client_request.protocols +} + /// Get the recorder from a request /// /// Returns the optional recorder attached to the request for recording or playback. @@ -927,7 +1661,7 @@ pub opaque type RequestId { /// Stream message types emitted by internal streaming machinery /// /// `start_stream()` runs a stream loop in a dedicated process; that process -/// receives and decodes httpc messages into these variants. +/// receives and decodes gun messages into these variants. /// /// ## Message Flow /// @@ -939,7 +1673,7 @@ pub opaque type RequestId { /// ## DecodeError /// /// `DecodeError` indicates the Erlang→Gleam FFI boundary received a malformed -/// message from `httpc`. This is **not a normal HTTP error** - it means either: +/// message from `gun`. This is **not a normal HTTP error** - it means either: /// /// - Erlang/OTP version incompatibility with this library /// - Memory corruption or other serious runtime issue @@ -959,7 +1693,7 @@ pub type StreamMessage { /// Stream completed successfully StreamEnd(request_id: RequestId, headers: List(Header)) /// Stream failed with error (connection drop, timeout, HTTP error, etc.) - StreamError(request_id: RequestId, reason: String) + StreamError(request_id: RequestId, error: StreamFailure) /// Failed to decode stream message from Erlang FFI (indicates library bug) DecodeError(reason: String) } @@ -1036,14 +1770,14 @@ pub opaque type StreamHandle { /// /// - `Ok(HttpResponse)`: Successful response (status < 400) with status, headers, and body /// - `Error(ResponseError(response))`: HTTP error response (status >= 400) with full response -/// - `Error(RequestError(message))`: Connection failure, timeout, or other transport error +/// - `Error(RequestError(error: transport_error))`: Connection failure, timeout, or other transport error /// /// ## Example /// /// ```gleam /// import dream_http_client/client.{ -/// HttpResponse, RequestError, ResponseError, -/// host, path, add_header, send, +/// HttpResponse, RequestError, ResponseError, ConnectFailed, TimedOut, +/// host, path, add_header, send, transport_error_to_string, /// } /// /// let result = client.new() @@ -1062,8 +1796,12 @@ pub opaque type StreamHandle { /// } /// Error(ResponseError(response)) -> /// Error("HTTP " <> int.to_string(response.status) <> ": " <> response.body) -/// Error(RequestError(message)) -> -/// Error("Request failed: " <> message) +/// Error(RequestError(error: ConnectFailed(reason))) -> +/// Error("Connection failed: " <> reason) +/// Error(RequestError(error: TimedOut(timeout_ms))) -> +/// Error("Timed out after " <> int.to_string(timeout_ms) <> "ms") +/// Error(RequestError(error: transport_error)) -> +/// Error("Request failed: " <> transport_error_to_string(transport_error)) /// } /// ``` pub fn send(client_request: ClientRequest) -> Result(HttpResponse, SendError) { @@ -1077,7 +1815,7 @@ pub fn send(client_request: ClientRequest) -> Result(HttpResponse, SendError) { fn send_without_recorder( client_request: ClientRequest, ) -> Result(HttpResponse, SendError) { - send_client_request_to_httpc(client_request) + send_client_request_via_gun(client_request) } fn send_with_recorder( @@ -1091,7 +1829,7 @@ fn send_with_recorder( handle_recorded_blocking_response(response) Ok(option.None) -> send_and_maybe_record(client_request, recorder_instance, recorded_request) - Error(reason) -> Error(RequestError(message: reason)) + Error(reason) -> Error(RequestError(error: Unexpected(raw: reason))) } } @@ -1102,9 +1840,11 @@ fn handle_recorded_blocking_response( recording.BlockingResponse(status, headers, body) -> response_result(status, headers, body) recording.StreamingResponse(_, _, _) -> - Error(RequestError( - message: "Recording contains streaming response, use stream_yielder() instead", - )) + Error( + RequestError(error: Unexpected( + raw: "Recording contains streaming response, use stream_yielder() instead", + )), + ) } } @@ -1113,7 +1853,7 @@ fn send_and_maybe_record( recorder_instance: recorder.Recorder, recorded_request: recording.RecordedRequest, ) -> Result(HttpResponse, SendError) { - case send_client_request_to_httpc_with_meta(client_request) { + case send_client_request_via_gun_with_meta(client_request) { Ok(#(status, headers, body)) -> { let recorded_response = recording.BlockingResponse(status: status, headers: headers, body: body) @@ -1137,7 +1877,8 @@ fn send_and_maybe_record( response_result(status, headers, body) } - Error(error_message) -> Error(RequestError(message: error_message)) + Error(error_dyn) -> + Error(RequestError(error: decode_transport_error(error_dyn))) } } @@ -1156,35 +1897,54 @@ fn record_response_if_needed( } } -fn send_client_request_to_httpc( +fn send_client_request_via_gun( client_request: ClientRequest, ) -> Result(HttpResponse, SendError) { - case send_client_request_to_httpc_with_meta(client_request) { + case send_client_request_via_gun_with_meta(client_request) { Ok(#(status, headers, body)) -> response_result(status, headers, body) - Error(error_message) -> Error(RequestError(message: error_message)) + Error(error_dyn) -> + Error(RequestError(error: decode_transport_error(error_dyn))) } } -fn send_client_request_to_httpc_with_meta( +fn send_client_request_via_gun_with_meta( client_request: ClientRequest, -) -> Result(#(Int, List(#(String, String)), String), String) { +) -> Result(#(Int, List(#(String, String)), String), d.Dynamic) { let http_request = to_http_request(client_request) let url = build_url(http_request) let method_atom = internal.atomize_method(http_request.method) let method_dynamic = atom.to_dynamic(method_atom) let body = <> let timeout_value = resolve_timeout(client_request) + let connect_timeout_value = resolve_connect_timeout(client_request) + let auto_redirect_value = resolve_auto_redirect(client_request) + let protocols_value = resolve_protocols(client_request) case - send_sync(method_dynamic, url, http_request.headers, body, timeout_value) + send_sync( + method_dynamic, + url, + http_request.headers, + body, + timeout_value, + connect_timeout_value, + auto_redirect_value, + protocols_value, + ) { Ok(#(status, headers, response_body)) -> { - response_body - |> bit_array.to_string - |> result.map_error(convert_string_error) - |> result.map(fn(body_str) { #(status, headers, body_str) }) + case bit_array.to_string(response_body) { + Ok(body_str) -> Ok(#(status, headers, body_str)) + Error(_) -> + Error( + to_dynamic(#( + atom.create("unexpected"), + "Response body is not valid UTF-8", + )), + ) + } } - Error(error_message) -> Error(error_message) + Error(error_dyn) -> Error(error_dyn) } } @@ -1203,12 +1963,6 @@ fn client_request_to_recorded_request( ) } -fn convert_string_error(_unused: Nil) -> String { - // BitArray.to_string uses Nil for string conversion errors, so there is no - // additional error information to surface here. - "Failed to convert response to string" -} - fn resolve_timeout(client_request: ClientRequest) -> Int { case client_request.timeout { Some(timeout_value) -> timeout_value @@ -1216,14 +1970,43 @@ fn resolve_timeout(client_request: ClientRequest) -> Int { } } -@external(erlang, "dream_httpc_shim", "request_sync") +fn resolve_connect_timeout(client_request: ClientRequest) -> Int { + case client_request.connect_timeout { + Some(ms) -> ms + None -> 15_000 + } +} + +fn resolve_auto_redirect(client_request: ClientRequest) -> Bool { + case client_request.auto_redirect { + Some(enabled) -> enabled + None -> True + } +} + +fn resolve_protocols(request: ClientRequest) -> atom.Atom { + case request.protocols { + option.Some(Http1Only) -> atom.create("http1_only") + option.Some(Http2Only) -> atom.create("http2_only") + option.Some(Http2Preferred) -> atom.create("http2_preferred") + option.None -> atom.create("default") + } +} + +@external(erlang, "gleam_stdlib", "identity") +fn to_dynamic(value: a) -> d.Dynamic + +@external(erlang, "dream_http_shim", "request_sync") fn send_sync( method: d.Dynamic, url: String, headers: List(#(String, String)), body: BitArray, timeout_ms: Int, -) -> Result(#(Int, List(#(String, String)), BitArray), String) + connect_timeout_ms: Int, + autoredirect: Bool, + protocols: atom.Atom, +) -> Result(#(Int, List(#(String, String)), BitArray), d.Dynamic) /// Stream HTTP response chunks using a yielder /// @@ -1253,9 +2036,9 @@ fn send_sync( /// /// ## Error Semantics /// -/// The yielder produces `Result(BytesTree, String)` for each chunk: +/// The yielder produces `Result(BytesTree, StreamFailure)` for each chunk: /// - `Ok(chunk)` - Successful chunk, more may follow -/// - `Error(reason)` - **Terminal error**, stream is done +/// - `Error(failure)` - **Terminal error**, stream is done /// /// After an `Error`, the yielder immediately returns `Done` on the next call. /// This design reflects that HTTP stream errors (timeouts, connection drops, @@ -1264,9 +2047,9 @@ fn send_sync( /// **Normal stream completion**: When the stream finishes successfully, the yielder /// returns `Done` (no more items). The stream does NOT yield an error for normal completion. /// -/// Possible error reasons (actual errors only): -/// - `"timeout"` - Request timed out -/// - Connection errors from `httpc` +/// Errors are structured as `StreamFailure`: +/// - `HttpFailure(response)` - Server returned non-2xx (status, headers, body available) +/// - `TransportFailure(error)` - Transport-level failure (timeout, connection drop, etc.) /// /// ## Parameters /// @@ -1274,7 +2057,7 @@ fn send_sync( /// /// ## Returns /// -/// A `Yielder` that produces `Result(BytesTree, String)`. Always check each +/// A `Yielder` that produces `Result(BytesTree, StreamFailure)`. Always check each /// result - errors are terminal and mean the stream has ended. /// /// ## Examples @@ -1294,8 +2077,8 @@ fn send_sync( /// |> each(fn(result) { /// case result { /// Ok(chunk) -> print(to_string(chunk)) -/// Error(error_reason) -> { -/// println_error("Stream error: " <> error_reason) +/// Error(failure) -> { +/// println_error("Stream error: " <> client.stream_failure_to_string(failure)) /// // Stream is now done, no more chunks will arrive /// } /// } @@ -1330,12 +2113,12 @@ fn send_sync( /// |> string.join("") /// Ok(body) /// } -/// Error(error_reason) -> Error("Stream failed: " <> error_reason) +/// Error(failure) -> Error(client.stream_failure_to_string(failure)) /// } /// ``` pub fn stream_yielder( client_request: ClientRequest, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { case client_request.recorder { option.Some(recorder_instance) -> stream_yielder_with_recorder(client_request, recorder_instance) @@ -1346,20 +2129,21 @@ pub fn stream_yielder( fn stream_yielder_with_recorder( client_request: ClientRequest, recorder_instance: recorder.Recorder, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { let recorded_request = client_request_to_recorded_request(client_request) case recorder.find_recording(recorder_instance, recorded_request) { Ok(option.Some(recording.Recording(_, response))) -> create_yielder_from_recorded_response(response) Ok(option.None) -> create_stream_yielder_from_client_request(client_request) - Error(reason) -> yielder.single(Error(reason)) + Error(reason) -> + yielder.single(Error(TransportFailure(error: Unexpected(raw: reason)))) } } fn create_yielder_from_recorded_response( response: recording.RecordedResponse, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { case response { recording.StreamingResponse(_, _, chunks) -> create_yielder_from_chunks(chunks) @@ -1373,9 +2157,12 @@ fn create_yielder_from_recorded_response( fn create_stream_yielder_from_client_request( client_request: ClientRequest, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { let http_request = to_http_request(client_request) let timeout_value = resolve_timeout(client_request) + let connect_timeout_value = resolve_connect_timeout(client_request) + let auto_redirect_value = resolve_auto_redirect(client_request) + let protocols_value = resolve_protocols(client_request) case client_request.recorder { option.Some(recorder_instance) -> @@ -1384,8 +2171,18 @@ fn create_stream_yielder_from_client_request( recorder_instance, http_request, timeout_value, + connect_timeout_value, + auto_redirect_value, + protocols_value, + ) + option.None -> + create_plain_yielder( + http_request, + timeout_value, + connect_timeout_value, + auto_redirect_value, + protocols_value, ) - option.None -> create_plain_yielder(http_request, timeout_value) } } @@ -1394,16 +2191,21 @@ fn stream_yielder_with_record_mode( recorder_instance: recorder.Recorder, http_request: request.Request(String), timeout_value: Int, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { + connect_timeout_value: Int, + auto_redirect_value: Bool, + protocols_value: atom.Atom, +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { case recorder.is_record_mode(recorder_instance) { True -> { - // Recording mode - wrap yielder to capture chunks let recorded_request = client_request_to_recorded_request(client_request) let initial_state = RecordingYielderState( owner: None, http_req: http_request, timeout_ms: timeout_value, + connect_timeout_ms: connect_timeout_value, + auto_redirect: auto_redirect_value, + protocols_atom: protocols_value, recorder: recorder_instance, recorded_request: recorded_request, start_headers: [], @@ -1413,23 +2215,38 @@ fn stream_yielder_with_record_mode( yielder.unfold(initial_state, handle_recording_yielder_unfold) } False -> - // Playback mode was already handled, use normal yielder - create_plain_yielder(http_request, timeout_value) + create_plain_yielder( + http_request, + timeout_value, + connect_timeout_value, + auto_redirect_value, + protocols_value, + ) } } fn create_plain_yielder( http_request: request.Request(String), timeout_value: Int, -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { + connect_timeout_value: Int, + auto_redirect_value: Bool, + protocols_value: atom.Atom, +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { let initial_state = - YielderState(owner: None, http_req: http_request, timeout_ms: timeout_value) + YielderState( + owner: None, + http_req: http_request, + timeout_ms: timeout_value, + connect_timeout_ms: connect_timeout_value, + auto_redirect: auto_redirect_value, + protocols_atom: protocols_value, + ) yielder.unfold(initial_state, handle_yielder_unfold_with_deps) } fn create_yielder_from_chunks( chunks: List(recording.Chunk), -) -> yielder.Yielder(Result(bytes_tree.BytesTree, String)) { +) -> yielder.Yielder(Result(bytes_tree.BytesTree, StreamFailure)) { chunks |> yielder.from_list |> yielder.map(convert_chunk_to_result) @@ -1437,7 +2254,7 @@ fn create_yielder_from_chunks( fn convert_chunk_to_result( chunk: recording.Chunk, -) -> Result(bytes_tree.BytesTree, String) { +) -> Result(bytes_tree.BytesTree, StreamFailure) { // TODO: Add delay based on chunk.delay_ms let data = bytes_tree.from_bit_array(chunk.data) Ok(data) @@ -1448,6 +2265,9 @@ type YielderState { owner: Option(d.Dynamic), http_req: request.Request(String), timeout_ms: Int, + connect_timeout_ms: Int, + auto_redirect: Bool, + protocols_atom: atom.Atom, ) } @@ -1456,6 +2276,9 @@ type RecordingYielderState { owner: Option(d.Dynamic), http_req: request.Request(String), timeout_ms: Int, + connect_timeout_ms: Int, + auto_redirect: Bool, + protocols_atom: atom.Atom, recorder: recorder.Recorder, recorded_request: recording.RecordedRequest, start_headers: List(#(String, String)), @@ -1466,7 +2289,7 @@ type RecordingYielderState { fn handle_yielder_unfold_with_deps( state: YielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { +) -> yielder.Step(Result(bytes_tree.BytesTree, StreamFailure), YielderState) { case state.owner { None -> handle_yielder_start_with_state(state) Some(owner) -> handle_yielder_next_with_state(owner, state) @@ -1513,9 +2336,15 @@ fn response_result( fn handle_yielder_start_with_state( state: YielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { +) -> yielder.Step(Result(bytes_tree.BytesTree, StreamFailure), YielderState) { let request_result = - internal.start_httpc_stream(state.http_req, state.timeout_ms) + internal.start_gun_stream( + state.http_req, + state.timeout_ms, + state.connect_timeout_ms, + state.auto_redirect, + state.protocols_atom, + ) let owner = internal.extract_owner_pid(request_result) case internal.receive_next(owner, state.timeout_ms) { Ok(option.Some(bin)) -> @@ -1524,19 +2353,21 @@ fn handle_yielder_start_with_state( YielderState(..state, owner: Some(owner)), ) Ok(option.None) -> yielder.Done - Error(error_reason) -> yielder.Next(Error(error_reason), state) + Error(error_dyn) -> + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } fn handle_yielder_next_with_state( owner: d.Dynamic, state: YielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), YielderState) { +) -> yielder.Step(Result(bytes_tree.BytesTree, StreamFailure), YielderState) { case internal.receive_next(owner, state.timeout_ms) { Ok(option.Some(bin)) -> yielder.Next(Ok(bytes_tree.from_bit_array(bin)), state) Ok(option.None) -> yielder.Done - Error(error_reason) -> yielder.Next(Error(error_reason), state) + Error(error_dyn) -> + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } @@ -1556,7 +2387,10 @@ fn convert_time_unit(time: Int, from_unit: atom.Atom, to_unit: atom.Atom) -> Int fn handle_recording_yielder_unfold( state: RecordingYielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { +) -> yielder.Step( + Result(bytes_tree.BytesTree, StreamFailure), + RecordingYielderState, +) { case state.owner { None -> handle_recording_yielder_start(state) Some(owner) -> handle_recording_yielder_next(owner, state) @@ -1565,9 +2399,18 @@ fn handle_recording_yielder_unfold( fn handle_recording_yielder_start( state: RecordingYielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { +) -> yielder.Step( + Result(bytes_tree.BytesTree, StreamFailure), + RecordingYielderState, +) { let request_result = - internal.start_httpc_stream(state.http_req, state.timeout_ms) + internal.start_gun_stream( + state.http_req, + state.timeout_ms, + state.connect_timeout_ms, + state.auto_redirect, + state.protocols_atom, + ) let owner = internal.extract_owner_pid(request_result) let start_headers = case internal.get_stream_start_headers(owner, state.timeout_ms) @@ -1604,9 +2447,8 @@ fn handle_recording_yielder_start( ) yielder.Done } - Error(error_reason) -> { - // Error on first chunk - don't record, just pass through error - yielder.Next(Error(error_reason), state) + Error(error_dyn) -> { + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } } @@ -1614,18 +2456,19 @@ fn handle_recording_yielder_start( fn handle_recording_yielder_next( owner: d.Dynamic, state: RecordingYielderState, -) -> yielder.Step(Result(bytes_tree.BytesTree, String), RecordingYielderState) { +) -> yielder.Step( + Result(bytes_tree.BytesTree, StreamFailure), + RecordingYielderState, +) { let now = get_time_ms() case internal.receive_next(owner, state.timeout_ms) { Ok(option.Some(bin)) -> { - // Calculate delay since last chunk let delay = case state.last_chunk_time { Some(last_time) -> now - last_time None -> 0 } - // Record the chunk let chunk = recording.Chunk(data: bin, delay_ms: delay) let new_state = RecordingYielderState( @@ -1636,14 +2479,12 @@ fn handle_recording_yielder_next( yielder.Next(Ok(bytes_tree.from_bit_array(bin)), new_state) } Ok(option.None) -> { - // Stream finished - save recording save_streaming_recording(state, state.chunks) yielder.Done } - Error(error_reason) -> { - // Stream error - save what we have so far + Error(error_dyn) -> { save_streaming_recording(state, state.chunks) - yielder.Next(Error(error_reason), state) + yielder.Next(Error(decode_stream_failure(error_dyn)), state) } } } @@ -1655,7 +2496,7 @@ fn save_streaming_recording( // Reverse chunks to get correct order (we prepended them) let ordered_chunks = list.reverse(chunks) - // httpc only streams body chunks for successful responses (200/206). We + // gun only streams body chunks for successful responses (200/206). We // infer 206 if Content-Range is present; otherwise default to 200. let status = case list.any(state.start_headers, fn(h) { @@ -1680,10 +2521,12 @@ fn save_streaming_recording( } // Internal: Start a message-based streaming HTTP request -// Used by start_stream() to initiate the low-level HTTP stream via httpc. +// Used by start_stream() to initiate the low-level HTTP stream via gun. // Note: start_stream() already handles playback via maybe_replay_from_recording() // before reaching this function. This path is for live HTTP requests only. -fn stream_messages(client_request: ClientRequest) -> Result(RequestId, String) { +fn stream_messages( + client_request: ClientRequest, +) -> Result(RequestId, StreamFailure) { case client_request.recorder { option.Some(recorder_instance) -> stream_messages_with_recorder(client_request, recorder_instance) @@ -1694,45 +2537,47 @@ fn stream_messages(client_request: ClientRequest) -> Result(RequestId, String) { fn stream_messages_with_recorder( client_request: ClientRequest, recorder_instance: recorder.Recorder, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let recorded_request = client_request_to_recorded_request(client_request) case recorder.find_recording(recorder_instance, recorded_request) { Ok(option.Some(_recording)) -> - // Safety net: start_stream() replays via maybe_replay_from_recording() - // before reaching here. If we land here anyway, it means the low-level - // httpc message path cannot replay recordings. Error( - "Unexpected: recording found in stream_messages path. This should have been handled by start_stream() playback.", + TransportFailure(error: Unexpected( + raw: "Unexpected: recording found in stream_messages path. This should have been handled by start_stream() playback.", + )), ) Ok(option.None) -> - send_stream_messages_to_httpc( + send_stream_messages_via_gun( client_request, option.Some(recorder_instance), recorded_request, ) - Error(reason) -> Error(reason) + Error(reason) -> Error(TransportFailure(error: Unexpected(raw: reason))) } } fn stream_messages_without_recorder( client_request: ClientRequest, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let recorded_request = client_request_to_recorded_request(client_request) - send_stream_messages_to_httpc(client_request, option.None, recorded_request) + send_stream_messages_via_gun(client_request, option.None, recorded_request) } -fn send_stream_messages_to_httpc( +fn send_stream_messages_via_gun( client_request: ClientRequest, recorder_option: Option(recorder.Recorder), recorded_request: recording.RecordedRequest, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let http_request = to_http_request(client_request) let url = build_url(http_request) let method_atom = internal.atomize_method(http_request.method) let body = <> let caller_process = process.self() let timeout_value = resolve_timeout(client_request) + let connect_timeout_value = resolve_connect_timeout(client_request) + let auto_redirect_value = resolve_auto_redirect(client_request) + let protocols_value = resolve_protocols(client_request) let start_result = internal.start_stream_messages( @@ -1742,6 +2587,9 @@ fn send_stream_messages_to_httpc( body, caller_process, timeout_value, + connect_timeout_value, + auto_redirect_value, + protocols_value, ) case parse_stream_start_result(start_result) { @@ -1795,53 +2643,55 @@ fn build_url(request: request.Request(String)) -> String { <> query_string } -fn parse_stream_start_result(result: d.Dynamic) -> Result(RequestId, String) { +fn parse_stream_start_result( + result: d.Dynamic, +) -> Result(RequestId, StreamFailure) { let tag_result = d.run(result, d.at([0], d.dynamic)) case tag_result { Ok(tag_dyn) -> parse_stream_start_tag(tag_dyn, result) Error(decode_errors) -> - Error("Failed to parse httpc response: " <> string.inspect(decode_errors)) + Error( + TransportFailure(error: Unexpected( + raw: "Failed to parse gun response: " <> string.inspect(decode_errors), + )), + ) } } fn parse_stream_start_tag( tag_dyn: d.Dynamic, result: d.Dynamic, -) -> Result(RequestId, String) { +) -> Result(RequestId, StreamFailure) { let tag = atom.cast_from_dynamic(tag_dyn) |> atom.to_string case tag { "ok" -> extract_request_id(result) - "error" -> extract_error_reason(result) - _ -> Error("Unknown response from httpc") + "error" -> Error(extract_error_reason(result)) + _ -> + Error( + TransportFailure(error: Unexpected(raw: "Unknown response from gun")), + ) } } -fn extract_request_id(result: d.Dynamic) -> Result(RequestId, String) { +fn extract_request_id(result: d.Dynamic) -> Result(RequestId, StreamFailure) { let id_result = d.run(result, d.at([1], d.string)) case id_result { Ok(id_string) -> Ok(RequestId(id: id_string)) Error(decode_errors) -> - Error("Failed to extract request ID: " <> string.inspect(decode_errors)) - } -} - -fn extract_error_reason(result: d.Dynamic) -> Result(RequestId, String) { - let reason_result = d.run(result, d.at([1], d.dynamic)) - case reason_result { - Ok(reason_dyn) -> { - let reason = string.inspect(reason_dyn) - Error("Failed to start stream: " <> reason) - } - Error(decode_error) -> { Error( - "Failed to start stream (decode error: " - <> string.inspect(decode_error) - <> ")", + TransportFailure(error: Unexpected( + raw: "Failed to extract request ID: " <> string.inspect(decode_errors), + )), ) - } } } +fn extract_error_reason(result: d.Dynamic) -> StreamFailure { + let error_dyn = + d.run(result, d.at([1], d.dynamic)) |> result.unwrap(dynamic.nil()) + decode_stream_failure(error_dyn) +} + // Internal: Add stream message handling to a selector // Used by start_stream() to receive HTTP messages in spawned process fn select_stream_messages( @@ -1869,7 +2719,7 @@ fn apply_mapper_to_dynamic( dyn: d.Dynamic, mapper: fn(StreamMessage) -> msg, ) -> msg { - // Erlang does the heavy lifting: converts raw httpc messages to clean format + // Erlang does the heavy lifting: converts raw gun messages to clean format // We just decode the simple {Tag, RequestId, Data} tuple let simplified = internal.decode_stream_message_for_selector(dyn) let stream_msg = decode_simplified_message(simplified) @@ -1902,7 +2752,7 @@ fn handle_tag_decode_error( case req_id_result { Ok(req_id_string) -> { let req_id = RequestId(id: req_id_string) - StreamError(req_id, error_msg) + StreamError(req_id, TransportFailure(error: Unexpected(raw: error_msg))) } Error(req_id_error) -> { let full_error_msg = @@ -1947,7 +2797,12 @@ fn decode_by_tag( "stream_end" -> decode_stream_end(req_id, data_result) "stream_error" -> decode_stream_error(req_id, data_result) _ -> - StreamError(req_id, "Internal error: Unknown stream message tag: " <> tag) + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Internal error: Unknown stream message tag: " <> tag, + )), + ) } } @@ -1957,12 +2812,14 @@ fn decode_stream_start( ) -> StreamMessage { case data_result { Ok(headers_dyn) -> decode_stream_start_headers(req_id, headers_dyn) - Error(decode_error) -> { - let error_msg = - "Failed to get headers data in StreamStart: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to get headers data in StreamStart: " + <> string.inspect(decode_error), + )), + ) } } @@ -1972,12 +2829,14 @@ fn decode_stream_start_headers( ) -> StreamMessage { case decode_headers(headers_dyn) { Ok(headers) -> StreamStart(req_id, tuples_to_headers(headers)) - Error(header_decode_error) -> { - let error_msg = - "Failed to decode headers in StreamStart: " - <> string.inspect(header_decode_error) - StreamError(req_id, error_msg) - } + Error(header_decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to decode headers in StreamStart: " + <> string.inspect(header_decode_error), + )), + ) } } @@ -1987,24 +2846,28 @@ fn decode_chunk( ) -> StreamMessage { case data_result { Ok(data_dyn) -> decode_chunk_data(req_id, data_dyn) - Error(decode_error) -> { - let error_msg = - "Internal error: Failed to get chunk data: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Internal error: Failed to get chunk data: " + <> string.inspect(decode_error), + )), + ) } } fn decode_chunk_data(req_id: RequestId, data_dyn: d.Dynamic) -> StreamMessage { case d.run(data_dyn, d.bit_array) { Ok(data) -> Chunk(req_id, data) - Error(decode_error) -> { - let error_msg = - "Internal error: Failed to decode chunk data: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Internal error: Failed to decode chunk data: " + <> string.inspect(decode_error), + )), + ) } } @@ -2014,12 +2877,14 @@ fn decode_stream_end( ) -> StreamMessage { case data_result { Ok(headers_dyn) -> decode_stream_end_headers(req_id, headers_dyn) - Error(decode_error) -> { - let error_msg = - "Failed to get trailing headers data in StreamEnd: " - <> string.inspect(decode_error) - StreamError(req_id, error_msg) - } + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to get trailing headers data in StreamEnd: " + <> string.inspect(decode_error), + )), + ) } } @@ -2029,46 +2894,100 @@ fn decode_stream_end_headers( ) -> StreamMessage { case decode_headers(headers_dyn) { Ok(headers) -> StreamEnd(req_id, tuples_to_headers(headers)) - Error(header_decode_error) -> { - let error_msg = - "Failed to decode trailing headers in StreamEnd: " - <> string.inspect(header_decode_error) - StreamError(req_id, error_msg) + Error(header_decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected( + raw: "Failed to decode trailing headers in StreamEnd: " + <> string.inspect(header_decode_error), + )), + ) + } +} + +fn decode_transport_error(dyn: d.Dynamic) -> TransportError { + let tag = + d.run(dyn, d.at([0], d.dynamic)) + |> result.map(fn(t) { atom.to_string(atom.cast_from_dynamic(t)) }) + |> result.unwrap("") + case tag { + "stream_reset" -> { + let code = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + let description = d.run(dyn, d.at([2], d.string)) |> result.unwrap("") + StreamReset(code: code, description: description) + } + "goaway" -> { + let code = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + let last_stream_id = d.run(dyn, d.at([2], d.int)) |> result.unwrap(0) + let debug_data = d.run(dyn, d.at([3], d.string)) |> result.unwrap("") + Goaway(code: code, last_stream_id: last_stream_id, debug_data: debug_data) } + "connection_error" -> { + let code = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + let description = d.run(dyn, d.at([2], d.string)) |> result.unwrap("") + ConnectionError(code: code, description: description) + } + "remote_closed" -> RemoteClosed + "timed_out" -> { + let timeout_ms = d.run(dyn, d.at([1], d.int)) |> result.unwrap(0) + TimedOut(timeout_ms: timeout_ms) + } + "process_down" -> { + let reason = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + ProcessDown(reason: reason) + } + "connect_failed" -> { + let reason = d.run(dyn, d.at([1], d.string)) |> result.unwrap("unknown") + ConnectFailed(reason: reason) + } + "unexpected" -> { + let raw = + d.run(dyn, d.at([1], d.string)) |> result.unwrap(string.inspect(dyn)) + Unexpected(raw: raw) + } + _ -> Unexpected(raw: "Unknown error tag: " <> tag) } } -fn decode_stream_error( - req_id: RequestId, - data_result: Result(d.Dynamic, List(d.DecodeError)), -) -> StreamMessage { - case data_result { - Ok(reason_dyn) -> decode_error_reason(req_id, reason_dyn) - Error(decode_error) -> { - let error_msg = - "Stream error (failed to decode error reason: " - <> string.inspect(decode_error) - <> ")" - StreamError(req_id, error_msg) +fn decode_stream_failure(dyn: d.Dynamic) -> StreamFailure { + let tag = + d.run(dyn, d.at([0], d.dynamic)) + |> result.map(fn(t) { atom.to_string(atom.cast_from_dynamic(t)) }) + |> result.unwrap("") + case tag { + "http_failure" -> { + let status = d.run(dyn, d.at([1], d.int)) |> result.unwrap(0) + let headers = + d.run(dyn, d.at([2], d.list(d.dynamic))) + |> result.unwrap([]) + |> list.filter_map(fn(h) { + case d.run(h, d.at([0], d.string)), d.run(h, d.at([1], d.string)) { + Ok(name), Ok(value) -> Ok(Header(name: name, value: value)) + _, _ -> Error(Nil) + } + }) + let body = d.run(dyn, d.at([3], d.string)) |> result.unwrap("") + HttpFailure(response: HttpResponse( + status: status, + headers: headers, + body: body, + )) } + _ -> TransportFailure(error: decode_transport_error(dyn)) } } -fn decode_error_reason( +fn decode_stream_error( req_id: RequestId, - reason_dyn: d.Dynamic, + data_result: Result(d.Dynamic, List(d.DecodeError)), ) -> StreamMessage { - case d.run(reason_dyn, d.string) { - Ok(reason) -> StreamError(req_id, reason) - Error(_) -> - case d.run(reason_dyn, d.bit_array) { - Ok(bytes) -> - case bit_array.to_string(bytes) { - Ok(s) -> StreamError(req_id, s) - Error(_) -> StreamError(req_id, string.inspect(reason_dyn)) - } - Error(_) -> StreamError(req_id, string.inspect(reason_dyn)) - } + case data_result { + Ok(error_dyn) -> StreamError(req_id, decode_stream_failure(error_dyn)) + Error(decode_error) -> + StreamError( + req_id, + TransportFailure(error: Unexpected(raw: string.inspect(decode_error))), + ) } } @@ -2136,8 +3055,8 @@ fn pair_with_name(value: String, name: String) -> #(String, String) { /// Error(_) -> Nil /// } /// }) -/// |> client.on_stream_error(fn(reason) { -/// io.println_error("Error: " <> reason) +/// |> client.on_stream_error(fn(failure) { +/// io.println_error("Error: " <> client.stream_failure_to_string(failure)) /// }) /// |> client.start_stream() /// @@ -2164,10 +3083,9 @@ fn run_stream_process(request: ClientRequest) -> Nil { // Start the stream using internal API case stream_messages(request) { - Error(reason) -> { - // Call error callback if set + Error(failure) -> { case request.on_stream_error { - Some(on_error) -> on_error(reason) + Some(on_error) -> on_error(failure) None -> Nil } } @@ -2251,9 +3169,9 @@ fn process_stream_loop( handle_stream_message(message, req_id, request, selector, timeout_ms) } Error(Nil) -> { - // Timeout waiting for messages case request.on_stream_error { - Some(on_error) -> on_error("Timeout waiting for stream messages") + Some(on_error) -> + on_error(TransportFailure(error: TimedOut(timeout_ms: timeout_ms))) None -> Nil } } @@ -2307,11 +3225,11 @@ fn handle_stream_message( } } - StreamError(stream_req_id, reason) -> { + StreamError(stream_req_id, failure) -> { case stream_req_id == req_id { True -> { case request.on_stream_error { - Some(on_error) -> on_error(reason) + Some(on_error) -> on_error(failure) None -> Nil } Nil @@ -2322,7 +3240,10 @@ fn handle_stream_message( DecodeError(reason) -> { case request.on_stream_error { - Some(on_error) -> on_error("DecodeError: " <> reason) + Some(on_error) -> + on_error( + TransportFailure(error: Unexpected(raw: "DecodeError: " <> reason)), + ) None -> Nil } Nil @@ -2432,7 +3353,7 @@ type MessageStreamRecorderState { // ETS table name for recorder state const recorder_table_name = "dream_http_client_stream_recorders" -@external(erlang, "dream_httpc_shim", "ets_insert") +@external(erlang, "dream_http_shim", "ets_insert") fn ets_insert( table: String, key: String, @@ -2443,10 +3364,10 @@ fn ets_insert( last_chunk_time: Option(Int), ) -> Nil -@external(erlang, "dream_httpc_shim", "ets_lookup") +@external(erlang, "dream_http_shim", "ets_lookup") fn ets_lookup(table: String, key: String) -> Option(MessageStreamRecorderState) -@external(erlang, "dream_httpc_shim", "ets_delete") +@external(erlang, "dream_http_shim", "ets_delete") fn ets_delete(table: String, key: String) -> Bool fn store_message_stream_recorder( @@ -2528,13 +3449,13 @@ fn record_stream_message(message: StreamMessage) -> Nil { option.None -> Nil } } - StreamError(request_id, error_reason) -> { + StreamError(request_id, failure) -> { let RequestId(request_id_string) = request_id io.println_error( "HTTP stream error while recording messages for request " <> request_id_string <> ": " - <> error_reason, + <> stream_failure_to_string(failure), ) case get_message_stream_recorder(request_id) { option.Some(state) -> { diff --git a/modules/http_client/src/dream_http_client/dream_http_client_app.erl b/modules/http_client/src/dream_http_client/dream_http_client_app.erl index d5b5b0a..9b21d48 100644 --- a/modules/http_client/src/dream_http_client/dream_http_client_app.erl +++ b/modules/http_client/src/dream_http_client/dream_http_client_app.erl @@ -5,6 +5,9 @@ start(_Type, _Args) -> ets:new(dream_http_client_ref_mapping, [set, public, named_table]), ets:new(dream_http_client_stream_recorders, [set, public, named_table]), + ets:new(dream_http_client_transport_config, [set, public, named_table]), + ets:new(dream_http_client_connections, [bag, public, named_table]), + logger:set_module_level([dream_http_conn_manager, dream_http_shim], info), dream_http_client_sup:start_link(). stop(_State) -> diff --git a/modules/http_client/src/dream_http_client/dream_http_client_sup.erl b/modules/http_client/src/dream_http_client/dream_http_client_sup.erl index 8221780..812d8ef 100644 --- a/modules/http_client/src/dream_http_client/dream_http_client_sup.erl +++ b/modules/http_client/src/dream_http_client/dream_http_client_sup.erl @@ -6,4 +6,10 @@ start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). init([]) -> - {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, []}}. + ConnManager = #{ + id => dream_http_conn_manager, + start => {dream_http_conn_manager, start_link, []}, + restart => permanent, + type => worker + }, + {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, [ConnManager]}}. diff --git a/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl b/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl new file mode 100644 index 0000000..3b4f36b --- /dev/null +++ b/modules/http_client/src/dream_http_client/dream_http_conn_manager.erl @@ -0,0 +1,308 @@ +-module(dream_http_conn_manager). +-behaviour(gen_server). + +-export([start_link/0, get_connection/4, ensure_connection/4]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). + +-define(TABLE, dream_http_client_connections). + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +%% Hot path: lock-free ETS lookup with round-robin selection. +%% Returns {ok, ConnPid, Protocol} | none. +get_connection(Scheme, Host, Port, Protocols) -> + case ets:lookup(?TABLE, {Scheme, Host, Port, Protocols}) of + [] -> + none; + Entries -> + Len = length(Entries), + Idx = erlang:unique_integer([monotonic, positive]) rem Len, + {_Key, ConnPid, _MonRef, Protocol, _LastUsed} = lists:nth(Idx + 1, Entries), + case erlang:is_process_alive(ConnPid) of + true -> + touch(ConnPid), + {ok, ConnPid, Protocol}; + false -> + %% Dead connection — try the others + Alive = [E || {_, Pid, _, _, _} = E <- Entries, erlang:is_process_alive(Pid)], + case Alive of + [] -> none; + _ -> + Idx2 = erlang:unique_integer([monotonic, positive]) rem length(Alive), + {_, Pid2, _, Proto2, _} = lists:nth(Idx2 + 1, Alive), + touch(Pid2), + {ok, Pid2, Proto2} + end + end + end. + +%% Cold path: create or reuse a connection via the gen_server. +ensure_connection(Scheme, Host, Port, GunOpts) -> + gen_server:call(?MODULE, {ensure_connection, Scheme, Host, Port, GunOpts}, 30000). + +%% Update LastUsed for a connection entry. +%% bag tables don't support select_replace, so we delete+insert. +touch(ConnPid) -> + Now = erlang:monotonic_time(millisecond), + case ets:match_object(?TABLE, {'_', ConnPid, '_', '_', '_'}) of + [{Key, _, MonRef, Protocol, _OldLastUsed}] -> + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}), + ets:insert(?TABLE, {Key, ConnPid, MonRef, Protocol, Now}); + [_ | _] = Entries -> + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}), + lists:foreach(fun({Key, _, MonRef, Protocol, _}) -> + ets:insert(?TABLE, {Key, ConnPid, MonRef, Protocol, Now}) + end, Entries); + [] -> + ok + end. + +%% ============================================================================ +%% gen_server callbacks +%% ============================================================================ + +init([]) -> + %% Crash recovery: scan ETS for leftover entries from previous incarnation + recover_connections(), + schedule_idle_check(), + {ok, #{}}. + +handle_call({ensure_connection, Scheme, Host, Port, GunOpts}, _From, State) -> + Transport = case Scheme of + https -> tls; + _ -> tcp + end, + Protocols = resolve_protocols(GunOpts, Transport), + ResolvedOpts = GunOpts#{protocols => Protocols}, + Key = {Scheme, Host, Port, Protocols}, + Existing = ets:lookup(?TABLE, Key), + %% Clean up dead connections first + Alive = [E || {_, Pid, _, _, _} = E <- Existing, erlang:is_process_alive(Pid)], + Dead = length(Existing) - length(Alive), + case Dead > 0 of + true -> + lists:foreach(fun({_, Pid, _, _, _}) -> + case erlang:is_process_alive(Pid) of + false -> ets:match_delete(?TABLE, {'_', Pid, '_', '_', '_'}); + true -> ok + end + end, Existing); + false -> ok + end, + MaxConns = maps:get(max_connections, GunOpts, 50), + AliveCount = length(Alive), + case AliveCount < MaxConns of + true -> + case open_connection(Scheme, Host, Port, ResolvedOpts) of + {ok, ConnPid, Protocol} -> + MonRef = erlang:monitor(process, ConnPid), + Now = erlang:monotonic_time(millisecond), + ets:insert(?TABLE, {Key, ConnPid, MonRef, Protocol, Now}), + {reply, {ok, ConnPid, Protocol}, State}; + {error, Reason} -> + {reply, {error, Reason}, State} + end; + false -> + %% At max — round-robin an existing one + Idx = erlang:unique_integer([monotonic, positive]) rem AliveCount, + {_, Pid, _, Proto, _} = lists:nth(Idx + 1, Alive), + touch(Pid), + {reply, {ok, Pid, Proto}, State} + end; + +handle_call(_Request, _From, State) -> + {reply, {error, unknown_call}, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({'DOWN', _MonRef, process, ConnPid, _Reason}, State) -> + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}), + {noreply, State}; + +handle_info(check_idle, State) -> + reap_idle_connections(), + schedule_idle_check(), + {noreply, State}; + +handle_info({gun_up, _ConnPid, _Protocol}, State) -> + {noreply, State}; + +handle_info({gun_down, ConnPid, Protocol, Reason, KilledStreams}, State) -> + Killed = length(KilledStreams), + log_gun_down(ConnPid, Protocol, Reason, Killed, 0), + {noreply, State}; + +handle_info({gun_down, ConnPid, Protocol, Reason, KilledStreams, UnprocessedStreams}, State) -> + Killed = length(KilledStreams), + Unprocessed = length(UnprocessedStreams), + log_gun_down(ConnPid, Protocol, Reason, Killed, Unprocessed), + {noreply, State}; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +%% ============================================================================ +%% Internal +%% ============================================================================ + +open_connection(Scheme, Host, Port, GunOpts) -> + Transport = case Scheme of + https -> tls; + _ -> tcp + end, + ConnectTimeout = maps:get(connect_timeout, GunOpts, 15000), + HostStr = case is_binary(Host) of + true -> binary_to_list(Host); + false -> Host + end, + Protocols = maps:get(protocols, GunOpts, case Transport of + tls -> [http2, http]; + tcp -> [http] + end), + BaseOpts = #{ + transport => Transport, + connect_timeout => ConnectTimeout, + protocols => Protocols, + retry => maps:get(retry, GunOpts, 3), + retry_timeout => maps:get(retry_timeout, GunOpts, 1000), + domain_lookup_timeout => maps:get(domain_lookup_timeout, GunOpts, 5000), + tls_handshake_timeout => maps:get(tls_handshake_timeout, GunOpts, 10000) + }, + Http2Opts = build_http2_opts(GunOpts), + Opts = case maps:size(Http2Opts) of + 0 -> BaseOpts; + _ -> BaseOpts#{http2_opts => Http2Opts} + end, + TlsOpts = case Transport of + tls -> + AlpnProtocols = resolve_alpn(Protocols), + Opts#{tls_opts => [ + {verify, verify_peer}, + {cacerts, public_key:cacerts_get()}, + {depth, 3}, + {customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}, + {alpn_advertised_protocols, AlpnProtocols} + ]}; + tcp -> Opts + end, + case gun:open(HostStr, Port, TlsOpts) of + {ok, ConnPid} -> + case gun:await_up(ConnPid, ConnectTimeout) of + {ok, Protocol} -> + {ok, ConnPid, Protocol}; + {error, {down, {shutdown, Reason}}} -> + {error, Reason}; + {error, timeout} -> + gun:close(ConnPid), + {error, connect_timeout}; + {error, Reason} -> + gun:close(ConnPid), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +build_http2_opts(GunOpts) -> + Pairs = [ + {keepalive, maps:get(keepalive, GunOpts, undefined)}, + {keepalive_tolerance, maps:get(keepalive_tolerance, GunOpts, undefined)}, + {max_concurrent_streams, maps:get(max_concurrent_streams, GunOpts, undefined)}, + {initial_connection_window_size, maps:get(initial_connection_window_size, GunOpts, undefined)}, + {initial_stream_window_size, maps:get(initial_stream_window_size, GunOpts, undefined)}, + {closing_timeout, maps:get(closing_timeout, GunOpts, undefined)} + ], + maps:from_list([{K, V} || {K, V} <- Pairs, V =/= undefined]). + +recover_connections() -> + try + All = ets:tab2list(?TABLE), + lists:foreach(fun({Key, ConnPid, OldMonRef, Protocol, LastUsed}) -> + catch erlang:demonitor(OldMonRef, [flush]), + case erlang:is_process_alive(ConnPid) of + true -> + NewMonRef = erlang:monitor(process, ConnPid), + ets:match_delete(?TABLE, {Key, ConnPid, '_', '_', '_'}), + ets:insert(?TABLE, {Key, ConnPid, NewMonRef, Protocol, LastUsed}); + false -> + ets:match_delete(?TABLE, {Key, ConnPid, '_', '_', '_'}) + end + end, All) + catch + error:badarg -> ok + end. + +schedule_idle_check() -> + IdleTimeout = get_idle_timeout(), + erlang:send_after(IdleTimeout, self(), check_idle). + +reap_idle_connections() -> + IdleTimeout = get_idle_timeout(), + Now = erlang:monotonic_time(millisecond), + try + All = ets:tab2list(?TABLE), + lists:foreach(fun({_Key, ConnPid, _MonRef, _Protocol, LastUsed}) -> + case Now - LastUsed > IdleTimeout of + true -> + gun:close(ConnPid), + ets:match_delete(?TABLE, {'_', ConnPid, '_', '_', '_'}); + false -> + ok + end + end, All) + catch + error:badarg -> ok + end. + +log_gun_down(ConnPid, Protocol, Reason, Killed, Unprocessed) -> + case is_expected_disconnect(Reason, Killed) of + true -> + logger:info( + "[dream_http] connection closed: pid=~p protocol=~p reason=~p", + [ConnPid, Protocol, Reason]); + false when Unprocessed > 0 -> + logger:warning( + "[dream_http] connection down: pid=~p protocol=~p reason=~p killed=~p unprocessed=~p", + [ConnPid, Protocol, Reason, Killed, Unprocessed]); + false -> + logger:warning( + "[dream_http] connection down: pid=~p protocol=~p reason=~p killed_streams=~p", + [ConnPid, Protocol, Reason, Killed]) + end. + +is_expected_disconnect(closed, 0) -> true; +is_expected_disconnect(normal, 0) -> true; +is_expected_disconnect(_, _) -> false. + +resolve_protocols(GunOpts, Transport) -> + case maps:get(protocols, GunOpts, default) of + default -> + case Transport of + tls -> [http2, http]; + tcp -> [http] + end; + http1_only -> [http]; + http2_only -> [http2]; + http2_preferred -> [http2, http] + end. + +resolve_alpn(Protocols) -> + lists:filtermap(fun + (http2) -> {true, <<"h2">>}; + (http) -> {true, <<"http/1.1">>}; + (_) -> false + end, Protocols). + +get_idle_timeout() -> + case ets:lookup(dream_http_client_transport_config, config) of + [{config, Config}] -> + element(3, Config); % idle_timeout is the 3rd field (after tag + max_connections) + [] -> + 60000 + end. + diff --git a/modules/http_client/src/dream_http_client/dream_http_shim.erl b/modules/http_client/src/dream_http_client/dream_http_shim.erl new file mode 100644 index 0000000..a6f9230 --- /dev/null +++ b/modules/http_client/src/dream_http_client/dream_http_shim.erl @@ -0,0 +1,989 @@ +-module(dream_http_shim). + +-export([request_stream/9, fetch_next/2, fetch_start_headers/2, request_stream_messages/9, + cancel_stream/1, cancel_stream_by_string/1, receive_stream_message/1, + decode_stream_message_for_selector/1, normalize_headers/1, request_sync/8, + configure_transport/1, + ets_table_exists/1, ets_new/2, ets_insert/7, ets_lookup/2, ets_delete/2]). + +-define(REF_MAPPING_TABLE, dream_http_client_ref_mapping). +-define(MAX_REDIRECTS, 5). + +%% ============================================================================ +%% Synchronous (blocking) request +%% ============================================================================ + +request_sync(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> + NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), + request_sync_impl(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, 1, Protocols). + +request_sync_impl(_Method, _Url, _Headers, _Body, _TimeoutMs, _ConnectTimeoutMs, _AutoRedirect, Redirects, _RetriesLeft, _Protocols) when Redirects >= ?MAX_REDIRECTS -> + {error, {unexpected, <<"Too many redirects (max 5)">>}}; +request_sync_impl(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft, Protocols) -> + case parse_url(Url) of + {ok, Scheme, Host, Port, PathQs} -> + GunOpts = build_gun_opts(ConnectTimeoutMs, Protocols), + case get_or_open_connection(Scheme, Host, Port, GunOpts) of + {ok, ConnPid, _Protocol} -> + MethodAtom = to_method_atom(Method), + StreamRef = send_request(ConnPid, MethodAtom, PathQs, Headers, Body), + case gun:await(ConnPid, StreamRef, TimeoutMs) of + {response, fin, Status, RespHeaders} -> + NormHeaders = normalize_headers(RespHeaders), + handle_sync_response(Status, NormHeaders, <<>>, AutoRedirect, Redirects, + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders, Protocols); + {response, nofin, Status, RespHeaders} -> + case gun:await_body(ConnPid, StreamRef, TimeoutMs) of + {ok, RespBody} -> + {DecompBody, CleanHeaders} = maybe_decompress_response(RespBody, RespHeaders), + NormHeaders = normalize_headers(CleanHeaders), + handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders, Protocols); + {ok, RespBody, _Trailers} -> + {DecompBody, CleanHeaders} = maybe_decompress_response(RespBody, RespHeaders), + NormHeaders = normalize_headers(CleanHeaders), + handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, + Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RespHeaders, Protocols); + {error, timeout} -> + {error, {timed_out, TimeoutMs}}; + {error, Reason} -> + {error, classify_error(Reason, TimeoutMs)} + end; + {error, timeout} -> + {error, {timed_out, TimeoutMs}}; + {error, {stream_error, Reason, HumanReadable}} -> + case RetriesLeft > 0 andalso is_stale_connection_error({stream_error, Reason}) of + true -> + gun:close(ConnPid), + request_sync_impl(Method, Url, Headers, Body, TimeoutMs, + ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1, Protocols); + false -> + {error, classify_error({stream_error, Reason, HumanReadable}, TimeoutMs)} + end; + {error, Reason} -> + case RetriesLeft > 0 andalso is_stale_connection_error(Reason) of + true -> + gun:close(ConnPid), + request_sync_impl(Method, Url, Headers, Body, TimeoutMs, + ConnectTimeoutMs, AutoRedirect, Redirects, RetriesLeft - 1, Protocols); + false -> + {error, classify_error(Reason, TimeoutMs)} + end + end; + {error, Reason} -> + {error, classify_connect_error(Reason)} + end; + {error, Reason} -> + {error, classify_error(Reason, TimeoutMs)} + end. + +handle_sync_response(Status, NormHeaders, DecompBody, AutoRedirect, Redirects, + Method, Url, OrigHeaders, OrigBody, TimeoutMs, ConnectTimeoutMs, RawRespHeaders, Protocols) -> + case AutoRedirect andalso is_redirect(Status) of + true -> + case get_location(RawRespHeaders) of + {ok, Location} -> + ResolvedUrl = resolve_redirect_url(Location, Url), + RedirectMethod = redirect_method(Status, Method), + RedirectBody = redirect_body(Status, OrigBody), + request_sync_impl(RedirectMethod, ResolvedUrl, OrigHeaders, RedirectBody, + TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1, 1, Protocols); + error -> + {ok, {Status, NormHeaders, DecompBody}} + end; + false -> + {ok, {Status, NormHeaders, DecompBody}} + end. + +%% ============================================================================ +%% Pull-based streaming +%% ============================================================================ + +request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> + NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), + Owner = spawn(fun() -> + stream_owner_init(Method, Url, NHeaders, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) + end), + {ok, Owner}. + +stream_owner_init(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> + case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, Protocols) of + {ok, ConnPid, StreamRef, _Status, RespHeaders} -> + ZlibCtx = maybe_init_stream_zlib(RespHeaders), + NormHeaders = normalize_headers(RespHeaders), + stream_owner_wait(ConnPid, StreamRef, [], NormHeaders, [], ZlibCtx, TimeoutMs); + {error, Reason} -> + exit({stream_start_failed, Reason}) + end. + +%% Follows redirects, then returns {ok, ConnPid, StreamRef, Status, Headers} for a +%% streaming response (status 2xx), or {error, Reason} for non-2xx / failure. +start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, _AutoRedirect, Redirects, Protocols) when Redirects >= ?MAX_REDIRECTS -> + start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, Protocols); +start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects, Protocols) -> + case start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, Protocols) of + {ok, ConnPid, StreamRef, Status, RespHeaders} -> + case AutoRedirect andalso is_redirect(Status) of + true -> + %% Drain the body so the stream is clean + drain_stream(ConnPid, StreamRef), + case get_location(RespHeaders) of + {ok, Location} -> + ResolvedUrl = resolve_redirect_url(Location, Url), + RedirectMethod = redirect_method(Status, Method), + RedirectBody = redirect_body(Status, Body), + start_gun_stream(RedirectMethod, ResolvedUrl, Headers, RedirectBody, + TimeoutMs, ConnectTimeoutMs, AutoRedirect, Redirects + 1, Protocols); + error -> + {ok, ConnPid, StreamRef, Status, RespHeaders} + end; + false -> + case Status >= 200 andalso Status < 300 of + true -> + {ok, ConnPid, StreamRef, Status, RespHeaders}; + false -> + FullBody = collect_body(ConnPid, StreamRef, TimeoutMs), + {error, {http_failure, Status, normalize_headers(RespHeaders), ensure_utf8_binary(FullBody)}} + end + end; + {error, Reason} -> + {error, Reason} + end. + +start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, Protocols) -> + start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, 1, Protocols). + +start_gun_stream_final(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, RetriesLeft, Protocols) -> + case parse_url(Url) of + {ok, Scheme, Host, Port, PathQs} -> + GunOpts = build_gun_opts(ConnectTimeoutMs, Protocols), + case get_or_open_connection(Scheme, Host, Port, GunOpts) of + {ok, ConnPid, _Protocol} -> + MethodAtom = to_method_atom(Method), + StreamRef = send_request(ConnPid, MethodAtom, PathQs, Headers, Body), + case gun:await(ConnPid, StreamRef, TimeoutMs) of + {response, fin, Status, RespHeaders} -> + {ok, ConnPid, StreamRef, Status, RespHeaders}; + {response, nofin, Status, RespHeaders} -> + {ok, ConnPid, StreamRef, Status, RespHeaders}; + {error, timeout} -> + {error, {timed_out, TimeoutMs}}; + {error, Reason} -> + case RetriesLeft > 0 andalso is_stale_connection_error(Reason) of + true -> + gun:close(ConnPid), + start_gun_stream_final(Method, Url, Headers, Body, + TimeoutMs, ConnectTimeoutMs, RetriesLeft - 1, Protocols); + false -> + {error, classify_error(Reason, TimeoutMs)} + end + end; + {error, Reason} -> + {error, classify_connect_error(Reason)} + end; + {error, Reason} -> + {error, classify_error(Reason, TimeoutMs)} + end. + +drain_stream(ConnPid, StreamRef) -> + case gun:await_body(ConnPid, StreamRef, 5000) of + {ok, _Body} -> ok; + {ok, _Body, _Trailers} -> ok; + _ -> ok + end. + +collect_body(ConnPid, StreamRef, Timeout) -> + case gun:await_body(ConnPid, StreamRef, Timeout) of + {ok, Body} -> Body; + {ok, Body, _Trailers} -> Body; + _ -> <<>> + end. + +%% Stream owner loop: services fetch_next requests using gun messages +stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> + receive + {fetch_next, From} -> + handle_fetch_next(From, ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs); + {fetch_start_headers, From} -> + case StartHeaders of + undefined -> + stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, [From | StartWaiters], ZlibCtx, TimeoutMs); + _ -> + From ! {stream_start_headers, normalize_headers_default(StartHeaders)}, + stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end; + {gun_data, ConnPid, StreamRef, nofin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{chunk, DecompData}], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs); + {gun_data, ConnPid, StreamRef, fin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + cleanup_zlib(ZlibCtx), + FinalBuffer = case byte_size(DecompData) > 0 of + true -> Buffer ++ [{chunk, DecompData}, {finished, []}]; + false -> Buffer ++ [{finished, []}] + end, + stream_owner_wait(ConnPid, StreamRef, FinalBuffer, StartHeaders, StartWaiters, undefined, TimeoutMs); + {gun_trailers, ConnPid, StreamRef, Trailers} -> + cleanup_zlib(ZlibCtx), + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{finished, normalize_headers(Trailers)}], + StartHeaders, StartWaiters, undefined, TimeoutMs); + {gun_error, ConnPid, StreamRef, Reason} -> + cleanup_zlib(ZlibCtx), + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, classify_error(Reason, TimeoutMs)}], + StartHeaders, StartWaiters, undefined, TimeoutMs); + {gun_error, ConnPid, Reason} -> + cleanup_zlib(ZlibCtx), + stream_owner_wait(ConnPid, StreamRef, Buffer ++ [{error, classify_error(Reason, TimeoutMs)}], + StartHeaders, StartWaiters, undefined, TimeoutMs); + _Other -> + stream_owner_wait(ConnPid, StreamRef, Buffer, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end. + +handle_fetch_next(From, ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> + %% Buffer empty — wait for next gun message + receive + {gun_data, ConnPid, StreamRef, nofin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + From ! {stream_chunk, DecompData}, + stream_owner_wait(ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs); + {gun_data, ConnPid, StreamRef, fin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + cleanup_zlib(ZlibCtx), + case byte_size(DecompData) > 0 of + true -> + From ! {stream_chunk, DecompData}, + stream_owner_wait(ConnPid, StreamRef, [{finished, []}], StartHeaders, StartWaiters, undefined, TimeoutMs); + false -> + From ! {stream_end, []}, + ok + end; + {gun_trailers, ConnPid, StreamRef, Trailers} -> + cleanup_zlib(ZlibCtx), + From ! {stream_end, normalize_headers(Trailers)}, + ok; + {gun_error, ConnPid, StreamRef, Reason} -> + cleanup_zlib(ZlibCtx), + From ! {stream_error, classify_error(Reason, TimeoutMs)}, + ok; + {gun_error, ConnPid, Reason} -> + cleanup_zlib(ZlibCtx), + From ! {stream_error, classify_error(Reason, TimeoutMs)}, + ok + after TimeoutMs -> + From ! {stream_error, {timed_out, TimeoutMs}}, + stream_owner_wait(ConnPid, StreamRef, [], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end; +handle_fetch_next(From, ConnPid, StreamRef, [Item | Rest], StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) -> + deliver_message(From, Item), + case Item of + {finished, _} -> ok; + {error, _} -> ok; + _ -> stream_owner_wait(ConnPid, StreamRef, Rest, StartHeaders, StartWaiters, ZlibCtx, TimeoutMs) + end. + +deliver_message(From, {chunk, Bin}) -> + From ! {stream_chunk, Bin}; +deliver_message(From, {finished, Headers}) -> + From ! {stream_end, Headers}; +deliver_message(From, {error, Reason}) -> + From ! {stream_error, Reason}. + +fetch_next(OwnerPid, TimeoutMs) -> + MonitorRef = erlang:monitor(process, OwnerPid), + OwnerPid ! {fetch_next, self()}, + receive + {stream_chunk, Bin} -> + erlang:demonitor(MonitorRef, [flush]), + {chunk, Bin}; + {stream_end, Headers} -> + erlang:demonitor(MonitorRef, [flush]), + {finished, Headers}; + {stream_error, Reason} -> + erlang:demonitor(MonitorRef, [flush]), + {error, Reason}; + {'DOWN', MonitorRef, process, OwnerPid, {stream_start_failed, ErrorInfo}} -> + {error, ErrorInfo}; + {'DOWN', MonitorRef, process, OwnerPid, Reason} -> + {error, {process_down, ensure_utf8_binary(io_lib:format("~p", [Reason]))}} + after TimeoutMs -> + erlang:demonitor(MonitorRef, [flush]), + {error, {timed_out, TimeoutMs}} + end. + +fetch_start_headers(OwnerPid, TimeoutMs) -> + MonitorRef = erlang:monitor(process, OwnerPid), + OwnerPid ! {fetch_start_headers, self()}, + receive + {stream_start_headers, Headers} -> + erlang:demonitor(MonitorRef, [flush]), + {ok, Headers}; + {'DOWN', MonitorRef, process, OwnerPid, {stream_start_failed, ErrorInfo}} -> + {error, ErrorInfo}; + {'DOWN', MonitorRef, process, OwnerPid, Reason} -> + {error, {process_down, ensure_utf8_binary(io_lib:format("~p", [Reason]))}} + after TimeoutMs -> + erlang:demonitor(MonitorRef, [flush]), + {error, {timed_out, TimeoutMs}} + end. + +normalize_headers_default(undefined) -> []; +normalize_headers_default(Headers) -> Headers. + +%% ============================================================================ +%% Message-based streaming +%% ============================================================================ + +request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs, + ConnectTimeoutMs, AutoRedirect, Protocols) -> + NHeaders = maybe_add_accept_encoding(to_gun_headers(Headers)), + CallerPid = self(), + TranslatorPid = spawn(fun() -> + translator_init(Method, Url, NHeaders, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) + end), + %% Generate a unique string ID for this stream + StringId = translator_ref_to_string(TranslatorPid), + store_ref_mapping(StringId, TranslatorPid), + {ok, StringId}. + +translator_init(Method, Url, Headers, Body, CallerPid, TimeoutMs, ConnectTimeoutMs, AutoRedirect, Protocols) -> + case start_gun_stream(Method, Url, Headers, Body, TimeoutMs, ConnectTimeoutMs, AutoRedirect, 0, Protocols) of + {ok, ConnPid, StreamRef, _Status, RespHeaders} -> + StringId = get_my_string_id(), + %% Store ConnPid and StreamRef for cancellation + store_cancel_info(StringId, ConnPid, StreamRef), + NormHeaders = normalize_headers(RespHeaders), + CallerPid ! {http, {StringId, stream_start, NormHeaders}}, + ZlibCtx = maybe_init_stream_zlib(RespHeaders), + translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs); + {error, Reason} -> + StringId = get_my_string_id(), + CallerPid ! {http, {StringId, {error, Reason}}}, + ok + end. + +translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) -> + receive + {gun_data, ConnPid, StreamRef, nofin, Data} -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + CallerPid ! {http, {StringId, stream, DecompData}}, + translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs); + {gun_data, ConnPid, StreamRef, fin, Data} -> + case byte_size(Data) > 0 of + true -> + DecompData = case ZlibCtx of + undefined -> Data; + _ -> decompress_chunk(ZlibCtx, Data) + end, + CallerPid ! {http, {StringId, stream, DecompData}}; + false -> ok + end, + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, stream_end, []}}, + ok; + {gun_trailers, ConnPid, StreamRef, Trailers} -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, stream_end, normalize_headers(Trailers)}}, + ok; + {gun_error, ConnPid, StreamRef, Reason} -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, {error, classify_error(Reason, TimeoutMs)}}}, + ok; + {gun_error, ConnPid, Reason} -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, {error, classify_error(Reason, TimeoutMs)}}}, + ok; + cancel -> + cleanup_zlib(ZlibCtx), + gun:cancel(ConnPid, StreamRef), + ok; + _Other -> + translator_loop(ConnPid, StreamRef, CallerPid, StringId, ZlibCtx, TimeoutMs) + after TimeoutMs -> + cleanup_zlib(ZlibCtx), + CallerPid ! {http, {StringId, {error, {timed_out, TimeoutMs}}}}, + ok + end. + +get_my_string_id() -> + translator_ref_to_string(self()). + +translator_ref_to_string(Pid) -> + ensure_utf8_binary(io_lib:format("~p", [Pid])). + +store_cancel_info(StringId, ConnPid, StreamRef) -> + ets:insert(?REF_MAPPING_TABLE, {{cancel, StringId}, {ConnPid, StreamRef}}). + +%% ============================================================================ +%% Cancellation +%% ============================================================================ + +cancel_stream(RequestId) -> + case ets:lookup(?REF_MAPPING_TABLE, {cancel, RequestId}) of + [{{cancel, _}, {ConnPid, StreamRef}}] -> + gun:cancel(ConnPid, StreamRef), + ok; + [] -> + ok + end. + +cancel_stream_by_string(StringId) -> + case lookup_ref_by_string(StringId) of + {some, TranslatorPid} when is_pid(TranslatorPid) -> + TranslatorPid ! cancel, + remove_ref_mapping(StringId), + nil; + _ -> + %% Try direct cancel via stored info + case ets:lookup(?REF_MAPPING_TABLE, {cancel, StringId}) of + [{{cancel, _}, {ConnPid, StreamRef}}] -> + gun:cancel(ConnPid, StreamRef), + ets:delete(?REF_MAPPING_TABLE, {cancel, StringId}), + remove_ref_mapping(StringId), + nil; + [] -> + nil + end + end. + +%% ============================================================================ +%% Message decoding for selectors +%% ============================================================================ + +receive_stream_message(TimeoutMs) -> + receive + {http, {StringId, stream_start, Headers}} -> + {stream_start, StringId, Headers}; + {http, {StringId, stream, Data}} -> + {chunk, StringId, Data}; + {http, {StringId, stream_end, Headers}} -> + {stream_end, StringId, Headers}; + {http, {StringId, {error, ErrorInfo}}} -> + {stream_error, StringId, ErrorInfo} + after TimeoutMs -> + timeout + end. + +decode_stream_message_for_selector({http, InnerMessage}) -> + case InnerMessage of + {StringId, stream_start, Headers} -> + {stream_start, StringId, Headers}; + {StringId, stream, Data} -> + {chunk, StringId, Data}; + {StringId, stream_end, Headers} -> + remove_ref_mapping(StringId), + {stream_end, StringId, Headers}; + {StringId, {error, ErrorInfo}} -> + remove_ref_mapping(StringId), + {stream_error, StringId, ErrorInfo}; + _ -> + error(badarg) + end. + +%% ============================================================================ +%% Header normalization +%% ============================================================================ + +normalize_headers(Headers) when is_list(Headers) -> + lists:map(fun normalize_header_tuple/1, Headers); +normalize_headers(_) -> + []. + +normalize_header_tuple({Name, Value}) -> + {to_binary(Name), to_binary(Value)}; +normalize_header_tuple(_) -> + {<<"">>, <<"">>}. + +%% ============================================================================ +%% Transport configuration +%% ============================================================================ + +configure_transport(Config) -> + %% Config is a Gleam opaque type = Erlang tuple {transport_config, F1, F2, ...} + ets:insert(dream_http_client_transport_config, {config, Config}), + LogLevel = map_log_level(element(15, Config)), + logger:set_module_level([dream_http_conn_manager, dream_http_shim], LogLevel), + nil. + +map_log_level(log_debug) -> debug; +map_log_level(log_info) -> info; +map_log_level(log_warning) -> warning; +map_log_level(log_error) -> error; +map_log_level(log_none) -> none. + +get_transport_config() -> + case ets:lookup(dream_http_client_transport_config, config) of + [{config, Config}] -> + %% Gleam TransportConfig tuple: {transport_config, MaxConn, IdleTimeout, DefaultConnTimeout, + %% DomainLookupTimeout, TlsHandshakeTimeout, Retry, RetryTimeout, Keepalive, + %% KeepaliveTolerance, MaxConcurrentStreams, InitConnWindowSize, InitStreamWindowSize, + %% ClosingTimeout} + #{max_connections => element(2, Config), + idle_timeout => element(3, Config), + connect_timeout => element(4, Config), + domain_lookup_timeout => element(5, Config), + tls_handshake_timeout => element(6, Config), + retry => element(7, Config), + retry_timeout => element(8, Config), + keepalive => element(9, Config), + keepalive_tolerance => element(10, Config), + max_concurrent_streams => element(11, Config), + initial_connection_window_size => element(12, Config), + initial_stream_window_size => element(13, Config), + closing_timeout => element(14, Config)}; + [] -> + #{max_connections => 50, + idle_timeout => 60000, + connect_timeout => 15000, + domain_lookup_timeout => 5000, + tls_handshake_timeout => 10000, + retry => 3, + retry_timeout => 1000, + keepalive => 30000, + keepalive_tolerance => 3, + max_concurrent_streams => 100, + initial_connection_window_size => 65535, + initial_stream_window_size => 65535, + closing_timeout => 15000} + end. + +%% ============================================================================ +%% Connection management helpers +%% ============================================================================ + +get_or_open_connection(Scheme, Host, Port, GunOpts) -> + TransportConfig = get_transport_config(), + FullOpts = maps:merge(TransportConfig, GunOpts), + dream_http_conn_manager:ensure_connection(Scheme, Host, Port, FullOpts). + +build_gun_opts(ConnectTimeoutMs, Protocols) -> + Opts = #{connect_timeout => ConnectTimeoutMs}, + case Protocols of + default -> Opts; + _ -> Opts#{protocols => Protocols} + end. + +send_request(ConnPid, Method, PathQs, Headers, Body) -> + MethodBin = method_atom_to_binary(Method), + ActualBody = case Body of + undefined -> <<>>; + _ -> Body + end, + gun:request(ConnPid, MethodBin, PathQs, Headers, ActualBody). + +%% ============================================================================ +%% URL parsing +%% ============================================================================ + +parse_url(Url) when is_binary(Url) -> + parse_url(binary_to_list(Url)); +parse_url(Url) when is_list(Url) -> + case uri_string:parse(Url) of + #{scheme := SchemeStr, host := Host} = Parsed -> + Scheme = case SchemeStr of + "https" -> https; + "http" -> http; + <<"https">> -> https; + <<"http">> -> http; + _ -> http + end, + Port = case maps:get(port, Parsed, undefined) of + undefined -> + case Scheme of + https -> 443; + http -> 80 + end; + P -> P + end, + Path = case maps:get(path, Parsed, "/") of + "" -> "/"; + <<>> -> "/"; + P2 -> to_list(P2) + end, + Query = case maps:get(query, Parsed, undefined) of + undefined -> ""; + Q -> to_list(Q) + end, + PathQs = case Query of + "" -> Path; + _ -> Path ++ "?" ++ Query + end, + HostStr = to_list(Host), + {ok, Scheme, HostStr, Port, to_binary(PathQs)}; + {error, Reason, _} -> + {error, Reason}; + _ -> + {error, invalid_url} + end. + +%% ============================================================================ +%% Redirect helpers +%% ============================================================================ + +is_redirect(301) -> true; +is_redirect(302) -> true; +is_redirect(303) -> true; +is_redirect(307) -> true; +is_redirect(308) -> true; +is_redirect(_) -> false. + +get_location(Headers) -> + case lists:keyfind(<<"location">>, 1, Headers) of + {_, Location} -> {ok, Location}; + false -> + %% Try case-insensitive + Result = lists:foldl(fun({K, V}, Acc) -> + case Acc of + error -> + case string:lowercase(to_list(K)) of + "location" -> {ok, V}; + _ -> error + end; + Found -> Found + end + end, error, Headers), + Result + end. + +redirect_method(303, _) -> <<"GET">>; +redirect_method(301, _) -> <<"GET">>; +redirect_method(302, _) -> <<"GET">>; +redirect_method(_, Method) -> Method. + +redirect_body(303, _) -> <<>>; +redirect_body(301, _) -> <<>>; +redirect_body(302, _) -> <<>>; +redirect_body(_, Body) -> Body. + +resolve_redirect_url(Location, OriginalUrl) -> + LocStr = to_list(Location), + case uri_string:parse(LocStr) of + #{scheme := _} -> + Location; + _ -> + OrigStr = to_list(OriginalUrl), + case uri_string:parse(OrigStr) of + #{scheme := S, host := H} = Parsed -> + Port = maps:get(port, Parsed, undefined), + Base = case Port of + undefined -> io_lib:format("~s://~s", [S, H]); + _ -> io_lib:format("~s://~s:~B", [S, H, Port]) + end, + iolist_to_binary([Base, LocStr]); + _ -> + Location + end + end. + +status_reason(200) -> <<"OK">>; +status_reason(201) -> <<"Created">>; +status_reason(204) -> <<"No Content">>; +status_reason(301) -> <<"Moved Permanently">>; +status_reason(302) -> <<"Found">>; +status_reason(303) -> <<"See Other">>; +status_reason(307) -> <<"Temporary Redirect">>; +status_reason(308) -> <<"Permanent Redirect">>; +status_reason(400) -> <<"Bad Request">>; +status_reason(401) -> <<"Unauthorized">>; +status_reason(403) -> <<"Forbidden">>; +status_reason(404) -> <<"Not Found">>; +status_reason(422) -> <<"Unprocessable Entity">>; +status_reason(429) -> <<"Too Many Requests">>; +status_reason(500) -> <<"Internal Server Error">>; +status_reason(502) -> <<"Bad Gateway">>; +status_reason(503) -> <<"Service Unavailable">>; +status_reason(_) -> <<"Unknown">>. + +%% ============================================================================ +%% Method conversion +%% ============================================================================ + +to_method_atom(Method) when is_atom(Method) -> Method; +to_method_atom(Method) when is_binary(Method) -> + case string:lowercase(binary_to_list(Method)) of + "get" -> get; + "post" -> post; + "put" -> put; + "delete" -> delete; + "patch" -> patch; + "head" -> head; + "options" -> options; + Other -> list_to_atom(Other) + end. + +method_atom_to_binary(get) -> <<"GET">>; +method_atom_to_binary(post) -> <<"POST">>; +method_atom_to_binary(put) -> <<"PUT">>; +method_atom_to_binary(delete) -> <<"DELETE">>; +method_atom_to_binary(patch) -> <<"PATCH">>; +method_atom_to_binary(head) -> <<"HEAD">>; +method_atom_to_binary(options) -> <<"OPTIONS">>; +method_atom_to_binary(Other) -> + list_to_binary(string:to_upper(atom_to_list(Other))). + +%% ============================================================================ +%% Header conversion +%% ============================================================================ + +to_gun_headers(Hs) when is_list(Hs) -> + lists:map(fun({K, V}) -> {to_binary(K), to_binary(V)} end, Hs); +to_gun_headers(Other) -> + Other. + +maybe_add_accept_encoding(Headers) -> + HasAcceptEncoding = lists:any( + fun({K, _V}) -> + string:lowercase(binary_to_list(K)) =:= "accept-encoding" + end, Headers), + case HasAcceptEncoding of + true -> Headers; + false -> Headers ++ [{<<"accept-encoding">>, <<"gzip, deflate">>}] + end. + +%% ============================================================================ +%% Decompression +%% ============================================================================ + +get_content_encoding(Headers) -> + Val = lists:foldl( + fun({K, V}, Acc) -> + case string:lowercase(to_list(K)) of + "content-encoding" -> string:trim(string:lowercase(to_list(V))); + _ -> Acc + end + end, "", Headers), + Val. + +remove_header(Name, Headers) -> + NormName = string:lowercase(to_list(Name)), + lists:filter( + fun({K, _V}) -> string:lowercase(to_list(K)) =/= NormName end, + Headers). + +maybe_decompress_response(Body, Headers) -> + Encoding = get_content_encoding(Headers), + case Encoding of + "gzip" -> + try_decompress(fun() -> zlib:gunzip(iolist_to_binary(Body)) end, + Body, Headers); + "deflate" -> + try_decompress(fun() -> zlib:uncompress(iolist_to_binary(Body)) end, + Body, Headers); + "identity" -> {Body, Headers}; + "" -> {Body, Headers}; + Other -> + logger:warning("unrecognized Content-Encoding, passing through raw bytes: ~s", + [to_binary(Other)]), + {Body, Headers} + end. + +try_decompress(DecompressFn, OrigBody, Headers) -> + try + Decompressed = DecompressFn(), + {Decompressed, remove_header("content-encoding", Headers)} + catch + _:_ -> + logger:warning("decompression failed, passing through raw bytes", []), + {OrigBody, Headers} + end. + +detect_stream_encoding(Headers) -> + Encoding = get_content_encoding(Headers), + case Encoding of + "gzip" -> {gzip, 31}; + "deflate" -> {deflate, 15}; + "" -> none; + "identity" -> none; + Other -> + logger:warning("unrecognized Content-Encoding for stream, passing through raw bytes: ~s", + [to_binary(Other)]), + none + end. + +init_zlib_context(WindowBits) -> + Z = zlib:open(), + ok = zlib:inflateInit(Z, WindowBits), + Z. + +maybe_init_stream_zlib(Headers) -> + case detect_stream_encoding(Headers) of + {_Enc, WindowBits} -> init_zlib_context(WindowBits); + none -> undefined + end. + +decompress_chunk(ZlibCtx, Chunk) -> + iolist_to_binary(zlib:inflate(ZlibCtx, Chunk)). + +cleanup_zlib(undefined) -> ok; +cleanup_zlib(ZlibCtx) -> + try zlib:inflateEnd(ZlibCtx) catch _:_ -> ok end, + try zlib:close(ZlibCtx) catch _:_ -> ok end, + ok. + +%% ============================================================================ +%% Stale connection detection +%% ============================================================================ + +is_stale_connection_error({stream_error, closed}) -> true; +is_stale_connection_error({stream_error, {goaway, _, _, _}}) -> true; +is_stale_connection_error({closed, _}) -> true; +is_stale_connection_error(closed) -> true; +is_stale_connection_error(_) -> false. + +%% ============================================================================ +%% Error classification (returns tagged tuples for Gleam decoders) +%% ============================================================================ + +%% 3-tuple GOAWAY clause MUST come before generic 3-tuple clause. +%% Gun can return {stream_error, {goaway, ...}, HumanReadable} as a 3-tuple. +%% Without this clause, the generic {stream_error, Code, HumanReadable} would +%% match with Code = {goaway, ...} (a tuple), and atom_to_binary would crash. +classify_error({stream_error, {goaway, LastStreamId, Code, DebugData}, _HumanReadable}, _TimeoutMs) -> + {goaway, atom_to_binary(Code, utf8), LastStreamId, ensure_utf8_binary(DebugData)}; +classify_error({stream_error, {goaway, LastStreamId, Code, DebugData}}, _TimeoutMs) -> + {goaway, atom_to_binary(Code, utf8), LastStreamId, ensure_utf8_binary(DebugData)}; +classify_error({stream_error, Code, HumanReadable}, _TimeoutMs) when is_atom(Code) -> + {stream_reset, atom_to_binary(Code, utf8), ensure_utf8_binary(HumanReadable)}; +classify_error({stream_error, Code}, _TimeoutMs) when is_atom(Code) -> + {stream_reset, atom_to_binary(Code, utf8), atom_to_binary(Code, utf8)}; +classify_error({connection_error, Code, HumanReadable}, _TimeoutMs) -> + {connection_error, atom_to_binary(Code, utf8), ensure_utf8_binary(HumanReadable)}; +classify_error(closed, _TimeoutMs) -> + {remote_closed}; +classify_error({closed, _}, _TimeoutMs) -> + {remote_closed}; +classify_error(timeout, TimeoutMs) -> + {timed_out, TimeoutMs}; +classify_error(Reason, _TimeoutMs) -> + {unexpected, ensure_utf8_binary(io_lib:format("~p", [Reason]))}. + +classify_connect_error(econnrefused) -> + {connect_failed, <<"Connection refused">>}; +classify_connect_error(connect_timeout) -> + {connect_failed, <<"Connection timed out">>}; +classify_connect_error(timeout) -> + {connect_failed, <<"Connection timed out">>}; +classify_connect_error(nxdomain) -> + {connect_failed, <<"DNS resolution failed (NXDOMAIN)">>}; +classify_connect_error(Reason) -> + {connect_failed, ensure_utf8_binary(io_lib:format("~p", [Reason]))}. + +%% ============================================================================ +%% Binary/string conversion +%% ============================================================================ + +to_binary(Bin) when is_binary(Bin) -> Bin; +to_binary(List) when is_list(List) -> + unicode:characters_to_binary(List); +to_binary(Other) -> + ensure_utf8_binary(io_lib:format("~p", [Other])). + +to_list(S) when is_binary(S) -> unicode:characters_to_list(S); +to_list(S) when is_list(S) -> S; +to_list(Other) -> io_lib:format("~p", [Other]). + +ensure_utf8_binary(Bin) when is_binary(Bin) -> + case unicode:characters_to_binary(Bin, utf8, utf8) of + Result when is_binary(Result) -> Result; + _ -> + case unicode:characters_to_binary(Bin, latin1, utf8) of + Result2 when is_binary(Result2) -> Result2; + _ -> iolist_to_binary(io_lib:format("~w", [Bin])) + end + end; +ensure_utf8_binary(List) when is_list(List) -> + case unicode:characters_to_binary(List) of + Result when is_binary(Result) -> Result; + _ -> iolist_to_binary(io_lib:format("~w", [List])) + end; +ensure_utf8_binary(Other) -> + iolist_to_binary(io_lib:format("~w", [Other])). + +%% ============================================================================ +%% ETS Functions +%% ============================================================================ + +ets_table_exists(Name) -> + try + NameAtom = binary_to_atom(Name, utf8), + case ets:info(NameAtom) of + undefined -> false; + _ -> true + end + catch + error:badarg -> false + end. + +ets_new(Name, Options) -> + NameAtom = binary_to_atom(Name, utf8), + ets:new(NameAtom, Options). + +ets_insert(TableName, Key, Recorder, RecordedRequest, Headers, Chunks, LastChunkTime) -> + TableAtom = binary_to_atom(TableName, utf8), + Value = {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}, + ets:insert(TableAtom, {Key, Value}), + nil. + +ets_lookup(TableName, Key) -> + try + TableAtom = binary_to_atom(TableName, utf8), + case ets:lookup(TableAtom, Key) of + [{Key, {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}}] -> + State = {message_stream_recorder_state, + Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}, + {some, State}; + [] -> none + end + catch + error:badarg -> none + end. + +ets_delete(TableName, Key) -> + try + TableAtom = binary_to_atom(TableName, utf8), + ets:delete(TableAtom, Key) + catch + error:badarg -> false + end. + +%% ============================================================================ +%% Request ID Mapping +%% ============================================================================ + +store_ref_mapping(StringId, Ref) -> + ets:insert(?REF_MAPPING_TABLE, {StringId, Ref}), + ets:insert(?REF_MAPPING_TABLE, {Ref, StringId}), + ok. + +lookup_ref_by_string(StringId) -> + case ets:lookup(?REF_MAPPING_TABLE, StringId) of + [{StringId, Ref}] -> {some, Ref}; + [] -> none + end. + +remove_ref_mapping(StringId) -> + case lookup_ref_by_string(StringId) of + {some, Ref} -> + ets:delete(?REF_MAPPING_TABLE, StringId), + ets:delete(?REF_MAPPING_TABLE, Ref), + ets:delete(?REF_MAPPING_TABLE, {cancel, StringId}), + ok; + none -> + ok + end. + + diff --git a/modules/http_client/src/dream_http_client/dream_httpc_shim.erl b/modules/http_client/src/dream_http_client/dream_httpc_shim.erl deleted file mode 100644 index db3bf81..0000000 --- a/modules/http_client/src/dream_http_client/dream_httpc_shim.erl +++ /dev/null @@ -1,1160 +0,0 @@ --module(dream_httpc_shim). - --export([request_stream/6, fetch_next/2, fetch_start_headers/2, request_stream_messages/6, - cancel_stream/1, cancel_stream_by_string/1, receive_stream_message/1, - decode_stream_message_for_selector/1, normalize_headers/1, request_sync/5, - ets_table_exists/1, ets_new/2, ets_insert/7, ets_lookup/2, ets_delete/2]). - -%% @doc Start a streaming HTTP request with pull-based chunk retrieval -%% -%% Initiates a streaming HTTP request using Erlang's `httpc` library in continuous -%% streaming mode. Creates an owner process that manages the stream and services -%% `fetch_next` requests. This function returns immediately; chunks are retrieved -%% by calling `fetch_next` with the returned owner PID. -%% -%% ## Parameters -%% -%% - `Method`: HTTP method atom (`get`, `post`, `put`, `delete`, `patch`, `head`, etc.) -%% - `Url`: Full request URL as a string (e.g., `"https://api.example.com/path"`) -%% - `Headers`: List of `{Key, Value}` tuples where both are strings or binaries -%% - `Body`: Request body as a binary (empty binary `<<>>` for requests without body) -%% - `Receiver`: Process ID (unused, kept for API compatibility) -%% - `TimeoutMs`: Request timeout in milliseconds -%% -%% ## Returns -%% -%% `{ok, OwnerPid}` where `OwnerPid` is the process handling the stream. Use this -%% PID with `fetch_next` to retrieve chunks. -%% -%% ## Examples -%% -%% ```erlang -%% {ok, Owner} = request_stream(get, "https://api.example.com/data", [], <<>>, self(), 30000), -%% {chunk, Data} = fetch_next(Owner, 5000), -%% ``` -%% -%% ## Notes -%% -%% - Returns immediately; HTTP errors are detected asynchronously via `fetch_next` -%% - The owner process will exit if the HTTP request fails to start -%% - `fetch_next` will detect the dead process and return an error -%% - Ensures `ssl` and `inets` applications are started before making requests -%% - Configures httpc with streaming-optimized settings (no pipelining, high session cap) -request_stream(Method, Url, Headers, Body, _Receiver, TimeoutMs) -> - ok = ensure_started(ssl), - ok = ensure_started(inets), - ok = configure_httpc(), - - NUrl = to_list(Url), - NHeaders = maybe_add_accept_encoding(to_headers(Headers)), - Req = build_req(NUrl, NHeaders, Body), - Owner = spawn(fun() -> stream_owner_loop(Method, Req, NUrl, TimeoutMs) end), - {ok, Owner}. - -%% @doc Fetch the next chunk from a streaming HTTP request -%% -%% Retrieves the next chunk of data from an active streaming request. This function -%% implements a pull-based model where chunks are requested on-demand rather than -%% being pushed to a mailbox. The owner process buffers chunks internally and delivers -%% them when requested. -%% -%% ## Parameters -%% -%% - `OwnerPid`: The owner process PID returned from `request_stream` -%% - `TimeoutMs`: Timeout in milliseconds (0 for non-blocking, -1 for infinite wait) -%% -%% ## Returns -%% -%% - `{chunk, Bin}`: Next chunk of response data as a binary -%% - `{finished, Headers}`: Stream completed successfully with trailing headers -%% - `{error, Reason}`: Error occurred (connection failure, timeout, owner process died, etc.) -%% -%% ## Examples -%% -%% ```erlang -%% {ok, Owner} = request_stream(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% case fetch_next(Owner, 5000) of -%% {chunk, Data} -> process_chunk(Data); -%% {finished, Headers} -> process_complete(Headers); -%% {error, Reason} -> handle_error(Reason) -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Blocks until a chunk is available, timeout expires, or an error occurs -%% - Monitors the owner process; returns error if owner dies -%% - Owner process buffers chunks internally for efficient delivery -%% - After `{finished, Headers}` or `{error, Reason}`, the stream is complete -%% - Timeout errors are returned as `{error, timeout}` -fetch_next(OwnerPid, TimeoutMs) -> - MonitorRef = erlang:monitor(process, OwnerPid), - OwnerPid ! {fetch_next, self()}, - receive - {stream_chunk, Bin} -> - erlang:demonitor(MonitorRef, [flush]), - {chunk, Bin}; - {stream_end, Headers} -> - erlang:demonitor(MonitorRef, [flush]), - {finished, Headers}; - {stream_error, Reason} -> - erlang:demonitor(MonitorRef, [flush]), - {error, Reason}; - {'DOWN', MonitorRef, process, OwnerPid, Reason} -> - %% Owner process died - extract the real error - {error, format_exit_reason(Reason)} - after TimeoutMs -> - erlang:demonitor(MonitorRef, [flush]), - {error, timeout} - end. - -%% @doc Fetch the response headers from stream_start -%% -%% Returns the normalized headers received in the initial `stream_start` message. -%% This is used by the recorder to persist response headers for streaming recordings. -%% -%% Note: httpc's streamed response status code is not included in stream_start. -fetch_start_headers(OwnerPid, TimeoutMs) -> - MonitorRef = erlang:monitor(process, OwnerPid), - OwnerPid ! {fetch_start_headers, self()}, - receive - {stream_start_headers, Headers} -> - erlang:demonitor(MonitorRef, [flush]), - {ok, Headers}; - {'DOWN', MonitorRef, process, OwnerPid, Reason} -> - {error, format_exit_reason(Reason)} - after TimeoutMs -> - erlang:demonitor(MonitorRef, [flush]), - {error, timeout} - end. - -%% Stream owner process: starts httpc in continuous mode and services fetch_next requests -stream_owner_loop(Method, Req, _Url, TimeoutMs) -> - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, 15000}, {autoredirect, true}], - Opts = [{stream, self}, {sync, false}], - case httpc:request(Method, Req, HttpOpts, Opts) of - {ok, RequestId} -> - stream_owner_wait(RequestId, [], undefined, [], undefined); - Error -> - exit({stream_start_failed, Error}) - end. - -%% Wait for either a fetch_next request or internal http messages (buffered) -%% State: -%% Buffer - queued {chunk, Bin}/{finished, Headers}/{error, Reason} -%% StartHeaders - normalized headers from stream_start (or undefined) -%% StartWaiters - callers waiting for stream_start headers -%% ZlibCtx - zlib inflate context for decompression (undefined if none) -stream_owner_wait(RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx) -> - receive - {fetch_next, From} -> - handle_fetch_next(From, RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx); - {fetch_start_headers, From} -> - case StartHeaders of - undefined -> - stream_owner_wait(RequestId, Buffer, StartHeaders, [From | StartWaiters], ZlibCtx); - _ -> - From ! {stream_start_headers, normalize_headers_default(StartHeaders)}, - stream_owner_wait(RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx) - end; - {http, {RequestId, stream, Bin}} -> - DecompBin = case ZlibCtx of - undefined -> Bin; - _ -> decompress_chunk(ZlibCtx, Bin) - end, - stream_owner_wait(RequestId, Buffer ++ [{chunk, DecompBin}], StartHeaders, StartWaiters, ZlibCtx); - {http, {RequestId, stream_start, Headers}} -> - Norm = normalize_headers(Headers), - lists:foreach(fun(W) -> W ! {stream_start_headers, Norm} end, StartWaiters), - NewZlib = maybe_init_stream_zlib(Headers), - stream_owner_wait(RequestId, Buffer, Norm, [], NewZlib); - {http, {RequestId, stream_start, Headers, _Pid}} -> - Norm = normalize_headers(Headers), - lists:foreach(fun(W) -> W ! {stream_start_headers, Norm} end, StartWaiters), - NewZlib = maybe_init_stream_zlib(Headers), - stream_owner_wait(RequestId, Buffer, Norm, [], NewZlib); - {http, {RequestId, stream_end, Headers}} -> - cleanup_zlib(ZlibCtx), - stream_owner_wait(RequestId, - Buffer ++ [{finished, normalize_headers(Headers)}], - StartHeaders, - StartWaiters, - undefined); - {http, {RequestId, {error, Reason}}} -> - cleanup_zlib(ZlibCtx), - stream_owner_wait(RequestId, Buffer ++ [{error, format_error(Reason)}], StartHeaders, StartWaiters, undefined); - {http, {RequestId, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}}} -> - cleanup_zlib(ZlibCtx), - ErrorMsg = format_complete_response_error(StatusCode, ReasonPhrase, Body), - stream_owner_wait(RequestId, Buffer ++ [{error, ErrorMsg}], StartHeaders, StartWaiters, undefined); - _Other -> - stream_owner_wait(RequestId, Buffer, StartHeaders, StartWaiters, ZlibCtx) - end. - -%% Handle a fetch_next request from the client -handle_fetch_next(From, RequestId, [], StartHeaders, StartWaiters, ZlibCtx) -> - %% Buffer empty - fetch next message from stream - case stream_owner_next_message(RequestId, ZlibCtx) of - {start, _Hs, NewZlib} -> - Norm = normalize_headers(_Hs), - lists:foreach(fun(W) -> W ! {stream_start_headers, Norm} end, StartWaiters), - handle_fetch_next_after_start(From, RequestId, Norm, [], NewZlib); - {Msg, NewZlib} -> - deliver_message(From, Msg, RequestId, StartHeaders, StartWaiters, NewZlib) - end; -handle_fetch_next(From, RequestId, [Item | Rest], StartHeaders, StartWaiters, ZlibCtx) -> - deliver_message(From, Item, RequestId, Rest, StartHeaders, StartWaiters, ZlibCtx). - -%% Handle fetch_next after receiving stream_start (headers) -handle_fetch_next_after_start(From, RequestId, StartHeaders, StartWaiters, ZlibCtx) -> - case stream_owner_next_message(RequestId, ZlibCtx) of - {{chunk, Bin}, NewZlib} -> - From ! {stream_chunk, Bin}, - stream_owner_wait(RequestId, [], StartHeaders, StartWaiters, NewZlib); - {{finished, Headers}, _NewZlib} -> - From ! {stream_end, Headers}, - ok; - {{error, Reason}, _NewZlib} -> - From ! {stream_error, Reason}, - ok - end. - -%% Deliver a message to the client (from live stream, with ZlibCtx) -deliver_message(From, {chunk, Bin}, RequestId, StartHeaders, StartWaiters, ZlibCtx) -> - From ! {stream_chunk, Bin}, - stream_owner_wait(RequestId, [], StartHeaders, StartWaiters, ZlibCtx); -deliver_message(From, {finished, Headers}, _RequestId, _StartHeaders, _StartWaiters, _ZlibCtx) -> - From ! {stream_end, Headers}, - ok; -deliver_message(From, {error, Reason}, _RequestId, _StartHeaders, _StartWaiters, _ZlibCtx) -> - From ! {stream_error, Reason}, - ok. - -%% Deliver a message to the client (from buffer, with ZlibCtx) -deliver_message(From, {chunk, Bin}, RequestId, Rest, StartHeaders, StartWaiters, ZlibCtx) -> - From ! {stream_chunk, Bin}, - stream_owner_wait(RequestId, Rest, StartHeaders, StartWaiters, ZlibCtx); -deliver_message(From, - {finished, Headers}, - _RequestId, - _Rest, - _StartHeaders, - _StartWaiters, - _ZlibCtx) -> - From ! {stream_end, Headers}, - ok; -deliver_message(From, {error, Reason}, _RequestId, _Rest, _StartHeaders, _StartWaiters, _ZlibCtx) -> - From ! {stream_error, Reason}, - ok. - -normalize_headers_default(undefined) -> - []; -normalize_headers_default(Headers) -> - Headers. - -%% Wait for the next HTTP message from httpc. -%% Returns {start, Headers, NewZlibCtx} | {{chunk|finished|error, Data}, NewZlibCtx} -stream_owner_next_message(RequestId, ZlibCtx) -> - receive - {http, {RequestId, stream_start, Headers}} -> - NewZlib = maybe_init_stream_zlib(Headers), - {start, Headers, NewZlib}; - {http, {RequestId, stream_start, Headers, _Pid}} -> - NewZlib = maybe_init_stream_zlib(Headers), - {start, Headers, NewZlib}; - {http, {RequestId, stream, Bin}} -> - DecompBin = case ZlibCtx of - undefined -> Bin; - _ -> decompress_chunk(ZlibCtx, Bin) - end, - {{chunk, DecompBin}, ZlibCtx}; - {http, {RequestId, stream_end, Headers}} -> - cleanup_zlib(ZlibCtx), - {{finished, normalize_headers(Headers)}, undefined}; - {http, {RequestId, {error, Reason}}} -> - cleanup_zlib(ZlibCtx), - {{error, format_error(Reason)}, undefined}; - {http, {RequestId, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}}} -> - cleanup_zlib(ZlibCtx), - {{error, format_complete_response_error(StatusCode, ReasonPhrase, Body)}, undefined}; - _Other -> - stream_owner_next_message(RequestId, ZlibCtx) - end. - -%% Ensure an Erlang application is started -ensure_started(App) -> - case application:ensure_all_started(App) of - {ok, _} -> - ok; - {error, {already_started, _}} -> - ok; - {error, _Reason} -> - ok - end. - -%% Configure httpc with appropriate settings for streaming -configure_httpc() -> - %% Increase parallelism and avoid head-of-line blocking with streaming - %% - Disable HTTP pipelining so long-lived streams don't block queued requests - %% - Raise session cap so concurrent streams can use separate connections - %% - Keep-alive tuning to allow reuse for non-streaming while not limiting concurrency - ok = - httpc:set_options([{max_sessions, 100}, - {max_pipeline_length, 0}, - {keep_alive_timeout, 60000}, - {max_keep_alive_length, 100}], - default), - ok. - -%% Convert various types to string lists -to_list(S) when is_binary(S) -> - unicode:characters_to_list(S); -to_list(S) when is_list(S) -> - S; -to_list(Other) -> - io_lib:format("~p", [Other]). - -%% Convert headers to the format expected by httpc -to_headers(Hs) when is_list(Hs) -> - lists:map(fun({K, V}) -> {to_list(K), to_list(V)} end, Hs); -to_headers(Other) -> - Other. - -%% Inject Accept-Encoding: gzip, deflate unless the user already set one -maybe_add_accept_encoding(Headers) -> - HasAcceptEncoding = lists:any( - fun({K, _V}) -> - string:lowercase(to_list(K)) =:= "accept-encoding" - end, Headers), - case HasAcceptEncoding of - true -> Headers; - false -> Headers ++ [{"Accept-Encoding", "gzip, deflate"}] - end. - -%% Get Content-Encoding header value (lowercase, trimmed) -get_content_encoding(Headers) -> - Val = lists:foldl( - fun({K, V}, Acc) -> - case string:lowercase(to_list(K)) of - "content-encoding" -> string:trim(string:lowercase(to_list(V))); - _ -> Acc - end - end, "", Headers), - Val. - -%% Remove a header by name (case-insensitive) -remove_header(Name, Headers) -> - NormName = string:lowercase(Name), - lists:filter( - fun({K, _V}) -> string:lowercase(to_list(K)) =/= NormName end, - Headers). - -%% Decompress a sync response body based on Content-Encoding. -%% Returns {DecompressedBody, CleanedHeaders}. -maybe_decompress_response(Body, Headers) -> - Encoding = get_content_encoding(Headers), - case Encoding of - "gzip" -> - try_decompress(fun() -> zlib:gunzip(iolist_to_binary(Body)) end, - Body, Headers); - "deflate" -> - try_decompress(fun() -> zlib:uncompress(iolist_to_binary(Body)) end, - Body, Headers); - "identity" -> - {Body, Headers}; - "" -> - {Body, Headers}; - Other -> - io:format("WARNING: unrecognized Content-Encoding, passing through raw bytes: ~s~n", - [to_binary(Other)]), - {Body, Headers} - end. - -try_decompress(DecompressFn, OrigBody, Headers) -> - try - Decompressed = DecompressFn(), - {Decompressed, remove_header("content-encoding", Headers)} - catch - _:_ -> - io:format("WARNING: decompression failed, passing through raw bytes~n"), - {OrigBody, Headers} - end. - -%% Detect stream encoding from headers. -%% Returns {gzip, 31} | {deflate, 15} | none -detect_stream_encoding(Headers) -> - Encoding = get_content_encoding(Headers), - case Encoding of - "gzip" -> {gzip, 31}; - "deflate" -> {deflate, 15}; - "" -> none; - "identity" -> none; - Other -> - io:format("WARNING: unrecognized Content-Encoding for stream, passing through raw bytes: ~s~n", - [to_binary(Other)]), - none - end. - -%% Initialize a zlib inflate context for streaming decompression -init_zlib_context(WindowBits) -> - Z = zlib:open(), - ok = zlib:inflateInit(Z, WindowBits), - Z. - -%% Initialize streaming zlib context from headers if Content-Encoding is gzip/deflate -maybe_init_stream_zlib(Headers) -> - case detect_stream_encoding(Headers) of - {_Enc, WindowBits} -> init_zlib_context(WindowBits); - none -> undefined - end. - -%% Decompress a chunk using an existing zlib context -decompress_chunk(ZlibCtx, Chunk) -> - iolist_to_binary(zlib:inflate(ZlibCtx, Chunk)). - -%% Clean up a zlib context -cleanup_zlib(undefined) -> ok; -cleanup_zlib(ZlibCtx) -> - try zlib:inflateEnd(ZlibCtx) catch _:_ -> ok end, - try zlib:close(ZlibCtx) catch _:_ -> ok end, - ok. - -%% Extract Content-Type header value (case-insensitive) and strip it from headers. -%% -%% httpc's request tuple for entity-body requests is `{Url, Headers, ContentType, Body}`. -%% If we leave a `content-type` header in `Headers` while also providing `ContentType`, -%% we can end up sending duplicate/conflicting headers. We therefore: -%% - Prefer an explicitly provided Content-Type header value (last one wins) -%% - Remove all Content-Type headers from the outgoing Headers list -extract_content_type_and_strip_headers(Headers) when is_list(Headers) -> - {ContentType, RevHeaders} = - lists:foldl(fun({K, V}, {Ct0, Acc}) -> - KeyLower = string:lowercase(to_list(K)), - case KeyLower of - "content-type" -> {to_list(V), Acc}; - _ -> {Ct0, [{to_list(K), to_list(V)} | Acc]} - end - end, - {undefined, []}, - Headers), - {ContentType, lists:reverse(RevHeaders)}; -extract_content_type_and_strip_headers(Other) -> - {undefined, Other}. - -%% Build the request tuple for httpc -build_req(Url, Headers, Body) when is_binary(Body), byte_size(Body) =:= 0 -> - {Url, Headers}; -build_req(Url, Headers, Body) when Body =:= undefined; Body =:= <<>> -> - {Url, Headers}; -build_req(Url, Headers, Body) -> - {HeaderContentType, StrippedHeaders} = extract_content_type_and_strip_headers(Headers), - ContentType = - case HeaderContentType of - undefined -> - to_list("application/octet-stream"); - _ -> - HeaderContentType - end, - {Url, StrippedHeaders, ContentType, Body}. - -%% ============================================================================ -%% Message-Based Streaming (Thin Wrapper) -%% ============================================================================ - -%% @doc Start a message-based streaming HTTP request -%% -%% Initiates a streaming HTTP request where messages are sent directly to the caller's -%% process mailbox. This is used for OTP actor integration where messages arrive -%% asynchronously without needing to call `fetch_next`. The request ID is returned -%% as a string for type-safe handling in Gleam. -%% -%% ## Parameters -%% -%% - `Method`: HTTP method atom (`get`, `post`, `put`, `delete`, etc.) -%% - `Url`: Full request URL as a string -%% - `Headers`: List of `{Key, Value}` tuples (both strings or binaries) -%% - `Body`: Request body as a binary -%% - `ReceiverPid`: Process ID that will receive stream messages (unused, kept for compatibility) -%% - `TimeoutMs`: Request timeout in milliseconds -%% -%% ## Returns -%% -%% - `{ok, StringId}`: Stream started successfully, `StringId` is a string representation -%% of the httpc request ID (use with `cancel_stream_by_string`) -%% - `{error, Reason}`: Failed to start stream (connection error, invalid URL, etc.) -%% -%% ## Examples -%% -%% ```erlang -%% {ok, ReqId} = request_stream_messages(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% %% Messages will arrive as {http, {HttpcRef, stream_start, Headers}}, etc. -%% ``` -%% -%% ## Notes -%% -%% - Messages arrive as `{http, {HttpcRef, Tag, Data}}` tuples in the process mailbox -%% - Use `decode_stream_message_for_selector` for OTP selector integration -%% - Stores bidirectional mapping: `StringId <-> HttpcRef` for cancellation -%% - String ID is derived from httpc ref's string representation (guaranteed unique) -%% - Ensures `ssl` and `inets` applications are started before making requests -request_stream_messages(Method, Url, Headers, Body, _ReceiverPid, TimeoutMs) -> - ok = ensure_started(ssl), - ok = ensure_started(inets), - ok = configure_httpc(), - - NUrl = to_list(Url), - NHeaders = maybe_add_accept_encoding(to_headers(Headers)), - Req = build_req(NUrl, NHeaders, Body), - - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, 15000}, {autoredirect, true}], - StreamOpts = [{stream, self}, {sync, false}], - - case httpc:request(Method, Req, HttpOpts, StreamOpts) of - {ok, HttpcRef} -> - %% Convert ref to string for type-safe Gleam API - RefString = ref_to_string(HttpcRef), - %% Store mapping for cancellation - store_ref_mapping(RefString, HttpcRef), - {ok, RefString}; - {error, Reason} -> - {error, format_error(Reason)} - end. - -%% @doc Cancel a streaming request using httpc ref directly -%% -%% Cancels an active streaming HTTP request using the httpc request reference directly. -%% This is a legacy function that takes the raw httpc ref. For new code, use -%% `cancel_stream_by_string` which works with the type-safe string IDs. -%% -%% ## Parameters -%% -%% - `RequestId`: The httpc request reference (returned from `httpc:request/4`) -%% -%% ## Returns -%% -%% `ok` - Always returns successfully (even if request doesn't exist) -%% -%% ## Notes -%% -%% - This function is kept for backward compatibility -%% - Prefer `cancel_stream_by_string` for type-safe cancellation -%% - After cancellation, no more messages will be sent to the receiver process -%% - Safe to call multiple times on the same request ID -cancel_stream(RequestId) -> - httpc:cancel_request(RequestId), - ok. - -%% @doc Cancel a streaming request by string ID -%% -%% Cancels an active streaming HTTP request using the string ID returned from -%% `request_stream_messages`. Looks up the corresponding httpc reference from -%% the internal mapping table and cancels the request. -%% -%% ## Parameters -%% -%% - `StringId`: The string request ID returned from `request_stream_messages` -%% -%% ## Returns -%% -%% `nil` - Always returns successfully (even if request doesn't exist or already ended) -%% -%% ## Examples -%% -%% ```erlang -%% {ok, ReqId} = request_stream_messages(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% %% Later, cancel the stream -%% cancel_stream_by_string(ReqId). -%% ``` -%% -%% ## Notes -%% -%% - Uses internal ETS table to map string IDs to httpc refs -%% - Returns `nil` if the request ID is not found (stream already ended or never existed) -%% - After cancellation, no more messages will be sent to the receiver process -%% - Safe to call multiple times on the same request ID -%% - Mapping is cleaned up automatically when stream ends normally or errors -cancel_stream_by_string(StringId) -> - case lookup_ref_by_string(StringId) of - {some, HttpcRef} -> - httpc:cancel_request(HttpcRef), - remove_ref_mapping(StringId), - nil; - none -> - %% Ref not found - stream already ended or never existed - nil - end. - -%% @doc Receive and decode the next stream message from process mailbox -%% -%% Blocks waiting for an httpc stream message in the process mailbox and returns -%% a normalized tuple format that Gleam can easily decode. This is a helper function -%% for non-selector use cases where you want to receive messages directly from the -%% mailbox rather than using OTP selectors. -%% -%% ## Parameters -%% -%% - `TimeoutMs`: Timeout in milliseconds (0 for non-blocking, -1 for infinite wait) -%% -%% ## Returns -%% -%% - `{stream_start, RequestId, Headers}`: Stream started, initial headers received -%% - `{chunk, RequestId, Data}`: Data chunk received (binary) -%% - `{stream_end, RequestId, Headers}`: Stream completed successfully with trailing headers -%% - `{stream_error, RequestId, Reason}`: Stream failed with error (binary error message) -%% - `timeout`: No message received within the timeout period -%% -%% ## Examples -%% -%% ```erlang -%% {ok, _ReqId} = request_stream_messages(get, "https://api.example.com/stream", [], <<>>, self(), 30000), -%% case receive_stream_message(5000) of -%% {stream_start, ReqId, Headers} -> process_start(ReqId, Headers); -%% {chunk, ReqId, Data} -> process_chunk(ReqId, Data); -%% {stream_end, ReqId, Headers} -> process_end(ReqId, Headers); -%% {stream_error, ReqId, Reason} -> handle_error(ReqId, Reason); -%% timeout -> handle_timeout() -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Blocks until a message arrives or timeout expires -%% - Headers are normalized to binary tuples for consistent Gleam decoding -%% - RequestId is the httpc reference (use `decode_stream_message_for_selector` for string IDs) -%% - Handles both `{http, {Ref, stream_start, Headers}}` and `{http, {Ref, stream_start, Headers, Pid}}` formats -%% - Error reasons are formatted as binaries for Gleam compatibility -receive_stream_message(TimeoutMs) -> - receive - {http, {RequestId, stream_start, Headers}} -> - StringId = get_or_create_string_id(RequestId), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, RequestId, normalize_headers(Headers)}; - {http, {RequestId, stream_start, Headers, _Pid}} -> - StringId = get_or_create_string_id(RequestId), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, RequestId, normalize_headers(Headers)}; - {http, {RequestId, stream, Data}} -> - StringId = get_or_create_string_id(RequestId), - DecompData = maybe_decompress_stream_chunk(StringId, Data), - {chunk, RequestId, DecompData}; - {http, {RequestId, stream_end, Headers}} -> - StringId = get_or_create_string_id(RequestId), - cleanup_stream_zlib(StringId), - {stream_end, RequestId, normalize_headers(Headers)}; - {http, {RequestId, {error, Reason}}} -> - StringId = get_or_create_string_id(RequestId), - cleanup_stream_zlib(StringId), - {stream_error, RequestId, format_error(Reason)}; - {http, {RequestId, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}}} -> - StringId = get_or_create_string_id(RequestId), - cleanup_stream_zlib(StringId), - {stream_error, RequestId, format_complete_response_error(StatusCode, ReasonPhrase, Body)} - after TimeoutMs -> - timeout - end. - -%% @doc Decode an httpc stream message for OTP selector integration -%% -%% Processes raw httpc stream messages extracted by OTP selectors and converts them -%% to a normalized format suitable for Gleam decoding. Converts httpc references to -%% string IDs for type-safe handling in Gleam, normalizes headers to binary tuples, -%% and formats error messages as binaries. -%% -%% ## Parameters -%% -%% - `{http, InnerMessage}`: The message tuple extracted by `process:select_record/4` -%% where `InnerMessage` is the inner tuple from httpc (e.g., `{Ref, stream_start, Headers}`) -%% -%% ## Returns -%% -%% A normalized tuple `{Tag, StringId, Data}` where: -%% - `Tag`: Atom (`stream_start`, `chunk`, `stream_end`, `stream_error`) -%% - `StringId`: String representation of the httpc request ID (for type-safe Gleam API) -%% - `Data`: Varies by tag: -%% - `stream_start`: Normalized headers (list of `{Binary, Binary}` tuples) -%% - `chunk`: Binary data -%% - `stream_end`: Normalized trailing headers -%% - `stream_error`: Binary error message -%% -%% ## Examples -%% -%% ```erlang -%% %% In selector callback -%% case decode_stream_message_for_selector({http, {Ref, stream_start, Headers}}) of -%% {stream_start, StringId, NormalizedHeaders} -> process_start(StringId, NormalizedHeaders); -%% {chunk, StringId, Data} -> process_chunk(StringId, Data); -%% {stream_end, StringId, Headers} -> process_end(StringId, Headers); -%% {stream_error, StringId, Reason} -> handle_error(StringId, Reason) -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Used internally by `client.select_stream_messages()` for selector integration -%% - Creates string ID mapping if it doesn't exist (handles messages arriving before mapping stored) -%% - Cleans up ref mapping when stream ends (`stream_end`) or errors (`stream_error`) -%% - Headers are normalized to binary tuples for consistent Gleam decoding -%% - Handles both `{Ref, stream_start, Headers}` and `{Ref, stream_start, Headers, Pid}` formats -%% - Error reasons are formatted as binaries for Gleam compatibility -decode_stream_message_for_selector({http, InnerMessage}) -> - case InnerMessage of - {HttpcRef, stream_start, Headers} -> - StringId = get_or_create_string_id(HttpcRef), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, StringId, normalize_headers(Headers)}; - {HttpcRef, stream_start, Headers, _Pid} -> - StringId = get_or_create_string_id(HttpcRef), - maybe_store_stream_zlib(StringId, Headers), - {stream_start, StringId, normalize_headers(Headers)}; - {HttpcRef, stream, Data} -> - StringId = get_or_create_string_id(HttpcRef), - DecompData = maybe_decompress_stream_chunk(StringId, Data), - {chunk, StringId, DecompData}; - {HttpcRef, stream_end, Headers} -> - StringId = get_or_create_string_id(HttpcRef), - cleanup_stream_zlib(StringId), - remove_ref_mapping(StringId), - {stream_end, StringId, normalize_headers(Headers)}; - {HttpcRef, {error, Reason}} -> - StringId = get_or_create_string_id(HttpcRef), - cleanup_stream_zlib(StringId), - remove_ref_mapping(StringId), - {stream_error, StringId, format_error(Reason)}; - {HttpcRef, {{_HttpVersion, StatusCode, ReasonPhrase}, _Headers, Body}} -> - StringId = get_or_create_string_id(HttpcRef), - cleanup_stream_zlib(StringId), - remove_ref_mapping(StringId), - {stream_error, StringId, format_complete_response_error(StatusCode, ReasonPhrase, Body)}; - _ -> - error(badarg) - end. - -%% Get string ID for httpc ref, creating mapping if needed. -%% This handles the case where selector receives messages before we stored -%% the mapping. -get_or_create_string_id(HttpcRef) -> - case lookup_string_by_ref(HttpcRef) of - {some, StringId} -> - StringId; - none -> - NewId = ref_to_string(HttpcRef), - store_ref_mapping(NewId, HttpcRef), - NewId - end. - -%% @doc Normalize HTTP headers to binary tuples for Gleam decoding -%% -%% Converts HTTP headers from various formats (charlists, binaries, mixed types) -%% to a consistent format of `{Binary, Binary}` tuples that Gleam can easily decode. -%% This ensures type safety and consistent handling regardless of how httpc returns headers. -%% -%% ## Parameters -%% -%% - `Headers`: List of header tuples in any format (charlists, binaries, mixed) -%% -%% ## Returns -%% -%% List of `{Binary, Binary}` tuples where both name and value are binaries. -%% -%% ## Examples -%% -%% ```erlang -%% Headers = [{"content-type", "application/json"}, {"authorization", <<"Bearer token">>}], -%% Normalized = normalize_headers(Headers), -%% %% Returns: [{<<"content-type">>, <<"application/json">>}, {<<"authorization">>, <<"Bearer token">>}] -%% ``` -%% -%% ## Notes -%% -%% - Converts charlists to binaries using `unicode:characters_to_binary/1` -%% - Leaves binaries unchanged -%% - Converts other types to binaries using `io_lib:format/2` -%% - Returns empty list `[]` if input is not a list -%% - Invalid header tuples are converted to `{<<"">>, <<"">>}` -normalize_headers(Headers) when is_list(Headers) -> - lists:map(fun normalize_header_tuple/1, Headers); -normalize_headers(_) -> - []. - -normalize_header_tuple({Name, Value}) -> - {to_binary(Name), to_binary(Value)}; -normalize_header_tuple(_) -> - {<<"">>, <<"">>}. - -to_binary(Bin) when is_binary(Bin) -> - Bin; -to_binary(List) when is_list(List) -> - unicode:characters_to_binary(List); -to_binary(Other) -> - ensure_utf8_binary(io_lib:format("~p", [Other])). - -%% Guarantee a valid UTF-8 binary from any input. -%% Handles binaries (validates UTF-8, falls back to Latin-1 reinterpretation), -%% charlists from io_lib:format (unicode codepoints), and arbitrary terms. -ensure_utf8_binary(Bin) when is_binary(Bin) -> - case unicode:characters_to_binary(Bin, utf8, utf8) of - Result when is_binary(Result) -> Result; - _ -> - case unicode:characters_to_binary(Bin, latin1, utf8) of - Result2 when is_binary(Result2) -> Result2; - _ -> iolist_to_binary(io_lib:format("~w", [Bin])) - end - end; -ensure_utf8_binary(List) when is_list(List) -> - case unicode:characters_to_binary(List) of - Result when is_binary(Result) -> Result; - _ -> iolist_to_binary(io_lib:format("~w", [List])) - end; -ensure_utf8_binary(Other) -> - iolist_to_binary(io_lib:format("~w", [Other])). - -%% @doc Make a synchronous (blocking) HTTP request -%% -%% Sends an HTTP request and waits for the complete response body. This is the -%% correct way to make non-streaming HTTP requests - it uses httpc's synchronous -%% mode without streaming, which is more efficient than streaming mode for complete -%% responses. -%% -%% ## Parameters -%% -%% - `Method`: HTTP method atom (`get`, `post`, `put`, `delete`, `patch`, `head`, etc.) -%% - `Url`: Full request URL as a string (e.g., `"https://api.example.com/users"`) -%% - `Headers`: List of `{Key, Value}` tuples where both are strings or binaries -%% - `Body`: Request body as a binary (empty binary `<<>>` for requests without body) -%% - `TimeoutMs`: Request timeout in milliseconds -%% -%% ## Returns -%% -%% - `{ok, {StatusCode, ResponseHeaders, Body}}`: -%% - `StatusCode` is an integer HTTP status code (e.g. 200, 404) -%% - `ResponseHeaders` is a list of `{Name, Value}` tuples as binaries -%% - `Body` is the complete response body as a binary -%% - `{error, Reason}`: Error occurred (connection failure, timeout, etc.) -%% where `Reason` is a binary error message -%% -%% ## Examples -%% -%% ```erlang -%% {ok, {Status, Headers, Body}} = request_sync(get, "https://api.example.com/users", [], <<>>, 30000), -%% ``` -%% -%% ## Notes -%% -%% - Uses httpc's synchronous mode (`{sync, true}`) - blocks until complete response received -%% - Uses `{body_format, binary}` to get response as binary (not parsed) -%% - More efficient than streaming mode for non-streaming use cases -%% - Ensures `ssl` and `inets` applications are started before making requests -%% - Configures httpc with appropriate timeout and redirect settings -%% - Error reasons are formatted as binaries for Gleam compatibility -request_sync(Method, Url, Headers, Body, TimeoutMs) -> - ok = ensure_started(ssl), - ok = ensure_started(inets), - ok = configure_httpc(), - - NUrl = to_list(Url), - NHeaders = maybe_add_accept_encoding(to_headers(Headers)), - Req = build_req(NUrl, NHeaders, Body), - - %% Use synchronous mode WITHOUT streaming - this is what send() should use - HttpOpts = [{timeout, TimeoutMs}, {connect_timeout, 15000}, {autoredirect, true}], - Opts = [{sync, true}, {body_format, binary}], - - case httpc:request(Method, Req, HttpOpts, Opts) of - {ok, {{_Version, StatusCode, _ReasonPhrase}, ResponseHeaders, ResponseBody}} -> - {DecompressedBody, CleanHeaders} = - maybe_decompress_response(ResponseBody, ResponseHeaders), - {ok, {StatusCode, normalize_headers(CleanHeaders), DecompressedBody}}; - {error, Reason} -> - {error, format_error(Reason)} - end. - -format_error(Reason) -> - ensure_utf8_binary(io_lib:format("~p", [Reason])). - -%% Format error for a complete (non-streaming) HTTP response from httpc. -%% httpc sends this instead of stream_start/stream/stream_end when the upstream -%% returns a non-streaming response (typically 4xx/5xx errors). -format_complete_response_error(StatusCode, ReasonPhrase, Body) -> - <<(<<"HTTP ">>)/binary, - (integer_to_binary(StatusCode))/binary, - (<<" ">>)/binary, - (ensure_utf8_binary(ReasonPhrase))/binary, - (<<": ">>)/binary, - (ensure_utf8_binary(Body))/binary>>. - -%% Format exit reason from owner process death -%% -%% When the owner process dies, we extract the exit reason and format it -%% into a meaningful error message for the user. -format_exit_reason({stream_start_failed, Error}) -> - %% HTTP request failed to start - return the actual httpc error - format_error(Error); -format_exit_reason(normal) -> - %% Normal exit - shouldn't happen in middle of stream - <<"Stream process exited normally">>; -format_exit_reason(Reason) -> - %% Some other exit reason - format it for debugging - ensure_utf8_binary(io_lib:format("Stream process died: ~p", [Reason])). - -%% ============================================================================= -%% ETS Functions for Stream Recorder State Management -%% ============================================================================= - -%% @doc Check if an ETS table exists -%% -%% Determines whether a named ETS table exists by attempting to get its info. -%% Used internally to check if tables need to be created before use. -%% -%% ## Parameters -%% -%% - `Name`: Table name as a binary (will be converted to atom) -%% -%% ## Returns -%% -%% - `true`: Table exists -%% - `false`: Table does not exist or name is invalid -%% -%% ## Notes -%% -%% - Converts binary name to atom using `binary_to_atom` -%% - Returns `false` if conversion fails or table doesn't exist -%% - Used internally for idempotent table creation -ets_table_exists(Name) -> - try - NameAtom = binary_to_atom(Name, utf8), - case ets:info(NameAtom) of - undefined -> - false; - _ -> - true - end - catch - error:badarg -> - false - end. - -%% @doc Create a new ETS table -%% -%% Creates a new ETS (Erlang Term Storage) table with the specified name and options. -%% Used internally for storing stream recorder state and request ID mappings. -%% -%% ## Parameters -%% -%% - `Name`: Table name as a binary (will be converted to atom) -%% - `Options`: List of ETS table options (e.g., `[set, public, named_table]`) -%% -%% ## Returns -%% -%% The table reference (atom or integer) returned by `ets:new/2`. -%% -%% ## Examples -%% -%% ```erlang -%% TableRef = ets_new(<<"my_table">>, [set, public, named_table]). -%% ``` -%% -%% ## Notes -%% -%% - Converts binary name to atom using `binary_to_atom` -%% - Options should include `named_table` if you want to reference by name later -%% - Used internally for creating recorder and ref mapping tables -ets_new(Name, Options) -> - NameAtom = binary_to_atom(Name, utf8), - ets:new(NameAtom, Options). - -%% @doc Insert recorder state into ETS table -%% -%% Stores stream recorder state in an ETS table for message-based streaming recording. -%% The state includes the recorder handle, recorded request, accumulated chunks, and -%% timing information for recreating streaming behavior during playback. -%% -%% ## Parameters -%% -%% - `TableName`: Table name as a binary (will be converted to atom) -%% - `Key`: String key identifying the stream (typically the request ID string) -%% - `Recorder`: Gleam recorder handle (opaque term) -%% - `RecordedRequest`: The recorded HTTP request structure -%% - `Chunks`: List of accumulated chunks (in reverse order, will be reversed on completion) -%% - `LastChunkTime`: Optional timestamp of the last chunk (for delay calculation) -%% -%% ## Returns -%% -%% `nil` - Always returns successfully -%% -%% ## Notes -%% -%% - Stores as `{Key, {Recorder, RecordedRequest, Chunks, LastChunkTime}}` -%% - Overwrites existing entry if key already exists -%% - Used internally by message-based streaming recorder -%% - Chunks are stored in reverse order (prepended) and reversed when stream completes -ets_insert(TableName, Key, Recorder, RecordedRequest, Headers, Chunks, LastChunkTime) -> - TableAtom = binary_to_atom(TableName, utf8), - Value = {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}, - ets:insert(TableAtom, {Key, Value}), - nil. - -%% @doc Lookup recorder state from ETS table -%% -%% Retrieves stream recorder state from an ETS table using the stream's request ID key. -%% Returns the state in a format that Gleam can decode as `MessageStreamRecorderState`. -%% -%% ## Parameters -%% -%% - `TableName`: Table name as a binary (will be converted to atom) -%% - `Key`: String key identifying the stream (typically the request ID string) -%% -%% ## Returns -%% -%% - `{some, State}`: State found, where `State` is a tuple matching Gleam's -%% `MessageStreamRecorderState` constructor format -%% - `none`: No state found for the key (stream doesn't exist or already completed) -%% -%% ## Examples -%% -%% ```erlang -%% case ets_lookup(<<"dream_http_client_stream_recorders">>, ReqId) of -%% {some, State} -> update_recorder_state(State); -%% none -> handle_missing_state() -%% end. -%% ``` -%% -%% ## Notes -%% -%% - Returns `none` if table doesn't exist or key not found -%% - State format: `{message_stream_recorder_state, Recorder, RecordedRequest, Chunks, LastChunkTime}` -%% - Used internally by message-based streaming recorder to update state -%% - Returns `none` if table conversion fails (safe error handling) -ets_lookup(TableName, Key) -> - try - TableAtom = binary_to_atom(TableName, utf8), - case ets:lookup(TableAtom, Key) of - [{Key, {Recorder, RecordedRequest, Headers, Chunks, LastChunkTime}}] -> - %% Return as Gleam MessageStreamRecorderState constructor - State = - {message_stream_recorder_state, - Recorder, - RecordedRequest, - Headers, - Chunks, - LastChunkTime}, - {some, State}; - [] -> - none - end - catch - error:badarg -> - none - end. - -%% @doc Delete a key from an ETS table -%% -%% Removes an entry from an ETS table by key. Used for cleaning up stream recorder -%% state when a stream completes or is cancelled. -%% -%% ## Parameters -%% -%% - `TableName`: Table name as a binary (will be converted to atom) -%% - `Key`: String key identifying the entry to delete -%% -%% ## Returns -%% -%% - `true`: Key was deleted successfully -%% - `false`: Key not found or table doesn't exist -%% -%% ## Examples -%% -%% ```erlang -%% ets_delete(<<"dream_http_client_stream_recorders">>, ReqId). -%% ``` -%% -%% ## Notes -%% -%% - Returns `false` if table doesn't exist or conversion fails (safe error handling) -%% - Used internally to clean up recorder state when streams end -%% - Safe to call multiple times on the same key -ets_delete(TableName, Key) -> - try - TableAtom = binary_to_atom(TableName, utf8), - ets:delete(TableAtom, Key) - catch - error:badarg -> - false - end. - -%% ============================================================================= -%% Request ID Mapping (String <-> Httpc Ref) -%% ============================================================================= - -%% Table for mapping string IDs to httpc refs (for cancellation) --define(REF_MAPPING_TABLE, dream_http_client_ref_mapping). - -%% Convert httpc ref to unique string ID -%% Uses the ref's string representation which is guaranteed unique -ref_to_string(Ref) -> - ensure_utf8_binary(io_lib:format("~p", [Ref])). - -%% Store bidirectional mapping: string <-> ref -store_ref_mapping(StringId, HttpcRef) -> - ets:insert(?REF_MAPPING_TABLE, {StringId, HttpcRef}), - ets:insert(?REF_MAPPING_TABLE, {HttpcRef, StringId}), - ok. - -%% Lookup httpc ref by string ID (for cancellation) -lookup_ref_by_string(StringId) -> - case ets:lookup(?REF_MAPPING_TABLE, StringId) of - [{StringId, HttpcRef}] -> - {some, HttpcRef}; - [] -> - none - end. - -%% Lookup string ID by httpc ref (for message translation) -lookup_string_by_ref(HttpcRef) -> - case ets:lookup(?REF_MAPPING_TABLE, HttpcRef) of - [{HttpcRef, StringId}] -> - {some, StringId}; - [] -> - none - end. - -%% Store a zlib context in ETS for message-based streaming decompression -maybe_store_stream_zlib(StringId, Headers) -> - case detect_stream_encoding(Headers) of - {_Enc, WindowBits} -> - Z = init_zlib_context(WindowBits), - ets:insert(?REF_MAPPING_TABLE, {{zlib, StringId}, Z}), - ok; - none -> - ok - end. - -%% Decompress a chunk using ETS-stored zlib context -maybe_decompress_stream_chunk(StringId, Data) -> - case ets:lookup(?REF_MAPPING_TABLE, {zlib, StringId}) of - [{{zlib, StringId}, Z}] -> - decompress_chunk(Z, Data); - [] -> - Data - end. - -%% Clean up ETS-stored zlib context -cleanup_stream_zlib(StringId) -> - case ets:lookup(?REF_MAPPING_TABLE, {zlib, StringId}) of - [{{zlib, StringId}, Z}] -> - cleanup_zlib(Z), - ets:delete(?REF_MAPPING_TABLE, {zlib, StringId}), - ok; - [] -> - ok - end. - -%% Remove both mappings (cleanup after stream ends) -remove_ref_mapping(StringId) -> - case lookup_ref_by_string(StringId) of - {some, HttpcRef} -> - ets:delete(?REF_MAPPING_TABLE, StringId), - ets:delete(?REF_MAPPING_TABLE, HttpcRef), - ok; - none -> - ok - end. diff --git a/modules/http_client/src/dream_http_client/internal.gleam b/modules/http_client/src/dream_http_client/internal.gleam index 3dd95e1..5b39350 100644 --- a/modules/http_client/src/dream_http_client/internal.gleam +++ b/modules/http_client/src/dream_http_client/internal.gleam @@ -8,7 +8,7 @@ //// This is an internal module. Use `dream_http_client/client`, //// `dream_http_client/recorder`, and `dream_http_client/matching` instead. -import gleam/bit_array +import gleam/dynamic import gleam/dynamic/decode as d import gleam/erlang/atom import gleam/erlang/process @@ -22,7 +22,7 @@ import gleam/result import gleam/string // Erlang externals for streaming HTTP requests -@external(erlang, "dream_httpc_shim", "request_stream") +@external(erlang, "dream_http_shim", "request_stream") fn request_stream( method: atom.Atom, url: String, @@ -30,18 +30,21 @@ fn request_stream( body: BitArray, receiver: process.Pid, timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, + protocols: atom.Atom, ) -> d.Dynamic -@external(erlang, "dream_httpc_shim", "fetch_next") +@external(erlang, "dream_http_shim", "fetch_next") fn fetch_next(owner: d.Dynamic, timeout_ms: Int) -> d.Dynamic -@external(erlang, "dream_httpc_shim", "fetch_start_headers") +@external(erlang, "dream_http_shim", "fetch_start_headers") fn fetch_start_headers(owner: d.Dynamic, timeout_ms: Int) -> d.Dynamic /// Convert an HTTP method to an Erlang atom /// /// Converts a Gleam HTTP method type to an Erlang atom for use with the -/// Erlang httpc library. This is an internal function used by the streaming +/// Erlang gun library. This is an internal function used by the streaming /// request implementation. /// /// ## Parameters @@ -68,7 +71,7 @@ pub fn atomize_method(method: http.Method) -> atom.Atom { /// Start an HTTP streaming request /// -/// Initiates a streaming HTTP request using Erlang's httpc library. This +/// Initiates a streaming HTTP request using Erlang's gun library. This /// function constructs the URL, converts the method to an atom, and starts /// the streaming process. Returns a dynamic value containing the owner PID /// that can be used to receive chunks. @@ -82,9 +85,12 @@ pub fn atomize_method(method: http.Method) -> atom.Atom { /// /// A dynamic value containing the owner PID in the format `{ok, OwnerPid}`. /// Use `extract_owner_pid()` to get the PID for receiving chunks. -pub fn start_httpc_stream( +pub fn start_gun_stream( request: Request(String), timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, + protocols_atom: atom.Atom, ) -> d.Dynamic { let port_string = case request.port { option.Some(port) -> ":" <> int.to_string(port) @@ -104,17 +110,27 @@ pub fn start_httpc_stream( let method_atom = atomize_method(request.method) let body = <> let receiver = process.self() - request_stream(method_atom, url, request.headers, body, receiver, timeout_ms) + request_stream( + method_atom, + url, + request.headers, + body, + receiver, + timeout_ms, + connect_timeout_ms, + autoredirect, + protocols_atom, + ) } /// Extract the owner PID from the request result /// -/// Extracts the owner process ID from the result returned by `start_httpc_stream()`. +/// Extracts the owner process ID from the result returned by `start_gun_stream()`. /// The owner PID is used to receive response chunks from the streaming request. /// /// ## Parameters /// -/// - `request_result`: The dynamic result from `start_httpc_stream()`, which is +/// - `request_result`: The dynamic result from `start_gun_stream()`, which is /// always `{ok, OwnerPid}` (errors are detected asynchronously) /// /// ## Returns @@ -143,8 +159,9 @@ pub fn extract_owner_pid(request_result: d.Dynamic) -> d.Dynamic { /// Receive the next chunk from the stream /// /// Receives the next chunk of data from an active streaming HTTP request. -/// Returns `Ok(BitArray)` when a chunk is available, or `Error(String)` when -/// the stream has finished or an error occurred. +/// Returns `Ok(BitArray)` when a chunk is available, or `Error(d.Dynamic)` when +/// the stream has finished or an error occurred. The error dynamic value is a +/// structured tagged tuple from the Erlang shim's `classify_error`. /// /// ## Parameters /// @@ -155,11 +172,11 @@ pub fn extract_owner_pid(request_result: d.Dynamic) -> d.Dynamic { /// /// - `Ok(Some(BitArray))`: The next chunk of response data /// - `Ok(None)`: Stream finished normally (no more data) -/// - `Error(String)`: Error occurred with reason +/// - `Error(d.Dynamic)`: Structured error from the Erlang shim pub fn receive_next( owner: d.Dynamic, timeout_ms: Int, -) -> Result(option.Option(BitArray), String) { +) -> Result(option.Option(BitArray), d.Dynamic) { let resp = fetch_next(owner, timeout_ms) let tag = d.run(resp, d.at([0], d.dynamic)) @@ -173,21 +190,17 @@ pub fn receive_next( } "finished" -> Ok(option.None) "error" -> { - let reason = case d.run(resp, d.at([1], d.string)) { - Ok(s) -> s - Error(_) -> - case d.run(resp, d.at([1], d.bit_array)) { - Ok(bytes) -> - case bit_array.to_string(bytes) { - Ok(s) -> s - Error(_) -> string.inspect(resp) - } - Error(_) -> string.inspect(resp) - } - } - Error(reason) + let reason_dyn = + d.run(resp, d.at([1], d.dynamic)) |> result.unwrap(dynamic.nil()) + Error(reason_dyn) } - _ -> Error("Unexpected stream message tag: " <> tag) + _ -> + Error( + to_dynamic(#( + atom.create("unexpected"), + "Unexpected stream message tag: " <> tag, + )), + ) } } @@ -225,10 +238,9 @@ pub fn get_stream_start_headers( } "error" -> { - let reason = - d.run(resp, d.at([1], d.string)) - |> result.unwrap("Unknown stream_start header error") - Error(reason) + let reason_dyn = + d.run(resp, d.at([1], d.dynamic)) |> result.unwrap(dynamic.nil()) + Error(string.inspect(reason_dyn)) } _ -> Error("Unexpected fetch_start_headers response: " <> tag) @@ -239,6 +251,9 @@ fn convert_to_atom(dyn: d.Dynamic) -> Result(atom.Atom, e) { Ok(atom.cast_from_dynamic(dyn)) } +@external(erlang, "gleam_stdlib", "identity") +fn to_dynamic(value: a) -> d.Dynamic + // ============================================================================ // Message-Based Streaming FFI // ============================================================================ @@ -246,7 +261,7 @@ fn convert_to_atom(dyn: d.Dynamic) -> Result(atom.Atom, e) { /// Start a message-based streaming HTTP request /// /// Low-level FFI function that initiates a streaming HTTP request using Erlang's -/// `httpc` library. Messages are sent directly to the specified process mailbox +/// `gun` library. Messages are sent directly to the specified process mailbox /// without buffering or an intermediate owner process. /// /// **Note:** This is an internal function used by the public API. Most callers @@ -272,7 +287,7 @@ fn convert_to_atom(dyn: d.Dynamic) -> Result(atom.Atom, e) { /// - This function is used internally by `client.start_stream()` /// - Messages arrive as Erlang tuples that must be decoded /// - Use `decode_stream_message_for_selector()` for selector integration -@external(erlang, "dream_httpc_shim", "request_stream_messages") +@external(erlang, "dream_http_shim", "request_stream_messages") pub fn start_stream_messages( method: atom.Atom, url: String, @@ -280,26 +295,29 @@ pub fn start_stream_messages( body: BitArray, receiver: process.Pid, timeout_ms: Int, + connect_timeout_ms: Int, + autoredirect: Bool, + protocols: atom.Atom, ) -> d.Dynamic /// Cancel a streaming request /// /// Low-level FFI function that cancels an active streaming HTTP request using -/// the httpc request ID. +/// the gun request ID. /// /// **Note:** This is an internal function used by the public API. Most callers /// should use `client.cancel_stream()` instead. /// /// ## Parameters /// -/// - `request_id`: The httpc request ID as a dynamic value +/// - `request_id`: The gun request ID as a dynamic value /// /// ## Notes /// /// - This function is used internally by `client.cancel_stream()` /// - After cancellation, no more messages will be sent to the receiver process /// - Safe to call multiple times on the same request ID -@external(erlang, "dream_httpc_shim", "cancel_stream") +@external(erlang, "dream_http_shim", "cancel_stream") pub fn cancel_stream_internal(request_id: d.Dynamic) -> Nil /// Cancel a streaming request by string ID @@ -317,20 +335,20 @@ pub fn cancel_stream_internal(request_id: d.Dynamic) -> Nil /// ## Notes /// /// - This function is used internally by `client.cancel_stream()` -/// - Converts the string ID to the appropriate format for httpc +/// - Converts the string ID to the appropriate format for gun /// - After cancellation, no more messages will be sent to the receiver process -@external(erlang, "dream_httpc_shim", "cancel_stream_by_string") +@external(erlang, "dream_http_shim", "cancel_stream_by_string") pub fn cancel_stream_by_string(request_id_string: String) -> Nil /// Receive the next stream message with timeout /// -/// Low-level FFI function that blocks waiting for an httpc stream message from +/// Low-level FFI function that blocks waiting for an gun stream message from /// the process mailbox and returns a normalized tuple. This is used for direct /// message receiving without selector integration. /// /// **Note:** This is an internal function. Most callers should use the public /// streaming API (`client.start_stream()` or `client.stream_yielder()`), not -/// raw httpc messages. +/// raw gun messages. /// /// ## Parameters /// @@ -346,12 +364,12 @@ pub fn cancel_stream_by_string(request_id_string: String) -> Nil /// - This function is used internally for non-selector message handling /// - Messages are normalized by the Erlang shim before being returned /// - Use `decode_stream_message_for_selector()` for selector integration -@external(erlang, "dream_httpc_shim", "receive_stream_message") +@external(erlang, "dream_http_shim", "receive_stream_message") pub fn receive_stream_message(timeout_ms: Int) -> d.Dynamic /// Decode stream message for selector integration /// -/// Low-level FFI function that processes raw httpc messages from OTP selectors. +/// Low-level FFI function that processes raw gun messages from OTP selectors. /// The Erlang shim handles pattern matching, normalizes charlists to binaries, /// and returns a clean tuple format that Gleam can easily decode. /// @@ -374,5 +392,5 @@ pub fn receive_stream_message(timeout_ms: Int) -> d.Dynamic /// - This function is used internally by `client.start_stream()` /// - Handles all message normalization and type conversion /// - Returns a format optimized for Gleam's dynamic decoder -@external(erlang, "dream_httpc_shim", "decode_stream_message_for_selector") +@external(erlang, "dream_http_shim", "decode_stream_message_for_selector") pub fn decode_stream_message_for_selector(message: d.Dynamic) -> d.Dynamic diff --git a/modules/http_client/test/client_test.gleam b/modules/http_client/test/client_test.gleam index 22366ec..aa8552b 100644 --- a/modules/http_client/test/client_test.gleam +++ b/modules/http_client/test/client_test.gleam @@ -1,4 +1,4 @@ -import dream_http_client/client.{Header} +import dream_http_client/client.{Header, Http1Only, Http2Only, Http2Preferred} import gleam/http import gleam/list import gleam/option @@ -110,6 +110,66 @@ pub fn timeout_sets_request_timeout_test() { client.get_timeout(updated) |> should.equal(option.Some(timeout_value)) } +pub fn connect_timeout_sets_request_connect_timeout_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.connect_timeout(request, 5000) + + // Assert + client.get_connect_timeout(updated) |> should.equal(option.Some(5000)) +} + +pub fn connect_timeout_defaults_to_none_test() { + // Arrange & Act + let request = client.new() + + // Assert + client.get_connect_timeout(request) |> should.equal(option.None) +} + +pub fn connect_timeout_accepts_zero_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.connect_timeout(request, 0) + + // Assert + client.get_connect_timeout(updated) |> should.equal(option.Some(0)) +} + +pub fn auto_redirect_sets_request_auto_redirect_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.auto_redirect(request, False) + + // Assert + client.get_auto_redirect(updated) |> should.equal(option.Some(False)) +} + +pub fn auto_redirect_defaults_to_none_test() { + // Arrange & Act + let request = client.new() + + // Assert + client.get_auto_redirect(request) |> should.equal(option.None) +} + +pub fn auto_redirect_can_be_set_to_true_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.auto_redirect(request, True) + + // Assert + client.get_auto_redirect(updated) |> should.equal(option.Some(True)) +} + pub fn add_header_adds_header_to_request_test() { // Arrange let request = client.new() @@ -128,3 +188,44 @@ pub fn add_header_adds_header_to_request_test() { [] -> should.fail() } } + +pub fn protocols_sets_request_protocols_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.protocols(request, Http2Only) + + // Assert + client.get_protocols(updated) |> should.equal(option.Some(Http2Only)) +} + +pub fn protocols_defaults_to_none_test() { + // Arrange & Act + let request = client.new() + + // Assert + client.get_protocols(request) |> should.equal(option.None) +} + +pub fn protocols_http1_only_round_trip_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.protocols(request, Http1Only) + + // Assert + client.get_protocols(updated) |> should.equal(option.Some(Http1Only)) +} + +pub fn protocols_http2_preferred_round_trip_test() { + // Arrange + let request = client.new() + + // Act + let updated = client.protocols(request, Http2Preferred) + + // Assert + client.get_protocols(updated) |> should.equal(option.Some(Http2Preferred)) +} diff --git a/modules/http_client/test/compression_test.gleam b/modules/http_client/test/compression_test.gleam index 8e71e2d..631ff3b 100644 --- a/modules/http_client/test/compression_test.gleam +++ b/modules/http_client/test/compression_test.gleam @@ -167,7 +167,9 @@ pub fn start_stream_passes_through_unknown_encoding_chunks_test() { mock_request("/stream/unknown-encoding") |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, failure) + }) let assert Ok(_handle) = client.start_stream(request) @@ -179,7 +181,7 @@ pub fn start_stream_passes_through_unknown_encoding_chunks_test() { Ok(False) -> should.fail() Error(Nil) -> { case process.receive(error_subject, 1000) { - Ok(_reason) -> Nil + Ok(_failure) -> Nil Error(Nil) -> { io.println("start_stream unknown-encoding: neither end nor error") should.fail() @@ -290,8 +292,10 @@ pub fn stream_yielder_works_without_encoding_test() { list.all(results, fn(r) { case r { Ok(_) -> True - Error(reason) -> { - io.println("Unexpected error: " <> reason) + Error(failure) -> { + io.println( + "Unexpected error: " <> client.stream_failure_to_string(failure), + ) False } } @@ -320,9 +324,12 @@ pub fn start_stream_auto_injects_accept_encoding_test() { mock_request("/echo-accept-encoding") |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { + |> client.on_stream_error(fn(failure) { process.send(ended_subject, False) - io.println("Error in header injection test: " <> reason) + io.println( + "Error in header injection test: " + <> client.stream_failure_to_string(failure), + ) }) let assert Ok(_handle) = client.start_stream(request) @@ -376,9 +383,11 @@ pub fn start_stream_preserves_custom_accept_encoding_test() { |> client.headers([Header("Accept-Encoding", "zstd")]) |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) - |> client.on_stream_error(fn(reason) { + |> client.on_stream_error(fn(failure) { process.send(ended_subject, False) - io.println("Error in preserve test: " <> reason) + io.println( + "Error in preserve test: " <> client.stream_failure_to_string(failure), + ) }) let assert Ok(_handle) = client.start_stream(request) @@ -468,12 +477,14 @@ pub fn start_stream_gzip_cleans_up_zlib_on_error_test() { mock_request("/status/500") |> client.on_stream_chunk(fn(_data) { Nil }) |> client.on_stream_end(fn(_headers) { Nil }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, failure) + }) let assert Ok(handle) = client.start_stream(request) case process.receive(error_subject, 5000) { - Ok(_reason) -> Nil + Ok(_failure) -> Nil Error(Nil) -> { io.println("Zlib error cleanup test: error never called") should.fail() diff --git a/modules/http_client/test/empty_body_test.gleam b/modules/http_client/test/empty_body_test.gleam new file mode 100644 index 0000000..fb4799a --- /dev/null +++ b/modules/http_client/test/empty_body_test.gleam @@ -0,0 +1,60 @@ +//// Empty body regression tests +//// +//// Verifies that PUT, POST, PATCH, GET, and DELETE all complete successfully +//// when sent with no request body. Previously, PUT/POST/PATCH hung forever +//// due to gun's inconsistent method-specific dispatch for empty bodies. + +import dream_http_client/client +import dream_http_client_test +import gleam/http +import gleeunit/should + +fn mock_request(method: http.Method, path: String) -> client.ClientRequest { + client.new() + |> client.method(method) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) + |> client.timeout(5000) +} + +pub fn send_put_empty_body_succeeds_test() { + let req = mock_request(http.Put, "/put") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} + +pub fn send_post_empty_body_succeeds_test() { + let req = mock_request(http.Post, "/post") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(201) +} + +pub fn send_patch_empty_body_succeeds_test() { + let req = mock_request(http.Patch, "/patch") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} + +pub fn send_get_empty_body_succeeds_test() { + let req = mock_request(http.Get, "/text") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} + +pub fn send_delete_empty_body_succeeds_test() { + let req = mock_request(http.Delete, "/delete") + + let assert Ok(resp) = client.send(req) + + resp.status |> should.equal(200) +} diff --git a/modules/http_client/test/error_handling_test.gleam b/modules/http_client/test/error_handling_test.gleam index 20eb709..26a75e4 100644 --- a/modules/http_client/test/error_handling_test.gleam +++ b/modules/http_client/test/error_handling_test.gleam @@ -47,10 +47,10 @@ pub fn send_404_status_test() { status |> should.equal(404) string.contains(body, "404") |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_404_status_test encountered connection-level error: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) } Ok(_) -> { @@ -78,10 +78,10 @@ pub fn send_500_status_test() { status |> should.equal(500) string.contains(body, "500") |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_500_status_test encountered connection-level error: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) } Ok(_) -> { @@ -109,10 +109,10 @@ pub fn send_400_status_test() { status |> should.equal(400) string.contains(body, "400") |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_400_status_test encountered connection-level error: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) } Ok(_) -> { @@ -138,7 +138,8 @@ pub fn send_connection_failure_test() { // Assert - Should get a RequestError (transport failure) case result { - Error(client.RequestError(message: error_msg)) -> { + Error(client.RequestError(error: transport_error)) -> { + let error_msg = client.transport_error_to_string(transport_error) string.length(error_msg) |> should.not_equal(0) } Error(client.ResponseError(_)) -> { @@ -151,9 +152,8 @@ pub fn send_connection_failure_test() { /// Test: requests with body do not hardcode Content-Type /// -/// Regression test for the Erlang httpc shim: it must respect the caller's -/// `Content-Type` header when building the `{Url, Headers, ContentType, Body}` -/// request tuple (and never force `application/json`). +/// Regression test: the shim must respect the caller's `Content-Type` header +/// and never force a default content type. pub fn send_respects_explicit_request_content_type_test() { let req = client.new() @@ -175,10 +175,10 @@ pub fn send_respects_explicit_request_content_type_test() { ) False |> should.be_true() } - Error(client.RequestError(message: error_reason)) -> { + Error(client.RequestError(error: transport_error)) -> { io.println( "send_respects_explicit_request_content_type_test failed: " - <> error_reason, + <> client.transport_error_to_string(transport_error), ) False |> should.be_true() } @@ -282,8 +282,11 @@ pub fn send_error_response_includes_headers_test() { }) has_content_type |> should.be_true() } - Error(client.RequestError(message: msg)) -> { - io.println("Connection error (mock server down?): " <> msg) + Error(client.RequestError(error: transport_error)) -> { + io.println( + "Connection error (mock server down?): " + <> client.transport_error_to_string(transport_error), + ) } Ok(_) -> { io.println("Expected ResponseError for 404, got Ok") @@ -358,6 +361,71 @@ pub fn send_status_503_returns_error_test() { // Error Message Quality Tests // ============================================================================ +/// Test: connection refused returns ConnectFailed with a non-empty reason +pub fn send_connection_refused_returns_connect_failed_test() { + let req = + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(19_999) + |> client.path("/nonexistent") + + let result = client.send(req) + + case result { + Error(client.RequestError(error: client.ConnectFailed(reason: reason))) -> { + { reason != "" } |> should.be_true() + } + Error(client.RequestError(error: other)) -> { + io.println( + "Expected ConnectFailed, got: " + <> client.transport_error_to_string(other), + ) + should.fail() + } + Error(client.ResponseError(_)) -> { + io.println("Expected RequestError, got ResponseError") + should.fail() + } + Ok(_) -> should.fail() + } +} + +/// Test: transport_error_to_string produces a readable message for ConnectFailed +pub fn transport_error_to_string_returns_readable_message_test() { + let msg = + client.transport_error_to_string(client.ConnectFailed( + reason: "Connection refused", + )) + { msg != "" } |> should.be_true() + string.contains(msg, "Connection refused") |> should.be_true() +} + +/// Test: stream_failure_to_string produces a readable message for HttpFailure +pub fn stream_failure_to_string_returns_readable_message_for_http_failure_test() { + let msg = + client.stream_failure_to_string( + client.HttpFailure(response: client.HttpResponse( + status: 404, + headers: [], + body: "not found", + )), + ) + { msg != "" } |> should.be_true() + string.contains(msg, "404") |> should.be_true() +} + +/// Test: stream_failure_to_string produces a readable message for TransportFailure +pub fn stream_failure_to_string_returns_readable_message_for_transport_failure_test() { + let msg = + client.stream_failure_to_string( + client.TransportFailure(error: client.TimedOut(timeout_ms: 5000)), + ) + { msg != "" } |> should.be_true() + string.contains(msg, "5000") |> should.be_true() +} + /// Test: Errors contain useful information pub fn error_messages_are_informative_test() { // Arrange - Connect to non-existent server @@ -374,7 +442,8 @@ pub fn error_messages_are_informative_test() { // Assert - Error message should have substance case result { - Error(client.RequestError(message: error_msg)) -> { + Error(client.RequestError(error: transport_error)) -> { + let error_msg = client.transport_error_to_string(transport_error) // Should be more than just "error" or empty string.length(error_msg) |> should.not_equal(0) // Should not be just "Nil" or similar diff --git a/modules/http_client/test/ets_table_ownership_test.gleam b/modules/http_client/test/ets_table_ownership_test.gleam index ff89a7e..5c400b6 100644 --- a/modules/http_client/test/ets_table_ownership_test.gleam +++ b/modules/http_client/test/ets_table_ownership_test.gleam @@ -22,6 +22,7 @@ import dream_http_client/client import dream_http_client_test import gleam/erlang/process import gleam/http +import gleam/int import gleam/list import gleeunit/should @@ -85,8 +86,11 @@ pub fn stream_from_expired_caller_completes_test() { |> client.on_stream_end(fn(_headers) { process.send(end_subject, "completed") }) - |> client.on_stream_error(fn(reason) { - process.send(end_subject, "error:" <> reason) + |> client.on_stream_error(fn(failure) { + process.send( + end_subject, + "error:" <> client.stream_failure_to_string(failure), + ) }) let assert Ok(_handle) = client.start_stream(request) }) @@ -115,7 +119,7 @@ pub fn concurrent_streams_from_expired_callers_both_complete_test() { let request = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, 1) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, -1) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -1) }) let assert Ok(_handle) = client.start_stream(request) }) @@ -126,7 +130,7 @@ pub fn concurrent_streams_from_expired_callers_both_complete_test() { let request = mock_request("/stream/slow") |> client.on_stream_end(fn(_headers) { process.send(end_subject, 2) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, -2) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -2) }) let assert Ok(_handle) = client.start_stream(request) }) @@ -145,7 +149,7 @@ pub fn three_concurrent_streams_all_complete_test() { let request = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, i) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, -i) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -i) }) let assert Ok(_handle) = client.start_stream(request) Nil }) @@ -167,7 +171,7 @@ pub fn sequential_streams_after_process_exit_test() { let request2 = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, True) }) - |> client.on_stream_error(fn(_reason) { process.send(end_subject, False) }) + |> client.on_stream_error(fn(_failure) { process.send(end_subject, False) }) let assert Ok(_handle2) = client.start_stream(request2) case process.receive(end_subject, 5000) { @@ -184,13 +188,13 @@ pub fn five_concurrent_streams_from_expired_callers_test() { let end_subject = process.new_subject() let count = 5 - list.each(list.range(1, count), fn(i) { + int.range(from: 1, to: count + 1, with: Nil, run: fn(_, i) { let _pid = process.spawn_unlinked(fn() { let request = mock_request("/stream/fast") |> client.on_stream_end(fn(_headers) { process.send(end_subject, i) }) - |> client.on_stream_error(fn(_reason) { + |> client.on_stream_error(fn(_failure) { process.send(end_subject, -i) }) let assert Ok(_handle) = client.start_stream(request) @@ -198,7 +202,7 @@ pub fn five_concurrent_streams_from_expired_callers_test() { Nil }) - let results = collect_n(end_subject, count, 8000) + let results = collect_n(end_subject, count, 15_000) list.length(results) |> should.equal(count) list.each(results, fn(id) { { id > 0 } |> should.be_true() }) } diff --git a/modules/http_client/test/h2c_test.gleam b/modules/http_client/test/h2c_test.gleam new file mode 100644 index 0000000..f9aec2b --- /dev/null +++ b/modules/http_client/test/h2c_test.gleam @@ -0,0 +1,390 @@ +//// h2c integration tests — HTTP/2 over cleartext (prior knowledge mode) +//// +//// Verifies that dream_http_client with Http2Only over TCP successfully +//// negotiates HTTP/2 with a Mist-based server and correctly routes requests +//// to the right endpoints. Tests all three execution modes (send, +//// stream_yielder, start_stream) with path-specific content assertions. + +import dream_http_client/client.{Header, Http1Only, Http2Only, Http2Preferred} +import dream_http_client_test +import gleam/bit_array +import gleam/bytes_tree +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/option +import gleam/string +import gleam/yielder +import gleeunit/should + +fn h2c_request(path: String) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) + |> client.protocols(Http2Only) +} + +fn http1_request(path: String) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) +} + +// ============================================================================ +// A. send() over h2c — path-specific routing +// ============================================================================ + +/// h2c routes to /text and returns the correct body +pub fn h2c_send_text_returns_correct_body_test() { + let req = h2c_request("/text") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +/// h2c routes to /json and returns JSON with correct content +pub fn h2c_send_json_returns_correct_body_test() { + let req = h2c_request("/json") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello, World!") |> should.be_true() +} + +/// h2c routes to /status/500 and surfaces the error +pub fn h2c_send_error_status_test() { + let req = h2c_request("/status/500") + case client.send(req) { + Error(client.ResponseError(response: client.HttpResponse( + status: status, + body: _body, + .., + ))) -> { + status |> should.equal(500) + } + Ok(resp) -> { + let _ = resp + should.fail() + } + _other -> { + should.fail() + } + } +} + +/// h2c POST with body routes correctly +pub fn h2c_send_post_with_body_test() { + let req = + h2c_request("/post") + |> client.method(http.Post) + |> client.body("{\"key\":\"value\"}") + |> client.headers([Header("Content-Type", "application/json")]) + + case client.send(req) { + Ok(resp) -> { + resp.status |> should.equal(201) + string.contains(resp.body, "key") |> should.be_true() + } + Error(err) -> { + let _ = err + should.fail() + } + } +} + +/// h2c decompresses gzip responses correctly +pub fn h2c_send_decompresses_gzip_test() { + let req = h2c_request("/gzip") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +/// h2c send() returns response headers +pub fn h2c_send_returns_headers_test() { + let req = h2c_request("/text") + let assert Ok(resp) = client.send(req) + let content_type = + list.find(resp.headers, fn(h) { + let Header(name, _) = h + string.lowercase(name) == "content-type" + }) + let assert Ok(Header(_, ct_value)) = content_type + string.contains(ct_value, "text/plain") |> should.be_true() +} + +// ============================================================================ +// B. stream_yielder() over h2c — path-specific routing +// ============================================================================ + +/// h2c stream_yielder to /stream/fast receives all 10 chunks +pub fn h2c_stream_yielder_receives_all_chunks_test() { + let req = h2c_request("/stream/fast") + let results = client.stream_yielder(req) |> yielder.to_list + + let ok_chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + + { ok_chunks != [] } |> should.be_true() + + let combined = combine_chunks(ok_chunks) + string.contains(combined, "Chunk 1") |> should.be_true() + string.contains(combined, "Chunk 10") |> should.be_true() +} + +/// h2c stream_yielder decompresses gzip chunks +pub fn h2c_stream_yielder_decompresses_gzip_test() { + let req = h2c_request("/stream/gzip") + let results = client.stream_yielder(req) |> yielder.to_list + + let ok_chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + + { ok_chunks != [] } |> should.be_true() + + let combined = combine_chunks(ok_chunks) + string.contains(combined, "Chunk 1") |> should.be_true() + string.contains(combined, "Chunk 5") |> should.be_true() +} + +// ============================================================================ +// C. start_stream() over h2c — path-specific routing +// ============================================================================ + +/// h2c start_stream to /stream/fast delivers correct chunks +pub fn h2c_start_stream_delivers_correct_chunks_test() { + let chunks_subject = process.new_subject() + let ended_subject = process.new_subject() + + let request = + h2c_request("/stream/fast") + |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) + |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) + |> client.on_stream_error(fn(_failure) { + process.send(ended_subject, False) + }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(ended_subject, 10_000) { + Ok(ended_ok) -> ended_ok |> should.be_true() + Error(Nil) -> should.fail() + } + + let chunks = collect_chunks(chunks_subject, []) + { chunks != [] } |> should.be_true() + let combined = combine_chunks(chunks) + string.contains(combined, "Chunk 1") |> should.be_true() +} + +/// h2c start_stream fires on_stream_start with headers +pub fn h2c_start_stream_fires_on_stream_start_test() { + let headers_subject = process.new_subject() + let ended_subject = process.new_subject() + + let request = + h2c_request("/stream/fast") + |> client.on_stream_start(fn(headers) { + process.send(headers_subject, headers) + }) + |> client.on_stream_chunk(fn(_data) { Nil }) + |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) + |> client.on_stream_error(fn(_failure) { + process.send(ended_subject, False) + }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(headers_subject, 10_000) { + Ok(headers) -> { + { headers != [] } |> should.be_true() + } + Error(Nil) -> should.fail() + } + + case process.receive(ended_subject, 10_000) { + Ok(_) -> Nil + Error(Nil) -> should.fail() + } +} + +// ============================================================================ +// D. Connection pool isolation — h2c and http1 use separate connections +// ============================================================================ + +/// HTTP/1.1 requests still work after h2c requests +pub fn http1_still_works_after_h2c_test() { + let _h2c = client.send(h2c_request("/text")) + + let assert Ok(resp) = client.send(http1_request("/text")) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +/// h2c returns correct content (not the index page) +pub fn h2c_returns_correct_content_not_index_page_test() { + let assert Ok(h2c_resp) = client.send(h2c_request("/text")) + h2c_resp.status |> should.equal(200) + h2c_resp.body |> should.equal("Hello, World!") + + // Must NOT be the index page (which is what broken h2c returns) + string.contains(h2c_resp.body, "Mock Server") |> should.be_false() +} + +// ============================================================================ +// C. Protocol builder API — verify protocols field round-trips +// ============================================================================ + +pub fn protocols_builder_sets_http2_only_test() { + let req = + client.new() + |> client.protocols(Http2Only) + client.get_protocols(req) + |> should.equal(option.Some(Http2Only)) +} + +pub fn protocols_builder_sets_http1_only_test() { + let req = + client.new() + |> client.protocols(Http1Only) + client.get_protocols(req) + |> should.equal(option.Some(Http1Only)) +} + +pub fn protocols_builder_sets_http2_preferred_test() { + let req = + client.new() + |> client.protocols(Http2Preferred) + client.get_protocols(req) + |> should.equal(option.Some(Http2Preferred)) +} + +pub fn protocols_defaults_to_none_test() { + let req = client.new() + client.get_protocols(req) + |> should.equal(option.None) +} + +// ============================================================================ +// D. Multiple concurrent h2c requests +// ============================================================================ + +pub fn concurrent_h2c_requests_all_succeed_test() { + let subject1 = process.new_subject() + let subject2 = process.new_subject() + let subject3 = process.new_subject() + + let _pid1 = + process.spawn_unlinked(fn() { + let result = client.send(h2c_request("/text")) + process.send(subject1, result) + }) + + let _pid2 = + process.spawn_unlinked(fn() { + let result = client.send(h2c_request("/text")) + process.send(subject2, result) + }) + + let _pid3 = + process.spawn_unlinked(fn() { + let result = client.send(h2c_request("/text")) + process.send(subject3, result) + }) + + let assert Ok(Ok(resp1)) = process.receive(subject1, 10_000) + let assert Ok(Ok(resp2)) = process.receive(subject2, 10_000) + let assert Ok(Ok(resp3)) = process.receive(subject3, 10_000) + + resp1.status |> should.equal(200) + resp2.status |> should.equal(200) + resp3.status |> should.equal(200) + + { string.length(resp1.body) > 0 } |> should.be_true() + { string.length(resp2.body) > 0 } |> should.be_true() + { string.length(resp3.body) > 0 } |> should.be_true() +} + +// ============================================================================ +// E. Http1Only explicitly forces HTTP/1.1 +// ============================================================================ + +pub fn http1_only_returns_correct_content_test() { + let req = + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path("/text") + |> client.protocols(Http1Only) + + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + resp.body |> should.equal("Hello, World!") +} + +pub fn http1_only_stream_yielder_returns_correct_content_test() { + let req = + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path("/stream/fast") + |> client.protocols(Http1Only) + + let results = client.stream_yielder(req) |> yielder.to_list + + let ok_chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + + { ok_chunks != [] } |> should.be_true() + + let combined = combine_chunks(ok_chunks) + string.contains(combined, "Chunk 1") |> should.be_true() + string.contains(combined, "Chunk 10") |> should.be_true() +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn collect_chunks( + subject: process.Subject(BitArray), + acc: List(BitArray), +) -> List(BitArray) { + case process.receive(subject, 100) { + Ok(item) -> collect_chunks(subject, [item, ..acc]) + Error(Nil) -> list.reverse(acc) + } +} + +fn combine_chunks(chunks: List(BitArray)) -> String { + let combined = + list.fold(chunks, <<>>, fn(acc, chunk) { bit_array.append(acc, chunk) }) + case bit_array.to_string(combined) { + Ok(s) -> s + Error(Nil) -> "" + } +} diff --git a/modules/http_client/test/recorder_client_test.gleam b/modules/http_client/test/recorder_client_test.gleam index 3cb3a15..beaf8a9 100644 --- a/modules/http_client/test/recorder_client_test.gleam +++ b/modules/http_client/test/recorder_client_test.gleam @@ -274,8 +274,12 @@ pub fn send_with_recorder_finding_streaming_response_returns_error_test() { // Assert - should be RequestError, not ResponseError case result { - Error(client.RequestError(message: msg)) -> - string.contains(msg, "streaming response") |> should.be_true() + Error(client.RequestError(error: transport_error)) -> + string.contains( + client.transport_error_to_string(transport_error), + "streaming response", + ) + |> should.be_true() Error(client.ResponseError(_)) -> { io.println("Expected RequestError, got ResponseError") should.fail() @@ -540,8 +544,12 @@ pub fn send_with_recorder_in_playback_mode_with_ambiguous_key_returns_error_test // Assert result |> should.be_error() case result { - Error(client.RequestError(message: reason)) -> - string.contains(reason, "Ambiguous recording match") |> should.be_true() + Error(client.RequestError(error: transport_error)) -> + string.contains( + client.transport_error_to_string(transport_error), + "Ambiguous recording match", + ) + |> should.be_true() Error(client.ResponseError(_)) -> should.fail() Ok(_) -> should.fail() } @@ -616,8 +624,11 @@ pub fn playback_of_error_recording_preserves_status_and_headers_test() { }) has_custom |> should.be_true() } - Error(client.RequestError(message: msg)) -> { - io.println("Expected ResponseError, got RequestError: " <> msg) + Error(client.RequestError(error: transport_error)) -> { + io.println( + "Expected ResponseError, got RequestError: " + <> client.transport_error_to_string(transport_error), + ) should.fail() } Ok(_) -> { @@ -1230,7 +1241,7 @@ fn extract_query_from_get_response(body: String) -> String { } fn stream_chunks_to_string( - chunks: List(Result(bytes_tree.BytesTree, String)), + chunks: List(Result(bytes_tree.BytesTree, client.StreamFailure)), ) -> String { chunks |> list.filter_map(fn(chunk) { chunk }) diff --git a/modules/http_client/test/redirect_test.gleam b/modules/http_client/test/redirect_test.gleam new file mode 100644 index 0000000..c885ccd --- /dev/null +++ b/modules/http_client/test/redirect_test.gleam @@ -0,0 +1,183 @@ +//// Auto-redirect integration tests +//// +//// Tests the shim's manual redirect-following logic against live mock server +//// endpoints. Gun does not auto-redirect natively, so the shim implements +//// redirect following for 301, 302, 303, 307, and 308 status codes. + +import dream_http_client/client +import dream_http_client_test +import gleam/bit_array +import gleam/bytes_tree +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/string +import gleam/yielder +import gleeunit/should + +fn mock_request(path: String) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(dream_http_client_test.get_test_port()) + |> client.path(path) +} + +// ============================================================================ +// send() redirect tests +// ============================================================================ + +pub fn send_follows_301_redirect_test() { + let req = mock_request("/redirect/301") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_302_redirect_test() { + let req = mock_request("/redirect/302") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_303_redirect_test() { + let req = mock_request("/redirect/303") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_307_redirect_test() { + let req = mock_request("/redirect/307") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_308_redirect_test() { + let req = mock_request("/redirect/308") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_auto_redirect_false_returns_3xx_test() { + let req = + mock_request("/redirect/301") + |> client.auto_redirect(False) + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(301) + let has_location = + list.any(resp.headers, fn(h) { string.lowercase(h.name) == "location" }) + has_location |> should.be_true() +} + +pub fn send_follows_redirect_chain_test() { + let req = mock_request("/redirect/chain") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +pub fn send_follows_absolute_url_redirect_test() { + let req = mock_request("/redirect/absolute") + let assert Ok(resp) = client.send(req) + resp.status |> should.equal(200) + string.contains(resp.body, "Hello") |> should.be_true() +} + +// ============================================================================ +// stream_yielder() redirect tests +// ============================================================================ + +pub fn stream_yielder_follows_301_redirect_test() { + let req = mock_request("/redirect/301") + let results = client.stream_yielder(req) |> yielder.to_list + + { results != [] } |> should.be_true() + + let combined = combine_stream_chunks(results) + string.contains(combined, "Hello") |> should.be_true() +} + +pub fn stream_yielder_follows_redirect_chain_test() { + let req = mock_request("/redirect/chain") + let results = client.stream_yielder(req) |> yielder.to_list + + { results != [] } |> should.be_true() + + let combined = combine_stream_chunks(results) + string.contains(combined, "Hello") |> should.be_true() +} + +// ============================================================================ +// start_stream() redirect tests +// ============================================================================ + +pub fn start_stream_follows_301_redirect_test() { + let chunks_subject = process.new_subject() + let ended_subject = process.new_subject() + let error_subject = process.new_subject() + + let request = + mock_request("/redirect/301") + |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) + |> client.on_stream_end(fn(_headers) { process.send(ended_subject, True) }) + |> client.on_stream_error(fn(_failure) { process.send(error_subject, Nil) }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(ended_subject, 10_000) { + Ok(True) -> { + let chunks = collect_chunks(chunks_subject, []) + { chunks != [] } |> should.be_true() + let combined = combine_bit_chunks(chunks) + string.contains(combined, "Hello") |> should.be_true() + } + Ok(False) -> should.fail() + Error(Nil) -> { + case process.receive(error_subject, 1000) { + Ok(_) -> should.fail() + Error(Nil) -> should.fail() + } + } + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn collect_chunks( + subject: process.Subject(BitArray), + acc: List(BitArray), +) -> List(BitArray) { + case process.receive(subject, 100) { + Ok(item) -> collect_chunks(subject, [item, ..acc]) + Error(Nil) -> list.reverse(acc) + } +} + +fn combine_stream_chunks( + results: List(Result(bytes_tree.BytesTree, client.StreamFailure)), +) -> String { + let chunks = + list.filter_map(results, fn(r) { + case r { + Ok(bt) -> Ok(bytes_tree.to_bit_array(bt)) + Error(_) -> Error(Nil) + } + }) + combine_bit_chunks(chunks) +} + +fn combine_bit_chunks(chunks: List(BitArray)) -> String { + let combined = + list.fold(chunks, <<>>, fn(acc, chunk) { bit_array.append(acc, chunk) }) + case bit_array.to_string(combined) { + Ok(s) -> s + Error(Nil) -> "" + } +} diff --git a/modules/http_client/test/snippets/connect_timeout_config.gleam b/modules/http_client/test/snippets/connect_timeout_config.gleam new file mode 100644 index 0000000..4298607 --- /dev/null +++ b/modules/http_client/test/snippets/connect_timeout_config.gleam @@ -0,0 +1,15 @@ +import dream_http_client/client.{ + type HttpResponse, type SendError, connect_timeout, host, path, port, scheme, + send, +} +import gleam/http + +pub fn fast_timeout() -> Result(HttpResponse, SendError) { + client.new() + |> scheme(http.Http) + |> host("localhost") + |> port(9876) + |> path("/text") + |> connect_timeout(5000) + |> send() +} diff --git a/modules/http_client/test/snippets/protocols_config.gleam b/modules/http_client/test/snippets/protocols_config.gleam new file mode 100644 index 0000000..31c9e50 --- /dev/null +++ b/modules/http_client/test/snippets/protocols_config.gleam @@ -0,0 +1,14 @@ +//// Demonstrates using HTTP/2 over cleartext (h2c) with protocol preference. + +import dream_http_client/client.{type HttpResponse, type SendError, Http2Only} +import gleam/http + +pub fn h2c_request() -> Result(HttpResponse, SendError) { + client.new() + |> client.scheme(http.Http) + |> client.host("internal-service.local") + |> client.port(8080) + |> client.path("/api/data") + |> client.protocols(Http2Only) + |> client.send() +} diff --git a/modules/http_client/test/snippets/recording_playback.gleam b/modules/http_client/test/snippets/recording_playback.gleam index 4c303b4..0a52d9d 100644 --- a/modules/http_client/test/snippets/recording_playback.gleam +++ b/modules/http_client/test/snippets/recording_playback.gleam @@ -45,7 +45,9 @@ pub fn test_with_playback() -> Result(HttpResponse, SendError) { |> directory(recordings_directory_path) |> mode("record") |> start() - |> result.map_error(fn(e) { client.RequestError(message: e) }), + |> result.map_error(fn(e) { + client.RequestError(error: client.Unexpected(raw: e)) + }), ) recorder.add_recording(rec, test_recording) @@ -57,7 +59,9 @@ pub fn test_with_playback() -> Result(HttpResponse, SendError) { |> directory(recordings_directory_path) |> mode("playback") |> start() - |> result.map_error(fn(e) { client.RequestError(message: e) }), + |> result.map_error(fn(e) { + client.RequestError(error: client.Unexpected(raw: e)) + }), ) // Make request - returns recorded response without network call diff --git a/modules/http_client/test/snippets/redirect_config.gleam b/modules/http_client/test/snippets/redirect_config.gleam new file mode 100644 index 0000000..2d5afbc --- /dev/null +++ b/modules/http_client/test/snippets/redirect_config.gleam @@ -0,0 +1,15 @@ +import dream_http_client/client.{ + type HttpResponse, type SendError, auto_redirect, host, path, port, scheme, + send, +} +import gleam/http + +pub fn no_auto_redirect() -> Result(HttpResponse, SendError) { + client.new() + |> scheme(http.Http) + |> host("localhost") + |> port(9876) + |> path("/text") + |> auto_redirect(False) + |> send() +} diff --git a/modules/http_client/test/snippets/stream_messages_basic.gleam b/modules/http_client/test/snippets/stream_messages_basic.gleam index 5b05bcf..12d8431 100644 --- a/modules/http_client/test/snippets/stream_messages_basic.gleam +++ b/modules/http_client/test/snippets/stream_messages_basic.gleam @@ -5,7 +5,7 @@ import dream_http_client/client.{ await_stream, host, on_stream_chunk, on_stream_end, on_stream_error, - on_stream_start, path, port, scheme, start_stream, + on_stream_start, path, port, scheme, start_stream, stream_failure_to_string, } import gleam/bit_array import gleam/http @@ -27,8 +27,8 @@ pub fn stream_and_print() -> Result(Nil, String) { } }) |> on_stream_end(fn(_headers) { io.println("\nStream completed") }) - |> on_stream_error(fn(reason) { - io.println_error("Stream error: " <> reason) + |> on_stream_error(fn(failure) { + io.println_error("Stream error: " <> stream_failure_to_string(failure)) }) |> start_stream() diff --git a/modules/http_client/test/snippets/transport_config_example.gleam b/modules/http_client/test/snippets/transport_config_example.gleam new file mode 100644 index 0000000..54ad603 --- /dev/null +++ b/modules/http_client/test/snippets/transport_config_example.gleam @@ -0,0 +1,9 @@ +import dream_http_client/client + +pub fn configure_high_concurrency() -> Nil { + client.transport_config() + |> client.max_connections(200) + |> client.idle_timeout(120_000) + |> client.max_concurrent_streams(500) + |> client.configure_transport() +} diff --git a/modules/http_client/test/start_stream_test.gleam b/modules/http_client/test/start_stream_test.gleam index 5df6080..0bd198e 100644 --- a/modules/http_client/test/start_stream_test.gleam +++ b/modules/http_client/test/start_stream_test.gleam @@ -97,13 +97,15 @@ pub fn start_stream_calls_on_error_for_network_failure_test() { |> client.host("localhost") |> client.port(19_999) |> client.path("/") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) // Act let assert Ok(_handle) = client.start_stream(request) - // Assert - on_error was called - case process.receive(error_subject, 2000) { + // Assert - on_error was called (gun retries 3 times with 1s between retries) + case process.receive(error_subject, 10_000) { Ok(reason) -> { { reason != "" } |> should.be_true() } diff --git a/modules/http_client/test/stream_error_decode_test.gleam b/modules/http_client/test/stream_error_decode_test.gleam index c19b46c..126a8b7 100644 --- a/modules/http_client/test/stream_error_decode_test.gleam +++ b/modules/http_client/test/stream_error_decode_test.gleam @@ -1,15 +1,14 @@ //// Tests for stream error reason decoding robustness //// -//// Verifies that the HTTP client can decode error reasons from Erlang's httpc -//// regardless of the error format: transport-level errors (atoms/tuples from -//// httpc), non-UTF-8 response bodies, and connection failures. Both the +//// Verifies that the HTTP client can decode error reasons from gun +//// regardless of the error format: transport-level errors (atoms/tuples), +//// non-UTF-8 response bodies, and connection failures. Both the //// message-based (start_stream) and pull-based (stream_yielder) paths are //// tested. //// //// These tests close the gap left by stream_non_streaming_response_test.gleam, -//// which only covered HTTP error responses (complete response messages). The -//// tests here cover the {error, Reason} message path from httpc, which fires -//// on transport-level failures like connection refused and socket drops. +//// which only covered HTTP error responses. The tests here cover +//// transport-level failures like connection refused and socket drops. import dream_http_client/client import dream_http_client_test @@ -49,7 +48,9 @@ pub fn start_stream_connection_refused_surfaces_error_test() { let request = dead_port_request() - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let _result = client.start_stream(request) @@ -78,7 +79,8 @@ pub fn stream_yielder_connection_refused_surfaces_error_test() { let assert [first, ..] = results case first { - Error(reason) -> { + Error(failure) -> { + let reason = client.stream_failure_to_string(failure) { string.length(reason) > 0 } |> should.be_true() io.println( "stream_yielder connection refused error: " @@ -96,7 +98,8 @@ pub fn stream_yielder_connection_refused_surfaces_error_test() { pub fn send_connection_refused_surfaces_error_test() { let req = dead_port_request() case client.send(req) { - Error(client.RequestError(message: reason)) -> { + Error(client.RequestError(error: transport_error)) -> { + let reason = client.transport_error_to_string(transport_error) { string.length(reason) > 0 } |> should.be_true() io.println( "send() connection refused error: " <> string.slice(reason, 0, 80), @@ -125,7 +128,9 @@ pub fn start_stream_connection_drop_surfaces_error_test() { let request = mock_request("/stream/drop") |> client.on_stream_chunk(fn(data) { process.send(chunk_subject, data) }) - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -172,7 +177,9 @@ pub fn start_stream_non_utf8_error_body_surfaces_error_test() { let request = mock_request("/non-utf8-error") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -202,7 +209,8 @@ pub fn stream_yielder_non_utf8_error_body_surfaces_error_test() { let assert [first, ..] = results case first { - Error(reason) -> { + Error(failure) -> { + let reason = client.stream_failure_to_string(failure) { string.length(reason) > 0 } |> should.be_true() string.contains(reason, "400") |> should.be_true() io.println( @@ -223,7 +231,8 @@ pub fn stream_yielder_non_utf8_error_body_surfaces_error_test() { pub fn send_non_utf8_error_body_surfaces_error_test() { let req = mock_request("/non-utf8-error") case client.send(req) { - Error(client.RequestError(message: msg)) -> { + Error(client.RequestError(error: transport_error)) -> { + let msg = client.transport_error_to_string(transport_error) { string.length(msg) > 0 } |> should.be_true() io.println("send() non-UTF-8 body error (expected): " <> msg) } @@ -249,7 +258,9 @@ pub fn connection_refused_error_is_not_unknown_test() { let request = dead_port_request() - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let _result = client.start_stream(request) diff --git a/modules/http_client/test/stream_non_streaming_response_test.gleam b/modules/http_client/test/stream_non_streaming_response_test.gleam index b49ef34..acd3b30 100644 --- a/modules/http_client/test/stream_non_streaming_response_test.gleam +++ b/modules/http_client/test/stream_non_streaming_response_test.gleam @@ -2,10 +2,10 @@ //// //// When a streaming HTTP request is made via start_stream/stream_yielder and //// the upstream returns a non-streaming response (e.g. HTTP 401/500 with a -//// JSON body), Erlang's httpc sends a complete response message instead of -//// the expected stream_start/stream/stream_end sequence. +//// body), the gun shim detects non-2xx status codes and surfaces them as +//// errors through the existing error paths. //// -//// These tests verify that complete response messages are handled gracefully +//// These tests verify that non-2xx responses are handled gracefully //// and surfaced through the existing error paths rather than crashing the //// stream process or silently hanging. @@ -38,7 +38,9 @@ pub fn start_stream_calls_on_error_for_401_non_streaming_response_test() { let request = mock_request("/status/401") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -59,7 +61,9 @@ pub fn start_stream_calls_on_error_for_500_non_streaming_response_test() { let request = mock_request("/status/500") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -80,7 +84,9 @@ pub fn start_stream_error_contains_status_code_test() { let request = mock_request("/status/403") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -102,7 +108,9 @@ pub fn start_stream_error_contains_response_body_test() { let request = mock_request("/status/429") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(_handle) = client.start_stream(request) @@ -124,7 +132,9 @@ pub fn start_stream_does_not_crash_process_on_non_streaming_response_test() { let request = mock_request("/status/401") - |> client.on_stream_error(fn(reason) { process.send(error_subject, reason) }) + |> client.on_stream_error(fn(failure) { + process.send(error_subject, client.stream_failure_to_string(failure)) + }) let assert Ok(handle) = client.start_stream(request) @@ -157,7 +167,9 @@ pub fn stream_yielder_returns_error_for_401_non_streaming_response_test() { let assert [first, ..] = results case first { - Error(reason) -> string.contains(reason, "401") |> should.be_true() + Error(failure) -> + string.contains(client.stream_failure_to_string(failure), "401") + |> should.be_true() Ok(_) -> { io.println("Expected Error, got Ok") should.fail() @@ -174,7 +186,9 @@ pub fn stream_yielder_returns_error_for_500_non_streaming_response_test() { let assert [first, ..] = results case first { - Error(reason) -> string.contains(reason, "500") |> should.be_true() + Error(failure) -> + string.contains(client.stream_failure_to_string(failure), "500") + |> should.be_true() Ok(_) -> { io.println("Expected Error, got Ok") should.fail() @@ -191,7 +205,8 @@ pub fn stream_yielder_error_contains_response_body_test() { let assert [first, ..] = results case first { - Error(reason) -> { + Error(failure) -> { + let reason = client.stream_failure_to_string(failure) string.contains(reason, "422") |> should.be_true() { string.length(reason) > 10 } |> should.be_true() } @@ -242,8 +257,11 @@ pub fn stream_yielder_normal_streaming_still_works_test() { list.all(results, fn(result) { case result { Ok(_) -> True - Error(reason) -> { - io.println("Unexpected error in normal stream: " <> reason) + Error(failure) -> { + io.println( + "Unexpected error in normal stream: " + <> client.stream_failure_to_string(failure), + ) False } } @@ -257,3 +275,57 @@ fn collect_from_subject(subject: process.Subject(a), acc: List(a)) -> List(a) { Error(Nil) -> list.reverse(acc) } } + +// ============================================================================ +// Structured error type verification +// ============================================================================ + +/// HttpFailure from a 401 must carry response headers (not empty) +pub fn start_stream_401_http_failure_carries_headers_test() { + let error_subject = process.new_subject() + + let request = + mock_request("/status/401") + |> client.on_stream_error(fn(failure) { + process.send(error_subject, failure) + }) + + let assert Ok(_handle) = client.start_stream(request) + + case process.receive(error_subject, 3000) { + Ok(client.HttpFailure(response: response)) -> { + { response.headers != [] } |> should.be_true() + } + Ok(client.TransportFailure(_)) -> { + io.println("Expected HttpFailure, got TransportFailure") + should.fail() + } + Error(Nil) -> { + io.println("on_stream_error was never called") + should.fail() + } + } +} + +/// HttpFailure from a 500 via stream_yielder must carry a non-empty body +pub fn stream_yielder_500_http_failure_carries_body_test() { + let req = mock_request("/status/500") + let results = client.stream_yielder(req) |> yielder.take(1) |> yielder.to_list + + { results != [] } |> should.be_true() + + let assert [first, ..] = results + case first { + Error(client.HttpFailure(response: response)) -> { + { response.body != "" } |> should.be_true() + } + Error(client.TransportFailure(_)) -> { + io.println("Expected HttpFailure, got TransportFailure") + should.fail() + } + Ok(_) -> { + io.println("Expected Error, got Ok") + should.fail() + } + } +} diff --git a/modules/http_client/test/stream_yielder_completion_test.gleam b/modules/http_client/test/stream_yielder_completion_test.gleam index e40f634..8565c0b 100644 --- a/modules/http_client/test/stream_yielder_completion_test.gleam +++ b/modules/http_client/test/stream_yielder_completion_test.gleam @@ -42,11 +42,10 @@ pub fn stream_completes_without_error_test() { list.all(results, fn(result) { case result { Ok(_) -> True - Error(error_reason) -> { - // Unexpected error in stream; this test expects only Ok results. + Error(failure) -> { io.println( "stream_completes_without_error_test saw unexpected error: " - <> error_reason, + <> client.stream_failure_to_string(failure), ) False } @@ -68,9 +67,10 @@ pub fn last_chunk_is_ok_not_error_test() { case list.last(results) { Ok(Ok(_chunk)) -> Nil // Correct! - Ok(Error(error_reason)) -> { + Ok(Error(failure)) -> { io.println( - "last_chunk_is_ok_not_error_test saw unexpected error: " <> error_reason, + "last_chunk_is_ok_not_error_test saw unexpected error: " + <> client.stream_failure_to_string(failure), ) should.fail() } @@ -95,10 +95,10 @@ pub fn to_list_works_correctly_test() { list.all(results, fn(result) { case result { Ok(_) -> True - Error(error_reason) -> { + Error(failure) -> { io.println( "to_list_works_correctly_test saw unexpected error: " - <> error_reason, + <> client.stream_failure_to_string(failure), ) False } diff --git a/modules/http_client/test/transport_config_test.gleam b/modules/http_client/test/transport_config_test.gleam new file mode 100644 index 0000000..c5ed428 --- /dev/null +++ b/modules/http_client/test/transport_config_test.gleam @@ -0,0 +1,194 @@ +import dream_http_client/client.{ + LogDebug, LogError, LogInfo, LogNone, LogWarning, +} +import gleeunit/should + +pub fn transport_config_has_correct_defaults_test() { + let config = client.transport_config() + + client.get_max_connections(config) |> should.equal(50) + client.get_idle_timeout(config) |> should.equal(60_000) + client.get_default_connect_timeout(config) |> should.equal(15_000) + client.get_domain_lookup_timeout(config) |> should.equal(5000) + client.get_tls_handshake_timeout(config) |> should.equal(10_000) + client.get_retry(config) |> should.equal(3) + client.get_retry_timeout(config) |> should.equal(1000) + client.get_keepalive(config) |> should.equal(30_000) + client.get_keepalive_tolerance(config) |> should.equal(3) + client.get_max_concurrent_streams(config) |> should.equal(100) + client.get_initial_connection_window_size(config) |> should.equal(65_535) + client.get_initial_stream_window_size(config) |> should.equal(65_535) + client.get_closing_timeout(config) |> should.equal(15_000) + client.get_log_level(config) |> should.equal(LogInfo) +} + +pub fn max_connections_sets_value_test() { + let config = client.transport_config() + let updated = client.max_connections(config, 200) + client.get_max_connections(updated) |> should.equal(200) +} + +pub fn max_connections_accepts_one_test() { + let config = client.transport_config() + let updated = client.max_connections(config, 1) + client.get_max_connections(updated) |> should.equal(1) +} + +pub fn idle_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.idle_timeout(config, 120_000) + client.get_idle_timeout(updated) |> should.equal(120_000) +} + +pub fn default_connect_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.default_connect_timeout(config, 30_000) + client.get_default_connect_timeout(updated) |> should.equal(30_000) +} + +pub fn domain_lookup_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.domain_lookup_timeout(config, 10_000) + client.get_domain_lookup_timeout(updated) |> should.equal(10_000) +} + +pub fn tls_handshake_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.tls_handshake_timeout(config, 20_000) + client.get_tls_handshake_timeout(updated) |> should.equal(20_000) +} + +pub fn retry_sets_value_test() { + let config = client.transport_config() + let updated = client.retry(config, 5) + client.get_retry(updated) |> should.equal(5) +} + +pub fn retry_accepts_zero_test() { + let config = client.transport_config() + let updated = client.retry(config, 0) + client.get_retry(updated) |> should.equal(0) +} + +pub fn retry_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.retry_timeout(config, 5000) + client.get_retry_timeout(updated) |> should.equal(5000) +} + +pub fn keepalive_sets_value_test() { + let config = client.transport_config() + let updated = client.keepalive(config, 60_000) + client.get_keepalive(updated) |> should.equal(60_000) +} + +pub fn keepalive_tolerance_sets_value_test() { + let config = client.transport_config() + let updated = client.keepalive_tolerance(config, 5) + client.get_keepalive_tolerance(updated) |> should.equal(5) +} + +pub fn keepalive_tolerance_accepts_zero_test() { + let config = client.transport_config() + let updated = client.keepalive_tolerance(config, 0) + client.get_keepalive_tolerance(updated) |> should.equal(0) +} + +pub fn max_concurrent_streams_sets_value_test() { + let config = client.transport_config() + let updated = client.max_concurrent_streams(config, 500) + client.get_max_concurrent_streams(updated) |> should.equal(500) +} + +pub fn max_concurrent_streams_accepts_one_test() { + let config = client.transport_config() + let updated = client.max_concurrent_streams(config, 1) + client.get_max_concurrent_streams(updated) |> should.equal(1) +} + +pub fn initial_connection_window_size_sets_value_test() { + let config = client.transport_config() + let updated = client.initial_connection_window_size(config, 131_070) + client.get_initial_connection_window_size(updated) |> should.equal(131_070) +} + +pub fn initial_stream_window_size_sets_value_test() { + let config = client.transport_config() + let updated = client.initial_stream_window_size(config, 131_070) + client.get_initial_stream_window_size(updated) |> should.equal(131_070) +} + +pub fn closing_timeout_sets_value_test() { + let config = client.transport_config() + let updated = client.closing_timeout(config, 30_000) + client.get_closing_timeout(updated) |> should.equal(30_000) +} + +pub fn log_level_defaults_to_info_test() { + let config = client.transport_config() + client.get_log_level(config) |> should.equal(LogInfo) +} + +pub fn log_level_sets_debug_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogDebug) + client.get_log_level(updated) |> should.equal(LogDebug) +} + +pub fn log_level_sets_warning_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogWarning) + client.get_log_level(updated) |> should.equal(LogWarning) +} + +pub fn log_level_sets_error_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogError) + client.get_log_level(updated) |> should.equal(LogError) +} + +pub fn log_level_sets_none_test() { + let config = client.transport_config() + let updated = client.log_level(config, LogNone) + client.get_log_level(updated) |> should.equal(LogNone) +} + +pub fn transport_config_builder_chain_sets_all_values_test() { + let config = + client.transport_config() + |> client.max_connections(200) + |> client.idle_timeout(120_000) + |> client.default_connect_timeout(30_000) + |> client.domain_lookup_timeout(10_000) + |> client.tls_handshake_timeout(20_000) + |> client.retry(5) + |> client.retry_timeout(5000) + |> client.keepalive(60_000) + |> client.keepalive_tolerance(5) + |> client.max_concurrent_streams(500) + |> client.initial_connection_window_size(131_070) + |> client.initial_stream_window_size(131_070) + |> client.closing_timeout(30_000) + |> client.log_level(LogWarning) + + client.get_max_connections(config) |> should.equal(200) + client.get_idle_timeout(config) |> should.equal(120_000) + client.get_default_connect_timeout(config) |> should.equal(30_000) + client.get_domain_lookup_timeout(config) |> should.equal(10_000) + client.get_tls_handshake_timeout(config) |> should.equal(20_000) + client.get_retry(config) |> should.equal(5) + client.get_retry_timeout(config) |> should.equal(5000) + client.get_keepalive(config) |> should.equal(60_000) + client.get_keepalive_tolerance(config) |> should.equal(5) + client.get_max_concurrent_streams(config) |> should.equal(500) + client.get_initial_connection_window_size(config) |> should.equal(131_070) + client.get_initial_stream_window_size(config) |> should.equal(131_070) + client.get_closing_timeout(config) |> should.equal(30_000) + client.get_log_level(config) |> should.equal(LogWarning) +} + +pub fn configure_transport_applies_without_error_test() { + let config = client.transport_config() + let result = client.configure_transport(config) + result |> should.equal(Nil) +} diff --git a/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam b/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam index 035242b..9b3143f 100644 --- a/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam +++ b/modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam @@ -46,6 +46,15 @@ pub fn put( json_response(status.ok, api_view.put_to_json(request.path, request.body)) } +/// PATCH /patch - Echoes request body as JSON +pub fn patch( + request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + json_response(status.ok, api_view.patch_to_json(request.path, request.body)) +} + /// DELETE /delete - Returns success response pub fn delete( request: Request, @@ -266,3 +275,91 @@ pub fn echo_accept_encoding( } text_response(status.ok, value) } + +/// GET /redirect/301 - Returns 301 with Location: /text +pub fn redirect_301( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.moved_permanently, "/text") +} + +/// GET /redirect/302 - Returns 302 with Location: /text +pub fn redirect_302( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.found, "/text") +} + +/// GET /redirect/303 - Returns 303 with Location: /text (forces GET) +pub fn redirect_303( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.see_other, "/text") +} + +/// GET /redirect/307 - Returns 307 with Location: /text (preserves method) +pub fn redirect_307( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.temporary_redirect, "/text") +} + +/// GET /redirect/308 - Returns 308 with Location: /text (preserves method, permanent) +pub fn redirect_308( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(308, "/text") +} + +/// GET /redirect/chain - Returns 302 with Location: /redirect/chain/2 +pub fn redirect_chain( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.found, "/redirect/chain/2") +} + +/// GET /redirect/chain/2 - Returns 302 with Location: /text +pub fn redirect_chain_2( + _request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + redirect_response(status.found, "/text") +} + +/// GET /redirect/absolute - Returns 302 with a fully-qualified Location URL +pub fn redirect_absolute( + request: Request, + _context: EmptyContext, + _services: EmptyServices, +) -> Response { + let port_str = case + list.find(request.headers, fn(h) { string.lowercase(h.name) == "host" }) + { + Ok(host_header) -> host_header.value + Error(Nil) -> "localhost:9876" + } + redirect_response(status.found, "http://" <> port_str <> "/text") +} + +fn redirect_response(status_code: Int, location: String) -> Response { + response.Response( + status: status_code, + body: response.Text(""), + headers: [Header("Location", location)], + cookies: [], + content_type: option.None, + ) +} diff --git a/modules/mock_server/src/dream_mock_server/router.gleam b/modules/mock_server/src/dream_mock_server/router.gleam index 3205e3c..06a4e77 100644 --- a/modules/mock_server/src/dream_mock_server/router.gleam +++ b/modules/mock_server/src/dream_mock_server/router.gleam @@ -18,6 +18,15 @@ //// - `GET /empty` - Returns empty response body //// - `GET /slow` - Returns response after 5s delay //// +//// **Redirect:** +//// - `GET /redirect/301` - 301 redirect to /text +//// - `GET /redirect/302` - 302 redirect to /text +//// - `GET /redirect/303` - 303 redirect to /text (forces GET) +//// - `GET /redirect/307` - 307 redirect to /text (preserves method) +//// - `GET /redirect/308` - 308 redirect to /text (preserves method, permanent) +//// - `GET /redirect/chain` - 302 chain: /chain -> /chain/2 -> /text +//// - `GET /redirect/absolute` - 302 with fully-qualified Location URL +//// //// **Streaming:** //// - `GET /` - Info page //// - `GET /stream/fast` - 10 chunks @ 100ms @@ -74,6 +83,12 @@ pub fn create_router() -> Router(EmptyContext, EmptyServices) { controller: api_controller.put, middleware: [], ) + |> route( + method: Patch, + path: "/patch", + controller: api_controller.patch, + middleware: [], + ) |> route( method: Delete, path: "/delete", @@ -165,6 +180,55 @@ pub fn create_router() -> Router(EmptyContext, EmptyServices) { controller: api_controller.non_utf8_error, middleware: [], ) + // Redirect endpoints + |> route( + method: Get, + path: "/redirect/301", + controller: api_controller.redirect_301, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/302", + controller: api_controller.redirect_302, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/303", + controller: api_controller.redirect_303, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/307", + controller: api_controller.redirect_307, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/308", + controller: api_controller.redirect_308, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/chain", + controller: api_controller.redirect_chain, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/chain/2", + controller: api_controller.redirect_chain_2, + middleware: [], + ) + |> route( + method: Get, + path: "/redirect/absolute", + controller: api_controller.redirect_absolute, + middleware: [], + ) // Streaming endpoints |> route( method: Get, diff --git a/modules/mock_server/src/dream_mock_server/views/api_view.gleam b/modules/mock_server/src/dream_mock_server/views/api_view.gleam index 35f8ac3..a3b43cc 100644 --- a/modules/mock_server/src/dream_mock_server/views/api_view.gleam +++ b/modules/mock_server/src/dream_mock_server/views/api_view.gleam @@ -23,6 +23,12 @@ pub fn put_to_json(path: String, body: String) -> String { |> json.to_string() } +/// Format PATCH request info as JSON string +pub fn patch_to_json(path: String, body: String) -> String { + patch_to_json_object(path, body) + |> json.to_string() +} + /// Format DELETE request info as JSON string pub fn delete_to_json(path: String) -> String { delete_to_json_object(path) @@ -85,6 +91,14 @@ fn put_to_json_object(path: String, body: String) -> json.Json { ]) } +fn patch_to_json_object(path: String, body: String) -> json.Json { + json.object([ + #("method", json.string("PATCH")), + #("url", json.string(path)), + #("data", json.string(body)), + ]) +} + fn delete_to_json_object(path: String) -> json.Json { json.object([ #("method", json.string("DELETE")), diff --git a/modules/opensearch/manifest.toml b/modules/opensearch/manifest.toml index 3b92965..7b95d0e 100644 --- a/modules/opensearch/manifest.toml +++ b/modules/opensearch/manifest.toml @@ -2,17 +2,19 @@ # You typically do not need to edit this file packages = [ - { name = "dream_http_client", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "simplifile"], source = "local", path = "../http_client" }, + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "dream_http_client", version = "5.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gleam_yielder", "gun", "simplifile"], source = "local", path = "../http_client" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, - { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, - { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, ] [requirements] diff --git a/modules/opensearch/src/dream_opensearch/client.gleam b/modules/opensearch/src/dream_opensearch/client.gleam index c106c01..6377566 100644 --- a/modules/opensearch/src/dream_opensearch/client.gleam +++ b/modules/opensearch/src/dream_opensearch/client.gleam @@ -138,7 +138,8 @@ pub fn send_request( body: body, .., ))) -> Error(body) - Error(http_client.RequestError(message: message)) -> Error(message) + Error(http_client.RequestError(error: transport_error)) -> + Error(http_client.transport_error_to_string(transport_error)) } } diff --git a/research/gun_research/gleam.toml b/research/gun_research/gleam.toml new file mode 100644 index 0000000..c16ee82 --- /dev/null +++ b/research/gun_research/gleam.toml @@ -0,0 +1,11 @@ +name = "gun_research" +version = "0.0.0" +description = "Research: gun HTTP/2 client for dream_http_client" + +[dependencies] +gleam_stdlib = ">= 0.60.0 and < 1.0.0" +gleam_erlang = ">= 1.1.0 and < 2.0.0" +gun = ">= 2.2.0 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/research/gun_research/manifest.toml b/research/gun_research/manifest.toml new file mode 100644 index 0000000..7fd075d --- /dev/null +++ b/research/gun_research/manifest.toml @@ -0,0 +1,16 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "cowlib", version = "2.16.0", build_tools = ["make", "rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "gun", version = "2.2.0", build_tools = ["make", "rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A" }, +] + +[requirements] +gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +gun = { version = ">= 2.2.0 and < 3.0.0" } diff --git a/research/gun_research/src/gun_bench.erl b/research/gun_research/src/gun_bench.erl new file mode 100644 index 0000000..87ce5fd --- /dev/null +++ b/research/gun_research/src/gun_bench.erl @@ -0,0 +1,545 @@ +-module(gun_bench). +-export([ + run_all/0, + test_http1_sync/0, + test_http2_negotiation/0, + test_http2_multiplexing/0, + test_http2_multiplexing_single_conn/0, + test_concurrent_scale/0, + test_streaming/0, + test_cancel_stream/0, + test_error_handling/0, + test_connection_reuse/0, + test_connect_timeout/0, + test_auto_decompression/0, + test_h2_cancel_stream/0 +]). + +-define(BASE_HOST, "localhost"). +-define(BASE_PORT, 3004). + +run_all() -> + io:format("Starting gun research...~n~n"), + io:format("========================================~n"), + io:format(" GUN RESEARCH RESULTS~n"), + io:format("========================================~n~n"), + + io:format("--- 1. HTTP/1.1 sync request (localhost) ---~n"), + try test_http1_sync(), + io:format(" RESULT: PASS~n~n") + catch C1:R1:S1 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C1, R1, hd(S1)]) + end, + + io:format("--- 2. HTTP/2 negotiation (real HTTPS server) ---~n"), + try test_http2_negotiation(), + io:format(" RESULT: PASS~n~n") + catch C2:R2:S2 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C2, R2, hd(S2)]) + end, + + io:format("--- 3. HTTP/2 multiplexing (real HTTPS, multiple streams) ---~n"), + try test_http2_multiplexing(), + io:format(" RESULT: PASS~n~n") + catch C3:R3:S3 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C3, R3, hd(S3)]) + end, + + io:format("--- 4. HTTP/2 multiplexing (single conn, many streams) ---~n"), + try test_http2_multiplexing_single_conn(), + io:format(" RESULT: PASS~n~n") + catch C4:R4:S4 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C4, R4, hd(S4)]) + end, + + io:format("--- 5. Concurrent scale (100, 500, 1000, 5000 against localhost) ---~n"), + try test_concurrent_scale(), + io:format(" RESULT: PASS~n~n") + catch C5:R5:S5 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C5, R5, hd(S5)]) + end, + + io:format("--- 6. Streaming response ---~n"), + try test_streaming(), + io:format(" RESULT: PASS~n~n") + catch C6:R6:S6 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C6, R6, hd(S6)]) + end, + + io:format("--- 7. Cancel stream ---~n"), + try test_cancel_stream(), + io:format(" RESULT: PASS~n~n") + catch C7:R7:S7 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C7, R7, hd(S7)]) + end, + + io:format("--- 8. Error handling ---~n"), + try test_error_handling(), + io:format(" RESULT: PASS~n~n") + catch C8:R8:S8 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C8, R8, hd(S8)]) + end, + + io:format("--- 9. Connection reuse (many requests, one conn) ---~n"), + try test_connection_reuse(), + io:format(" RESULT: PASS~n~n") + catch C9:R9:S9 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C9, R9, hd(S9)]) + end, + + io:format("--- 10. Connect timeout behavior ---~n"), + try test_connect_timeout(), + io:format(" RESULT: PASS~n~n") + catch C10:R10:S10 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C10, R10, hd(S10)]) + end, + + io:format("--- 11. Auto decompression ---~n"), + try test_auto_decompression(), + io:format(" RESULT: PASS~n~n") + catch C11:R11:S11 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C11, R11, hd(S11)]) + end, + + io:format("--- 12. HTTP/2 cancel stream (connection survives) ---~n"), + try test_h2_cancel_stream(), + io:format(" RESULT: PASS~n~n") + catch C12:R12:S12 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C12, R12, hd(S12)]) + end, + + io:format("========================================~n"), + io:format(" DONE~n"), + io:format("========================================~n"), + ok. + +%% Test 1: Basic HTTP/1.1 sync request against mock server +test_http1_sync() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, Protocol} = gun:await_up(ConnPid), + io:format(" Protocol: ~p~n", [Protocol]), + StreamRef = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef, 5000) of + {response, fin, Status, Headers} -> + io:format(" Status: ~p (no body)~n", [Status]), + io:format(" Headers: ~p~n", [Headers]); + {response, nofin, Status, Headers} -> + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + {ok, Body} = gun:await_body(ConnPid, StreamRef, 5000), + io:format(" Body: ~s (~p bytes)~n", [Body, byte_size(Body)]) + end, + gun:close(ConnPid), + ok. + +%% Test 2: HTTP/2 ALPN negotiation with a real HTTPS server +test_http2_negotiation() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}] + }), + case gun:await_up(ConnPid, 10000) of + {ok, Protocol} -> + io:format(" Negotiated protocol: ~p~n", [Protocol]), + StreamRef = gun:get(ConnPid, "/"), + case gun:await(ConnPid, StreamRef, 10000) of + {response, nofin, Status, Headers} -> + io:format(" Status: ~p~n", [Status]), + HeaderNames = [N || {N, _} <- Headers], + AllLower = lists:all(fun(N) -> + N =:= string:lowercase(N) + end, HeaderNames), + io:format(" All headers lowercase: ~p~n", [AllLower]), + io:format(" Sample headers: ~p~n", [lists:sublist(Headers, 3)]), + gun:cancel(ConnPid, StreamRef); + {response, fin, Status, _Headers} -> + io:format(" Status: ~p (no body)~n", [Status]) + end; + {error, Reason} -> + io:format(" Connection failed: ~p~n", [Reason]) + end, + gun:close(ConnPid), + ok. + +%% Test 3: HTTP/2 multiplexing - concurrent requests on separate connections +test_http2_multiplexing() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}] + }), + {ok, http2} = gun:await_up(ConnPid, 10000), + io:format(" Connected via HTTP/2~n"), + + N = 10, + T1 = erlang:monotonic_time(millisecond), + StreamRefs = [gun:get(ConnPid, "/") || _ <- lists:seq(1, N)], + io:format(" Sent ~p concurrent requests on ONE connection~n", [N]), + + Results = lists:map(fun(Ref) -> + case gun:await(ConnPid, Ref, 10000) of + {response, nofin, Status, _H} -> + gun:cancel(ConnPid, Ref), + {ok, Status}; + {response, fin, Status, _H} -> + {ok, Status}; + {error, E} -> + {error, E} + end + end, StreamRefs), + T2 = erlang:monotonic_time(millisecond), + + Successes = length([ok || {ok, _} <- Results]), + io:format(" ~p/~p succeeded in ~pms~n", [Successes, N, T2 - T1]), + io:format(" All on a SINGLE TCP connection (HTTP/2 multiplexing)~n"), + gun:close(ConnPid), + ok. + +%% Test 4: Many HTTP/2 streams on a single connection +test_http2_multiplexing_single_conn() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}], + http2_opts => #{max_concurrent_streams => 1000} + }), + {ok, http2} = gun:await_up(ConnPid, 10000), + + N = 50, + T1 = erlang:monotonic_time(millisecond), + StreamRefs = [gun:get(ConnPid, "/") || _ <- lists:seq(1, N)], + io:format(" Sent ~p requests on single HTTP/2 connection~n", [N]), + + Results = lists:map(fun(Ref) -> + case gun:await(ConnPid, Ref, 15000) of + {response, nofin, Status, _H} -> + gun:cancel(ConnPid, Ref), + {ok, Status}; + {response, fin, Status, _H} -> + {ok, Status}; + {error, E} -> + {error, E} + end + end, StreamRefs), + T2 = erlang:monotonic_time(millisecond), + + Successes = length([ok || {ok, _} <- Results]), + Errors = [E || {error, E} <- Results], + io:format(" ~p/~p succeeded in ~pms~n", [Successes, N, T2 - T1]), + case Errors of + [] -> ok; + _ -> io:format(" Errors: ~p~n", [lists:sublist(Errors, 5)]) + end, + gun:close(ConnPid), + ok. + +%% Test 5: Concurrent scale test against localhost (HTTP/1.1) +%% Each request gets its own gun connection (no pool bottleneck) +test_concurrent_scale() -> + lists:foreach(fun(N) -> + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + Result = try + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http], + connect_timeout => 5000 + }), + {ok, _} = gun:await_up(ConnPid, 5000), + StreamRef = gun:get(ConnPid, "/text"), + Res = case gun:await(ConnPid, StreamRef, 10000) of + {response, nofin, 200, _H} -> + {ok, Body} = gun:await_body(ConnPid, StreamRef, 10000), + {ok, byte_size(Body)}; + {response, fin, 200, _H} -> + {ok, 0}; + Other -> + {error, Other} + end, + gun:close(ConnPid), + Res + catch + _:Err -> {error, Err} + end, + Self ! {gun_done, N, I, Result} + end) + end, lists:seq(1, N)), + Successes = lists:foldl(fun(I, Acc) -> + receive + {gun_done, N, I, {ok, _}} -> Acc + 1; + {gun_done, N, I, _} -> Acc + after 30000 -> Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" gun ~p concurrent: ~p/~p in ~pms~n", [N, Successes, N, T2 - T1]) + end, [100, 500, 1000, 5000]), + ok. + +%% Test 6: Streaming response +test_streaming() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + StreamRef = gun:get(ConnPid, "/stream/10"), + {response, nofin, Status, _Headers} = gun:await(ConnPid, StreamRef, 5000), + io:format(" Status: ~p~n", [Status]), + Chunks = collect_body_chunks(ConnPid, StreamRef, []), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + TotalBytes = lists:sum([byte_size(C) || C <- Chunks]), + io:format(" Total bytes: ~p~n", [TotalBytes]), + gun:close(ConnPid), + ok. + +collect_body_chunks(ConnPid, StreamRef, Acc) -> + receive + {gun_data, ConnPid, StreamRef, nofin, Data} -> + collect_body_chunks(ConnPid, StreamRef, [Data | Acc]); + {gun_data, ConnPid, StreamRef, fin, Data} -> + lists:reverse([Data | Acc]) + after 5000 -> + lists:reverse(Acc) + end. + +%% Test 7: Cancel a stream mid-response +test_cancel_stream() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + StreamRef = gun:get(ConnPid, "/stream/slow"), + case gun:await(ConnPid, StreamRef, 5000) of + {response, nofin, Status, _H} -> + io:format(" Got response status: ~p~n", [Status]), + ok = gun:cancel(ConnPid, StreamRef), + io:format(" Cancelled stream~n"), + gun:flush(StreamRef), + io:format(" Flushed remaining messages~n"); + {response, fin, Status, _H} -> + io:format(" Got complete response: ~p~n", [Status]) + end, + %% Verify connection is still usable after cancel + StreamRef2 = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef2, 5000) of + {response, nofin, 200, _} -> + {ok, Body} = gun:await_body(ConnPid, StreamRef2, 5000), + io:format(" Connection still usable after cancel: ~s~n", [Body]); + {response, fin, 200, _} -> + io:format(" Connection still usable after cancel~n"); + {error, E} -> + io:format(" Connection broken after cancel: ~p~n", [E]) + end, + gun:close(ConnPid), + ok. + +%% Test 8: Error handling +test_error_handling() -> + %% Connection refused + T1 = erlang:monotonic_time(millisecond), + {ok, ConnPid} = gun:open("localhost", 19999, #{ + protocols => [http], + connect_timeout => 2000 + }), + case gun:await_up(ConnPid, 3000) of + {ok, _} -> + io:format(" Unexpectedly connected~n"); + {error, Reason} -> + T2 = erlang:monotonic_time(millisecond), + io:format(" Connection refused: ~p in ~pms~n", [Reason, T2 - T1]) + end, + gun:close(ConnPid), + + %% Unreachable host (connect timeout) + io:format(" Testing connect timeout to unreachable host...~n"), + T3 = erlang:monotonic_time(millisecond), + {ok, ConnPid2} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 1000 + }), + case gun:await_up(ConnPid2, 3000) of + {ok, _} -> + io:format(" Unexpectedly connected~n"); + {error, Reason2} -> + T4 = erlang:monotonic_time(millisecond), + io:format(" Timeout error: ~p in ~pms~n", [Reason2, T4 - T3]) + end, + gun:close(ConnPid2), + ok. + +%% Test 10: Connect timeout - does gun's connect_timeout option work? +test_connect_timeout() -> + %% Gun retries by default (retry=5, retry_timeout=5000). + %% Disable retries to isolate connect_timeout behavior. + + %% Test 1: connect_timeout=500ms, no retries, unreachable host + io:format(" Testing with retry=0 to isolate connect_timeout...~n"), + T1 = erlang:monotonic_time(millisecond), + {ok, ConnPid1} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 500, + retry => 0 + }), + Result1 = gun:await_up(ConnPid1, 10000), + T2 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=500ms, retry=0: ~p in ~pms~n", [Result1, T2 - T1]), + gun:close(ConnPid1), + + %% Test 2: connect_timeout=2000ms, no retries + T3 = erlang:monotonic_time(millisecond), + {ok, ConnPid2} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 2000, + retry => 0 + }), + Result2 = gun:await_up(ConnPid2, 10000), + T4 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=2000ms, retry=0: ~p in ~pms~n", [Result2, T4 - T3]), + gun:close(ConnPid2), + + %% Test 3: connection refused (should be instant, regardless of timeout) + T5 = erlang:monotonic_time(millisecond), + {ok, ConnPid3} = gun:open("localhost", 19999, #{ + protocols => [http], + connect_timeout => 5000, + retry => 0 + }), + Result3 = gun:await_up(ConnPid3, 10000), + T6 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=5000ms, refused port, retry=0: ~p in ~pms~n", + [Result3, T6 - T5]), + gun:close(ConnPid3), + + %% Test 4: verify retries amplify the time (retry=2 with 500ms timeout) + T7 = erlang:monotonic_time(millisecond), + {ok, ConnPid4} = gun:open("192.0.2.1", 80, #{ + protocols => [http], + connect_timeout => 500, + retry => 2, + retry_timeout => 100 + }), + Result4 = gun:await_up(ConnPid4, 30000), + T8 = erlang:monotonic_time(millisecond), + io:format(" connect_timeout=500ms, retry=2, retry_timeout=100ms: ~p in ~pms~n", + [Result4, T8 - T7]), + gun:close(ConnPid4), + ok. + +%% Test 11: Auto decompression - does gun handle gzip/deflate? +test_auto_decompression() -> + %% Request gzipped content from mock server + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + + %% Request with Accept-Encoding: gzip + StreamRef1 = gun:get(ConnPid, "/gzip", [ + {<<"accept-encoding">>, <<"gzip">>} + ]), + case gun:await(ConnPid, StreamRef1, 5000) of + {response, nofin, Status1, Headers1} -> + {ok, Body1} = gun:await_body(ConnPid, StreamRef1, 5000), + CE = proplists:get_value(<<"content-encoding">>, Headers1, <<"none">>), + io:format(" With accept-encoding:gzip~n"), + io:format(" Status: ~p~n", [Status1]), + io:format(" Content-Encoding: ~s~n", [CE]), + io:format(" Body size: ~p bytes~n", [byte_size(Body1)]), + %% Check if body is still gzipped or was auto-decompressed + IsGzipped = case Body1 of + <<16#1f, 16#8b, _/binary>> -> true; + _ -> false + end, + io:format(" Body still gzipped: ~p~n", [IsGzipped]), + case IsGzipped of + true -> + Decompressed = zlib:gunzip(Body1), + io:format(" Decompressed: ~s (~p bytes)~n", + [Decompressed, byte_size(Decompressed)]), + io:format(" GUN DOES NOT AUTO-DECOMPRESS~n"); + false -> + io:format(" Body: ~s~n", [Body1]), + io:format(" GUN AUTO-DECOMPRESSES~n") + end; + {response, fin, Status1, _} -> + io:format(" Status: ~p (no body)~n", [Status1]) + end, + + %% Also test without accept-encoding + StreamRef2 = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef2, 5000) of + {response, nofin, _Status2, _Headers2} -> + {ok, Body2} = gun:await_body(ConnPid, StreamRef2, 5000), + io:format(" Without accept-encoding:~n"), + io:format(" Body: ~s~n", [Body2]); + {response, fin, _Status2, _} -> + io:format(" No body without accept-encoding~n") + end, + + gun:close(ConnPid), + ok. + +%% Test 12: HTTP/2 cancel stream - connection should survive +test_h2_cancel_stream() -> + {ok, ConnPid} = gun:open("www.google.com", 443, #{ + transport => tls, + protocols => [http2], + tls_opts => [{verify, verify_none}] + }), + {ok, http2} = gun:await_up(ConnPid, 10000), + io:format(" Connected via HTTP/2~n"), + + %% Start a request and cancel it + StreamRef1 = gun:get(ConnPid, "/search?q=test"), + timer:sleep(100), + gun:cancel(ConnPid, StreamRef1), + gun:flush(StreamRef1), + io:format(" Cancelled first request~n"), + + %% Now try another request on the SAME connection + StreamRef2 = gun:get(ConnPid, "/"), + case gun:await(ConnPid, StreamRef2, 10000) of + {response, nofin, Status, _H} -> + gun:cancel(ConnPid, StreamRef2), + io:format(" Second request after cancel: status ~p~n", [Status]), + io:format(" HTTP/2 connection SURVIVES cancel (streams are independent)~n"); + {response, fin, Status, _H} -> + io:format(" Second request after cancel: status ~p~n", [Status]), + io:format(" HTTP/2 connection SURVIVES cancel~n"); + {error, E} -> + io:format(" Second request FAILED: ~p~n", [E]), + io:format(" HTTP/2 connection BROKEN after cancel~n") + end, + gun:close(ConnPid), + ok. + +%% Test 9: Connection reuse - many sequential requests on one connection +test_connection_reuse() -> + {ok, ConnPid} = gun:open(?BASE_HOST, ?BASE_PORT, #{ + protocols => [http] + }), + {ok, _} = gun:await_up(ConnPid, 5000), + + N = 100, + T1 = erlang:monotonic_time(millisecond), + Successes = lists:foldl(fun(_, Acc) -> + StreamRef = gun:get(ConnPid, "/text"), + case gun:await(ConnPid, StreamRef, 5000) of + {response, nofin, 200, _} -> + {ok, _} = gun:await_body(ConnPid, StreamRef, 5000), + Acc + 1; + {response, fin, 200, _} -> + Acc + 1; + _ -> + Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" ~p/~p sequential requests on ONE connection in ~pms~n", + [Successes, N, T2 - T1]), + gun:close(ConnPid), + ok. diff --git a/research/gun_research/src/gun_research.gleam b/research/gun_research/src/gun_research.gleam new file mode 100644 index 0000000..4ead3b1 --- /dev/null +++ b/research/gun_research/src/gun_research.gleam @@ -0,0 +1,3 @@ +pub fn main() { + Nil +} diff --git a/research/gun_research/test/gun_research_test.gleam b/research/gun_research/test/gun_research_test.gleam new file mode 100644 index 0000000..7c8adfd --- /dev/null +++ b/research/gun_research/test/gun_research_test.gleam @@ -0,0 +1,10 @@ +import gleam/io + +@external(erlang, "gun_bench", "run_all") +fn run_all() -> Nil + +pub fn main() { + io.println("Running gun research benchmarks...") + run_all() + io.println("Done.") +} diff --git a/research/hackney_research/.github/workflows/test.yml b/research/hackney_research/.github/workflows/test.yml new file mode 100644 index 0000000..9821961 --- /dev/null +++ b/research/hackney_research/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.14.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/research/hackney_research/.gitignore b/research/hackney_research/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/research/hackney_research/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/research/hackney_research/README.md b/research/hackney_research/README.md new file mode 100644 index 0000000..ab4e5f8 --- /dev/null +++ b/research/hackney_research/README.md @@ -0,0 +1,24 @@ +# hackney_research + +[![Package Version](https://img.shields.io/hexpm/v/hackney_research)](https://hex.pm/packages/hackney_research) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/hackney_research/) + +```sh +gleam add hackney_research@1 +``` +```gleam +import hackney_research + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/research/hackney_research/gleam.toml b/research/hackney_research/gleam.toml new file mode 100644 index 0000000..ba92fb6 --- /dev/null +++ b/research/hackney_research/gleam.toml @@ -0,0 +1,11 @@ +name = "hackney_research" +version = "0.0.0" +description = "Research: hackney vs httpc for dream_http_client" + +[dependencies] +gleam_stdlib = ">= 0.60.0 and < 1.0.0" +gleam_erlang = ">= 1.1.0 and < 2.0.0" +hackney = ">= 3.0.0 and < 4.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/research/hackney_research/manifest.toml b/research/hackney_research/manifest.toml new file mode 100644 index 0000000..9c2713f --- /dev/null +++ b/research/hackney_research/manifest.toml @@ -0,0 +1,21 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "certifi", version = "2.16.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "8A64F6669D85E9CC0E5086FCF29A5B13DE57A13EFA23D3582874B9A19303F184" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "hackney", version = "3.2.1", build_tools = ["rebar3"], requirements = ["certifi", "idna", "mimerl", "parse_trans", "quic", "ssl_verify_fun"], otp_app = "hackney", source = "hex", outer_checksum = "1D9260C31B7C910B63E6B3929D296F18BA3D8217EF01800989AFCFCC77776AFE" }, + { name = "idna", version = "7.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "idna", source = "hex", outer_checksum = "6AE959A025BF36DF61A8CAB8508D9654891B5426A84C44D82DEAFFD6DDF8C71F" }, + { name = "mimerl", version = "1.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144" }, + { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, + { name = "quic", version = "0.10.2", build_tools = ["rebar3"], requirements = [], otp_app = "quic", source = "hex", outer_checksum = "7C196A66973C877A59768A5687F0A0610FF11817254D0A4E45CC4E3A16B1D00B" }, + { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, +] + +[requirements] +gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +hackney = { version = ">= 3.0.0 and < 4.0.0" } diff --git a/research/hackney_research/src/hackney_bench.erl b/research/hackney_research/src/hackney_bench.erl new file mode 100644 index 0000000..16810b1 --- /dev/null +++ b/research/hackney_research/src/hackney_bench.erl @@ -0,0 +1,384 @@ +-module(hackney_bench). +-export([ + run_all/0, + test_sync_request/0, + test_pull_stream/0, + test_async_stream/0, + test_async_once_stream/0, + test_connection_pool/0, + test_redirect_control/0, + test_connect_timeout/0, + test_auto_decompression/0, + test_error_format/0, + test_cancel_async/0, + test_http2_negotiation/0, + test_http2_multiplexing/0, + test_concurrent_requests_hackney/0, + test_concurrent_requests_httpc/0 +]). + +%% Run all research tests against a target URL +%% Start the mock server first: cd modules/http_client && gleam test +%% Or use httpbin.org for external tests + +-define(BASE, "http://localhost:3004"). + +run_all() -> + {ok, _} = application:ensure_all_started(hackney), + Tests = [ + {"1. Sync request", fun test_sync_request/0}, + {"2. Pull-based streaming (stream_body)", fun test_pull_stream/0}, + {"3. Async streaming (async: true)", fun test_async_stream/0}, + {"4. Async-once streaming (async: once)", fun test_async_once_stream/0}, + {"5. Connection pooling", fun test_connection_pool/0}, + {"6. Redirect control (follow_redirect)", fun test_redirect_control/0}, + {"7. Connect timeout", fun test_connect_timeout/0}, + {"8. Auto decompression", fun test_auto_decompression/0}, + {"9. Error format", fun test_error_format/0}, + {"10. Cancel async request", fun test_cancel_async/0} + ], + io:format("~n========================================~n"), + io:format(" HACKNEY RESEARCH RESULTS~n"), + io:format("========================================~n~n"), + lists:foreach(fun({Name, Fun}) -> + io:format("--- ~s ---~n", [Name]), + try + Fun(), + io:format(" RESULT: PASS~n~n") + catch + Class:Reason:Stack -> + io:format(" RESULT: FAIL~n"), + io:format(" Error: ~p:~p~n", [Class, Reason]), + io:format(" Stack: ~p~n~n", [hd(Stack)]) + end + end, Tests), + io:format("~n--- 11. HTTP/2 negotiation ---~n"), + try test_http2_negotiation(), + io:format(" RESULT: PASS~n~n") + catch C11:R11:S11 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C11, R11, hd(S11)]) + end, + + io:format("--- 12. HTTP/2 multiplexing ---~n"), + try test_http2_multiplexing(), + io:format(" RESULT: PASS~n~n") + catch C12:R12:S12 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C12, R12, hd(S12)]) + end, + + io:format("--- 13. Concurrent requests (hackney, 100 parallel) ---~n"), + try test_concurrent_requests_hackney(), + io:format(" RESULT: PASS~n~n") + catch C13:R13:S13 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C13, R13, hd(S13)]) + end, + + io:format("--- 14. Concurrent requests (httpc, 100 parallel) ---~n"), + try test_concurrent_requests_httpc(), + io:format(" RESULT: PASS~n~n") + catch C14:R14:S14 -> + io:format(" RESULT: FAIL (~p:~p)~n Stack: ~p~n~n", [C14, R14, hd(S14)]) + end, + + io:format("========================================~n"), + io:format(" DONE~n"), + io:format("========================================~n"), + ok. + +%% Test 1: Basic sync request - does hackney return status, headers, body? +test_sync_request() -> + {ok, Status, Headers, Body} = hackney:request(get, <>, [], <<>>, []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers type: ~p (first: ~p)~n", [length(Headers), hd(Headers)]), + io:format(" Body type: ~p, size: ~p~n", [element(1, {Body}), byte_size(Body)]), + io:format(" Body: ~s~n", [Body]), + true = is_integer(Status), + true = Status =:= 200, + true = is_binary(Body), + ok. + +%% Test 2: Pull-based streaming via stream_body/1 +%% This is hackney's equivalent of our pull-based yielder model +test_pull_stream() -> + {ok, Status, Headers, Ref} = hackney:request(get, <>, [], <<>>, []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + io:format(" Ref type: ~p~n", [Ref]), + {Chunks, _FinalRef} = collect_stream_body(Ref, []), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + io:format(" Total bytes: ~p~n", [lists:sum([byte_size(C) || C <- Chunks])]), + true = length(Chunks) > 0, + ok. + +collect_stream_body(Ref, Acc) -> + case hackney:stream_body(Ref) of + {ok, Data} -> + collect_stream_body(Ref, [Data | Acc]); + done -> + {lists:reverse(Acc), Ref}; + {error, Reason} -> + io:format(" Stream error: ~p~n", [Reason]), + {lists:reverse(Acc), Ref} + end. + +%% Test 3: Async streaming - messages pushed to process mailbox +%% This is hackney's equivalent of our message-based start_stream() model +test_async_stream() -> + {ok, Ref} = hackney:request(get, <>, [], <<>>, [{async, true}]), + io:format(" Ref: ~p~n", [Ref]), + {Status, Headers, Chunks} = collect_async_messages(Ref, undefined, [], []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + io:format(" Total bytes: ~p~n", [lists:sum([byte_size(C) || C <- Chunks])]), + true = Status =:= 200, + true = length(Chunks) > 0, + ok. + +collect_async_messages(Ref, Status, Headers, Chunks) -> + receive + {hackney_response, Ref, {status, S, _Reason}} -> + collect_async_messages(Ref, S, Headers, Chunks); + {hackney_response, Ref, {headers, H}} -> + collect_async_messages(Ref, Status, H, Chunks); + {hackney_response, Ref, done} -> + {Status, Headers, lists:reverse(Chunks)}; + {hackney_response, Ref, Bin} when is_binary(Bin) -> + collect_async_messages(Ref, Status, Headers, [Bin | Chunks]); + {hackney_response, Ref, {error, Reason}} -> + io:format(" Async error: ~p~n", [Reason]), + {Status, Headers, lists:reverse(Chunks)} + after 30000 -> + io:format(" TIMEOUT waiting for async message~n"), + {Status, Headers, lists:reverse(Chunks)} + end. + +%% Test 4: Async-once streaming - pull one chunk at a time via stream_next/1 +%% This gives backpressure control like our pull-based model +test_async_once_stream() -> + {ok, Ref} = hackney:request(get, <>, [], <<>>, [{async, once}]), + io:format(" Ref: ~p~n", [Ref]), + {Status, Headers, Chunks} = collect_async_once(Ref, undefined, [], []), + io:format(" Status: ~p~n", [Status]), + io:format(" Headers count: ~p~n", [length(Headers)]), + io:format(" Chunks received: ~p~n", [length(Chunks)]), + true = Status =:= 200, + true = length(Chunks) > 0, + ok. + +collect_async_once(Ref, Status, Headers, Chunks) -> + receive + {hackney_response, Ref, {status, S, _Reason}} -> + hackney:stream_next(Ref), + collect_async_once(Ref, S, Headers, Chunks); + {hackney_response, Ref, {headers, H}} -> + hackney:stream_next(Ref), + collect_async_once(Ref, Status, H, Chunks); + {hackney_response, Ref, done} -> + {Status, Headers, lists:reverse(Chunks)}; + {hackney_response, Ref, Bin} when is_binary(Bin) -> + hackney:stream_next(Ref), + collect_async_once(Ref, Status, Headers, [Bin | Chunks]); + {hackney_response, Ref, {error, Reason}} -> + io:format(" Async-once error: ~p~n", [Reason]), + {Status, Headers, lists:reverse(Chunks)} + after 30000 -> + io:format(" TIMEOUT~n"), + {Status, Headers, lists:reverse(Chunks)} + end. + +%% Test 5: Connection pooling - does hackney reuse connections? +test_connection_pool() -> + %% Make 5 requests to same host - check pool stats + lists:foreach(fun(I) -> + {ok, 200, _H, Body} = hackney:request(get, <>, [], <<>>, []), + io:format(" Request ~p: ~p bytes~n", [I, byte_size(Body)]) + end, lists:seq(1, 5)), + %% Check pool stats + Stats = hackney_pool:get_stats(default), + io:format(" Pool stats: ~p~n", [Stats]), + ok. + +%% Test 6: Redirect control +test_redirect_control() -> + %% hackney uses follow_redirect option (default: false) + %% This is different from httpc where autoredirect defaults to true + io:format(" hackney default: follow_redirect = false~n"), + io:format(" httpc default: autoredirect = true~n"), + io:format(" hackney also supports max_redirect option (default: 5)~n"), + io:format(" NOTE: No redirect endpoint on mock server to test against~n"), + ok. + +%% Test 7: Connect timeout behavior +test_connect_timeout() -> + %% hackney default connect_timeout is 8000ms + %% httpc default is infinity (we hardcoded 15000ms) + T1 = erlang:monotonic_time(millisecond), + Result = hackney:request(get, <<"http://localhost:1/test">>, [], <<>>, [ + {connect_timeout, 1000} + ]), + T2 = erlang:monotonic_time(millisecond), + Elapsed = T2 - T1, + io:format(" Result: ~p~n", [Result]), + io:format(" Elapsed: ~pms (with 1000ms connect_timeout)~n", [Elapsed]), + io:format(" hackney default connect_timeout: 8000ms~n"), + io:format(" httpc default connect_timeout: infinity~n"), + ok. + +%% Test 8: Auto decompression +test_auto_decompression() -> + %% Does hackney handle gzip automatically? + {ok, Status, Headers, Body} = hackney:request(get, <>, [], <<>>, []), + io:format(" Status: ~p~n", [Status]), + CE = proplists:get_value(<<"content-encoding">>, Headers, <<"none">>), + io:format(" Content-Encoding header: ~p~n", [CE]), + io:format(" Body size: ~p~n", [byte_size(Body)]), + io:format(" Body starts with: ~p~n", [binary:part(Body, 0, min(100, byte_size(Body)))]), + %% Check if body looks like JSON (decompressed) or binary (compressed) + IsJson = case Body of + <<"{", _/binary>> -> true; + _ -> false + end, + io:format(" Body looks like JSON (decompressed): ~p~n", [IsJson]), + ok. + +%% Test 9: Error format - what do hackney errors look like? +test_error_format() -> + %% Connection refused + {error, Reason1} = hackney:request(get, <<"http://localhost:1/test">>, [], <<>>, [ + {connect_timeout, 500} + ]), + io:format(" Connection refused error: ~p~n", [Reason1]), + io:format(" Error type: ~p~n", [element(1, {Reason1})]), + ok. + +%% Test 10: Cancel an async request +test_cancel_async() -> + {ok, Ref} = hackney:request(get, <>, [], <<>>, [{async, true}]), + io:format(" Started async request, ref: ~p~n", [Ref]), + timer:sleep(500), + %% hackney 3.x: try hackney:close/1 instead of cancel_request + Result = hackney:close(Ref), + io:format(" Close result: ~p~n", [Result]), + ok. + +%% Test 11: Does hackney negotiate HTTP/2 with a real HTTPS server? +test_http2_negotiation() -> + Urls = [ + <<"https://http2.pro/api/v1">>, + <<"https://www.google.com">> + ], + lists:foreach(fun(Url) -> + io:format(" Requesting ~s~n", [Url]), + case hackney:request(get, Url, [], <<>>, []) of + {ok, Status, Headers, Body} -> + io:format(" Status: ~p~n", [Status]), + io:format(" Body size: ~p~n", [byte_size(Body)]), + HeaderNames = [N || {N, _V} <- Headers], + AllLower = lists:all(fun(N) -> N =:= string:lowercase(N) end, HeaderNames), + io:format(" All headers lowercase (HTTP/2 indicator): ~p~n", [AllLower]), + io:format(" Sample headers: ~p~n", [lists:sublist(Headers, 3)]), + %% Check if body mentions h2 protocol + case binary:match(Body, <<"h2">>) of + nomatch -> io:format(" Body does not mention h2~n"); + _ -> io:format(" Body mentions h2 (server confirms HTTP/2)~n") + end; + {error, E} -> + io:format(" Error: ~p~n", [E]) + end, + io:format("~n") + end, Urls), + ok. + +%% Test 12: HTTP/2 multiplexing - multiple requests on same connection +test_http2_multiplexing() -> + Url = <<"https://nghttp2.org/httpbin/get">>, + %% Make 5 concurrent requests and see if they share connections + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + Result = hackney:request(get, Url, [], <<>>, [ + {protocols, [http2, http1]} + ]), + Self ! {done, I, Result} + end) + end, lists:seq(1, 5)), + %% Collect results + Results = [receive {done, I, R} -> {I, R} after 15000 -> {timeout, timeout} end + || I <- lists:seq(1, 5)], + T2 = erlang:monotonic_time(millisecond), + lists:foreach(fun({I, R}) -> + case R of + {ok, S, _H, _B} -> io:format(" Request ~p: status ~p~n", [I, S]); + {error, E} -> io:format(" Request ~p: error ~p~n", [I, E]); + _ -> io:format(" Request ~p: ~p~n", [I, R]) + end + end, Results), + io:format(" Total time for 5 concurrent HTTPS requests: ~pms~n", [T2 - T1]), + ok. + +%% Test 13: Benchmark concurrent requests with hackney at multiple scales +test_concurrent_requests_hackney() -> + %% Use a dedicated pool with large max to avoid pool-size bottleneck + hackney_pool:start_pool(bench_pool, [{max_connections, 2000}, {timeout, 60000}]), + lists:foreach(fun(N) -> + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + R = hackney:request(get, <>, [], <<>>, [{pool, bench_pool}]), + Self ! {hackney_done, N, I, R} + end) + end, lists:seq(1, N)), + Successes = lists:foldl(fun(I, Acc) -> + receive + {hackney_done, N, I, {ok, 200, _H, _B}} -> Acc + 1; + {hackney_done, N, I, Other} -> + case N =< 100 of + true -> io:format(" hackney fail ~p: ~p~n", [I, Other]); + false -> ok + end, + Acc + after 30000 -> Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" hackney ~p concurrent: ~p/~p in ~pms~n", [N, Successes, N, T2 - T1]) + end, [100, 500, 1000, 5000]), + PoolStats = hackney_pool:get_stats(bench_pool), + io:format(" Pool stats after: ~p~n", [PoolStats]), + hackney_pool:stop_pool(bench_pool), + ok. + +%% Test 14: Benchmark concurrent requests with httpc at multiple scales +test_concurrent_requests_httpc() -> + {ok, _} = application:ensure_all_started(inets), + {ok, _} = application:ensure_all_started(ssl), + %% Reset httpc to cold state by using a fresh profile + inets:stop(httpc, default), + inets:start(httpc, [{profile, default}]), + httpc:set_options([{max_sessions, 2000}, {max_pipeline_length, 0}, + {keep_alive_timeout, 60000}, {max_keep_alive_length, 1000}]), + lists:foreach(fun(N) -> + Self = self(), + T1 = erlang:monotonic_time(millisecond), + lists:foreach(fun(I) -> + spawn(fun() -> + R = httpc:request(get, {"http://localhost:3004/text", []}, + [{timeout, 30000}, {connect_timeout, 5000}], + [{sync, true}, {body_format, binary}]), + Self ! {httpc_done, N, I, R} + end) + end, lists:seq(1, N)), + Successes = lists:foldl(fun(I, Acc) -> + receive + {httpc_done, N, I, {ok, _}} -> Acc + 1; + {httpc_done, N, I, _} -> Acc + after 30000 -> Acc + end + end, 0, lists:seq(1, N)), + T2 = erlang:monotonic_time(millisecond), + io:format(" httpc ~p concurrent: ~p/~p in ~pms~n", [N, Successes, N, T2 - T1]) + end, [100, 500, 1000, 5000]), + ok. diff --git a/research/hackney_research/src/hackney_research.gleam b/research/hackney_research/src/hackney_research.gleam new file mode 100644 index 0000000..5dbde80 --- /dev/null +++ b/research/hackney_research/src/hackney_research.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() { + io.println("Run tests with: gleam test") +} diff --git a/research/hackney_research/test/hackney_research_test.gleam b/research/hackney_research/test/hackney_research_test.gleam new file mode 100644 index 0000000..78f8b12 --- /dev/null +++ b/research/hackney_research/test/hackney_research_test.gleam @@ -0,0 +1,10 @@ +import gleam/io + +pub fn main() { + io.println("Starting hackney research...") + run_all() + io.println("Done.") +} + +@external(erlang, "hackney_bench", "run_all") +fn run_all() -> Nil diff --git a/src/dream/servers/mist/handler.gleam b/src/dream/servers/mist/handler.gleam index e2e7106..6a150a8 100644 --- a/src/dream/servers/mist/handler.gleam +++ b/src/dream/servers/mist/handler.gleam @@ -1,7 +1,7 @@ import dream/dream import dream/http/header.{Header} import dream/http/request.{type Request, Request} -import dream/http/response.{type Response, Response, Text} +import dream/http/response.{type Response, Response, Stream, Text} import dream/router.{type Route, type Router, find_route} import dream/servers/mist/internal import dream/servers/mist/request as mist_request @@ -114,7 +114,7 @@ fn create_request_handler( cookies: [], content_type: option.Some("text/plain; charset=utf-8"), ) - mist_response.convert(dream_response) + convert_dream_response(mist_request, dream_response) } } @@ -167,9 +167,9 @@ fn handle_routed_request( option.Some(perform_upgrade) -> case dream_response.status { 200 -> perform_upgrade(extract_dream_headers(dream_response)) - _ -> mist_response.convert(dream_response) + _ -> convert_dream_response(mist_request, dream_response) } - option.None -> mist_response.convert(dream_response) + option.None -> convert_dream_response(mist_request, dream_response) } } Error(response) -> response @@ -215,6 +215,17 @@ fn prepare_buffered_request( } } +fn convert_dream_response( + mist_request: http_request.Request(Connection), + dream_response: Response, +) -> http_response.Response(ResponseData) { + case dream_response.body { + Stream(stream) -> + mist_response.convert_stream(mist_request, dream_response, stream) + _ -> mist_response.convert(dream_response) + } +} + fn extract_dream_headers(response: Response) -> List(#(String, String)) { list.map(response.headers, fn(h) { let Header(name, value) = h diff --git a/src/dream/servers/mist/request.gleam b/src/dream/servers/mist/request.gleam index bc4f948..fe63b4b 100644 --- a/src/dream/servers/mist/request.gleam +++ b/src/dream/servers/mist/request.gleam @@ -25,7 +25,7 @@ import gleam/list import gleam/option import gleam/result import gleam/string -import mist.{type Connection, type IpAddress, get_client_info} +import mist.{type Connection, type IpAddress, get_connection_info} /// Generate a simple request ID /// @@ -104,10 +104,10 @@ pub fn convert_metadata( |> option.from_result // Get client info - let client_info = get_client_info(mist_req.body) - let remote_address = case client_info { - Ok(info) -> { - let ip_address: IpAddress = info.ip_address + let connection_info = get_connection_info(mist_req.body) + let remote_address = case connection_info { + Ok(connection_info) -> { + let ip_address: IpAddress = connection_info.ip_address format_ip_address_value(ip_address) |> option.Some } Error(_) -> option.None diff --git a/src/dream/servers/mist/response.gleam b/src/dream/servers/mist/response.gleam index b89bea0..543aabf 100644 --- a/src/dream/servers/mist/response.gleam +++ b/src/dream/servers/mist/response.gleam @@ -10,13 +10,15 @@ import dream/http/cookie.{type Cookie, Cookie, Lax, None, Strict} import dream/http/header.{type Header, header_name, header_value} import dream/http/response.{type Response, Bytes as DreamBytes, Stream, Text} import gleam/bytes_tree +import gleam/erlang/process +import gleam/http/request as http_request import gleam/http/response as http_response import gleam/int import gleam/list import gleam/option import gleam/string import gleam/yielder -import mist.{type ResponseData, Bytes as MistBytes, Chunked} +import mist.{type Connection, type ResponseData, Bytes as MistBytes} /// Convert Dream response to Mist response format /// @@ -50,41 +52,56 @@ import mist.{type ResponseData, Bytes as MistBytes, Chunked} /// // Mist server sends mist_resp to client /// ``` pub fn convert(dream_resp: Response) -> http_response.Response(ResponseData) { - // Status is now a plain Int - let status_code = dream_resp.status + let headers = build_response_headers(dream_resp) - // Convert headers - let headers = list.map(dream_resp.headers, convert_header_to_tuple) - - // Add cookie headers - let headers_with_cookies = - list.fold(dream_resp.cookies, headers, add_cookie_header) - - // Add content-type header if present - let headers_with_content_type = case dream_resp.content_type { - option.Some(ct) -> list.key_set(headers_with_cookies, "content-type", ct) - option.None -> headers_with_cookies - } - - // Convert body based on ResponseBody variant let response_data = case dream_resp.body { Text(text) -> MistBytes(bytes_tree.from_string(text)) - DreamBytes(bytes) -> MistBytes(bytes_tree.from_bit_array(bytes)) - - Stream(stream) -> { - let byte_stream = - stream - |> yielder.map(bytes_tree.from_bit_array) - Chunked(byte_stream) - } + Stream(_stream) -> + panic as "Stream bodies must use convert_stream with mist request" } - let resp_with_body = - http_response.new(status_code) - |> http_response.set_body(response_data) + http_response.new(dream_resp.status) + |> http_response.set_body(response_data) + |> set_all_headers(headers) +} - set_all_headers(headers_with_content_type, resp_with_body) +/// Convert a Dream streaming response to Mist chunked response +/// +/// Uses mist's actor-based chunked API to drain a Dream yielder, +/// sending each element as an HTTP chunk. Requires the original mist +/// request for connection access. +/// +/// ## Example +/// +/// ```gleam +/// // Internal use - called by the handler when body is Stream +/// let mist_resp = convert_stream(mist_request, dream_resp, stream) +/// ``` +pub fn convert_stream( + mist_request: http_request.Request(Connection), + dream_response: Response, + stream: yielder.Yielder(BitArray), +) -> http_response.Response(ResponseData) { + let headers = build_response_headers(dream_response) + + let base_response = + http_response.Response( + status: dream_response.status, + headers: [], + body: Nil, + ) + |> set_all_headers(headers) + + mist.chunked( + request: mist_request, + response: base_response, + init: fn(subject) { + process.send(subject, DrainNext) + DrainState(remaining: stream, subject: subject) + }, + loop: drain_yielder_loop, + ) } fn convert_header_to_tuple(header: Header) -> #(String, String) { @@ -99,10 +116,20 @@ fn add_cookie_header( [#("set-cookie", cookie_header), ..acc] } +fn build_response_headers(dream_resp: Response) -> List(#(String, String)) { + let headers = list.map(dream_resp.headers, convert_header_to_tuple) + let headers_with_cookies = + list.fold(dream_resp.cookies, headers, add_cookie_header) + case dream_resp.content_type { + option.Some(ct) -> list.key_set(headers_with_cookies, "content-type", ct) + option.None -> headers_with_cookies + } +} + fn add_header( - acc: http_response.Response(ResponseData), + acc: http_response.Response(body), header: #(String, String), -) -> http_response.Response(ResponseData) { +) -> http_response.Response(body) { case header.0 { "set-cookie" -> http_response.prepend_header(acc, header.0, header.1) _ -> http_response.set_header(acc, header.0, header.1) @@ -110,12 +137,50 @@ fn add_header( } fn set_all_headers( + resp: http_response.Response(body), headers: List(#(String, String)), - resp: http_response.Response(ResponseData), -) -> http_response.Response(ResponseData) { +) -> http_response.Response(body) { list.fold(headers, resp, add_header) } +type DrainMessage { + DrainNext +} + +type DrainState { + DrainState( + remaining: yielder.Yielder(BitArray), + subject: process.Subject(DrainMessage), + ) +} + +fn drain_yielder_loop( + state: DrainState, + _message: DrainMessage, + connection: Connection, +) -> mist.ChunkNext(DrainState) { + case yielder.step(state.remaining) { + yielder.Next(chunk, rest) -> + send_chunk_and_continue(state, chunk, rest, connection) + yielder.Done -> mist.ChunkStop + } +} + +fn send_chunk_and_continue( + state: DrainState, + chunk: BitArray, + rest: yielder.Yielder(BitArray), + connection: Connection, +) -> mist.ChunkNext(DrainState) { + case mist.send_chunk(connection, chunk) { + Ok(Nil) -> { + process.send(state.subject, DrainNext) + mist.ChunkContinue(DrainState(remaining: rest, subject: state.subject)) + } + Error(Nil) -> mist.ChunkStop + } +} + /// Format a cookie for the Set-Cookie header fn format_cookie_header(cookie: Cookie) -> String { let Cookie( diff --git a/src/dream/servers/mist/sse.gleam b/src/dream/servers/mist/sse.gleam index fce3f72..5b035d2 100644 --- a/src/dream/servers/mist/sse.gleam +++ b/src/dream/servers/mist/sse.gleam @@ -95,7 +95,7 @@ import gleam/erlang/process.{type Selector, type Subject} import gleam/http/request as http_request import gleam/http/response as http_response import gleam/list -import gleam/option.{type Option, None, Some} +import gleam/option.{type Option, Some} import gleam/otp/actor import gleam/string_tree import mist.{ @@ -184,13 +184,8 @@ pub fn upgrade_to_sse( internal.unsafe_coerce(raw_request) let wrapped_init = fn(subj: Subject(message)) { - let #(state, maybe_selector) = on_init(subj, dependencies) - let initialised = actor.initialised(state) - let initialised = case maybe_selector { - Some(sel) -> actor.selecting(initialised, sel) - None -> initialised - } - Ok(initialised) + let #(state, _maybe_selector) = on_init(subj, dependencies) + state } let wrapped_loop = fn( diff --git a/test/dream/servers/mist/h2c_test.gleam b/test/dream/servers/mist/h2c_test.gleam new file mode 100644 index 0000000..c0b85a2 --- /dev/null +++ b/test/dream/servers/mist/h2c_test.gleam @@ -0,0 +1,353 @@ +//// Tests for HTTP/2 over cleartext (h2c) through dream's full server stack. +//// +//// Starts a real dream server with routes, makes h2c requests via +//// dream_http_client with Http2Only, and asserts correct responses through +//// the complete pipeline: router dispatch, controller execution, response +//// conversion, and mist HTTP/2 frame handling. + +import dream/http/request.{type Request, Get, Post} +import dream/http/response.{type Response, json_response, text_response} +import dream/router.{route, router} +import dream/servers/mist/server +import dream_http_client/client.{Http2Only, ResponseError} +import dream_test/types.{ + type AssertionResult, AssertionFailed, AssertionFailure, AssertionOk, +} +import dream_test/unit.{type UnitTest, describe, it} +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/option.{None} +import gleam/string + +// ============================================================================ +// Tests +// ============================================================================ + +pub fn tests() -> UnitTest { + describe("h2c", [ + it("GET returns correct body over h2c", fn() { + use handle <- with_h2c_server(19_980) + + let result = + h2c_request("/h2c/hello", 19_980) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + case resp.status == 200 && resp.body == "hello from dream over h2c" { + True -> AssertionOk + False -> + assertion_failed( + "h2c_get", + "Expected status 200 and body 'hello from dream over h2c', got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "'", + ) + } + } + Error(err) -> + assertion_failed("h2c_get", "Request failed: " <> string.inspect(err)) + } + }), + it("path params work over h2c", fn() { + use handle <- with_h2c_server(19_981) + + let result = + h2c_request("/h2c/echo/test-value", 19_981) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + case resp.status == 200 && string.contains(resp.body, "test-value") { + True -> AssertionOk + False -> + assertion_failed( + "h2c_path_params", + "Expected status 200 with body containing 'test-value', got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "'", + ) + } + } + Error(err) -> + assertion_failed( + "h2c_path_params", + "Request failed: " <> string.inspect(err), + ) + } + }), + it("JSON response has correct content-type over h2c", fn() { + use handle <- with_h2c_server(19_982) + + let result = + h2c_request("/h2c/json", 19_982) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + let has_json_body = string.contains(resp.body, "\"greeting\"") + let has_json_ct = + list.any(resp.headers, fn(h) { + let client.Header(name, value) = h + string.lowercase(name) == "content-type" + && string.contains(value, "application/json") + }) + + case resp.status == 200 && has_json_body && has_json_ct { + True -> AssertionOk + False -> + assertion_failed( + "h2c_json", + "Expected status 200, JSON body with 'greeting', and application/json content-type. Got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "' headers " + <> string.inspect(resp.headers), + ) + } + } + Error(err) -> + assertion_failed( + "h2c_json", + "Request failed: " <> string.inspect(err), + ) + } + }), + it("404 for unknown route over h2c", fn() { + use handle <- with_h2c_server(19_983) + + let result = + h2c_request("/h2c/nonexistent", 19_983) + |> client.send() + + server.stop(handle) + + case result { + Error(ResponseError(response: client.HttpResponse(status: 404, ..))) -> + AssertionOk + Error(ResponseError(response: client.HttpResponse(status: status, ..))) -> + assertion_failed( + "h2c_404", + "Expected status 404, got " <> string.inspect(status), + ) + Ok(resp) -> + assertion_failed( + "h2c_404", + "Expected error response, got Ok with status " + <> string.inspect(resp.status), + ) + Error(err) -> + assertion_failed( + "h2c_404", + "Unexpected error: " <> string.inspect(err), + ) + } + }), + it("POST with body echoes back over h2c", fn() { + use handle <- with_h2c_server(19_984) + + let payload = "h2c-test-payload" + let result = + h2c_request("/h2c/echo-body", 19_984) + |> client.method(http.Post) + |> client.body(payload) + |> client.send() + + server.stop(handle) + + case result { + Ok(resp) -> { + case resp.status == 200 && string.contains(resp.body, payload) { + True -> AssertionOk + False -> + assertion_failed( + "h2c_post_body", + "Expected status 200 with body containing '" + <> payload + <> "', got status " + <> string.inspect(resp.status) + <> " body '" + <> resp.body + <> "'", + ) + } + } + Error(err) -> + assertion_failed( + "h2c_post_body", + "Request failed: " <> string.inspect(err), + ) + } + }), + it("concurrent requests succeed over h2c", fn() { + use handle <- with_h2c_server(19_985) + + let subject1 = process.new_subject() + let subject2 = process.new_subject() + let subject3 = process.new_subject() + + let _pid1 = + process.spawn_unlinked(fn() { + let result = + h2c_request("/h2c/hello", 19_985) + |> client.send() + process.send(subject1, result) + }) + + let _pid2 = + process.spawn_unlinked(fn() { + let result = + h2c_request("/h2c/hello", 19_985) + |> client.send() + process.send(subject2, result) + }) + + let _pid3 = + process.spawn_unlinked(fn() { + let result = + h2c_request("/h2c/hello", 19_985) + |> client.send() + process.send(subject3, result) + }) + + let r1 = process.receive(subject1, 10_000) + let r2 = process.receive(subject2, 10_000) + let r3 = process.receive(subject3, 10_000) + + server.stop(handle) + + case r1, r2, r3 { + Ok(Ok(resp1)), Ok(Ok(resp2)), Ok(Ok(resp3)) -> { + case + resp1.status == 200 + && resp2.status == 200 + && resp3.status == 200 + && resp1.body == "hello from dream over h2c" + && resp2.body == "hello from dream over h2c" + && resp3.body == "hello from dream over h2c" + { + True -> AssertionOk + False -> + assertion_failed( + "h2c_concurrent", + "Not all responses matched. Got: " + <> string.inspect(#(resp1.status, resp2.status, resp3.status)), + ) + } + } + _, _, _ -> + assertion_failed( + "h2c_concurrent", + "One or more concurrent requests failed: " + <> string.inspect(#(r1, r2, r3)), + ) + } + }), + ]) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn h2c_request(path: String, port: Int) -> client.ClientRequest { + client.new() + |> client.method(http.Get) + |> client.scheme(http.Http) + |> client.host("localhost") + |> client.port(port) + |> client.path(path) + |> client.protocols(Http2Only) +} + +fn h2c_router() { + router() + |> route( + method: Get, + path: "/h2c/hello", + controller: hello_controller, + middleware: [], + ) + |> route( + method: Get, + path: "/h2c/echo/:value", + controller: echo_controller, + middleware: [], + ) + |> route( + method: Get, + path: "/h2c/json", + controller: json_controller, + middleware: [], + ) + |> route( + method: Post, + path: "/h2c/echo-body", + controller: echo_body_controller, + middleware: [], + ) +} + +fn hello_controller(_request: Request, _context, _services) -> Response { + text_response(200, "hello from dream over h2c") +} + +fn echo_controller(request: Request, _context, _services) -> Response { + case request.get_param(request, "value") { + Ok(param) -> text_response(200, param.value) + Error(msg) -> text_response(400, msg) + } +} + +fn json_controller(_request: Request, _context, _services) -> Response { + json_response(200, "{\"greeting\":\"hello from dream over h2c\"}") +} + +fn echo_body_controller(request: Request, _context, _services) -> Response { + text_response(200, request.body) +} + +fn with_h2c_server( + port: Int, + test_fn: fn(server.ServerHandle) -> AssertionResult, +) -> AssertionResult { + let result = + server.new() + |> server.router(h2c_router()) + |> server.listen_with_handle(port) + + case result { + Ok(handle) -> { + process.sleep(100) + test_fn(handle) + } + Error(start_error) -> + assertion_failed( + "with_h2c_server", + "Failed to start server on port " + <> string.inspect(port) + <> ": " + <> string.inspect(start_error), + ) + } +} + +fn assertion_failed(operator: String, message: String) -> AssertionResult { + AssertionFailed(AssertionFailure( + operator: operator, + message: message, + payload: None, + )) +} diff --git a/test/dream_test.gleam b/test/dream_test.gleam index 3eba57e..0bf429c 100644 --- a/test/dream_test.gleam +++ b/test/dream_test.gleam @@ -13,6 +13,7 @@ import dream/http/status_test import dream/http/validation_test import dream/router/parser_test import dream/router_test +import dream/servers/mist/h2c_test import dream/servers/mist/handler_test import dream/servers/mist/request_test as mist_request_test import dream/servers/mist/response_test as mist_response_test @@ -41,6 +42,7 @@ pub fn main() { unit.to_test_cases("dream/http/validation", validation_test.tests()), unit.to_test_cases("dream/router", router_test.tests()), unit.to_test_cases("dream/router/parser", parser_test.tests()), + unit.to_test_cases("dream/servers/mist/h2c", h2c_test.tests()), unit.to_test_cases("dream/servers/mist/handler", handler_test.tests()), unit.to_test_cases( "dream/servers/mist/request", diff --git a/test/fixtures/hooks.gleam b/test/fixtures/hooks.gleam index f8c715f..e9f21b5 100644 --- a/test/fixtures/hooks.gleam +++ b/test/fixtures/hooks.gleam @@ -45,7 +45,7 @@ pub fn start_server() -> AssertionResult { fn is_server_responding() -> Bool { let result = - client.new + client.new() |> client.scheme(http.Http) |> client.host("127.0.0.1") |> client.port(test_server_port) @@ -54,7 +54,7 @@ fn is_server_responding() -> Bool { case result { Ok(_response) -> True - // Connection refused, timeout, or HTTP error expected during startup polling + Error(client.ResponseError(_)) -> True Error(_error) -> False } }