From 39af3e150dbd8d8f185a8640ccdd6f1de97a383a Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Tue, 3 Mar 2026 14:40:24 -0700 Subject: [PATCH] fix(http_client): include query parameters in outgoing HTTP request URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why This Change Was Made - The `query()` builder function let callers set query parameters on a request, and the value was correctly stored on the ClientRequest and copied through `to_http_request` — but the final URL assembly step silently dropped it. Every request made with `.query("key=value")` sent the request without any query parameters. The server never received them. This affected all three execution modes: `send()`, `stream_yielder()`, and `start_stream()`. ## What Was Changed - `build_url` in `client.gleam` now appends `?query` to the URL when `request.query` is `Some(query)`. This fixes `send()` and `start_stream()`. - `start_httpc_stream` in `internal.gleam` had its own independent URL construction that also omitted the query string — same fix applied. This fixes `stream_yielder()`. - Mock server's `GET /get` endpoint now actually echoes query parameters (was documented since 1.0.0 but never implemented — the controller only passed `request.path` to the view). - 8 regression tests added with exact JSON field assertions covering all three execution modes, recorder integration, URL-encoded special characters, and the empty query string edge case. - Version bumped: http_client 5.1.1 → 5.1.2, mock_server 1.1.0 → 1.1.1. - CHANGELOGs, READMEs, and release notes updated for both modules. ## Note to Future Engineer - There are TWO separate URL construction sites: `build_url` in client.gleam (used by send/start_stream) and inline URL building in `start_httpc_stream` in internal.gleam (used by stream_yielder). If you ever add a fourth execution mode, you'll need to make sure it also includes query params — or better yet, refactor both call sites to share a single URL builder. - The mock server's GET /get endpoint claimed to echo query params for over three months before anyone noticed it didn't. The CHANGELOG said it did. The code said otherwise. Trust the code, not the comments. Or the CHANGELOG. Especially not the CHANGELOG. --- modules/http_client/CHANGELOG.md | 21 ++ modules/http_client/gleam.toml | 2 +- modules/http_client/releases/release-5.1.2.md | 79 +++++++ .../src/dream_http_client/client.gleam | 5 + .../src/dream_http_client/internal.gleam | 5 + .../test/recorder_client_test.gleam | 195 ++++++++++++++++++ modules/mock_server/CHANGELOG.md | 10 + modules/mock_server/README.md | 2 +- modules/mock_server/gleam.toml | 2 +- modules/mock_server/releases/release-1.1.1.md | 39 ++++ .../controllers/api_controller.gleam | 4 +- .../dream_mock_server/views/api_view.gleam | 7 +- 12 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 modules/http_client/releases/release-5.1.2.md create mode 100644 modules/mock_server/releases/release-1.1.1.md diff --git a/modules/http_client/CHANGELOG.md b/modules/http_client/CHANGELOG.md index 36b97b3..78fcc6f 100644 --- a/modules/http_client/CHANGELOG.md +++ b/modules/http_client/CHANGELOG.md @@ -5,6 +5,27 @@ 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.1.2 - 2026-03-03 + +### Fixed + +- **Query parameters are now included in HTTP requests.** `build_url` in + `client.gleam` constructed the URL from scheme, host, port, and path but + never appended the query string. Any caller using `.query("key=value")` + silently sent requests without query parameters. Affects `send()` and + `start_stream()`. +- **`stream_yielder()` also dropped query parameters.** `start_httpc_stream` + in `internal.gleam` had its own URL construction that independently omitted + the query string — a separate code path from the `build_url` fix above. + +### Added + +- **8 regression tests** covering query parameter delivery across all three + execution modes (`send`, `stream_yielder`, `start_stream`), recorder + integration (record + playback round-trip), URL-encoded special characters, + and the empty query string edge case. All assertions parse the JSON `query` + field from the mock server's response for exact matching. + ## 5.1.1 - 2026-03-01 ### Fixed diff --git a/modules/http_client/gleam.toml b/modules/http_client/gleam.toml index 19b7979..0fb3568 100644 --- a/modules/http_client/gleam.toml +++ b/modules/http_client/gleam.toml @@ -1,5 +1,5 @@ name = "dream_http_client" -version = "5.1.1" +version = "5.1.2" description = "Type-safe HTTP client for Gleam with streaming support" licences = ["MIT"] repository = { type = "github", user = "TrustBound", repo = "dream" } diff --git a/modules/http_client/releases/release-5.1.2.md b/modules/http_client/releases/release-5.1.2.md new file mode 100644 index 0000000..f190923 --- /dev/null +++ b/modules/http_client/releases/release-5.1.2.md @@ -0,0 +1,79 @@ +# dream_http_client v5.1.2 + +**Release Date:** March 3, 2026 + +This patch release fixes a bug where query parameters set via `.query()` were +silently dropped from outgoing HTTP requests across all three execution modes. + +No API changes -- this is a fully transparent bug fix. + +--- + +## Bug Fix: Query parameters dropped from URLs + +### The problem + +The `query()` builder correctly stored the query string on the `ClientRequest`, +and `to_http_request` correctly copied it to the `Request.query` field. But the +final URL assembly step dropped it: + +- **`build_url` in `client.gleam`** constructed the URL from scheme, host, port, + and path — but never appended `?query`. This affected `send()` and + `start_stream()`. +- **`start_httpc_stream` in `internal.gleam`** had its own independent URL + construction that also omitted the query string. This affected + `stream_yielder()`. + +Any caller using `.query("key=value")` silently sent requests without query +parameters. The server never received them. + +### Why previous tests didn't catch it + +All existing tests hit mock server endpoints that don't vary by query string +(`/text`, `/stream/fast`, etc.), so the absence of query parameters had no +observable effect. The mock server's `GET /get` endpoint was documented as +echoing query parameters, but the implementation only echoed the path — so even +tests using that endpoint couldn't detect the bug (fixed in mock server v1.1.1). + +### The fix + +**`client.gleam` — `build_url`:** + +Added a case expression on `request.query`: +- `Some(query)` → appends `"?" <> query` to the URL +- `None` → no change (preserves existing behavior) + +**`internal.gleam` — `start_httpc_stream`:** + +Same fix applied to the independent URL construction used by `stream_yielder()`. + +### Regression tests (8 new tests, 185 total) + +All assertions parse the mock server's JSON `query` field for exact matching +rather than substring checks. + +| Test | Execution mode | What it verifies | +|---|---|---| +| `send_includes_query_params_in_request` | `send()` | Query arrives at server | +| `send_without_query_params_sends_empty_query` | `send()` | No query = empty string | +| `send_with_special_characters_in_query` | `send()` | URL-encoded chars arrive verbatim | +| `send_with_empty_query_string` | `send()` | Empty string edge case | +| `stream_yielder_includes_query_params_in_request` | `stream_yielder()` | Query arrives via yielder path | +| `start_stream_includes_query_params_in_request` | `start_stream()` | Query arrives via callback path | +| `send_with_query_and_recorder_record_mode_preserves_query` | `send()` + recorder | Server receives query; recording captures it | +| `send_with_query_and_recorder_playback_mode_matches_query` | `send()` + recorder | Record + playback round-trip with query | + +--- + +## Files changed + +- `modules/http_client/src/dream_http_client/client.gleam` — `build_url` now + appends query string +- `modules/http_client/src/dream_http_client/internal.gleam` — + `start_httpc_stream` now appends query string +- `modules/http_client/test/recorder_client_test.gleam` — 8 new regression + tests +- `modules/mock_server/src/dream_mock_server/controllers/api_controller.gleam` + — `GET /get` now passes `request.query` to the view +- `modules/mock_server/src/dream_mock_server/views/api_view.gleam` — + `get_to_json` now includes `query` field in JSON response diff --git a/modules/http_client/src/dream_http_client/client.gleam b/modules/http_client/src/dream_http_client/client.gleam index 88120c6..2cc01bb 100644 --- a/modules/http_client/src/dream_http_client/client.gleam +++ b/modules/http_client/src/dream_http_client/client.gleam @@ -1783,11 +1783,16 @@ fn build_url(request: request.Request(String)) -> String { option.Some(port) -> ":" <> int.to_string(port) option.None -> "" } + let query_string = case request.query { + option.Some(query) -> "?" <> query + option.None -> "" + } http.scheme_to_string(request.scheme) <> "://" <> request.host <> port_string <> request.path + <> query_string } fn parse_stream_start_result(result: d.Dynamic) -> Result(RequestId, String) { diff --git a/modules/http_client/src/dream_http_client/internal.gleam b/modules/http_client/src/dream_http_client/internal.gleam index 15726b0..3dd95e1 100644 --- a/modules/http_client/src/dream_http_client/internal.gleam +++ b/modules/http_client/src/dream_http_client/internal.gleam @@ -90,12 +90,17 @@ pub fn start_httpc_stream( option.Some(port) -> ":" <> int.to_string(port) option.None -> "" } + let query_string = case request.query { + option.Some(query) -> "?" <> query + option.None -> "" + } let url = http.scheme_to_string(request.scheme) <> "://" <> request.host <> port_string <> request.path + <> query_string let method_atom = atomize_method(request.method) let body = <> let receiver = process.self() diff --git a/modules/http_client/test/recorder_client_test.gleam b/modules/http_client/test/recorder_client_test.gleam index 908ac4c..3cb3a15 100644 --- a/modules/http_client/test/recorder_client_test.gleam +++ b/modules/http_client/test/recorder_client_test.gleam @@ -8,9 +8,11 @@ import dream_http_client/storage import dream_http_client_test import gleam/bit_array import gleam/bytes_tree +import gleam/dynamic/decode import gleam/erlang/process import gleam/http import gleam/io +import gleam/json import gleam/list import gleam/option import gleam/result @@ -1209,3 +1211,196 @@ fn collect_chunks_from_mailbox( Error(Nil) -> list.reverse(acc) } } + +// --------------------------------------------------------------------------- +// Regression tests: query parameters must survive through to the HTTP request +// +// The mock server's GET /get endpoint echoes the received query string back +// in a JSON response: {"method":"GET","url":"/get","query":"...","headers":[]} +// We parse that JSON field for exact assertions rather than substring matching. +// --------------------------------------------------------------------------- + +fn extract_query_from_get_response(body: String) -> String { + let decoder = { + use query <- decode.field("query", decode.string) + decode.success(query) + } + let assert Ok(query) = json.parse(body, decoder) + query +} + +fn stream_chunks_to_string( + chunks: List(Result(bytes_tree.BytesTree, String)), +) -> String { + chunks + |> list.filter_map(fn(chunk) { chunk }) + |> list.map(fn(bt) { + bt |> bytes_tree.to_bit_array |> bit_array.to_string |> result.unwrap("") + }) + |> string.join("") +} + +// -- send() ----------------------------------------------------------------- + +pub fn send_includes_query_params_in_request_test() { + // Arrange + let request = + mock_request("/get") + |> client.query("page=1&limit=10") + + // Act + let assert Ok(client.HttpResponse(body: body, ..)) = client.send(request) + + // Assert - exact match on the echoed query field + extract_query_from_get_response(body) |> should.equal("page=1&limit=10") +} + +pub fn send_without_query_params_sends_empty_query_test() { + // Arrange + let request = mock_request("/get") + + // Act + let assert Ok(client.HttpResponse(body: body, ..)) = client.send(request) + + // Assert - query field should be empty when none was set + extract_query_from_get_response(body) |> should.equal("") +} + +pub fn send_with_special_characters_in_query_test() { + // Arrange - URL-encoded spaces, ampersands, equals signs + let request = + mock_request("/get") + |> client.query("name=hello%20world&tag=a%26b&eq=1%3D1") + + // Act + let assert Ok(client.HttpResponse(body: body, ..)) = client.send(request) + + // Assert - query string arrives verbatim (no double-encoding) + extract_query_from_get_response(body) + |> should.equal("name=hello%20world&tag=a%26b&eq=1%3D1") +} + +pub fn send_with_empty_query_string_test() { + // Arrange - explicitly set query to empty string + let request = + mock_request("/get") + |> client.query("") + + // Act + let assert Ok(client.HttpResponse(body: body, ..)) = client.send(request) + + // Assert - empty query should still arrive (as empty string, not omitted) + extract_query_from_get_response(body) |> should.equal("") +} + +// -- stream_yielder() ------------------------------------------------------- + +pub fn stream_yielder_includes_query_params_in_request_test() { + // Arrange + let request = + mock_request("/get") + |> client.query("format=json") + + // Act + let body = + client.stream_yielder(request) + |> yielder.to_list() + |> stream_chunks_to_string() + + // Assert - exact match on the echoed query field + extract_query_from_get_response(body) |> should.equal("format=json") +} + +// -- start_stream() (callback-based) ---------------------------------------- + +pub fn start_stream_includes_query_params_in_request_test() { + // Arrange + let chunks_subject = process.new_subject() + + let request = + mock_request("/get") + |> client.query("stream_key=abc") + |> client.on_stream_chunk(fn(data) { process.send(chunks_subject, data) }) + + // Act + let assert Ok(handle) = client.start_stream(request) + client.await_stream(handle) + + // Assert - reassemble body from callback chunks and check exact query + let body = + collect_chunks_from_mailbox(chunks_subject, []) + |> list.map(fn(d) { bit_array.to_string(d) |> result.unwrap("") }) + |> string.join("") + extract_query_from_get_response(body) |> should.equal("stream_key=abc") +} + +// -- recorder integration --------------------------------------------------- + +pub fn send_with_query_and_recorder_record_mode_preserves_query_test() { + // Arrange + let recordings_directory_path = temp_directory("query_record_test") + let assert Ok(rec) = + recorder.new() + |> directory(recordings_directory_path) + |> mode("record") + |> start() + + let request = + mock_request("/get") + |> client.query("search=hello") + |> client.recorder(rec) + + // Act + let assert Ok(client.HttpResponse(body: body, ..)) = client.send(request) + + // Assert - query string arrived at the server (exact match) + extract_query_from_get_response(body) |> should.equal("search=hello") + + // Flush recordings to disk + recorder.stop(rec) |> result.unwrap(Nil) + + // Verify the recording captured the query + let assert Ok(recordings) = storage.load_recordings(recordings_directory_path) + let assert [first_recording, ..] = recordings + first_recording.request.query |> should.equal(option.Some("search=hello")) +} + +pub fn send_with_query_and_recorder_playback_mode_matches_query_test() { + // Arrange - record a request with a specific query + let recordings_directory_path = temp_directory("query_playback_test") + let assert Ok(rec) = + recorder.new() + |> directory(recordings_directory_path) + |> mode("record") + |> start() + + let request = + mock_request("/get") + |> client.query("id=42") + |> client.recorder(rec) + + let assert Ok(_) = client.send(request) + recorder.stop(rec) |> result.unwrap(Nil) + + // Replay with the same query + let assert Ok(playback_rec) = + recorder.new() + |> directory(recordings_directory_path) + |> mode("playback") + |> start() + + let playback_request = + mock_request("/get") + |> client.query("id=42") + |> client.recorder(playback_rec) + + // Act + let assert Ok(client.HttpResponse(body: body, ..)) = + client.send(playback_request) + + // Assert - playback returns the original recorded body (exact match) + extract_query_from_get_response(body) |> should.equal("id=42") + + // Cleanup + recorder.stop(playback_rec) |> result.unwrap(Nil) +} diff --git a/modules/mock_server/CHANGELOG.md b/modules/mock_server/CHANGELOG.md index 1388752..81f245c 100644 --- a/modules/mock_server/CHANGELOG.md +++ b/modules/mock_server/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 1.1.1 - 2026-03-03 + +### Fixed + +- **`GET /get` now actually echoes query parameters.** The endpoint was + documented as echoing query parameters since 1.0.0, but the implementation + only passed `request.path` to the view layer. Now passes `request.query` as + well, and the JSON response includes a `"query"` field with the raw query + string. + ## 1.1.0 - 2026-02-15 ### Added diff --git a/modules/mock_server/README.md b/modules/mock_server/README.md index 55f3172..d855895 100644 --- a/modules/mock_server/README.md +++ b/modules/mock_server/README.md @@ -2,7 +2,7 @@ A general-purpose HTTP mock server developed by Dream that provides both streaming and non-streaming endpoints for testing HTTP clients. -**Current module version:** `1.1.0` +**Current module version:** `1.1.1` ## Overview diff --git a/modules/mock_server/gleam.toml b/modules/mock_server/gleam.toml index aac35a2..37a979d 100644 --- a/modules/mock_server/gleam.toml +++ b/modules/mock_server/gleam.toml @@ -1,5 +1,5 @@ name = "dream_mock_server" -version = "1.1.0" +version = "1.1.1" description = "General-purpose HTTP mock server developed by Dream - provides both streaming and non-streaming endpoints for testing HTTP clients" licences = ["MIT"] repository = { type = "github", user = "TrustBound", repo = "dream" } diff --git a/modules/mock_server/releases/release-1.1.1.md b/modules/mock_server/releases/release-1.1.1.md new file mode 100644 index 0000000..6543d3c --- /dev/null +++ b/modules/mock_server/releases/release-1.1.1.md @@ -0,0 +1,39 @@ +# Dream Mock Server Release 1.1.1: GET /get now echoes query parameters + +**Release Date:** March 3, 2026 + +This patch release fixes the `GET /get` endpoint to actually echo query +parameters as documented since 1.0.0. + +## What was fixed + +The `GET /get` endpoint was listed as "Echo query parameters" in the CHANGELOG +and endpoint documentation, but the implementation only passed `request.path` to +the view layer. The query string was silently ignored. + +Now the controller passes `request.query` to the view, and the JSON response +includes a `"query"` field: + +```json +{"method":"GET","url":"/get","query":"page=1&limit=10","headers":[]} +``` + +When no query string is present, the field contains an empty string: + +```json +{"method":"GET","url":"/get","query":"","headers":[]} +``` + +## Files changed + +- `src/dream_mock_server/controllers/api_controller.gleam` — `get()` now passes + `request.query` to `api_view.get_to_json` +- `src/dream_mock_server/views/api_view.gleam` — `get_to_json` and + `get_to_json_object` now accept a `query` parameter and include it in the + JSON output + +## Backward compatibility + +The JSON response for `GET /get` now has an additional `"query"` field. Callers +that parse only `"method"` and `"url"` are unaffected. Callers that do strict +schema validation may need to accept the new field. 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 9e9786e..035242b 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 @@ -16,13 +16,13 @@ import gleam/list import gleam/option import gleam/string -/// GET /get - Returns JSON with request info +/// GET /get - Returns JSON with request info including query parameters pub fn get( request: Request, _context: EmptyContext, _services: EmptyServices, ) -> Response { - json_response(status.ok, api_view.get_to_json(request.path)) + json_response(status.ok, api_view.get_to_json(request.path, request.query)) } /// POST /post - Echoes request body as JSON 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 9c9815f..35f8ac3 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 @@ -6,8 +6,8 @@ import gleam/json /// Format GET request info as JSON string -pub fn get_to_json(path: String) -> String { - get_to_json_object(path) +pub fn get_to_json(path: String, query: String) -> String { + get_to_json_object(path, query) |> json.to_string() } @@ -60,10 +60,11 @@ pub fn error_to_json(message: String) -> String { // Private helpers - all named functions -fn get_to_json_object(path: String) -> json.Json { +fn get_to_json_object(path: String, query: String) -> json.Json { json.object([ #("method", json.string("GET")), #("url", json.string(path)), + #("query", json.string(query)), #("headers", json.array(from: [], of: json.string)), ]) }