lib/mongo/url_parser.ex

defmodule Mongo.UrlParser do
  @moduledoc """
    Mongo connection URL parsing util

    [See](https://docs.mongodb.com/manual/reference/connection-string/#connections-connection-options) for the complete list of options.

  """

  @mongo_url_regex ~r/^mongodb(?<srv>\+srv)?:\/\/((?<username>[^:]+):(?<password>[^@]+)@)?(?<seeds>[^\/]+)(\/(?<database>[^?]+))?(\?(?<options>.*))?$/

  # https://docs.mongodb.com/manual/reference/connection-string/#connections-connection-options
  @mongo_options %{
    # Path options
    "username" => :string,
    "password" => :string,
    "database" => :string,
    # Query options
    "replicaSet" => :string,
    "ssl" => ["true", "false"],
    "connectTimeoutMS" => :number,
    "socketTimeoutMS" => :number,
    "maxPoolSize" => :number,
    "minPoolSize" => :number,
    "maxIdleTimeMS" => :number,
    "waitQueueMultiple" => :number,
    "waitQueueTimeoutMS" => :number,
    "w" => :number_or_string,
    "wtimeoutMS" => :number,
    "journal" => ["true", "false"],
    "readConcernLevel" => ["local", "majority", "linearizable", "available"],
    "readPreference" => [
      "primary",
      "primaryPreferred",
      "secondary",
      "secondaryPreferred",
      "nearest"
    ],
    "maxStalenessSeconds" => :number,
    "readPreferenceTags" => :string,
    "authSource" => :string,
    "authMechanism" => ["SCRAM-SHA-1", "MONGODB-CR", "MONGODB-X509", "GSSAPI", "PLAIN"],
    "gssapiServiceName" => :string,
    "localThresholdMS" => :number,
    "serverSelectionTimeoutMS" => :number,
    "serverSelectionTryOnce" => ["true", "false"],
    "heartbeatFrequencyMS" => :number,
    "retryWrites" => ["true", "false"],
    "tls" => ["true", "false"],
    "uuidRepresentation" => ["standard", "csharpLegacy", "javaLegacy", "pythonLegacy"],
    # Elixir Driver options
    "type" => ["unknown", "single", "replicaSetNoPrimary", "sharded"]
  }

  @driver_option_map %{
    max_pool_size: :pool_size,
    replica_set: :set_name,
    w_timeout: :wtimeout
  }

  defp parse_option_value(_key, ""), do: nil

  defp parse_option_value(key, value) do
    case @mongo_options[key] do
      :number ->
        String.to_integer(value)

      :string ->
        value

      :number_or_string ->
        case Integer.parse(value) do
          {num, ""} ->
            num

          _string ->
            value
        end

      enum when is_list(enum) ->
        if Enum.member?(enum, value) do
          value
          |> Macro.underscore()
          |> String.to_atom()
        end

      _other ->
        nil
    end
  end

  defp add_option([key, value], opts), do: add_option({key, value}, opts)

  defp add_option({key, value}, opts) do
    case parse_option_value(key, value) do
      nil ->
        opts

      value ->
        key =
          key
          |> Macro.underscore()
          |> String.to_atom()

        value = decode_percent(key, value)

        Keyword.put(opts, @driver_option_map[key] || key, value)
    end
  end

  defp add_option(_other, acc), do: acc

  defp decode_percent(:username, value), do: URI.decode_www_form(value)
  defp decode_percent(:password, value), do: URI.decode_www_form(value)
  defp decode_percent(_other, value), do: value

  defp parse_query_options(opts, %{"options" => options}) when is_binary(options) do
    options
    |> String.split("&")
    |> Enum.map(fn option -> String.split(option, "=") end)
    |> Enum.reduce(opts, &add_option/2)
  end

  defp parse_query_options(opts, _frags), do: opts

  defp parse_seeds(opts, %{"seeds" => seeds}) do
    Keyword.put(opts, :seeds, String.split(seeds, ","))
  end

  defp parse_seeds(opts, _frags), do: opts

  defp resolve_srv_url(%{"seeds" => url, "srv" => srv, "options" => orig_options} = frags)
       when is_bitstring(url) and srv == "+srv" do
    # Fix for windows only
    with {:win32, _} <- :os.type() do
      :inet_db.add_ns({4, 2, 2, 1})
    end

    with url_char <- String.to_charlist(url),
         {:ok, {_, _, _, _, _, srv_record}} <-
           :inet_res.getbyname(~c"_mongodb._tcp." ++ url_char, :srv),
         {:ok, host} <- get_host_srv(srv_record),
         {:ok, {_, _, _, _, _, txt_record}} <- :inet_res.getbyname(url_char, :txt),
         txt <- "#{orig_options}&#{txt_record}&ssl=true" do
      frags
      |> Map.put("seeds", host)
      |> Map.put("options", txt)
    else
      err -> err
    end
  end

  defp resolve_srv_url(frags), do: frags

  @spec get_host_srv([{term, term, term, term}]) :: {:ok, String.t()}
  defp get_host_srv(srv) when is_list(srv) do
    hosts = Enum.map_join(srv, ",", fn {_, _, port, host} -> "#{host}:#{port}" end)

    {:ok, hosts}
  end

  defp hide_password(opts) do
    case Keyword.get(opts, :password) do
      nil ->
        opts

      value ->
        ## start GenServer and put id
        with {:ok, pid} <- Mongo.PasswordSafe.new(),
             :ok <- Mongo.PasswordSafe.set_password(pid, value) do
          opts
          |> Keyword.put(:password, "*****")
          |> Keyword.put(:pw_safe, pid)
        end
    end
  end

  @spec parse_url(Keyword.t()) :: Keyword.t()
  def parse_url(opts) when is_list(opts) do
    with {url, opts} when is_binary(url) <- Keyword.pop(opts, :url),
         frags when frags != nil <- Regex.named_captures(@mongo_url_regex, url),
         frags <- resolve_srv_url(frags),
         opts <- parse_seeds(opts, frags),
         opts <- parse_query_options(opts, frags),
         # Parse fixed parameters (database, username & password) & merge them with query options
         opts <- Enum.reduce(frags, opts, &add_option/2) do
      opts
    else
      _other -> opts
    end
    |> hide_password()
  end

  def parse_url(opts), do: opts
end