How to resolve interfaces and union types in AppSync
Abstract types require a __typename field
Abstract types in GraphQL
GraphQL supports abstract types where a field references a type that can be one of multiple concrete types when the resolver returns. They come in two varieties: interfaces and union types.
For example, let's say a schema defines 2 types of users, both implementing the same interface:
type AdminUser implements User {
id: ID!
permissions: [String!]!
}
type NormalUser implements User {
id: ID!
}
interface User {
id: ID!
}
Then a field can return the interface:
type Query {
allUsers: [User!]!
}
Here, the query returns a list of users. Then the caller can define what fields to resolve for each concrete type using inline fragments:
query MyQuery {
allUsers {
id
__typename
... on AdminUser {
id
permissions
}
... on NormalUser {
id
}
}
}
The result of this query depends on the type of the user objects, as the AdminUser
has an extra permissions
field.
Implementing resolvers for abstract types
This is all great on the schema level, but how does AppSync know if a user is AdminUser
or NormalUser
? Let's see the resolver implementation!
Let's say the users are stored in a database:
Then the resolver issues a Scan
to fetch them:
{
"version" : "2018-05-29",
"operation" : "Scan"
}
Then the response mapping template returns the list:
#if($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
$utils.toJson($ctx.result.items)
Sending the query produces a rather strange error:
{
"errors" : [
{
"errorType" : "BadRequestException",
"message" : "Could not determine the exact type of User. Missing __typename key on value.'"
}
]
}
This message comes with a 400 status code, which is very rare in the GraphQL world. When a field produces an error it is usually a partial response with a 2XX status code. But in this particular case, this blows up the whole query.
Setting the __typename
So, what's the problem here?
AppSync can not know the type of the users, so it could not resolve the inline fragment. The solution is simple from here: convert the types from the database
to a format AppSync understands. And this is, as stated in the error message, is the __typename
:
#foreach($item in $ctx.result.items)
#if($item.type == "admin")
$util.qr($item.put("__typename", "AdminUser"))
#else
$util.qr($item.put("__typename", "NormalUser"))
#end
#end
$utils.toJson($ctx.result.items)
The foreach
iterates over the result items then checks the type
field of each item. Then it adds a field to the item using the
$util.qr($item.put(...))
structure. With this, the query runs as expected.
Why not add the __typename
to the database directly? That would work also, but then it mixes two things: the GraphQL schema and the data model. Usually, a
database shouldn't adapt to the API but the other way around, so it's better to add this check to the resolver rather than to the database.
The above example returned a list, so the resolver needed to use a foreach
to iterate over the elements and add the field. But the same problem applies to
single results too. In that case, there is no need for a foreach
, just the .put(...)
.
And finally, notice that this needed per field that returns an abstract type. This leads to some code duplication as the __typename
handling needs to be
added to all fields' resolvers.
Union types
The same mechanism applies to union types too. For example, a search query can return documents and files:
type Document {
id: ID!
contents: String!
}
type File {
id: ID!
filename: String!
url: String!
}
type Query {
search(text: String!): [SearchResult!]!
}
union SearchResult = File | Document
Here, the results need to have a __typename
that is either File
or Document
. Which means the resolver nees to add either:
$util.qr($item.put("__typename", "File"))
or:
$util.qr($item.put("__typename", "Document"))
When the __typename
field is present for all items, the query runs just fine.