README.md

<div align="center">
  <img src="https://raw.githubusercontent.com/TrustBound/dream/main/ricky_and_lucy.png" alt="Dream Logo" width="200">
</div>

<br />

<div align="center">
  <a href="https://hex.pm/packages/dream_http_client">
    <img src="https://img.shields.io/hexpm/v/dream_http_client" alt="Hex Package">
  </a>
  <a href="https://hexdocs.pm/dream_http_client">
    <img src="https://img.shields.io/badge/hex-docs-lightgreen.svg" alt="HexDocs">
  </a>
  <a href="https://github.com/TrustBound/dream/blob/main/modules/http_client/LICENSE.md">
    <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License">
  </a>
  <a href="https://gleam.run">
    <img src="https://img.shields.io/badge/gleam-%E2%9C%A8-ffaff3" alt="Gleam">
  </a>
</div>

<br />

# dream_http_client

**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.

---

## Contents

- [Why dream_http_client?](#why-dream_http_client)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Execution Modes](#execution-modes)
- [Recording & Playback](#recording--playback)
- [API Reference](#api-reference)
- [Examples](#examples)

---

## Why dream_http_client?

| Feature                   | What you get                                                |
| ------------------------- | ----------------------------------------------------------- |
| **Three execution modes** | Blocking, yielder streaming, process-based—choose what fits |
| **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  |
| **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                           |
| **Builder pattern**       | Consistent, composable request configuration                |

---

## Installation

```bash
gleam add dream_http_client
```

---

## Quick Start

Make a simple HTTP request:

```gleam
import dream_http_client/client.{host, method, path, port, scheme, send}
import gleam/http

pub fn simple_get() -> Result(String, String) {
  client.new()
  |> method(http.Get)
  |> scheme(http.Http)
  |> host("localhost")
  |> port(9876)
  |> path("/text")
  |> send()
}
```

<sub>🧪 [Tested source](test/snippets/blocking_request.gleam)</sub>

---

## Execution Modes

dream_http_client provides three execution modes. Choose based on your use case:

### 1. Blocking - `send()`

**Best for:** JSON APIs, small responses

```gleam
import dream_http_client/client.{host, path, port, scheme, send}
import gleam/http

let result =
  client.new()
  |> scheme(http.Http)
  |> host("localhost")
  |> port(9876)
  |> path("/text")
  |> send()

case result {
  Ok(body) -> Ok(body)
  Error(msg) -> Error(msg)
}
```

<sub>🧪 [Tested source](test/snippets/blocking_request.gleam)</sub>

### 2. Yielder Streaming - `stream_yielder()`

**Best for:** AI/LLM streaming, file downloads, sequential processing

```gleam
import dream_http_client/client.{host, path, port, scheme, stream_yielder}
import gleam/bytes_tree
import gleam/http
import gleam/yielder

let total_bytes =
  client.new()
  |> scheme(http.Http)
  |> host("localhost")
  |> port(9876)
  |> path("/stream/fast")
  |> stream_yielder()
  |> yielder.fold(0, fn(total, chunk_result) {
    case chunk_result {
      Ok(chunk) -> total + bytes_tree.byte_size(chunk)
      Error(_) -> total
    }
  })
```

<sub>🧪 [Tested source](test/snippets/stream_yielder_basic.gleam)</sub>

**⚠️ Note:** This blocks while waiting for chunks. Not suitable for OTP actors handling concurrent operations.

### 3. Process-Based Streaming - `start_stream()`

**Best for:** Background tasks, concurrent operations, cancellable streams

```gleam
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,
}
import gleam/bit_array
import gleam/http
import gleam/io

pub fn stream_and_print() -> Result(Nil, String) {
  let stream_result =
    client.new()
    |> scheme(http.Http)
    |> host("localhost")
    |> port(9876)
    |> path("/stream/fast")
    |> on_stream_start(fn(_headers) { io.println("Stream started") })
    |> on_stream_chunk(fn(data) {
      case bit_array.to_string(data) {
        Ok(text) -> io.print(text)
        Error(_) -> io.print("<binary>")
      }
    })
    |> on_stream_end(fn(_headers) { io.println("\nStream completed") })
    |> on_stream_error(fn(reason) {
      io.println_error("Stream error: " <> reason)
    })
    |> start_stream()

  case stream_result {
    Error(reason) -> Error(reason)
    Ok(stream_handle) -> {
      await_stream(stream_handle)
      Ok(Nil)
    }
  }
}
```

<sub>🧪 [Tested source](test/snippets/stream_messages_basic.gleam)</sub>

### Choosing a Mode

| Use Case                          | Mode               | Why                                   |
| --------------------------------- | ------------------ | ------------------------------------- |
| JSON API calls                    | `send()`           | Simple, complete response at once     |
| Small file downloads              | `send()`           | Load entire file into memory          |
| AI/LLM streaming (single request) | `stream_yielder()` | Sequential token processing           |
| File downloads                    | `stream_yielder()` | Memory-efficient chunked processing   |
| Background processing             | `start_stream()`   | Non-blocking, concurrent, cancellable |
| Long-lived connections            | `start_stream()`   | Can cancel mid-stream                 |
| Cancellable operations            | `start_stream()`   | Cancel via handle                     |

---

## Recording & Playback

Record HTTP requests/responses for testing, debugging, and offline development.

### Quick Example

```gleam
import dream_http_client/recorder.{directory, mode, start}
import dream_http_client/client.{host, path, port, recorder as with_recorder, scheme, send}
import gleam/http

// Record real requests
let assert Ok(rec) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("record")
  |> start()

client.new()
  |> scheme(http.Http)
  |> host("localhost")
  |> port(9876)
  |> path("/text")
  |> with_recorder(rec)
  |> send()  // Saved immediately to disk

// Playback later (no network)
let assert Ok(playback) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("playback")
  |> start()

client.new()
  |> scheme(http.Http)
  |> host("localhost")
  |> port(9876)
  |> path("/text")
  |> with_recorder(playback)
  |> send()  // Returns recorded response
```

<sub>🧪 [Tested source](test/snippets/recording_basic.gleam)</sub>

### Recording Modes

- **`"record"`** - Records real requests to disk immediately
- **`"playback"`** - Returns recorded responses (no network)
- **`"passthrough"`** - No recording/playback

**Important:** Recordings are saved immediately when captured. `recorder.stop()` is optional and only performs cleanup. This ensures recordings are never lost even if the process crashes.

### Use Cases

**Testing:**

```gleam
// test/api_test.gleam
import dream_http_client/recorder.{directory, mode, start}

let assert Ok(rec) =
  recorder.new()
  |> directory("test/fixtures/api")
  |> mode("playback")
  |> start()

// Tests run without external dependencies
```

<sub>🧪 [Tested source](test/snippets/recording_playback.gleam)</sub>

**Offline Development:**
Record API responses once, then work offline using recorded responses.

**Debugging Production:**
Record problematic request/response pairs for investigation.

### Request Matching

```gleam
import dream_http_client/matching
import dream_http_client/recorder.{directory, key, mode, start}

// Build a request key function from include/exclude flags
let request_key_fn = matching.request_key(
  method: True,
  url: True,
  headers: False,  // Ignore auth tokens, timestamps
  body: False,     // Ignore request IDs in body
)

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("playback")
  |> key(request_key_fn)
  |> start()
```

<sub>🧪 [Tested source](test/snippets/matching_config.gleam)</sub>

### Scrubbing Secrets (Transformers)

If your requests contain secrets (like `Authorization` headers) or volatile fields (timestamps, request IDs),
you can attach a transformer to **normalize** requests _before_ the key is computed and before anything is persisted.

```gleam
import dream_http_client/matching
import dream_http_client/recorder.{
  directory, key, mode, request_transformer, start,
}
import dream_http_client/recording
import gleam/list

let request_key_fn =
  matching.request_key(method: True, url: True, headers: True, body: True)

fn scrub_auth_and_body(
  request: recording.RecordedRequest,
) -> recording.RecordedRequest {
  fn is_not_authorization_header(header: #(String, String)) -> Bool {
    header.0 != "Authorization"
  }

  let recording.RecordedRequest(
    method,
    scheme,
    host,
    port,
    path,
    query,
    headers,
    _body,
  ) = request

  let scrubbed_headers =
    list.filter(headers, is_not_authorization_header)

  recording.RecordedRequest(
    method: method,
    scheme: scheme,
    host: host,
    port: port,
    path: path,
    query: query,
    headers: scrubbed_headers,
    body: "",
  )
}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("record")
  |> key(request_key_fn)
  |> request_transformer(scrub_auth_and_body)
  |> start()

// ... requests recorded via this recorder will have secrets scrubbed ...
```

<sub>🧪 [Tested source](test/snippets/recording_transformer.gleam)</sub>

If you need to scrub **responses** (cookies, tokens, PII) before fixtures are written to disk, use a response transformer.
This runs **only in record mode**.

```gleam
import dream_http_client/recorder.{directory, mode, response_transformer, start}
import dream_http_client/recording

fn scrub_response(
  _request: recording.RecordedRequest,
  response: recording.RecordedResponse,
) -> recording.RecordedResponse {
  // Implementation omitted here (see tested snippet)
  response
}

let assert Ok(rec) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("record")
  |> response_transformer(scrub_response)
  |> start()
```

<sub>🧪 [Tested source](test/snippets/recording_response_transformer.gleam)</sub>

### Ambiguous Matches (Key Collisions)

Playback **errors** if more than one recording matches the same request key. This is intentional: it forces you to
refine your key function (or add a transformer) so each request maps to exactly one recording.

```gleam
import dream_http_client/matching
import dream_http_client/recorder.{directory, key, mode, start}

let request_key_fn =
  matching.request_key(method: True, url: True, headers: False, body: False)

let assert Ok(playback) =
  recorder.new()
  |> directory("mocks/api")
  |> mode("playback")
  |> key(request_key_fn)
  |> start()

// ... lookup will return Error("Ambiguous recording match ...") if multiple match ...
```

<sub>🧪 [Tested source](test/snippets/recording_ambiguous_match.gleam)</sub>

### Recording Storage

Recordings are stored as individual files (one per request) with human-readable filenames:

- Filename format: `{method}_{host}_{path}_{key_hash}_{content_hash}.json`
- **`key_hash`** groups recordings by request key
- **`content_hash`** prevents overwrites when multiple recordings share the same key

```
mocks/api/GET_localhost__text_a3f5b2_19d0a1.json
mocks/api/POST_localhost__text_c7d8e9_4f22bc.json
```

**Benefits:**

- **O(1) write performance** - No read-modify-write cycles
- **Concurrent tests work** - No file contention between parallel tests
- **Easy inspection** - Each recording is a separate, readable file
- **Version control friendly** - Individual files show clear diffs

---

## API Reference

### Builder Pattern

```gleam
import dream_http_client/client.{
  add_header, body, host, method, path, port, query, scheme, send, timeout,
}
import gleam/http

let json_body = "{\"hello\":\"world\"}"

client.new()
|> method(http.Post)         // HTTP method
|> scheme(http.Http)         // HTTP or HTTPS
|> host("localhost")         // Hostname (required)
|> port(9876)                // Port (optional, defaults 80/443)
|> path("/post")             // Request path
|> query("page=1&limit=10")  // Query string
|> add_header("Content-Type", "application/json")
|> body(json_body)           // Request body
|> timeout(60_000)           // Timeout in ms (default: 30s)
|> send()
```

<sub>🧪 [Tested source](test/snippets/request_builder.gleam)</sub>

### Execution

**Blocking:**

- `send(req) -> Result(String, String)` - Returns complete response body

**Yielder Streaming:**

- `stream_yielder(req) -> Yielder(Result(BytesTree, String))` - Returns yielder producing chunks

**Process-Based Streaming:**

- `start_stream(req) -> Result(StreamHandle, String)` - Starts stream, returns handle
- `await_stream(handle) -> Nil` - Wait for completion (optional)
- `cancel_stream_handle(handle) -> Nil` - Cancel running stream

### Types

**`StreamHandle`** - Opaque identifier for process-based streams

### Error Handling

All modes use `Result` types for explicit error handling:

```gleam
import dream_http_client/client.{host, path, port, scheme, send, timeout}
import gleam/http
import gleam/io

let request =
  client.new()
  |> scheme(http.Http)
  |> host("localhost")
  |> port(9876)
  |> path("/text")
  |> timeout(5000)

case send(request) {
  Ok(body) -> {
    io.println(body)
    Ok(body)
  }
  Error(msg) -> {
    io.println_error("Request failed: " <> msg)
    Error(msg)
  }
}
```

<sub>🧪 [Tested source](test/snippets/timeout_config.gleam)</sub>

---

## Examples

All examples are tested and verified. See [test/snippets/](test/snippets/) for complete, runnable code.

**Basic requests:**

- [Blocking request](test/snippets/blocking_request.gleam) - Simple GET
- [POST with JSON](test/snippets/post_json.gleam) - JSON body
- [Request builder](test/snippets/request_builder.gleam) - Full configuration
- [Timeout configuration](test/snippets/timeout_config.gleam) - Custom timeouts

**Streaming:**

- [Yielder streaming](test/snippets/stream_yielder_basic.gleam) - Sequential processing
- [Process-based streaming](test/snippets/stream_messages_basic.gleam) - Callback-driven streaming
- [Stream cancellation](test/snippets/stream_cancel.gleam) - Cancel via `StreamHandle`

**Recording:**

- [Record and playback](test/snippets/recording_basic.gleam) - Testing without network
- [Playback-only testing](test/snippets/recording_playback.gleam) - Test fixtures without network
- [Custom request keys](test/snippets/matching_config.gleam) - Configure request matching
- [Request transformers](test/snippets/recording_transformer.gleam) - Scrub secrets before keying/persistence
- [Response transformers](test/snippets/recording_response_transformer.gleam) - Scrub secrets from recorded responses
- [Ambiguous match errors](test/snippets/recording_ambiguous_match.gleam) - Key collision behavior

---

## Design Principles

This module follows the same quality standards as [Dream](https://github.com/TrustBound/dream):

- **No nested cases** - Clear, flat control flow throughout
- **Prefer named functions** - Use named functions when it improves readability
- **Builder pattern** - Consistent, composable request configuration
- **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`

---

## About Dream

This module was originally built for the [Dream](https://github.com/TrustBound/dream) web toolkit, but it's completely standalone and can be used in any Gleam project. It follows Dream's design principles and will be maintained as part of the Dream ecosystem.

---

## License

MIT — see [LICENSE.md](LICENSE.md)

---

<div align="center">
  <sub>Built in Gleam, on the BEAM, by the <a href="https://github.com/trustbound/dream">Dream Team</a> ❤️</sub>
</div>