# `Bourse.ResponseTransformer`
[🔗](https://github.com/ZenHive/bourse/blob/main/lib/bourse/response_transformer.ex#L1)

Response shape normalization applied *before* field-mapping extraction.

Some exchanges return responses in wire shapes the unified parsers don't expect.
For example:

- BitMEX returns `[%{ticker}]` instead of `%{ticker}` for `fetch_ticker`
- BitMEX returns a flat order list instead of `%{bids: [], asks: []}` for `fetch_order_book`
- Deribit OHLCV is column-oriented (`%{"open" => [...], "close" => [...]}`) rather than rows

## Scope relative to authored `normalization.response_envelopes`

Authored specs carry per-method **envelope key paths** (e.g. `key: "result"`
with `fallback_keys`/`default`). That covers the simple "reach into the response
envelope" cases the `:extract_path*` family handled manually in the reference
implementation — prefer the authored envelopes for those.

This module retains the residual transforms that are **not** expressible as a
static envelope key path because they reshape the wire data itself:
list/map unwrapping, flat-list → order-book, positional-array → maps,
column-oriented → rows, and composition. The `:extract_path*` variants are
kept too, for per-exchange overrides and `:compose` chains where an envelope
hop must precede a reshape.

## Usage

Configure a `:response_transformer` on an endpoint config; `Bourse.Dispatch`
applies it to the response body before the body reaches the parser:

    %{
      name: :fetch_order_book,
      path: "/orderBook/L2",
      response_transformer: :order_book_from_flat_list
    }

Available transformers:

- `{:extract_path, path}` — extracts nested data via key path
- `{:extract_path_unwrap, path}` — extracts via path, then unwraps single-element list (`nil` for empty list)
- `{:extract_path_unwrap_map, path}` — extracts via path, then unwraps single-key map to its value
- `{:extract_path_unwrap_merge, path, merge_keys}` — like `:extract_path_unwrap`, merging envelope keys into the result (inner fields win via `put_new`)
- `:unwrap_single_element_list` — unwraps `[item]` to `item` (returns `[]` for empty list)
- `:unwrap_single_element_map` — unwraps `%{key => value}` to `value` (0 or 2+ key maps unchanged)
- `:extract_first_list_value` — first value that is a list in a map (ignores metadata keys like `"last"`)
- `:order_book_from_flat_list` — converts a flat order list to `%{bids: [], asks: []}`
- `{:positional_to_maps, field_names}` — converts inner positional arrays to maps by zipping with field names
- `{:transpose_columns_to_rows, column_keys}` — transposes column-oriented map to a list of rows
- `{:compose, [transformer]}` — chains transformers left-to-right

# `transformer`

```elixir
@type transformer() ::
  :unwrap_single_element_list
  | :unwrap_single_element_map
  | :extract_first_list_value
  | :order_book_from_flat_list
  | {:extract_path, [String.t()]}
  | {:extract_path_unwrap, [String.t()]}
  | {:extract_path_unwrap_map, [String.t()]}
  | {:extract_path_unwrap_merge, [String.t()], [String.t()]}
  | {:positional_to_maps, [String.t()]}
  | {:transpose_columns_to_rows, [String.t()]}
  | {:compose, [transformer()]}
  | nil
```

# `extract_first_list_value`

```elixir
@spec extract_first_list_value(term()) :: term()
```

Extracts the first list value from a map, ignoring non-list metadata keys.

Used when an API returns a map with a dynamic data key alongside metadata keys.
E.g., Kraken's fetch_trades returns `%{"XXBTZUSD" => [[...trades...]], "last" => "1541439421"}`
where `"last"` is metadata and the pair key holds the actual data.

Returns the map unchanged when no list values exist.

## Examples

    iex> Bourse.ResponseTransformer.extract_first_list_value(%{"XXBTZUSD" => [[1], [2]], "last" => "123"})
    [[1], [2]]

    iex> Bourse.ResponseTransformer.extract_first_list_value(%{"XXBTZUSD" => [[1]]})
    [[1]]

    iex> Bourse.ResponseTransformer.extract_first_list_value(%{"a" => 1, "b" => 2})
    %{"a" => 1, "b" => 2}

    iex> Bourse.ResponseTransformer.extract_first_list_value("not a map")
    "not a map"

# `extract_path`

```elixir
@spec extract_path(term(), [String.t()]) :: term()
```

Extracts nested data from a response envelope using a key path.

Walks into nested maps following the given keys. Returns the data at the
final key, or the current level's data if a key is not found (i.e., stops
walking and returns whatever map it reached).

