Skip to content

Instantly share code, notes, and snippets.

@benmccallum
Last active June 17, 2022 06:20
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benmccallum/bc421a21f2c55755b8a7dce1a55047b5 to your computer and use it in GitHub Desktop.
Save benmccallum/bc421a21f2c55755b8a7dce1a55047b5 to your computer and use it in GitHub Desktop.
NodeResolver when stitching in Hot Chocolate

Note: These steps are current for HC v12.9, but v13 will likely include this out of the box.

  1. Ensure your node IDs have the schema name in them (so you know where to direct node calls to the right downstream server)
services.RemoveAll<IIdSerializer>();
services.TryAddSingleton<IIdSerializer>(new IdSerializer(includeSchemaName: true));
  1. Setup the node resolver (that unravels the id and calls the relevant downstream server)
.MapField<NodeResolverMiddleware>(
    new FieldReference(GraphQLTypeNames.Query, "node"))
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Execution;
using HotChocolate.Language;
using HotChocolate.Resolvers;
using HotChocolate.Stitching;
using HotChocolate.Stitching.Delegation;
using HotChocolate.Stitching.Requests;
using HotChocolate.Stitching.Utilities;
using HotChocolate.Types;
using HotChocolate.Types.Relay;
namespace AutoGuru.GraphQLService.Stitching
{
/// <summary>
/// Temporary node resolver until something is built-in to HC.
/// </summary>
/// <remarks>
/// Heavily inspired by:
/// https://github.com/ChilliCream/hotchocolate/blob/main/src/HotChocolate/Stitching/src/Stitching/Delegation/DelegateToRemoteSchemaMiddleware.cs
/// </remarks>
public class NodeResolverMiddleware
{
private readonly FieldDelegate _next;
private readonly IIdSerializer _idSerializer;
// ../WellKnownProperties.cs
internal static class WellKnownProperties
{
public const string IsAutoGenerated = "__hc_IsAutoGenerated";
public const string SchemaName = "__hc_SchemaName";
}
internal static class WellKnownContextData
{
public const string NameLookup = "HotChocolate.Stitching.NameLookup";
}
public NodeResolverMiddleware(FieldDelegate next, IIdSerializer idSerializer)
{
_next = next;
_idSerializer = idSerializer;
}
public async Task InvokeAsync(IMiddlewareContext context)
{
var idRaw = context.ArgumentValue<string>("id");
var id = _idSerializer.Deserialize(idRaw);
var schemaName = id.SchemaName;
if (string.IsNullOrWhiteSpace(schemaName))
{
throw new ArgumentException("id");
}
var path = ImmutableStack<SelectionPathComponent>.Empty.Push(
new SelectionPathComponent(
new NameNode(context.Selection.SyntaxNode.Location, "node"),
new List<ArgumentNode>(0)));
var request = CreateQuery(context, schemaName, path);
var result = await ExecuteQueryAsync(context, request, schemaName);
context.RegisterForCleanup(result.Dispose);
UpdateContextData(context, result, schemaName);
var data = ExtractData(result.Data, path.Count());
if (data is { })
{
context.Result = new SerializedData(data);
}
if (result.Errors is { Count: > 0 })
{
ReportErrors(schemaName, context, result.Errors);
}
await _next(context);
}
private static IReadOnlyQueryRequest CreateQuery(
IMiddlewareContext context,
NameString schemaName,
IImmutableStack<SelectionPathComponent> path)
{
var fieldRewriter = new ExtractFieldQuerySyntaxRewriter(
context.Schema,
context.Service<IEnumerable<IQueryDelegationRewriter>>());
var extractedField = fieldRewriter.ExtractField(
schemaName, context.Document, context.Operation,
context.Selection, context.ObjectType);
var queryBuilder = RemoteQueryBuilder.New()
.SetRequestField(extractedField.SyntaxNodes[0])
.SetOperation(context.Operation.Name, OperationType.Query)
.SetSelectionPath(path)
.AddFragmentDefinitions(extractedField.Fragments);
queryBuilder.AddVariables(extractedField.Variables);
var query = queryBuilder
.Build(schemaName, GetNameLookup(context.Schema));
var requestBuilder = QueryRequestBuilder.New()
.SetQuery(query);
foreach (var extractedFieldVariable in extractedField.Variables)
{
var variableName = extractedFieldVariable.Variable.Name.Value;
var namedType = context.Schema.GetType<INamedInputType>(
extractedFieldVariable.Type.NamedType().Name.Value);
if (!context.Variables.TryGetVariable<IValueNode>(variableName, out var value))
{
value = NullValueNode.Default;
}
value = fieldRewriter.RewriteValueNode(
schemaName,
(IInputType)extractedFieldVariable.Type.ToType(namedType),
value);
requestBuilder.SetVariableValue(variableName, value);
}
requestBuilder.SetProperties(context.ContextData);
requestBuilder.SetProperty(WellKnownProperties.IsAutoGenerated, true);
return requestBuilder.Create();
}
private static async Task<IReadOnlyQueryResult> ExecuteQueryAsync(
IResolverContext context,
IReadOnlyQueryRequest request,
string schemaName)
{
var stitchingContext = context.Service<IStitchingContext>();
var executor = stitchingContext.GetRemoteRequestExecutor(schemaName);
var result = await executor.ExecuteAsync(request);
if (result is IReadOnlyQueryResult queryResult)
{
return queryResult;
}
throw new QueryException("Only query results");
}
private static object? ExtractData(
IReadOnlyDictionary<string, object?>? data,
int levels)
{
if (data == null || data.Count == 0)
{
return null;
}
var obj = data.Count == 0 ? null : data.First().Value;
if (obj != null && levels > 1)
{
for (var i = levels - 1; i >= 1; i--)
{
var current = obj as IReadOnlyDictionary<string, object>;
obj = current!.Count == 0 ? null : current.First().Value;
if (obj is null)
{
return null;
}
}
}
return obj;
}
private void UpdateContextData(
IResolverContext context,
IReadOnlyQueryResult result,
NameString schemaName)
{
if (result.ContextData is { Count: > 0 })
{
var builder = ImmutableDictionary.CreateBuilder<string, object?>();
builder.AddRange(context.ScopedContextData);
builder[WellKnownProperties.SchemaName] = schemaName;
builder.AddRange(result.ContextData);
context.ScopedContextData = builder.ToImmutableDictionary();
}
else
{
context.SetScopedValue(WellKnownProperties.SchemaName, schemaName);
}
}
private const string _originalMsgErrorField = "originalMessage";
private const string _remoteErrorField = "remote";
private const string _schemaNameErrorField = "schemaName";
private static void ReportErrors(
NameString schemaName,
IResolverContext context,
IEnumerable<IError> errors)
{
foreach (var error in errors)
{
var builder = ErrorBuilder.FromError(error)
.SetMessage($"Node resolution failed for some reason. See logs in DB. Schema: {schemaName.Value}.")
.SetExtension(_originalMsgErrorField, error.Message)
.SetExtension(_remoteErrorField, error.RemoveException())
.SetExtension(_schemaNameErrorField, schemaName.Value); ;
context.ReportError(builder.Build());
}
}
private static IReadOnlyDictionary<(NameString, NameString), NameString> GetNameLookup(
ISchema schema)
{
if (schema.ContextData.TryGetValue(WellKnownContextData.NameLookup, out var value) &&
value is IReadOnlyDictionary<(NameString, NameString), NameString> dict)
{
return dict;
}
throw RequestExecutorBuilder_NameLookupNotFound();
}
private static InvalidOperationException RequestExecutorBuilder_NameLookupNotFound() =>
new InvalidOperationException("A stitched schema must provide a name lookup");
}
}
@anthony-keller
Copy link

@benmccallum There is a typo in the readme - should be 12.9 and 13

@benmccallum
Copy link
Author

cheers @anthony-keller :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment