Skip to content

Commit

Permalink
Add constraints phase (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
twist900 authored Sep 12, 2024
1 parent 0ccbb53 commit 9bce778
Show file tree
Hide file tree
Showing 16 changed files with 566 additions and 17 deletions.
126 changes: 121 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,132 @@
# AbsintheHelpers
# Absinthe Helpers

Adds support for schema constraints, type coercions, and other custom transformations.
This package provides two key features:

1. **constraints**: enforce validation rules (like `min`, `max`, etc.) on fields and arguments in your schema.
2. **transforms**: apply custom transformations (like `Trim`, `ToInteger`, etc.) to input fields and arguments.

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `absinthe_helpers` to your list of dependencies in `mix.exs`:
Add `absinthe_helpers` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:absinthe_helpers, "~> 0.1.0", organization: "fresha"}
{:absinthe_helpers, "~> 0.1.0", organization: "fresha"}
]
end
```

Then, run:

```bash
mix deps.get
```

### Setup: adding constraints and transforms to your Absinthe pipeline

To set up both **constraints** and **transforms**, follow these steps:

1. Add constraints and transforms to your Absinthe pipeline:

```elixir
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)
|> AbsintheHelpers.Phases.ApplyTransforms.add_to_pipeline(opts)
end
```

2. Add constraints to your schema:

```elixir
defmodule MyApp.Schema do
use Absinthe.Schema
@prototype_schema AbsintheHelpers.Directives.Constraints
# ...
end
```

---

## Constraints

The `constraints` directive allows you to enforce validation rules on fields and arguments in your GraphQL schema. Constraints are applied at the schema level and are visible in the GraphQL schema, making them accessible to the frontend.

### Example: graphql schema with constraints

```graphql
"Overrides for location-specific service pricing."
input LocationOverrideInput {
duration: Int @constraints(min: 300, max: 43200)
price: Decimal @constraints(min: 0, max: 100000000)
priceType: ServicePriceType
locationId: ID!
}
```

### How to use constraints

1. Apply constraints to a field or argument:

```elixir
field :my_list, list_of(:integer) do
directive(:constraints, [min_items: 2, max_items: 5, min: 1, max: 100])

resolve(&MyResolver.resolve/3)
end

field :my_field, :integer do
arg :my_arg, non_null(:string), directives: [constraints: [min: 10]]

resolve(&MyResolver.resolve/3)
end
```

---

## Transforms

Transforms allow you to modify or coerce input values at runtime. You can apply these transformations to individual fields, lists, or arguments in your Absinthe schema.

### Example: applying transforms in your schema

1. Apply transforms directly to a field:

```elixir
alias AbsintheHelpers.Transforms.ToInteger
alias AbsintheHelpers.Transforms.Trim
alias AbsintheHelpers.Transforms.Increment

field :employee_id, :id do
meta transforms: [Trim, ToInteger, {Increment, 3}]
end
```

2. Apply transforms to a list of values:

```elixir
field :employee_ids, non_null(list_of(non_null(:id))) do
meta transforms: [Trim, ToInteger, {Increment, 3}]
end
```

3. Apply transforms to an argument:

```elixir
field(:create_booking, :string) do
arg(:employee_id, non_null(:id),
__private__: [meta: [transforms: [Trim, ToInteger, {Increment, 3}]]]
)

resolve(&TestResolver.run/3)
end
```
11 changes: 11 additions & 0 deletions lib/constraints.ex
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
27 changes: 27 additions & 0 deletions lib/constraints/max.ex
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}},
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}},
else: {:ok, node}
end

def call(node = %{data: data}, {:max, max}) do
if data > max,
do: {:error, :max_exceeded, %{max: max}},
else: {:ok, node}
end
end
17 changes: 17 additions & 0 deletions lib/constraints/max_items.ex
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}}
else
{:ok, node}
end
end

def call(node, {:max_items, _max_items}) do
{:ok, node}
end
end
27 changes: 27 additions & 0 deletions lib/constraints/min.ex
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}},
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}},
else: {:ok, node}
end

def call(node = %{data: data}, {:min, min}) do
if data < min,
do: {:error, :min_not_met, %{min: min}},
else: {:ok, node}
end
end
17 changes: 17 additions & 0 deletions lib/constraints/min_items.ex
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}}
else
{:ok, node}
end
end

def call(node, {:min_items, _min_items}) do
{: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 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
Loading

0 comments on commit 9bce778

Please sign in to comment.