Hot Chocolatev13

Schema Configuration

Schema stitching and federations do have a lot more potential than just merging root types. You can remove and rename types and fields, extend types with new resolvers and delegate these resolvers to a domain service.

Schema Transformation

Rename Types

The name of a GraphQL type has to be unique. When you build a standalone GraphQL server, the schema validation will make sure that no name is duplicated. In case a name is duplicated, an exception is thrown and the schema will not compile.

This behavior is good for the standalone server but can be an issue in distributed schemas. Even with domain services covering domain-specific topics, a type may be duplicated.

To avoid an invalid schema, Hot Chocolate will prefix duplicated types with the schema name and auto resolves name collisions if they are not structurally equal.

Let us assume we have a product and an inventory service. Both define a type called Category:

SDL
type Category {
name: String
}
SDL
type Category {
name: String
subCategories: [Category!]!
}

The collision resolver of Hot Chocolate will resolve the following on the stitching layer:

SDL
type Category @source(name: "Category", schema: "products") {
name: String!
subCategories: [Category!]!
}
type inventory_Category @source(name: "Category", schema: "inventory") {
name: String!
}

Hot Chocolate allows you to rename types to avoid collision auto resolving:

SDL
type Category @source(name: "Category", schema: "inventory") {
name: String!
}
type ProductCategory @source(name: "Category", schema: "products") {
name: String!
subCategories: [ProductCategory!]!
}

Schema Stitching

In schema stitching type renames can be defined on the gateway:

C#
services
.AddGraphQLServer()
.AddRemoteSchema(Products)
.AddRemoteSchema(Inventory)
.RenameType("Category","ProductCategory", Products);

Schema Federations

In a federated approach, type renames can be done on the domain service:

C#
services
.AddSingleton(ConnectionMultiplexer.Connect("stitching-redis.services.local"))
.AddGraphQLServer()
.AddQueryType<Query>()
.InitializeOnStartup()
.PublishSchemaDefinition(
c => c
.SetName("products")
.RenameType("Category", "ProductCategory")
.AddTypeExtensionsFromFile("./Stitching.graphql")
.PublishToRedis(
"Demo",
sp => sp.GetRequiredService<ConnectionMultiplexer>()));

Rename Fields

Similar to type names, also fields can collide. A type can only declare a field once. When you bundle domain services together, multiple domain services may declare the same field on the query type.

Let us assume we have a product and an inventory service. Both define a type field called categories:

SDL
type Query {
categories: [Category!]!
}
SDL
type Query {
categories: [ProductCategory!]!
}

Hot Chocolate will auto-resolve the naming conflict by prefixing the field with the schema name:

SDL
type Query {
categories: [ProductCategory!]! @delegate(schema: "products")
inventory_categories: [Category!]! @delegate(schema: "inventory", path: "categories")
}

Hot Chocolate allows you to rename fields to avoid collision auto resolving:

SDL
type Query {
productCategories: [ProductCategory!]! @source(name: "categories", schema: "products") @delegate(schema: "products")
categories: [Category!]! @delegate(schema: "inventory")
}

Schema Stitching

In schema stitching field renames can be defined on the gateway:

C#
services
.AddGraphQLServer()
.AddRemoteSchema(Products)
.AddRemoteSchema(Inventory)
.RenameField("Query", "categories", "productCategories", schemaName: Products)

Schema Federations

In a federated approach, type renames can be done on the domain service:

C#
services
.AddSingleton(ConnectionMultiplexer.Connect("stitching-redis.services.local"))
.AddGraphQLServer()
.AddQueryType<Query>()
.InitializeOnStartup()
.PublishSchemaDefinition(
c => c
.SetName("products")
.RenameField("Query", "categories", "productCategories")
.AddTypeExtensionsFromFile("./Stitching.graphql")
.PublishToRedis(
"Demo",
sp => sp.GetRequiredService<ConnectionMultiplexer>()));

Ignore Types

By default, all types of remote schemas are added to the gateway schema. This can produce types that are not reachable. You can remove all not reachable types on the gateway:

