elixirabsinthe

Mapping elixir absinthe input_object to single data structure


Is there some way to map an input_object into a single data structure?

  input_object(:numrange) do
    field :start, non_null(:integer) do
      resolve(fn field, _, _ ->
        ...
      end)
    end
    field :end, non_null(:integer) do
      resolve(fn field, _, _ ->
        ...
      end)
    end
  end

And then have it parsed as [start, end] ?


Solution

  • Unfortunately, there is no way to define this at the input_object level. However, there are a few different ways to handle parameter transformation.

    1. Change your business logic

    You can just change the contracts within your service layer to handle an object instead of an array.

    # Inside of some module
    
    def my_functon(attrs, other, params) do
      formatted_attrs = case attrs do
        %{range: %{start: startrange, end: endrange}} ->
          %{attrs | range: [startrange, endrange]}
        _ -> 
          attrs
      end
      # ...
    end
    

    2. Handle the input object where it is passed by args in the schema

    You can transform the arguments when they are submitted

    # Some graphql definition
    
    field(:my_field, :my_object) do
      arg(:range, non_null(:numrange))
      resolve(fn parent, %{range: %{start: rstart, end: rend}} = args, ctx ->
        new_args = %{args | range: [rstart, rend]}
        SomeModule.my_function(new_attrs, parent, ctx)
      end)
    end
    

    3. Create a middleware

    You can create an Absinthe.Middleware to transform arguments when they are submitted

    defmodule MyApp.NumrangeTransform do
      @behaviour Absinthe.Middleware
    
      @impl Absinthe.Middleware
      def call(%Absinthe.Resolution{arguments: args} = res, opts) do
        field = Keyword.fetch!(opts, :field)
        new_args = case Map.get(args, field) do
          %{start: rstart, end: rend} ->
             Map.put(args, field, [rstart, rend])
          _ ->
            args
        end
    
        %{res | arguments: new_args}
      end
    end
    

    Then in your schema definition:

    field(:my_field, :type) do
      middleware(MyApp.NumrangeTransform, field: :range)
      arg(:range, :numrange)
      # ...
    end
    

    The Middleware will transform the args for you without having to write transformation logic everywhere

    4. Create a custom scalar type

    custom scalar types can be defined in Absinthe:

    # In some schema definition
    
    scalar :numrange, name: "NumRange" do
      description("A number range of integers m..n")
      serialize([rstart, rend]) when is_integer(rstart) and is_integer(rend) do
        rstart <> ".." <> rend
      end
    
      parse(&do_parse/1)
    
      defp do_parse(%Absinthe.Blueprint.Input.String{value: range_str}) do
        with [s_str, e_str] <- String.split(range_str, ".."),
           {rstart, _} <- Integer.parse(s_str),
           {rend, _} <- Integer.parse(e_str) do
          {:ok, [rstart, rend]}
        else
          _ -> :error
        end
      end
    
      def do_parse(%Absinthe.Blueprint.Input.Null{}), do: {:ok, nil}
      def do_parse(_), do: :error
    end
    

    Then it is added somewhere in the schema

    field(:my_field, :type) do
      arg(:range, non_null(:numrange))
      # ...
    end
    

    And the GraphQL looks something like this:

    query SomeQuery {
      myField(range:"1..3")
    }
    

    This is probably the least attractive option as it creates a non-standard way of both presenting and accepting number ranges for any front-end application. However, if this is not a public API that is accessed by third-party applications, there should be no issue with doing it.

    Conclusion

    There are many ways to define and handle parameter transformation in your input arguments. There may be other solutions that I haven't mentioned. You could probably do something pretty crazy by writing a custom Absinthe.Phase, but that is a complex endeavour that would likely be too heavy-handed for something this simple.