Directives
Composition works without intervention if the subgraphs are independent from another and do not share types and fields. But composition can also act as much more than schema stitching: federation directives allow each subgraph to declaratively specify what it can resolve and how it fits with other subgraphs in the federated graph, so the router can expose a consistent whole with connections that span across subgraphs, with minimal coordination between the teams owning each subgraph. Read on to understand what directives exist and what they do, or read the Use cases.
directive @key(
fields: FieldSet!
resolvable: Boolean = true
) repeatable on OBJECT | INTERFACE
The @key
directive is how entities are defined. Entities are types with a key that exist in multiple subgraphs. This is the main mechanism that connects subgraphs together in Federation. It can be thought of as a primary key.
When you make a type an entity with the @key
directive, your Federation compatible GraphQL framework of choice will also require that you define an entity resolver for that type. This is simply a resolver that will be used in the Federation-specific Query._entities
field to fetch
Arguments:
fields
: a string containing GraphQL selection set for the fields included in the key. The selection can be nested (e.g.@key(fields: "a { b } c d")
) but it cannot include field arguments (e.g.@key(fields: "id(type: UUID) { bytes }")
is not valid).resolvable
: when false, indicates that the subgraph references an entity — often by returning its key — but cannot resolve it throughQuery._entities
. In other words, there is no entity resolver for that entity in this subgraph. This is useful when a subgraph has the key for an entity — for example anauthor_id
on a blog post — but does not contribute fields to that entity.
Let us use a fictitious e-commerce website to showcase what you can do with entities. First we define an inventory
subgraph:
type Product @key(fields: "id") @key(fields: "sku") {
id: ID!
itemsInStock: Int!
sku: String!
}
The second @key
simply means that the inventory
subgraph can also resolve a Product from its SKU.
Then a reviews
subgraph for product reviews:
type Product @key(fields: "id") {
id: ID!
reviews: [Review!]
}
And finally a search
subgraph that can find products based on a query:
type Query {
findProducts(searchQuery: String!): [Product!]
}
type Product @key(fields: "id") {
id: ID!
}
After composition, the API of the federated graph will look like this:
type Query {
findProducts(searchQuery: String!): [Product!]
}
type Product {
id: ID!
reviews: [Review!]
itemsInStock: Int!
sku: String!
}
From an API client's perspective, there is a single Product type. Each subgraph contributed fields to it transparently and without coordination.
directive @interfaceObject on OBJECT
Expresses that an object type is part of an Entity Interface. See the section on Entity Interfaces for details and examples.
directive @shareable on FIELD_DEFINITION | OBJECT
Declares a type or a field as shared between subgraphs. Unlike an entity defined with @key
, shareable types and fields must be resolvable in all subgraphs. A good example would be a Color type:
type Color @shareable {
red: Int!
green: Int!
blue: Int!
}
Every subgraph that can return a Color
is expected to provide all the fields. Another way to think about shareable types is to consider them like value types that are always available as a whole.
Annotating a type like Color
in the example above is the same as annotating every field of that type with @shareable
.
directive @requires(fields: FieldSet!) on FIELD_DEFINITION
Expresses the idea that a field requires other fields from the parent type that may be resolved in other subgraphs. For example, let us say that your hotel booking has a subgraph responsible for managing room service, but that subgraph does not have specific hotels in its database, it knows what room service is available depending on the location and category of the hotel, but it does not have a hotels database. We could use @requires
in the following way.
In the Hotels subgraph:
type Hotel @key(fields: "id") {
id: ID!
category: Int
countryCode: String
}
and in the RoomService subgraph:
type Hotel @key(fields: "id") {
id: ID!
category: Int @external
countryCode: String @external
roomServiceOffering: [String!]! @requires(fields: "category countryCode")
}
In this last snippet, the RoomService subgraph expresses that it can resolve Hotel.roomServiceOffering
, but to do so, it needs the category
and countryCode
fields, resolved by an other subgraph. The dependency on the category
and countryCode
fields is expressed with @requires
, and the fact that the subgraph cannot resolve them itself is expressed by @external
. The required fields must be defined on the type, and they must be annotated with @external
.
At runtime, when the gateway must resolve a query that selects Hotel.roomServiceOffering
, it will know that it must first query the Hotels subgraph in order to pass them to the RoomService subgraph when resolving roomServiceOffering
for that hotel. Diving a bit deeper, the mechanism by which the gateway does that is by passing the two previously retrieved fields to the entity resolver (Query._entities
) on the RoomService subgraph.
Note that the fields marked with @external
can be resolved by any number of other subgraphs: zero is valid but will make the field with @requires
impossible to query, one if the fields are regular fields on the entity, or potentially more if the fields are @shareable
.
Remember: @requires
is only allowed on fields of entities, and only used in combination with @external
.
directive @provides(fields: FieldSet!) on FIELD_DEFINITION
Written on a field, @provides
communicates to the gateway that whenever the field is resolved, a set of other fields on the same object can also be resolved by the same subgraph, as an optimization. The other fields must be annotated with @external
, because they cannot be resolved in the subgraph unless the field annotated with @provides
is also resolved. You can think of this directive as a more restricted version of @shareable
. Shareable fields can always be resolved, whereas fields marked with @external
and provided with @provides
are only resolvable when the providing field is also resolved.
A Farms
subgraph:
type Farm @key(fields: "id") {
id: ID!
name: String!
location: String
vegetables: [Vegetable] @provides(fields: "name")
}
type Vegetable @key(fields: "id") {
id: ID!
name: String! @external
}
extend type Query {
farm(id: ID!): Farm
vegetablesInSeason(date: Date!): [Vegetable!]
}
And a Veggies
subgraph:
type Vegetable @key(fields: "id") {
id: ID!
name: String!
scientificName: String!
nutritionInfo: NutritionInfo
marketPriceEur: Int
}
Now let us look at what @provides
does with two example queries.
query {
farm(id: "6058691a-2d0a-47f1-95b3-1632f9ad16f9") {
id
name
}
}
Since the Query.farm
field provides name
, the gateway can resolve the whole query only talking to the Farms
subgraph. On the other hand, in this second query:
query {
vegetablesInSeason(date: "2023-10-03") {
id
name
}
}
the gateway will have to fetch the vegetable name from the Veggies
subgraph. Note that this is transparent to the client: from an API consumer's perspective, there is only one Vegetable type with all the fields defined across all the subgraphs.
directive @external on FIELD_DEFINITION | OBJECT
On a field, expresses that a field is present in the subgraph's schema, but the subgraph cannot resolve it. This directive is only meaningful in combination with @provides
and @requires
.
Writing @external
on an object is equivalent to writing @external
on each of the object's fields.
directive @override(from: String!, label: String) on FIELD_DEFINITION
The use case for @override
is migrating a field from one subgraph to another subgraph. Before you can remove the field from the first subgraph, you have to define it in the second subgraph, but the rules of composition will prevent you from defining the same field in two subgraphs. If you define the field as @shareable
, it must be resolvable in all subgraphs, not only these two. You could mark the new field as @inaccessible
at first, but you cannot switch the field over from the old to the new subgraph without coordination and downtime.
So another solution is needed, and the @override
directive is that solution. When you annotate a field with @override(from: "other-subgraph")
, the gateway will route requests for that field to the subgraph that defines the override and ignore that the field still exists in other-subgraph
. That way, teams can deploy at their own pace with the following workflow (each bullet point is a publish
):
- Deploy the new field with
@override
in the overriding subgraph. That change can easily be reverted if the field does not behave as well in the next subgraph. Once the change is reverted, the gateway will go back to simply resolving the field in the original subgraph. - Remove the old field in the overriden subgraph.
- Remove the
@override
directive in the overriding subgraph.
Imagine you are splitting the handling of comments away from your blog engine monolith into its own service, with its own subgraph. The Post
type looks like this on the monolith:
type Post @key(fields: "id") {
id: ID!
title: String
comments: [Comment!]
publishedAt: DateTime
author: User
}
Migrating the Post.comments
field to the new comments service can be achieved by adding the following to the comments subgraph's schema:
type Post @key(fields: "id") {
id: ID!
comments: [Comment!] @override(from: "monolith")
}
After deploying this addition, all the traffic for Post.comments
will be routed to the comments subgraph.
from
: the name of the subgraph where the field is also defined, and which will be overriden by the field annotated with@override
. Note that this is not validated to enable the following workflow: 1. define the overriding field with@override
, 2. deploy, 3. remove the overriden field, 4. remove the@override
on the new field. If composition validated that the field is also defined in the subgraph referenced infrom
, the migration would break at step (3.), since subgraphs are published independently.label
: allows partial or progressive overriding. Thelabel
argument takes a string of the form "percent(n)" where "n" is an integer from 0 to 100 specifying a percentage of the traffic for the field that the gateway should route to the overriding subgraph. In other words,@override(from: "inventory", label: "percent(0)"")
will not change how the field is resolved, and@override(from: "inventory", label: "percent(100)")
would be equivalent to@override(from: "inventory")
withoutlabel
argument.
directive @composeDirective(name: String!) repeatable on SCHEMA
By default, only some built-in (@deprecated
) and Federation directives (e.g. @inaccessible
) defined on subgraphs will be passed on by composition into the Federated Schema.
The @composeDirective
directive is a signal for composition to preserve instances of a specific directive in the final API schema of the Federated Graph.
Arguments:
name
: the name of the directive to compose.
Example:
extend schema @composeDirective(name: "myCustomDirective")
type Test @myCustomDirective {
id: ID!
}
directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
Restrict access to the annotated item to successfully authenticated users. For more granularity, use @requiresScopes.
directive @requiresScopes(
scopes: [[String!]!]!
) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
Restrict access to the annotated item to users having a matching scope
claim in their JWT access token. The scope
claim is expected to be a string of space-separated scope names.
The directive scopes
argument is an array of arrays that should be understood as a list of combinations of scopes. Each inner array is a combination of scopes where all scopes must be present (AND
). The outer array is a list of accepted alternative combinations of scopes (OR
). The order is not relevant.
For example, imagine you want to restrict access to the view count for a blog post to either users with the editor
and analytics
scope, or admins:
type BlogPost {
id: ID!
title: String!
author: User
viewCount: Int @requiresScopes(scopes: [["admin"], ["editor", "analytics"]])
content: String
}
directive @inaccessible on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION
Parts of the schema that are marked as @inaccessible
will not be part of the composed API schema exposed by the gateway. It only takes one @inaccessible
in one subgraph to exclude an item from the composed API, even if the same item is not inaccessible in other subgraphs.
type PersonalDetails @inaccessible {
age: Int
heightCm: Int
birday: Date
}
type User @key(fields: "id") @key(fields: "socialSecurityNumber") {
id: ID!
# socialSecurityNumber is a key to enable fetching users by social security number, but we do not want the field to be queryable.
socialSecurityNumber: String! @inaccessible
# This field must be marked as inaccessible because the field type is inaccessible.
details: PersonalDetails @inaccessible
}
Imagine you have an RGB color type shared across multiple subgraphs. Since the type is @shareable
, every subgraph must be able to return all of its fields. Now you want to add a new field to that type, so you start adding it to a first subgraph, and then publish. This will lead to a composition error: one subgraph has the new field, but not others. You can accept this fact and publish all your subgraphs in close succession, accepting that your federated graph will not compose in the intermediate states. That works, but it scales poorly if you have many subgraphs managed by different teams. A better solution can be implemented with @inaccessible
. You can publish the first subgraph adding the new field like this:
type Color @shareable {
red: Int!
green: Int!
blue: Int!
opacity: Int! @inaccessible
}
Since opacity
is inaccessible, it does not trigger a composition error, even if opacity
is not defined in other subgraphs. You can progressively add the field to other subgraphs, and once they all define it, remove the @inaccessible
. All the intermediate states compose cleanly.
directive @authorized(
arguments: InputValueSet
metadata: _Any
) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION
The authorized
directive is for authorizing resource access based on attribute data. The directive is available only in the self-hosted Grafbase Gateway together with the authorization hooks. The idea of the directive is to utilize the gateway hook to parse information from the request headers to the context object, and to use this information together with the authorization data to decide should the data be sent back to the user or not.
If an authorization hook responds an error, the requested data will be null
together with a custom error message.
In Grafbase Gateway version 0.4.0, the directive can be written to an edge definition:
schema {
query: QueryRoot
}
type Query {
getUser(id: Int!): User
@authorized(arguments: "id", metadata: { accessLevel: "user" })
}
type User {
id: Int!
name: String!
}
When the user runs a query that accesses the getUser edge:
query {
getUser(id: 1) {
id
name
}
}
It calls the authorize-edge-pre-execution
WebAssembly hook with an edge-definition
of:
EdgeDefinition {
parent_type_name: "Query",
field_name: "getUser",
}
With the arguments:
{ "id": 1 }
And with the metadata:
{ "accessLevel": "user" }
Together with custom headers, the hook function can make a decision should it allow running the query or not.
In Grafbase Gateway version 0.4.0+, the directive can be written to an edge definition:
schema {
query: QueryRoot
}
type Query {
users: User @authorized(node: "id", metadata: { accessLevel: "user" })
}
type User {
id: Int!
name: String!
address: String!
@authorized(fields: "id", metadata: { accessLevel: "user:address" })
}
When the user runs a query that accesses the getUser edge:
query {
users {
id
name
address
}
}
It first calls the authorize-parent-edge-post-execution
WebAssembly hook with the following arguments:
{
context: ..., // The context object
definition: {
parent_type_name: "Query",
field_name: "users",
},
nodes: [
'{"id": 1}',
'{"id": 2}',
],
metadata: '{"accessLevel": "user"}',
}
Any unauthorized nodes will raise an error.
Then the second hook authorize-edge-nost-post-execution
is called with the following arguments:
{
context: ..., // The context object
definition: {
parent_type_name: "Query",
field_name: "users",
},
parents: [
'{"id": 1}',
'{"id": 2}',
],
metadata: '{"accessLevel": "user:address"}',
}
Any unauthorized fields will raise an error.
In Grafbase Gateway version 0.4.0, the directive can be written to a node definition:
type User @authorized(metadata: { accessLevel: "user" }) {
id: Int! @join__field(graph: USERS)
name: String! @join__field(graph: USERS)
}
When the user runs a query that accesses the User type:
query {
getUser(id: 1) {
id
name
}
}
It calls the authorize-node-pre-execution
WebAssembly hook with an node-definition
of:
NodeDefinition {
type_name: "User",
}
And with the metadata:
{ "accessLevel": "user" }
Together with parsing the data in the custom headers to the hook context objects, the hook funtion can make a decision should it allow responding with fields from the User
type.