Skip to content

Commit

Permalink
Add transformations phase
Browse files Browse the repository at this point in the history
  • Loading branch information
twist900 committed Sep 9, 2024
1 parent fe0105e commit b721be7
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 2 deletions.
182 changes: 182 additions & 0 deletions lib/phase/apply_transforms.ex
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
7 changes: 7 additions & 0 deletions lib/transform.ex
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
23 changes: 23 additions & 0 deletions lib/transforms/to_integer_transform.ex
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
22 changes: 22 additions & 0 deletions lib/transforms/trim_transform.ex
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
10 changes: 8 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ defmodule AbsintheHelpers.MixProject do
start_permanent: Mix.env() == :prod,
deps: deps(),
description: description(),
package: package()
package: package(),
elixirc_paths: elixirc_paths(Mix.env())
]
end

Expand All @@ -20,13 +21,18 @@ defmodule AbsintheHelpers.MixProject do
]
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:junit_formatter, "~> 3.3", only: [:test]},
{:credo, "~> 1.0", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0", only: :dev, runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:absinthe, "~> 1.0"},
{:mimic, "~> 1.10", only: :test}
]
end

Expand Down
4 changes: 4 additions & 0 deletions mix.lock
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"},
}
94 changes: 94 additions & 0 deletions test/absinthe_helpers/phase/apply_transforms_test.exs
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

View workflow job for this annotation

GitHub Actions / Static Checks (Elixir 1.16.2)

Nested modules could be aliased at the top of the invoking module.

Check warning on line 41 in test/absinthe_helpers/phase/apply_transforms_test.exs

View workflow job for this annotation

GitHub Actions / Static Checks (Elixir 1.16.2)

Nested modules could be aliased at the top of the invoking module.
)
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
5 changes: 5 additions & 0 deletions test/support/test_resolver.ex
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

View workflow job for this annotation

GitHub Actions / Static Checks (Elixir 1.16.2)

Modules should have a @moduledoc tag.
def run(_arg1, _arg2, _arg3) do
{:ok, ""}
end
end
2 changes: 2 additions & 0 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
ExUnit.start()

Mimic.copy(AbsintheHelpers.TestResolver)

0 comments on commit b721be7

Please sign in to comment.