Skip to content

Commit

Permalink
Add constraints phase
Browse files Browse the repository at this point in the history
  • Loading branch information
twist900 committed Sep 9, 2024
1 parent 9f85f6f commit 92d473e
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 1 deletion.
5 changes: 5 additions & 0 deletions lib/constraints.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule AbsintheHelpers.Constraint do
@moduledoc false

@callback call(Tuple.t(), Input.Value.t()) :: {:ok, Input.Value.t()} | {:error, atom()}
end
23 changes: 23 additions & 0 deletions lib/constraints/max.ex
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions lib/constraints/min.ex
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions lib/directives/constraints.ex
Original file line number Diff line number Diff line change
@@ -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
152 changes: 152 additions & 0 deletions lib/phases/apply_constraints.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
76 changes: 76 additions & 0 deletions test/absinthe_helpers/constraints/apply_constraints_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 92d473e

Please sign in to comment.