zf

zenflows testing
git clone https://s.sonu.ch/~srfsh/zf.git
Log | Files | Refs | Submodules | README | LICENSE

validate.ex (9960B)


      1 # Zenflows is designed to implement the Valueflows vocabulary,
      2 # written and maintained by srfsh <info@dyne.org>.
      3 # Copyright (C) 2021-2023 Dyne.org foundation <foundation@dyne.org>.
      4 #
      5 # This program is free software: you can redistribute it and/or modify
      6 # it under the terms of the GNU Affero General Public License as published by
      7 # the Free Software Foundation, either version 3 of the License, or
      8 # (at your option) any later version.
      9 #
     10 # This program is distributed in the hope that it will be useful,
     11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13 # GNU Affero General Public License for more details.
     14 #
     15 # You should have received a copy of the GNU Affero General Public License
     16 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
     17 
     18 defmodule Zenflows.Validate do
     19 @moduledoc "Ecto.Changeset validation helpers."
     20 
     21 alias Ecto.Changeset
     22 
     23 require Logger
     24 
     25 @typedoc """
     26 Used to specify whether to fetch the field's value from the `:changes`,
     27 `:data`, or `:both` of an `Ecto.Changeset`, respectively.
     28 
     29 Basically, uses `Ecto.Changeset.fetch_field/2` behind the scenes,
     30 but `nil` values will mean empty/not-provided.
     31 """
     32 @type fetch_method() :: :change | :data | :both
     33 
     34 @doc """
     35 Escape possible characters that could represent expressions in SQL's
     36 `LIKE` keyword of a field.   The value is changed if it is present.
     37 """
     38 @spec escape_like(Changeset.t(), atom()) :: Changeset.t()
     39 def escape_like(cset, field) do
     40 	case Changeset.fetch_change(cset, :field) do
     41 		{:ok, v} ->
     42 			v = Regex.replace(~r/\\|%|_/, v, &"\\#{&1}")
     43 			Changeset.put_change(cset, field, v)
     44 		:error -> cset
     45 	end
     46 end
     47 
     48 @doc """
     49 Use the OR logic on the existance of `fields`, and error if the
     50 result is false.
     51 
     52 Note: `fields` must contain at least 2 items, and make sure to read
     53 `t:fetch_method()`'s doc.
     54 """
     55 @spec exist_or(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t()
     56 def exist_or(cset, fields, opts \\ []) do
     57 	meth = Keyword.get(opts, :method, :change)
     58 	if Enum.any?(fields, &field_exists?(meth, cset, &1)) do
     59 		cset
     60 	else
     61 		Enum.reduce(fields, cset,
     62 			&Changeset.add_error(&2, &1, "at least one of them must be provided"))
     63 	end
     64 end
     65 
     66 @doc """
     67 Use the XOR logic on the existance of `fields`, and error if the
     68 result is false.
     69 
     70 Note: `fields` must contain at least 2 items, and make sure to read
     71 `t:fetch_method()`'s doc.
     72 """
     73 @spec exist_xor(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t()
     74 def exist_xor(cset, [h | t] = fields, opts \\ []) do
     75 	meth = Keyword.get(opts, :method, :change)
     76 	h_exists? = field_exists?(meth, cset, h)
     77 
     78 	if Enum.all?(t, &(h_exists? != field_exists?(meth, cset, &1))) do
     79 		cset
     80 	else
     81 		Enum.reduce(fields, cset,
     82 			&Changeset.add_error(&2, &1, "exactly one of them must be provided"))
     83 	end
     84 end
     85 
     86 @doc """
     87 Use the NAND logic on the existance of `fields`, and error if the
     88 result is false.
     89 
     90 Note: `fields` must contain at least 2 items, and make sure to
     91 read `t:fetch_method()`'s doc.
     92 """
     93 @spec exist_nand(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t()
     94 def exist_nand(cset, fields, opts \\ []) do
     95 	meth = Keyword.get(opts, :method, :change)
     96 	if Enum.all?(fields, &field_exists?(meth, cset, &1)) do
     97 		Enum.reduce(fields, cset,
     98 			&Changeset.add_error(&2, &1, "one or none of them must be provided"))
     99 	else
    100 		cset
    101 	end
    102 end
    103 
    104 @doc """
    105 Compare the values of `fields`, and error if they aren't equal to
    106 each other.
    107 
    108 Note: `fields` must contain at least 2 items, and make sure to read
    109 `t:fetch_method()`'s doc.  Also, empty fields will be skipped to
    110 allow more flexibility.  Use `Ecto.Changeset.validate_required/3`
    111 to achieve otherwise.
    112 """
    113 @spec value_eq(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t()
    114 def value_eq(cset, [a, b], opts \\ []) do
    115 	meth = Keyword.get(opts, :method, :change)
    116 	with {:ok, x} <- field_fetch(meth, cset, a),
    117 			{:ok, y} <- field_fetch(meth, cset, b) do
    118 		if x == y do
    119 			cset
    120 		else
    121 			msg = "all of them must be the same"
    122 			cset
    123 			|> Changeset.add_error(a, msg)
    124 			|> Changeset.add_error(b, msg)
    125 		end
    126 	else _ ->
    127 		cset
    128 	end
    129 end
    130 
    131 @doc """
    132 Compare the values of `fields`, and error if they aren't all
    133 different.
    134 
    135 Note: `fields` must contain at least 2 items, and make sure to read
    136 `t:fetch_method()`'s doc.  Also, empty fields will be skipped to
    137 allow more flexibility.  Use `Ecto.Changeset.validate_required/3`
    138 to achieve otherwise.
    139 """
    140 @spec value_ne(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t()
    141 def value_ne(cset, [a, b], opts \\ []) do
    142 	meth = Keyword.get(opts, :method, :change)
    143 	with {:ok, x} <- field_fetch(meth, cset, a),
    144 			{:ok, y} <- field_fetch(meth, cset, b) do
    145 		if x != y do
    146 			cset
    147 		else
    148 			msg = "all of them must be different"
    149 			cset
    150 			|> Changeset.add_error(a, msg)
    151 			|> Changeset.add_error(b, msg)
    152 		end
    153 	else _ ->
    154 		cset
    155 	end
    156 end
    157 
    158 @doc "Validate that given `field` is a valid email address."
    159 @spec email(Changeset.t(), atom()) :: Changeset.t()
    160 def email(cset, field) do
    161 	# works good enough for now
    162 	Changeset.validate_format(cset, field, ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/)
    163 end
    164 
    165 @doc """
    166 Validate that given `field` is [1, 256] bytes long.
    167 
    168 The name "name" is a reference to short texts, as in email subject,
    169 usernames, full names, and so on.
    170 """
    171 @spec name(Changeset.t(), atom()) :: Changeset.t()
    172 def name(cset, field), do: byte_range(cset, field, 1, 256)
    173 
    174 @doc """
    175 Validate that given `field` is [1, 2048] bytes long.
    176 
    177 The name "note" is a reference to long texts, as in email bodies,
    178 descriptions, notes, and so on.
    179 """
    180 @spec note(Changeset.t(), atom()) :: Changeset.t()
    181 def note(cset, field), do: byte_range(cset, field, 1, 2048)
    182 
    183 @doc """
    184 Validate that given `field` is [16, 2048] bytes long.
    185 
    186 The name "key" is a reference to encoded cryptographic keys (so the
    187 size is not the size of the binary key).
    188 """
    189 @spec key(Changeset.t(), atom()) :: Changeset.t()
    190 def key(cset, field), do: byte_range(cset, field, 16, 2048)
    191 
    192 @doc """
    193 Validate that given `field` is [1, 512] bytes long.
    194 
    195 The name "uri" is a reference to not-literal URIs, but a size used
    196 for mostly URIs.
    197 """
    198 @spec uri(Changeset.t(), atom()) :: Changeset.t()
    199 def uri(cset, field), do: byte_range(cset, field, 1, 512)
    200 
    201 @doc """
    202 Validate that given `field` is [1B, 25MiB] long, and log a warning
    203 if it is longer than 4MiB.
    204 
    205 The name "img" is a reference to Base64-encoded image binary data.
    206 """
    207 @spec img(Changeset.t(), atom()) :: Changeset.t()
    208 def img(cset, field) do
    209 	Changeset.validate_change(cset, field, __MODULE__, fn
    210 		_, str when byte_size(str) < 1 ->
    211 			[{field, "should be at least 1B long"}]
    212 		_, str when byte_size(str) > 25 * 1024 * 1024 ->
    213 			[{field, "should be at most 25MiB long"}]
    214 		_, str when byte_size(str) > 4 * 1024 * 1024 ->
    215 			Logger.warning("file exceeds 4MiB")
    216 			[]
    217 		_, _ ->
    218 			[]
    219 	end)
    220 end
    221 
    222 @doc """
    223 Validate that given `field` is a list of binaries, which are [1,
    224 512] bytes long in size, and the list itself contains [1, 128]
    225 items.
    226 
    227 The name "class" is a reference to list of strings used for tagging,
    228 categorization and so on.
    229 """
    230 @spec class(Changeset.t(), atom()) :: Changeset.t()
    231 def class(cset, field) do
    232 	Changeset.validate_change(cset, field, __MODULE__, fn
    233 		_, [] ->
    234 			[{field, "must contain at least 1 item"}]
    235 		_, list ->
    236 			case do_class(list, 0, 128) do
    237 				{:exceeds, _ind} ->
    238 					[{field, "must contain at most 128 items"}]
    239 				{:short, ind} ->
    240 					[{field, "the item at #{ind + 1} cannot be shorter than 1 byte"}]
    241 				{:long, ind} ->
    242 					[{field, "the item at #{ind + 1} cannot be longer than 512 bytes"}]
    243 				{:valid, _ind} ->
    244 					[]
    245 			end
    246 	end)
    247 end
    248 
    249 # The rationale of this function is to loop over the list while
    250 # decreasing `rem` (reminder) and increasing `ind` (index) until
    251 # either one of these happen (in that order):
    252 #
    253 #      1. `rem` is equal to 0
    254 #      2. one of the items in the list is shorter than 1 byte long
    255 #      3. one of the items in the list is longer than 512 bytes long
    256 #
    257 @spec do_class([String.t()], non_neg_integer(), non_neg_integer())
    258 	:: {:exceeds | :short | :long | :valid, non_neg_integer()}
    259 defp do_class([], ind, _), do: {:valid, ind - 1}
    260 defp do_class([h | t], ind, rem) do
    261 	cond do
    262 		rem == 0 -> {:exceeds, ind}
    263 		byte_size(h) < 1 -> {:short, ind}
    264 		byte_size(h) > 512 -> {:long, ind}
    265 		true -> do_class(t, ind + 1, rem - 1)
    266 	end
    267 end
    268 
    269 @doc """
    270 Validate that the given binary (in Elixir terms) is in this inclusive
    271 octects/bytes range.
    272 """
    273 @spec byte_range(Changeset.t(), atom(), non_neg_integer(), non_neg_integer())
    274 	:: Changeset.t()
    275 def byte_range(cset, field, min, max) do
    276 	Changeset.validate_change(cset, field, __MODULE__, fn
    277 		_, bin when byte_size(bin) < min ->
    278 			[{field, "should be at least #{min} byte(s) long"}]
    279 		_, bin when byte_size(bin) > max ->
    280 			[{field, "should be at most #{max} byte(s) long"}]
    281 		_, _ ->
    282 			[]
    283 	end)
    284 end
    285 
    286 @spec field_exists?(fetch_method(), Changeset.t(), atom()) :: boolean()
    287 defp field_exists?(:change, cset, field) do
    288 	case Changeset.fetch_field(cset, field) do
    289 		{:changes, x} when not is_nil(x) -> true
    290 		_ -> false
    291 	end
    292 end
    293 defp field_exists?(:data, cset, field) do
    294 	case Changeset.fetch_field(cset, field) do
    295 		{:data, x} when not is_nil(x) -> true
    296 		_ -> false
    297 	end
    298 end
    299 defp field_exists?(:both, cset, field) do
    300 	case Changeset.fetch_field(cset, field) do
    301 		{:changes, x} when not is_nil(x) -> true
    302 		{:data, x} when not is_nil(x) -> true
    303 		_ -> false
    304 	end
    305 end
    306 
    307 @spec field_fetch(fetch_method(), Changeset.t(), atom()) :: {:ok, term()} | :error
    308 defp field_fetch(:change, cset, field) do
    309 	case  Changeset.fetch_field(cset, field) do
    310 		{:changes, v} -> {:ok, v}
    311 		_ -> :error
    312 	end
    313 end
    314 defp field_fetch(:data, cset, field) do
    315 	case  Changeset.fetch_field(cset, field) do
    316 		{:data, v} -> {:ok, v}
    317 		_ -> :error
    318 	end
    319 end
    320 defp field_fetch(:both, cset, field) do
    321 	case Changeset.fetch_field(cset, field) do
    322 		:error -> :error
    323 		{_, v} -> {:ok, v}
    324 	end
    325 end
    326 end