-
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
9 changed files
with
347 additions
and
2 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,182 @@ | ||
defmodule AbsintheHelpers.Phase.ApplyTransforms do | ||
@moduledoc """ | ||
The module identifies input nodes with transformation metadata and applies | ||
them to the values. These transformations can be applied to both single-value | ||
nodes and lists of items. | ||
New transformations can be added in the `lib/transforms/` directory, like | ||
`AbsintheHelpers.Transforms.ToIntegerTransform`, or within your own project, | ||
as long as they follow the convention. For example, you could define a new | ||
`:increment` transformation tag and create a corresponding | ||
`AbsintheHelpers.Transforms.IncrementTransform` to increment numeric input | ||
values. | ||
## 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.Phase.ApplyTransforms.add_to_pipeline(opts) | ||
end | ||
To define a custom transformation on a schema field: | ||
field :employee_id, :id do | ||
meta transforms: [:trim, :to_integer, :increment] | ||
end | ||
or on a list: | ||
field :employee_ids, non_null(list_of(non_null(:id))) do | ||
meta transforms: [:trim, :to_integer, :increment] | ||
end | ||
To define a custom transformation on a schema arg: | ||
field(:create_booking, :string) do | ||
arg(:employee_id, non_null(:id), | ||
__private__: [meta: [transforms: [:trim, :to_integer, :increment]]] | ||
) | ||
resolve(&TestResolver.run/3) | ||
end | ||
In this case, both the `TrimTransforms`, `ToIntegerTransform`, and `IncrementTransform` | ||
will be applied to the `employee_id` field. | ||
""" | ||
|
||
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 | ||
|
||
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 transform?(private), do: apply_transforms(node, normalized), else: node | ||
end | ||
|
||
defp handle_node(node), do: node | ||
|
||
defp apply_transforms(node, %Input.List{items: items}) do | ||
case transform_items(items, node.schema_node.__private__) do | ||
{:ok, new_items} -> | ||
%{node | input_value: %{node.input_value | normalized: %Input.List{items: new_items}}} | ||
|
||
{:error, reason} -> | ||
add_custom_error(node, reason) | ||
end | ||
end | ||
|
||
defp apply_transforms(node, %{value: _value}) do | ||
case transform_item(node.input_value, node.schema_node.__private__) do | ||
{:ok, transformed_value} -> | ||
%{node | input_value: %{node.input_value | data: transformed_value.data}} | ||
|
||
{:error, reason} -> | ||
add_custom_error(node, reason) | ||
end | ||
end | ||
|
||
defp apply_transforms(node, _), do: node | ||
|
||
defp transform_items(items, private_tags) do | ||
Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} -> | ||
case transform_item(item, private_tags) do | ||
{:ok, transformed_item} -> {:cont, {:ok, [transformed_item | acc]}} | ||
{:error, reason} -> {:halt, {:error, reason}} | ||
end | ||
end) | ||
|> case do | ||
{:ok, new_items} -> {:ok, Enum.reverse(new_items)} | ||
{:error, reason} -> {:error, reason} | ||
end | ||
end | ||
|
||
defp transform_item(item, private_tags) do | ||
transform_in_sequence( | ||
item, | ||
get_transforms(private_tags) | ||
) | ||
end | ||
|
||
defp transform_in_sequence(item, [first_transform | other_transforms]) do | ||
Enum.reduce( | ||
other_transforms, | ||
call_transform(first_transform, item), | ||
&transform_in_sequence_each/2 | ||
) | ||
end | ||
|
||
defp transform_in_sequence(item, []), do: {:ok, item} | ||
|
||
defp transform_in_sequence_each(_next_transform, {:error, reason}), do: {:error, reason} | ||
|
||
defp transform_in_sequence_each(next_transform, {:ok, prev_output}) do | ||
call_transform(next_transform, prev_output) | ||
end | ||
|
||
defp call_transform(transforms, input) when is_list(transforms), | ||
do: transform_in_sequence(input, transforms) | ||
|
||
defp call_transform(transform, input) when is_atom(transform), | ||
do: call_transform({transform}, input) | ||
|
||
defp call_transform(transform_tuple, input) when is_tuple(transform_tuple) do | ||
[transform_name | transform_args] = Tuple.to_list(transform_tuple) | ||
transform_args = if transform_args == [], do: [nil], else: transform_args | ||
|
||
transform_camelized = | ||
transform_name | ||
|> Atom.to_string() | ||
|> Macro.camelize() | ||
|
||
transform_module = | ||
String.to_existing_atom("Elixir.AbsintheHelpers.Transforms.#{transform_camelized}Transform") | ||
|
||
apply(transform_module, :call, [input | transform_args]) | ||
end | ||
|
||
defp get_transforms(private) do | ||
private | ||
|> Keyword.get(:meta, []) | ||
|> Keyword.get(:transforms, []) | ||
end | ||
|
||
defp transform?(private) do | ||
private | ||
|> get_transforms() | ||
|> Enum.any?() | ||
end | ||
|
||
defp add_custom_error(node, reason) do | ||
Absinthe.Phase.put_error(node, %Absinthe.Phase.Error{ | ||
phase: __MODULE__, | ||
message: reason | ||
}) | ||
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,7 @@ | ||
defmodule AbsintheHelpers.Transform do | ||
@moduledoc false | ||
|
||
alias Absinthe.Blueprint.Input | ||
|
||
@callback call(Input.Value.t(), Keyword.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.Transforms.ToIntegerTransform do | ||
@moduledoc """ | ||
A transformation that converts string input values to integers in an Absinthe | ||
schema. | ||
Add the transformation in your schema: | ||
field :employee_id, :id do | ||
meta transforms: [:to_integer] | ||
end | ||
""" | ||
|
||
alias Absinthe.Blueprint.Input | ||
|
||
@behaviour AbsintheHelpers.Transform | ||
|
||
def call(%Input.Value{data: data} = item, _opts) do | ||
case Integer.parse(data) do | ||
{int, ""} -> {:ok, %{item | data: int}} | ||
_ -> {:error, :invalid_integer} | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
defmodule AbsintheHelpers.Transforms.TrimTransform do | ||
@moduledoc """ | ||
A transformation that trims whitespace from string input values in an Absinthe | ||
schema. | ||
## Example Usage | ||
Add the transformation to a field in your schema: | ||
field :username, :string do | ||
meta transforms: [:trim] | ||
end | ||
""" | ||
|
||
alias Absinthe.Blueprint.Input | ||
|
||
@behaviour AbsintheHelpers.Transform | ||
|
||
def call(%Input.Value{data: data} = item, _opts) do | ||
{:ok, %{item | data: String.trim(data)}} | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,19 @@ | ||
%{ | ||
"absinthe": {:hex, :absinthe, "1.7.8", "43443d12ad2b4fcce60e257ac71caf3081f3d5c4ddd5eac63a02628bcaf5b556", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4085df201892a498384f997649aedb37a4ce8a726c170d5b5617ed3bf45d40b"}, | ||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, | ||
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, | ||
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, | ||
"earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, | ||
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, | ||
"ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, | ||
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, | ||
"ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, | ||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, | ||
"junit_formatter": {:hex, :junit_formatter, "3.4.0", "d0e8db6c34dab6d3c4154c3b46b21540db1109ae709d6cf99ba7e7a2ce4b1ac2", [:mix], [], "hexpm", "bb36e2ae83f1ced6ab931c4ce51dd3dbef1ef61bb4932412e173b0cfa259dacd"}, | ||
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, | ||
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, | ||
"makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, | ||
"mimic": {:hex, :mimic, "1.10.0", "58ee13aa46addcadbb033ce311bb5ed8b0a825c2dec6e1d55ca767138a6374c8", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "ea639e48f6a5bd043218297b80c3a52e227541aafa3dc8a299cc0c01991523a5"}, | ||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, | ||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, | ||
} |
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,94 @@ | ||
defmodule AbsintheHelpers.Phase.ApplyTransformsTest do | ||
use ExUnit.Case, async: true | ||
use Mimic | ||
|
||
alias AbsintheHelpers.TestResolver | ||
|
||
describe "apply transforms phase" do | ||
defmodule TestSchema do | ||
use Absinthe.Schema | ||
|
||
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(:id), | ||
__private__: [meta: [transforms: [:trim, {:to_integer, true}]]] | ||
) | ||
|
||
arg(:service, non_null(:service_input)) | ||
|
||
resolve(&TestResolver.run/3) | ||
end | ||
end | ||
|
||
input_object :service_input do | ||
field(:employee_id, :id, meta: [transforms: [{:to_integer, true}]]) | ||
|
||
field(:override_ids, non_null(list_of(non_null(:id)))) do | ||
meta(transforms: [:trim, {:to_integer, true}]) | ||
end | ||
end | ||
|
||
def run_query(query) do | ||
Absinthe.run( | ||
query, | ||
__MODULE__, | ||
pipeline_modifier: &AbsintheHelpers.Phase.ApplyTransforms.add_to_pipeline/2 | ||
Check warning on line 41 in test/absinthe_helpers/phase/apply_transforms_test.exs
|
||
) | ||
end | ||
end | ||
|
||
test "applies transforms to graphql document" do | ||
expect( | ||
TestResolver, | ||
:run, | ||
fn _, input, _ -> | ||
assert input == %{ | ||
customer_id: 1, | ||
service: %{ | ||
employee_id: 456, | ||
override_ids: [1, 2, 3] | ||
} | ||
} | ||
|
||
{:ok, ""} | ||
end | ||
) | ||
|
||
query = """ | ||
mutation { | ||
create_booking( | ||
customer_id: " 1 ", | ||
service: { | ||
employee_id: "456", | ||
override_ids: ["1", " 2", "3"] | ||
} | ||
) | ||
} | ||
""" | ||
|
||
assert TestSchema.run_query(query) == {:ok, %{data: %{"create_booking" => ""}}} | ||
end | ||
|
||
test "propagates errors from transforms" do | ||
query = """ | ||
mutation { | ||
create_booking( | ||
customer_id: "1", | ||
service: { | ||
employee_id: "456", | ||
override_ids: ["1", "invalid_id", "3"] | ||
} | ||
) | ||
} | ||
""" | ||
|
||
assert TestSchema.run_query(query) == {:ok, %{errors: [%{message: :invalid_integer}]}} | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
defmodule AbsintheHelpers.TestResolver do | ||
Check warning on line 1 in test/support/test_resolver.ex
|
||
def run(_arg1, _arg2, _arg3) do | ||
{:ok, ""} | ||
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 |
---|---|---|
@@ -1 +1,3 @@ | ||
ExUnit.start() | ||
|
||
Mimic.copy(AbsintheHelpers.TestResolver) |