Skip to content

Latest commit

 

History

History
758 lines (627 loc) · 24.2 KB

configuration.md

File metadata and controls

758 lines (627 loc) · 24.2 KB

Configuration

Contents

Container Registration

Enabling is done via registering in a container.

The container registration can be done via adding to a IServiceCollection:

public static void RegisterInContainer<TDbContext>(
        IServiceCollection services,
        ResolveDbContext<TDbContext>? resolveDbContext = null,
        IModel? model = null,
        ResolveFilters? resolveFilters = null)

snippet source | anchor

EfGraphQLConventions.RegisterInContainer<MyDbContext>(
    serviceCollection,
    model: ModelBuilder.GetInstance());

snippet source | anchor

Inputs

IModel

Configuration requires an instance of Microsoft.EntityFrameworkCore.Metadata.IModel. This can be passed in as a parameter, or left as null to be resolved from the container. When IModel is resolved from the container, IServiceProvider.GetService is called first on IModel, then on TDbContext. If both return null, then an exception will be thrown.

To build an instance of an IModel at configuration time it can be helpful to have a class specifically for that purpose:

static class ModelBuilder
{
    public static IModel GetInstance()
    {
        var builder = new DbContextOptionsBuilder();
        builder.UseSqlServer("Fake");
        using var context = new MyDbContext(builder.Options);
        return context.Model;
    }
}

snippet source | anchor

Resolve DbContext

A delegate that resolves the DbContext.

using Microsoft.EntityFrameworkCore;

namespace GraphQL.EntityFramework
{
    public delegate TDbContext ResolveDbContext<out TDbContext>(object userContext)
        where TDbContext : DbContext;
}

snippet source | anchor

It has access to the current GraphQL user context.

If null then the DbContext will be resolved from the container.

Resolve Filters

A delegate that resolves the Filters.

namespace GraphQL.EntityFramework
{
    public delegate Filters? ResolveFilters(object userContext);
}

snippet source | anchor

It has access to the current GraphQL user context.

If null then the Filters will be resolved from the container.

Usage

public static void RegisterInContainer<TDbContext>(
        IServiceCollection services,
        ResolveDbContext<TDbContext>? resolveDbContext = null,
        IModel? model = null,
        ResolveFilters? resolveFilters = null)

snippet source | anchor

EfGraphQLConventions.RegisterInContainer<MyDbContext>(
    serviceCollection,
    model: ModelBuilder.GetInstance());

snippet source | anchor

Then the IEfGraphQLService can be resolved via dependency injection in GraphQL.net to be used in ObjectGraphTypes when adding query fields.

DocumentExecuter

The default GraphQL DocumentExecuter uses Task.WhenAll to resolve async fields. This can result in multiple EF queries being executed on different threads and being resolved out of order. In this scenario the following exception will be thrown.

Message: System.InvalidOperationException : A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext, however instance members are not guaranteed to be thread safe. This could also be caused by a nested query being evaluated on the client, if this is the case rewrite the query avoiding nested invocations.

To avoid this a custom implementation of DocumentExecuter but be used that uses SerialExecutionStrategy when the operation type is OperationType.Query. There is one included in this library named EfDocumentExecuter:

using GraphQL.Execution;
using GraphQL.Language.AST;

namespace GraphQL.EntityFramework
{
    public class EfDocumentExecuter :
        DocumentExecuter
    {
        protected override IExecutionStrategy SelectExecutionStrategy(ExecutionContext context)
        {
            Guard.AgainstNull(nameof(context), context);
            if (context.Operation.OperationType == OperationType.Query)
            {
                return new SerialExecutionStrategy();
            }
            return base.SelectExecutionStrategy(context);
        }
    }
}

snippet source | anchor

Connection Types

GraphQL enables paging via Connections. When using Connections in GraphQL.net it is necessary to register several types in the container:

services.AddTransient(typeof(ConnectionType<>));
services.AddTransient(typeof(EdgeType<>));
services.AddSingleton<PageInfoType>();

There is a helper methods to perform the above:

EfGraphQLConventions.RegisterConnectionTypesInContainer(IServiceCollection services);

or

EfGraphQLConventions.RegisterConnectionTypesInContainer(Action<Type> register)

DependencyInjection and ASP.Net Core

As with GraphQL .net, GraphQL.EntityFramework makes no assumptions on the container or web framework it is hosted in. However given Microsoft.Extensions.DependencyInjection and ASP.Net Core are the most likely usage scenarios, the below will address those scenarios explicitly.

See the GraphQL .net documentation for ASP.Net Core and the ASP.Net Core sample.

The Entity Framework Data Context instance is generally scoped per request. This can be done in the Startup.ConfigureServices method:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddScoped(
          provider => MyDbContextBuilder.BuildDbContext());
    }
}

Entity Framework also provides several helper methods to control a DbContexts lifecycle. For example:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContext<MyDbContext>(
          provider => DbContextBuilder.BuildDbContext());
    }
}

