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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.3.3] - 2026-03-07

### Fixed

- Fixed multiple `Set-Cookie` headers being collapsed into a single header during mist response conversion
- `set_header` (which replaces) was used for all headers including `Set-Cookie`, but RFC 6265 requires each cookie to be sent as a separate `Set-Cookie` header
- `add_header` now uses `prepend_header` (which allows duplicates) for `set-cookie` headers, matching the Gleam standard library's own `set_cookie` convention
- Browsers that received responses with multiple cookies now correctly receive all cookies instead of only the last one

### Added

- Added `count_mist_headers` test matcher for verifying header counts by name
- Added `extract_all_mist_header_values` test matcher for extracting all values of a header
- Added 7 tests covering multiple `Set-Cookie` header behavior (RFC 6265 compliance)

## [2.3.2] - 2026-02-04

### Added
Expand Down Expand Up @@ -475,7 +490,8 @@ Special thanks to [Louis Pilfold](https://github.com/lpil) for suggesting the ra
- All code examples now include proper imports
- Improved documentation tone and consistency

[Unreleased]: https://github.com/TrustBound/dream/compare/v2.3.2...HEAD
[Unreleased]: https://github.com/TrustBound/dream/compare/v2.3.3...HEAD
[2.3.3]: https://github.com/TrustBound/dream/compare/v2.3.2...v2.3.3
[2.3.2]: https://github.com/TrustBound/dream/compare/v2.3.1...v2.3.2
[2.3.1]: https://github.com/TrustBound/dream/compare/v2.3.0...v2.3.1
[2.3.0]: https://github.com/TrustBound/dream/compare/v2.2.0...v2.3.0
Expand Down
2 changes: 1 addition & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name = "dream"
version = "2.3.2"
version = "2.3.3"
description = "Clean, composable web development for Gleam. No magic."
licences = ["MIT"]
repository = { type = "github", user = "TrustBound", repo = "dream" }
Expand Down
69 changes: 69 additions & 0 deletions releases/release-2.3.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Dream 2.3.3 Release Notes

**Release Date:** March 7, 2026

This release fixes a bug where multiple `Set-Cookie` headers were collapsed into a single header, causing browsers to only receive the last cookie.

## Key Highlights

- **Set-Cookie fix**: Multiple cookies now each produce their own `Set-Cookie` header per RFC 6265
- **Comprehensive test coverage**: 7 new tests validate multi-cookie behavior and RFC compliance

## Fixed

The mist response converter used `set_header` (which replaces existing headers with the same name) for every header, including `Set-Cookie`. RFC 6265 requires each cookie to be sent as a separate `Set-Cookie` header — browsers do not parse comma-separated `Set-Cookie` values.

`add_header` now uses `prepend_header` (which allows duplicates) for `set-cookie` headers, and `set_header` (which replaces) for everything else. This matches the Gleam standard library's own `set_cookie` convention.

### Before

```gleam
fn add_header(acc, header) {
http_response.set_header(acc, header.0, header.1)
}
```

### After

```gleam
fn add_header(acc, header) {
case header.0 {
"set-cookie" -> http_response.prepend_header(acc, header.0, header.1)
_ -> http_response.set_header(acc, header.0, header.1)
}
}
```

## Added

- `count_mist_headers` test matcher for verifying header counts by name
- `extract_all_mist_header_values` test matcher for extracting all values of a header
- 7 new tests covering:
- Multiple cookies produce separate `Set-Cookie` headers
- Each cookie value is individually present
- Three cookies produce three headers
- Manual `Set-Cookie` in headers coexists with cookies from the `cookies` field
- Duplicate non-cookie headers are still deduplicated
- Cookies with attributes each get their own header
- Cookies alongside other headers don't interfere

## Upgrading

Update your dependencies:

```toml
[dependencies]
dream = ">= 2.3.3 and < 3.0.0"
```

Then run:

```bash
gleam deps download
```

## Documentation

- [dream](https://hexdocs.pm/dream) - v2.3.3

---
2 changes: 1 addition & 1 deletion src/dream/http/response.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub type ResponseBody {
/// - `status`: HTTP status code (200, 404, 500, etc.) - use constants from `dream/http/status`
/// - `body`: Response body as Text, Bytes, or Stream
/// - `headers`: List of HTTP headers
/// - `cookies`: List of cookies to set
/// - `cookies`: List of cookies to set (each becomes a separate `Set-Cookie` header)
/// - `content_type`: Content-Type header value (automatically set by builders)
///
/// ## Example
Expand Down
7 changes: 5 additions & 2 deletions src/dream/servers/mist/response.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import mist.{type ResponseData, Bytes as MistBytes, Chunked}
///
/// - Status code (Int remains Int)
/// - Headers (Dream Header to Mist tuple format)
/// - Cookies (formatted as Set-Cookie headers)
/// - Cookies (each cookie becomes its own `Set-Cookie` header per RFC 6265)
/// - Body (Text/Bytes/Stream to Mist ResponseData)
///
/// The conversion handles all three body types:
Expand Down Expand Up @@ -103,7 +103,10 @@ fn add_header(
acc: http_response.Response(ResponseData),
header: #(String, String),
) -> http_response.Response(ResponseData) {
http_response.set_header(acc, header.0, header.1)
case header.0 {
"set-cookie" -> http_response.prepend_header(acc, header.0, header.1)
_ -> http_response.set_header(acc, header.0, header.1)
}
}

fn set_all_headers(
Expand Down
232 changes: 231 additions & 1 deletion test/dream/servers/mist/response_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import dream/http/cookie.{secure_cookie, simple_cookie}
import dream/http/header.{Header}
import dream/http/response.{Response, Text}
import dream/servers/mist/response as mist_response
import dream_test/assertions/should.{contain_string, equal, or_fail_with, should}
import dream_test/assertions/should.{
contain, contain_string, equal, or_fail_with, should,
}
import dream_test/unit.{type UnitTest, describe, it}
import gleam/option
import matchers/count_mist_headers.{count_mist_headers}
import matchers/extract_all_mist_header_values.{extract_all_mist_header_values}
import matchers/extract_mist_header_value.{extract_mist_header_value}
import matchers/have_mist_header.{have_mist_header}
import matchers/have_mist_header_containing.{have_mist_header_containing}
Expand All @@ -18,6 +22,7 @@ import matchers/have_mist_header_containing.{have_mist_header_containing}
pub fn tests() -> UnitTest {
describe("response", [
convert_tests(),
multiple_set_cookie_tests(),
])
}

Expand Down Expand Up @@ -237,3 +242,228 @@ fn convert_tests() -> UnitTest {
}),
])
}