C#
services
.AddGraphQLServer()
.AddQueryType(d => d.Name("Query"))
.AddRemoteSchemasFromRedis("Demo", sp => sp.GetRequiredService<ConnectionMultiplexer>())
.ModifyOptions(x => x.RemoveUnreachableTypes = true)

If you want to remove a specific type from the schema you can also use IgnoreType

Schema Stitching

C#
services
.AddGraphQLServer()
.AddRemoteSchema(Products)
.AddRemoteSchema(Inventory)
.IgnoreType("Category", schemaName: Products);

Schema Federations

C#
services
.AddSingleton(ConnectionMultiplexer.Connect("stitching-redis.services.local"))
.AddGraphQLServer()
.AddQueryType<Query>()
.InitializeOnStartup()
.PublishSchemaDefinition(
c => c
.SetName("products")
.IgnoreType("Category")
.AddTypeExtensionsFromFile("./Stitching.graphql")
.PublishToRedis(
"Demo",
sp => sp.GetRequiredService<ConnectionMultiplexer>()));

Ignore Field

Hot Chocolate has a convenience API to ignore fields of types. This can be useful when you want to merge root fields of domain services, but ignore some specific fields

Schema Stitching

C#
services
.AddGraphQLServer()
.AddRemoteSchema(Products)
.AddRemoteSchema(Inventory)
.IgnoreField("Query", "categories", Products)
.IgnoreField("Query", "categories", Inventory);

Delegation of Resolvers

The real power of schema stitching is the delegation of resolvers. You can extend types with fields and redirect calls to a domain service

Let us assume we have a product and an inventory service.

The product service defines the following types

SDL
type Product {
upc: Int!
name: String!
price: Int!
weight: Int!
}
type Query {
products: [Product!]!
}

The inventory service defines the following types

SDL
type InventoryInfo {
upc: Int!
isInStock: bool
}
type Query {
inventoryInfo(upc: Int!): InventoryInfo!
shippingEsitmate(price: Int!, weight: Int!): InventoryInfo!
}

Resolver delegation allows us to combine these schemas into one cohesive schema.

We can extend the product type with inStock and shippingEstimate

SDL
extend type Product {
inStock: Boolean @delegate(schema:"inventory", path: "inventoryInfo(upc: $fields:upc).isInStock")
shippingEstimate: Int @delegate(schema:"inventory", path: "shippingEstimate(price: $fields:price weight: $fields:weight)")
}

This results in the following schema:

SDL
type Product {
upc: Int!
name: String!
price: Int!
weight: Int!
inStock: Boolean
shippingEstimate: Int
}
type Query {
products: [Product!]!
}

Delegate Directive

The @delegate directive describes where the remote data is found.

SDL
directive @delegate(
"The name of the schema to which this field shall be delegated to"
schema: String
"The path on the schema where delegation points to"
path: String!
) on FIELD_DEFINITION

The path argument can contain references to context data or fields.

Field Reference ($fields)

SDL
@delegate(path: "inventoryInfo(upc: $fields:upc).isInStock")

With the $fields variable, you can access fields of the type you extend.

SDL
type Product {
upc: Int!
name: String!
}
extend type Product {
inStock: Boolean @delegate(schema:"inventory", path: "inventoryInfo(upc: $fields:upc).isInStock")
}

Argument Reference ($arguments)

SDL
@delegate(path: "inventoryInfo(upc: $arguments:sku).isInStock")

With the $arguments variable you can access the arguments of the Gateway resolver

SDL
extend type Query {
isProductInStock(sku:String!): Boolean @delegate(schema:"inventory", path: "inventoryInfo(upc: $arguments:upc)")
}

Context Data Reference ($contextData)

Every request contains context data. Context data can be set in resolvers or with a IHttpRequestInterceptor

SDL
extend type Query {
me: User! @delegate(schema: "users", path: "user(id: $contextData:UserId)")
}

UseRequest

C#
services
.AddGraphQLServer()
.UseRequest(next => context =>
{
context.ContextData["UserId"] = context.GetLoggedInUserId();
return next(context);
})
...

RequestInterceptor