See also EntityFrameworkServiceCollectionExtensions

With the DbContext existing in the container, it can be resolved in the controller that handles the GraphQL query:

[Route("[controller]")]
[ApiController]
public class GraphQlController :
    Controller
{
    IDocumentExecuter executer;
    ISchema schema;

    public GraphQlController(ISchema schema, IDocumentExecuter executer)
    {
        this.schema = schema;
        this.executer = executer;
    }

    [HttpPost]
    public Task<ExecutionResult> Post(
        [BindRequired, FromBody] PostBody body,
        CancellationToken cancellation)
    {
        return Execute(body.Query, body.OperationName, body.Variables, cancellation);
    }

    public class PostBody
    {
        public string? OperationName;
        public string Query = null!;
        public JObject? Variables;
    }

    [HttpGet]
    public Task<ExecutionResult> Get(
        [FromQuery] string query,
        [FromQuery] string? variables,
        [FromQuery] string? operationName,
        CancellationToken cancellation)
    {
        var jObject = ParseVariables(variables);
        return Execute(query, operationName, jObject, cancellation);
    }

    Task<ExecutionResult> Execute(string query,
        string? operationName,
        JObject? variables,
        CancellationToken cancellation)
    {
        var options = new ExecutionOptions
        {
            Schema = schema,
            Query = query,
            OperationName = operationName,
            Inputs = variables?.ToInputs(),
            CancellationToken = cancellation,
#if (DEBUG)
            ExposeExceptions = true,
            EnableMetrics = true,
#endif
        };

        return executer.ExecuteAsync(options);
    }

    static JObject? ParseVariables(string? variables)
    {
        if (variables == null)
        {
            return null;
        }

        try
        {
            return JObject.Parse(variables);
        }
        catch (Exception exception)
        {
            throw new Exception("Could not parse variables.", exception);
        }
    }
}

snippet source | anchor

Multiple DbContexts

Multiple different DbContext types can be registered and used.

UserContext

A user context that exposes both types.

public class UserContext
{
    public UserContext(DbContext1 context1, DbContext2 context2)
    {
        DbContext1 = context1;
        DbContext2 = context2;
    }

    public readonly DbContext1 DbContext1;
    public readonly DbContext2 DbContext2;
}

snippet source | anchor

Register in container

Register both DbContext types in the container and include how those instance can be extracted from the GraphQL context:

EfGraphQLConventions.RegisterInContainer(
    services,
    userContext => ((UserContext) userContext).DbContext1);
EfGraphQLConventions.RegisterInContainer(
    services,
    userContext => ((UserContext) userContext).DbContext2);

snippet source | anchor

ExecutionOptions

Use the user type to pass in both DbContext instances.

var executionOptions = new ExecutionOptions
{
    Schema = schema,
    Query = query,
    UserContext = new UserContext(dbContext1, dbContext2)
};

snippet source | anchor

Query

Use both DbContexts in a Query:

using GraphQL.EntityFramework;
using GraphQL.Types;

public class MultiContextQuery :
    ObjectGraphType
{
    public MultiContextQuery(
        IEfGraphQLService<DbContext1> efGraphQlService1,
        IEfGraphQLService<DbContext2> efGraphQlService2)
    {
        efGraphQlService1.AddSingleField(
            graph: this,
            name: "entity1",
            resolve: context =>
            {
                var userContext = (UserContext) context.UserContext;
                return userContext.DbContext1.Entities;
            });
        efGraphQlService2.AddSingleField(
            graph: this,
            name: "entity2",
            resolve: context =>
            {
                var userContext = (UserContext) context.UserContext;
                return userContext.DbContext2.Entities;
            });
    }
}

snippet source | anchor

GraphType

Use a DbContext in a Graph:

using GraphQL.EntityFramework;

public class Entity1Graph :
    EfObjectGraphType<DbContext1, Entity1>
{
    public Entity1Graph(IEfGraphQLService<DbContext1> graphQlService) :
        base(graphQlService)
    {
        Field(x => x.Id);
        Field(x => x.Property);
    }
}

snippet source | anchor

Testing the GraphQlController

The GraphQlController can be tested using the ASP.NET Integration tests via the Microsoft.AspNetCore.Mvc.Testing NuGet package.

