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://github.com/TrustBound/dream/releases">
    <img src="https://img.shields.io/github/v/release/TrustBound/dream?label=Release" alt="Latest Release">
  </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 streaming support.**

A standalone HTTP/HTTPS client built on Erlang's battle-tested `httpc`. Supports blocking requests, yielder streaming, and message-based streaming. 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, message-based—choose what fits |
| **OTP-first design**      | Message-based streams integrate with actors and selectors   |
| **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
import gleam/http

pub fn fetch_data() {
  client.new
  |> client.method(http.Get)
  |> client.scheme(http.Https)
  |> client.host("api.example.com")
  |> client.path("/users/123")
  |> client.add_header("Authorization", "Bearer " <> token)
  |> client.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
let result = client.new
  |> client.host("api.example.com")
  |> client.path("/users")
  |> client.send()

case result {
  Ok(body) -> decode_json(body)
  Error(msg) -> handle_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 gleam/yielder

client.new
  |> client.host("api.openai.com")
  |> client.path("/v1/chat/completions")
  |> client.stream_yielder()
  |> yielder.each(fn(chunk_result) {
    case chunk_result {
      Ok(chunk) -> process_chunk(chunk)
      Error(reason) -> log_error(reason)
    }
  })
```

<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
// Start stream with callbacks - returns immediately
let assert Ok(stream) = client.new
  |> client.host("api.openai.com")
  |> client.path("/v1/chat/completions")
  |> client.on_stream_chunk(fn(data) {
    case bit_array.to_string(data) {
      Ok(text) -> io.print(text)
      Error(_) -> Nil
    }
  })
  |> client.start_stream()

// Wait for completion if needed
client.await_stream(stream)

// Or cancel early
client.cancel_stream_handle(stream)
```

<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
import dream_http_client/matching

// Record real requests
let assert Ok(rec) = recorder.start(
  recorder.Record(directory: "mocks/api"),
  matching.match_url_only(),
)

client.new
  |> client.host("api.example.com")
  |> client.recorder(rec)
  |> client.send()  // Saved immediately to disk

// Playback later (no network)
let assert Ok(playback) = recorder.start(
  recorder.Playback(directory: "mocks/api"),
  matching.match_url_only(),
)

client.new
  |> client.host("api.example.com")
  |> client.recorder(playback)
  |> client.send()  // Returns recorded response
```

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

### Recording Modes

- **`Record(directory)`** - Records real requests to disk immediately
- **`Playback(directory)`** - 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
let assert Ok(rec) = recorder.start(
  recorder.Playback(directory: "test/fixtures/api"),
  matching.match_url_only(),
)
// 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

// Default: Match on method + URL only
let config = matching.match_url_only()

// Custom matching
let config = matching.MatchingConfig(
  match_method: True,
  match_url: True,
  match_headers: False,  // Ignore auth tokens, timestamps
  match_body: False,     // Ignore request IDs in body
)
```

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

### Performance Note

Recording uses a read-modify-write approach (O(n) where n is existing recordings), prioritizing reliability over performance. This is suitable for typical use cases (record once, playback often). If you need high-performance recording, please create an issue at https://github.com/TrustBound/dream/issues.

---

## API Reference

### Builder Pattern

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

<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

**Message-Based Streaming:**

- `stream_messages(req) -> Result(RequestId, String)` - Starts stream, returns ID
- `select_stream_messages(selector, mapper) -> Selector(msg)` - Integrates with OTP selectors
- `cancel_stream(request_id)` - Cancels active stream

### Types

**`RequestId`** - Opaque identifier for message-based streams

**`StreamMessage`**:

- `StreamStart(request_id, headers)` - Stream started
- `Chunk(request_id, data)` - Data chunk received
- `StreamEnd(request_id, headers)` - Stream completed
- `StreamError(request_id, reason)` - Stream failed
- `DecodeError(reason)` - FFI corruption (report as bug)

### Error Handling

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

```gleam
case client.send(request) {
  Ok(body) -> process_response(body)
  Error(msg) -> {
    // Common errors:
    // - Connection refused
    // - DNS resolution failed
    // - Timeout
    log_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

**Recording:**

- [Record and playback](test/snippets/recording_basic.gleam) - Testing without network

---

## Design Principles

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

- **No nested cases** - Clear, flat control flow throughout
- **No anonymous functions** - All functions are named for clarity
- **Builder pattern** - Consistent, composable request configuration
- **Type safety** - `Result` types force error handling at compile time
- **OTP-first design** - Message-based API 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>