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.
"""
require Logger
@mongo_url_regex ~r/^mongodb(?<srv>\+srv)?:\/\/(?:(?<username>[^:]+):(?<password>[^@]+)@)?(?<seeds>[^\/\?]+)(?:\/(?<database>[^?]*)?(?:\?(?<options>(?:[^\s=]+=[^\s&]*)+))?)?$/
if Code.ensure_loaded?(:ezstd) do
@compressors ["zstd", "zlib"]
else
@compressors ["zlib"]
end
# 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,
"directConnection" => ["true", "false"],
"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"],
"compressors" => @compressors,
"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("compressors", values) do
values
|> String.split(",")
|> Enum.filter(fn compressor -> compressor in @compressors end)
end
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_new(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(:auth_source, 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} <- resolve_txt_record(url_char),
txt <- build_params(orig_options, txt_record) do
frags
|> Map.put("seeds", host)
|> Map.put("options", txt)
else
err -> err
end
end
defp resolve_srv_url(frags), do: frags
defp build_params(orig_options, nil) do
"#{orig_options}&ssl=true"
end
defp build_params(orig_options, txt_record) do
"#{orig_options}&#{txt_record}&ssl=true"
end
defp resolve_txt_record(url_char) do
case :inet_res.lookup(url_char, :in, :txt) do
[[txt_record] | _] ->
{:ok, txt_record}
_other ->
{:ok, nil}
end
end
@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.start_link(),
: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),
opts <- process_read_preferences(opts),
# 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
defp process_read_preferences(opts) do
opts =
case Keyword.get(opts, :read_preference) do
nil ->
opts
mode when is_atom(mode) ->
read_preference =
%{mode: mode}
|> extend_read_preference_tags(opts)
|> extend_max_staleness_ms(opts)
Keyword.put(opts, :read_preference, read_preference)
_other ->
opts
end
Keyword.drop(opts, [:read_preference_tags, :max_staleness_seconds])
end
defp extend_read_preference_tags(read_preference, opts) do
case Keyword.get(opts, :read_preference_tags, []) |> parse_tags() do
[] ->
read_preference
tags ->
Map.put(read_preference, :tags, Keyword.new(tags))
end
end
defp extend_max_staleness_ms(read_preference, opts) do
case Keyword.get(opts, :max_staleness_seconds) do
nil ->
read_preference
max_staleness_seconds ->
Map.put(read_preference, :max_staleness_ms, max_staleness_seconds * 1_000)
end
end
defp parse_tags([]) do
[]
end
defp parse_tags(tags) do
tags
|> String.split(",")
|> Enum.map(fn key_value -> to_tuple(key_value) end)
|> Enum.reject(fn key_value -> key_value == nil end)
end
defp to_tuple(key_value) do
case String.split(key_value, ":") do
[key, value] ->
{String.to_atom(key), value}
_other ->
Logger.warning("Unable to parse the read preference tags #{inspect(key_value)}")
nil
end
end
end