Current section
Files
Jump to
Current section
Files
lib/nldoc/spec/schema.ex
defmodule NLdoc.Spec.Schema do
@moduledoc """
This module sets some defaults over the Ecto schema for the NLdoc spec.
1. Use this in all schemas for the NLdoc spec, instead of `use Ecto.Schema` or `use TypedEctoSchema`:
`use NLdoc.Spec.Schema, type: "https://spec.nldoc.nl/Resource/Foo"`
2. Implement the `changeset/2` function in the schema module.
3. Make sure to add `@cast_opts` to all calls to `cast/4` or `cast_embed/3` or `cast_polymorphic_embed/3`,
as it ensures strings that only contain whitespace are accepted as valid.
Note: the `type` option is required and must be a string. It is the resource type of the schema.
It will be available as `@resource_type` in the schema module and through the `resource_type/0` function on the schema module.
"""
defmacro __using__(opts) do
# coveralls-ignore-next-line This is a macro, so it is only executed at compile-time.
{opts, ecto_opts} = parse_opts(opts)
quote location: :keep do
use TypedEctoSchema, unquote(ecto_opts)
import Ecto.Changeset
import PolymorphicEmbed
import NLdoc.Spec.Schema, only: [map: 1, validate_url: 1]
@primary_key false
# This function must be implemented in the using module.
@spec changeset(struct(), map()) :: Ecto.Changeset.t()
# This must be passed to all calls to cast/4, as it ensures strings
# that only contain whitespace are accepted as valid.
@cast_opts [empty_values: []]
# This automatically makes all schemas JSON-serializable.
@derive Jason.Encoder
# This makes the `type` argument available inside and outside of the schema module.
@resource_type unquote(opts[:type])
def resource_type, do: @resource_type
# This defines the constructor functions for the schema module.
@before_compile {NLdoc.Spec.Schema, :def_constructor}
@doc """
This validation function can be used in `changeset/2` implementations to ensure that
the value for a specific field is a valid URL.
## Options
* `:message` - The error message to use when the URL is invalid.
* `:allow_relative` - If set to `true`, relative URLs are allowed. Default is `false`.
"""
@type validate_url_opt() :: {:message, String.t()} | {:allow_relative, boolean()}
@spec validate_url(Ecto.Changeset.t(), atom(), [validate_url_opt()]) :: Ecto.Changeset.t()
def validate_url(changeset, field, opts \\ []) do
validate_change(changeset, field, fn ^field, url ->
case NLdoc.Spec.Schema.validate_url(url, Keyword.drop(opts, [:message])) do
nil -> []
error -> [{field, Keyword.get(opts, :message, error)}]
end
end)
end
end
end
defp parse_opts(opts) do
# coveralls-ignore-start This is part of a macro, so it is only executed at compile-time.
if not is_nil(opts[:type]) and (!is_binary(opts[:type]) or opts[:type] == "") do
raise """
\n
A #{IO.ANSI.yellow()}type#{IO.ANSI.reset()} option must be passed to #{IO.ANSI.yellow()}use NLdoc.Spec.Schema#{IO.ANSI.reset()}.
Example: #{IO.ANSI.yellow()}use NLdoc.Spec.Schema, type: "https://spec.nldoc.nl/Resource/Foo"
#{IO.ANSI.reset()}
"""
end
ecto_opts = Keyword.delete(opts, :type)
{opts, ecto_opts}
# coveralls-ignore-stop
end
@doc """
This macro defines the `new/1` and `new!/1` functions for the schema module that create a new document from a template,
which essentially functions like an input-validating constructor for the struct defined by the schema module.
"""
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defmacro def_constructor(_) do
# coveralls-ignore-next-line This is part of a macro, so it is only executed at compile-time.
full_module_name =
quote do
__MODULE__ |> to_string() |> String.split(".") |> List.delete_at(0) |> Enum.join(".")
end
# coveralls-ignore-next-line This is part of a macro, so it is only executed at compile-time.
module_name =
quote do
__MODULE__ |> to_string() |> String.split(".") |> List.last()
end
quote location: :keep,
bind_quoted: [full_module_name: full_module_name, module_name: module_name] do
@doc """
This function creates a new `#{full_module_name}` struct from a map of keys and values, where the keys are snake_case.
Returns a tuple with either `{:ok, #{Macro.underscore(module_name)}}` or `{:error, changeset}`.
Note: If you want to use a map with camelCase keys, use `NLdoc.Util.Recase.to_snake/1`
to convert them to snake_case before passing them to the constructor.
"""
@spec new() :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
@spec new(map()) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
def new(template \\ %{type: @resource_type})
def new(template = %{type: @resource_type}) do
changeset = changeset(%__MODULE__{}, template)
if changeset.valid? do
document = Ecto.Changeset.apply_changes(changeset)
{:ok, document}
else
{:error, changeset}
end
end
def new(template = %{"type" => @resource_type}) do
changeset = changeset(%__MODULE__{}, template)
if changeset.valid? do
document = Ecto.Changeset.apply_changes(changeset)
{:ok, document}
else
{:error, changeset}
end
end
def new(template = %{type: type}) when is_nil(type) do
{:error, %Ecto.Changeset{valid?: false, errors: [type: {"is missing", [type: type]}]}}
end
def new(template = %{type: type}) do
{:error, %Ecto.Changeset{valid?: false, errors: [type: {"is invalid", [type: type]}]}}
end
def new(template = %{"type" => type}) when is_nil(type) do
{:error, %Ecto.Changeset{valid?: false, errors: [type: {"is missing", [type: type]}]}}
end
def new(template = %{"type" => type}) do
{:error, %Ecto.Changeset{valid?: false, errors: [type: {"is invalid", [type: type]}]}}
end
if is_nil(@resource_type) do
def new(template = %{}) do
changeset = changeset(%__MODULE__{}, template)
if changeset.valid? do
document = Ecto.Changeset.apply_changes(changeset)
{:ok, document}
else
{:error, changeset}
end
end
end
def new(template = %{}) do
{:error, %Ecto.Changeset{valid?: false, errors: [type: {"is missing", [type: nil]}]}}
end
def new(template) do
{:error,
%Ecto.Changeset{
valid?: false,
errors: [template: {"is expected to be a map", [actual_type: Useful.typeof(template)]}]
}}
end
@doc """
This function creates a new `#{full_module_name}` struct from a map of keys and values, where the keys are snake_case.
Returns the `#{Macro.underscore(module_name)}}` object or raises an `Ecto.CastError`.
Note: If you want to use a map with camelCase keys, use `NLdoc.Util.Recase.to_snake/1`
to convert them to snake_case before passing them to the constructor.
"""
@spec new!() :: __MODULE__.t()
@spec new!(map()) :: __MODULE__.t()
def new!(template \\ %{type: @resource_type}) do
case new(template) do
{:ok, document} -> document
# TODO: Be more descriptive of what the error is? Something with NLdoc.Spec.Schema.collect_errors/1
{:error, changeset} -> raise Ecto.CastError, type: __MODULE__, value: template
end
end
end
end
@doc """
Returns true if the given module is an existing module.
"""
@spec module_exists?(module()) :: boolean()
def module_exists?(module),
do: Code.ensure_loaded?(module) and function_exported?(module, :__info__, 1)
@doc """
Returns true for a module that is a schema module, i.e. it has a `new/1` and a `changeset/2` function.
"""
@spec schema_module?(module()) :: boolean()
def schema_module?(module) when is_atom(module),
do:
module |> Code.ensure_compiled!() |> Code.ensure_loaded?() and
function_exported?(module, :new, 1) and
function_exported?(module, :changeset, 2)
def schema_module?(_), do: false
@doc """
This macro maps a list of modules to a list of tuples with the module's resource type and the module itself.
It's a macro because Ecto's schema definitions require this mapping to be done at compile time.
Example:
> NLdoc.Schema.map([Heading, Image])
[
"https://spec.nldoc.nl/Resource/Heading": Heading,
"https://spec.nldoc.nl/Resource/Image": Image
]
"""
defmacro map(modules) do
{resolved_modules, _} = Code.eval_quoted(modules, [], __CALLER__)
for module <- resolved_modules do
quote do
{String.to_atom(unquote(module).resource_type()), unquote(module)}
end
end
end
@doc """
This function validates whether a string is a valid URL, returning nil if it's valid
and an `Ecto.Changeset.error()` if it's not.
## Examples
iex> NLdoc.Spec.Schema.validate_url("http://localhost:4000")
nil
iex> NLdoc.Spec.Schema.validate_url("https://nldoc.nl/path/to/resource?query=string#fragment")
nil
iex> NLdoc.Spec.Schema.validate_url("https://no-tld")
nil
iex> NLdoc.Spec.Schema.validate_url("test")
{"is not a valid URL", error: [invalid_scheme: nil]}
iex> NLdoc.Spec.Schema.validate_url("/path/to/resource")
{"is not a valid URL", error: [invalid_scheme: nil, invalid_host: "/path/to/resource"]}
"""
@type validate_url_opt() :: {:allow_relative, boolean()}
@spec validate_url(String.t(), [validate_url_opt()]) :: Ecto.Changeset.error() | nil
def validate_url(url, opts \\ []) do
case URI.new(url) |> validate_uri(opts) do
[] -> nil
errors -> {"is not a valid URL", error: errors}
end
end
defp validate_uri({:error, invalid_part}, _) do
[{:invalid_part, invalid_part}]
end
defp validate_uri({:ok, %URI{scheme: scheme, host: host, path: path}}, opts) do
allow_relative = opts |> Keyword.get(:allow_relative, false)
if allow_relative do
validate_host(host, path)
else
validate_scheme(scheme) ++ validate_host(host, path)
end
end
defp validate_scheme("http"), do: []
defp validate_scheme("https"), do: []
defp validate_scheme("ftp"), do: []
defp validate_scheme("data"), do: []
defp validate_scheme("mailto"), do: []
defp validate_scheme(scheme), do: [{:invalid_scheme, scheme}]
defp validate_host(host, path \\ nil)
defp validate_host(nil, nil), do: [{:invalid_host, nil}]
defp validate_host(nil, path), do: validate_host(path)
defp validate_host(host = "/" <> _rest, _), do: [{:invalid_host, host}]
defp validate_host("", _), do: [{:invalid_host, ""}]
defp validate_host(_, _), do: []
@type errors() :: %{(property :: atom()) => Ecto.Changeset.error()}
@doc """
Collects all errors from a changeset and its nested changesets into a flat map,
mapping the full JSON path of each property to its error.
## Example
iex> changeset = %Ecto.Changeset{
...> changes: [
...> descriptors: [
...> %Ecto.Changeset{errors: [url: {"is invalid", [validation: :invalid_url]}]}
...> ]
...> ],
...> errors: [
...> name: {"is invalid", [type: :string]}
...> ]
...> }
iex> NLdoc.Spec.Schema.collect_errors(changeset)
%{
name: {"is invalid", [type: :string]},
"descriptors[0].url": {"is invalid", [validation: :invalid_url]}
}
"""
@spec collect_errors(Ecto.Changeset.t()) :: errors()
def collect_errors(changeset = %Ecto.Changeset{}) do
collect_errors(changeset, :"")
|> Map.new(fn {field, error} ->
{field |> Atom.to_string() |> String.trim_leading(".") |> String.to_atom(), error}
end)
end
@spec collect_errors(Ecto.Changeset.t(), parent :: atom() | String.t()) :: errors()
def collect_errors(%Ecto.Changeset{changes: changes, errors: errors}, parent) do
child_errors =
changes
|> Enum.flat_map(fn
{field, changeset} when changeset |> is_struct(Ecto.Changeset) ->
collect_errors(changeset, field)
{field, value} when value |> is_list() ->
collect_errors(value, field)
_ ->
[]
end)
|> Map.new()
errors
|> Map.new()
|> Map.merge(child_errors)
|> Map.new(fn {field, error} -> {:"#{parent}.#{field}", error} end)
end
@spec collect_errors([Ecto.Changeset.t() | any()], parent :: atom() | String.t()) :: errors()
def collect_errors(list, parent) when list |> is_list() do
list
|> Enum.with_index(fn
changeset = %Ecto.Changeset{}, index -> collect_errors(changeset, "#{parent}[#{index}]")
_, _ -> %{}
end)
|> Enum.reduce(&Map.merge/2)
end
end