Nodes
The node
function is a core abstraction in fuse
, it allows you to express a way to load
a key-able* entity and its shape. By defining the way to load the entity we enable a few use-cases
- We can return the
key
of the node at any point in our graph and theload
function defined on thenode
will take care of resolving the full entity - We can return a list of
keys
when our list endpoint does not return the full entity and theload
function will take care of loading these in parallel.
A few things this endpoint does for you is create an automatic entry-point for the node
query-field as well as the lower-cased type
query-field (in this case user
) and
we'll ensure that the id
of this entity is globally unique.
A node
will define its fields, this is what it will expose to the outside world, fields can be a
translation of properties on the Resource
, computed properties or just plainly expose the property as-is. Every field is nullable by default,
unless you explicitly define it as non-nullable by means of nullable: false
.
Let's look at the example of the Getting Started guide:
import { node } from 'fuse'
type UserSource = {
id: string
name: string
avatarUrl: string
}
export const UserNode = node<
UserSource,
// This is the default, you can change this when you use a different type, like number.
// string
>({
name: 'User',
// This is the default, however if you use a different key-field property you can change this.
// key: 'id',
load: async (ids) => getUsers(ids),
fields: (t) => ({
name: t.exposeString('name'),
avatarUrl: t.exposeString('avatarUrl'),
firstName: t.string({
// resolve here allows us to compute this property with
// the data we have available from the resource.
resolve: (user) => user.name.split(' ')[0],
}),
}),
})
If we were to have a list endpoint that only returned the name
and was missing avatarUrl
we could do the following:
import { addQueryFields } from 'fuse'
addQueryFields((t) => ({
users: t.field({
type: [UserNode],
resolve: async () => {
const result = await listUsers()
return result.map(user => user.id)
}
}),
}))
Now the underlying API knows it needs to go back to the load
function and resolve all the details for these keys.
Similarly if we were to have an objectField
that needs to return a UserNode
we can just return the id
and
the dataloader (opens in a new tab) will ensure that these are loaded in parallel.
*key-able: A key-able entity is an entity that has a unique identifier that can be used to load the entity.
Example of querying the automatically generated entry-points:
query {
node(id: x) {
... on User { id firstName }
}
user(id: x) {
id
firstName
}
}
Globally unique identifiers
You might notice when querying your node
that the value for your key
is slightly different
than what you expect. This is because we ensure that the key
is globally unique, in our case
we will take the name of the type
(in the above case User
) and base64 encode it with the
key
(in the above case id
).
This results in having a globally unique identifier that we can easily decode to be of a certain type
and if we so choose we can keep an entity-independent cache across types that won't result in collisions.
When you use t.arg.id
and the ID
output field to signal that we potentially dealing with an encoded key
we will do the translation back for you.
This means when we'd get a list back as a result of query { users { id firstName } }
that it might look like
{
"users": [
{ "id": "VXNlcjox", "firstName": "John" },
{ "id": "VXNlcjoy", "firstName": "Jane" }
]
}
Both ids respectively translate to User:1
and User:2
, now we can ues that in for instance our
user(id: ID!)
or node(id: ID!)
queries.
Let's expand the example from above, we can query the node
field on Query
with the globally unique key
we
retrieved from our list:
query ($id: ID!) {
node(id: $id) {
... on User { id firstname }
}
}
Now we can invoke that with VXNlcjox
and we'll get our user named John
as a result.
Connecting nodes
In the API we want our data to be connected so we can do a single request to retrieve all the resources we need to show the data on the screen.
Referencing other nodes
For 1:1 relationships you can add a field to your node
that returns the key
of the related node.
In doing so it will take the key
and invoke the load
function of the related node.
When doing so you'll see that we have a resolve
function similar to the firstName
field above,
rather than computing a new property we are telling our field the key that we want loaded. Returning
this key
will make the UserNode
load the full User
object. In resolvers we can see the first property
being named parent
in our case this is the BlogPostSource
which is the object we loaded from our
external datasource.
import { node } from 'fuse'
export const BlogPostNode = node<BlogPostSource>({
name: 'BlogPost',
load: async (ids) => fetchBlogPosts(ids),
fields: (t) => ({
// Exose a field as-is
title: t.exposeString('title'),
author: t.field({
// This refers to the UserNode we created earlier,
// the id will be used to load the full object.
type: UserNode,
resolve: (parent) => parent.author_id
})
}),
})
The benefit here is that when your parent is a list that all of the related nodes will be loaded in parallel.
Extending nodes
When we have a more complex relationship or don't have the key
of the related entity at hand we can choose to
extend the node
.
import { addNodeFields } from 'fuse'
addNodeFields(UserNode, t => ({
blogPosts: t.field({
type: [BlogPostNode],
resolve: (parent) => fetchBlogPostsByAuthor(parent.id)
})
}))
This adds a new field to the node
named blogPosts
and the resolver will fetch all
the blog posts of the user
.
If we want to optmise this to load in parallel as well we can use
t.loadableList
instead oft.field
.
How to handle authorization
Authorization can be hard to reason about in these cases, this is why we thought about a few ways to handle this.
You can centralise authorization to the node
of an entity, this would mean
implementing the logic in the load
function. This means that every time
you need to go to the load
function to resolve an entity it will run
through the authorization logic by default. This does mean that if you need
a different contextual authority you can't just return the identifier.
Alternatively if you know that the underlying datasource is already running authorization you can choose to defer that logic to the datasource.