Current section
Files
Jump to
Current section
Files
lib/validators/url.ex
defmodule EctoCommons.URLValidator do
@moduledoc ~S"""
This validator is used to validate URLs.
## Options
There are some available options depending on the strictness of what you want to validate:
- `:parsable`: Checks to see if the URL is parsable by `:http_uri.parse/1` Erlang function.
This can have issues with international URLs where it should be disabled (see tests). Defaults to enabled
- `:empty`: Checks to see if the parsed `%URI{}` struct is not empty (all fields set to nil). Defaults to enabled
- `:scheme`: Checks to see if the parsed `%URI{}` struct contains a `:scheme`. Defaults to enabled
- `:host`: Checks to see if the parsed `%URI{}` struct contains a `:host`. Defaults to enabled
- `:valid_host`: Does a `:inet.getbyhostname/1` call to check if the host exists. Defaults to disabled
- `:path`: Checks to see if the parsed `%URI{}` struct contains a `:path`. Defaults to disabled
- `:regexp`: Tries to match URL to a regexp known to catch many unwanted URLs (see below). Defaults to disabled
The approach is not yet very satisfactory IMHO, if you have suggestions, Pull Requests are welcome :)
## Example:
iex> types = %{url: :string}
iex> params = %{url: "https://www.example.com/"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> validate_url(:url)
#Ecto.Changeset<action: nil, changes: %{url: "https://www.example.com/"}, errors: [], data: %{}, valid?: true>
iex> types = %{url: :string}
iex> params = %{url: "https://www.example.com/"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> validate_url(:url, checks: [:empty, :path, :scheme, :host])
#Ecto.Changeset<action: nil, changes: %{url: "https://www.example.com/"}, errors: [], data: %{}, valid?: true>
iex> types = %{url: :string}
iex> params = %{url: "some@invalid_url"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> validate_url(:url)
#Ecto.Changeset<action: nil, changes: %{url: "some@invalid_url"}, errors: [url: {"is not a valid url", [validation: :url]}], data: %{}, valid?: false>
iex> types = %{url: :string}
iex> params = %{url: "Just some random text"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> validate_url(:url)
#Ecto.Changeset<action: nil, changes: %{url: "Just some random text"}, errors: [url: {"is not a valid url", [validation: :url]}], data: %{}, valid?: false>
"""
import Ecto.Changeset
# Taken from here https://mathiasbynens.be/demo/url-regex
# credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
@url_regex ~r/^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/ius
def validate_url(changeset, field, opts \\ []) do
validate_change(changeset, field, {:url, opts}, fn _, value ->
checks = Keyword.get(opts, :checks, [:parsable, :empty, :scheme, :host])
parsed = URI.parse(value)
case do_validate_url(value, parsed, checks) do
:ok -> []
:error -> [{field, {message(opts, "is not a valid url"), [validation: :url]}}]
end
end)
end
defp do_validate_url(value, parsed, checks) when is_list(checks) do
check_all = Enum.map(checks, fn check -> do_validate_url(value, parsed, check) end)
if Enum.member?(check_all, :error), do: :error, else: :ok
end
defp do_validate_url(value, _parsed, :parsable) do
case :http_uri.parse(String.to_charlist(value)) do
{:ok, _uri} -> :ok
{:error, _msg} -> :error
end
end
defp do_validate_url(value, _parsed, :regexp) do
case String.match?(value, @url_regex) do
true -> :ok
false -> :error
end
end
# Caution: this check does a network call and can be slow.
defp do_validate_url(_value, %URI{host: host}, :valid_host) do
case :inet.gethostbyname(String.to_charlist(host)) do
{:ok, _value} -> :ok
{:error, :nxdomain} -> :error
end
end
defp do_validate_url(_value, parsed, :empty) do
values = parsed |> Map.from_struct() |> Enum.map(fn {_key, val} -> blank?(val) end)
if Enum.member?(values, false), do: :ok, else: :error
end
defp do_validate_url(_value, %URI{path: path} = _parsed, :path) do
if blank?(path), do: :error, else: :ok
end
defp do_validate_url(_value, %URI{scheme: scheme} = _parsed, :scheme) do
if blank?(scheme), do: :error, else: :ok
end
defp do_validate_url(_value, %URI{host: host} = _parsed, :host) do
if blank?(host), do: :error, else: :ok
end
defp message(opts, key \\ :message, default) do
Keyword.get(opts, key, default)
end
@compile {:inline, blank?: 1}
def blank?(""), do: true
def blank?([]), do: true
def blank?(nil), do: true
def blank?({}), do: true
def blank?(%{} = map) when map_size(map) == 0, do: true
def blank?(_), do: false
end