GraphQL Server with Hot Chocolate

In my last blog post (1), we implemented a REST API with DDD. This blog post will detail how to add a GraphQL endpoint to this API. If you have never implemented GraphQL, or have never done it with Hot Chocolate, this blog post is for you. You can find the completed source code on GitHub (2).

I chose Hot Chocolate for this because GraphQL .NET, the other alternative, has much less support and requires explicitly defining the schema, whereas Hot Chocolate can infer it from your types, as well as explicitly defining it, if you wish to take the time to do that. In addition to this, Hot Chocolate has a better parser (it passes Facebook’s smoke tests, while GraphQL .NET’s parser does not), it has better performance, better data loaders (I will cover what a Data Loader is below), and supports schema stitching (where you combine multiple GraphQL endpoints into a single schema), among other things (3). This blog post starts where my last one, Domain-Driven Design, leaves off; a REST API implemented with DDD.

Before we get started, there are a couple things to note. First, GraphQL is a graph query language. The definition of “graph” it is using is “a collection of vertices and edges that join pairs of vertices” (4). In this definition, our vertices are entities (e.g. a record in a DB), and our edges are the relationships between entities.

Next, GraphQL always returns a 200 response, regardless of errors. On the server-side, which this blog post covers, this primarily means you will add errors to the response in your error handlers, rather than setting the response code in a global exception handler. Error details are returned via an error field, which can be defined on the root of the response, such as a network error, inside the data field with details on a failed top-level query, or on an individual field within a response.

The reason you would use GraphQL over REST or another protocol is the ability to specify exactly what data you need for an entire page (or section of page). With our REST implementation in my last post, we had to load a user and the details on their checked out books sequentially, because we only returned the checked out book ids on the user object. With GraphQL, we can define a relationship there so the user can pull all the details in a single request; unlike REST, adding these fields does not add any overhead, because the user specifies exactly which fields they want returned in the query. Additionally, we can perform multiple root queries with a single request simply by including them in the query; REST forces use to make one request per query. Combining all of these, we can significantly reduce the network requests our apps and websites make and completely eliminate all unused data transferred, which can significantly improve performance on slow networks.

Setting up Hot Chocolate

First, we need to add the Hot Chocolate packages; I am using version 12.0.1 in this blog post of the following two packages:

  • HotChocolate
  • HotChocolate.AspNetCore

The first step to migrating a REST API to a GraphQL server with Hot Chocolate is creating a query type.

public class Query {}

Next we will add the GraphQL middleware to the Startup.cs file.

services
     .AddGraphQLServer()
     .AddQueryType<Query>();

And use the middleware.

app.UseEndpoints(endpoints => {
     endpoints.MapGraphQL();
     endpoints.MapBananaCakePop();
});

Now that we have everything registered, we can start adding our query endpoints (the GET endpoints in REST). For this first step, we will simply do the same implementation as our old REST controllers.

private readonly FeatureFlags featureFlags;
private readonly IBookApplication bookApplication;

public Query(IOptions<FeatureFlags> featureFlags, IBookApplication bookApplication)
{
    this.featureFlags = featureFlags.Value;
    this.bookApplication = bookApplication;
}

public async Task<List<Book>> GetAllBooks()
{
    if (!featureFlags.EnableBook)
    {
        throw new NotImplementedException("Query not implemented");
    }

    var books = await bookApplication.GetAll();
    return books;
}

Now we can run this query in at the /GraphQL endpoint.

query {
    allBooks {
        id
        isbn
        name 
        publishedOn   
        authors { name }
        publisher { name }
    }
}

Banana Cake Pop is Hot Chocolate’s in-browser query tool; it replaces the original GraphiQL query viewer/editor most GraphQL servers provide, and is a Postman-style tool to support building GraphQL queries and mutations. Now we can go to /GraphQL, see Banana Cake Pop, and run the query above and it will work. Adding the rest of the GET endpoints is equally easy. Adding the PUT, POST, and DELETE endpoints is as simple as adding a Mutation class and adding .AddMutationType<Mutation>() after .AddQueryType<Query>().

Before you jump in and start making these changes to your app, note that you can only register one Query and one Mutation type. This does not mean you must add all your query and mutation types to the same file—there could be hundreds of these for a large API, which would get very messy. To split these into multiple files, just use the type extension feature.

public class Query { }

[ExtendObjectType(typeof(Query))]
public class QueryBookResolvers
{
    private readonly FeatureFlags featureFlags;
    private readonly IBookApplication bookApplication;

    public QueryBookResolvers(IOptions<FeatureFlags> featureFlags, IBookApplication bookApplication)
    {
        this.featureFlags = featureFlags.Value;
        this.bookApplication = bookApplication;
    }

     public async Task<List<Book>> GetAllBooks()
    {
        if (!featureFlags.EnableBook)
        {
            throw new NotImplementedException("Query not implemented");
        }

        var books = await bookApplication.GetAll();
        return books;
    }
}

Then register each type extension, and it will all work correctly.

services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddTypeExtension<QueryBookResolvers>()
    .AddTypeExtension<QueryUserResolvers>();

