defmodule Xema.Builder do @moduledoc """ This module contains some convenience functions to generate schemas. Beside the type-functions the module contains the combinator-functions `all_of/2`, `any_of/2` and `one_of/2`. ## Examples iex> import Xema.Builder ...> schema = Xema.new integer(minimum: 1) ...> Xema.valid?(schema, 6) true ...> Xema.valid?(schema, 0) false """ alias Xema.{CastError, Schema, ValidationError} @types Xema.Schema.types() @types |> Enum.filter(fn x -> x not in [nil, true, false, :struct] end) |> Enum.each(fn fun -> @doc """ Returns :#{fun}. """ @spec unquote(fun)() :: unquote(fun) def unquote(fun)() do unquote(fun) end @doc """ Returns a tuple of `:#{fun}` and the given keyword list. ## Examples iex> Xema.Builder.#{fun}(key: 42) {:#{fun}, key: 42} """ @spec unquote(fun)(keyword) :: {unquote(fun), keyword} def unquote(fun)(keywords) when is_list(keywords) do {unquote(fun), keywords} end end) @xema_deprecated_message """ Xema.Builder.xema/1 with :field arguments is deprecated. Use Xema.Builder.xema_struct/1 to define a struct instead. Example: defmoudle MyApp.User do use Xema xema_struct do field :name, :string end end """ @doc """ Returns a tuple with the given `type` (default `:any`) and the given schemas tagged by the keyword `:any_of`. This function provides a shortcut for something like `integer(any_of: [...])` or `any(any_of: [...])`. ## Examples iex> Xema.Builder.any_of([:integer, :string]) {:any, any_of: [:integer, :string] } iex> Xema.Builder.any_of(:integer, [[minimum: 10], [maximum: 5]]) {:integer, any_of: [[minimum: 10], [maximum: 5]]} ```elixir defmodule MySchema do use Xema xema do any_of [ list(items: integer(minimum: 1, maximum: 66)), list(items: integer(minimum: 33, maximum: 100)) ] end end MySchema.valid?([20, 30]) #=> true MySchema.valid?([40, 50]) #=> true MySchema.valid?([60, 70]) #=> true MySchema.valid?([10, 90]) #=> false ``` """ @spec any_of(type, [schema]) :: {type, any_of: [schema]} when type: Schema.type(), schema: Schema.t() | Schema.type() | tuple | atom | keyword def any_of(type \\ :any, schemas) when type in @types and is_list(schemas) do {type, any_of: schemas} end @doc """ Returns a tuple with the given `type` (default `:any`) and the given schemas tagged by the keyword `:all_of`. This function provides a shortcut for something like `integer(all_of: [...])` or `any(all_of: [...])`. ## Examples iex> Xema.Builder.all_of([:integer, :string]) {:any, all_of: [:integer, :string] } iex> Xema.Builder.all_of(:integer, [[minimum: 10], [maximum: 5]]) {:integer, all_of: [[minimum: 10], [maximum: 5]]} ```elixir defmodule MySchema do use Xema xema do all_of [ list(items: integer(minimum: 1, maximum: 66)), list(items: integer(minimum: 33, maximum: 100)) ] end end MySchema.valid?([20, 30]) #=> false MySchema.valid?([40, 50]) #=> true MySchema.valid?([60, 70]) #=> false MySchema.valid?([10, 90]) #=> false ``` """ @spec all_of(type, [schema]) :: {type, all_of: [schema]} when type: Schema.type(), schema: Schema.t() | Schema.type() | tuple | atom | keyword def all_of(type \\ :any, schemas) when type in @types and is_list(schemas) do {type, all_of: schemas} end @doc """ Returns a tuple with the given `type` (default `:any`) and the given schemas tagged by the keyword `:one_of`. This function provides a shortcut for something like `integer(one_of: [...])` or `any(one_of: [...])`. ## Examples iex> Xema.Builder.one_of([:integer, :string]) {:any, one_of: [:integer, :string] } iex> Xema.Builder.one_of(:integer, [[minimum: 10], [maximum: 5]]) {:integer, one_of: [[minimum: 10], [maximum: 5]]} ```elixir defmodule MySchema do use Xema xema do one_of [ list(items: integer(minimum: 1, maximum: 66)), list(items: integer(minimum: 33, maximum: 100)) ] end end MySchema.valid?([20, 30]) #=> true MySchema.valid?([40, 50]) #=> false MySchema.valid?([60, 70]) #=> true MySchema.valid?([10, 90]) #=> false ``` """ @spec one_of(type, [schema]) :: {type, one_of: [schema]} when type: Schema.type(), schema: Schema.t() | Schema.type() | tuple | atom | keyword def one_of(type \\ :any, schemas) when type in @types and is_list(schemas) do {type, one_of: schemas} end @doc """ Returns the tuple `{:ref, ref}`. """ def ref(ref) when is_binary(ref), do: {:ref, ref} @doc """ Returns `:struct`. """ @spec strux :: :struct def strux, do: :struct @doc """ Returns a tuple of `:struct` and the given keyword list when the function gets a keyword list. Returns the tuple `{:struct, module: module}` when the function gets an atom. """ @spec strux(keyword) :: {:struct, keyword} def strux(keywords) when is_list(keywords), do: {:struct, keywords} @spec strux(atom) :: {:struct, module: module} def strux(module) when is_atom(module), do: strux(module: module) def strux(module, keywords) when is_atom(module), do: keywords |> Keyword.put(:module, module) |> strux() @doc """ Creates a `schema`. """ defmacro xema(do: {:field, _context, _args} = schema) do warn(@xema_deprecated_message, __CALLER__) do_xema(schema) end defmacro xema(do: {:__block__, _context_1, [{:field, _context_2, _args} | _ast]} = schema) do warn(@xema_deprecated_message, __CALLER__) do_xema(schema) end defmacro xema(do: schema) do do_xema(schema) end defp do_xema(schema) do quote do xema :__xema_default_schema__ do unquote(schema) end end end @doc """ Creates a `schema` with the given name. """ defmacro xema(name, do: {:filed, _context, _args} = schema) do unless name == :__xema_default_schema__, do: warn(@xema_deprecated_message, __CALLER__) do_xema(name, schema) end defmacro xema( name, do: {:__block__, _context_1, [{:field, _context_2, _args} | _ast]} = schema ) do unless name == :__xema_default_schema__, do: warn(@xema_deprecated_message, __CALLER__) do_xema(name, schema) end defmacro xema(name, do: schema) do do_xema(name, schema) end defp do_xema(name, schema) do schema = dep_xema_struct(schema) quote do Module.register_attribute(__MODULE__, :xemas, accumulate: true) multi = Module.get_attribute(__MODULE__, :multi) if Module.get_attribute(__MODULE__, :default) == true do message = """ The attribute `@default true` to mark a schema as default is deprecated. Found in module #{inspect(__MODULE__)} for xema #{inspect(unquote(name))}. To set a default add the option `:default` to `use Xema`. Example: defmoudle MyApp.Schemas do use Xema, multi: true, default: :s1 xema :s1 do ... end xema :s2 do ... end end """ IO.warn(IO.ANSI.format([IO.ANSI.yellow(), "#{message}"]), []) Module.delete_attribute(__MODULE__, :default) end default = Module.get_attribute(__MODULE__, :__xema_default__) if multi == nil do raise "Use `use Xema` to use the `xema/2` macro." end if !multi && length(@xemas) > 0 do raise "Use `use Xema, multi: true` to setup multiple schema in a module." end Module.put_attribute( __MODULE__, :xemas, {unquote(name), Xema.new(add_new_module(unquote(schema), __MODULE__))} ) if multi do if length(@xemas) == 1 do if default == nil do unquote(xema_funs(:header_without_default)) else unquote(xema_funs(:header_with_default)) end end if unquote(name) == default do unquote(xema_funs(:default, name)) end unquote(xema_funs(:by_name, name)) else if unquote(name) == :__xema_default_schema__ do unquote(xema_funs(:single, name)) else unquote(xema_funs(:header_with_default)) unquote(xema_funs(:default, name)) unquote(xema_funs(:by_name, name)) end end end end @doc """ Creates a `schema` and defines the corresponding struct. """ defmacro xema_struct(do: schema) do do_xema(schema) end defp xema_funs(:header_with_default) do quote do @doc """ Returns true if the specified `data` is valid against the schema defined under `name`, otherwise false. """ @spec valid?(atom, term) :: boolean def valid?(name \\ :default, data) @doc """ Validates the given `data` against the schema defined under `name`. Returns `:ok` for valid data, otherwise an `:error` tuple. """ @spec validate(atom, term) :: :ok | {:error, ValidationError.t()} def validate(name \\ :default, data) @doc """ Validates the given `data` against the schema defined under `name`. Returns `:ok` for valid data, otherwise a `Xema.ValidationError` is raised. """ @spec validate!(atom, term) :: :ok def validate!(name \\ :default, data) @doc """ Converts the given `data` according to the schema defined under `name`. Returns an `:ok` tuple with the converted data for valid `data`, otherwise an `:error` tuple is returned. """ @spec cast(atom, term) :: {:ok, term} | {:error, ValidationError.t() | CastError.t()} def cast(name \\ :default, data) @doc """ Converts the given `data` according to the schema defined under `name`. Returns converted data for valid `data`, otherwise a `Xema.CastError` or `Xema.ValidationError` is raised. """ @spec cast!(atom, term) :: {:ok, term} def cast!(name \\ :default, data) @doc false def xema(name \\ :default) end end defp xema_funs(:header_without_default) do quote do @doc """ Returns true if the specified `data` is valid against the schema defined under `name`, otherwise false. """ @spec valid?(atom, term) :: boolean def valid?(name, data) @doc """ Validates the given `data` against the schema defined under `name`. Returns `:ok` for valid data, otherwise an `:error` tuple. """ @spec validate(atom, term) :: :ok | {:error, ValidationError.t()} def validate(name, data) @doc """ Validates the given `data` against the schema defined under `name`. Returns `:ok` for valid data, otherwise a `Xema.ValidationError` is raised. """ @spec validate!(atom, term) :: :ok def validate!(name, data) @doc """ Converts the given `data` according to the schema defined under `name`. Returns an `:ok` tuple with the converted data for valid `data`, otherwise an `:error` tuple is returned. """ @spec cast(atom, term) :: {:ok, term} | {:error, ValidationError.t() | CastError.t()} def cast(name, data) @doc """ Converts the given `data` according to the schema defined under `name`. Returns converted data for valid `data`, otherwise a `Xema.CastError` or `Xema.ValidationError` is raised. """ @spec cast!(atom, term) :: {:ok, term} def cast!(name, data) @doc false def xema(name) end end defp xema_funs(:by_name, name) do quote do def valid?(unquote(name), data), do: Xema.valid?(@xemas[unquote(name)], data) def validate(unquote(name), data), do: Xema.validate(@xemas[unquote(name)], data) def validate!(unquote(name), data), do: Xema.validate!(@xemas[unquote(name)], data) def cast(unquote(name), data), do: Xema.cast(@xemas[unquote(name)], data) def cast!(unquote(name), data), do: Xema.cast!(@xemas[unquote(name)], data) @doc false def xema(unquote(name)), do: @xemas[unquote(name)] end end defp xema_funs(:default, name) do quote do def valid?(:default, data), do: Xema.valid?(@xemas[unquote(name)], data) def validate(:default, data), do: Xema.validate(@xemas[unquote(name)], data) def validate!(:default, data), do: Xema.validate!(@xemas[unquote(name)], data) def cast(:default, data), do: Xema.cast(@xemas[unquote(name)], data) def cast!(:default, data), do: Xema.cast!(@xemas[unquote(name)], data) @doc false def xema(:default), do: @xemas[unquote(name)] end end defp xema_funs(:single, name) do quote do @doc """ Returns true if the given `data` valid against the defined schema, otherwise false. """ @spec valid?(term) :: boolean def valid?(data), do: Xema.valid?(@xemas[unquote(name)], data) @doc """ Validates the given `data` against the defined schema. Returns `:ok` for valid data, otherwise an `:error` tuple. """ @spec validate(term) :: :ok | {:error, ValidationError.t()} def validate(data), do: Xema.validate(@xemas[unquote(name)], data) @doc """ Validates the given `data` against the defined schema. Returns `:ok` for valid data, otherwise a `Xema.ValidationError` is raised. """ @spec validate!(term) :: :ok def validate!(data), do: Xema.validate!(@xemas[unquote(name)], data) @doc """ Converts the given `data` according to the defined schema. Returns an `:ok` tuple with the converted data for valid `data`, otherwise an `:error` tuple is returned. """ @spec cast(term) :: {:ok, term} | {:error, ValidationError.t() | CastError.t()} def cast(data), do: Xema.cast(@xemas[unquote(name)], data) @doc """ Converts the given `data` according to the defined schema. Returns converted data for valid `data`, otherwise a `Xeam.CastError` or `Xema.ValidationError` is raised. """ @spec cast!(term) :: term def cast!(data), do: Xema.cast!(@xemas[unquote(name)], data) @doc false def xema, do: @xemas[unquote(name)] end end defp dep_xema_struct({:field, _context, _args} = data), do: do_dep_xema_struct([data]) defp dep_xema_struct({:__block__, _context, data}), do: do_dep_xema_struct(data) defp dep_xema_struct(data), do: data defp do_dep_xema_struct(data) do data = data |> Enum.group_by(fn {name, _, _} when name in [:required, :field] -> name _ -> :rest end) |> Map.put_new(:field, []) |> Map.put_new(:required, nil) |> Map.put_new(:rest, nil) quote do unquote(data.rest) defstruct unquote(Enum.map(data.field, &xema_field_name/1)) {:struct, [ properties: Map.new(unquote(Enum.map(data.field, &xema_field/1))), keys: :atoms ] |> Keyword.merge(unquote(xema_required(data.required)))} end end defp xema_field({:field, _context, [name | _]} = field) do quote do {unquote(name), unquote(field)} end end defp xema_field_name({:field, _context, [name, _type, opts]}) do default = Keyword.get(opts, :default, nil) quote do unquote({name, default}) end end defp xema_field_name({:field, _context, [name | _]}) do quote do unquote({name, nil}) end end @doc """ Specifies a field. This function will be used inside `xema/0`. Arguments: + `name`: the name of the field. + `type`: the type of the field. The `type` can also be a `struct` or another schema. + `opts`: the rules for the field. ## Examples iex> defmodule User do ...> use Xema ...> ...> xema_struct do ...> field :name, :string, min_length: 1 ...> end ...> end ...> iex> %{"name" => "Tim"} |> User.cast!() |> Map.from_struct() %{name: "Tim"} For more examples see "[Examples: Struct](examples.html#struct)". """ @spec field(atom, Schema.type() | module, keyword) :: {atom() | [atom()], keyword()} def field(name, type, opts \\ []) do case check_field_type!(name, type) do {:xema, module} -> allow(module.xema(), opts) {:module, module} -> allow(:struct, Keyword.put(opts, :module, module)) {:type, type} -> allow(type, opts) end end defp allow(type, opts) when is_atom(type) do case Keyword.has_key?(opts, :allow) do true -> allow([type], opts) false -> {type, opts} end end defp allow(types, opts) when is_list(types) do case Keyword.pop(opts, :allow, :undefined) do {:undefined, opts} -> {types, opts} {value, opts} when is_list(value) -> {Enum.concat(types, value), opts} {value, opts} -> {[value | types], opts} end end defp allow(%Xema{schema: schema} = xema, opts) do schema = case Keyword.get(opts, :allow, :undefined) do :undefined -> schema value -> update_allow(schema, value) end %Xema{xema | schema: schema} end defp update_allow(schema, types) when is_list(types) do Map.update!(schema, :type, fn type -> [type | types] end) end defp update_allow(schema, type), do: update_allow(schema, [type]) defp check_field_type!(field, types) when is_list(types) do Enum.each(types, fn type -> check_field_type!(field, type) end) {:type, types} end defp check_field_type!(_field, type) when type in @types, do: {:type, type} defp check_field_type!(_field, module) when is_atom(module) do case Xema.behaviour?(module) do true -> {:xema, module} false -> {:module, module} end end defp check_field_type!(field, type), do: raise(ArgumentError, "invalid type #{inspect(type)} for field #{inspect(field)}") defp xema_required([required]) do quote do unquote(required) end end defp xema_required(nil) do quote do: [] end defp xema_required(_) do raise ArgumentError, "the required function can only be called once per xema" end @doc """ Sets the list of required fields. Specifies a field. This function will be used inside `xema/0`. ## Examples iex> defmodule Person do ...> use Xema ...> ...> xema_struct do ...> field :name, :string, min_length: 1 ...> required [:name] ...> end ...> end ...> iex> %{"name" => "Tim"} |> Person.cast!() |> Map.from_struct() %{name: "Tim"} """ @spec required([atom]) :: term def required(fields), do: [required: fields] @doc false def add_new_module({:struct, keywords}, module), do: {:struct, Keyword.put_new(keywords, :module, module)} def add_new_module(schema, _module), do: schema defp warn(message, %{file: file, line: line}) do IO.warn(IO.ANSI.format([IO.ANSI.yellow(), "#{message}\nat: #{file}:#{line}"]), []) :ok end end