Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# VSCode dir
/.vscode/

# The directory Mix will write compiled artifacts to.
/_build/

Expand Down
96 changes: 77 additions & 19 deletions lib/spect.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@ 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
definition within the module (defaults to `:t`).

## 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!()
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) ->
Expand Down
25 changes: 25 additions & 0 deletions test/spect_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions test/support/specs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down