Error Handling

Now that we have our operations set up, we need an error filter. Hot Chocolate does not expose errors to the client when not in production mode for the same reasons most REST API servers automatically block them when in production mode. However, this prevents us from giving useful errors to our users when they make a mistake. To resolve this, we will implement the IErrorFilter interface, check if the error is because of a validation exception, and if so, expose our error message and set the code to Validation to provide context to the message.

public class ValidationErrorFilter : IErrorFilter
{
    public IError OnError(IError error)
    {
        if (error.Exception is not ValidationException validationException)
        {
            return error;
        }

        var errors = new List<IError>();
        foreach (var err in validationException.Errors)
        {
            var newErr = ErrorBuilder.New()
                .SetMessage(err.ErrorMessage)
                .SetCode("Validation")
                .Build();

            errors.Add(newErr);
        }

        return new AggregateError(errors);
    }
}

Finally, we need to register our filter with .AddErrorFilter<ValidationErrorFilter>(). Now, our queries will display a reasonable response when there are errors, instead of simply a message "Unexpected Execution Error" when we throw a ValidationException when we perform validations inside our entities or applications. Because we used an aggregate error, we can split each validation error into its own error inside our filter as well, instead of having one long error message with multiple error states or just showing the first message.

{
   "errors": [
    {
      "message": "User must have a name",
      "extensions": {
        "code": "Validation"
      }
    }
  ],
  "data": {
    "createUser": null
  }
}

At this point, you will want to add an error filter for NotImplementedException or change the exception thrown in our operations to be a QueryException as well, which is automatically handled by GraphQL. For further validation, there is a library called Fairybread (5) you can use to automatically validate incoming input objects, but I did not think it was at a good point for production use yet; it only reported the first error found and returned too much information to the caller, including the fact that we were using Fairybread and FluentValidation for validation. If these are not issues for your use case, feel free to try it out.

Creating a Graph

Part of the reason to use GraphQL is so clients can pull all related data in a single query. Our user query currently only returns the book id, but as a GraphQL implementation, our users expect to be able to retrieve book information from the relationship between the user and a checked-out book. Without this ability, we are not really defining a graph of vertices joined by edges so much as a simple list of vertices. To fix this, we will now write a resolver to override that field and return an object that contains both the checked out date, the return date, and the fields from the main book type.

[ExtendObjectType(typeof(User))]
public class UserExtensions
{
    private readonly IBookApplication bookApplication;

    public UserExtensions(IBookApplication bookApplication)
    {
        this.bookApplication = bookApplication;
    }

    [BindMember(nameof(User.Books))]
    public async Task<IReadOnlyList<CheckedOutBookDetails>> GetBooks([Parent] User user)
    {
        var books = new List<CheckedOutBookDetails>();
        foreach (var book in user.Books)
        {
            var bookDetails = await bookApplication.GetBook(book.BookId);
            return new CheckedOutBookDetails(bookDetails.Id, bookDetails.Isbn, bookDetails.Name, bookDetails.PublishedOn, bookDetails.Publisher, bookDetails.Authors, book.CheckedOutOn, book.ReturnBy);
        }

        return books;
    }
}

Once we register this new resolver in our startup file with .AddTypeExtension<UserExtensions>(), we have a fully functional GraphQL server implementation. We can also use extensions to add new fields entirely (as such, I could have defined many extension functions on the CheckedOutBook type instead of creating a CheckedOutBookDetails type), and also prevent fields from being resolved (6). However, there is still a bit more work we can do. Look at this query and see if you can identity the issues:

query {
  user(id: "e796b1ed-dce1-4302-9d74-c5a543f8cae6") {
    id
    name
    books {
      id name
    }
  }

  u1: user(id: "e2087ec5-8caf-4969-91ce-5c39fc378afc") {
    id
    name
    books {
      id name
    }
  }
}

First, we have two root objects that are hitting the same table; if we had those ids before we started resolving the data, we could combine that into a single database query. If both queries were using the same user id, we could resolve all the data once and reuse it for the second query as well. In this case, it is not a huge concern, but we could maliciously construct a query to pull a very large number of data repeatedly, which could lock other requests out of our database while it resolves it. Second, we have an N+1 problem in each root query; we are resolving the user (1 query), then returning to the database once for each book the user has checked out (N queries). We can resolve both of these issues and reduce our database requests down to two no matter how much data the user needs with the use of a Data Loader.

Data Loaders

Data loaders take a list of keys and resolve all of them at the same time; since GraphQL knows which user ids we are querying for immediately, it can combine the two user queries into a single query. Once it gets the users back, it can pull the book ids from the users and resolve them in a single database query as well. Here is an example of our Books field with the data loader:

[BindMember(nameof(User.Books))]
public async Task<IReadOnlyList<CheckedOutBookDetails>> GetBooks([Parent] User user, IResolverContext context)
{
    var books = await context.BatchDataLoader<Guid, Book>(
        async (keys, ct) =>
        {
            var books = await bookApplication.GetBooks(keys);
            return books.ToDictionary(x => x.Id);
        })
    .LoadAsync(user.Books.Select(s => s.BookId).ToList());

    return books.Select(s => {
        var book = user.Books.Single(t => t.BookId == s.Id);
        return new CheckedOutBookDetails(s.Id, s.Isbn, s.Name, s.PublishedOn, s.Publisher, s.Authors, book.CheckedOutOn, book.ReturnBy);
    }).ToList();
}

