# `Bourse.WS.Subscription.Behaviour`
[🔗](https://github.com/ZenHive/bourse/blob/main/lib/bourse/ws/subscription/behaviour.ex#L1)

Behaviour for WebSocket subscription pattern implementations.

Each pattern module converts a list of pre-formatted channel strings (or
per-pattern objects) into the exchange-native subscribe frame. Frame
encoding (Jason) happens at the WS boundary in `Bourse.WS.subscribe/3`, not
inside pattern modules — modules return plain maps.

## Return shape

`subscribe/2` and `unsubscribe/2` return `map() | [map()] | {:error, term()}`.

- Most exchanges accept an array of channels in a single frame → return a
  `map()`.
- HTX (`:sub_subscribe`) requires one frame per channel → return a
  `[map()]` with one frame per channel.
- Upbit-style custom (`config[:custom_type] == "array_format"`) also
  returns a `[map()]`.
- Implementations that enforce an input-shape contract may return
  `{:error, term()}` (e.g. `:multiple_maps_not_supported` or
  `:mixed_channel_types` from `EventSubscribe` / `MethodParams`).
  `Bourse.WS.Subscription.build_subscribe/3` passes these through to the
  caller verbatim rather than wrapping them in `{:ok, _}`.

`Bourse.WS.subscribe/3` handles both frame shapes by iterating the list
when present and sending one `ZenWebsocket.Client.send_message/2` per
frame.

## Channel formatting is the caller's responsibility

Pattern modules do **not** format channel strings from unified symbols.
Callers pass pre-formatted channels like `"tickers.BTCUSDT"` or
`"market.btcusdt.ticker"`. A future task may introduce spec-driven
channel templates; this behaviour is deliberately narrow.

## Implementing a Pattern

    defmodule Bourse.WS.Subscription.OpSubscribe do
      @behaviour Bourse.WS.Subscription.Behaviour

      @impl true
      def subscribe(channels, config) do
        %{
          (config[:op_field] || "op") => "subscribe",
          (config[:args_field] || "args") => channels
        }
      end

      @impl true
      def unsubscribe(channels, config) do
        %{
          (config[:op_field] || "op") => "unsubscribe",
          (config[:args_field] || "args") => channels
        }
      end
    end

# `channel`

```elixir
@type channel() :: String.t() | map()
```

# `channel_shape_error`

```elixir
@type channel_shape_error() :: :multiple_maps_not_supported | :mixed_channel_types
```

# `config`

```elixir
@type config() :: map()
```

# `frame`

```elixir
@type frame() :: map()
```

# `subscribe`

```elixir
@callback subscribe(channels :: [channel()], config :: config()) ::
  frame() | [frame()] | {:error, term()}
```

# `unsubscribe`
*optional* 

```elixir
@callback unsubscribe(channels :: [channel()], config :: config()) ::
  frame() | [frame()] | {:error, term()}
```

# `build_single_envelope`

```elixir
@spec build_single_envelope([channel()], config(), String.t(), ([channel()],
                                                          config(),
                                                          String.t() -&gt;
                                                            frame())) ::
  frame() | {:error, channel_shape_error()}
```

Builds a single-envelope frame after validating channel-list shape.

Pattern modules pass their string-channel frame builder. Map-only and mixed
channel lists return the shared error atoms used by the dispatcher tests.

# `classify_channel_list`

```elixir
@spec classify_channel_list([channel()]) :: :strings | :all_maps | :mixed
```

Classifies a channel list by element shape.

Empty lists classify as `:strings`, preserving the default-frame behavior in
single-envelope pattern modules.

---

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