lib/membrane_rtsp/response.ex

defmodule Membrane.RTSP.Response do
  @moduledoc """
  This module represents a RTSP response.
  """

  @start_line_regex ~r/^RTSP\/(\d\.\d) (\d\d\d) [A-Z a-z]+$/
  @line_ending ["\r\n", "\r", "\n"]

  @enforce_keys [:status, :version]
  defstruct @enforce_keys ++ [headers: [], body: ""]

  @type t :: %__MODULE__{
          status: non_neg_integer(),
          headers: Membrane.RTSP.headers(),
          body: ExSDP.t() | binary()
        }

  @type result :: {:ok, t()} | {:error, atom()}

  @doc """
  Parses RTSP response.

  If the body is present it will be parsed according to `Content-Type` header.
  Currently only the `application/sdp` is supported.
  """
  @spec parse(binary()) :: {:ok, t()} | {:error, :invalid_start_line | :malformed_header}
  def parse(response) do
    [headers, body] = String.split(response, ["\r\n\r\n", "\n\n", "\r\r"], parts: 2)

    with {:ok, {response, headers}} <- parse_start_line(headers),
         {:ok, headers} <- parse_headers(headers),
         {:ok, body} <- parse_body(body, headers) do
      {:ok, %__MODULE__{response | headers: headers, body: body}}
    end
  end

  @doc """
  Retrieves the first header matching given name from a response.

  ```
    iex> response = %Response{
    ...>   status: 200,
    ...>   version: "1.0",
    ...>   headers: [{"header_name", "header_value"}]
    ...> }
    iex> Response.get_header(response, "header_name")
    {:ok, "header_value"}
    iex> Response.get_header(response, "non_existent_header")
    {:error, :no_such_header}

  ```

  """

  @spec get_header(__MODULE__.t(), binary()) :: {:error, :no_such_header} | {:ok, binary()}
  def get_header(%__MODULE__{headers: headers}, name) do
    case List.keyfind(headers, name, 0) do
      {_name, value} -> {:ok, value}
      nil -> {:error, :no_such_header}
    end
  end

  @spec parse_start_line(raw_response :: binary()) ::
          {:ok, {response :: t(), remainder :: binary}} | {:error, :invalid_start_line}
  defp parse_start_line(binary) do
    [line, rest] = String.split(binary, @line_ending, parts: 2)

    case Regex.run(@start_line_regex, line) do
      [_match, version, code] ->
        case Integer.parse(code) do
          :error ->
            {:error, :invalid_status_code}

          {code, _rest} when is_number(code) ->
            response = %__MODULE__{version: version, status: code}
            {:ok, {response, rest}}
        end

      _other ->
        {:error, :invalid_start_line}
    end
  end

  defp parse_headers(headers) do
    headers
    |> String.split(@line_ending)
    |> Bunch.Enum.try_map(fn header ->
      case String.split(header, ":", parts: 2) do
        [name, " " <> value] -> {:ok, {name, value}}
        _else -> {:error, {:malformed_header, header}}
      end
    end)
  end

  defp parse_body(data, headers) do
    case List.keyfind(headers, "Content-Type", 0) do
      {"Content-Type", "application/sdp"} ->
        ExSDP.parse(data)

      _other ->
        {:ok, data}
    end
  end
end