Note that we inject the resolver context into this method; Hot Chocolate will inject that into any resolver method for us; we do not need to configure it anywhere. Note also that we still fully control our database access. If we had an issue with querying too many ids at once, we could build our response dictionary from batches of N items per database query rather than all loading data for all ids at once. To support this, I added the following implementations to our library context and book application to query multiple books by id in one operation. See if you can use these as a reference to change the GetUser method on our query resolver to use a Data Loader.

public async Task<IReadOnlyList<Book>> GetBooksAsync(IReadOnlyList<Guid> ids)
{
    return await libraryContext.Book.AsQueryable()
        .Where(f => ids.Contains(f.Id))
        .ToListAsync();
}

public async Task<IReadOnlyList<ApiContracts.Book>> GetBooks(IReadOnlyList<Guid> ids)
{
    var users = await libraryRepository.GetBooksAsync(ids);
    return users.Select(mapper.Map<Book, ApiContracts.Book>).ToList();
}

Testing

Now that our server is done, we need to write tests for it. If you are not using the data loaders, you can simply write unit tests around your resolver methods. Using the data loaders with mocks quickly becomes a nuisance due to the sheer amount of mocking needed, but writing integration tests is still easy. First, we set up our service collection and build a service provider; we can tie into our Startup.ConfigureServices method to handle most of the work with this. Now we just call ExecuteRequestAsync with our query as a string parameter; we can then assert against the error object on the result or call ToJson() on it and either assert against the JSON directly or deserialize the result into an object to test against.

[Fact]
public async Task ReturnsUser()
{
    var options = new Dictionary<string, string>
    {
        ["FeatureFlags:EnableUser"] = bool.TrueString,
        ["ConnectionStrings:Database"] = "mongodb://localhost"
    };

    var config = new ConfigurationBuilder().AddInMemoryCollection(options);

    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(config.Build());
    new Startup(config.Build()).ConfigureServices(services);
    var serviceProvider = services.BuildServiceProvider();

    var builder = await serviceProvider.ExecuteRequestAsync(
@"query {
  user(id: ""e796b1ed-dce1-4302-9d74-c5a543f8cae6"") {
    id name books { id name }
  }
}");

    var result = builder.ToJson();
    var expected = @"{
  ""data"": {
    ""user"": {
      ""id"": ""e796b1ed-dce1-4302-9d74-c5a543f8cae6"",
      ""name"": ""Abraham Hosch"",
      ""books"": [
        {
          ""id"": ""30558e66-f0df-4dcd-aa96-1b3d329f1b86"",
          ""name"": ""C# in Depth: 4th Edition""
        },
        {
          ""id"": ""0a08e8df-b71e-4300-9683-bd4a1b7bcaf1"",
          ""name"": ""Dependency Injection Principles, Practices, and Patterns""
        }
      ]
    }
  }
}";

    Assert.Equal(expected, result);
}

Conclusion

Now we have a functioning GraphQL server complete with error handling and probably better performance under load than our original REST API thanks to the data loaders and fewer requests to construct a full graph of data. There are still a few more things to consider before we are complete, however, primarily around security.

GraphQL has some attack vectors REST APIs avoid, including:

  • Exposing the entire query and response structure to all clients
  • Potentially deeply nested queries that take a long time to resolve, such as an arbitrarily deep friends of friends relationship
  • Fields that are performance intensive to resolve which can be queried multiple times in a single request

For this API, I do not mind that anyone can read our type schema, but if I did, could disable introspection for all unauthorized users (7) the same way I could require authorization for specific operations and/or fields. The simplest way to resolve the other two is to set a timeout to prevent slow operations from killing performance; Hot Chocolate defaults to a 30-second timeout. If necessary, we can also define complexity values (8) for operations and block execution of requests if their computed complexity is higher than our assigned limit. We could, for example, prevent any queries nested 5 levels or deeper, and not allow multiple root queries in a request when running a an expensive operation by setting the complexity for these operations to or above the maximum allowed value.

Reference

  1. Previous blog post: https://superdevelopment.com/2021/09/24/domain-driven-design/
  2. Source code: https://github.com/Hosch250/Library-DDD/tree/hotChocolateBlog
  3. Discussion of Hot Chocolate vs GraphQL .NET: https://github.com/ChilliCream/hotchocolate/issues/392#issuecomment-571733745
  4. Definition of a Graph: https://www.merriam-webster.com/dictionary/graph
  5. Fairybread: https://github.com/benmccallum/fairybread
  6. Extending a schema: https://chillicream.com/docs/hotchocolate/defining-a-schema/extending-types
  7. Introspection: https://chillicream.com/docs/hotchocolate/server/introspection
  8. Operation Complexity: https://chillicream.com/docs/hotchocolate/security/operation-complexity