diff --git a/README.md b/README.md index d7cb780..67ab6eb 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/lib/constraints.ex b/lib/constraints.ex new file mode 100644 index 0000000..db90ff5 --- /dev/null +++ b/lib/constraints.ex @@ -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 diff --git a/lib/constraints/max.ex b/lib/constraints/max.ex new file mode 100644 index 0000000..5504957 --- /dev/null +++ b/lib/constraints/max.ex @@ -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 diff --git a/lib/constraints/max_items.ex b/lib/constraints/max_items.ex new file mode 100644 index 0000000..fa81609 --- /dev/null +++ b/lib/constraints/max_items.ex @@ -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 diff --git a/lib/constraints/min.ex b/lib/constraints/min.ex new file mode 100644 index 0000000..fb02a12 --- /dev/null +++ b/lib/constraints/min.ex @@ -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 diff --git a/lib/constraints/min_items.ex b/lib/constraints/min_items.ex new file mode 100644 index 0000000..f152d04 --- /dev/null +++ b/lib/constraints/min_items.ex @@ -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 diff --git a/lib/directives/constraints.ex b/lib/directives/constraints.ex new file mode 100644 index 0000000..486672a --- /dev/null +++ b/lib/directives/constraints.ex @@ -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 diff --git a/lib/phases/apply_constraints.ex b/lib/phases/apply_constraints.ex new file mode 100644 index 0000000..e5bad77 --- /dev/null +++ b/lib/phases/apply_constraints.ex @@ -0,0 +1,137 @@ +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) + + 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_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 + """ + + 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))}" + ) + 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}) + } + }) + end +end diff --git a/lib/phases/apply_transforms.ex b/lib/phases/apply_transforms.ex index 823e754..d709c8d 100644 --- a/lib/phases/apply_transforms.ex +++ b/lib/phases/apply_transforms.ex @@ -8,7 +8,7 @@ defmodule AbsintheHelpers.Phases.ApplyTransforms do `AbsintheHelpers.Transforms.ToInteger`, or within your own project, as long as they implement the same behaviour. - ## Example Usage + ## Example usage To add this phase to your pipeline, add the following to your router: @@ -73,10 +73,10 @@ defmodule AbsintheHelpers.Phases.ApplyTransforms do end defp handle_node( - %{ + node = %{ input_value: %{normalized: normalized}, schema_node: %{__private__: private} - } = node + } ) do if transform?(private), do: apply_transforms(node, normalized), else: node end diff --git a/lib/transforms/to_integer.ex b/lib/transforms/to_integer.ex index 966a8be..d1d2230 100644 --- a/lib/transforms/to_integer.ex +++ b/lib/transforms/to_integer.ex @@ -19,9 +19,9 @@ defmodule AbsintheHelpers.Transforms.ToInteger do def call(%Input.Value{data: data} = item, _opts) when is_binary(data) do case Integer.parse(data) do {int, ""} -> {:ok, %{item | data: int}} - _ -> {:error, :invalid_integer, %{value: data}} + _ -> {:error, :invalid_integer, %{}} end end - def call(%Input.Value{data: data}, _opts), do: {:error, :invalid_integer, %{value: data}} + def call(%Input.Value{data: _data}, _opts), do: {:error, :invalid_integer, %{}} end diff --git a/lib/transforms/trim.ex b/lib/transforms/trim.ex index f5120e2..0eef40b 100644 --- a/lib/transforms/trim.ex +++ b/lib/transforms/trim.ex @@ -22,5 +22,5 @@ defmodule AbsintheHelpers.Transforms.Trim do {:ok, %{item | data: String.trim(data)}} end - def call(%Input.Value{data: data}, _), do: {:error, :invalid_value, %{value: data}} + def call(%Input.Value{data: _data}, _), do: {:error, :invalid_value, %{}} end diff --git a/mix.exs b/mix.exs index 59ed06f..9f6dbe4 100644 --- a/mix.exs +++ b/mix.exs @@ -4,13 +4,17 @@ defmodule AbsintheHelpers.MixProject do def project do [ app: :absinthe_helpers, - version: "0.1.3", + version: "0.1.4", elixir: "~> 1.16", start_permanent: Mix.env() == :prod, deps: deps(), description: description(), package: package(), - elixirc_paths: elixirc_paths(Mix.env()) + elixirc_paths: elixirc_paths(Mix.env()), + docs: [ + main: "readme", + extras: ["README.md"] + ] ] end @@ -32,7 +36,8 @@ defmodule AbsintheHelpers.MixProject do {:dialyxir, "~> 1.0", only: :dev, runtime: false}, {:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:absinthe, "~> 1.0"}, - {:mimic, "~> 1.10", only: :test} + {:mimic, "~> 1.10", only: :test}, + {:decimal, "~> 1.9"} ] end diff --git a/mix.lock b/mix.lock index 9433c7c..f90720c 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "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"}, + "decimal": {:hex, :decimal, "1.9.0", "83e8daf59631d632b171faabafb4a9f4242c514b0a06ba3df493951c08f64d07", [:mix], [], "hexpm", "b1f2343568eed6928f3e751cf2dffde95bfaa19dd95d09e8a9ea92ccfd6f7d85"}, "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"}, diff --git a/test/absinthe_helpers/phases/apply_constraints_test.exs b/test/absinthe_helpers/phases/apply_constraints_test.exs new file mode 100644 index 0000000..d1d888e --- /dev/null +++ b/test/absinthe_helpers/phases/apply_constraints_test.exs @@ -0,0 +1,116 @@ +defmodule AbsintheHelpers.Phases.ApplyConstraintsTest do + use ExUnit.Case, async: true + + alias AbsintheHelpers.Phases.ApplyConstraints + alias AbsintheHelpers.TestResolver + + describe "apply constraints phase with min/max on integers, decimals, and strings" do + defmodule TestSchema do + use Absinthe.Schema + + import_types(Absinthe.Type.Custom) + + @prototype_schema AbsintheHelpers.Directives.Constraints + + 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(:integer), directives: [constraints: [min: 1, max: 1000]]) + arg(:service, non_null(:service_input)) + + resolve(&TestResolver.run/3) + end + end + + input_object :service_input do + field(:cost, :decimal, directives: [constraints: [min: 10, max: 1000]]) + + field(:description, :string) do + directive(:constraints, min: 5, max: 50) + end + + field(:override_ids, non_null(list_of(non_null(:integer)))) do + directive(:constraints, min_items: 3, min: 5, max: 50) + end + + field(:location_ids, non_null(list_of(non_null(:integer)))) do + directive(:constraints, min_items: 2, min: 5, max: 50) + end + + field(:commission_ids, non_null(list_of(non_null(:integer)))) do + directive(:constraints, max_items: 2) + end + end + + def run_query(query) do + Absinthe.run( + query, + __MODULE__, + pipeline_modifier: &ApplyConstraints.add_to_pipeline/2 + ) + end + end + + test "validates mutation arguments including decimal and string constraints and returns success" do + query = """ + mutation { + create_booking( + customer_id: 1, + service: { + cost: "150.75", + description: "Valid description", + override_ids: [6, 7, 8], + location_ids: [8, 9, 10], + commission_ids: [] + } + ) + } + """ + + assert TestSchema.run_query(query) == {:ok, %{data: %{"create_booking" => ""}}} + end + + test "returns errors for invalid decimal and string arguments" do + query = """ + mutation { + create_booking( + customer_id: 1001, + service: { + cost: "5.00", + description: "bad", + override_ids: [6, 1, 7], + location_ids: [1], + commission_ids: [1, 2, 3] + } + ) + } + """ + + assert {:ok, + %{ + errors: [ + %{ + message: :max_exceeded, + details: %{field: "customer_id", max: 1000} + }, + %{message: :min_not_met, details: %{field: "cost", min: 10}}, + %{message: :min_not_met, details: %{field: "description", min: 5}}, + %{message: :min_not_met, details: %{field: "override_ids", min: 5}}, + %{ + message: :min_items_not_met, + details: %{field: "location_ids", min_items: 2} + }, + %{ + message: :max_items_exceeded, + details: %{field: "commission_ids", max_items: 2} + } + ] + }} = TestSchema.run_query(query) + end + end +end diff --git a/test/absinthe_helpers/phases/apply_transforms_test.exs b/test/absinthe_helpers/phases/apply_transforms_test.exs index fe8278c..fb1644f 100644 --- a/test/absinthe_helpers/phases/apply_transforms_test.exs +++ b/test/absinthe_helpers/phases/apply_transforms_test.exs @@ -104,11 +104,11 @@ defmodule AbsintheHelpers.Phases.ApplyTransformsTest do errors: [ %{ message: :invalid_integer, - details: %{field: "customer_id", value: "bad"} + details: %{field: "customer_id"} }, %{ message: :invalid_integer, - details: %{field: "override_ids", value: "abc123"} + details: %{field: "override_ids"} } ] } diff --git a/test/support/transforms/increment.ex b/test/support/transforms/increment.ex index e69468c..c3b3358 100644 --- a/test/support/transforms/increment.ex +++ b/test/support/transforms/increment.ex @@ -5,7 +5,7 @@ defmodule AbsintheHelpers.Transforms.Increment do @behaviour AbsintheHelpers.Transform - def call(%Input.Value{data: data} = item, [step]) do + def call(item = %Input.Value{data: data}, [step]) do {:ok, %{item | data: data + step}} end end