From cc66e90640d4e7511160d49542936df95b43a678 Mon Sep 17 00:00:00 2001 From: Georgy Shabunin Date: Mon, 9 Sep 2024 18:59:17 +0100 Subject: [PATCH] Add constraints phase --- lib/constraints.ex | 11 ++ lib/constraints/max.ex | 27 ++++ lib/constraints/max_items.ex | 17 +++ lib/constraints/min.ex | 27 ++++ lib/constraints/min_items.ex | 17 +++ lib/directives/constraints.ex | 78 ++++++++++ lib/phases/apply_constraints.ex | 135 ++++++++++++++++++ lib/phases/apply_transforms.ex | 2 +- mix.exs | 3 +- mix.lock | 1 + .../phases/apply_constraints_test.exs | 107 ++++++++++++++ test/support/transforms/increment.ex | 2 +- 12 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 lib/constraints.ex create mode 100644 lib/constraints/max.ex create mode 100644 lib/constraints/max_items.ex create mode 100644 lib/constraints/min.ex create mode 100644 lib/constraints/min_items.ex create mode 100644 lib/directives/constraints.ex create mode 100644 lib/phases/apply_constraints.ex create mode 100644 test/absinthe_helpers/phases/apply_constraints_test.exs diff --git a/lib/constraints.ex b/lib/constraints.ex new file mode 100644 index 0000000..db90ff5 --- /dev/null +++ b/lib/constraints.ex @@ -0,0 +1,11 @@ +defmodule AbsintheHelpers.Constraint do + @moduledoc false + + alias Absinthe.Blueprint.Input + + @type error_reason :: atom() + @type error_details :: map() + + @callback call(Input.Value.t(), tuple()) :: + {:ok, Input.Value.t()} | {:error, error_reason(), error_details()} +end diff --git a/lib/constraints/max.ex b/lib/constraints/max.ex new file mode 100644 index 0000000..127f9bb --- /dev/null +++ b/lib/constraints/max.ex @@ -0,0 +1,27 @@ +defmodule AbsintheHelpers.Constraints.Max do + @moduledoc false + + @behaviour AbsintheHelpers.Constraint + + def call(node = %{items: _items}, {:max, _min}) do + {:ok, node} + end + + def call(node = %{data: data = %Decimal{}}, {:max, max}) do + if is_integer(max) and Decimal.gt?(data, max), + do: {:error, :max_exceeded, %{max: max, value: data}}, + else: {:ok, node} + end + + def call(node = %{data: data}, {:max, max}) when is_binary(data) do + if String.length(data) > max, + do: {:error, :max_exceeded, %{max: max, value: data}}, + else: {:ok, node} + end + + def call(node = %{data: data}, {:max, max}) do + if data > max, + do: {:error, :max_exceeded, %{max: max, value: data}}, + else: {:ok, node} + end +end diff --git a/lib/constraints/max_items.ex b/lib/constraints/max_items.ex new file mode 100644 index 0000000..d26ca48 --- /dev/null +++ b/lib/constraints/max_items.ex @@ -0,0 +1,17 @@ +defmodule AbsintheHelpers.Constraints.MaxItems do + @moduledoc false + + @behaviour AbsintheHelpers.Constraint + + def call(node = %{items: items}, {:max_items, max_items}) do + if Enum.count(items) > max_items do + {:error, :max_items_exceeded, %{max_items: max_items, items: Enum.map(items, & &1.data)}} + else + {:ok, node} + end + end + + def call(node, {:max_items, _max_items}) do + {:ok, node} + end +end diff --git a/lib/constraints/min.ex b/lib/constraints/min.ex new file mode 100644 index 0000000..d64cd6a --- /dev/null +++ b/lib/constraints/min.ex @@ -0,0 +1,27 @@ +defmodule AbsintheHelpers.Constraints.Min do + @moduledoc false + + @behaviour AbsintheHelpers.Constraint + + def call(node = %{items: _items}, {:min, _min}) do + {:ok, node} + end + + def call(node = %{data: data = %Decimal{}}, {:min, min}) do + if is_integer(min) and Decimal.lt?(data, min), + do: {:error, :min_not_met, %{min: min, value: data}}, + else: {:ok, node} + end + + def call(node = %{data: data}, {:min, min}) when is_binary(data) do + if String.length(data) < min, + do: {:error, :min_not_met, %{min: min, value: data}}, + else: {:ok, node} + end + + def call(node = %{data: data}, {:min, min}) do + if data < min, + do: {:error, :min_not_met, %{min: min, value: data}}, + else: {:ok, node} + end +end diff --git a/lib/constraints/min_items.ex b/lib/constraints/min_items.ex new file mode 100644 index 0000000..66ea901 --- /dev/null +++ b/lib/constraints/min_items.ex @@ -0,0 +1,17 @@ +defmodule AbsintheHelpers.Constraints.MinItems do + @moduledoc false + + @behaviour AbsintheHelpers.Constraint + + def call(node = %{items: items}, {:min_items, min_items}) do + if Enum.count(items) < min_items do + {:error, :min_items_not_met, %{min_items: min_items, items: Enum.map(items, & &1.data)}} + else + {:ok, node} + end + end + + def call(node, {:min_items, _min_items}) do + {:ok, node} + end +end diff --git a/lib/directives/constraints.ex b/lib/directives/constraints.ex new file mode 100644 index 0000000..4cce38f --- /dev/null +++ b/lib/directives/constraints.ex @@ -0,0 +1,78 @@ +defmodule AbsintheHelpers.Directives.Constraints do + @moduledoc """ + Defines a GraphQL directive to add constraints to field definitions + and argument definitions. + + ## Example Usage + + input_object :my_input do + field(:my_field, :integer, directives: [constraints: [min: 1]]) + end + + object :my_query do + field :my_field, non_null(:string) do + arg(:my_arg, non_null(:string), directives: [constraints: [max: 1000]]) + + resolve(&MyResolver.resolve/3) + end + end + """ + + use Absinthe.Schema.Prototype + + alias Absinthe.Blueprint.TypeReference.{List, NonNull} + + @constraints %{ + string: [:min, :max], + number: [:min, :max], + list: [:min, :max, :min_items, :max_items] + } + + directive :constraints do + on([:argument_definition, :field_definition]) + + arg(:min, :integer, description: "Minimum value allowed") + arg(:max, :integer, description: "Maximum value allowed") + arg(:min_items, :integer, description: "Minimum number of items allowed in a list") + arg(:max_items, :integer, description: "Maximum number of items allowed in a list") + + expand(&__MODULE__.expand_constraints/2) + end + + def expand_constraints(args, node = %{type: type}) do + do_expand(args, node, get_args(type)) + end + + defp get_args(:string), do: @constraints.string + defp get_args(type) when type in [:integer, :float, :decimal], do: @constraints.number + defp get_args(%List{}), do: @constraints.list + defp get_args(%NonNull{of_type: of_type}), do: get_args(of_type) + defp get_args(type), do: raise(ArgumentError, "Unsupported type: #{inspect(type)}") + + defp do_expand(args, node, allowed_args) do + {valid_args, invalid_args} = Map.split(args, allowed_args) + handle_invalid_args(node, invalid_args) + update_node(valid_args, node) + end + + defp handle_invalid_args(_, args) when map_size(args) == 0, do: :ok + + defp handle_invalid_args(%{type: type, name: name, __reference__: reference}, invalid_args) do + args = Map.keys(invalid_args) + location_line = get_in(reference, [:location, :line]) + + raise Absinthe.Schema.Error, + phase_errors: [ + %Absinthe.Phase.Error{ + phase: __MODULE__, + message: + "Invalid constraints for field/arg `#{name}` of type `#{inspect(type)}`: #{inspect(args)}", + locations: [%{line: location_line, column: 0}] + } + ] + end + + defp update_node(args, node) do + %{node | __private__: Keyword.put(node.__private__, :constraints, args)} + end +end diff --git a/lib/phases/apply_constraints.ex b/lib/phases/apply_constraints.ex new file mode 100644 index 0000000..ee71615 --- /dev/null +++ b/lib/phases/apply_constraints.ex @@ -0,0 +1,135 @@ +defmodule AbsintheHelpers.Phases.ApplyConstraints do + @moduledoc """ + Validates input nodes against constraints defined by the `constraints` + directive in your Absinthe schema. Constraints can be applied to fields + and arguments, enforcing rules such as `min`, `max`, etc. These constraints + can be applied to both individual items and lists simultaneously. + + ## Example usage + + Add this phase to your pipeline in your router: + + pipeline = + config + |> Absinthe.Plug.default_pipeline(opts) + |> AbsintheHelpers.Phases.ApplyConstraints.add_to_pipeline(opts) + + Add the constraints directive's prototype schema to your schema: + + defmodule MyApp.Schema do + use Absinthe.Schema + @prototype_schema AbsintheHelpers.Directives.Constraints + # ... + end + + Apply constraints to a field or argument: + + field :my_field, :integer, directives: [constraints: [min: 1, max: 10]] do + resolve(&MyResolver.resolve/3) + end + + arg :my_arg, non_null(:string), directives: [constraints: [min: 10]] + + field :my_list, list_of(:integer), directives: [constraints: [min_items: 2, max_items: 5, min: 1, max: 100]] do + resolve(&MyResolver.resolve/3) + end + """ + + use Absinthe.Phase + + alias Absinthe.Blueprint + alias Absinthe.Phase + alias Blueprint.Input + + def add_to_pipeline(pipeline, opts) do + Absinthe.Pipeline.insert_before( + pipeline, + Phase.Document.Validation.Result, + {__MODULE__, opts} + ) + end + + @impl Absinthe.Phase + def run(input, _opts \\ []) do + {:ok, Blueprint.postwalk(input, &handle_node/1)} + end + + defp handle_node( + node = %{ + input_value: %{normalized: normalized}, + schema_node: %{__private__: private} + } + ) do + if constraints?(private), do: apply_constraints(node, normalized), else: node + end + + defp handle_node(node), do: node + + defp apply_constraints(node, list = %Input.List{items: _items}) do + with {:ok, _list} <- validate_list(list, node.schema_node.__private__), + {:ok, _items} <- validate_items(list.items, node.schema_node.__private__) do + node + else + {:error, reason, details} -> add_custom_error(node, reason, details) + end + end + + defp apply_constraints(node, %{value: _value}) do + case validate_item(node.input_value, node.schema_node.__private__) do + {:ok, _validated_value} -> node + {:error, reason, details} -> add_custom_error(node, reason, details) + end + end + + defp apply_constraints(node, _), do: node + + defp validate_list(list, private_tags) do + apply_constraints_in_sequence(list, get_constraints(private_tags)) + end + + defp validate_items(items, private_tags) do + Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} -> + case validate_item(item, private_tags) do + {:ok, validated_item} -> {:cont, {:ok, acc ++ [validated_item]}} + {:error, reason, details} -> {:halt, {:error, reason, details}} + end + end) + end + + defp validate_item(item, private_tags) do + apply_constraints_in_sequence(item, get_constraints(private_tags)) + end + + defp apply_constraints_in_sequence(item, constraints) do + Enum.reduce_while(constraints, {:ok, item}, fn constraint, {:ok, acc} -> + case call_constraint(constraint, acc) do + {:ok, result} -> {:cont, {:ok, result}} + {:error, reason, details} -> {:halt, {:error, reason, details}} + end + end) + end + + defp call_constraint(constraint = {name, _args}, input) do + get_constraint_module(name).call(input, constraint) + end + + defp get_constraint_module(constraint_name) do + String.to_existing_atom( + "Elixir.AbsintheHelpers.Constraints.#{Macro.camelize(Atom.to_string(constraint_name))}" + ) + end + + defp get_constraints(private), do: Keyword.get(private, :constraints, []) + + defp constraints?(private), do: private |> get_constraints() |> Enum.any?() + + defp add_custom_error(node, reason, details) do + Phase.put_error(node, %Phase.Error{ + phase: __MODULE__, + message: reason, + extra: %{ + details: Map.merge(details, %{field: node.name}) + } + }) + end +end diff --git a/lib/phases/apply_transforms.ex b/lib/phases/apply_transforms.ex index 823e754..e74a040 100644 --- a/lib/phases/apply_transforms.ex +++ b/lib/phases/apply_transforms.ex @@ -8,7 +8,7 @@ defmodule AbsintheHelpers.Phases.ApplyTransforms do `AbsintheHelpers.Transforms.ToInteger`, or within your own project, as long as they implement the same behaviour. - ## Example Usage + ## Example usage To add this phase to your pipeline, add the following to your router: diff --git a/mix.exs b/mix.exs index a95914e..45c67ca 100644 --- a/mix.exs +++ b/mix.exs @@ -32,7 +32,8 @@ defmodule AbsintheHelpers.MixProject do {:dialyxir, "~> 1.0", only: :dev, runtime: false}, {:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:absinthe, "~> 1.0"}, - {:mimic, "~> 1.10", only: :test} + {:mimic, "~> 1.10", only: :test}, + {:decimal, "~> 1.9"} ] end diff --git a/mix.lock b/mix.lock index 9433c7c..f90720c 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "absinthe": {:hex, :absinthe, "1.7.8", "43443d12ad2b4fcce60e257ac71caf3081f3d5c4ddd5eac63a02628bcaf5b556", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4085df201892a498384f997649aedb37a4ce8a726c170d5b5617ed3bf45d40b"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "decimal": {:hex, :decimal, "1.9.0", "83e8daf59631d632b171faabafb4a9f4242c514b0a06ba3df493951c08f64d07", [:mix], [], "hexpm", "b1f2343568eed6928f3e751cf2dffde95bfaa19dd95d09e8a9ea92ccfd6f7d85"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, diff --git a/test/absinthe_helpers/phases/apply_constraints_test.exs b/test/absinthe_helpers/phases/apply_constraints_test.exs new file mode 100644 index 0000000..c5452b7 --- /dev/null +++ b/test/absinthe_helpers/phases/apply_constraints_test.exs @@ -0,0 +1,107 @@ +defmodule AbsintheHelpers.Phases.ApplyConstraintsTest do + use ExUnit.Case, async: true + + alias AbsintheHelpers.Phases.ApplyConstraints + alias AbsintheHelpers.TestResolver + + describe "apply constraints phase with min/max on integers, decimals, and strings" do + defmodule TestSchema do + use Absinthe.Schema + + import_types(Absinthe.Type.Custom) + + @prototype_schema AbsintheHelpers.Directives.Constraints + + query do + field :get_booking, non_null(:string) do + resolve(&TestResolver.run/3) + end + end + + mutation do + field(:create_booking, :string) do + arg(:customer_id, non_null(:integer), directives: [constraints: [min: 1, max: 1000]]) + arg(:service, non_null(:service_input)) + + resolve(&TestResolver.run/3) + end + end + + input_object :service_input do + field(:cost, :decimal, directives: [constraints: [min: 10, max: 1000]]) + field(:description, :string, directives: [constraints: [min: 5, max: 50]]) + + field( + :override_ids, + non_null(list_of(non_null(:integer))), + directives: [constraints: [min_items: 3, min: 5, max: 50]] + ) + + field( + :location_ids, + non_null(list_of(non_null(:integer))), + directives: [constraints: [min_items: 2, min: 5, max: 50]] + ) + end + + def run_query(query) do + Absinthe.run( + query, + __MODULE__, + pipeline_modifier: &ApplyConstraints.add_to_pipeline/2 + ) + end + end + + test "validates mutation arguments including decimal and string constraints and returns success" do + query = """ + mutation { + create_booking( + customer_id: 1, + service: { + cost: "150.75", + description: "Valid description", + override_ids: [6, 7, 8], + location_ids: [8, 9, 10] + } + ) + } + """ + + assert TestSchema.run_query(query) == {:ok, %{data: %{"create_booking" => ""}}} + end + + test "returns errors for invalid decimal and string arguments" do + query = """ + mutation { + create_booking( + customer_id: 1001, + service: { + cost: "5.00", + description: "bad", + override_ids: [6, 1, 7], + location_ids: [1] + } + ) + } + """ + + assert {:ok, + %{ + errors: [ + %{ + message: :max_exceeded, + details: %{field: "customer_id", max: 1000, value: 1001} + }, + %{message: :min_not_met, details: %{field: "cost", min: 10, value: %Decimal{}}}, + %{message: :min_not_met, details: %{field: "description", min: 5, value: "bad"}}, + %{message: :min_not_met, details: %{field: "override_ids", min: 5, value: 1}}, + %{ + message: :min_items_not_met, + details: %{field: "location_ids", min_items: 2, items: [1]} + } + ] + }} = TestSchema.run_query(query) + end + end +end diff --git a/test/support/transforms/increment.ex b/test/support/transforms/increment.ex index e69468c..c3b3358 100644 --- a/test/support/transforms/increment.ex +++ b/test/support/transforms/increment.ex @@ -5,7 +5,7 @@ defmodule AbsintheHelpers.Transforms.Increment do @behaviour AbsintheHelpers.Transform - def call(%Input.Value{data: data} = item, [step]) do + def call(item = %Input.Value{data: data}, [step]) do {:ok, %{item | data: data + step}} end end