-
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
16 changed files
with
566 additions
and
17 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 |
---|---|---|
@@ -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 | ||
``` |
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}}, | ||
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 |
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}} | ||
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}}, | ||
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 |
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}} | ||
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 |
Oops, something went wrong.