fn multiple_set_cookie_tests() -> UnitTest {
describe("multiple Set-Cookie headers (RFC 6265)", [
it("produces separate Set-Cookie headers for each cookie", fn() {
// Arrange
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [],
cookies: [
simple_cookie("session", "abc123"),
simple_cookie("theme", "dark"),
],
content_type: option.None,
)

// Act
let result = mist_response.convert(dream_response)

// Assert
result
|> should()
|> count_mist_headers("set-cookie")
|> equal(2)
|> or_fail_with(
"Each cookie must produce its own Set-Cookie header per RFC 6265",
)
}),
it("each cookie value is individually present", fn() {
// Arrange
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [],
cookies: [
simple_cookie("session", "abc123"),
simple_cookie("theme", "dark"),
],
content_type: option.None,
)

// Act
let result = mist_response.convert(dream_response)

// Assert — both cookie values appear as separate headers
result
|> should()
|> have_mist_header_containing("set-cookie", "session=abc123")
|> or_fail_with("Should have session cookie header")

result
|> should()
|> have_mist_header_containing("set-cookie", "theme=dark")
|> or_fail_with("Should have theme cookie header")
}),
it("three cookies produce three separate Set-Cookie headers", fn() {
// Arrange
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [],
cookies: [
simple_cookie("a", "1"),
simple_cookie("b", "2"),
simple_cookie("c", "3"),
],
content_type: option.None,
)

// Act
let result = mist_response.convert(dream_response)

// Assert
result
|> should()
|> count_mist_headers("set-cookie")
|> equal(3)
|> or_fail_with("Three cookies must produce three Set-Cookie headers")
}),
it(
"manual Set-Cookie in headers coexists with cookies from cookies field",
fn() {
// Arrange — one cookie via headers, one via cookies field
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [Header("Set-Cookie", "manual=fromheader; Path=/")],
cookies: [simple_cookie("session", "abc123")],
content_type: option.None,
)

// Act
let result = mist_response.convert(dream_response)

// Assert — both must survive as separate headers
result
|> should()
|> count_mist_headers("set-cookie")
|> equal(2)
|> or_fail_with(
"Manual Set-Cookie header and cookie field should both be present",
)

result
|> should()
|> have_mist_header_containing("set-cookie", "manual=fromheader")
|> or_fail_with("Manual Set-Cookie header should be preserved")

result
|> should()
|> have_mist_header_containing("set-cookie", "session=abc123")
|> or_fail_with("Cookie field cookie should be preserved")
},
),
it("duplicate non-set-cookie headers are still deduplicated", fn() {
// Arrange — two headers with the same non-cookie name
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [
Header("X-Request-ID", "first"),
Header("X-Request-ID", "second"),
],
cookies: [],
content_type: option.None,
)

// Act
let result = mist_response.convert(dream_response)

// Assert — set_header replaces, so only the last value should remain
result
|> should()
|> count_mist_headers("x-request-id")
|> equal(1)
|> or_fail_with(
"Non-cookie duplicate headers should be deduplicated by set_header",
)
}),
it("cookies with attributes each get their own header", fn() {
// Arrange — mix of simple and secure cookies
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [],
cookies: [
simple_cookie("preferences", "lang=en"),
secure_cookie("auth_token", "secret123"),
],
content_type: option.None,
)

// Act
let result = mist_response.convert(dream_response)

// Assert — both cookies present, secure one has its attributes
result
|> should()
|> count_mist_headers("set-cookie")
|> equal(2)
|> or_fail_with("Both cookies should produce separate Set-Cookie headers")

result
|> should()
|> have_mist_header_containing("set-cookie", "preferences=lang=en")
|> or_fail_with("Simple cookie should be present")

result
|> should()
|> extract_all_mist_header_values("set-cookie")
|> contain("auth_token=secret123; Secure; HttpOnly; SameSite=Strict")
|> or_fail_with(
"Secure cookie should have all attributes in its own header",
)
}),
it("cookies alongside other headers don't interfere", fn() {
// Arrange
let dream_response =
Response(
status: 200,
body: Text("OK"),
headers: [
Header("X-Custom", "value1"),
Header("Cache-Control", "no-store"),
],
cookies: [
simple_cookie("session", "abc"),
simple_cookie("csrf", "token123"),
],
content_type: option.Some("text/html"),
)

// Act
let result = mist_response.convert(dream_response)

// Assert — all headers coexist correctly
result
|> should()
|> count_mist_headers("set-cookie")
|> equal(2)
|> or_fail_with("Both cookies should be present")

result
|> should()
|> have_mist_header("x-custom", "value1")
|> or_fail_with("Custom header should be preserved")

result
|> should()
|> have_mist_header("cache-control", "no-store")
|> or_fail_with("Cache-Control should be preserved")

result
|> should()
|> have_mist_header("content-type", "text/html")
|> or_fail_with("Content-type should be preserved")
}),
])
}
Loading
Loading