-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
357 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
test/absinthe_helpers/constraints/apply_constraints_test.exs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |