diff --git a/README.md b/README.md index cb162a9..90c69b4 100644 --- a/README.md +++ b/README.md @@ -167,13 +167,13 @@ DataSpecs.load( The type of the custom loader function is ```elixir -(value(), custom_type_loaders(), [type_params_loader()] -> value()) +(value(), custom_type_loaders(), [type_loader_fun()] -> value()) ``` for example a custom `MapSet.t/1` loader could be implement as: ```elixir -def custom_mapset_loader(value, custom_type_loaders, [type_params_loader]) do +def custom_mapset_loader(value, custom_type_loaders, [type_loader_fun]) do case Enumerable.impl_for(value) do nil -> {:error, ["can't convert #{inspect(value)} to a MapSet.t/1, value not enumerable"]} @@ -181,7 +181,7 @@ def custom_mapset_loader(value, custom_type_loaders, [type_params_loader]) do _ -> value |> Enum.to_list() - |> Loaders.list(custom_type_loaders, [type_params_loader]) + |> Loaders.list(custom_type_loaders, [type_loader_fun]) |> case do {:ok, loaded_value} -> {:ok, MapSet.new(loaded_value)} @@ -194,7 +194,7 @@ end ``` The custom loader take the input value, check it's enumerable and then builds a `MapSet` -over the items of the input value. It takes as argument a list of `type_params_loader()` associated +over the items of the input value. It takes as argument a list of `type_loader_fun()` associated with the type parameters. For example, let's say we have: diff --git a/lib/dataspecs.ex b/lib/dataspecs.ex index e22fe8d..012a9f0 100644 --- a/lib/dataspecs.ex +++ b/lib/dataspecs.ex @@ -1,15 +1,10 @@ defmodule DataSpecs do @moduledoc File.read!("README.md") - alias DataSpecs.Typespecs + alias DataSpecs.{Types, Typespecs} - @type value() :: any() - @type reason :: [String.t() | reason()] - @type type_id :: atom() - @type type_ref :: {module(), type_id()} - @type custom_type_ref :: {module(), type_id(), arity()} - @type type_params_loader :: (value(), custom_type_loaders(), [type_params_loader] -> value()) - @type custom_type_loaders :: %{custom_type_ref() => type_params_loader()} + @spec load(Types.value(), Types.type_ref(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, any()} @doc """ Loads a value that should conform to a typespec @@ -42,7 +37,6 @@ defmodule DataSpecs do surname: "Smith" } """ - @spec load(value(), type_ref(), custom_type_loaders(), [type_params_loader()]) :: {:error, reason()} | {:ok, value()} def load(value, {module, type_id}, custom_type_loaders \\ %{}, type_params_loaders \\ []) do loader = Typespecs.loader(module, type_id, length(type_params_loaders)) loader.(value, custom_type_loaders, type_params_loaders) diff --git a/lib/dataspecs/loaders.ex b/lib/dataspecs/loaders.ex index 69bb865..62f6172 100644 --- a/lib/dataspecs/loaders.ex +++ b/lib/dataspecs/loaders.ex @@ -1,10 +1,20 @@ defmodule DataSpecs.Loaders do - @moduledoc false + @moduledoc """ + Type loaders for Erlang builtin types + """ + + alias DataSpecs.Types + + @spec any(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, any()} def any(value, _custom_type_loaders, _type_params_loaders) do {:ok, value} end + @spec atom(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, atom()} + def atom(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_atom(value) -> @@ -23,6 +33,9 @@ defmodule DataSpecs.Loaders do end end + @spec boolean(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, boolean()} + def boolean(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_boolean(value) -> @@ -33,6 +46,9 @@ defmodule DataSpecs.Loaders do end end + @spec binary(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, binary()} + def binary(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_binary(value) -> @@ -43,6 +59,9 @@ defmodule DataSpecs.Loaders do end end + @spec pid(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, pid()} + def pid(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_pid(value) -> @@ -53,6 +72,9 @@ defmodule DataSpecs.Loaders do end end + @spec reference(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, reference()} + def reference(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_reference(value) -> @@ -63,6 +85,9 @@ defmodule DataSpecs.Loaders do end end + @spec number(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, number()} + def number(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_number(value) -> @@ -73,6 +98,9 @@ defmodule DataSpecs.Loaders do end end + @spec float(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, float()} + def float(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_number(value) -> @@ -83,6 +111,9 @@ defmodule DataSpecs.Loaders do end end + @spec integer(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, integer()} + def integer(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_integer(value) -> @@ -93,6 +124,9 @@ defmodule DataSpecs.Loaders do end end + @spec neg_integer(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, neg_integer()} + def neg_integer(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_integer(value) and value < 0 -> @@ -103,6 +137,9 @@ defmodule DataSpecs.Loaders do end end + @spec non_neg_integer(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, non_neg_integer()} + def non_neg_integer(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_integer(value) and value >= 0 -> @@ -113,6 +150,9 @@ defmodule DataSpecs.Loaders do end end + @spec pos_integer(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, pos_integer()} + def pos_integer(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_integer(value) and value > 0 -> @@ -123,6 +163,9 @@ defmodule DataSpecs.Loaders do end end + @spec range(integer(), integer(), Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, integer()} + def range(lower, upper, value, _custom_type_loaders, _type_params_loaders) do case value do value when is_integer(value) and lower <= value and value <= upper -> @@ -133,6 +176,9 @@ defmodule DataSpecs.Loaders do end end + @spec union(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, any()} + def union(value, custom_type_loaders, type_params_loaders) do type_params_loaders |> Enum.reduce_while({:error, []}, fn loader, {:error, errors} -> @@ -154,6 +200,9 @@ defmodule DataSpecs.Loaders do end end + @spec empty_list(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, []} + def empty_list(value, _custom_type_loaders, _type_params_loaders) do case value do [] -> @@ -164,6 +213,9 @@ defmodule DataSpecs.Loaders do end end + @spec nonempty_list(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, nonempty_list()} + def nonempty_list(value, custom_type_loaders, type_params_loaders) do case value do [_ | _] -> @@ -174,6 +226,9 @@ defmodule DataSpecs.Loaders do end end + @spec list(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, list()} + def list(value, custom_type_loaders, type_params_loaders) do case value do value when is_list(value) -> @@ -212,6 +267,9 @@ defmodule DataSpecs.Loaders do end end + @spec empty_map(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, %{}} + def empty_map(value, _custom_type_loaders, []) do if value == %{} do {:ok, value} @@ -220,6 +278,9 @@ defmodule DataSpecs.Loaders do end end + @spec map_field_required(map(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, {map(), map(), Types.reason()}} + def map_field_required(map, custom_type_loaders, [type_key_loader, type_value_loader]) do map_field_optional(map, custom_type_loaders, [type_key_loader, type_value_loader]) |> case do @@ -234,6 +295,9 @@ defmodule DataSpecs.Loaders do end end + @spec map_field_optional(map(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, {map(), map(), Types.reason()}} + def map_field_optional(map, custom_type_loaders, [type_key_loader, type_value_loader]) do case map do map when is_struct(map) -> @@ -262,7 +326,9 @@ defmodule DataSpecs.Loaders do end end - @spec tuple_any(any, any, any) :: {:error, [<<_::64, _::_*8>>, ...]} | {:ok, tuple} + @spec tuple_any(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, tuple()} + def tuple_any(value, _custom_type_loaders, _type_params_loaders) do case value do value when is_tuple(value) -> @@ -273,6 +339,9 @@ defmodule DataSpecs.Loaders do end end + @spec tuple(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, tuple()} + def tuple(value, custom_type_loaders, type_params_loaders) do tuple_type_size = length(type_params_loaders) diff --git a/lib/dataspecs/loaders/extra.ex b/lib/dataspecs/loaders/extra.ex new file mode 100644 index 0000000..1680b94 --- /dev/null +++ b/lib/dataspecs/loaders/extra.ex @@ -0,0 +1,53 @@ +defmodule DataSpecs.Loaders.Extra do + @moduledoc """ + Type loaders for Elixir types + """ + + alias DataSpecs.{Loaders, Types} + + @spec mapset(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, MapSet.t()} + + @doc """ + Type loader for Elixir MapSet.t(T). + Expect an Enumarable value of type T, returns a MapSet.t(T). + """ + def mapset(value, custom_type_loaders, [type_params_loader]) do + case Enumerable.impl_for(value) do + nil -> + {:error, ["can't convert #{inspect(value)} to a MapSet.t/1, value not enumerable"]} + + _ -> + value + |> Enum.to_list() + |> Loaders.list(custom_type_loaders, [type_params_loader]) + |> case do + {:ok, loaded_value} -> + {:ok, MapSet.new(loaded_value)} + + {:error, errors} -> + {:error, ["can't convert #{inspect(value)} to a MapSet.t/1", errors]} + end + end + end + + @spec isodatetime(Types.value(), Types.custom_type_loaders(), [Types.type_loader_fun()]) :: + {:error, Types.reason()} | {:ok, DateTime.t()} + + @doc """ + Type loader for Elixir DateTime.t(). + Expect an iso8601 datetime string value, returns a DateTime.t(). + """ + def isodatetime(value, _custom_type_loaders, []) do + with {:is_binary, true} <- {:is_binary, is_binary(value)}, + {:from_iso8601, {:ok, datetime, _}} <- {:from_iso8601, DateTime.from_iso8601(value)} do + {:ok, datetime} + else + {:is_binary, false} -> + {:error, ["can't convert #{inspect(value)} to a DateTime.t/0"]} + + {:from_iso8601, {:error, reason}} -> + {:error, ["can't convert #{inspect(value)} to a DateTime.t/0 (#{inspect(reason)})"]} + end + end +end diff --git a/lib/dataspecs/types.ex b/lib/dataspecs/types.ex new file mode 100644 index 0000000..5ea96e1 --- /dev/null +++ b/lib/dataspecs/types.ex @@ -0,0 +1,13 @@ +defmodule DataSpecs.Types do + @moduledoc """ + Common types + """ + + @type value() :: any() + @type reason :: [String.t() | reason()] + @type type_id :: atom() + @type type_ref :: {module(), type_id()} + @type custom_type_ref :: {module(), type_id(), arity()} + @type type_loader_fun :: (value(), custom_type_loaders(), [type_loader_fun] -> value()) + @type custom_type_loaders :: %{custom_type_ref() => type_loader_fun()} +end diff --git a/test/dataspec_test.exs b/test/dataspec_test.exs index 6ea1ac2..e14d917 100644 --- a/test/dataspec_test.exs +++ b/test/dataspec_test.exs @@ -382,20 +382,75 @@ defmodule Test.DataSpecs do custom_type_loaders = %{ {@types_module, :t_opaque, 1} => &CustomLoader.opaque/3, - {MapSet, :t, 1} => &CustomLoader.mapset/3, - {DateTime, :t, 0} => &CustomLoader.isodatetime/3 + {MapSet, :t, 1} => &Loaders.Extra.mapset/3 } assert {:ok, {:custom_opaque, 1}} == DataSpecs.load(1, {@types_module, :t_opaque}, custom_type_loaders, [integer]) + assert {:ok, MapSet.new(1..3)} == DataSpecs.load(1..3, {@types_module, :t_mapset}, custom_type_loaders) + + assert {:ok, MapSet.new(["1", :a, 1])} == + DataSpecs.load(["1", :a, 1], {@types_module, :t_mapset_1}, custom_type_loaders) + end + end + + describe "extra loaders" do + test "isodatetime" do + custom_type_loaders = %{ + {DateTime, :t, 0} => &Loaders.Extra.isodatetime/3 + } + datetime = ~U[2021-07-14 20:22:49.653077Z] iso_datetime_string = DateTime.to_iso8601(datetime) + + assert {:ok, datetime} == DataSpecs.load(iso_datetime_string, {@types_module, :t_datetime}, custom_type_loaders) + end + + test "isodatetime error" do + custom_type_loaders = %{ + {DateTime, :t, 0} => &Loaders.Extra.isodatetime/3 + } + + value = "not a datetime" + + assert {:error, ["can't convert \"#{value}\" to a DateTime.t/0 (:invalid_format)"]} == + DataSpecs.load(value, {@types_module, :t_datetime}, custom_type_loaders) + + value = 123 + + assert {:error, ["can't convert #{value} to a DateTime.t/0"]} == + DataSpecs.load(value, {@types_module, :t_datetime}, custom_type_loaders) + end + + test "mapset" do + custom_type_loaders = %{ + {MapSet, :t, 1} => &Loaders.Extra.mapset/3 + } + assert {:ok, MapSet.new(1..3)} == DataSpecs.load(1..3, {@types_module, :t_mapset}, custom_type_loaders) assert {:ok, MapSet.new(["1", :a, 1])} == DataSpecs.load(["1", :a, 1], {@types_module, :t_mapset_1}, custom_type_loaders) + end - assert {:ok, datetime} == DataSpecs.load(iso_datetime_string, {@types_module, :t_datetime}, custom_type_loaders) + test "mapset error" do + custom_type_loaders = %{ + {MapSet, :t, 1} => &Loaders.Extra.mapset/3 + } + + value = 123 + + assert {:error, ["can't convert #{value} to a MapSet.t/1, value not enumerable"]} == + DataSpecs.load(value, {@types_module, :t_mapset}, custom_type_loaders) + + value = [%{}] + + assert {:error, + [ + "can't convert #{inspect(value)} to a MapSet.t/1", + ["can't convert #{inspect(value)} to a list, bad item at index=0", ["can't convert %{} to an integer"]] + ]} == + DataSpecs.load(value, {@types_module, :t_mapset}, custom_type_loaders) end end @@ -405,8 +460,6 @@ defmodule Test.DataSpecs do end defmodule Test.DataSpecs.CustomLoader do - alias DataSpecs.Loaders - def opaque(value, custom_type_loaders, [type_params_loader]) do type_params_loader.(value, custom_type_loaders, []) |> case do @@ -417,36 +470,4 @@ defmodule Test.DataSpecs.CustomLoader do error end end - - def mapset(value, custom_type_loaders, [type_params_loader]) do - case Enumerable.impl_for(value) do - nil -> - {:error, ["can't convert #{inspect(value)} to a MapSet.t/1, value not enumerable"]} - - _ -> - value - |> Enum.to_list() - |> Loaders.list(custom_type_loaders, [type_params_loader]) - |> case do - {:ok, loaded_value} -> - {:ok, MapSet.new(loaded_value)} - - {:error, errors} -> - {:error, ["can't convert #{inspect(value)} to a MapSet.t/1", errors]} - end - end - end - - def isodatetime(value, _custom_type_loaders, []) do - with {:is_binary, true} <- {:is_binary, is_binary(value)}, - {:from_iso8601, {:ok, datetime, _}} <- {:from_iso8601, DateTime.from_iso8601(value)} do - {:ok, datetime} - else - {:is_binary, false} -> - {:error, ["can't convert #{inspect(value)} to a DateTime.t/0"]} - - {:from_iso8601, {:error, reason}} -> - {:error, ["can't convert #{inspect(value)} to a DateTime.t/0 (#{inspect(reason)})"]} - end - end end