How to add resolver function to generated Absinthe schema

I’ve been working on generating schemas dynamically at runtime based on injecting structs into the blueprint by tinkering with @pipeline_modifier – just following my nose with the examples given on Absinthe.Schema — absinthe v1.7.0

However, I have not yet seen how a field definition references its resolver – did I miss that? I’ve dumped out the entire contents of the blueprint, e.g.

IO.inspect(blueprint, label: "BLUEPRINT", limit: :infinity, printable_limit: :infinity)

And I’m looking for a very distinctive function name in all that output, but… it’s not there. How would I go about adding a resolver into the blueprint at runtime? Is this possible?

Thanks in advance!

Hey @fireproofsocks! What you have at the Blueprint layer is really just a list of middleware. “Resolvers” just turn into entries in the middleware list that look like this:

{{Absinthe.Resolution, :call}, &MyApp.Resolvers.resolve_field/3}

Let me make sure I have this straight…

%Absinthe.Blueprint{} has a key for :schema_definitions which contains a list of %Absinthe.Blueprint.Schema.SchemaDefinition{} structs (which in turn, define lists of type definitions). This %Absinthe.Blueprint{} struct (or any of its “children”) does not define middleware. I.e. the schema’s structure is kept separate from its actions. I think this makes sense… that must be how/why you can export the GraphQL schema as a JSON document via mix absinthe.schema.json. The “actions” (i.e. the resolvers) are something like an implementation of the schema.

The resolve macro registers a bit of middleware… I’m less clear on where the authoritative list of middleware is stored… I haven’t yet spotted a place to inspect all of it in the same way that the entire blueprint for a schema can be inspected by adding in the @pipeline_modifier attribute can let you add a phase to the pipeline as demonstrated by Absinthe.Schema — absinthe v1.7.0

That’s not a deal-breaker because it’s easy enough to define the middleware/3 function inside my schema module. Here’s what I’m trying to do:

  mutation do
    @desc "Create an item"
    field :create_item, type: :item do
      arg(:id, non_null(:string))
      arg(:name, :string)
      # resolve(&create_item/3)  # <-- how to re-create this by manually inserting middleware?
    end
  end

  @desc "An item"
  object :item do
    field :id, :id
    field :name, :string
  end

  def middleware(middleware, _field, %{identifier: :create_item}) do
    IO.puts("MANUALLY DEFINING RESOLUTION VIA MIDDLEWARE")
    middleware ++ [{{Absinthe.Resolution, :call}, &create_item/3}]
  end

  # Pass thru
  def middleware(middleware, _field, _object) do
    middleware
  end

  # The resolver function
  def create_item(_parent, _args, _context) do
    IO.put("CREATING THE ITEM....")
    {:ok, %{id: "my-id", name: "my name"}}
  end

The middleware is being registered, but the resolver function is never called. I can programmatically generate the objects and fields and arguments and inject them into the blueprint to create the schema, but I can’t seem to programmatically recreate the effects of the resolve macro.

The list of middleware for a field is managed here: absinthe/lib/absinthe/blueprint/schema/field_definition.ex at main · absinthe-graphql/absinthe · GitHub I meant to link that in my original reply but was on mobile and apparently messed up the link!

Your bug with def middleware is that you are pattern matching on the object identifier not the field identifier. What you want to do is set the resolver on a field named :create_item on the object named :mutation so what you need is:

  def middleware(middleware, %{identifier: :create_item}, _object) do
    IO.puts("MANUALLY DEFINING RESOLUTION VIA MIDDLEWARE")
    middleware ++ [{{Absinthe.Resolution, :call}, &create_item/3}]
  end

Back to the question of pipeline modifiers though, if you set [{{Absinthe.Resolution, :call}, &create_item/3}] on the middleware list of the FieldDefinition you should be good to go. Here’s’ a complete example:

defmodule MySchemaBuilder do
  alias Absinthe.{Phase, Pipeline, Blueprint}

  def pipeline(pipeline) do
    Pipeline.insert_after(pipeline, Phase.Schema.TypeImports, __MODULE__)
  end

  def run(blueprint, _) do
    blueprint = Blueprint.prewalk(blueprint, fn
      %Blueprint.Schema.FieldDefinition{} = field_def ->
        set_middleware(field_def)

      node ->
        node
    end)

    {:ok, blueprint}
  end

  def set_middleware(%{identifier: :create_item} = field_def) do
    Map.update!(field_def, :middleware, fn middleware ->
      middleware ++ [{{Absinthe.Resolution, :call}, &create_item/3}]
    end)
  end

  def set_middleware(field_def) do
    field_def
  end

  # this could be anywhere, doesn't need to be in this module.
  def create_item(_parent, _args, _context) do
    IO.puts("CREATING THE ITEM....")
    {:ok, %{id: "my-id", name: "my name"}}
  end
