lib/ankh/http2/frame.ex

defmodule Ankh.HTTP2.Frame do
  @moduledoc """
  HTTP/2 frame struct

  The __using__ macro injects the frame struct needed by `Ankh.HTTP2.Frame`.
  """

  require Logger

  alias Ankh.HTTP2.Frame.Encodable

  @typedoc "Struct injected by the `Ankh.HTTP2.Frame` __using__ macro."
  @type t :: struct()

  @typedoc "Frame length"
  @type length :: non_neg_integer()

  @typedoc "Frame type code"
  @type type :: non_neg_integer()

  @typedoc "Frame data"
  @type data :: iodata()

  @typedoc "Encode/Decode options"
  @type options :: keyword()

  @header_size 9

  @doc """
  Injects the frame struct in a module.

  - type: HTTP/2 frame type code
  - flags: data type implementing `Ankh.HTTP2.Frame.Encodable`
  - payload: data type implementing `Ankh.HTTP2.Frame.Encodable`
  """
  @spec __using__(type: type(), flags: atom() | nil, payload: atom() | nil) :: Macro.t()
  defmacro __using__(args) do
    with {:ok, type} <- Keyword.fetch(args, :type),
         flags <- Keyword.get(args, :flags),
         payload <- Keyword.get(args, :payload) do
      quote bind_quoted: [type: type, flags: flags, payload: payload] do
        alias Ankh.HTTP2.Frame
        alias Ankh.HTTP2.Stream, as: HTTP2Stream

        @typedoc """
        - length: payload length in bytes
        - flags: data type implementing `Ankh.HTTP2.Frame.Encodable`
        - stream_id: Stream ID of the frame
        - payload: data type implementing `Ankh.HTTP2.Frame.Encodable`
        """
        @type t :: %__MODULE__{
                length: Frame.length(),
                type: Frame.type(),
                stream_id: HTTP2Stream.id(),
                flags: Encodable.t() | nil,
                payload: Encodable.t() | nil
              }

        flags = if flags, do: struct(flags), else: nil
        payload = if payload, do: struct(payload), else: nil

        defstruct length: 0,
                  type: type,
                  stream_id: 0,
                  flags: flags,
                  payload: payload
      end
    else
      :error ->
        raise "Missing type code: You must provide a type code for the frame"
    end
  end

  @doc """
  Decodes a binary into a frame struct

  Parameters:
    - struct: struct using `Ankh.HTTP2.Frame`
    - binary: data to decode into the struct
    - options: options to pass as context to the decoding function
  """
  @spec decode(t(), binary(), options()) :: {:ok, t()} | {:error, any()}
  def decode(frame, data, options \\ [])

  def decode(frame, <<0::24, _type::8, flags::binary-size(1), _reserved::1, id::31>>, options) do
    with {:ok, flags} <- Encodable.decode(frame.flags, flags, options),
         do: {:ok, %{frame | stream_id: id, flags: flags, payload: nil}}
  end

  def decode(
        frame,
        <<length::24, _type::8, flags::binary-size(1), _reserved::1, id::31, payload::binary>>,
        options
      ) do
    with {:ok, flags} <- Encodable.decode(frame.flags, flags, options),
         payload_options <- Keyword.put(options, :flags, flags),
         {:ok, payload} <- Encodable.decode(frame.payload, payload, payload_options),
         do: {:ok, %{frame | length: length, stream_id: id, flags: flags, payload: payload}}
  end

  @doc """
  Encodes a frame struct into binary

  Parameters:
    - struct: struct using `Ankh.HTTP2.Frame`
    - options: options to pass as context to the encoding function
  """
  @spec encode(t(), options()) :: {:ok, t(), data} | {:error, any()}
  def encode(frame, options \\ [])

  def encode(%{type: type, flags: flags, stream_id: id, payload: nil} = frame, options) do
    with {:ok, flags} <- Encodable.encode(flags, options) do
      {
        :ok,
        frame,
        [<<0::24, type::8, flags::binary-size(1), 0::1, id::31>>]
      }
    end
  end

  def encode(%{type: type, flags: nil, stream_id: id, payload: payload} = frame, options) do
    with {:ok, payload} <- Encodable.encode(payload, options) do
      length = IO.iodata_length(payload)

      {
        :ok,
        %{frame | length: length},
        [<<length::24, type::8, 0::8, 0::1, id::31>> | payload]
      }
    end
  end

  def encode(%{type: type, stream_id: id, flags: flags, payload: payload} = frame, options) do
    payload_options = Keyword.put(options, :flags, flags)

    with {:ok, payload} <- Encodable.encode(payload, payload_options),
         {:ok, flags} <- Encodable.encode(flags, options) do
      length = IO.iodata_length(payload)

      {
        :ok,
        %{frame | length: length},
        [<<length::24, type::8, flags::binary-size(1), 0::1, id::31>> | payload]
      }
    end
  end

  @doc """
  Returns s tream of frames from a buffer, returning the leftover buffer data
  and the frame header information and data (without decoding it) in a tuple:

  `{remaining_buffer, {length, type, id, frame_data}}`

  or nil to signal partial leftover data:

  `{remaining_buffer, nil}`
  """
  @spec stream(iodata()) :: Enumerable.t()
  def stream(data) do
    Stream.unfold(data, fn
      <<length::24, type::8, _flags::binary-size(1), _reserved::1, id::31, rest::binary>> =
          data
      when byte_size(rest) >= length ->
        frame_size = @header_size + length
        frame_data = binary_part(data, 0, frame_size)
        rest_size = byte_size(data) - frame_size
        rest_data = binary_part(data, frame_size, rest_size)
        {{rest_data, {length, type, id, frame_data}}, rest_data}

      data when byte_size(data) == 0 ->
        nil

      data ->
        {{data, nil}, data}
    end)
  end
end