-
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
12 changed files
with
423 additions
and
5 deletions.
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,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 |
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,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 |
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,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 |
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,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 |
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,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 |
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 for adding constraints to fields and arguments. | ||
Supports: | ||
- `:min`, `:max`: For numbers and string lengths | ||
- `:min_items`, `:max_items`: For lists | ||
Applicable to scalars (:string, :integer, :float, :decimal) and lists. | ||
Example: | ||
field :username, :string, directives: [constraints: [min: 3, max: 20]] | ||
arg :tags, list_of(:string), directives: [constraints: [max_items: 5, max: 10]] | ||
Constraints are automatically enforced during query execution. | ||
""" | ||
|
||
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 |
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,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 |
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
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
Oops, something went wrong.