## Examples

    iex> body = %{"retCode" => 0, "result" => %{"list" => [[1, 2, 3]]}}
    iex> Bourse.ResponseTransformer.extract_path(body, ["result", "list"])
    [[1, 2, 3]]

    iex> Bourse.ResponseTransformer.extract_path(%{"data" => "test"}, [])
    %{"data" => "test"}

    iex> Bourse.ResponseTransformer.extract_path(%{"a" => 1}, ["missing"])
    %{"a" => 1}

    iex> Bourse.ResponseTransformer.extract_path(%{"result" => %{"data" => "test"}}, ["result", "missing"])
    %{"data" => "test"}

# `order_book_from_flat_list`

```elixir
@spec order_book_from_flat_list(term()) :: term()
```

Converts a flat order list to structured order book format.

BitMEX returns orders as a flat list with a "side" field:
`[%{"side" => "Sell", "price" => 100, "size" => 10}, %{"side" => "Buy", ...}]`

This transforms it to unified format:
`%{"bids" => [[price, size], ...], "asks" => [[price, size], ...]}`

## Examples

    iex> orders = [
    ...>   %{"side" => "Sell", "price" => 100.5, "size" => 10},
    ...>   %{"side" => "Buy", "price" => 99.5, "size" => 20}
    ...> ]
    iex> Bourse.ResponseTransformer.order_book_from_flat_list(orders)
    %{"bids" => [[99.5, 20]], "asks" => [[100.5, 10]]}

# `positional_to_maps`

```elixir
@spec positional_to_maps(term(), [String.t()]) :: term()
```

Converts positional arrays in a list to maps by zipping with field names.

Used when an API returns trades (or similar) as positional arrays instead of maps.
E.g., Kraken's fetchTrades returns `[["50000", "0.01", 1710327959, "b", "m", "", 123], ...]`

## Examples

    iex> Bourse.ResponseTransformer.positional_to_maps(
    ...>   [["50000", "0.01", "b"], ["51000", "0.02", "s"]],
    ...>   ["price", "amount", "side"]
    ...> )
    [%{"price" => "50000", "amount" => "0.01", "side" => "b"}, %{"price" => "51000", "amount" => "0.02", "side" => "s"}]

    iex> Bourse.ResponseTransformer.positional_to_maps("not a list", ["a"])
    "not a list"

# `transform`

```elixir
@spec transform(term(), transformer()) :: term()
```

Applies a transformer to the response body.

Returns the transformed body, or the original body if no transformer is specified.

# `transpose_columns_to_rows`

```elixir
@spec transpose_columns_to_rows(term(), [String.t()]) :: term()
```

Transposes column-oriented data into a list of rows.

Some exchanges (e.g., Deribit OHLCV) return data as a map of columns:
`%{"ticks" => [1, 2], "open" => [10, 20]}` instead of rows `[[1, 10], [2, 20]]`.

Column keys determine the order of elements in each row. Missing keys or
non-list column values cause the data to be returned unchanged (defensive).

## Examples

    iex> Bourse.ResponseTransformer.transpose_columns_to_rows(
    ...>   %{"a" => [1, 2], "b" => [3, 4]},
    ...>   ["a", "b"]
    ...> )
    [[1, 3], [2, 4]]

    iex> Bourse.ResponseTransformer.transpose_columns_to_rows("not a map", ["a"])
    "not a map"

# `unwrap_single_element_list`

```elixir
@spec unwrap_single_element_list(term()) :: term()
```

Unwraps a single-element list to its element.

Used when an API returns `[item]` but the unified API expects `item`.

## Examples

    iex> Bourse.ResponseTransformer.unwrap_single_element_list([%{"symbol" => "BTC"}])
    %{"symbol" => "BTC"}

    iex> Bourse.ResponseTransformer.unwrap_single_element_list([])
    []

    iex> Bourse.ResponseTransformer.unwrap_single_element_list([%{a: 1}, %{b: 2}])
    [%{a: 1}, %{b: 2}]

# `unwrap_single_element_map`

```elixir
@spec unwrap_single_element_map(term()) :: term()
```

Unwraps a single-key map to its value.

Used when an API returns `%{"KEY" => value}` but the unified API expects `value`
directly. E.g., Kraken's /public/Ticker returns `{"result": {"XXBTUSD": {...}}}`
where the key is the symbol name (not a meaningful container).

Returns maps with 0 or 2+ keys unchanged.

## Examples

    iex> Bourse.ResponseTransformer.unwrap_single_element_map(%{"XXBTUSD" => %{"a" => [1]}})
    %{"a" => [1]}

    iex> Bourse.ResponseTransformer.unwrap_single_element_map(%{})
    %{}

    iex> Bourse.ResponseTransformer.unwrap_single_element_map(%{"a" => 1, "b" => 2})
    %{"a" => 1, "b" => 2}

    iex> Bourse.ResponseTransformer.unwrap_single_element_map("not a map")
    "not a map"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
