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)), ]) }