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 `:checks` 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. This will do a network call.
Defaults to disabled
- `:path`: Checks to see if the parsed `%URI{}` struct contains a `:path`. Defaults to disabled
- `:http_regexp`: Tries to match URL to a regexp known to catch many unwanted URLs (see code). It only accepts
HTTP(S) and FTP schemes though. 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
@http_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 URI.new(value) do
{:ok, _uri} -> :ok
{:error, _msg} -> :error
end
end
defp do_validate_url(value, _parsed, :http_regexp) do
case String.match?(value, @http_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