C#
public class RequestInterceptor : DefaultHttpRequestInterceptor
{
public ValueTask OnCreateAsync(
HttpContext context,
IRequestExecutor requestExecutor,
IQueryRequestBuilder requestBuilder,
CancellationToken cancellationToken)
{
string userId = context.GetLoggedInUserId();
requestBuilder.SetProperty("UserId", userId);
return base.OnCreateAsync(
context, requestExecutor, requestBuilder, cancellationToken);
}
}
C#
services
.AddGraphQLServer()
.AddHttpRequestInterceptor<RequestInterceptor>()
...

Static Context Data Context data can also be set directly on the schema builder.

C#
services
.AddGraphQLServer()
.SetContextData("foo", "bar")
...

Scoped Context Data Reference ($scopedContext)

Scoped context data can be set in a resolver and will be available in all resolvers in the subtree. You have to use scoped context data when a resolver depends on a field that is higher up than just the parent. You can use field middlewares to set scoped context data.

Let's assume you have a message and account service. The message holds a field messageInfo and knows the id of the creator of the message. You want to extend the messageInfo with the user from the account service.

Schema

SDL
type Message {
content: String!
createdById: ID!
messageInfo: MessageInfo!
}
type MessageInfo {
createdAt: DateTime!
}

Extensions

SDL
extend type MessageInfo {
createdBy: User @delegate(schema:"accounts", path: "userById(upc: $scopedContextData:upc).isInStock")
}

UseField

This middleware is executed for each field.

C#
services
.AddGraphQLServer()
.UseField(next => async context =>
{
if(context.Field.Type.NamedType() is ObjectType objectType &&
objectType.Name.Equals("Message") &&
context.Result is IDictionary<string, object> data &&
data.TryGetValue("createdById", out object value))
{
context.ScopedContextData =
context.ScopedContextData.SetItem("createdById", value);
}
await next.Invoke(context);
})

Type Interceptor

The middleware of UseField is executed on each field and created overhead. It would be better if the middleware is only applied to the field that needs it. You can use a schema interceptor to apply the middleware to the fields that use it.

C#
public class MessageMiddlwareInterceptor : TypeInterceptor
{
public override bool CanHandle(ITypeSystemObjectContext context)
{
return context.Type is INamedType { Name: { Value: "Message" } };
}
public override void OnBeforeCompleteType(
ITypeCompletionContext completionContext,
DefinitionBase? definition,
IDictionary<string, object?> contextData)
{
if (definition is ObjectTypeDefinition otd)
{
var field = otd.Fields
.FirstOrDefault(x => x.Name.Value == "messageInfo");
if (field is { } messageInfo)
{
messageInfo.MiddlewareComponents.Insert(
0,
next => async context =>
{
if(context.Result is IDictionary<string, object> data &&
data.TryGetValue("createdById", out object value))
{
context.ScopedContextData =
context.ScopedContextData.SetItem("createdById", value);
}
await next.Invoke(context);
});
}
}
}
}

Configuration

You can configure the schema extensions either on the gateway or on the domain service if you use federations. Type extensions can either be strings, files or resources

  • AddTypeExtensionFromFile("./Stitching.graphql");
  • AddTypeExtensionFromResource(assembly, key);
  • AddTypeExtensionFromString("extend type Product {foo : String}");

Schema Stitching

Gateway:

C#
services
.AddGraphQLServer()
.AddRemoteSchema(Products)
.AddRemoteSchema(Inventory)
// Adds a type extension.
.AddTypeExtensionsFromFile("./Stitching.graphql")

Schema Federations

Inventory Domain Service:

C#
services
.AddSingleton(ConnectionMultiplexer.Connect("stitching-redis.services.local"))
.AddGraphQLServer()
.AddQueryType<Query>()
.InitializeOnStartup()
.PublishSchemaDefinition(
c => c
.SetName("inventory")
// Ignores the root types. This removes `inStock` and `shippingEsitmate`
// from the `Query` type of the Gateway
.IgnoreRootTypes()
// Adds a type extension.
.AddTypeExtensionsFromFile("./Stitching.graphql")
.PublishToRedis(
"Demo",
sp => sp.GetRequiredService<ConnectionMultiplexer>()));

If you use the @delegate directive in federations you can omit the schema: argument.