GraphQL in .NET: Error Handling

GraphQL in .NET: Error Handling

In our journey with GraphQL, sooner or later, we are going to need to pick a strategy to handle errors during mutations. There is a great post "A Guide to GraphQL Errors" where the author shows several options (its pros and cons). Based on this, we will implement the last stage presented there: Stage 6a: Error Union List + Interface.

We will modify the code presented here, please clone or download it. Open the solution and add a file named IError.cs with the following content:

public interface IError
{
    string Message { get; set; }
}

public class NotEmptyError : IError
{
    public string Message { get; set; } = null!;

    public NotEmptyError(string field)
    {
        Message= $"The {field} is required";
    }
}

public class MaxLengthError : IError
{
    public string Message { get; set; } = null!;

    public MaxLengthError(string field, int maxLength)
    {
        Message = $"The max length for {field} is {maxLength}";
    }
}

Open the PostMutations.cs file, and modify the AddPostPayload record as follow:

public record AddPostPayload(Post? post, IError[]? errors);

In the same file, modify the AddPost method with the following content:

public AddPostPayload AddPost(AddPostInput input, [Service] Storage storage)
{
    var errors = new List<IError>();

    if(string.IsNullOrEmpty(input.Body))
    {
        errors.Add(new NotEmptyError(nameof(AddPostInput.Body)));
    }

    if (string.IsNullOrEmpty(input.Title))
    {
        errors.Add(new NotEmptyError(nameof(AddPostInput.Title)));
    }

    if (input.Body?.Length >= 256)
    {
        errors.Add(new MaxLengthError(nameof(AddPostInput.Body), 256));
    }

    if (input.Title?.Length >= 1024)
    {
        errors.Add(new MaxLengthError(nameof(AddPostInput.Title), 1024));
    }

    if (errors.Any())
    {
        return new AddPostPayload(null, errors.ToArray());
    }

    var post = new Post() { Id = Guid.NewGuid(), Body = input.Body, Title = input.Title };

    storage.Posts.Add(post);

    return new AddPostPayload(post, null);
}

All the code to support the errors is done, time to setup the schema to support it. Start creating a file named ErrorType.cs. Here we are going to register an interface, two errors and an union:

public class ErrorType : InterfaceType<IError>
{
    protected override void Configure(
        IInterfaceTypeDescriptor<IError> descriptor)
    {
    }
}

public class MaxLengthErrorType : ObjectType<MaxLengthError>
{
    protected override void Configure(
        IObjectTypeDescriptor<MaxLengthError> descriptor)
    {
        descriptor.Implements<ErrorType>();
    }
}

public class NotEmptyErrorType : ObjectType<NotEmptyError>
{
    protected override void Configure(
        IObjectTypeDescriptor<NotEmptyError> descriptor)
    {
        descriptor.Implements<ErrorType>();
    }
}

public class AddPostPayloadErrorType : UnionType
{
    protected override void Configure(IUnionTypeDescriptor descriptor)
    {
        descriptor.Name("AddPostPayloadError");
        descriptor.Type<MaxLengthErrorType>();
        descriptor.Type<NotEmptyErrorType>();
    }
}

Create a file named AddPostPayloadType.cs with the following content:

public class AddPostPayloadType : ObjectType<AddPostPayload>
{
    protected override void Configure(IObjectTypeDescriptor<AddPostPayload> descriptor)
    {
        descriptor
            .Field(f => f.post)
            .Type<PostType>();

        descriptor
            .Field(f=>f.errors)
            .Type<ListType<AddPostPayloadErrorType>>();
    }
}

And finally, update the PostMutationsType.cs file as follows:

public class PostMutationsType : ObjectType<PostMutations>
{
    protected override void Configure(
        IObjectTypeDescriptor<PostMutations> descriptor)
    {
        descriptor.Field(f => f.AddPost(default!, default!)).Type<AddPostPayloadType>();
        descriptor.Field(f => f.RemovePost(default!, default!));
        descriptor.Field(f => f.EditPost(default!, default!));
    }
}

Run the solution, go to https://localhost:7121/graphql/ and run the following mutation:

mutation {
  addPost(input: { body: "", title: "" }) {
    post {
      id
    }
    errors {
      ... on MaxLengthError {
        message
      }
      ... on NotEmptyError {
        message
      }
    }
  }
}

To see a result such as:

{
  "data": {
    "addPost": {
      "post": null,
      "errors": [
        {
          "message": "The Body is required"
        },
        {
          "message": "The Title is required"
        }
      ]
    }
  }
}

We did it! but there is a lot of boilerplate code. Luckily, HotChocolate has an implementation of this pattern out of the box with a difference, the errors are going to be thrown as exceptions. Create a new file NotFoundException.cs as follows:

public class NotFoundException : Exception
{
    public NotFoundException(Guid id) : base($"The with {id} was not found")
    {
    }
}

Open the PostMutations.cs file and modify the EditPost method with the following content:

public Post EditPost(EditPostInput input, [Service] Storage storage)
{
    var post = storage.Posts.FirstOrDefault(post => post.Id == input.Id);
    if(post==null)
    {
        throw new NotFoundException(input.Id);
    }
    post.Body = input.Body;
    return post;
}

Go to the PostMutationsType.cs file and update the code as follow:

public class PostMutationsType : ObjectType<PostMutations>
{
    protected override void Configure(
        IObjectTypeDescriptor<PostMutations> descriptor)
    {
        descriptor.Field(f => f.AddPost(default!, default!)).Type<AddPostPayloadType>();
        descriptor.Field(f => f.RemovePost(default!, default!));
        descriptor.Field(f => f.EditPost(default!, default!))
            .UseMutationConvention()
            .Error<NotFoundException>();
    }
}

Finally, go to the Program.cs file and enable the mutation conventions with AddMutationConventions:

builder.Services
    .AddGraphQLServer()
    .AddMutationConventions(applyToAllMutations: false)
    .AddQueryType<PostQueriesType>()
    .AddMutationType<PostMutationsType>()
    .AddFiltering()
    .AddSorting();

Run the solution and run the following mutation:

mutation {
  editPost(input: { body: "abc", id: "5e629e6fb8d64126196c7d12cb6f8a6a" }) {
    post {
      id
    }
    errors {
      ... on NotFoundError {
        message
      }
    }
  }
}

To get the same result as our manual implementation:

{
  "data": {
    "editPost": {
      "post": null,
      "errors": [
        {
          "message": "The with 5e629e6f-b8d6-4126-196c-7d12cb6f8a6a was not found"
        }
      ]
    }
  }
}

You can check more about this feature here. The final code of this post is available here. Thanks, and happy coding.