From 92d473e359e44294a2a564ab3d598327fd5386ed 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 | 5 + lib/constraints/max.ex | 23 +++ lib/constraints/min.ex | 23 +++ lib/directives/constraints.ex | 75 +++++++++ lib/phases/apply_constraints.ex | 152 ++++++++++++++++++ mix.exs | 3 +- mix.lock | 1 + .../constraints/apply_constraints_test.exs | 76 +++++++++ 8 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 lib/constraints.ex create mode 100644 lib/constraints/max.ex create mode 100644 lib/constraints/min.ex create mode 100644 lib/directives/constraints.ex create mode 100644 lib/phases/apply_constraints.ex create mode 100644 test/absinthe_helpers/constraints/apply_constraints_test.exs diff --git a/lib/constraints.ex b/lib/constraints.ex new file mode 100644 index 0000000..d59ad5c --- /dev/null +++ b/lib/constraints.ex @@ -0,0 +1,5 @@ +defmodule AbsintheHelpers.Constraint do + @moduledoc false + + @callback call(Tuple.t(), Input.Value.t()) :: {:ok, Input.Value.t()} | {:error, atom()} +end diff --git a/lib/constraints/max.ex b/lib/constraints/max.ex new file mode 100644 index 0000000..7dd52e8 --- /dev/null +++ b/lib/constraints/max.ex @@ -0,0 +1,23 @@ +defmodule AbsintheHelpers.Constraints.Max do + @moduledoc false + + @behaviour AbsintheHelpers.Constraint + + def call({:max, max}, node = %{data: decimal = %Decimal{}}) do + if is_integer(max) and Decimal.gt?(decimal, max), + do: {:error, :max}, + else: {:ok, node} + end + + def call({:max, max}, node = %{data: data}) when is_binary(data) do + if String.length(data) > max, + do: {:error, :max}, + else: {:ok, node} + end + + def call({:max, max}, node = %{data: data}) do + if data > max, + do: {:error, :max}, + else: {:ok, node} + end +end diff --git a/lib/constraints/min.ex b/lib/constraints/min.ex new file mode 100644 index 0000000..326b474 --- /dev/null +++ b/lib/constraints/min.ex @@ -0,0 +1,23 @@ +defmodule AbsintheHelpers.Constraints.Min do + @moduledoc false + + @behaviour AbsintheHelpers.Constraint + + def call({:min, min}, node = %{data: decimal = %Decimal{}}) do + if is_integer(min) and Decimal.lt?(decimal, min), + do: {:error, :min}, + else: {:ok, node} + end + + def call({:min, min}, node = %{data: data}) when is_binary(data) do + if String.length(data) < min, + do: {:error, :min}, + else: {:ok, node} + end + + def call({:min, min}, node = %{data: data}) do + if data < min, + do: {:error, :min}, + else: {:ok, node} + end +end diff --git a/lib/directives/constraints.ex b/lib/directives/constraints.ex new file mode 100644 index 0000000..a53e4a5 --- /dev/null +++ b/lib/directives/constraints.ex @@ -0,0 +1,75 @@ +defmodule AbsintheHelpers.Directives.Constraints do + @moduledoc """ + Defines a GraphQL directive to add constraints to field definitions and argument definitions. + + Example + + ```elixir + 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: [format: "uuid"]]) + resolve(&MyResolver.resolve/2) + end + end + ``` + """ + + use Absinthe.Schema.Prototype + + alias Absinthe.Blueprint.TypeReference.NonNull + + @string_args [:min, :max] + @number_args [:min, :max] + + directive :constraints do + on([:argument_definition, :field_definition]) + + arg(:min, :integer, description: "Ensure value is greater than or equal to") + arg(:max, :integer, description: "Ensure value is less than or equal to") + + expand(&__MODULE__.expand_constraints/2) + end + + def expand_constraints(args, %{type: type} = node), + do: do_expand(args, node, get_args(type)) + + defp get_args(:string), do: @string_args + defp get_args(:integer), do: @number_args + defp get_args(:float), do: @number_args + defp get_args(:decimal), do: @number_args + defp get_args(%NonNull{of_type: of_type}), do: get_args(of_type) + defp get_args(type), do: raise("Unsupported type: #{inspect(type)}") + + defp do_expand(args, node, args_list) do + {valid_args, invalid_args} = Map.split(args, args_list) + handle_invalid_args(node, invalid_args) + + update_node(valid_args, node) + end + + defp handle_invalid_args(_, args) when args == %{}, do: nil + + defp handle_invalid_args(%{type: type, name: name} = node, invalid_args) do + args = Map.keys(invalid_args) + location_line = get_in(node.__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..6cb9a27 --- /dev/null +++ b/lib/phases/apply_constraints.ex @@ -0,0 +1,152 @@ +defmodule AbsintheHelpers.Phases.ApplyConstraints do + @moduledoc """ + This module 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. + + ## Example Usage + + To add this phase to your pipeline, add the following to your router: + + forward "/graphql", + to: Absinthe.Plug, + init_opts: [ + schema: MyProject.Schema, + pipeline: {__MODULE__, :absinthe_pipeline}, + ] + + def absinthe_pipeline(config, opts) do + config + |> Absinthe.Plug.default_pipeline(opts) + |> AbsintheHelpers.Phases.ApplyConstraints.add_to_pipeline(opts) + end + + Then add the constraints directive's prototype schema to your schema + + defmodule MyApp.Schema do + use Absinthe.Schema + + @prototype_schema AbsintheHelpers.Directives.Constraints + + ... + end + + Then apply constraints to a field: + + field :my_field, :integer do + directives [constraints: [min: 1, max: 10]] + + resolve(&MyResolver.resolve/3) + end + + Or an argument: + + arg :my_arg, non_null(:string), directives: [constraints: [min: 10]] + """ + + use Absinthe.Phase + + alias Absinthe.Blueprint + alias Absinthe.Blueprint.Input + + def add_to_pipeline(pipeline, opts) do + Absinthe.Pipeline.insert_before( + pipeline, + Absinthe.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( + %{ + input_value: %{normalized: normalized}, + schema_node: %{__private__: private} + } = node + ) do + if constraints?(private), do: apply_constraints(node, normalized), else: node + end + + defp handle_node(node), do: node + + defp apply_constraints(node, %Input.List{items: items}) do + case validate_items(items, node.schema_node.__private__) do + {:ok, new_items} -> + %{node | input_value: %{node.input_value | normalized: %Input.List{items: new_items}}} + + {:error, reasons} -> + add_custom_errors(node, reasons) + 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} -> add_custom_errors(node, reason) + end + end + + defp apply_constraints(node, _), do: node + + defp validate_items(items, private_tags) do + Enum.reduce(items, {:ok, []}, fn item, {:ok, acc} -> + case validate_item(item, private_tags) do + {:ok, validated_item} -> {:ok, acc ++ [validated_item]} + {:error, reason} -> {:error, acc ++ reason} + 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(constraints, {:ok, item, []}, fn constraint, {:ok, prev_output, errors} -> + case call_constraint(constraint, prev_output) do + {:ok, result} -> {:ok, result, errors} + {:error, reason} -> {:ok, prev_output, errors ++ [reason]} + end + end) + |> handle_constraint_results() + end + + defp handle_constraint_results({:ok, item, []}), do: {:ok, item} + defp handle_constraint_results({:ok, _item, errors}), do: {:error, errors} + + defp call_constraint(constraint = {name, _args}, input) do + constraint_module = get_constraint_module(name) + constraint_module.call(constraint, input) + end + + defp get_constraint_module(constraint_name) do + constraint_name + |> Atom.to_string() + |> Macro.camelize() + |> then(&String.to_existing_atom("Elixir.AbsintheHelpers.Constraints.#{&1}")) + end + + defp get_constraints(private) do + private + |> Keyword.get(:constraints, []) + end + + defp constraints?(private) do + private + |> get_constraints() + |> Enum.any?() + end + + defp add_custom_errors(node, reasons) do + Enum.reduce(reasons, node, fn reason, acc -> + Absinthe.Phase.put_error(acc, %Absinthe.Phase.Error{ + phase: __MODULE__, + message: reason + }) + end) + end +end 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/constraints/apply_constraints_test.exs b/test/absinthe_helpers/constraints/apply_constraints_test.exs new file mode 100644 index 0000000..9d7ee40 --- /dev/null +++ b/test/absinthe_helpers/constraints/apply_constraints_test.exs @@ -0,0 +1,76 @@ +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]]) + 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" + } + ) + } + """ + + 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: "Too short" + } + ) + } + """ + + assert TestSchema.run_query(query) == {:ok, %{errors: [%{message: :max}, %{message: :min}]}} + end + end +end