Different approaches for using maps with string keys in Absinthe GraphQL
The problem
By default, when rendering a GraphQL response, Absinthe only considers atom keys in maps. For example, if your query resolver returns:
# ↓ atom key ↓ string key
{:ok, %{:name => "John Doe", "age" => 30}}
And your GraphQL object definition is:
object :person do
field :name, :string
field :age, :number
end
Absinthe will produce the following GraphQL response:
{
"data": {
"person": {
"name": "John Doe",
"age": null
}
}
}
Here, age
is null because Absinthe expected :age
(an atom) but only found "age"
(a string). Meanwhile, name was returned correctly since :name
is an atom.
While having mixed key types (strings and atoms) in the same map is uncommon, this example highlights the issue.
But why would you have string keys in the first place? This could happen if your data originates from an external API that returns JSON, and you don’t want to manually transform it into a struct (maybe you should consider it). You may also want to avoid blindly converting all keys to atoms due to concerns about atom exhaustion.
How to allow string keys in Absinthe
This post covers three levels of granularity for enabling string keys in Absinthe: globally, at the object level, and at the field level.
In a large codebase, enabling this behavior globally may be too risky, so applying it at a smaller scope might be preferable.
Regardless of the approach, we first need to define a custom Absinthe middleware.
Understanding Absinthe’s Default Behavior
By default, every field in Absinthe eventually reaches the default middleware, which is MapGet
. Looking at its implementation helps explain why Absinthe only supports atom keys:
defmodule Absinthe.Middleware.MapGet do
@behaviour Absinthe.Middleware
def call(%{state: :unresolved, source: source} = res, key) do
%{res | state: :resolved, value: Map.get(source, key)}
end
def call(res, _key), do: res
end
Notice that it calls Map.get(source, key)
, and key
corresponds to the GraphQL field name as an atom (e.g. :name
, :age
). This function does not attempt to fetch the key as a string.
Defining a Custom Middleware
To support both atom and string keys, we can define a middleware that also accepts strings, by copying the existing middleware and doing some small changes.
To make it less likely to break existing code, we can change it to attempt the atom key first, then string later if it doesn’t exist, by using Map.get/3
which accepts a fallback.
I’ll call it MyApp.Middleware.MapGetWithIndifferentAccess
, naming inspired by Ruby on Rails’s hash with indifferent access, and from now on in this post, use this term whenever we talk about an object that should accept atom or string keys. Feel free to use your own nomenclature.
defmodule MyApp.Middleware.MapGetWithIndifferentAccess do
@behaviour Absinthe.Middleware
@impl true
def call(%{state: :unresolved, source: source} = res, key) do
fallback = Map.get(source, Atom.to_string(key))
%{res | state: :resolved, value: Map.get(source, key, fallback)}
end
def call(res, _key), do: res
end
Applying the custom middleware
Now that we have our middleware MapGetWithIndifferentAccess
, we can enable it at different levels. I’ll start with the broadest solution first (global), then cover at object level and at field level:
1. Global level
To enable string keys globally, after having defined our custom middleware MapGetWithIndifferentAccess
, we go to our schema file and add a middleware/3
callback to set it as the default:
def middleware(middleware, field, object) do
new_middleware = {MyApp.Middleware.MapGetWithIndifferentAccess, field.identifier}
middleware
|> Absinthe.Schema.replace_default(new_middleware, field, object)
end
If your schema already defines a middleware/3 callback, integrate this logic accordingly. The overall idea is that we need to call replace_default and pass the custom middleware.
2. At object level
Absinthe doesn’t support middlewares at the object level, so for object granularity, we still need to add a global middleware on the schema, but somehow detect that only a few specific objects (the ones we allow list) should support string keys.
The first, and simplest approach, is to keep a list of objects on the schema file, and add a clause that only matches for those objects. So, in your schema file, add something like:
@objects_with_indifferent_access [:person]
def middleware(middleware, field, object) when object.identifier in @objects_with_indifferent_access do
new_middleware = {MyApp.Middleware.MapGetWithIndifferentAccess, field.identifier}
middleware
|> Absinthe.Schema.replace_default(new_middleware, field, object)
end
def middleware(middleware, _field, _object), do: middleware
While this approach works, I don’t like that the indication of the objects with indifferent access lives in the schema file, while most of the time the definition of the objects is far away, likely in their own type files. I strongly believe that for better maintainability, related things should be colocated.
To allow things to be colocated, we could store some metadata on the objects indicating that they should allow indifferent access, and read that on the middleware.
Unfortunately, there’s no official way of storing metadata about an object in Absinthe, but we can cheat by adding some meta into the __private__
key. Technically this is for internal use of the framework, but the object
macro also allows us to put stuff in there:
object :person, __private__: [indifferent_access: true] do
field :name, :string
field :age, :number
end
At the schema, we pattern match against objects with that metadata:
def middleware(middleware, field, %{__private__: %{indifferent_access: true}} = object) do
new_middleware = {MyApp.Middleware.MapGetWithIndifferentAccess, field.identifier}
middleware
|> Absinthe.Schema.replace_default(new_middleware, field, object)
end
def middleware(middleware, _field, _object), do: middleware
To improve readability, we can also define a custom macro (optional):
defmodule MyApp.Schema.Utils do
defmacro object_with_indifferent_access(name, opts \\ [], do: block) do
quote do
object unquote(name), unquote(Keyword.put(opts, :__private__, indifferent_access: true)) do
unquote(block)
end
end
end
end
This allows defining objects more concisely:
import MyApp.Schema.Utils
object_with_indifferent_access :person do
field :name, :string
field :age, :number
end
3. At field level
Absinthe allows calling middlewares at the field level, so in order to get string keys to work for fields, we just need to pass our custom middleware MapGetWithIndifferentAccess
to it:
object :person do
field :name, :string
field :age, :number do
middleware MyApp.Middleware.MapGetWithIndifferentAccess, :age
end
end
If you need to add that to multiple fields and it gets too verbose, you could also define a function indifferent_access/2
. In the example below, I’m adding the function inside the middleware module:
defmodule MyApp.Middleware.MapGetWithIndifferentAccess do
# [existing code]
def indifferent_access(%Absinthe.Resolution{} = resolution, _opts) do
key = resolution.definition.schema_node.identifier
call(resolution, key)
end
end
And use it like this:
import MyApp.Middleware.MapGetWithIndifferentAccess, only: [indifferent_access: 2]
object :person do
field :name, :string
field :age, :number do
middleware &indifferent_access/2
end
end
Or you can also go hardcore with a custom macro:
defmodule MyApp.Middleware.MapGetWithIndifferentAccess do
# [existing code]
defmacro field_with_indifferent_access(name, type, opts \\ [], [do: block] \\ [do: nil]) do
quote do
field unquote(name), unquote(type), unquote(opts) do
middleware MyApp.Middleware.MapGetWithIndifferentAccess, unquote(name)
if block, do: unquote(block)
end
end
end
end
Usage:
import MyApp.Middleware.MapGetWithIndifferentAccess, only: :macros
object :person do
field :name, :string
field_with_indifferent_access :age, :number
end
In this case, I personally think the helper function is already readable enough and I’d avoid defining a macro in this case–(they can easily get out of hand).
Conclusion
This post shares some learnings:
- Absinthe will use the
MapGet
middleware by default, that only supports reading atom keys. - We can define your own custom middleware to also support string keys.
- The custom middleware can be added either to the schema (global) or to fields, but not at the object level. To support object granularity, we need to use a schema (global) middleware and detect if the object wants indifferent access.
- Macros can help us with to remove code repetition, but they also introduce complexity and indirection. Consider tradeoffs before using.
You can also avoid this altogether by converting your JSON to a struct, and an easy way to do that is via Ecto. I covered that on the post Converting complex JSON into Elixir structs