diff --git a/.gitignore b/.gitignore index 96ed849..4237bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# VSCode dir +/.vscode/ + # The directory Mix will write compiled artifacts to. /_build/ diff --git a/lib/spect.ex b/lib/spect.ex index d54a774..ca4ce54 100644 --- a/lib/spect.ex +++ b/lib/spect.ex @@ -18,9 +18,9 @@ defmodule Spect do This function converts a data structure into a new one derived from a type specification. This provides for the effective decoding of (nested) data - structures from serialization formats that do not support Elixir's rich - set of types (JSON, etc.). Atoms can be decoded from strings, tuples from - lists, structs from maps, etc. + structures from serialization formats that do not support Elixir's rich set of + types (JSON, etc.). Atoms can be decoded from strings, tuples from lists, + structs from maps, etc. `data` is the data structure to decode, `module` is the name of the module containing the type specification, and `name` is the name of the @type @@ -28,8 +28,8 @@ defmodule Spect do ## Examples - As mentioned above, a common use case is to decode a JSON document into - an Elixir struct, for example using the `Poison` parser: + As mentioned above, a common use case is to decode a JSON document into an + Elixir struct, for example using the `Poison` parser: ```elixir "test.json" |> File.read!() @@ -64,11 +64,11 @@ defmodule Spect do end ``` - The conventional name for a module's primary type is `t`, - so that is the default value for `to_spec`'s third argument. However, that - name is not mandatory, and modules can expose more than one type, - so `to_spec` will accept any atom as a third argument and attempt to find a - type with that name. Continuing with the above example: + The conventional name for a module's primary type is `t`, so that is the + default value for `to_spec`'s third argument. However, that name is not + mandatory, and modules can expose more than one type, so `to_spec` will accept + any atom as a third argument and attempt to find a type with that name. + Continuing with the above example: ```elixir iex> data = %{"film" => "Amadeus", "lead?" => true} %{"film" => "Amadeus", "lead?" => true} @@ -77,10 +77,10 @@ defmodule Spect do {:ok, %{film: "Amadeus", lead?: true}} ``` - If any of the nested fields in the typespec is declared as a `DateTime.t()`, - `to_spec` will convert the value only if it is an - [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) string or already - a `DateTime` struct. + If any of the nested fields in the typespec is declared as a `Date.t()`, + `NaiveDateTime.t()`, or `DateTime.t()`, `to_spec` will convert the value only + if it is an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) string or + already a `Date` / `NaiveDateTime` / `DateTime` struct. """ @spec to_spec(data :: any, module :: atom, name :: atom) :: {:ok, any} | {:error, any} @@ -129,11 +129,19 @@ defmodule Spect do defp to_kind!(data, module, {:remote_type, _line, type}, _params) do [{:atom, _, remote_module}, {:atom, _, name}, args] = type - if remote_module == DateTime and name == :t do - to_datetime!(data) - else - params = Enum.map(args, &{module, &1}) - to_spec!(data, remote_module, name, params) + cond do + remote_module == Date and name == :t -> + to_date!(data) + + remote_module == NaiveDateTime and name == :t -> + to_naivedatetime!(data) + + remote_module == DateTime and name == :t -> + to_datetime!(data) + + true -> + params = Enum.map(args, &{module, &1}) + to_spec!(data, remote_module, name, params) end end @@ -526,6 +534,56 @@ defmodule Spect do # miscellaneous types # ------------------------------------------------------------------------- + defp to_date!(data) do + cond do + is_binary(data) -> + case Date.from_iso8601(data) do + {:ok, dt} -> + dt + + {:error, reason} -> + raise( + ConvertError, + "invalid string format for Date: #{reason}" + ) + end + + is_map(data) and data.__struct__ == Date -> + data + + true -> + raise( + ConvertError, + "expected ISO8601 string or Date struct, found: #{inspect(data)}" + ) + end + end + + defp to_naivedatetime!(data) do + cond do + is_binary(data) -> + case NaiveDateTime.from_iso8601(data) do + {:ok, dt} -> + dt + + {:error, reason} -> + raise( + ConvertError, + "invalid string format for NaiveDateTime: #{reason}" + ) + end + + is_map(data) and data.__struct__ == NaiveDateTime -> + data + + true -> + raise( + ConvertError, + "expected ISO8601 string or NaiveDateTime struct, found: #{inspect(data)}" + ) + end + end + defp to_datetime!(data) do cond do is_binary(data) -> diff --git a/test/spect_test.exs b/test/spect_test.exs index 4ea4407..442cc39 100644 --- a/test/spect_test.exs +++ b/test/spect_test.exs @@ -183,6 +183,31 @@ defmodule Spect.Test do {:error, %ConvertError{}} = to_spec("NonExistent", Specs, :module_test) end + test "dates" do + {:error, %ConvertError{}} = to_spec("non_dt_str", Specs, :date_test) + + {:error, %ConvertError{}} = to_spec(1, Specs, :date_test) + + today = Date.utc_today() + expect = {:ok, today} + + assert to_spec(to_string(today), Specs, :date_test) == expect + assert to_spec(today, Specs, :date_test) == expect + end + + test "naive datetimes" do + {:error, %ConvertError{}} = + to_spec("non_dt_str", Specs, :naivedatetime_test) + + {:error, %ConvertError{}} = to_spec(1, Specs, :naivedatetime_test) + + now = NaiveDateTime.utc_now() + expect = {:ok, now} + + assert to_spec(to_string(now), Specs, :naivedatetime_test) == expect + assert to_spec(now, Specs, :naivedatetime_test) == expect + end + test "datetimes" do {:error, %ConvertError{}} = to_spec("non_dt_str", Specs, :datetime_test) diff --git a/test/support/specs.ex b/test/support/specs.ex index 1ee4b03..3ef9e1a 100644 --- a/test/support/specs.ex +++ b/test/support/specs.ex @@ -40,6 +40,8 @@ defmodule Spect.Support.Specs do optional(:key3) => integer() } + @type date_test :: Date.t() + @type naivedatetime_test :: NaiveDateTime.t() @type datetime_test :: DateTime.t() @type module_test :: module()