wponisoft 611 Views

GraphQL

In 2012 Facebook started GraphQL as an internal project. An idea was to increase the flexibility of client-server communication, especially for data fetching issues in their mobile apps. In 2015 GraphQL had became open source, which resulted in growing GraphQL popularity also in fortune 500 companies:

Expedia Group has moved away from its RESTful API strategy, which was causing hardships for development teams, in favor of a GraphQL approach.

In 2019, Airbnb software engineer Brie Bunge said about 5.8% of all Airbnb traffic involves GraphQL, and she expected that number to reach 10% by the end of last year.

LinkedIn has organized its “economic graph” of relationships between users, using a unified graph to hide complex systems behind a simple GraphQL API. They are leveraging the technology to enable a consistent access experience across online, nearline, and offline environments.

GraphQL vs REST

But is GraphQL an alternative for REST or should be treated as an extension, what are the possible challenges that should be considered when choosing GraphQL?

Design perspective

  • GraphQL is transport agnostic query language while REST is HTTP based architectural style for API design. Obviously, GraphQL typically served over HTTP, and exposes one endpoint (that often designed to accept POST requests, but it is not a must) while it is an “entrypoint” for GraphQL queries: server returns the data that’s queried by the client.
  • REST API, on the other hand, bases on HTTP and defines a suite of URLs each of which expose a single resource using various HTTP verbs and HTTP error codes in compliance with REST API design standards, for example can be found them here. In other words, it is “bound” to HTTP.

While there’s nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.
When there’s limited control over the data that’s returned from an API endpoint, any change can be considered a breaking change, and breaking changes require a new version. If adding new features to an API requires a new version, then a tradeoff emerges between releasing often and having many incremental versions versus the understandability and maintainability of the API.

There is API backward compatibility issues “underneath” and a problems for REST and GraphQL are similar – while REST API during development should be kept backward compatible or should be versioned, so GraphQL schema should evolve in the same way: it also has to be backward compatible with a previous schema (Let’s imagine, that during developing of new functionality few old fields from new schema have been removed – from a client perspective the issue is the same as when you will remove a fields from a REST API – in both cases client will receive an error in response).

Before you start coding

  • Do you expect search engines to index your URL (if so, than for these particular issue API REST has to be your choice: search engines do not index POST requests which are often used for sending GraphQL queries).
  • Do you plan to expose GraphQL endpoint as a data fetch from only one data storage source?
  • Do you plan design your GraphQL as a Gateway?
  • Will it be gateway for REST API or gateway for a number of GraphQL services (here you can expect n + 1 problem and it can be a challenge to design DataLoaders on your layers)? You also should consider here how authorization rules, throttling rates or caching timeouts should be handled. Since GraphQL uses only one endpoint REST route-specific rules should be transferred into GraphQL schema rules: you may need to write authorization, throttling or caching logic in a separate layer.
  • Is GraphQL API is a part of your Mobile and Web application or internal microservice?

Coding

For .NET Web App you can add GraphQL to you app in a few ways: for example, via separate endpoint in your controller, adding your custom GraphQL Middleware or you can use NUGET package GraphQL.Server.Transports.AspNetCore and add GraphQLHttpMiddleware or even Web Sockets Middleware.

Here I’ll show an approach with adding GraphQL endpoint via standard Web API controller. First let’s add such controller to Web API project:

[ApiController]
[Route("api")]
public class GraphQLController : ControllerBase
{
    private readonly IGraphQLProcessor _graphQLService;

    public GraphQLController(IGraphQLProcessor graphQLService)
    {
        _graphQLService = graphQLService;
    }

    [HttpPost("/graphql")]
    [ApiConventionMethod(typeof(GraphQLApiConventions), nameof(GraphQLApiConventions.Post))]
    public async Task<IActionResult> Post([FromBody] GraphQLRequest request)
    {
        var result = await _graphQLService.ProcessQuery(request);
        if (result.HasError)
        {
            return BadRequest(result);
        }

        if (result.Data is null)
        {
            return NotFound();
        }

        return Ok(result);
    }
}

IGraphQLProcessor is a service responsible for schema execution and exposes one method for processing GraphQL requests: Task<GraphQLResponse> ProcessQuery(GraphQLRequest request):

