Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions modules/http_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion modules/http_client/gleam.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
79 changes: 79 additions & 0 deletions modules/http_client/releases/release-5.1.2.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions modules/http_client/src/dream_http_client/client.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions modules/http_client/src/dream_http_client/internal.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <<request.body:utf8>>
let receiver = process.self()
Expand Down
195 changes: 195 additions & 0 deletions modules/http_client/test/recorder_client_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
10 changes: 10 additions & 0 deletions modules/mock_server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion modules/mock_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion modules/mock_server/gleam.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
Loading
Loading