From 4a0037730300e2641b40130453b413a1fd7a5c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wo=C5=BAniak?= Date: Tue, 2 Aug 2022 15:58:34 +0200 Subject: [PATCH 1/4] Adds Proto.Transfromations --- lib/util/proto/transformations.ex | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 lib/util/proto/transformations.ex diff --git a/lib/util/proto/transformations.ex b/lib/util/proto/transformations.ex new file mode 100644 index 0000000..f681fd0 --- /dev/null +++ b/lib/util/proto/transformations.ex @@ -0,0 +1,56 @@ +defmodule Util.Proto.Transformations do + @moduledoc """ + This module is intended as a simple way for initializing deeply nested Protobuf + structures defined by protobuf-elixir modules. + """ + + @doc """ + ## Examples: + + iex> alias Util.Proto.Transformations + iex> Transformations.string_to_enum_atom_or_0("name", "") + 0 + + iex> alias Util.Proto.Transformations + iex> Transformations.string_to_enum_atom_or_0("name", 1) + 0 + + iex> alias Util.Proto.Transformations + iex> Transformations.string_to_enum_atom_or_0("name", "value") + :Value + """ + def string_to_enum_atom_or_0(_field_name, field_value) + when is_binary(field_value) and field_value != "" do + field_value |> String.upcase() |> String.to_atom() + end + def string_to_enum_atom_or_0(_field_name, _field_value), do: 0 + + + @doc """ + ## Examples: + + iex> alias Util.Proto.Transformations + iex> Transformations.date_time_to_timestamps("name", nil) + %{seconds: 0, nanos: 0} + + iex> alias Util.Proto.Transformations + iex> {:ok, time} = DateTime.new(~D[2016-05-24], ~T[13:26:08.003], "Etc/UTC") + iex> Transformations.date_time_to_timestamps("name", time) + %{seconds: 1464096368, nanos: 3000000} + + iex> alias Util.Proto.Transformations + iex> Transformations.date_time_to_timestamps("name", %{seconds: 10, nanos: 10}) + %{seconds: 10, nanos: 10} + + """ + def date_time_to_timestamps(_field_name, nil), do: %{seconds: 0, nanos: 0} + def date_time_to_timestamps(_field_name, date_time = %DateTime{}) do + %{} + |> Map.put(:seconds, DateTime.to_unix(date_time, :second)) + |> Map.put(:nanos, elem(date_time.microsecond, 0) * 1_000) + end + def date_time_to_timestamps(_field_name, value), do: value + + def atom_to_lower_string(_field_name, value), + do: value |> Atom.to_string() |> String.downcase() +end From e0a1010b39a3b6cc68afb37830de8c54c474e7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wo=C5=BAniak?= Date: Wed, 3 Aug 2022 10:48:40 +0200 Subject: [PATCH 2/4] Adds Validator --- lib/util/validator.ex | 183 +++++++++++ lib/util/validator/base_validator.ex | 405 ++++++++++++++++++++++++ mix.exs | 1 + mix.lock | 7 +- test/validator_test.exs | 124 ++++++++ test/validators/base_validator_test.exs | 249 +++++++++++++++ 6 files changed, 967 insertions(+), 2 deletions(-) create mode 100644 lib/util/validator.ex create mode 100644 lib/util/validator/base_validator.ex create mode 100644 test/validator_test.exs create mode 100644 test/validators/base_validator_test.exs diff --git a/lib/util/validator.ex b/lib/util/validator.ex new file mode 100644 index 0000000..4ccefe3 --- /dev/null +++ b/lib/util/validator.ex @@ -0,0 +1,183 @@ +defmodule Util.Validator do + alias Util.ToTuple + import ToTuple + + @type global_validator_name :: atom() + @type validator_argument :: list() + + @type validator :: + global_validator_name + | {global_validator_name, validator_argument()} + | (value_to_validate :: any() -> validator_result()) + | (value_to_validate :: any(), validator_argument() -> validator_result()) + + @type validator_callback :: (value :: any() -> validator_result()) + @type validator_result() :: ToTuple.ok_tuple() | ToTuple.error_tuple(String.t()) + + @doc """ + Validates the value with a list of validators. + + ## Examples + + iex> validate(1, [eq: 1]) + {:ok, 1} + iex> validate(1, fn _ -> {:error, "I've failed"} end) + {:error, "I've failed"} + + iex> validate(1, [fn _ -> {:error, "I've failed"} end, fn _ -> {:error, "I've failed too"} end, fn _ -> {:error, "So did I"} end]) + {:error, "I've failed, I've failed too, So did I"} + + iex> validate(1, [fn _ -> {:error, "I've failed"} end, fn _ -> {:error, "I've failed too"} end, fn _ -> {:error, "So did I"} end]) + {:error, "I've failed, I've failed too, So did I"} + + """ + @spec validate(any, [validator()]) :: validator_result() + def validate(value_to_validate, validators) do + validators + |> compile() + |> Enum.map(fn validate -> + validate.(value_to_validate) + end) + |> resolve(value_to_validate) + end + + @doc """ + Compiles validators to a list of functions accepting value to validate as an argument. + + ## Examples + + iex> validators = [:identity, {:eq, 2}, fn _ -> {:ok, "I'm fine"} end, {fn _, name -> {:error, "\#{name} - I'm not fine"} end, "Joe"}] + ...> compiled_validators = compile(validators) + ...> Enum.map(compiled_validators, & &1.(2)) + [2, 2, {:ok, "I'm fine"}, {:error, "Joe - I'm not fine"}] + """ + @spec compile(validators :: [validator]) :: [validator_callback] + def compile(validators) do + validators + |> to_list + |> ensure_validator_arguments() + |> Enum.map(&normalize/1) + end + + @doc """ + Resolves the validation result. + If every validation resolves to ok tuple, wrapped value will be returned. + In case any validations resolves to error tuple, error messages from validations will be + concatenated and returned as error message. + """ + @spec resolve(results :: [validator_result()], value :: term()) :: + ToTuple.ok_tuple(value :: term()) | ToTuple.error_tuple(String.t()) + def resolve(results, value) do + results + |> Enum.filter(fn + {:error, _} -> true + _ -> false + end) + |> case do + errors when errors == [] -> + wrap(value) + + errors -> + errors + |> Enum.map_join(", ", &elem(&1, 1)) + |> error() + end + |> case do + {:ok, _} -> wrap(value) + error -> error + end + end + + @doc """ + This function makes sure that all validators have an argument. + When argument is a list - this function is recursively called on each element. + If not `[]` is used as a default + + ## Examples + iex> ensure_validator_arguments({:a, []}) + {:a, []} + + iex> ensure_validator_arguments(:a) + {:a, []} + + iex> ensure_validator_arguments([:a, :b, :c]) + [{:a, []}, {:b, []}, {:c, []}] + + iex> ensure_validator_arguments([:a, :b, c: 2, d: 3]) + [{:a, []}, {:b, []}, {:c, 2}, {:d, 3}] + + iex> ensure_validator_arguments([:a, [:b, [:c]]]) + [{:a, []}, [{:b, []}, [{:c, []}]]] + + """ + def ensure_validator_arguments(validator) do + case validator do + {validator, validator_opts} -> + {validator, validator_opts} + validators when is_list(validators) -> + validators + |> Enum.map(&ensure_validator_arguments/1) + + validator -> + {validator, []} + end + end + + @doc """ + Normalizes a validator tuple to a function that accepts one value - the value to validate. + Validations can run only on ok_tuples and plain values. Error tuples as values **will not** trigger validations. + + ## Examples + iex> is_function(normalize({:some_global_validator, []}), 1) + true + + iex> is_function(normalize({& &1, []}), 1) + true + + iex> is_function(normalize({& &1 + &2, []}), 1) + true + + iex> normalize(:a) + ** (RuntimeError) only atoms, one-argument and two-argument functions callbacks can be validators, got :a + """ + def normalize(validator_tuple) do + validator_tuple + |> case do + {validator_name, validator_argument} when is_atom(validator_name) -> + fn value -> + value + |> unwrap(fn value -> + Util.Validator.BaseValidator.select(validator_name).( + value, + validator_argument + ) + end) + end + + {validator_func, validator_argument} when is_function(validator_func, 2) -> + fn value -> + value + |> unwrap(fn value -> + validator_func.(value, validator_argument) + end) + end + + {validator_func, _validator_argument} when is_function(validator_func, 1) -> + validator_func + + invalid -> + raise( + "only atoms, one-argument and two-argument functions callbacks can be validators, got #{inspect(invalid)}" + ) + end + end + + defp to_list(value) do + value + |> is_list() + |> case do + true -> value + _ -> [value] + end + end +end diff --git a/lib/util/validator/base_validator.ex b/lib/util/validator/base_validator.ex new file mode 100644 index 0000000..c50e9b9 --- /dev/null +++ b/lib/util/validator/base_validator.ex @@ -0,0 +1,405 @@ +defmodule Util.Validator.BaseValidator do + alias Util.Validator + import Util.ToTuple + + import Validator, only: [validate: 2] + + def select(:identity) do + fn + value, [] -> + value + + _value, arg -> + arg + end + end + + def select(:inspect) do + fn value, opts -> + label = Keyword.get(opts, :label, "inspect") + + value + # credo:disable-for-next-line + |> IO.inspect(label: label) + end + end + + # credo:disable-for-next-line + def select(:chain) do + fn value, validators -> + last_item = + validators + |> case do + validators when length(validators) > 1 -> + validators + |> List.last() + |> case do + {:error_message, _} = err -> err + _ -> nil + end + + _ -> + nil + end + + validators = + last_item + |> case do + {:error_message, _} -> + validators |> pop_last + + _ -> + validators + end + + validators + |> Validator.compile() + |> Enum.reduce_while(value, fn validator, value -> + validator.(value) + |> case do + {:error, _} = error -> {:halt, error} + {:ok, value} -> {:cont, value} + value -> {:cont, value} + end + end) + |> unwrap_error(fn error_message -> + last_item + |> case do + {:error_message, error_message} when is_bitstring(error_message) -> + error(error_message) + + {:error_message, error_callback} when is_function(error_callback, 1) -> + error_callback.(error_message) |> error() + + {:error_message, error_callback} when is_function(error_callback, 2) -> + error_callback.(error_message, value) |> error() + + nil -> + error(error_message) + end + end) + |> unwrap(fn value -> + value + end) + end + end + + def select(:from!) do + fn + value, keys when is_list(keys) -> + select(:is_map).(value, []) + |> unwrap(fn value -> + keys + |> Enum.reduce(value, fn key, value -> + select(:from!).(value, key) + end) + end) + + value, key -> + select(:is_map).(value, []) + |> unwrap(fn value -> + Map.has_key?(value, key) + |> case do + true -> Map.get(value, key) + _ -> error("#{inspect(key)} is not a key in #{inspect(value)}") + end + end) + end + end + + def select(:is_map) do + fn value, _opts -> + value + |> is_map + |> case do + true -> value + _ -> error("is not a map") + end + end + end + + def select(:any) do + fn value, validators -> + validation_results = + validators + |> Enum.map(&Util.Validator.validate(value, [&1])) + + validation_results + |> Enum.any?(fn + {:error, _} -> false + _ -> true + end) + |> case do + true -> value + _ -> Validator.resolve(validation_results, value) + end + end + end + + def select(:all) do + fn value, validators -> + validation_results = + validators + |> Enum.map(&Util.Validator.validate(value, [&1])) + + validation_results + |> Enum.any?(fn + {:error, _} -> true + _ -> false + end) + |> case do + false -> value + _ -> Validator.resolve(validation_results, value) + end + end + end + + def select(:take!) do + fn value, validators -> + validated_values = + validators + |> Validator.compile() + |> Enum.map(fn validator -> + validator.(value) + end) + + validated_values + |> Enum.any?(fn + {:error, _} -> true + _ -> false + end) + |> case do + true -> + Validator.resolve(validated_values, value) + + _ -> + validated_values + end + end + end + + def select(:check) do + fn value, callback -> + try do + if callback.(value) == true do + value + else + error("did not pass the check") + end + rescue + _e -> + error("catastrophicaly did not pass the check") + end + end + end + + def select(:length) do + fn value, _opts -> + cond do + is_list(value) -> length(value) + is_bitstring(value) -> String.length(value) + true -> error("doesn't have length") + end + end + end + + def select(:eq) do + fn value, arg -> + (value == arg) + |> case do + true -> value + _ -> error("is not equal to #{inspect(arg)}") + end + end + end + + def select(:neq) do + fn value, arg -> + select(:eq).(value, arg) + |> case do + {:error, _} -> value + _ -> error("is equal to #{inspect(arg)}") + end + end + end + + def select(:gt) do + fn value, arg -> + (value > arg) + |> case do + true -> value + _ -> error("is not greater than #{inspect(arg)}") + end + end + end + + def select(:gte) do + fn value, arg -> + (value >= arg) + |> case do + true -> value + _ -> error("is not greater than or equal to #{inspect(arg)}") + end + end + end + + def select(:lt) do + fn value, arg -> + (value < arg) + |> case do + true -> value + _ -> error("is not lesser than #{inspect(arg)}") + end + end + end + + def select(:lte) do + fn value, arg -> + (value <= arg) + |> case do + true -> value + _ -> error("is not greater than or equal to #{inspect(arg)}") + end + end + end + + def select(:is_not_empty) do + fn value, opts -> + select(:is_empty).(value, opts) + |> case do + {:error, _} -> value + _ -> error("is empty") + end + end + end + + def select(:is_empty) do + fn value, opts -> + empty_values = Keyword.get(opts, :empty_values, [nil, false, 0, "", [], {}, %{}]) + + (value in empty_values) + |> case do + true -> value + _ -> error("is not empty") + end + end + end + + def select(:is_integer) do + fn value, _opts -> + cast_integer(value) + |> unwrap_error(fn _ -> + error("is not an integer") + end) + |> unwrap(fn value -> + value + end) + end + end + + def select(:is_string) do + fn value, _opts -> + cast_binary(value) + |> unwrap_error(fn _ -> + error("is not a string") + end) + |> unwrap(fn value -> + value + end) + end + end + + def select(:is_uuid) do + fn value, _opts -> + UUID.info(value) + |> unwrap_error(fn _ -> + error("is not an uuid") + end) + |> unwrap(fn value -> + value + end) + end + end + + def select(:is_sha) do + fn value, _ -> + value + |> Integer.parse(16) + |> case do + :error -> + error("doesn't look like a sha") + + {_, remainder} when remainder != "" -> + error("doesn't look like a sha") + + {parsed_value, _} -> + if parsed_value >= 0 do + value + else + error("doesn't look like a sha") + end + end + end + end + + def select(:is_url) do + fn value, _ -> + value + |> validate(all: [:is_string, :is_not_empty]) + |> unwrap(fn url -> + url + |> cast_uri() + |> case do + {:error, message} -> error("is not a url: #{message}") + _ -> + value + end + end) + end + end + + def select(:is_file_path) do + fn value, _ -> + value + |> validate(all: [:is_string, :is_not_empty]) + |> unwrap(fn _ -> + value + end) + end + end + + def select(:error), do: select(:identity) + + def select(validator), do: raise("unknown validator #{inspect(validator)}") + + defp pop_last(list) when is_list(list) do + list |> Enum.reverse() |> tl() |> Enum.reverse() + end + + defp cast_integer(term) when is_binary(term) do + case Integer.parse(term) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + defp cast_integer(term) when is_integer(term), do: {:ok, term} + defp cast_integer(_), do: :error + + defp cast_binary(term) when is_binary(term), do: {:ok, term} + defp cast_binary(_), do: :error + + defp cast_uri(term) when is_binary(term) do + if Code.ensure_loaded?(:uri_string) do + case :uri_string.parse(term) do + %{} = _ -> {:ok, term} + {:error, :invalid_uri, reason} -> {:error, Kernel.to_string(reason)} + end + else + case :http_uri.parse(term) do + {:ok, _} = _ -> {:ok, term} + {:error, reason} -> {:error, Kernel.to_string(reason)} + end + end + end +end diff --git a/mix.exs b/mix.exs index 5dc5f8c..faeec4c 100644 --- a/mix.exs +++ b/mix.exs @@ -20,6 +20,7 @@ defmodule Util.Mixfile do {:wormhole, "~> 2.2"}, {:protobuf, "~> 0.5"}, {:mock, "~> 0.3.0", only: :test}, + {:uuid, "~> 1.1"} ] end end diff --git a/mix.lock b/mix.lock index 7fa690f..0ed146b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,8 @@ -%{"meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, +%{ + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, "protobuf": {:hex, :protobuf, "0.5.4", "2e1b8eec211aff034ad8a14e3674220b0158bfb9a3c7128ac9d2a1ed1b3724d3", [:mix], [], "hexpm", "994348a4592408bc99c132603b0fdb686a2b5df0321a8eb1a582ec2bd3495886"}, + "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, "watchman": {:git, "https://github.com/renderedtext/ex-watchman.git", "3286b9d999db11696fffbccab9637cd31e93a305", []}, - "wormhole": {:hex, :wormhole, "2.2.0", "4fa107a2a1cf4c0ada5d0ee29590fa78ce684993e6950a52c1488cb9fe313f31", [:mix], [], "hexpm", "945388051723c02a5bd2cd404f0bcc04008a415390235b1ad69e22dee8c38de3"}} + "wormhole": {:hex, :wormhole, "2.2.0", "4fa107a2a1cf4c0ada5d0ee29590fa78ce684993e6950a52c1488cb9fe313f31", [:mix], [], "hexpm", "945388051723c02a5bd2cd404f0bcc04008a415390235b1ad69e22dee8c38de3"}, +} diff --git a/test/validator_test.exs b/test/validator_test.exs new file mode 100644 index 0000000..3ac5f36 --- /dev/null +++ b/test/validator_test.exs @@ -0,0 +1,124 @@ +defmodule Util.ValidatorTest do + use ExUnit.Case, async: true + doctest Util.Validator, import: true + + alias Util.Validator + import Validator, only: [validate: 2] + + describe "Validator usage" do + test "some examples" do + contract = [ + chain: [ + take!: [from!: :number_of_items, from!: :item_price], + all: [ + chain: [ + check: fn [number_of_items, item_price] -> number_of_items * item_price < 10_000 end, + error_message: "Total must be less than 10000" + ], + chain: [ + check: fn [number_of_items, item_price] -> + String.length("$#{number_of_items * item_price}.00") < 9 + end, + error_message: fn _error, [number_of_items, item_price] -> + "`$#{number_of_items * item_price}.00` must fit on 9 digit screens" + end + ] + ] + ] + ] + + registry = %{ + number_of_items: 10, + item_price: 900 + } + + assert {:ok, ^registry} = validate(registry, contract) + + registry = %{ + number_of_items: 10, + item_price: 1_000 + } + + assert {:error, "Total must be less than 10000, `$10000.00` must fit on 9 digit screens"} = + validate(registry, contract) + end + + test "deferring expensive validations" do + contract = [ + chain: [ + all: [ + chain: [{:from!, :user_id}, :is_uuid], + chain: [{:from!, :email}, :is_string, :is_not_empty, eq: "joe@example.com"] + ], + chain: [ + {:from!, :email}, + fn + "joe@example.com" -> true + _ -> raise "I should have never been called" + end + ] + ] + ] + + user = %{ + email: "joe@example.com", + user_id: UUID.uuid4() + } + + assert {:ok, ^user} = validate(user, contract) + + user = %{ + email: "jones@example.com", + user_id: UUID.uuid4() + } + + assert {:error, _} = validate(user, contract) + end + + test "take" do + contract = [ + chain: [ + {:take!, [identity: 1, identity: 2]}, + {fn [a, b] -> a + b end, []}, + {:eq, 3} + ] + ] + + a_number = 3 + + assert {:ok, ^a_number} = validate(a_number, contract) + end + + test "check two values at once" do + contract = [ + all: [ + chain: [{:from!, :height}, :is_integer], + chain: [{:from!, :weight}, :is_integer] + ], + chain: [ + {:take!, [{:from!, :weight}, {:from!, :height}]}, + {fn [weight, height] -> weight / height end, []}, + all: [ + gt: 1.5, + lt: 3.0, + gt: 1.9 + ] + ] + ] + + measurements = %{ + weight: 200, + height: 100 + } + + assert {:ok, ^measurements} = validate(measurements, contract) + + measurements = %{ + weight: 220, + height: 150 + } + + assert {:error, _} = validate(measurements, contract) + end + end +end diff --git a/test/validators/base_validator_test.exs b/test/validators/base_validator_test.exs new file mode 100644 index 0000000..662ebff --- /dev/null +++ b/test/validators/base_validator_test.exs @@ -0,0 +1,249 @@ +defmodule Util.Validator.BaseValidatorTest do + use ExUnit.Case, async: true + doctest Util.Validator.BaseValidator, import: true + + describe "" do + import Util.Validator.BaseValidator, only: [select: 1] + + test ":inspect inspects a value" do + import ExUnit.CaptureIO + inspect = select(:inspect) + + io = + capture_io(fn -> + assert 100 = inspect.(100, []) + assert 100 = inspect.(100, label: "With label") + end) + + assert io == "inspect: 100\nWith label: 100\n" + end + + test ":identity passes value through" do + identity = select(:identity) + assert 99 == identity.(99, []) + assert 100 == identity.(100, []) + assert 101 == identity.(101, []) + + assert 100 == identity.(101, 100), "can be used to inject values" + end + + test ":eq checks if value is equal to an argument" do + eq = select(:eq) + assert {:error, _} = eq.(99, 100) + assert 100 == eq.(100, 100) + assert {:error, _} = eq.(101, 100) + end + + test ":neq checks if value is not equal to an argument" do + neq = select(:neq) + assert 99 == neq.(99, 100) + assert {:error, _} = neq.(100, 100) + assert 101 == neq.(101, 100) + end + + test ":lt checks if value is lesser than argument" do + lt = select(:lt) + assert 99 == lt.(99, 100) + assert {:error, _} = lt.(100, 100) + assert {:error, _} = lt.(101, 100) + end + + test ":lte checks if value is lesser or equal to argument" do + lte = select(:lte) + assert 99 == lte.(99, 100) + assert 100 == lte.(100, 100) + assert {:error, _} = lte.(101, 100) + end + + test ":gt checks if value is greater than argument" do + gt = select(:gt) + assert {:error, _} = gt.(99, 100) + assert {:error, _} = gt.(100, 100) + assert 101 == gt.(101, 100) + end + + test ":gte checks if value is greater than or equal to value" do + gte = select(:gte) + assert {:error, _} = gte.(99, 100) + assert 100 = gte.(100, 100) + assert 101 == gte.(101, 100) + end + + test ":length checks if value has length, and returns it if it does" do + length = select(:length) + assert 0 == length.([], []) + assert 1 == length.([2], []) + assert 3 == length.("1-1", []) + assert 0 == length.("", []) + assert {:error, _} = length.(100, []) + end + + test ":all validates if all of the given validators passes" do + all = select(:all) + + raiser = fn _ -> + raise "I will raise." + end + + divisible_by_3 = fn int -> + rem(int, 3) == 0 + end + + validator = fn value, _opts -> + divisible_by_3.(value) + |> case do + true -> value + _ -> {:error, "is not divisible by 3"} + end + end + + assert {:error, _} = all.(98, [validator, gt: 98, lt: 101]) + assert 99 == all.(99, [validator, gt: 98, lt: 101]) + assert {:error, _} = all.(100, [validator, gt: 98, lt: 101]) + assert {:error, _} = all.(101, [validator, gt: 98, lt: 101]) + + assert_raise RuntimeError, "I will raise.", fn -> + {:error, _} = all.(101, [validator, {:gt, 98}, {:lt, 101}, &raiser.(&1)]) + end + end + + test ":any validates if any of the given validators passes" do + any = select(:any) + assert 99 == any.(99, lt: 100, gt: 100) + assert {:error, _} = any.(100, lt: 100, gt: 100) + assert 101 == any.(101, lt: 100, gt: 100) + end + + test ":is_map checks if value is a map" do + is_map = select(:is_map) + assert %{} == is_map.(%{}, []) + assert %{a: 1} == is_map.(%{a: 1}, []) + assert {:error, _} = is_map.([], []) + end + + test ":from! fetches value by key(s) from map and returns it" do + from! = select(:from!) + + assert 0 == from!.(%{counter: 0}, :counter) + assert 1 == from!.(%{counter: 1}, :counter) + assert {:error, _} = from!.(%{other_counter: 2}, :counter) + assert {:error, _} = from!.(%{counter: 0}, :some_non_existent_key) + + user_data = %{user: %{id: "b01e81b6-1e48-443e-b625-f340977cd33a"}} + assert %{id: "b01e81b6-1e48-443e-b625-f340977cd33a"} == from!.(user_data, :user) + + assert "b01e81b6-1e48-443e-b625-f340977cd33a" == from!.(user_data, [:user, :id]) + assert {:error, _} = from!.(user_data, [:user, :bar]) + end + + test ":is_integer checks if value is an integer" do + is_integer = select(:is_integer) + assert 1 == is_integer.(1, []) + assert {:error, _} = is_integer.(1.2, []) + assert 5 == is_integer.(0x5, []) + assert 5 == is_integer.(05, []) + end + + test ":is_string" do + is_string = select(:is_string) + assert {:error, _} = is_string.(1, []) + assert "1" == is_string.("1", []) + end + + test ":is_empty" do + is_empty = select(:is_empty) + assert [] == is_empty.([], []) + assert %{} == is_empty.(%{}, []) + assert {} == is_empty.({}, []) + assert "" == is_empty.("", []) + assert {:error, _} = is_empty.("", empty_values: []) + end + + test ":is_not_empty" do + is_not_empty = select(:is_not_empty) + assert {:error, _} = is_not_empty.([], []) + assert {:error, _} = is_not_empty.(%{}, []) + assert {:error, _} = is_not_empty.({}, []) + assert {:error, _} = is_not_empty.("", []) + assert {:error, _} = is_not_empty.("empty", empty_values: ["empty"]) + assert "" = is_not_empty.("", empty_values: ["empty"]) + end + + test ":check runs a check function on value" do + check = select(:check) + assert true == check.(true, &is_boolean/1) + assert {:error, _} = check.("true", &is_boolean/1) + assert [] == check.([], &is_list/1) + assert {:error, _} = check.(nil, &is_list/1) + assert {:error, _} = check.(nil, fn -> raise "Can't stand it" end) + end + + test ":take! fetches validation results and passes them through as a list" do + take! = select(:take!) + + params = %{ + number_of_items: 10, + item_price: 5, + foo: 2 + } + + assert [10, 5] == take!.(params, from!: :number_of_items, from!: :item_price) + assert {:error, _} = take!.(params, from!: :number_of_items, from!: :item_prices) + end + + test ":chain enables constructing validation pipelines" do + chain = select(:chain) + + assert 10 == chain.(10, gt: 2, gt: 3, gt: 9, lte: 10) + assert 10 == chain.(10, gt: 2) + + assert {:error, "is not greater than 12"} == chain.(10, gt: 5, lt: 11, gt: 12) + + assert {:error, "is not greater than 13"} == + chain.(10, gt: 13, lt: 11, gt: 7, lt: 55, gt: 99) + + assert {:error, "custom error"} == + chain.(10, gt: 13, lt: 11, gt: 7, lt: 55, gt: 99, error_message: "custom error") + + assert {:error, "10 is not greater than 13"} == + chain.(10, + gt: 13, + lt: 11, + gt: 7, + lt: 55, + gt: 99, + error_message: fn error_message, value -> + "#{inspect(value)} #{error_message}" + end + ) + end + + test ":is_sha check if value is a valid sha (hex)" do + is_sha = select(:is_sha) + assert "abc" == is_sha.("abc", []) + assert {:error, _} = is_sha.("abcdefg", []) + assert {:error, _} = is_sha.("g123", []) + assert {:error, _} = is_sha.("-234", []) + end + + test ":is_url check if value is a valid url" do + is_url = select(:is_url) + assert "http://github.com/" == is_url.("http://github.com/", []) + assert "https://semaphore.semaphoreci.com/jobs/2a42ba5d-4413-4a2e-9513-14710f583996" = is_url.("https://semaphore.semaphoreci.com/jobs/2a42ba5d-4413-4a2e-9513-14710f583996", []) + assert {:error, _} = is_url.("no whitespaces plese", []) + end + + test ":is_file_path check if value is a valid file path" do + is_file_path = select(:is_file_path) + assert "abc" == is_file_path.("abc", []) + assert "a/b/c/d/e" = is_file_path.("a/b/c/d/e", []) + assert {:error, _} = is_file_path.("", []) + end + + test "selecting unknown validators fails" do + assert_raise RuntimeError, "unknown validator :some_unknown_validator", fn -> + select(:some_unknown_validator) + end + end + end +end From 0eefd449fc84f4a31c6d8af137624c0a4de018ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wo=C5=BAniak?= Date: Wed, 3 Aug 2022 11:09:04 +0200 Subject: [PATCH 3/4] Adds Helpers --- lib/util/helpers.ex | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/util/helpers.ex diff --git a/lib/util/helpers.ex b/lib/util/helpers.ex new file mode 100644 index 0000000..69e9406 --- /dev/null +++ b/lib/util/helpers.ex @@ -0,0 +1,24 @@ +defmodule Util.Helpers do + @moduledoc """ + Miscellaneous helpers + """ + + def non_empty_value_or_default(map, key, default) do + case Map.get(map, key) do + val when is_integer(val) and val > 0 -> {:ok, val} + val when is_binary(val) and val != "" -> {:ok, val} + val when is_list(val) and length(val) > 0 -> {:ok, val} + _ -> {:ok, default} + end + end + + def not_empty_string(map, key, error_atom \\ "") do + case Map.get(map, key) do + value when is_binary(value) and value != "" -> + {:ok, value} + error_val -> + "'#{key}' - invalid value: '#{error_val}', it must be a not empty string." + |> Util.ToTuple.error(error_atom) + end + end +end From 306ef0f6e43486357babecd1f959d22a7a6a69dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wo=C5=BAniak?= Date: Thu, 25 Aug 2022 12:21:59 +0200 Subject: [PATCH 4/4] Adds LogTee Move from https://github.com/renderedtext/log-tee --- lib/util/log_tee.ex | 15 +++++++++++++++ test/log_tee_test.exs | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 lib/util/log_tee.ex create mode 100644 test/log_tee_test.exs diff --git a/lib/util/log_tee.ex b/lib/util/log_tee.ex new file mode 100644 index 0000000..8975bba --- /dev/null +++ b/lib/util/log_tee.ex @@ -0,0 +1,15 @@ +defmodule Util.LogTee do + require Logger + + defmacro tee_(item, tag, severity) do + quote do + Logger.unquote(severity)( "#{unquote(tag)}: #{inspect unquote(item)}") + unquote(item) + end + end + + def debug(item, tag) when is_binary(tag), do: tee_(item, tag, :debug) + def info(item, tag) when is_binary(tag), do: tee_(item, tag, :info) + def warn(item, tag) when is_binary(tag), do: tee_(item, tag, :warn) + def error(item, tag) when is_binary(tag), do: tee_(item, tag, :error) +end diff --git a/test/log_tee_test.exs b/test/log_tee_test.exs new file mode 100644 index 0000000..ca0e608 --- /dev/null +++ b/test/log_tee_test.exs @@ -0,0 +1,22 @@ +defmodule Util.LogTeeTest do + use ExUnit.Case + alias Util.LogTee + + doctest LogTee + + import ExUnit.CaptureLog + + defmacro log_tee_test(severity) do + quote do + f = fn -> LogTee.unquote(severity)(12, "6+6") end + assert capture_log(f) =~ "[#{unquote(severity)}] " + assert capture_log(f) =~ " 6+6: 12" + assert f.() == 12 + end + end + + test "debug" do log_tee_test(:debug) end + test "info" do log_tee_test(:info) end + test "warn" do log_tee_test(:warn) end + test "error" do log_tee_test(:error) end +end