end

defmodule TestSchema do
  use Absinthe.Schema
  @schema_provider Absinthe.Schema.PersistentTerm
  @pipeline_modifier MySchemaBuilder

  query do
  end

  mutation do
    @desc "Create an item"
    field :create_item, type: :item do
      arg(:id, non_null(:string))
      arg(:name, :string)
    end
  end

  @desc "An item"
  object :item do
    field(:id, :id)
    field(:name, :string)
  end
end

Absinthe.run("mutation { createItem(id: \"1\", name: \"foo\") { id } }", TestSchema)
CREATING THE ITEM....
{:ok, %{data: %{"createItem" => %{"id" => "my-id"}}}}

As a small note, you’ll notice I set @schema_provider Absinthe.Schema.PersistentTerm. This is likely going to be the standard in the next Absinthe release, as it is much more flexible when dealing with middleware. The current default essentially has to store the middleware via a complicated macro system to deal with anonymous functions, but persistent_term has no such limitations, and is actually faster too!

Thank you! This is very helpful. The example works and I appreciate the note about persistent_term.

I realized the mistake re file vs. object shortly after I posted, but it wasn’t working:

  def middleware(middleware, %{identifier: :get_item}, _object) do
    IO.puts("MANUALLY DEFINING RESOLUTION VIA MIDDLEWARE")
    [{{Absinthe.Resolution, :call}, &create_item/3}] ++ middleware
  end

  # Passthru
  def middleware(middleware, _field, _object) do
    middleware
  end

  def create_item(_parent, _args, _context) do
    IO.puts("CREATING THE ITEM")
    {:ok, %{id: "my-id", name: "my name"}}
  end

Is this expected? Using the @pipeline_modifier to customize the blueprint/pipeline makes more sense to me because it’s making changes upstream at the blueprint level. But is the schema’s implementation of middleware/3 supposed to be able to be able to make the same kinds of changes?

Also, just for visibility, it’s possible to specify just the module and the function name (i.e. avoid the & capture) using this syntax:

middleware ++ [{{Absinthe.Resolution, :call}, {SomeModule, :create_item}}]

Here is a version of that which works:

defmodule TestSchema do
  use Absinthe.Schema
  query do
    
  end
  
   mutation do
    @desc "Create an item"
    field :create_item, type: :item do
      arg(:id, non_null(:string))
      arg(:name, :string)
    end
  end

  @desc "An item"
  object :item do
    field :id, :id
    field :name, :string
  end

  def middleware(middleware, %{identifier: :create_item} = field, _object) do
    IO.puts("MANUALLY DEFINING RESOLUTION VIA MIDDLEWARE")
    field.middleware |> IO.inspect(label: :existing_middleware)
    [{{Absinthe.Resolution, :call}, &create_item/3}]
  end

  # Pass thru
  def middleware(middleware, _field, _object) do
    middleware
  end

  # The resolver function
  def create_item(_parent, _args, _context) do
    IO.puts("CREATING THE ITEM....")
    {:ok, %{id: "my-id", name: "my name"}}
  end

end

Absinthe.run("mutation { createItem(id: \"1\", name: \"foo\") { id } }", TestSchema)

The reason you don’t get what you expect in your version is a somewhat subtle one. Notice how I’m just doing [{{Absinthe.Resolution, :call}, &create_item/3}] instead of appending the resolution middleware to the existing middleware list.

Run this and look at the output from this line field.middleware |> IO.inspect(label: :existing_middleware). Basically, when you don’t define a resolve on a field, it gets default middleware. This is important, because it’s why you can type just field :name, :string and not need to supply any resolver or middleware.

The default middleware basically does a Map.get(parent, field_name) and marks the resolution struct as resolved. The Absinthe.Resolution middleware only runs if the resolution struct is not already resolved, and so while you WERE succesfully adding the resolver to the list, you were doing it at the end, and so it was never actually calling your create_item/3 function.

1 Like

Thank you again, for the thorough explanation. That makes sense, and that explains why it did appear to work in some of my tinkerings when I put my resolution in front of the list of existing middleware.

Thanks for clarifying!

1 Like