public class GraphQlControllerTests :
    VerifyBase
{
    static HttpClient client = null!;
    static WebSocketClient websocketClient = null!;

    static GraphQlControllerTests()
    {
        var server = GetTestServer();
        client = server.CreateClient();
        websocketClient = server.CreateWebSocketClient();
        websocketClient.ConfigureRequest =
            request =>
            {
                var headers = request.Headers;
                headers["Sec-WebSocket-Protocol"] = "graphql-ws";
            };
    }

    [Fact]
    public async Task Get()
    {
        var query = @"
{
  companies
  {
    id
  }
}";
        using var response = await ClientQueryExecutor.ExecuteGet(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Get_single()
    {
        var query = @"
query ($id: ID!)
{
  company(id:$id)
  {
    id
  }
}";
        var variables = new
        {
            id = "1"
        };

        using var response = await ClientQueryExecutor.ExecuteGet(client, query, variables);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Get_single_not_found()
    {
        var query = @"
query ($id: ID!)
{
  company(id:$id)
  {
    id
  }
}";
        var variables = new
        {
            id = "99"
        };

        using var response = await ClientQueryExecutor.ExecuteGet(client, query, variables);
        var result = await response.Content.ReadAsStringAsync();
        Assert.Contains("Not found", result);
    }

    [Fact]
    public async Task Get_variable()
    {
        var query = @"
query ($id: ID!)
{
  companies(ids:[$id])
  {
    id
  }
}";
        var variables = new
        {
            id = "1"
        };

        using var response = await ClientQueryExecutor.ExecuteGet(client, query, variables);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Get_companies_paging()
    {
        var after = 1;
        var query = @"
query {
  companiesConnection(first:2, after:""" + after + @""") {
    edges {
      cursor
      node {
        id
      }
    }
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}";
        using var response = await ClientQueryExecutor.ExecuteGet(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Get_employee_summary()
    {
        var query = @"
query {
  employeeSummary {
    companyId
    averageAge
  }
}";
        using var response = await ClientQueryExecutor.ExecuteGet(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Get_complex_query_result()
    {
        var query = @"
query {
  employees (
    where: [
      {groupedExpressions: [
        {path: ""content"", comparison: ""contains"", value: ""4"", connector: ""or""},

          { path: ""content"", comparison: ""contains"", value: ""2""}
      ], connector: ""and""},
      {path: ""age"", comparison: ""greaterThanOrEqual"", value: ""31""}
  	]
  ) {
    id
  }
}";
        using var response = await ClientQueryExecutor.ExecuteGet(client, query);
        var result = await response.Content.ReadAsStringAsync();
        Assert.Contains("{\"employees\":[{\"id\":3},{\"id\":5}]}", result);
        response.EnsureSuccessStatusCode();
    }

    [Fact]
    public async Task Post()
    {
        var query = @"
{
  companies
  {
    id
  }
}";
        using var response = await ClientQueryExecutor.ExecutePost(client, query);
        var result = await response.Content.ReadAsStringAsync();
        Assert.Contains(
            "{\"companies\":[{\"id\":1},{\"id\":4},{\"id\":6},{\"id\":7}]}",
            result);
        response.EnsureSuccessStatusCode();
    }

    [Fact]
    public async Task Post_variable()
    {
        var query = @"
query ($id: ID!)
{
  companies(ids:[$id])
  {
    id
  }
}";
        var variables = new
        {
            id = "1"
        };
        using var response = await ClientQueryExecutor.ExecutePost(client, query, variables);
        var result = await response.Content.ReadAsStringAsync();
        Assert.Contains("{\"companies\":[{\"id\":1}]}", result);
        response.EnsureSuccessStatusCode();
    }

    [Fact]
    public async Task Should_subscribe_to_companies()
    {
        var resetEvent = new AutoResetEvent(false);

        var result = new GraphQLHttpSubscriptionResult(
            new Uri("http://example.com/graphql"),
            new GraphQLRequest
            {
                Query = @"
subscription
{
  companyChanged
  {
    id
  }
}"
            },
            websocketClient,response => {
                if (response == null)
                {
                    return;
                }
                Assert.Null(response.Errors);

                if (response.Data != null)
                {
                    resetEvent.Set();
                }});

        var cancellationSource = new CancellationTokenSource();

        var task = result.StartAsync(cancellationSource.Token);

        Assert.True(resetEvent.WaitOne(TimeSpan.FromSeconds(10)));

        cancellationSource.Cancel();

        await task;
    }

    static TestServer GetTestServer()
    {
        var hostBuilder = new WebHostBuilder();
        hostBuilder.UseStartup<Startup>();
        return new TestServer(hostBuilder);
    }

    public GraphQlControllerTests(ITestOutputHelper output) :
        base(output)
    {
    }
}

snippet source | anchor

GraphQlExtensions

The GraphQlExtensions class exposes some helper methods:

ExecuteWithErrorCheck

Wraps the DocumentExecuter.ExecuteAsync to throw if there are any errors.

public static async Task<ExecutionResult> ExecuteWithErrorCheck(
    this IDocumentExecuter executer,
    ExecutionOptions options)
{
    Guard.AgainstNull(nameof(executer), executer);
    Guard.AgainstNull(nameof(options), options);
    var executionResult = await executer.ExecuteAsync(options);

    var errors = executionResult.Errors;
    if (errors != null && errors.Count > 0)
    {
        if (errors.Count == 1)
        {
            throw errors.First();
        }

        throw new AggregateException(errors);
    }

    return executionResult;
}

snippet source | anchor