-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add constraints phase #4
Changes from 3 commits
105a56a
8361bf0
dde1a1d
ce15186
6641e6e
e6fb457
758d20d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we decide on the order, it depends on the order its added to the pipeline, in our case I've added constraints then transforms here: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. btw, here how it look all together on a real-life schema: |
||||||||||||||||||||||||||||||||||||||
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} | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I would be against passing values here. I'm always afraid, that if that's a struct we will have problems with serializing those. But what worries me more, is that we are putting into There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (and that goes for all values added to errors) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, removed user values from errors ✅ |
||||||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||||||
end |
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 |
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 |
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 |
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 |
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) | ||
Comment on lines
+14
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do wonder if this is needed at all. They way I understand directives, they can (should) already include the implementation (with the I think it would simplify a little bit the introduction into projects. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The directive is extended, but we still need to hook into the pipeline to interpret and execute the added constraints and their params There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated README here too There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👌 I see it now. |
||
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've now updated docs and test scenarios for demonstration of this 👍 ✅ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. btw, note how it is still useful to have directive inline as a key when it is applied to an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep |
||
""" | ||
|
||
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))}" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note that unlike transforms, here we can't pass constraint modules directly as whatever we pass to a directive is rendered in the graphql schema itself 😓 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 well.. I'm still not fan of "guessing modules". But at this point it's just an implementation detail - it will work either way. One thing I would suggest is dropping the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we can keep the modules separate for now, lets see how this grows, its a reversible decision :) |
||
) | ||
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}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👌 that much better than what we got. I would be much better if we could provide full path to it (like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yep, in a separate PR 👍 |
||
} | ||
}) | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also I do wonder if we need to use the
Decimal
here 🤔There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remember that these constraint values are reflected in the GraphQL schema. I'm unsure how we should represent decimals in that context. Should we represent them as strings and parse them here, or as floats? In the Surgex parsers, we compared decimals against integers, maybe we'll be fine here as well, this is reversible too