Skip to main content

lib/http/web_transport/options.ex

defmodule HTTP.WebTransport.Options do
  @moduledoc false

  @default_connect_timeout 30_000
  @default_max_datagram_size 64 * 1024
  @default_max_datagrams 1_024

  @string_keys %{
    "allowPooling" => :allow_pooling,
    "allow_pooling" => :allow_pooling,
    "anticipatedConcurrentIncomingBidirectionalStreams" =>
      :anticipated_concurrent_incoming_bidirectional_streams,
    "anticipatedConcurrentIncomingUnidirectionalStreams" =>
      :anticipated_concurrent_incoming_unidirectional_streams,
    "backend" => :backend,
    "congestionControl" => :congestion_control,
    "congestion_control" => :congestion_control,
    "connectTimeout" => :connect_timeout,
    "connect_timeout" => :connect_timeout,
    "datagramsReadableType" => :datagrams_readable_type,
    "datagrams_readable_type" => :datagrams_readable_type,
    "headers" => :headers,
    "idleTimeout" => :idle_timeout,
    "idle_timeout" => :idle_timeout,
    "maxDatagramSize" => :max_datagram_size,
    "maxIncomingDatagrams" => :max_incoming_datagrams,
    "maxOutgoingDatagrams" => :max_outgoing_datagrams,
    "max_datagram_size" => :max_datagram_size,
    "max_incoming_datagrams" => :max_incoming_datagrams,
    "max_outgoing_datagrams" => :max_outgoing_datagrams,
    "owner" => :owner,
    "protocols" => :protocols,
    "quic" => :quic,
    "requireUnreliable" => :require_unreliable,
    "require_unreliable" => :require_unreliable,
    "serverCertificateHashes" => :server_certificate_hashes,
    "server_certificate_hashes" => :server_certificate_hashes,
    "socketOpts" => :socket_opts,
    "socket_opts" => :socket_opts,
    "ssl" => :ssl
  }

  defstruct uri: nil,
            url: nil,
            owner: nil,
            allow_pooling: false,
            require_unreliable: false,
            headers: [],
            server_certificate_hashes: [],
            congestion_control: :default,
            anticipated_concurrent_incoming_unidirectional_streams: nil,
            anticipated_concurrent_incoming_bidirectional_streams: nil,
            protocols: [],
            datagrams_readable_type: :default,
            backend: HTTP.WebTransport.Transport.QUIC,
            connect_timeout: @default_connect_timeout,
            idle_timeout: :infinity,
            ssl: [],
            quic: [],
            socket_opts: [],
            max_incoming_datagrams: @default_max_datagrams,
            max_outgoing_datagrams: @default_max_datagrams,
            max_datagram_size: @default_max_datagram_size,
            ref: nil

  @type t :: %__MODULE__{
          uri: URI.t(),
          url: String.t(),
          owner: pid(),
          allow_pooling: boolean(),
          require_unreliable: boolean(),
          headers: [{String.t(), String.t()}],
          server_certificate_hashes: list(),
          congestion_control: :default | :throughput | :low_latency,
          anticipated_concurrent_incoming_unidirectional_streams: non_neg_integer() | nil,
          anticipated_concurrent_incoming_bidirectional_streams: non_neg_integer() | nil,
          protocols: [String.t()],
          datagrams_readable_type: :default | :bytes,
          backend: module(),
          connect_timeout: timeout(),
          idle_timeout: timeout(),
          ssl: keyword(),
          quic: keyword(),
          socket_opts: keyword(),
          max_incoming_datagrams: pos_integer(),
          max_outgoing_datagrams: pos_integer(),
          max_datagram_size: pos_integer(),
          ref: reference()
        }

  @spec new(String.t() | URI.t(), keyword() | map()) :: {:ok, t()} | {:error, term()}
  def new(url, init \\ []) do
    with {:ok, uri} <- normalize_url(url),
         {:ok, init} <- normalize_init(init) do
      {:ok,
       %__MODULE__{
         uri: uri,
         url: URI.to_string(uri),
         owner: Keyword.get(init, :owner, self()),
         allow_pooling: Keyword.get(init, :allow_pooling, false),
         require_unreliable: Keyword.get(init, :require_unreliable, false),
         headers: Keyword.get(init, :headers, []),
         server_certificate_hashes: Keyword.get(init, :server_certificate_hashes, []),
         congestion_control: Keyword.get(init, :congestion_control, :default),
         anticipated_concurrent_incoming_unidirectional_streams:
           Keyword.get(init, :anticipated_concurrent_incoming_unidirectional_streams),
         anticipated_concurrent_incoming_bidirectional_streams:
           Keyword.get(init, :anticipated_concurrent_incoming_bidirectional_streams),
         protocols: Keyword.get(init, :protocols, []),
         datagrams_readable_type: Keyword.get(init, :datagrams_readable_type, :default),
         backend: Keyword.get(init, :backend, HTTP.WebTransport.Transport.QUIC),
         connect_timeout: Keyword.get(init, :connect_timeout, @default_connect_timeout),
         idle_timeout: Keyword.get(init, :idle_timeout, :infinity),
         ssl: Keyword.get(init, :ssl, []),
         quic: Keyword.get(init, :quic, []),
         socket_opts: Keyword.get(init, :socket_opts, []),
         max_incoming_datagrams:
           Keyword.get(init, :max_incoming_datagrams, @default_max_datagrams),
         max_outgoing_datagrams:
           Keyword.get(init, :max_outgoing_datagrams, @default_max_datagrams),
         max_datagram_size: Keyword.get(init, :max_datagram_size, @default_max_datagram_size),
         ref: Keyword.get(init, :ref, make_ref())
       }}
    end
  end

  defp normalize_url(%URI{} = uri), do: normalize_uri(uri)

  defp normalize_url(url) when is_binary(url) do
    url
    |> URI.parse()
    |> normalize_uri()
  end

  defp normalize_url(_url), do: {:error, :invalid_url}

  defp normalize_uri(%URI{fragment: fragment}) when is_binary(fragment),
    do: {:error, :fragment_not_allowed}

  defp normalize_uri(%URI{scheme: "https", host: host} = uri) when is_binary(host),
    do: {:ok, uri}

  defp normalize_uri(%URI{scheme: scheme}), do: {:error, {:unsupported_scheme, scheme}}

  defp normalize_init(init) when is_map(init) do
    init
    |> Enum.map(fn {key, value} -> {normalize_key(key), value} end)
    |> normalize_init()
  end

  defp normalize_init(init) when is_list(init) do
    with {:ok, headers} <- normalize_headers(Keyword.get(init, :headers, [])),
         {:ok, owner} <- normalize_owner(Keyword.get(init, :owner, self())),
         {:ok, allow_pooling} <-
           normalize_boolean(Keyword.get(init, :allow_pooling, false), :invalid_allow_pooling),
         {:ok, require_unreliable} <-
           normalize_boolean(
             Keyword.get(init, :require_unreliable, false),
             :invalid_require_unreliable
           ),
         {:ok, server_certificate_hashes} <-
           normalize_server_certificate_hashes(Keyword.get(init, :server_certificate_hashes, [])),
         {:ok, congestion_control} <-
           normalize_congestion_control(Keyword.get(init, :congestion_control, :default)),
         {:ok, incoming_uni} <-
           normalize_optional_non_neg_integer(
             Keyword.get(init, :anticipated_concurrent_incoming_unidirectional_streams),
             :invalid_anticipated_concurrent_incoming_unidirectional_streams
           ),
         {:ok, incoming_bidi} <-
           normalize_optional_non_neg_integer(
             Keyword.get(init, :anticipated_concurrent_incoming_bidirectional_streams),
             :invalid_anticipated_concurrent_incoming_bidirectional_streams
           ),
         {:ok, protocols} <- normalize_protocols(Keyword.get(init, :protocols, [])),
         {:ok, datagrams_readable_type} <-
           normalize_datagrams_readable_type(
             Keyword.get(init, :datagrams_readable_type, :default)
           ),
         {:ok, backend} <- normalize_backend(Keyword.get(init, :backend, default_backend())),
         {:ok, connect_timeout} <-
           normalize_timeout(
             Keyword.get(init, :connect_timeout, @default_connect_timeout),
             :invalid_connect_timeout
           ),
         {:ok, idle_timeout} <-
           normalize_timeout(Keyword.get(init, :idle_timeout, :infinity), :invalid_idle_timeout),
         {:ok, ssl} <- normalize_keyword(Keyword.get(init, :ssl, []), :invalid_ssl_options),
         {:ok, quic} <- normalize_keyword(Keyword.get(init, :quic, []), :invalid_quic_options),
         {:ok, socket_opts} <-
           normalize_keyword(Keyword.get(init, :socket_opts, []), :invalid_socket_options),
         {:ok, max_incoming_datagrams} <-
           normalize_pos_integer(
             Keyword.get(init, :max_incoming_datagrams, @default_max_datagrams),
             :invalid_max_incoming_datagrams
           ),
         {:ok, max_outgoing_datagrams} <-
           normalize_pos_integer(
             Keyword.get(init, :max_outgoing_datagrams, @default_max_datagrams),
             :invalid_max_outgoing_datagrams
           ),
         {:ok, max_datagram_size} <-
           normalize_pos_integer(
             Keyword.get(init, :max_datagram_size, @default_max_datagram_size),
             :invalid_max_datagram_size
           ) do
      {:ok,
       init
       |> Keyword.put(:headers, headers)
       |> Keyword.put(:owner, owner)
       |> Keyword.put(:allow_pooling, allow_pooling)
       |> Keyword.put(:require_unreliable, require_unreliable)
       |> Keyword.put(:server_certificate_hashes, server_certificate_hashes)
       |> Keyword.put(:congestion_control, congestion_control)
       |> Keyword.put(:anticipated_concurrent_incoming_unidirectional_streams, incoming_uni)
       |> Keyword.put(:anticipated_concurrent_incoming_bidirectional_streams, incoming_bidi)
       |> Keyword.put(:protocols, protocols)
       |> Keyword.put(:datagrams_readable_type, datagrams_readable_type)
       |> Keyword.put(:backend, backend)
       |> Keyword.put(:connect_timeout, connect_timeout)
       |> Keyword.put(:idle_timeout, idle_timeout)
       |> Keyword.put(:ssl, ssl)
       |> Keyword.put(:quic, quic)
       |> Keyword.put(:socket_opts, socket_opts)
       |> Keyword.put(:max_incoming_datagrams, max_incoming_datagrams)
       |> Keyword.put(:max_outgoing_datagrams, max_outgoing_datagrams)
       |> Keyword.put(:max_datagram_size, max_datagram_size)}
    end
  end

  defp normalize_init(_init), do: {:error, :invalid_options}

  defp default_backend, do: HTTP.WebTransport.Transport.QUIC

  defp normalize_key(key) when is_binary(key), do: Map.get(@string_keys, key, key)
  defp normalize_key(key), do: key

  defp normalize_headers(%HTTP.Headers{headers: headers}), do: normalize_headers(headers)

  defp normalize_headers(headers) when is_map(headers) do
    headers
    |> Map.to_list()
    |> normalize_headers()
  end

  defp normalize_headers(headers) when is_list(headers) do
    headers =
      Enum.map(headers, fn {name, value} ->
        {HTTP.Headers.normalize_name(to_string(name)), to_string(value)}
      end)

    {:ok, headers}
  rescue
    _error -> {:error, :invalid_headers}
  end

  defp normalize_headers(_headers), do: {:error, :invalid_headers}

  defp normalize_owner(owner) when is_pid(owner), do: {:ok, owner}
  defp normalize_owner(_owner), do: {:error, :invalid_owner}

  defp normalize_boolean(value, _error) when is_boolean(value), do: {:ok, value}
  defp normalize_boolean(_value, error), do: {:error, error}

  defp normalize_server_certificate_hashes([]), do: {:ok, []}

  defp normalize_server_certificate_hashes(_hashes),
    do: {:error, :unsupported_server_certificate_hashes}

  defp normalize_congestion_control(value) when value in [:default, :throughput, :low_latency] do
    {:ok, value}
  end

  defp normalize_congestion_control("default"), do: {:ok, :default}
  defp normalize_congestion_control("throughput"), do: {:ok, :throughput}
  defp normalize_congestion_control("low-latency"), do: {:ok, :low_latency}
  defp normalize_congestion_control("low_latency"), do: {:ok, :low_latency}
  defp normalize_congestion_control(_value), do: {:error, :invalid_congestion_control}

  defp normalize_optional_non_neg_integer(nil, _error), do: {:ok, nil}

  defp normalize_optional_non_neg_integer(value, _error)
       when is_integer(value) and value >= 0 do
    {:ok, value}
  end

  defp normalize_optional_non_neg_integer(_value, error), do: {:error, error}

  defp normalize_protocols(nil), do: {:ok, []}
  defp normalize_protocols(protocol) when is_binary(protocol), do: normalize_protocols([protocol])

  defp normalize_protocols(protocols) when is_list(protocols) do
    with :ok <- validate_protocols(protocols),
         :ok <- reject_duplicate_protocols(protocols) do
      {:ok, protocols}
    end
  end

  defp normalize_protocols(_protocols), do: {:error, :invalid_protocols}

  defp validate_protocols(protocols) do
    if Enum.all?(protocols, &valid_protocol?/1) do
      :ok
    else
      {:error, :invalid_protocol}
    end
  end

  defp valid_protocol?(protocol) when is_binary(protocol) do
    byte_size(protocol) in 1..512
  end

  defp valid_protocol?(_protocol), do: false

  defp reject_duplicate_protocols(protocols) do
    if Enum.uniq(protocols) == protocols do
      :ok
    else
      {:error, :duplicate_protocol}
    end
  end

  defp normalize_datagrams_readable_type(value) when value in [:default, :bytes], do: {:ok, value}
  defp normalize_datagrams_readable_type("bytes"), do: {:ok, :bytes}
  defp normalize_datagrams_readable_type("default"), do: {:ok, :default}
  defp normalize_datagrams_readable_type(_value), do: {:error, :invalid_datagrams_readable_type}

  defp normalize_backend(backend) when is_atom(backend), do: {:ok, backend}
  defp normalize_backend(_backend), do: {:error, :invalid_backend}

  defp normalize_timeout(:infinity, _error), do: {:ok, :infinity}
  defp normalize_timeout(value, error), do: normalize_pos_integer(value, error)

  defp normalize_pos_integer(value, _error) when is_integer(value) and value > 0, do: {:ok, value}
  defp normalize_pos_integer(_value, error), do: {:error, error}

  defp normalize_keyword(value, _error) when is_list(value), do: {:ok, value}
  defp normalize_keyword(_value, error), do: {:error, error}
end