public async Task<GraphQLResponse> ProcessQuery(GraphQLRequest request)
{
    var result = await _schema.ExecuteAsync(_documentWriter, o =>
    {
        o.Query = request.Query;
        o.Inputs = request.Variables.ToInputs();
        o.OperationName = request.OperationName;
        o.ValidationRules = DocumentValidator.CoreRules;
        o.EnableMetrics = false;
        o.ThrowOnUnhandledException = true;
    });

    var response = JsonSerializer.Deserialize<GraphQLResponse>(result, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
    return response;
}

Similar logic for schema execution you can implement in your own Middleware like in this one.

In the next step, we should define our GraphQL schema. In the case it is used for data fetching and data mutations:

[ExcludeFromCodeCoverage]
public class GraphQLSchema : Schema
{
    public GraphQLSchema(QueryGraphType query, MutationGraphType mutation, IServiceProvider serviceProvider) : base(serviceProvider)
    {
        Query = query;
        Mutation = mutation;
    }
}

QueryGraphType is a schema definition part responsible for fetching data:

[ExcludeFromCodeCoverage]
public class QueryGraphType : ObjectGraphType
{
    public QueryGraphType(INotesService notesService)
    {
        Name = $"{GetType().Name}";

        FieldAsync<NoteGraphType, Note>("note",
            "Gets a note by its id.",
            arguments: new QueryArguments(new QueryArgument<StringGraphType> { Name = "noteId" }),
            resolve: context =>
            {
                var noteId = context.GetArgument<string>("noteId");
                return notesService.GetNote(noteId);
            });

        FieldAsync<ListGraphType<NoteGraphType>, IEnumerable<Note>>("notes", resolve: context => notesService.GetNotes());
    }
}


[ExcludeFromCodeCoverage]
public class NoteGraphType : ObjectGraphType<Note>
{
    public NoteGraphType()
    {
        Name = nameof(Note);
        Field<StringGraphType>("Id");
        Field<LongGraphType>("DocumentId");
        Field<StringGraphType>("Title");
        Field<StringGraphType>("Description");
        Field<DateTimeGraphType>("Timestamp");
    }
}

INotesService here is a business layer for data operations on INotesRepository wich uses MongoDb database. Obviously, that in real cloud native scenarios INotesService can aggregate and route a lot of data sources: REST, GraphQL, Database, etc.

Similar, MutationGraphType defined for notes adding, update and delete as:

[ExcludeFromCodeCoverage]
public class MutationGraphType : ObjectGraphType
{
    public MutationGraphType(INotesService notesService)
    {
        Name = $"{GetType().Name}";
        Description = "Mutation for the entities in the service object graph.";
        this.AuthorizeWith("DefaultPolicy");

        FieldAsync<NoteGraphType, Note>(
            "addNote",
            "Add note to database.",
            new QueryArguments(new QueryArgument<NonNullGraphType<NoteInputGraphType>>{ Name = "note" }),
            context =>
            {
                var note = context.GetArgument<Note>("note");
                return notesService.AddNote(note);
            });

        FieldAsync<NoteGraphType, Note>(
            "updateNote",
            "Update note in database.",
            new QueryArguments(
                new QueryArgument<NonNullGraphType<NoteInputGraphType>> { Name = "note" },
                new QueryArgument<NonNullGraphType<StringGraphType>> { Name = "noteId" }),
            context =>
            {
                var note = context.GetArgument<Note>("note");
                note.Id = context.GetArgument<string>("noteId");
                return notesService.UpdateNote(note);
            });

        Field<StringGraphType>(
            "deleteNote",
            "Delete note from database.",
            new QueryArguments(new QueryArgument<NonNullGraphType<StringGraphType>> { Name = "noteId" }),
            context =>
            {
                var noteId = context.GetArgument<string>("noteId");
                notesService.DeleteNote(noteId);
                return $"The note with noteId: '{noteId}' has been successfully deleted from db.";
            }).AuthorizeWith("AdminPolicy");
    }       
}

[ExcludeFromCodeCoverage]
public sealed class NoteInputGraphType : InputObjectGraphType<Note>
{
    public NoteInputGraphType()
    {
        Name = "NoteInput";
        Field(r => r.Description);
        Field(r => r.Title);
        Field(r => r.DocumentId);
        Field(r => r.Timestamp);
    }
}

In fact, all defined graph types, DocumentWriterDocumentExecuter should be registered in Web Application Dependency Injection container. I prefer to define GraphQLServicesCollectionExtensions class to have separate configuration for GraphQL stuff:

[ExcludeFromCodeCoverage]
public static class GraphQLServicesCollectionExtensions
{
    public static IServiceCollection ConfigureGraphQL(this IServiceCollection services)
    {
        services.AddTransient<IGraphQLProcessor, GraphQLProcessor>();               

        global::GraphQL.MicrosoftDI.GraphQLBuilderExtensions
            .AddGraphQL(services)
            .AddDocumentExecuter<DocumentExecuter>()
            .AddDocumentWriter<DocumentWriter>()
            .AddValidationRule<AuthorizationValidationRule>()
            .AddDataLoader()
            .AddSelfActivatingSchema<GraphQLSchema>()
            .AddSystemTextJson(options => options.PropertyNameCaseInsensitive = true);

        return services;
    }
}
  • AddGraphQL().AddSelfActivatingSchema<GraphQLSchema>() registers our schema with all graph types
  • AddDataLoader() registers DataLoader for batch processing and caching requests for n + 1 issue
  • services.AddTransient<IGraphQLProcessor, GraphQLProcessor>() reqisters our GraphQLProcessor

Consuming created API

Now we can consume created GraphQL API. In the GitHub Repo same functionality has been added with REST approach and GraphQL endpoint. Also widely used Swagger configured for Web API Endpoints as well as AltairUI added for GraphQL endpoint testing. Naturally, AltairUI it not a must for GraphQL, you can also use SwaggerGraphiQL, or GraphQL Playground.

In a case of AltairUI data fetching looks like:

For mutation in a case of updateNote:

where we send two variables note and noteId

{
  "note": {
  "documentId": 1234,
  "title": "Title for mutation modified",
  "description": "Description for mutation modified",
  "timestamp": "2021-12-13T10:35:14.830Z"
  },
  "noteId": "61b733a7434d2ea2b8a57d50"
}

For this case HTTP request sent by AltairUI looks like:

{
   "query":"mutation ($note: NoteInput!, $noteId: String!){\n  updateNote(note: $note, noteId: $noteId) {\n    title\n    description\n    documentId\n    timestamp\n  }\n}",
   "variables":{
      "note":{
         "documentId":1234,
         "title":"Title for mutation modified",
         "description":"Description for mutation modified",
         "timestamp":"2021-12-13T10:35:14.830Z"
      },
      "noteId":"61b733a7434d2ea2b8a57d50"
   },
   "operationName":null
}

Validation

Naturally, data validation is a one of the base part of Web Application. In GraphQL validation run when a query is executed. There is a predefined list of validation rules that are turned on by default. You can add your own validation rules or clear out the existing ones by setting the ValidationRules property:

var result = await _schema.ExecuteAsync(_documentWriter, o =>
{
    o.Query = request.Query;
    o.Inputs = request.Variables.ToInputs();
    o.OperationName = request.OperationName;
    o.ValidationRules = DocumentValidator.CoreRules.Concat(new[] { new NoteValidationRule() });
    o.EnableMetrics = false;
    o.ThrowOnUnhandledException = true;
});

Let assume, that note’s title should be less than 50 characters. ValidationRule implementation can look like:

public class NoteValidationRule : IValidationRule
{
    public async Task<INodeVisitor> ValidateAsync(ValidationContext context)
    {
        return new NodeVisitors(
            new MatchingNodeVisitor<Argument>((arg, context) =>
            {
                ValidateAsync(arg, context, context.TypeInfo.GetArgument());
            })
        );
    }

    private void ValidateAsync(IHaveValue node, ValidationContext context, QueryArgument argument)
    {
        if (argument is null)
            return;

        if (!IsNoteArgument(argument.Name))
            return;

        var note = context.Inputs.FirstOrDefault(x => IsNoteArgument(x.Key)).Value as Dictionary<string, object>;
        var noteTitle = note["title"] as string;

        if (!string.IsNullOrEmpty(noteTitle) && noteTitle.Length > 50)
        {
            context.ReportError(new ValidationError(context.Document.OriginalQuery, "1.0", $"Field 'title' in argument '{argument.Name}' can not be longer than 50", node));
        }
    }
    private static bool IsNoteArgument(string argumentName)
    {
        return argumentName.Equals("note", StringComparison.InvariantCultureIgnoreCase);
    }
}

Authorization

While in example GraphQL endpoint defined in API controller, it uses defined authentication for it.

Authorization in GraphQL based on Validation approach with using of AuthorizationValidationRule.

To add it to the project we need to do:

services.AddAuthorization(o => {
                o.AddPolicy("DefaultPolicy", policyBuilder => policyBuilder.RequireAuthenticatedUser());
                o.AddPolicy("AdminPolicy", policyBuilder => policyBuilder.RequireClaim("role", "Admin"));
            });

services.AddHttpContextAccessor().AddTransient<IClaimsPrincipalAccessor, DefaultClaimsPrincipalAccessor>();
  • Register AuthorizationValidationRule in container by adding AddValidationRule<AuthorizationValidationRule>():
global::GraphQL.MicrosoftDI.GraphQLBuilderExtensions
                .AddGraphQL(services)
                .AddDocumentExecuter<DocumentExecuter>()
                .AddDocumentWriter<DocumentWriter>()
                .AddValidationRule<AuthorizationValidationRule>()
                .AddDataLoader()
                .AddSelfActivatingSchema<GraphQLSchema>()
                .AddSystemTextJson(options => options.PropertyNameCaseInsensitive = true);
  • In GraphQLProcessor inject AuthorizationValidationRule and add it to ValidationRules collection:
public class GraphQLProcessor : IGraphQLProcessor
{
    private readonly ISchema _schema;
    private readonly IDocumentWriter _documentWriter;
    private readonly AuthorizationValidationRule _authorizationRule;

    public GraphQLProcessor(IDocumentWriter documentWriter, ISchema schema,  AuthorizationValidationRule authorizationRule)
    {
        _documentWriter = documentWriter;
        _schema = schema;
        _authorizationRule = authorizationRule;
    }


    public async Task<GraphQLResponse> ProcessQuery(GraphQLRequest request)
    {
        var result = await _schema.ExecuteAsync(_documentWriter, o =>
        {
            o.Query = request.Query;
            o.Inputs = request.Variables.ToInputs();
            o.OperationName = request.OperationName;
            o.ValidationRules = DocumentValidator.CoreRules
                .Concat(new[] { new NoteValidationRule() })
                .Concat(new[] { _authorizationRule });
            o.EnableMetrics = false;
            o.ThrowOnUnhandledException = true;
        });

        var response = JsonSerializer.Deserialize<GraphQLResponse>(result, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
        return response;
    }        
}

Having it configured now we can add authorization for graph objects, for example only Admin has permissions to Note removal, what is achieved by adding .AuthorizeWith("AdminPolicy") to field definition:

Field<StringGraphType>(
    "deleteNote",
    "Delete note from database.",
    new QueryArguments(new QueryArgument<NonNullGraphType<StringGraphType>> { Name = "noteId" }),
    context =>
    {
        var noteId = context.GetArgument<string>("noteId");
        notesService.DeleteNote(noteId);
        return $"The note with noteId: '{noteId}' has been successfully deleted from db.";
    }).AuthorizeWith("AdminPolicy");

Performance comparison

Lets now compare operations for data fetching and mutating for GraphQL and REST.

For notes list and details data fetching GraphQL average time is about 23 ms whereas for corresponding REST GET is about 14 ms. For mutation operations the times for REST and GraphQL are similar. So if a performance is a must for you and data fetching times are about 10 ms than REST should be a choice.

Summary

Obviously, GraphQL is powerful tool for designing API, especially for clients (mobile and web applications or microservices – it perfectly solves over-fetching and under-fetching issues), it has growing community, a number of solutions of known problems of data fetching and updating. However, it should be noted that it does not solve all the problems that arise when designing REST services: the complexity and number of endpoints is transferred to the complexity of the scheme.
Also, the statement that GraphQL is client oriented is a minor abuse: after all, the server defines the schema, and the client can only request a subset of the data defined on the server.

Code example

GitHub logo alekshura / Compentio.Notes.GraphQL

.NET 5 GraphQL Web API application with MongoDB.

No comments