diff --git a/core/handler/gear_error.ex b/core/handler/gear_error.ex index 1658f235..cb7eb2ff 100644 --- a/core/handler/gear_error.ex +++ b/core/handler/gear_error.ex @@ -55,6 +55,38 @@ defmodule AntikytheraCore.Handler.GearError do ) end + defun parameter_validation_error( + conn :: v[Conn.t()], + parameter_type :: Antikythera.Plug.ParamsValidator.parameter_type_t(), + reason :: AntikytheraCore.BaseParamStruct.validate_error_t() + ) :: v[Conn.t()] do + invoke_error_handler( + conn, + fn mod -> mod.parameter_validation_error(conn, parameter_type, reason) end, + fn -> + resp_body = create_default_parameter_validation_error_message(parameter_type, reason) + %Conn{conn | status: 400, resp_body: resp_body} + end + ) + end + + defunp create_default_parameter_validation_error_message( + parameter_type :: Antikythera.Plug.ParamsValidator.parameter_type_t(), + {error_reason, mods} :: AntikytheraCore.BaseParamStruct.validate_error_t() + ) :: v[String.t()] do + field_path = + Enum.flat_map(mods, fn + {_mod, field_name} -> [field_name] + _mod -> [] + end) + |> Enum.join(".") + + error_reason_message = + Atom.to_string(error_reason) |> String.capitalize() |> String.replace("_", " ") + + "ParameterValidationError: #{error_reason_message}#{if field_path != "", do: " at #{field_path}"} of #{parameter_type}" + end + defunp invoke_error_handler( conn :: v[Conn.t()], invoke_fn :: (module -> Conn.t()), diff --git a/core/type/base_param_struct.ex b/core/type/base_param_struct.ex new file mode 100644 index 00000000..9d817360 --- /dev/null +++ b/core/type/base_param_struct.ex @@ -0,0 +1,386 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule AntikytheraCore.BaseParamStruct do + @moduledoc """ + Base module to define a struct that represents abstract parameters. + """ + + alias Croma.Result, as: R + + @type json_value_t :: + boolean + | number + | String.t() + | [json_value_t] + | %{String.t() => json_value_t} + + @type params_t :: %{(atom | String.t()) => json_value_t} + @type validate_error_reason_t :: :invalid_value | :value_missing + @type validate_error_t :: {validate_error_reason_t, [module | {module, atom}]} + + @typep validatable_t :: term + @typep preprocessor_t :: (nil | json_value_t -> R.t(validatable_t) | validatable_t) + @typep accept_case_t :: :snake | :lower_camel | :upper_camel | :capital + @typep default_value_opt_t :: R.t(term, :no_default_value) + @typep field_option_t :: {:default, term} + @typep mod_with_options_t :: + module + | {module, preprocessor_t} + | {module, [field_option_t]} + | {module, preprocessor_t, [field_option_t]} + @typep field_t :: {atom, mod_with_options_t} + @typep field_with_attr_t :: + {atom, [atom], module, preprocessor_t, default_value_opt_t} + + @doc false + defun attach_attributes_to_field( + {field_name, mod_with_options} :: field_t, + accept_case :: nil | accept_case_t, + pp_generator :: (module -> R.t(term, :no_default_preprocessor)) + ) :: field_with_attr_t do + accepted_field_names = compute_accepted_field_names(field_name, accept_case) + + {mod, preprocessor, default_value_opt} = + extract_mod_and_options(mod_with_options, pp_generator) + + {field_name, accepted_field_names, mod, preprocessor, default_value_opt} + end + + defunp extract_mod_and_options( + mod_with_options :: mod_with_options_t, + pp_generator :: (module -> R.t(term, :no_default_preprocessor)) + ) :: {module, preprocessor_t, default_value_opt_t} do + {mod, preprocessor, [default: default_value]}, _pp_generator + when is_atom(mod) and is_function(preprocessor, 1) -> + {mod, preprocessor, {:ok, default_value}} + + {mod, preprocessor}, _pp_generator when is_atom(mod) and is_function(preprocessor, 1) -> + {mod, preprocessor, get_default_value_from_module(mod)} + + {mod, [default: default_value]}, pp_generator when is_atom(mod) -> + {mod, R.get(pp_generator.(mod), &Function.identity/1), {:ok, default_value}} + + mod, pp_generator when is_atom(mod) -> + {mod, R.get(pp_generator.(mod), &Function.identity/1), get_default_value_from_module(mod)} + end + + defunp get_default_value_from_module(mod :: v[module]) :: default_value_opt_t do + try do + {:ok, mod.default()} + rescue + UndefinedFunctionError -> {:error, :no_default_value} + end + end + + defunp compute_accepted_field_names(field_name :: atom, accept_case :: nil | accept_case_t) :: [ + atom + ] do + field_name, nil when is_atom(field_name) -> + [field_name] + + field_name, accept_case + when is_atom(field_name) and accept_case in [:snake, :lower_camel, :upper_camel, :capital] -> + converter = + case accept_case do + :snake -> &Macro.underscore/1 + :lower_camel -> &lower_camelize/1 + :upper_camel -> &Macro.camelize/1 + :capital -> &String.upcase/1 + end + + # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom + converted_field_name = Atom.to_string(field_name) |> converter.() |> String.to_atom() + Enum.uniq([field_name, converted_field_name]) + end + + defunp lower_camelize(s :: v[String.t()]) :: v[String.t()] do + case Macro.camelize(s) do + "" -> "" + <> <> rest -> String.downcase(<>) <> rest + end + end + + @doc false + defun preprocess_params( + struct_mod :: v[module], + fields_with_attrs :: v[[field_with_attr_t]], + params :: params_t + ) :: R.t(%{atom => term}, validate_error_t) do + Enum.map(fields_with_attrs, fn {field_name, accepted_field_names, mod, preprocessor, + default_value_opt} -> + case get_param_by_field_names(params, accepted_field_names) do + {:ok, param} -> + preprocess_param(param, field_name, mod, preprocessor) + |> R.map(&{field_name, &1}) + + {:error, :no_value} -> + case default_value_opt do + {:ok, default_value} -> {:ok, {field_name, default_value}} + {:error, :no_default_value} -> {:error, {:value_missing, [{mod, field_name}]}} + end + end + end) + |> R.sequence() + |> case do + {:ok, kvs} -> {:ok, Map.new(kvs)} + {:error, {reason, mods}} -> {:error, {reason, [struct_mod | mods]}} + end + end + + defunp get_param_by_field_names(params :: params_t, field_names :: [atom]) :: + R.t(nil | json_value_t, :no_value) do + Enum.find_value(field_names, {:error, :no_value}, fn field_name -> + field_name_str = Atom.to_string(field_name) + + cond do + is_map_key(params, field_name) -> {:ok, params[field_name]} + is_map_key(params, field_name_str) -> {:ok, params[field_name_str]} + true -> nil + end + end) + end + + defunp preprocess_param( + param :: nil | json_value_t, + field_name :: v[atom], + mod :: v[module], + preprocessor :: preprocessor_t + ) :: R.t(validatable_t, validate_error_t) do + try do + case preprocessor.(param) do + {:ok, v} -> + {:ok, v} + + {:error, {reason, [^mod | mods]}} when reason in [:invalid_value, :value_missing] -> + {:error, {reason, [{mod, field_name} | mods]}} + + {:error, _} -> + {:error, {:invalid_value, [{mod, field_name}]}} + + v -> + {:ok, v} + end + rescue + _error -> {:error, {:invalid_value, [{mod, field_name}]}} + end + end + + @doc false + defun new_impl( + struct_mod :: module, + fields_with_attrs :: [field_with_attr_t], + dict :: term + ) :: R.t(struct, validate_error_t) do + struct_mod, fields_with_attrs, dict when is_list(dict) or is_map(dict) -> + Enum.map(fields_with_attrs, fn {field_name, accepted_field_names, mod, _preprocessor, + default_value_opt} -> + fetch_and_validate_field(dict, field_name, accepted_field_names, mod, default_value_opt) + end) + |> R.sequence() + |> case do + {:ok, kvs} -> + {:ok, struct_mod.__struct__(kvs)} + + {:error, {reason, mods}} + when reason in [:invalid_value, :value_missing] and is_list(mods) -> + {:error, {reason, [struct_mod | mods]}} + end + + struct_mod, _, _ -> + {:error, {:invalid_value, [struct_mod]}} + end + + defunp fetch_and_validate_field( + dict :: v[[{atom | String.t(), term}] | %{(atom | String.t()) => term}], + field_name :: v[atom], + accepted_field_names :: v[[atom]], + mod :: v[module], + default_value_opt :: default_value_opt_t \\ {:error, :no_default_value} + ) :: R.t({atom, term}, validate_error_t) do + case fetch_from_dict(dict, accepted_field_names) do + {:ok, value} -> + case validate_field(value, mod) do + {:ok, v} -> + {:ok, {field_name, v}} + + {:error, {reason, [^mod | mods]}} when reason in [:invalid_value, :value_missing] -> + {:error, {reason, [{mod, field_name} | mods]}} + end + + :error -> + case default_value_opt do + {:ok, default_value} -> {:ok, {field_name, default_value}} + {:error, :no_default_value} -> {:error, {:value_missing, [{mod, field_name}]}} + end + end + end + + defunp fetch_from_dict( + dict :: [{atom | String.t(), term}] | %{(atom | String.t()) => term}, + accepted_keys :: [atom] + ) :: {:ok, term} | :error do + _dict, [] -> + :error + + dict, [accepted_key | rest] -> + case try_fetch_from_dict(dict, accepted_key) do + {:ok, value} -> {:ok, value} + :error -> fetch_from_dict(dict, rest) + end + end + + defunp try_fetch_from_dict( + dict :: [{atom | String.t(), term}] | %{(atom | String.t()) => term}, + key :: atom + ) :: {:ok, term} | :error do + dict, key when is_list(dict) -> + key_str = Atom.to_string(key) + + Enum.find_value(dict, :error, fn + {k, v} when k == key or k == key_str -> {:ok, v} + _ -> nil + end) + + dict, key when is_map(dict) -> + case Map.fetch(dict, key) do + {:ok, _} = result -> result + :error -> Map.fetch(dict, Atom.to_string(key)) + end + end + + defunp validate_field(value :: term, mod :: v[module]) :: R.t(term, validate_error_t) do + if valid_field?(value, mod), do: {:ok, value}, else: {:error, {:invalid_value, [mod]}} + end + + @doc false + defun valid_field?(value :: term, mod :: v[module]) :: boolean do + if :code.get_mode() == :interactive do + true = Code.ensure_loaded?(mod) + end + + cond do + function_exported?(mod, :valid?, 1) -> mod.valid?(value) + function_exported?(mod, :__struct__, 0) -> is_struct(value, mod) + end + end + + @doc false + defun update_impl( + s :: struct, + struct_mod :: module, + fields :: [{atom, [atom], module}], + dict :: term + ) :: R.t(struct, validate_error_t) do + s, struct_mod, fields, dict + when is_struct(s, struct_mod) and (is_list(dict) or is_map(dict)) -> + Enum.map(fields, fn {field_name, accept_field_names, mod} -> + fetch_and_validate_field(dict, field_name, accept_field_names, mod) + end) + |> Enum.reject(&match?({:error, {:value_missing, _}}, &1)) + |> R.sequence() + |> case do + {:ok, kvs} -> + {:ok, struct(s, kvs)} + + {:error, {reason, mods}} + when reason in [:invalid_value, :value_missing] and is_list(mods) -> + {:error, {reason, [struct_mod | mods]}} + end + + s, struct_mod, _, _ when is_struct(s, struct_mod) -> + {:error, {:invalid_value, [struct_mod]}} + end + + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + if opts[:accept_case] not in [nil, :snake, :lower_camel, :upper_camel, :capital] do + raise ":accept_case option must be one of :snake, :lower_camel, :upper_camel, or :capital" + end + + pp_generator = + Keyword.get(opts, :preprocessor_generator, fn _mod -> + {:error, :no_default_preprocessor} + end) + + fields_with_attrs = + Keyword.fetch!(opts, :fields) + |> Enum.map( + &AntikytheraCore.BaseParamStruct.attach_attributes_to_field( + &1, + opts[:accept_case], + pp_generator + ) + ) + + fields = + Enum.map(fields_with_attrs, fn {field_name, _field_names, mod, _preprocessor, + _default_value_opt} -> + {field_name, mod} + end) + + fields_with_accept_fields = + Enum.map(fields_with_attrs, fn {field_name, field_names, mod, _preprocessor, + _default_value_opt} -> + {field_name, field_names, mod} + end) + + opts_for_croma_struct = Keyword.put(opts, :fields, fields) + + @base_param_struct_fields fields + @base_param_struct_fields_with_attrs fields_with_attrs + @base_param_struct_fields_with_accept_fields fields_with_accept_fields + + use Croma + use Croma.Struct, opts_for_croma_struct + + defun from_params(params :: term) :: R.t(t()) do + params when is_map(params) -> + AntikytheraCore.BaseParamStruct.preprocess_params( + __MODULE__, + @base_param_struct_fields_with_attrs, + params + ) + |> R.bind(&new/1) + + _ -> + {:error, {:invalid_value, [__MODULE__]}} + end + + defun from_params!(params :: term) :: t() do + from_params(params) |> R.get!() + end + + # Override + def new(dict) do + AntikytheraCore.BaseParamStruct.new_impl( + __MODULE__, + @base_param_struct_fields_with_attrs, + dict + ) + end + + # Override + def update(s, dict) do + AntikytheraCore.BaseParamStruct.update_impl( + s, + __MODULE__, + @base_param_struct_fields_with_accept_fields, + dict + ) + end + + # Override + def valid?(%__MODULE__{} = s) do + Enum.all?(@base_param_struct_fields, fn {field_name, mod} -> + Map.fetch!(s, field_name) |> AntikytheraCore.BaseParamStruct.valid_field?(mod) + end) + end + + def valid?(_), do: false + + defoverridable from_params: 1, from_params!: 1, new: 1, update: 2, valid?: 1 + end + end +end diff --git a/core/util/body_json_common.ex b/core/util/body_json_common.ex new file mode 100644 index 00000000..f5fe1ba7 --- /dev/null +++ b/core/util/body_json_common.ex @@ -0,0 +1,41 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule AntikytheraCore.BodyJsonCommon do + @moduledoc """ + Common functions for `Antikythera.BodyJsonStruct`, `Antikythera.BodyJsonMap` and `Antikythera.BodyJsonList`. + """ + + defmodule PreprocessorGenerator do + @moduledoc false + @type t :: (nil | AntikytheraCore.BaseParamStruct.json_value_t() -> Croma.Result.t() | term) + + defun generate(mod :: v[module]) :: Croma.Result.t(t()) do + if :code.get_mode() == :interactive do + true = Code.ensure_loaded?(mod) + end + + cond do + function_exported?(mod, :from_params, 1) -> {:ok, &mod.from_params/1} + function_exported?(mod, :new, 1) -> {:ok, &mod.new/1} + true -> {:error, :no_default_preprocessor} + end + end + end + + @common_default_preprocessor &Function.identity/1 + + defun extract_preprocessor_or_default(mod :: {module, PreprocessorGenerator.t()} | module) :: + {module, PreprocessorGenerator.t()} do + {mod, preprocessor} = mod_with_preprocessor + when is_atom(mod) and is_function(preprocessor, 1) -> + mod_with_preprocessor + + mod when is_atom(mod) -> + case PreprocessorGenerator.generate(mod) do + {:ok, preprocessor} -> {mod, preprocessor} + {:error, :no_default_preprocessor} -> {mod, @common_default_preprocessor} + end + end +end diff --git a/doc_src/gear_developers/controller.md b/doc_src/gear_developers/controller.md index eb16c4a4..b29477d2 100644 --- a/doc_src/gear_developers/controller.md +++ b/doc_src/gear_developers/controller.md @@ -28,6 +28,7 @@ - `bad_request/1` - `bad_executor_pool_id/2` (optional) - `ws_too_many_connections/1` (optional) + - `parameter_validation_error/3` (optional) - These functions must return a `Antikythera.Conn.t` as in regular controller actions. - Note that custom error handlers should do as little task as possible to avoid further troubles. diff --git a/lib/gear_application/error_handler.ex b/lib/gear_application/error_handler.ex index 3848e6cd..0fd0a01b 100644 --- a/lib/gear_application/error_handler.ex +++ b/lib/gear_application/error_handler.ex @@ -17,6 +17,7 @@ defmodule Antikythera.GearApplication.ErrorHandler do - Optional error handlers - `bad_executor_pool_id(Antikythera.Conn.t, Antikythera.ExecutorPool.BadIdReason.t) :: Antikythera.Conn.t` - `ws_too_many_connections(Antikythera.Conn.t) :: Antikythera.Conn.t` (when your gear uses websocket) + - `parameter_validation_error(Antikythera.Conn.t, Antikythera.Plug.ParamsValidator.parameter_type_t, AntikytheraCore.BaseParamStruct.validate_error_t) :: Antikythera.Conn.t` (when your gear uses parameter validation) This module generates `YourGear.error_handler_module/0` function, which is called by antikythera when handling errors. """ diff --git a/lib/type/body_json_list.ex b/lib/type/body_json_list.ex new file mode 100644 index 00000000..23395478 --- /dev/null +++ b/lib/type/body_json_list.ex @@ -0,0 +1,198 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.BodyJsonList do + @moduledoc """ + Module for defining a list of JSON values with a preprocessor function. + + This module is designed for request body validation (see `Antikythera.Plug.ParamsValidator` and `Antikythera.BodyJsonCommon`). + You can define a type-safe list similar to `Croma.SubtypeOfList` plus a preprocessor function. + + ## Usage + + To define a list of JSON values with a preprocessor function, `use` this module in a module. + + ```elixir + defmodule Dates do + use Antikythera.BodyJsonList, elem_module: {Date, &Date.to_iso8601/1} + end + ``` + + You can use it for request body validation in a controller module, as shown below. + + ```elixir + defmodule MyBody do + use Antikythera.BodyJsonCommon, fields: [dates: Dates] + end + + plug Antikythera.Plug.ParamsValidator, :validate, body: MyBody + ``` + + When a request with the following JSON body is sent to the controller, it is validated by `MyBody`. + Every element in the `dates` field is converted to an `Date` struct by the `Date.to_iso8601/1` preprocessor. + + ```json + { + "dates": ["1970-01-01", "1970-01-02", "1970-01-03"] + } + ``` + + ## Exported functions + + The list module generated by this module has the following functions: + + - `@spec from_params(term) :: Croma.Result.t(t)` + - `@spec from_params!(term) :: t` + - `@spec valid?(term) :: boolean` + - `@spec new(term) :: Croma.Result.t(t)` + - `@spec new!(term) :: t` + - `@spec min_length() :: non_neg_integer` (if `min_length` is specified) + - `@spec max_length() :: non_neg_integer` (if `max_length` is specified) + + ## Options + + Options are almost the same as `Croma.SubtypeOfList`. + The following options are available: + + - `elem_module`: The module that defines the type of each element in the list. It must either have a `valid?/1` function or be a struct with a preprocessor function. + - `min_length`: The minimum length of the list. If not specified, there is no minimum length. + - `max_length`: The maximum length of the list. If not specified, there is no maximum length. + """ + alias Croma.Result, as: R + alias AntikytheraCore.BaseParamStruct + alias AntikytheraCore.BodyJsonCommon + + @doc false + defun preprocess_params( + list_mod :: v[module], + elem_mod :: v[module], + preprocessor :: BodyJsonCommon.PreprocessorGenerator.t(), + params :: v[list] + ) :: R.t(list, BaseParamStruct.validate_error_t()) do + Enum.map(params, fn elem -> preprocess_elem(elem, elem_mod, preprocessor) end) + |> R.sequence() + |> R.map_error(fn {reason, mods} -> {reason, [list_mod | mods]} end) + end + + defunp preprocess_elem( + elem :: BaseParamStruct.json_value_t(), + mod :: v[module], + preprocessor :: BodyJsonCommon.PreprocessorGenerator.t() + ) :: R.t(term, BaseParamStruct.validate_error_t()) do + try do + case preprocessor.(elem) do + {:ok, v} -> + {:ok, v} + + {:error, {reason, mods}} + when reason in [:invalid_value, :value_missing] and is_list(mods) -> + {:error, {reason, [mod | mods]}} + + {:error, _} -> + {:error, {:invalid_value, [mod]}} + + v -> + {:ok, v} + end + rescue + _error -> {:error, {:invalid_value, [mod]}} + end + end + + @doc false + defun new_impl(list_mod :: v[module], elem_mod :: v[module], value :: v[list]) :: + R.t(list, BaseParamStruct.validate_error_t()) do + Enum.map(value, fn v -> validate_field(v, elem_mod) end) + |> R.sequence() + |> case do + {:ok, _} = result -> + result + + {:error, {reason, mods}} + when reason in [:invalid_value, :value_missing] and is_list(mods) -> + {:error, {reason, [list_mod | mods]}} + end + end + + defunp validate_field(value :: term, mod :: v[module]) :: + R.t(term, BaseParamStruct.validate_error_t()) do + if valid_field?(value, mod), do: {:ok, value}, else: {:error, {:invalid_value, [mod]}} + end + + @doc false + defdelegate valid_field?(value, mod), to: BaseParamStruct + + defmacro __using__(opts) do + quote bind_quoted: [ + elem_module: opts[:elem_module], + min: opts[:min_length], + max: opts[:max_length] + ] do + {mod, preprocessor} = + AntikytheraCore.BodyJsonCommon.extract_preprocessor_or_default(elem_module) + + @mod mod + @preprocessor preprocessor + + @type t :: [unquote(@mod).t] + + @min min + @max max + cond do + is_nil(@min) and is_nil(@max) -> + defguardp is_valid_length(_len) when true + + is_nil(@min) -> + defguardp is_valid_length(len) when len <= @max + + is_nil(@max) -> + defguardp is_valid_length(len) when @min <= len + + true -> + defguardp is_valid_length(len) when @min <= len and len <= @max + end + + defun valid?(value :: term) :: boolean do + l when is_list(l) and is_valid_length(length(l)) -> + Enum.all?(l, fn v -> Antikythera.BodyJsonList.valid_field?(v, @mod) end) + + _ -> + false + end + + defun new(value :: term) :: R.t(t()) do + l when is_list(l) and is_valid_length(length(l)) -> + Antikythera.BodyJsonList.new_impl(__MODULE__, @mod, l) + + _ -> + {:error, {:invalid_value, [__MODULE__]}} + end + + defun new!(value :: term) :: t() do + new(value) |> R.get!() + end + + defun from_params(params :: term) :: R.t(t()) do + params when is_list(params) -> + Antikythera.BodyJsonList.preprocess_params(__MODULE__, @mod, @preprocessor, params) + |> R.bind(&new/1) + + _ -> + {:error, {:invalid_value, [__MODULE__]}} + end + + defun from_params!(params :: term) :: t() do + from_params(params) |> R.get!() + end + + unless is_nil(@min) do + defun min_length() :: non_neg_integer, do: @min + end + + unless is_nil(@max) do + defun max_length() :: non_neg_integer, do: @max + end + end + end +end diff --git a/lib/type/body_json_map.ex b/lib/type/body_json_map.ex new file mode 100644 index 00000000..5e91f0a3 --- /dev/null +++ b/lib/type/body_json_map.ex @@ -0,0 +1,212 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.BodyJsonMap do + @moduledoc """ + Module for defining a JSON object as a map with a preprocessor function. + + This module is designed for request body validation (see `Antikythera.Plug.ParamsValidator` and `Antikythera.BodyJsonCommon`). + You can define a type-safe map similar to `Croma.SubtypeOfMap` plus a preprocessor function. + + ## Usage + + To define a JSON object as a map with a preprocessor function, `use` this module in a module. + + ```elixir + defmodule DateMap do + use Antikythera.BodyJsonMap, value_module: {Date, &Date.to_iso8601/1} + end + ``` + + You can use it for request body validation in a controller module, as shown below. + + ```elixir + defmodule MyBody do + use Antikythera.BodyJsonCommon, fields: [dates: DateMap] + end + + plug Antikythera.Plug.ParamsValidator, :validate, body: MyBody + ``` + + When a request with the following JSON body is sent to the controller, it is validated by `MyBody`. + Every value in the `dates` field is converted to an `Date` struct by the `Date.to_iso8601/1` preprocessor. + + ```json + { + "dates": { + "date1": "1970-01-01", + "date2": "1970-01-02", + "date3": "1970-01-03" + } + } + ``` + + ## Exported functions + + The map module generated by this module has the following functions: + + - `@spec from_params(term) :: Croma.Result.t(t)` + - `@spec from_params!(term) :: t` + - `@spec valid?(term) :: boolean` + - `@spec new(term) :: Croma.Result.t(t)` + - `@spec new!(term) :: t` + - `@spec min_size() :: non_neg_integer` (if `min_size` is specified) + - `@spec max_size() :: non_neg_integer` (if `max_size` is specified) + + ## Options + + Options are almost the same as `Croma.SubtypeOfMap`. + The following options are available: + + - `value_module`: The module that defines the type of each value in the map. It must either have a `valid?/1` function or be a struct with a preprocessor function. + - `min_size`: The minimum size of the map. If not specified, there is no minimum size. + - `max_size`: The maximum size of the map. If not specified, there is no maximum size. + """ + alias Croma.Result, as: R + alias AntikytheraCore.BaseParamStruct + alias AntikytheraCore.BodyJsonCommon + + @doc false + defun preprocess_params( + map_mod :: v[module], + value_mod :: v[module], + preprocessor :: BodyJsonCommon.PreprocessorGenerator.t(), + params :: v[map] + ) :: R.t(map, BaseParamStruct.validate_error_t()) do + Enum.map(params, fn {k, v} -> + preprocess_value(v, value_mod, preprocessor) |> R.map(&{k, &1}) + end) + |> R.sequence() + |> case do + {:ok, kv_list} -> {:ok, Map.new(kv_list)} + {:error, {reason, mods}} -> {:error, {reason, [map_mod | mods]}} + end + end + + defunp preprocess_value( + value :: BaseParamStruct.json_value_t(), + mod :: v[module], + preprocessor :: BodyJsonCommon.PreprocessorGenerator.t() + ) :: R.t(term, BaseParamStruct.validate_error_t()) do + try do + case preprocessor.(value) do + {:ok, v} -> + {:ok, v} + + {:error, {reason, mods}} + when reason in [:invalid_value, :value_missing] and is_list(mods) -> + {:error, {reason, [mod | mods]}} + + {:error, _} -> + {:error, {:invalid_value, [mod]}} + + v -> + {:ok, v} + end + rescue + _error -> {:error, {:invalid_value, [mod]}} + end + end + + @doc false + defun new_impl(map_mod :: v[module], value_mod :: v[module], value :: v[map]) :: + R.t(map, BaseParamStruct.validate_error_t()) do + Enum.map(value, fn + {k, v} when is_binary(k) -> validate_field(v, value_mod) |> R.map(&{k, &1}) + _ -> {:error, {:invalid_value, []}} + end) + |> R.sequence() + |> case do + {:ok, kv_list} -> + {:ok, Map.new(kv_list)} + + {:error, {reason, mods}} + when reason in [:invalid_value, :value_missing] and is_list(mods) -> + {:error, {reason, [map_mod | mods]}} + end + end + + defunp validate_field(value :: term, mod :: v[module]) :: + R.t(term, BaseParamStruct.validate_error_t()) do + if valid_field?(value, mod), do: {:ok, value}, else: {:error, {:invalid_value, [mod]}} + end + + @doc false + defdelegate valid_field?(value, mod), to: BaseParamStruct + + defmacro __using__(opts) do + quote bind_quoted: [ + value_module: opts[:value_module], + min: opts[:min_size], + max: opts[:max_size] + ] do + {mod, preprocessor} = + AntikytheraCore.BodyJsonCommon.extract_preprocessor_or_default(value_module) + + @mod mod + @preprocessor preprocessor + + @type t :: %{String.t() => @mod.t()} + + @min min + @max max + cond do + is_nil(@min) and is_nil(@max) -> + defguardp is_valid_size(_size) when true + + is_nil(@min) -> + defguardp is_valid_size(size) when size <= @max + + is_nil(@max) -> + defguardp is_valid_size(size) when @min <= size + + true -> + defguardp is_valid_size(size) when @min <= size and size <= @max + end + + defun valid?(value :: term) :: boolean do + m when is_map(m) and is_valid_size(map_size(m)) -> + Enum.all?(m, fn {k, v} -> + is_binary(k) and Antikythera.BodyJsonMap.valid_field?(v, @mod) + end) + + _ -> + false + end + + defun new(value :: term) :: R.t(t()) do + m when is_map(m) and is_valid_size(map_size(m)) -> + Antikythera.BodyJsonMap.new_impl(__MODULE__, @mod, m) + + _ -> + {:error, {:invalid_value, [__MODULE__]}} + end + + defun new!(value :: term) :: t() do + new(value) |> R.get!() + end + + defun from_params(params :: term) :: R.t(t()) do + m when is_map(m) -> + Antikythera.BodyJsonMap.preprocess_params(__MODULE__, @mod, @preprocessor, m) + |> R.bind(&new/1) + + _ -> + {:error, {:invalid_value, [__MODULE__]}} + end + + defun from_params!(params :: term) :: t() do + from_params(params) |> R.get!() + end + + unless is_nil(@min) do + defun min_size() :: non_neg_integer, do: @min + end + + unless is_nil(@max) do + defun max_size() :: non_neg_integer, do: @max + end + end + end +end diff --git a/lib/type/body_json_struct.ex b/lib/type/body_json_struct.ex new file mode 100644 index 00000000..ffe09387 --- /dev/null +++ b/lib/type/body_json_struct.ex @@ -0,0 +1,166 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.BodyJsonStruct do + @moduledoc """ + Module to define a struct that represents the JSON body of a request. + + This module is designed for request body validation (see `Antikythera.Plug.ParamsValidator`). + If you are looking for a module for validation of other parameters, see `Antikythera.ParamStringStruct`. + + ## Usage + + To define a struct that represents the JSON body of a request, `use` this module in a module. + Each field in the struct is defined by the `:fields` option, as shown below. + + ```elixir + defmodule MyBody1 do + use Antikythera.BodyJsonStruct, + fields: [ + item_id: Croma.PosInteger, + tags: Croma.TypeGen.list_of(Croma.String), + expires_at: {DateTime, &Antikythera.StringPreprocessor.to_datetime/1} + ] + end + ``` + + The type of each field must be one of + + - modules having `valid?/1` function, such as `Croma.PosInteger`, `Croma.TypeGen.list_of(Croma.String)`, modules defined by `Antikythera.BodyJsonMap` or `Antikythera.BodyJsonList`, or + - structs with a preprocessor function, such as `DateTime` in the example above. + + When defining a field with a preprocessor, the argument type must be a JSON value type or `nil`, + and the result must be one of + + - a preprocessed value, or + - a tuple `{:ok, preprocessed_value}` or `{:error, error_reason}`. + + Now you can validate a JSON request body using the struct in a controller module. + + ```elixir + use Croma + + defmodule YourGear.Controller.Example do + use Antikythera.Controller + + plug Antikythera.Plug.ParamsValidator, :validate, body: MyBody1 + + defun some_action(%Conn{assigns: %{validated: validated}} = conn) :: Conn.t() do + # You can access the validated JSON body as a `MyBody1` struct via `validated.body`. + # ... + end + end + ``` + + When a request with the following JSON body is sent to the controller, it is validated by `MyBody1`. + The `expires_at` field is converted to a `DateTime` struct by the `Antikythera.StringPreprocessor.to_datetime/1` preprocessor. + + ```json + { + "item_id": 123, + "tags": ["tag1", "tag2"], + "expires_at": "2025-01-01T00:00:00Z" + } + ``` + + ### Exported functions + + The struct module generated by this module has the following exported functions: + + - `@spec from_params(term) :: Croma.Result.t(t)` + - `@spec from_params!(term) :: t` + - `@spec valid?(term) :: boolean` + - `@spec new(term) :: Croma.Result.t(t)` + - `@spec new!(term) :: t` + - `@spec update(t, term) :: Croma.Result.t(t)` + - `@spec update!(t, term) :: t` + + `from_params/1` and `from_params!/1` apply the preprocessor before creating a struct, which is useful when you want to create a struct from a JSON. + + ### Struct nesting + + You can define a nested struct using another struct as a field type without a preprocessor. + + ```elixir + defmodule MyBody2 do + defmodule GeoLocation do + use Antikythera.BodyJsonStruct, + fields: [ + latitude: Croma.Float, + longitude: Croma.Float + ] + end + + use Antikythera.BodyJsonStruct, + fields: [ + item_id: Croma.PosInteger, + location: GeoLocation + ] + end + ``` + + In the example above, `MyBody2` allows the following JSON body and the `location` field is converted to a `GeoLocation` struct. + + ```json + { + "item_id": 123, + "location": { + "latitude": 35.699793, + "longitude": 139.774113 + } + } + ``` + + ### Optional field and default value + + You can define an optional field using `Croma.TypeGen.nilable/1`. + If an optional field is missing in the JSON body, it is set to `nil`. + + By setting the `:default` option, you can set a default value when the field is missing in the JSON body. + + ```elixir + defmodule MyBody3 do + use Antikythera.BodyJsonStruct, + fields: [ + timezone: {Croma.String, [default: "UTC"]}, + date: {Date, &Date.from_iso8601/1, [default: ~D[1970-01-01]]} + ] + end + ``` + + In the example above, `MyBody3` allows the empty JSON object body `{}`, with each field set to the default value. + + ### Naming convention + + By default, the field name in the struct and the key name in the JSON body are the same. + You can specify a different key name scheme using the `:accept_case` option, which is the same as that of `Croma.Struct`. + + ```elixir + defmodule MyBody4 do + use Antikythera.BodyJsonStruct, + accept_case: :lower_camel, + fields: [ + long_name_field: Croma.String # The key name "longNameField" is also accepted in the JSON body. + ] + end + ``` + + ## Limitations + + The preprocessor must be a captured named function like `&module.function/1` to be used in module attributes internally. + """ + + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + opts_with_default_preprocessor_generator = + Keyword.put( + opts, + :preprocessor_generator, + &AntikytheraCore.BodyJsonCommon.PreprocessorGenerator.generate/1 + ) + + use AntikytheraCore.BaseParamStruct, opts_with_default_preprocessor_generator + end + end +end diff --git a/lib/type/param_string_struct.ex b/lib/type/param_string_struct.ex new file mode 100644 index 00000000..e3f1c628 --- /dev/null +++ b/lib/type/param_string_struct.ex @@ -0,0 +1,242 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.ParamStringStruct do + @moduledoc """ + Module to define a struct that represents a string parameter. + + This module is designed for parameter validation (see `Antikythera.Plug.ParamsValidator`). + If you are looking for a module for validation of JSON request bodies, see `Antikythera.BodyJsonStruct`. + + ## Usage + + To define a struct that represents a string parameter, `use` this module in a module. + Each field in the struct is defined by the `:fields` option, as shown below. + + ```elixir + defmodule MyQueryParams1 do + defmodule Limit do + use Croma.SubtypeOfInt, min: 1, max: 1_000 + end + + use Antikythera.ParamStringStruct, + fields: [ + item_id: Croma.PosInteger, + since: DateTime, + limit: {Limit, &String.to_integer/1} + ] + end + ``` + + The type of each field must be one of + + - Croma built-in types, such as `Croma.PosInteger`, + - DateTime-related types: `Date`, `DateTime`, `NaiveDateTime`, and `Time`, + - nilable types, such as `Croma.TypeGen.Nilable(Croma.PosInteger)`, or + - module or struct with a preprocessor function, such as `Limit` in the example above. + + When defining a field with a preprocessor, the argument type must be a string or `nil`, and the result must be one of + + - a preprocessed value, or + - a tuple `{:ok, preprocessed_value}` or `{:error, error_reason}`. + + Note that the parameter is always a string, so you need to convert it to the desired type in the preprocessor if you would like to use user-defined types. + `Antikythera.StringPreprocessor` provides some useful preprocessor functions which are not defined in the Elixir standard library. + + Now you can validate string parameters using the struct in a controller module. + The example below shows the validation of query parameters using `MyQueryParams1`. + + ```elixir + use Croma + + defmodule YourGear.Controller.Example do + use Antikythera.Controller + + plug Antikythera.Plug.ParamsValidator, :validate, query_params: MyQueryParams1 + + defun some_action(%Conn{assigns: %{validated: validated}} = conn) :: Conn.t() do + # You can access the validated query parameters as a `MyQueryParams1` struct via `validated.query_params`. + # ... + end + end + ``` + + When a request with the following query parameters is sent to the controller, it is validated by `MyQueryParams1`. + Each parameter is converted to the specified type by the preprocessor. + + ``` + /example?item_id=123&since=2025-01-01T00:00:00Z&limit=100 + ``` + + You can also validate path parameters(`:path_matches`), headers, and cookies in the same way. + + ### Exported functions + + The struct module generated by this module has the following exported functions: + + - `@spec from_params(term) :: Croma.Result.t(t)` + - `@spec from_params!(term) :: t` + - `@spec valid?(term) :: boolean` + - `@spec new(term) :: Croma.Result.t(t)` + - `@spec new!(term) :: t` + - `@spec update(t, term) :: Croma.Result.t(t)` + - `@spec update!(t, term) :: t` + + `from_params/1` and `from_params!/1` apply the preprocessor before creating a struct, which is useful when you want to create a struct from parameters. + + ### Optional field and default value + + You can define an optional field using `Croma.TypeGen.nilable/1`. + If an optional field is not included in the request, it is set to `nil`. + + By setting the `:default` option, you can set a default value when the parameter field is not included in the request. + + ```elixir + defmodule MyQueryParams2 do + use Antikythera.ParamStringStruct, + fields: [ + q: Croma.TypeGen.nilable(Croma.String), + date: {Date, [default: ~D[1970-01-01]]} + ] + end + + plug Antikythera.Plug.ParamsValidator, :validate, query_params: MyQueryParams2 + ``` + + In the example above, the request without any query parameters is allowed; `q` is set to `nil`, and `date` is set to `~D[1970-01-01]`. + + ### Naming convention + + By default, the field name in the struct is the same as the parameter key. + You can specify a different key name scheme using the `:accept_case` option, which is the same as that of `Croma.Struct`. + + ```elixir + defmodule MyQueryParams3 do + use Antikythera.ParamStringStruct, + accept_case: :lower_camel, + fields: [ + item_id: Croma.PosInteger # The parameter key name "itemId" is also accepted. + ] + end + ``` + + ## Limitations + + The preprocessor must be a captured named function like `&Module.function/arity` to be used in module attributes internally. + """ + + defmodule PreprocessorGenerator do + @moduledoc false + + @type t :: (nil | String.t() -> Croma.Result.t() | term) + + alias Antikythera.StringPreprocessor + + defun generate(mod :: v[module]) :: Croma.Result.t(t()) do + # The default preprocessors are defined as a capture form `&Mod.fun/arity`, which can be used in module attributes. + case Module.split(mod) do + # Preprocessors for Croma built-in types + ["Croma", "Boolean"] -> + {:ok, &StringPreprocessor.to_boolean/1} + + ["Croma", "Float"] -> + {:ok, &String.to_float/1} + + ["Croma", "Integer"] -> + {:ok, &String.to_integer/1} + + ["Croma", "NegInteger"] -> + {:ok, &String.to_integer/1} + + ["Croma", "NonNegInteger"] -> + {:ok, &String.to_integer/1} + + ["Croma", "Number"] -> + {:ok, &StringPreprocessor.to_number/1} + + ["Croma", "PosInteger"] -> + {:ok, &String.to_integer/1} + + ["Croma", "String"] -> + {:ok, &StringPreprocessor.passthrough_string/1} + + # Preprocessors for DateTime-related types + ["Date"] -> + {:ok, &Date.from_iso8601/1} + + ["DateTime"] -> + {:ok, &StringPreprocessor.to_datetime/1} + + ["NaiveDateTime"] -> + {:ok, &NaiveDateTime.from_iso8601/1} + + ["Time"] -> + {:ok, &Time.from_iso8601/1} + + # Preprocessors for nilable types + ["Croma", "TypeGen", "Nilable" | original_mod_split] -> + original_mod = Module.safe_concat(original_mod_split) + + original_mod + |> generate() + |> Croma.Result.map(&generate_nilable_preprocessor(&1, original_mod)) + + _ -> + {:error, :no_default_preprocessor} + end + end + + defunp generate_nilable_preprocessor(original_pp :: t(), original_mod :: v[module]) :: t() do + # This function internally generates a new module with a preprocessor for a specified nilable type. + # The reason for creating a new module is to satisfy the limitation of module attributes. + # In order to be used in module attributes, the function must be in the form of `&Mod.fun/arity`. + + # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom + nilable_pp_mod = Module.concat([__MODULE__, Nilable, original_mod]) + + nilable_pp_body = + quote do + @spec parse(nil | String.t()) :: nil | unquote(original_mod).t() + def parse(nil), do: nil + def parse(s), do: unquote(original_pp).(s) + end + + :ok = ensure_module_defined(nilable_pp_mod, nilable_pp_body, Macro.Env.location(__ENV__)) + + &nilable_pp_mod.parse/1 + end + + defunp ensure_module_defined( + mod :: v[module], + body :: term, + location :: Macro.Env.t() | keyword + ) :: :ok do + if :code.which(mod) == :non_existing do + case Agent.start(fn -> nil end, name: mod) do + {:ok, _pid} -> + Module.create(mod, body, location) + :ok + + {:error, _already_started} -> + :ok + end + else + :ok + end + end + end + + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + opts_with_default_preprocessor_generator = + Keyword.put( + opts, + :preprocessor_generator, + &Antikythera.ParamStringStruct.PreprocessorGenerator.generate/1 + ) + + use AntikytheraCore.BaseParamStruct, opts_with_default_preprocessor_generator + end + end +end diff --git a/lib/util/string_preprocessor.ex b/lib/util/string_preprocessor.ex new file mode 100644 index 00000000..6b9b2643 --- /dev/null +++ b/lib/util/string_preprocessor.ex @@ -0,0 +1,58 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.StringPreprocessor do + @moduledoc """ + String preprocessor functions for `Antikythera.ParamStringStruct`. + + This module defines string preprocessor functions which is not defined in Elixir standard library. + """ + + @doc """ + Converts a string to a boolean value. Raises ArgumentError if the argument is not a valid boolean value or is nil. + """ + defun to_boolean(s :: String.t()) :: boolean do + "true" -> true + "false" -> false + s when is_binary(s) -> raise ArgumentError, "Invalid boolean value: #{s}" + nil -> raise ArgumentError, "String expected, but got nil" + end + + @doc """ + Converts a string to a number. Raises ArgumentError if the argument is not a valid number or is nil. + """ + defun to_number(s :: String.t()) :: number do + s when is_binary(s) -> + try do + String.to_integer(s) + rescue + ArgumentError -> String.to_float(s) + end + + nil -> + raise ArgumentError, "String expected, but got nil" + end + + @doc """ + Passthrough function for a string. Raises ArgumentError if the argument is nil. + """ + defun passthrough_string(s :: String.t()) :: String.t() do + s when is_binary(s) -> s + nil -> raise ArgumentError, "String expected, but got nil" + end + + @doc """ + Converts a string to a DateTime struct. Raises ArgumentError if the argument is not a valid datetime value or is nil. + """ + defun to_datetime(s :: String.t()) :: DateTime.t() do + s when is_binary(s) -> + case DateTime.from_iso8601(s) do + {:ok, dt, _tz_offset} -> dt + _ -> raise ArgumentError, "Invalid datetime value: #{s}" + end + + nil -> + raise ArgumentError, "String expected, but got nil" + end +end diff --git a/lib/web/plug/params_validator.ex b/lib/web/plug/params_validator.ex new file mode 100644 index 00000000..2a6b799a --- /dev/null +++ b/lib/web/plug/params_validator.ex @@ -0,0 +1,166 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.Plug.ParamsValidator do + @moduledoc """ + Plug to validate request parameters. + + ## Usage + + First, generate parameter validation models using `Antikythera.ParamStringStruct` or `Antikythera.BodyJsonStruct`. + + ```elixir + defmodule MyQueryParams do + use Antikythera.ParamStringStruct, + fields: [ + item_id: Croma.PosInteger + ] + end + ``` + + Then, you can install this plug to validate parameters, as shown below. + + ```elixir + plug Antikythera.Plug.ParamsValidator, :validate, query_params: MyQueryParams + ``` + + In the controller function, you can get the validated parameters from their corresponding name in `conn.assigns.validated`. + For example, to access the validated query parameters, use the following code. + + ```elixir + defun index(conn :: v[Conn.t()]) :: Conn.t() do + %MyQueryParams{item_id: item_id} = conn.assigns.validated.query_params + + # Use item_id + end + ``` + + ### Validation error handling + + When a validation error occurs, a response with status code 400 like the following is returned without calling the controller function. + + ```text + ParameterValidationError: Invalid value at item_id of query_params + ``` + + The error response can be customized by implementing `YourGear.Controller.Error.parameter_validation_error/3`. + See `Antikythera.GearApplication.ErrorHandler` for more information on custom error handlers. + The second argument of `parameter_validation_error/3` is an atom representing the parameter type that failed validation. + The third argument is a tuple representing the reason for the validation error (see `t:AntikytheraCore.BaseParamStruct.validate_error_t/0`). + For example, when the plug above is set and the query parameter `item_id` is not a positive integer, the second argument is `:query_params` + and the third argument is `{:invalid_value, [MyQueryParams, {Croma.PosInteger, :item_id}]}`. + + ## Supported parameter types + + - `:path_matches`: Path parameters + - `:query_params`: Query parameters + - `:body`: Request body (JSON) + - Note: The module must be generated by `Antikythera.BodyJsonStruct`. + - `:headers`: Request headers + - Note: The keys must be lowercase. (e.g., `:"x-item-id"` instead of `:"X-Item-Id"`) + - `:cookies`: Cookies + + Multiple parameter types can be validated at once. + + ```elixir + plug Antikythera.Plug.ParamsValidator, :validate, path_matches: MyPathParams, body: MyBody + ``` + """ + + alias Antikythera.Conn + + @type parameter_type_t :: :path_matches | :query_params | :body | :headers | :cookies + @type validate_option_t :: {parameter_type_t, module} + + defun validate(conn :: v[Conn.t()], opts :: v[[validate_option_t]]) :: v[Conn.t()] do + :ok = validate_plug_options(opts) + + conn + |> prepare_validated_params_assignment() + |> validate_path_matches(opts[:path_matches]) + |> validate_query_params(opts[:query_params]) + |> validate_body(opts[:body]) + |> validate_headers(opts[:headers]) + |> validate_cookies(opts[:cookies]) + end + + defunp validate_plug_options(opts :: v[[validate_option_t]]) :: :ok do + Enum.reduce_while(opts, {:ok, []}, fn {param_type, mod}, {:ok, validated_param_types} -> + if :code.get_mode() == :interactive do + true = Code.ensure_loaded?(mod) + end + + cond do + param_type in validated_param_types -> + {:halt, {:error, "Parameter #{param_type} is specified more than once"}} + + function_exported?(mod, :from_params, 1) -> + {:cont, {:ok, [param_type | validated_param_types]}} + + param_type == :body -> + {:halt, + {:error, + "Body parameter validation module must be generated by Antikythera.BodyJsonStruct."}} + + true -> + {:halt, + {:error, + "Parameter validation module must be generated by Antikythera.ParamStringStruct."}} + end + end) + |> case do + {:ok, []} -> + raise ArgumentError, "At least one parameter to be validated must be specified." + + {:ok, _} -> + :ok + + {:error, msg} -> + raise ArgumentError, msg + end + end + + defunp prepare_validated_params_assignment(conn :: v[Conn.t()]) :: v[Conn.t()] do + if Map.has_key?(conn.assigns, :validated) do + IO.warn( + "[antikythera] Parameter validation may be executed more than once. The validation result will be overwritten." + ) + end + + Conn.assign(conn, :validated, %{}) + end + + for param_type <- [:path_matches, :query_params, :body, :headers, :cookies] do + # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom + defunp unquote(:"validate_#{param_type}")(conn :: Conn.t(), validator :: nil | module) :: + Conn.t() do + conn, nil -> + conn + + conn, validator when is_atom(validator) -> + params = conn.request.unquote(param_type) + + validate_params(conn, unquote(param_type), validator, params) + end + end + + defunp validate_params( + conn :: v[Conn.t()], + param_type :: parameter_type_t, + validator :: v[module], + params :: AntikytheraCore.BaseParamStruct.params_t() + ) :: v[Conn.t()] do + case validator.from_params(params) do + {:ok, validated_params} -> + Conn.assign( + conn, + :validated, + Map.put(conn.assigns.validated, param_type, validated_params) + ) + + {:error, reason} -> + AntikytheraCore.Handler.GearError.parameter_validation_error(conn, param_type, reason) + end + end +end diff --git a/test/core/type/base_param_struct_test.exs b/test/core/type/base_param_struct_test.exs new file mode 100644 index 00000000..c2c97f4e --- /dev/null +++ b/test/core/type/base_param_struct_test.exs @@ -0,0 +1,627 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule AntikytheraCore.BaseParamStructTest do + alias AntikytheraCore.BaseParamStructTest.TestStructOfVariousFieldTypes + use Croma.TestCase + + defmodule TestStructOfVariousFieldTypes do + use BaseParamStruct, + fields: [ + param_croma_builtin: Croma.PosInteger, + param_datetime_related: Date, + param_nilable: Croma.TypeGen.nilable(Croma.PosInteger), + param_with_throwable_preprocessor: {Croma.PosInteger, &String.to_integer/1}, + param_with_result_preprocessor: {Croma.PosInteger, &__MODULE__.to_integer/1} + ] + + defun to_integer(v :: v[String.t()]) :: Croma.Result.t(integer) do + Croma.Result.try(fn -> String.to_integer(v) end) + end + end + + @valid_fields_for_test_struct_of_various_field_types_1 %{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: 1, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + } + @valid_fields_for_test_struct_of_various_field_types_2 %{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: nil, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + } + @valid_params_for_test_struct_of_various_field_types_1 %{ + "param_croma_builtin" => 1, + "param_datetime_related" => ~D[1970-01-01], + "param_nilable" => 1, + "param_with_throwable_preprocessor" => "1", + "param_with_result_preprocessor" => "1" + } + @valid_params_for_test_struct_of_various_field_types_2 %{ + "param_croma_builtin" => 1, + "param_datetime_related" => ~D[1970-01-01], + "param_with_throwable_preprocessor" => "1", + "param_with_result_preprocessor" => "1" + } + + describe "new/1 of a struct module based on BaseParamStruct" do + test "should return :ok with a struct if all fields are valid" do + [ + @valid_fields_for_test_struct_of_various_field_types_1, + @valid_fields_for_test_struct_of_various_field_types_2 + ] + |> Enum.each(fn valid_fields -> + expected_struct = struct(TestStructOfVariousFieldTypes, valid_fields) + assert {:ok, ^expected_struct} = TestStructOfVariousFieldTypes.new(valid_fields) + end) + end + + test "should return invalid value error if a field is invalid" do + [ + param_croma_builtin: [0, "1", nil], + param_datetime_related: [~U[1970-01-01T00:00:00Z], "1970-01-01", nil], + param_nilable: [0, "1"], + param_with_throwable_preprocessor: [0, "1", nil], + param_with_result_preprocessor: [0, "1", nil] + ] + |> Enum.each(fn {field, invalid_values} -> + Enum.each(invalid_values, fn invalid_value -> + params = + Map.put( + @valid_fields_for_test_struct_of_various_field_types_1, + field, + invalid_value + ) + + assert {:error, {:invalid_value, [TestStructOfVariousFieldTypes, {_type, ^field}]}} = + TestStructOfVariousFieldTypes.new(params) + end) + end) + end + + test "should return value missing error if a field is missing" do + Map.keys(@valid_fields_for_test_struct_of_various_field_types_1) + # Reject the param_nilable field because it allows empty value + |> Enum.reject(&(&1 == :param_nilable)) + |> Enum.each(fn field -> + params = Map.delete(@valid_fields_for_test_struct_of_various_field_types_1, field) + + assert {:error, {:value_missing, [TestStructOfVariousFieldTypes, {_type, ^field}]}} = + TestStructOfVariousFieldTypes.new(params) + end) + end + + test "should return :ok with a struct if a field is missing but it is nilable" do + assert {:ok, %TestStructOfVariousFieldTypes{param_nilable: nil}} = + TestStructOfVariousFieldTypes.new(%{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + }) + end + end + + describe "from_params/1 of a struct module based on BaseParamStruct" do + test "should return :ok with a struct if all fields are valid" do + [ + { + struct( + TestStructOfVariousFieldTypes, + @valid_fields_for_test_struct_of_various_field_types_1 + ), + @valid_params_for_test_struct_of_various_field_types_1 + }, + { + struct( + TestStructOfVariousFieldTypes, + @valid_fields_for_test_struct_of_various_field_types_2 + ), + @valid_params_for_test_struct_of_various_field_types_2 + } + ] + |> Enum.each(fn {expected_struct, valid_params} -> + assert {:ok, ^expected_struct} = TestStructOfVariousFieldTypes.from_params(valid_params) + end) + end + + test "should return invalid value error if a field is invalid" do + [ + param_croma_builtin: [0, "1"], + param_datetime_related: [~U[1970-01-01T00:00:00Z], "1970-01-01"], + param_nilable: [0, "1"], + param_with_throwable_preprocessor: ["0", "string", 1], + param_with_result_preprocessor: ["0", "string", 1] + ] + |> Enum.each(fn {field, invalid_values} -> + Enum.each(invalid_values, fn invalid_value -> + params = + Map.put( + @valid_params_for_test_struct_of_various_field_types_1, + Atom.to_string(field), + invalid_value + ) + + assert {:error, {:invalid_value, [TestStructOfVariousFieldTypes, {_type, ^field}]}} = + TestStructOfVariousFieldTypes.from_params(params) + end) + end) + end + + test "should return value missing error if a field is missing" do + Map.keys(@valid_params_for_test_struct_of_various_field_types_1) + # Reject the param_nilable field because it allows empty value + |> Enum.reject(&(&1 == "param_nilable")) + |> Enum.each(fn field -> + params = Map.delete(@valid_params_for_test_struct_of_various_field_types_1, field) + field_atom = String.to_existing_atom(field) + + assert {:error, {:value_missing, [TestStructOfVariousFieldTypes, {_type, ^field_atom}]}} = + TestStructOfVariousFieldTypes.from_params(params) + end) + end + end + + describe "update/2 of a struct module based on BaseParamStruct" do + test "should return :ok and an updated struct if all given fields are valid" do + [ + { + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 2, + param_datetime_related: ~D[1970-01-01], + param_nilable: 1, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + }, + %{ + param_croma_builtin: 2 + } + }, + { + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-02], + param_nilable: 1, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + }, + %{ + param_datetime_related: ~D[1970-01-02] + } + }, + { + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: nil, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + }, + %{ + param_nilable: nil + } + }, + { + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: 1, + param_with_throwable_preprocessor: 2, + param_with_result_preprocessor: 1 + }, + %{ + param_with_throwable_preprocessor: 2 + } + }, + { + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: 1, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 2 + }, + %{ + param_with_result_preprocessor: 2 + } + } + ] + |> Enum.each(fn {expected_struct, valid_params} -> + assert {:ok, ^expected_struct} = + TestStructOfVariousFieldTypes.update( + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: 1, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + }, + valid_params + ) + end) + end + + test "should return invalid value error if a field is invalid" do + [ + param_croma_builtin: [0, "1"], + param_datetime_related: [~U[1970-01-01T00:00:00Z], "1970-01-01"], + param_nilable: [0, "1"], + param_with_throwable_preprocessor: [0, "1"], + param_with_result_preprocessor: [0, "1"] + ] + |> Enum.each(fn {field, invalid_values} -> + Enum.each(invalid_values, fn invalid_value -> + assert {:error, {:invalid_value, [TestStructOfVariousFieldTypes, {_type, ^field}]}} = + TestStructOfVariousFieldTypes.update( + %TestStructOfVariousFieldTypes{ + param_croma_builtin: 1, + param_datetime_related: ~D[1970-01-01], + param_nilable: 1, + param_with_throwable_preprocessor: 1, + param_with_result_preprocessor: 1 + }, + %{field => invalid_value} + ) + end) + end) + end + end + + describe "valid?/1 of a struct module based on BaseParamStruct" do + test "should return true if all fields are valid" do + [ + struct( + TestStructOfVariousFieldTypes, + @valid_fields_for_test_struct_of_various_field_types_1 + ), + struct( + TestStructOfVariousFieldTypes, + @valid_fields_for_test_struct_of_various_field_types_2 + ) + ] + |> Enum.each(fn valid_struct -> + assert TestStructOfVariousFieldTypes.valid?(valid_struct) + end) + end + + test "should return false if a field is invalid" do + [ + param_croma_builtin: [0, "1", nil], + param_datetime_related: [~U[1970-01-01T00:00:00Z], "1970-01-01", nil], + param_nilable: [0, "1"], + param_with_throwable_preprocessor: [0, "1", nil], + param_with_result_preprocessor: [0, "1", nil] + ] + |> Enum.each(fn {field, invalid_values} -> + Enum.each(invalid_values, fn invalid_value -> + invalid_struct = + struct( + TestStructOfVariousFieldTypes, + @valid_fields_for_test_struct_of_various_field_types_1 + ) + |> Map.put(field, invalid_value) + + refute TestStructOfVariousFieldTypes.valid?(invalid_struct) + end) + end) + end + end + + defmodule TestStructWithAcceptCaseSnake do + use BaseParamStruct, + accept_case: :snake, + fields: [paramNamedWithMultipleWords: Croma.PosInteger] + end + + describe "new/1 of a struct module based on BaseParamStruct with accept_case: :snake" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its snake case" do + assert {:ok, %TestStructWithAcceptCaseSnake{paramNamedWithMultipleWords: 1}} = + TestStructWithAcceptCaseSnake.new(%{paramNamedWithMultipleWords: 1}) + + assert {:ok, %TestStructWithAcceptCaseSnake{paramNamedWithMultipleWords: 1}} = + TestStructWithAcceptCaseSnake.new(%{param_named_with_multiple_words: 1}) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [TestStructWithAcceptCaseSnake, {Croma.PosInteger, :paramNamedWithMultipleWords}]}} = + TestStructWithAcceptCaseSnake.new(%{ParamNamedWithMultipleWords: 1}) + + assert {:error, + {:value_missing, + [TestStructWithAcceptCaseSnake, {Croma.PosInteger, :paramNamedWithMultipleWords}]}} = + TestStructWithAcceptCaseSnake.new(%{PARAM_NAMED_WITH_MULTIPLE_WORDS: 1}) + end + end + + describe "from_params/1 of a struct module based on BaseParamStruct with accept_case: :snake" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its snake case" do + assert {:ok, %TestStructWithAcceptCaseSnake{paramNamedWithMultipleWords: 1}} = + TestStructWithAcceptCaseSnake.from_params(%{"paramNamedWithMultipleWords" => 1}) + + assert {:ok, %TestStructWithAcceptCaseSnake{paramNamedWithMultipleWords: 1}} = + TestStructWithAcceptCaseSnake.from_params(%{"param_named_with_multiple_words" => 1}) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [TestStructWithAcceptCaseSnake, {Croma.PosInteger, :paramNamedWithMultipleWords}]}} = + TestStructWithAcceptCaseSnake.from_params(%{"ParamNamedWithMultipleWords" => 1}) + + assert {:error, + {:value_missing, + [TestStructWithAcceptCaseSnake, {Croma.PosInteger, :paramNamedWithMultipleWords}]}} = + TestStructWithAcceptCaseSnake.from_params(%{"PARAM_NAMED_WITH_MULTIPLE_WORDS" => 1}) + end + end + + defmodule TestStructWithAcceptCaseUpperCamel do + use BaseParamStruct, + accept_case: :upper_camel, + fields: [param_named_with_multiple_words: Croma.PosInteger] + end + + describe "new/1 of a struct module based on BaseParamStruct with accept_case: :upper_camel" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its upper camel case" do + assert {:ok, %TestStructWithAcceptCaseUpperCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseUpperCamel.new(%{param_named_with_multiple_words: 1}) + + assert {:ok, %TestStructWithAcceptCaseUpperCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseUpperCamel.new(%{ParamNamedWithMultipleWords: 1}) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseUpperCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = TestStructWithAcceptCaseUpperCamel.new(%{paramNamedWithMultipleWords: 1}) + + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseUpperCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = TestStructWithAcceptCaseUpperCamel.new(%{PARAM_NAMED_WITH_MULTIPLE_WORDS: 1}) + end + end + + describe "from_params/1 of a struct module based on BaseParamStruct with accept_case: :upper_camel" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its upper camel case" do + assert {:ok, %TestStructWithAcceptCaseUpperCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseUpperCamel.from_params(%{ + "param_named_with_multiple_words" => 1 + }) + + assert {:ok, %TestStructWithAcceptCaseUpperCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseUpperCamel.from_params(%{ + "ParamNamedWithMultipleWords" => 1 + }) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseUpperCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = + TestStructWithAcceptCaseUpperCamel.from_params(%{ + "paramNamedWithMultipleWords" => 1 + }) + + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseUpperCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = + TestStructWithAcceptCaseUpperCamel.from_params(%{ + "PARAM_NAMED_WITH_MULTIPLE_WORDS" => 1 + }) + end + end + + defmodule TestStructWithAcceptCaseLowerCamel do + use BaseParamStruct, + accept_case: :lower_camel, + fields: [param_named_with_multiple_words: Croma.PosInteger] + end + + describe "new/1 of a struct module based on BaseParamStruct with accept_case: :lower_camel" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its lower camel case" do + assert {:ok, %TestStructWithAcceptCaseLowerCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseLowerCamel.new(%{param_named_with_multiple_words: 1}) + + assert {:ok, %TestStructWithAcceptCaseLowerCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseLowerCamel.new(%{paramNamedWithMultipleWords: 1}) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseLowerCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = TestStructWithAcceptCaseLowerCamel.new(%{ParamNamedWithMultipleWords: 1}) + + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseLowerCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = TestStructWithAcceptCaseLowerCamel.new(%{PARAM_NAMED_WITH_MULTIPLE_WORDS: 1}) + end + end + + describe "from_params/1 of a struct module based on BaseParamStruct with accept_case: :lower_camel" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its lower camel case" do + assert {:ok, %TestStructWithAcceptCaseLowerCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseLowerCamel.from_params(%{ + "param_named_with_multiple_words" => 1 + }) + + assert {:ok, %TestStructWithAcceptCaseLowerCamel{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseLowerCamel.from_params(%{ + "paramNamedWithMultipleWords" => 1 + }) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseLowerCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = + TestStructWithAcceptCaseLowerCamel.from_params(%{ + "ParamNamedWithMultipleWords" => 1 + }) + + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseLowerCamel, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = + TestStructWithAcceptCaseLowerCamel.from_params(%{ + "PARAM_NAMED_WITH_MULTIPLE_WORDS" => 1 + }) + end + end + + defmodule TestStructWithAcceptCaseCapital do + use BaseParamStruct, + accept_case: :capital, + fields: [param_named_with_multiple_words: Croma.PosInteger] + end + + describe "new/1 of a struct module based on BaseParamStruct with accept_case: :capital" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its capital case" do + assert {:ok, %TestStructWithAcceptCaseCapital{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseCapital.new(%{param_named_with_multiple_words: 1}) + + assert {:ok, %TestStructWithAcceptCaseCapital{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseCapital.new(%{PARAM_NAMED_WITH_MULTIPLE_WORDS: 1}) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseCapital, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = TestStructWithAcceptCaseCapital.new(%{ParamNamedWithMultipleWords: 1}) + + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseCapital, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = TestStructWithAcceptCaseCapital.new(%{paramNamedWithMultipleWords: 1}) + end + end + + describe "from_params/1 of a struct module based on BaseParamStruct with accept_case: :capital" do + test "should return :ok with a struct if all fields are valid and the field name is either the same as the field name in the definition or its capital case" do + assert {:ok, %TestStructWithAcceptCaseCapital{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseCapital.from_params(%{ + "param_named_with_multiple_words" => 1 + }) + + assert {:ok, %TestStructWithAcceptCaseCapital{param_named_with_multiple_words: 1}} = + TestStructWithAcceptCaseCapital.from_params(%{ + "PARAM_NAMED_WITH_MULTIPLE_WORDS" => 1 + }) + end + + test "should return value missing error if the field name is not acceptable" do + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseCapital, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = + TestStructWithAcceptCaseCapital.from_params(%{"ParamNamedWithMultipleWords" => 1}) + + assert {:error, + {:value_missing, + [ + TestStructWithAcceptCaseCapital, + {Croma.PosInteger, :param_named_with_multiple_words} + ]}} = + TestStructWithAcceptCaseCapital.from_params(%{paramNamedWithMultipleWords: 1}) + end + end + + defmodule TestStructWithDefaultValue do + defmodule IntegerWithDefaultValue do + use Croma.SubtypeOfInt, min: 1, default: 1_000 + end + + use BaseParamStruct, + fields: [ + param_default_by_mod: IntegerWithDefaultValue, + param_default_by_val: {Croma.Integer, [default: 2_000]}, + param_pp_default_by_val: {Date, &Date.from_iso8601/1, [default: ~D[2001-01-01]]} + ] + end + + describe "new/1 of a struct module based on BaseParamStruct with default values" do + test "should return :ok with a struct if all fields are valid" do + assert {:ok, + %TestStructWithDefaultValue{ + param_default_by_mod: 1, + param_default_by_val: 2, + param_pp_default_by_val: ~D[1970-01-01] + }} = + TestStructWithDefaultValue.new(%{ + param_default_by_mod: 1, + param_default_by_val: 2, + param_pp_default_by_val: ~D[1970-01-01] + }) + end + + test "should return :ok with a struct if a field which has a default value is missing" do + assert {:ok, + %TestStructWithDefaultValue{ + param_default_by_mod: 1_000, + param_default_by_val: 2_000, + param_pp_default_by_val: ~D[2001-01-01] + }} = TestStructWithDefaultValue.new(%{}) + end + end + + describe "from_params/1 of a struct module based on BaseParamStruct with default values" do + test "should return :ok with a struct if all fields are valid" do + assert {:ok, + %TestStructWithDefaultValue{ + param_default_by_mod: 1, + param_default_by_val: 2, + param_pp_default_by_val: ~D[1970-01-01] + }} = + TestStructWithDefaultValue.from_params(%{ + "param_default_by_mod" => 1, + "param_default_by_val" => 2, + "param_pp_default_by_val" => "1970-01-01" + }) + end + + test "should return :ok with a struct if a field which has a default value is missing" do + assert {:ok, + %TestStructWithDefaultValue{ + param_default_by_mod: 1_000, + param_default_by_val: 2_000, + param_pp_default_by_val: ~D[2001-01-01] + }} = TestStructWithDefaultValue.from_params(%{}) + end + end +end diff --git a/test/lib/type/body_json_list_test.exs b/test/lib/type/body_json_list_test.exs new file mode 100644 index 00000000..7606cd7e --- /dev/null +++ b/test/lib/type/body_json_list_test.exs @@ -0,0 +1,237 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.BodyJsonListTest do + use Croma.TestCase + + defmodule TestListOfValidatableElem do + use BodyJsonList, elem_module: Croma.PosInteger + end + + describe "valid?/1 of a list based on BodyJsonList" do + test "should return true when all the elements are valid" do + [ + [], + [1], + [1, 2, 3] + ] + |> Enum.each(fn elem -> assert TestListOfValidatableElem.valid?(elem) end) + end + + test "should return false when an element is invalid" do + [ + [0], + ["invalid"], + [1, 0, 3] + ] + |> Enum.each(fn elem -> refute TestListOfValidatableElem.valid?(elem) end) + end + + test "should return false when the given value is not a list" do + refute TestListOfValidatableElem.valid?(1) + end + end + + describe "from_params/1 of a list based on BodyJsonList" do + test "should return :ok with a list when all the elements are valid" do + assert {:ok, [1, 2, 3]} = TestListOfValidatableElem.from_params([1, 2, 3]) + end + + test "should return invalid value error when an element is invalid" do + assert {:error, {:invalid_value, [TestListOfValidatableElem, Croma.PosInteger]}} = + TestListOfValidatableElem.from_params([1, 0, 3]) + end + + test "should return invalid value error when the given value is not a list" do + assert {:error, {:invalid_value, [TestListOfValidatableElem]}} = + TestListOfValidatableElem.from_params(1) + end + end + + defmodule TestListWithMinLength do + use BodyJsonList, elem_module: Croma.PosInteger, min_length: 2 + end + + describe "valid?/1 of a list based on BodyJsonList with minimum length" do + test "should return true if the length is greater than or equal to the minimum length and all the elements are valid" do + [ + [1, 2], + [1, 2, 3] + ] + |> Enum.each(fn elem -> assert TestListWithMinLength.valid?(elem) end) + end + + test "should return false if the length is less than the minimum length even if all the elements are valid" do + [ + [], + [1] + ] + |> Enum.each(fn elem -> refute TestListWithMinLength.valid?(elem) end) + end + + test "should return false if the length is greater than or equal to the minimum length but an element is invalid" do + [ + [0, 2], + [1, 0, 3] + ] + |> Enum.each(fn elem -> refute TestListWithMinLength.valid?(elem) end) + end + end + + describe "from_params/1 of a list based on BodyJsonList with minimum length" do + test "should return :ok with a list if the length is greater than or equal to the minimum length and all the elements are valid" do + assert {:ok, [1, 2, 3]} = TestListWithMinLength.from_params([1, 2, 3]) + end + + test "should return invalid value error if the length is less than the minimum length even if all the elements are valid" do + assert {:error, {:invalid_value, [TestListWithMinLength]}} = + TestListWithMinLength.from_params([1]) + end + + test "should return invalid value error if the length is greater than or equal to the minimum length but an element is invalid" do + assert {:error, {:invalid_value, [TestListWithMinLength, Croma.PosInteger]}} = + TestListWithMinLength.from_params([1, 0, 3]) + end + end + + defmodule TestListWithMaxLength do + use BodyJsonList, elem_module: Croma.PosInteger, max_length: 1 + end + + describe "valid?/1 of a list based on BodyJsonList with maximum length" do + test "should return true if the length is less than or equal to the maximum length and all the elements are valid" do + [ + [], + [1] + ] + |> Enum.each(fn elem -> assert TestListWithMaxLength.valid?(elem) end) + end + + test "should return false if the length is greater than the maximum length even if all the elements are valid" do + [ + [1, 2], + [1, 2, 3] + ] + |> Enum.each(fn elem -> refute TestListWithMaxLength.valid?(elem) end) + end + + test "should return false if the length is less than or equal to the maximum length but an element is invalid" do + refute TestListWithMaxLength.valid?([0]) + end + end + + describe "from_params/1 of a list based on BodyJsonList with maximum length" do + test "should return :ok with a list if the length is less than or equal to the maximum length and all the elements are valid" do + assert {:ok, [1]} = TestListWithMaxLength.from_params([1]) + end + + test "should return invalid value error if the length is greater than the maximum length even if all the elements are valid" do + assert {:error, {:invalid_value, [TestListWithMaxLength]}} = + TestListWithMaxLength.from_params([1, 2]) + end + + test "should return invalid value error if the length is less than or equal to the maximum length but an element is invalid" do + assert {:error, {:invalid_value, [TestListWithMaxLength, Croma.PosInteger]}} = + TestListWithMaxLength.from_params([0]) + end + end + + defmodule TestListWithBothMinAndMaxLength do + use BodyJsonList, elem_module: Croma.PosInteger, min_length: 2, max_length: 3 + end + + describe "valid?/1 of a list based on BodyJsonList with both minimum and maximum lengths" do + test "should return true if the length is within the specific range and all the elements are valid" do + [ + [1, 2], + [1, 2, 3] + ] + |> Enum.each(fn elem -> assert TestListWithBothMinAndMaxLength.valid?(elem) end) + end + + test "should return false if the length is less than the minimum length even if all the elements are valid" do + refute TestListWithBothMinAndMaxLength.valid?([1]) + end + + test "should return false if the length is greater than the maximum length even if all the elements are valid" do + refute TestListWithBothMinAndMaxLength.valid?([1, 2, 3, 4]) + end + + test "should return false if the length is within the range but an element is invalid" do + refute TestListWithBothMinAndMaxLength.valid?([1, 0, 3]) + end + end + + describe "from_params/1 of a list based on BodyJsonList with both minimum and maximum lengths" do + test "should return :ok with a list if the length is within the specific range and all the elements are valid" do + assert {:ok, [1, 2, 3]} = TestListWithBothMinAndMaxLength.from_params([1, 2, 3]) + end + + test "should return invalid value error if the length is less than the minimum length even if all the elements are valid" do + assert {:error, {:invalid_value, [TestListWithBothMinAndMaxLength]}} = + TestListWithBothMinAndMaxLength.from_params([1]) + end + + test "should return invalid value error if the length is greater than the maximum length even if all the elements are valid" do + assert {:error, {:invalid_value, [TestListWithBothMinAndMaxLength]}} = + TestListWithBothMinAndMaxLength.from_params([1, 2, 3, 4]) + end + + test "should return invalid value error if the length is within the range but an element is invalid" do + assert {:error, {:invalid_value, [TestListWithBothMinAndMaxLength, Croma.PosInteger]}} = + TestListWithBothMinAndMaxLength.from_params([1, 0, 3]) + end + end + + defmodule TestListWithCustomPreprocessor do + use BodyJsonList, elem_module: {Date, &Date.from_iso8601/1} + end + + describe "valid?/1 of a list based on BodyJsonList with a custom preprocessor" do + test "should return true when all the elements are valid" do + [ + [], + [~D[1970-01-01]], + [~D[1970-01-01], ~D[1970-01-02], ~D[1970-01-03]] + ] + |> Enum.each(fn elem -> assert TestListWithCustomPreprocessor.valid?(elem) end) + end + + test "should return false when an element is invalid" do + [ + ["1970-01-01"], + [~D[1970-01-01], "1970-01-02", ~D[1970-01-03]] + ] + |> Enum.each(fn elem -> refute TestListWithCustomPreprocessor.valid?(elem) end) + end + end + + describe "new/1 of a list based on BodyJsonList with a custom preprocessor" do + test "should return :ok with a list when all the elements are valid" do + assert {:ok, [~D[1970-01-01], ~D[1970-01-02], ~D[1970-01-03]]} = + TestListWithCustomPreprocessor.new([~D[1970-01-01], ~D[1970-01-02], ~D[1970-01-03]]) + end + + test "should return invalid value error when an element is invalid" do + assert {:error, {:invalid_value, [TestListWithCustomPreprocessor, Date]}} = + TestListWithCustomPreprocessor.new([~D[1970-01-01], "1970-01-02", ~D[1970-01-03]]) + end + end + + describe "from_params/1 of a list based on BodyJsonList with a custom preprocessor" do + test "should return :ok with a list when all the elements are valid and the elements are preprocessed" do + assert {:ok, [~D[1970-01-01], ~D[1970-01-02], ~D[1970-01-03]]} = + TestListWithCustomPreprocessor.from_params([ + "1970-01-01", + "1970-01-02", + "1970-01-03" + ]) + end + + test "should return invalid value error when an element cannot be preprocessed" do + assert {:error, {:invalid_value, [TestListWithCustomPreprocessor, Date]}} = + TestListWithCustomPreprocessor.from_params(["1970-01-01", "invalid", "1970-01-03"]) + end + end +end diff --git a/test/lib/type/body_json_map_test.exs b/test/lib/type/body_json_map_test.exs new file mode 100644 index 00000000..b1f7625a --- /dev/null +++ b/test/lib/type/body_json_map_test.exs @@ -0,0 +1,238 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.BodyJsonMapTest do + use Croma.TestCase + + defmodule TestMapOfValidatableValue do + use BodyJsonMap, value_module: Croma.PosInteger + end + + describe "valid?/1 of a map based on BodyJsonMap" do + test "should return true when all the values are valid" do + [ + %{}, + %{"a" => 1}, + %{"a" => 1, "b" => 2, "c" => 3} + ] + |> Enum.each(fn map -> assert TestMapOfValidatableValue.valid?(map) end) + end + + test "should return false when a value is invalid" do + [ + %{"a" => 0}, + %{"a" => "invalid"}, + %{"a" => 1, "b" => 0, "c" => 3} + ] + |> Enum.each(fn map -> refute TestMapOfValidatableValue.valid?(map) end) + end + + test "should return false when a key is not a string" do + refute TestMapOfValidatableValue.valid?(%{a: 1}) + end + + test "should return false when the given value is not a map" do + refute TestMapOfValidatableValue.valid?(1) + end + end + + describe "from_params/1 of a map based on BodyJsonMap" do + test "should return :ok with a map when all the values are valid" do + assert {:ok, %{"a" => 1, "b" => 2, "c" => 3}} = + TestMapOfValidatableValue.from_params(%{"a" => 1, "b" => 2, "c" => 3}) + end + + test "should return invalid value error when a value is invalid" do + assert {:error, {:invalid_value, [TestMapOfValidatableValue, Croma.PosInteger]}} = + TestMapOfValidatableValue.from_params(%{"a" => 1, "b" => 0, "c" => 3}) + end + + test "should return invalid value error when a key is not a string" do + assert {:error, {:invalid_value, [TestMapOfValidatableValue]}} = + TestMapOfValidatableValue.from_params(%{a: 1}) + end + + test "should return invalid value error when the given value is not a map" do + assert {:error, {:invalid_value, [TestMapOfValidatableValue]}} = + TestMapOfValidatableValue.from_params(1) + end + end + + defmodule TestMapWithMinSize do + use BodyJsonMap, value_module: Croma.PosInteger, min_size: 2 + end + + describe "valid/1 of a map based on BodyJsonMap with min_size option" do + test "should return true if the size is greater than or equal to the minimum size and all the values are valid" do + [ + %{"a" => 1, "b" => 2}, + %{"a" => 1, "b" => 2, "c" => 3} + ] + |> Enum.each(fn map -> assert TestMapWithMinSize.valid?(map) end) + end + + test "should return false if the size is less than the minimum size even if all the values are valid" do + [ + %{}, + %{"a" => 1} + ] + |> Enum.each(fn map -> refute TestMapWithMinSize.valid?(map) end) + end + + test "should return false if the size is greater than or equal to the minimum size but a value is invalid" do + refute TestMapWithMinSize.valid?(%{"a" => 0, "b" => 2}) + end + end + + describe "from_params/1 of a map based on BodyJsonMap with min_size option" do + test "should return :ok with a map if the size is greater than or equal to the minimum size and all the values are valid" do + assert {:ok, %{"a" => 1, "b" => 2, "c" => 3}} = + TestMapWithMinSize.from_params(%{"a" => 1, "b" => 2, "c" => 3}) + end + + test "should return invalid value error if the size is greater than or equal to the minimum size but a value is invalid" do + assert {:error, {:invalid_value, [TestMapWithMinSize, Croma.PosInteger]}} = + TestMapWithMinSize.from_params(%{"a" => 0, "b" => 2}) + end + + test "should return invalid value error if the size is less than the minimum size even if all the values are valid" do + assert {:error, {:invalid_value, [TestMapWithMinSize]}} = + TestMapWithMinSize.from_params(%{"a" => 1}) + end + end + + defmodule TestMapWithMaxSize do + use BodyJsonMap, value_module: Croma.PosInteger, max_size: 2 + end + + describe "valid/1 of a map based on BodyJsonMap with max_size option" do + test "should return true if the size is less than or equal to the maximum size and all the values are valid" do + [ + %{}, + %{"a" => 1}, + %{"a" => 1, "b" => 2} + ] + |> Enum.each(fn map -> assert TestMapWithMaxSize.valid?(map) end) + end + + test "should return false if the size is greater than the maximum size even if all the values are valid" do + refute TestMapWithMaxSize.valid?(%{"a" => 1, "b" => 2, "c" => 3}) + end + + test "should return false if the size is less than or equal to the maximum size but a value is invalid" do + refute TestMapWithMaxSize.valid?(%{"a" => 0, "b" => 2}) + end + end + + describe "from_params/1 of a map based on BodyJsonMap with max_size option" do + test "should return :ok with a map if the size is less than or equal to the maximum size and all the values are valid" do + assert {:ok, %{"a" => 1, "b" => 2}} = TestMapWithMaxSize.from_params(%{"a" => 1, "b" => 2}) + end + + test "should return invalid value error if the size is less than or equal to the maximum size but a value is invalid" do + assert {:error, {:invalid_value, [TestMapWithMaxSize, Croma.PosInteger]}} = + TestMapWithMaxSize.from_params(%{"a" => 0, "b" => 2}) + end + + test "should return invalid value error if the size is greater than the maximum size even if all the values are valid" do + assert {:error, {:invalid_value, [TestMapWithMaxSize]}} = + TestMapWithMaxSize.from_params(%{"a" => 1, "b" => 2, "c" => 3}) + end + end + + defmodule TestMapWithBothMinAndMaxSize do + use BodyJsonMap, value_module: Croma.PosInteger, min_size: 2, max_size: 3 + end + + describe "valid/1 of a map based on BodyJsonMap with both min_size and max_size options" do + test "should return true if the size is between the minimum size and the maximum size and all the values are valid" do + [ + %{"a" => 1, "b" => 2}, + %{"a" => 1, "b" => 2, "c" => 3} + ] + |> Enum.each(fn map -> assert TestMapWithBothMinAndMaxSize.valid?(map) end) + end + + test "should return false if the size is less than the minimum size even if all the values are valid" do + refute TestMapWithBothMinAndMaxSize.valid?(%{"a" => 1}) + end + + test "should return false if the size is greater than the maximum size even if all the values are valid" do + refute TestMapWithBothMinAndMaxSize.valid?(%{"a" => 1, "b" => 2, "c" => 3, "d" => 4}) + end + + test "should return false if the size is between the minimum size and the maximum size but a value is invalid" do + refute TestMapWithBothMinAndMaxSize.valid?(%{"a" => 0, "b" => 2}) + end + end + + describe "from_params/1 of a map based on BodyJsonMap with both min_size and max_size options" do + test "should return :ok with a map if the size is between the minimum size and the maximum size and all the values are valid" do + assert {:ok, %{"a" => 1, "b" => 2, "c" => 3}} = + TestMapWithBothMinAndMaxSize.from_params(%{"a" => 1, "b" => 2, "c" => 3}) + end + + test "should return invalid value error if the size is less than the minimum size even if all the values are valid" do + assert {:error, {:invalid_value, [TestMapWithBothMinAndMaxSize]}} = + TestMapWithBothMinAndMaxSize.from_params(%{"a" => 1}) + end + + test "should return invalid value error if the size is greater than the maximum size even if all the values are valid" do + assert {:error, {:invalid_value, [TestMapWithBothMinAndMaxSize]}} = + TestMapWithBothMinAndMaxSize.from_params(%{"a" => 1, "b" => 2, "c" => 3, "d" => 4}) + end + + test "should return invalid value error if the size is between the minimum size and the maximum size but a value is invalid" do + assert {:error, {:invalid_value, [TestMapWithBothMinAndMaxSize, Croma.PosInteger]}} = + TestMapWithBothMinAndMaxSize.from_params(%{"a" => 0, "b" => 2}) + end + end + + defmodule TestMapWithCustomPreprocessor do + use BodyJsonMap, value_module: {Date, &Date.from_iso8601/1} + end + + describe "valid?/1 of a map based on BodyJsonMap with a custom preprocessor" do + test "should return true if all the values are valid" do + [ + %{}, + %{"a" => ~D[1970-01-01]}, + %{"a" => ~D[1970-01-01], "b" => ~D[1970-01-02], "c" => ~D[1970-01-03]} + ] + |> Enum.each(fn map -> assert TestMapWithCustomPreprocessor.valid?(map) end) + end + + test "should return false if a value is invalid" do + [ + %{"a" => "1970-01-01"}, + %{"a" => ~D[1970-01-01], "b" => "1970-01-02", "c" => ~D[1970-01-03]} + ] + |> Enum.each(fn map -> refute TestMapWithCustomPreprocessor.valid?(map) end) + end + end + + describe "new/1 of a map based on BodyJsonMap with a custom preprocessor" do + test "should return :ok with a map if all the values are valid" do + assert {:ok, %{"a" => ~D[1970-01-01]}} = + TestMapWithCustomPreprocessor.new(%{"a" => ~D[1970-01-01]}) + end + + test "should return invalid value error if a value is invalid" do + assert {:error, {:invalid_value, [TestMapWithCustomPreprocessor, Date]}} = + TestMapWithCustomPreprocessor.new(%{"a" => "1970-01-01"}) + end + end + + describe "from_params/1 of a map based on BodyJsonMap with a custom preprocessor" do + test "should return :ok with a map if all the values are valid and the values are preprocessed" do + assert {:ok, %{"a" => ~D[1970-01-01]}} = + TestMapWithCustomPreprocessor.from_params(%{"a" => "1970-01-01"}) + end + + test "should return invalid value error if a value is invalid" do + assert {:error, {:invalid_value, [TestMapWithCustomPreprocessor, Date]}} = + TestMapWithCustomPreprocessor.from_params(%{"a" => "invalid"}) + end + end +end diff --git a/test/lib/type/body_json_struct_test.exs b/test/lib/type/body_json_struct_test.exs new file mode 100644 index 00000000..e324ebef --- /dev/null +++ b/test/lib/type/body_json_struct_test.exs @@ -0,0 +1,127 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.BodyJsonStructTest do + use Croma.TestCase + + defmodule TestStructOfNestedStructField do + defmodule NestedStruct do + use BodyJsonStruct, + fields: [ + param_pos_int: Croma.PosInteger + ] + end + + use BodyJsonStruct, + fields: [ + param_object: NestedStruct + ] + end + + describe "from_params/1 of a struct based on BodyJsonStruct with nested structs" do + test "should return :ok with a struct nested in a struct if all the fields are valid" do + params = %{ + "param_object" => %{ + "param_pos_int" => 1 + } + } + + assert {:ok, + %TestStructOfNestedStructField{ + param_object: %TestStructOfNestedStructField.NestedStruct{param_pos_int: 1} + }} = TestStructOfNestedStructField.from_params(params) + end + + test "should return invalid value error when a field of a nested struct is invalid" do + params = %{ + "param_object" => %{ + "param_pos_int" => 0 + } + } + + assert {:error, + {:invalid_value, + [ + TestStructOfNestedStructField, + {TestStructOfNestedStructField.NestedStruct, :param_object}, + {Croma.PosInteger, :param_pos_int} + ]}} = TestStructOfNestedStructField.from_params(params) + end + + test "should return value missing error when a field of a nested struct is missing" do + params = %{ + "param_object" => %{} + } + + assert {:error, + {:value_missing, + [ + TestStructOfNestedStructField, + {TestStructOfNestedStructField.NestedStruct, :param_object}, + {Croma.PosInteger, :param_pos_int} + ]}} = TestStructOfNestedStructField.from_params(params) + end + end + + defmodule TestStructOfListAndMap do + defmodule TestList do + use Antikythera.BodyJsonList, elem_module: Croma.PosInteger + end + + defmodule TestMap do + use Antikythera.BodyJsonMap, value_module: Croma.PosInteger + end + + use Antikythera.BodyJsonStruct, + fields: [ + param_list: TestList, + param_map: TestMap + ] + end + + describe "from_params/1 of a struct based on BodyJsonStruct with lists and maps" do + test "should return :ok with a struct if all the fields are valid" do + params = %{ + "param_list" => [1, 2, 3], + "param_map" => %{"a" => 1, "b" => 2, "c" => 3} + } + + assert {:ok, + %TestStructOfListAndMap{ + param_list: [1, 2, 3], + param_map: %{"a" => 1, "b" => 2, "c" => 3} + }} = TestStructOfListAndMap.from_params(params) + end + + test "should return invalid value error when an element of a list is invalid" do + params = %{ + "param_list" => [1, 0, 3], + "param_map" => %{"a" => 1, "b" => 2, "c" => 3} + } + + assert {:error, + {:invalid_value, + [ + TestStructOfListAndMap, + {TestStructOfListAndMap.TestList, :param_list}, + Croma.PosInteger + ]}} = TestStructOfListAndMap.from_params(params) + end + + test "should return invalid value error when a value of a map is invalid" do + params = %{ + "param_list" => [1, 2, 3], + "param_map" => %{"a" => 1, "b" => 0, "c" => 3} + } + + assert {:error, + {:invalid_value, + [ + TestStructOfListAndMap, + {TestStructOfListAndMap.TestMap, :param_map}, + Croma.PosInteger + ]}} = TestStructOfListAndMap.from_params(params) + end + end +end diff --git a/test/lib/type/param_string_struct_test.exs b/test/lib/type/param_string_struct_test.exs new file mode 100644 index 00000000..72a4e742 --- /dev/null +++ b/test/lib/type/param_string_struct_test.exs @@ -0,0 +1,239 @@ +# Copyright(c) 2015-2024 ACCESS CO., LTD. All rights reserved. + +use Croma + +defmodule Antikythera.ParamStringStructTest do + use Croma.TestCase + use ExUnitProperties + + defunp croma_builtins_with_generators() :: v[[{module, (() -> StreamData.t(term))}]] do + [ + {Croma.Boolean, fn -> boolean() end}, + {Croma.Float, fn -> float() end}, + {Croma.Integer, fn -> integer() end}, + {Croma.NegInteger, fn -> positive_integer() |> map(&(-&1)) end}, + {Croma.NonNegInteger, fn -> non_negative_integer() end}, + {Croma.Number, fn -> one_of([float(), integer()]) end}, + {Croma.PosInteger, fn -> positive_integer() end}, + {Croma.String, fn -> string(:utf8) end} + ] + end + + describe "default preprocessor" do + property "should convert a string to its corresponding Croma built-in types if the string can be naturally converted" do + croma_builtins_with_generators() + |> Enum.each(fn {mod, gen} -> + assert {:ok, f} = ParamStringStruct.PreprocessorGenerator.generate(mod) + + check all(v <- gen.()) do + case f.(to_string(v)) do + {:ok, x} -> assert x === v + x -> assert x === v + end + end + end) + end + + property "should convert a string to its corresponding nilable Croma built-in types if the string can be naturally converted" do + croma_builtins_with_generators() + |> Enum.each(fn {mod, gen} -> + assert {:ok, f} = + ParamStringStruct.PreprocessorGenerator.generate(Croma.TypeGen.nilable(mod)) + + check all(v <- gen.()) do + case f.(to_string(v)) do + {:ok, x} -> assert x === v + x -> assert x === v + end + end + end) + end + + test "should accept nil as a nilable Croma built-in type" do + croma_builtins_with_generators() + |> Enum.each(fn {mod, _gen} -> + assert {:ok, f} = + ParamStringStruct.PreprocessorGenerator.generate(Croma.TypeGen.nilable(mod)) + + case f.(nil) do + {:ok, x} -> assert is_nil(x) + x -> assert is_nil(x) + end + end) + end + + test "should convert a string to its corresponding DateTime-related types if the string can be naturally converted" do + [ + {Date, [~D[1970-01-01], ~D[9999-12-31]]}, + {DateTime, [~U[1970-01-01T00:00:00Z], ~U[9999-12-31T23:59:59.999999Z]]}, + {NaiveDateTime, [~N[1970-01-01T00:00:00], ~N[9999-12-31T23:59:59.999999]]}, + {Time, [~T[00:00:00], ~T[23:59:59.999999]]} + ] + |> Enum.each(fn {mod, valid_values} -> + assert {:ok, f} = ParamStringStruct.PreprocessorGenerator.generate(mod) + + Enum.each(valid_values, fn v -> + case f.(to_string(v)) do + {:ok, x} -> assert x === v + x -> assert x === v + end + end) + end) + end + + test "should convert a string to DateTime if the string is in ISO 8601 format with an arbitrary time zone" do + assert {:ok, f} = ParamStringStruct.PreprocessorGenerator.generate(DateTime) + + case f.("2024-02-01T00:00:00+09:00") do + {:ok, x} -> assert x === ~U[2024-01-31T15:00:00Z] + x -> assert x === ~U[2024-01-31T15:00:00Z] + end + end + + test "should not be defined for unsupported types" do + defmodule Time do + use Croma.SubtypeOfInt, min: 0, max: 86_399 + end + + [Time, Croma.TypeGen.nilable(Croma.Atom)] + |> Enum.each(fn mod -> + assert {:error, :no_default_preprocessor} = + ParamStringStruct.PreprocessorGenerator.generate(mod) + end) + end + end + + defmodule TestParamStringStruct do + use ParamStringStruct, + fields: [ + param_boolean: Croma.Boolean, + param_float: Croma.Float, + param_integer: Croma.Integer, + param_neg_integer: Croma.NegInteger, + param_non_neg_integer: Croma.NonNegInteger, + param_number: Croma.Number, + param_pos_integer: Croma.PosInteger, + param_string: Croma.String, + param_date: Date, + param_datetime: DateTime, + param_naive_datetime: NaiveDateTime, + param_time: Time + ] + end + + describe "from_params/1 of a struct module based on ParamStringStruct" do + test "should return :ok with a struct if all parameters are valid" do + params = %{ + "param_boolean" => "true", + "param_float" => "1.0", + "param_integer" => "1", + "param_neg_integer" => "-1", + "param_non_neg_integer" => "0", + "param_number" => "1", + "param_pos_integer" => "1", + "param_string" => "string", + "param_date" => "2024-02-01", + "param_datetime" => "2024-02-01T00:00:00+09:00", + "param_naive_datetime" => "2024-02-01T00:00:00", + "param_time" => "00:00:00" + } + + assert {:ok, + %TestParamStringStruct{ + param_boolean: true, + param_float: 1.0, + param_integer: 1, + param_neg_integer: -1, + param_non_neg_integer: 0, + param_number: 1, + param_pos_integer: 1, + param_string: "string", + param_date: ~D[2024-02-01], + param_datetime: ~U[2024-01-31T15:00:00Z], + param_naive_datetime: ~N[2024-02-01T00:00:00], + param_time: ~T[00:00:00] + }} = TestParamStringStruct.from_params(params) + end + + test "should return invalid value error if a parameter is invalid" do + [ + {"param_boolean", ["nil", "invalid"]}, + {"param_float", ["0", "invalid"]}, + {"param_integer", ["0.0", "invalid"]}, + {"param_neg_integer", ["0", "invalid"]}, + {"param_non_neg_integer", ["-1", "invalid"]}, + {"param_number", ["invalid"]}, + {"param_pos_integer", ["0", "invalid"]}, + {"param_date", ["2024-02-30", "invalid"]}, + {"param_datetime", ["2024-02-01T00:00:00", "invalid"]}, + {"param_naive_datetime", ["2024-02-30T00:00:00", "invalid"]}, + {"param_time", ["24:00:00", "invalid"]} + ] + |> Enum.each(fn {field_name, invalid_values} -> + Enum.each(invalid_values, fn invalid_value -> + params = + %{ + "param_boolean" => "true", + "param_float" => "1.0", + "param_integer" => "1", + "param_neg_integer" => "-1", + "param_non_neg_integer" => "0", + "param_number" => "1", + "param_pos_integer" => "1", + "param_string" => "string", + "param_date" => "2024-02-01", + "param_datetime" => "2024-02-01T00:00:00+09:00", + "param_naive_datetime" => "2024-02-01T00:00:00", + "param_time" => "00:00:00" + } + |> Map.put(field_name, invalid_value) + + field_name_atom = String.to_existing_atom(field_name) + + assert {:error, {:invalid_value, [TestParamStringStruct, {_type, ^field_name_atom}]}} = + TestParamStringStruct.from_params(params) + end) + end) + end + + test "should return value missing error if a parameter is missing" do + [ + "param_boolean", + "param_float", + "param_integer", + "param_neg_integer", + "param_non_neg_integer", + "param_number", + "param_pos_integer", + "param_string", + "param_date", + "param_datetime", + "param_naive_datetime", + "param_time" + ] + |> Enum.each(fn field_name -> + params = + %{ + "param_boolean" => "true", + "param_float" => "1.0", + "param_integer" => "1", + "param_neg_integer" => "-1", + "param_non_neg_integer" => "0", + "param_number" => "1", + "param_pos_integer" => "1", + "param_string" => "string", + "param_date" => "2024-02-01", + "param_datetime" => "2024-02-01T00:00:00+09:00", + "param_naive_datetime" => "2024-02-01T00:00:00", + "param_time" => "00:00:00" + } + |> Map.delete(field_name) + + field_name_atom = String.to_existing_atom(field_name) + + assert {:error, {:value_missing, [TestParamStringStruct, {_type, ^field_name_atom}]}} = + TestParamStringStruct.from_params(params) + end) + end + end +end