defmodule Xema.Format do @moduledoc """ This module contains semantic validators for strings. """ @formats [ :date, :date_time, :email, :hostname, :ipv4, :ipv6, :json_pointer, :regex, :relative_json_pointer, :time, :uri, :uri_fragment, :uri_path, :uri_query, :uri_reference, :uri_template, :uri_userinfo ] @typedoc "The list of supported validators." @type format :: :date | :date_time | :email | :hostname | :ipv4 | :ipv6 | :json_pointer | :regex | :relative_json_pointer | :time | :uri | :uri_fragment | :uri_path | :uri_query | :uri_reference | :uri_template | :uri_userinfo defmacro __using__(_opts) do quote do alias Xema.Format require Format end end @doc """ Returns `true` for a valid format type, `false` otherwise. This macro can be used in guards. """ defguard supports(format) when format in @formats @doc """ Checks if the value matches the given type. The function expected a available `format` and a `string` to check. Returns `true` for a valid `string`, `false` otherwise. ## Examples iex> Xema.Format.is?(:email, "foo@bar.net") true iex> Xema.Format.is?(:email, "foo.bar.net") false """ @spec is?(format, String.t()) :: boolean for fmt <- @formats do def is?(unquote(fmt), string) when is_binary(string) do unquote(:"#{Atom.to_string(fmt)}?")(string) end end # # Date-Time # @doc """ Checks if the `string` is a valid date time representation. This function returns `true` if the value is a `string` and is formatted as defined by [RFC 3339](https://tools.ietf.org/html/rfc3339), `false` otherwise. """ @date_time ~r/^ (\d{4})-([01]\d)-([0-3]\d)T ([0-2]\d):([0-5]\d):([0-6]\d)(?:\.(\d+))? (?:Z|[-+](?:[01]\d|2[0-3]):(?:[0-5]\d|60)) $/xi @spec date_time?(String.t()) :: boolean def date_time?(string) when is_binary(string) do case Regex.run(@date_time, string) do nil -> false [_ | date] -> date |> Enum.map(&String.to_integer/1) |> date_time_valid?() end end @spec date_time_valid?([integer]) :: boolean defp date_time_valid?([year, month, day, hour, min, sec]), do: date_time_valid?([year, month, day, hour, min, sec, 0]) defp date_time_valid?([year, month, day, hour, min, sec, frac]) do case NaiveDateTime.new(year, month, day, hour, min, sec, frac) do {:ok, _} -> true _ -> false end end # # Time # @doc """ Checks if the `string` is a valid time representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 3339](https://tools.ietf.org/html/rfc3339), `false` otherwise. """ @spec time?(String.t()) :: boolean def time?(string) when is_binary(string), do: date_time?("2000-01-01T#{string}") # # Date # @doc """ Checks if the `string` is a valid date representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 3339](https://tools.ietf.org/html/rfc3339), `false` otherwise. """ @spec date?(String.t()) :: boolean def date?(string) when is_binary(string), do: date_time?("#{string}T00:00:00.0Z") # # Email # @doc """ Checks if the `string` is a valid email representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 5322](https://tools.ietf.org/html/rfc5322), `false` otherwise. The regular expression was taken from [https://emailregex.com/](https://emailregex.com/). ## Examples iex> import Xema.Format iex> iex> email?("marin.musterman@germany.net") true iex> email?("Otto.Normalverbraucher") false iex> email?("Otto.Normal@Verbraucher.NET") true """ # credo:disable-for-previous-line @email ~r< (?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)* |"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\ [\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+ [a-z0-9](?:[a-z0-9-]*[a-z0-9])?| \[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?) \.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]: (?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\ [\x01-\x09\x0b\x0c\x0e-\x7f])+)\]) >ix @spec email?(String.t()) :: boolean def email?(string) when is_binary(string) do !Regex.match?(~r/^\./, string) && !Regex.match?(~r/\.\./, string) && Regex.match?(@email, string) end @doc """ Checks if the `string` is a valid host representation. This function returns `true` if the value is a valid IPv4 address, IPv6 address, or a valid hostname, `false` otherwise. ## Examples iex> import Xema.Format iex> iex> host?("127.0.0.1") true iex> host?("localhost") true iex> host?("elixirforum.com") true iex> host?("go go go") false """ @spec host?(String.t()) :: boolean def host?(string) when is_binary(string), do: ipv4?(string) || ipv6?(string) || hostname?(string) @doc """ Checks if the `string` is a valid hostname representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 1034](https://tools.ietf.org/html/rfc1034), `false` otherwise. """ @hostname ~r/ (?(DEFINE) (? (?:[a-z\d][-a-z\d]{0,62}) ) ) ^(?&sub_domain)(?:\.(?&sub_domain))*$ /xi @spec hostname?(String.t()) :: boolean def hostname?(string) when is_binary(string), do: !Regex.match?(~r/-$/, string) && Regex.match?(@hostname, string) @doc """ Checks if the `string` is a valid IPv4 address representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 2673](https://tools.ietf.org/html/rfc2673), `false` otherwise. """ @ipv4 ~r/ (?(DEFINE) (? (?:25[0-5]|2[0-4]\d|[0-1]?\d{1,2}) ) (? (?:(?&dec_octet)(?:\.(?&dec_octet)){3}) ) ) ^(?&ipv4)$ /x @spec ipv4?(String.t()) :: boolean def ipv4?(string) when is_binary(string), do: Regex.match?(@ipv4, string) @doc """ Checks if the `string` is a valid IPv6 address representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 2373](https://tools.ietf.org/html/rfc2373), `false` otherwise. """ @ipv6 ~r/ (?(DEFINE) (?(?:[[:xdigit:]]{1,4}) ) (? (?:25[0-5]|2[0-4]\d|[0-1]?\d{1,2}) ) (? (?:(?&dec_octet)(?:\.(?&dec_octet)){3}) ) (? (?:(?:(?&h16):(?&h16))|(?&ipv4)) ) ) ^(?: (?: (?:(?&h16):){6}(?&ls32) ) |(?: ::(?:(?&h16):){5}(?&ls32) ) |(?:(?: (?&h16))?::(?:(?&h16):){4}(?&ls32) ) |(?:(?:(?:(?&h16):){0,1}(?&h16))?::(?:(?&h16):){3}(?&ls32) ) |(?:(?:(?:(?&h16):){0,2}(?&h16))?::(?:(?&h16):){2}(?&ls32) ) |(?:(?:(?:(?&h16):){0,3}(?&h16))?:: (?&h16): (?&ls32) ) |(?:(?:(?:(?&h16):){0,4}(?&h16))?:: (?&ls32) ) |(?:(?:(?:(?&h16):){0,5}(?&h16))?:: (?&h16) ) |(?:(?:(?:(?&h16):){0,6}(?&h16))?:: ) )$ /x @spec ipv6?(String.t()) :: boolean def ipv6?(string) when is_binary(string), do: Regex.match?(@ipv6, string) @doc """ Checks if the `string` is a valid JSON pointer representation. """ @json_pointer ~r/ (?(DEFINE) (? (?: \/ (?&reference_token))* ) (? (?:(?&unescaped)|(?&escaped))* ) (? [^~\/] ) (? ~[01] ) ) ^(?&json_pointer)$ /x @spec json_pointer?(String.t()) :: boolean def json_pointer?(string) when is_binary(string), do: Regex.match?(@json_pointer, string) @doc """ Checks if the `string` is a valid JSON pointer representation. """ @spec relative_json_pointer?(String.t()) :: boolean def relative_json_pointer?(string) when is_binary(string) do with false <- Regex.match?(~r/^\d#$/, string), false <- Regex.match?(~r/^\d$/, string), false <- do_relative_json_pointer?(string) do false end end defp do_relative_json_pointer?(string) do case String.split(string, "/", parts: 2) do [pre, pointer] -> Regex.match?(~r/^\d+$/, pre) && json_pointer?("/#{pointer}") _ -> false end end @doc """ Return true if `string` contains a regular expression. """ @spec regex?(String.t()) :: boolean def regex?(string) do case Regex.compile(string) do {:ok, _} -> true {:error, _} -> false end end # # URI # @doc """ Checks if the `string` is a valid URI representation. This function returns `true` if the value is a string and is formatted as defined by [RFC 3986](https://tools.ietf.org/html/rfc3986), `false` otherwise. The following are two example URIs and their component parts: ```code hierarchical part | |-----------------------------------------| authority path | | |-------------------------------||--------| abc://username:password@example.com:123/path/data?key=value#fragid1 |-| |---------------| |---------| |-| |-------| |-----| | | | | | | scheme user information host port query fragment urn:example:mammal:monotreme:echidna |-| |------------------------------| | | scheme path ``` Wikipedia: [Uniform Resource Identifier](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier) """ @spec uri?(String.t()) :: boolean def uri?(string), do: do_uri?(string, :uri) @doc """ Checks if the `string` is a valid URI reference representation. """ @spec uri_reference?(String.t()) :: boolean def uri_reference?(string), do: do_uri?(string, :uri_reference) @doc """ Checks if the `string` is a valid URI template representation. """ def uri_template?(string), do: do_uri?(string, :uri_template) # do_uri?/2 handles: # * uri? # * uri_reference? # * uri_template? defp do_uri?(string, type) when is_binary(string), do: do_uri?(URI.parse(string), type) defp do_uri?(%URI{scheme: nil}, :uri), do: false defp do_uri?(%URI{scheme: "mailto", path: path}, _), do: email?(path) # credo:disable-for-next-line defp do_uri?(%URI{} = uri, :uri_template) do (is_nil(uri.host) || uri_host?(uri.host)) && (is_nil(uri.userinfo) || uri_userinfo?(uri.userinfo)) && (is_nil(uri.path) || uri_template_path?(uri.path)) end # credo:disable-for-next-line defp do_uri?(%URI{} = uri, _) do (is_nil(uri.host) || uri_host?(uri.host)) && (is_nil(uri.userinfo) || uri_userinfo?(uri.userinfo)) && (is_nil(uri.path) || uri_path?(uri.path)) && (is_nil(uri.query) || uri_query?(uri.query)) && (is_nil(uri.fragment) || uri_fragment?(uri.fragment)) end defp do_uri?(_, _), do: false @doc """ Checks if the `string` is a valid URI user info. See also `Xema.Format.uri?/1`. """ @uri_userinfo ~r/ (?(DEFINE) (? %[[:xdigit:]][[:xdigit:]] ) (? [-._~[:alnum:]!$&'()*+,;=:] ) ) ^(?:(?&chars)|(?&pct_encoded))*$ /x @spec uri_userinfo?(String.t()) :: boolean def uri_userinfo?(string) when is_binary(string), do: Regex.match?(@uri_userinfo, string) @doc """ Checks if the `string` is a valid URI path representation. See also `Xema.Format.uri?/1`. """ @uri_path ~r/ (?(DEFINE) (? [-._~[:alnum:]] ) (? [!$&'()*+,;=] ) (? %[[:xdigit:]][[:xdigit:]] ) (? @|(?&unreserved)|(?&pct_encoded)|(?&sub_delims) ) (? (?&pchar)+ ) (? (?::|(?&pchar))+ ) (? (?::|(?&pchar))* ) (? (?&seg_nz)(?:\/(?&seg))* ) (? (?&seg_nz_nc)(?:\/(?&seg)*) ) (? \/(?:(?&seg_nz)(?:\/(?&seg))*)? ) (? (?:\/(?&seg))* ) ) ^(?:(?&rootless)|(?&noscheme)|(?&absolute)|(?&abempty))$ /x @spec uri_path?(String.t()) :: boolean def uri_path?(string) when is_binary(string), do: Regex.match?(@uri_path, string) @doc """ Checks if the `string` is a valid URI template path representation. """ @uri_template_path ~r/ (?(DEFINE) (? [-._~[:alnum:]] ) (? [!$&'()*+,;=] ) (? %[[:xdigit:]][[:xdigit:]] ) (? @|(?&unreserved)|(?&pct_encoded)|(?&sub_delims) |(?&template) ) (? (?&pchar)+ ) (? (?::|(?&pchar))+ ) (? (?::|(?&pchar))* ) (? (?&seg_nz)(?:\/(?&seg))* ) (? (?&seg_nz_nc)(?:\/(?&seg)*) ) (? \/(?:(?&seg_nz)(?:\/(?&seg))*)? ) (? (?:\/(?&seg))* ) (? ([_[:alnum:]])|(?&pct_encoded) ) (? [+#.,;?&=@!|\/] ) (? (?&prefix)|\* ) (? :\d+ ) (? (?&tmpl_char)+(?&modifier)? ) (? (?&var)(?:,(?&var))* ) (?