/Graphity

A .NET Core library that exposes a DbContext as a GraphQL endpoint with zero configuration.

Primary LanguageC#GNU General Public License v3.0GPL-3.0

Graphity

Nuget AppVeyor Unit Tests Code Coverage

A .NET Core library that exposes a DbContext as a GraphQL endpoint with zero configuration.

The aim of this project is to provide a GraphQL endpoint by providing only the DbContext. Further configuration of the schema and queries is available but not required to get up and running fast.

How to use Graphity

  1. Set up your DbContext as you would normally, ensuring it has been added to the DI container.

  2. Add the Graphity Nuget package to your project using the Visual Studio Nuget Package manager or from the command line:

    dotnet add package graphity
    
  3. In your ConfigureServices method in Startup, add Graphity to your container:

    services.AddGraphity<YourDbContext>();
  4. Add the Graphity middleware to your pipeline, this needs to happen before MVC if you are using it. Add this line to the Startup.Configure method:

    app.UseGraphity();

    This exposes the graph on the default endpoint of /api/graph. Supply a different value if you prefer another path.

  5. Now you can call your graph with any GraphQL tool you choose. For example Insomnia or GraphiQL.

That's it?!

The idea behind Graphity is to be able to get up and running with minimal code. Of course you can configure the graph further by manually specifying exactly what you want to expose. For example:

services.AddGraphity<AnimalContext>(options =>
{
    options
        .ConfigureSet(ctx => ctx.Animals)
        .ConfigureSet(ctx => ctx.Countries, SetOption.IncludeAsFieldOnly);
});

With this code, no matter how many DbSets you have in your context, the graph will only expose the ones configured here.

You can also apply some default filters to your sets. For example, perhaps you only want to query on rows where the Active column is set to true, that would look something like this:

services.AddGraphity<AnimalContext>(options =>
{
    options
        .ConfigureSet(ctx => ctx.Animals, defaultFilter: a => a.Active == true);
});

Or another example demonstrating the fluent interface:

services.AddGraphity<AnimalContext>(options =>
{
    options.QueryName("AnimalsQuery");

    options.ConfigureSet(ctx => ctx.Animals)
        .FieldName("filteredAnimals")  //Name the field
        .Filter(a => a.Active == true) //Exclude all inactive animals
        .DefaultOrderBy(a => a.Name) //Add a default order to sort by name
        .ConfigureProperty(a => a.Id).Exclude() //Hide the Id column from the graph
        .ConfigureProperty(a => a.LivesInId).Exclude(); //Hide the LivesInId column from the graph

    options.ConfigureSet(ctx => ctx.Countries)
        .ConfigureProperty(c => c.Id).Exclude(); //Hide the Id column from the graph
});

Also, you can add your own custom projections that sit on top of your context. You can think of these in the same way you would a view in SQL. For example:

services.AddGraphity<AnimalContext>(options =>
{
    options.AddProjection("animalCountries", ctx => ctx.Animals.Select(a => new AnimalCountry
    {
        Animal = a.Name,
        Country = a.LivesIn.Name
    }));
});

The only caveat is that the type you are projecting to must be a concrete object and not an anonymous type.

Authorisation

Authorisation comes in two parts. First the policies need to be defined, and secondly those policies need to be assigned to the query or individual fields.

Graphity comes with some default policies that you can use, or you can add your own custom policies. If you want to ensure a user has a particular role, use the AddHasRolesAuthorisationPolicy method:

services.AddGraphity<AnimalContext>(options =>
{
    options.AddHasRolesAuthorisationPolicy("admin-policy", "admin");
};

If you want to ensure a user has a particular scope, use the AddHasScopeAuthorisationPolicy method:

services.AddGraphity<AnimalContext>(options =>
{
    options.AddHasScopeAuthorisationPolicy("scope-policy", "scope1");
};

For a more complex requirement you have two options. You can implement your own IAuthorizationPolicy and add it to the store:

services.AddGraphity<AnimalContext>(options =>
{
    options.options.AddAuthorisationPolicy<MyCustomAuthPolicy>("custom-policy");
};

Or you can use the Func policy that lets you use a simple method. For example, you could have a method as simple as this:

private static async Task<AuthorisationResult> WeekendOnlyPolicy()
{
    var isWeekend = DateTime.Today.DayOfWeek == DayOfWeek.Saturday ||
                    DateTime.Today.DayOfWeek == DayOfWeek.Sunday;

    //This isn't really an async method
    await Task.CompletedTask;

    return isWeekend 
        ? AuthorisationResult.Success() 
        : AuthorisationResult.Fail("This query can only be used on a Saturday or Sunday");
}

And add it to the store like this, note how it take a delegate to the WeekendOnlyPolicy method:

services.AddGraphity<AnimalContext>(options =>
{
    options.AddFuncAuthorisationPolicy("weekendsOnly", WeekendOnlyPolicy);
};

Now you've defined your policies, you need to assign them. If you want to assign this policy to the entire query, add it as a global policy:

services.AddGraphity<AnimalContext>(options =>
{
    options.AddAuthorisationPolicy<HasAdminRoleAuthorisationPolicy>("admin-policy");

    options.SetGlobalAuthorisationPolicy("admin-policy");
}

Or if you only want to protect a single field:

services.AddGraphity<AnimalContext>(options =>
{
    options.AddAuthorisationPolicy<HasAdminRoleAuthorisationPolicy>("admin-policy");

    options.ConfigureSet(ctx => ctx.Countries)
        .SetAuthorisationPolicy("admin-policy");
}

Entity Framework Queries

Another aim of this project is to construct the Entity Framework queries to be as efficient as possible. One of the way we do that is to Include the relevant child entities and only Select the properties we need.

Example Graph Queries

Starting off with a basic query, get all animals and the country where they live. Note that it is possible to rename the fields:

{
  filteredAnimals {
    name
    livesIn {
      country: name
    }
  }
}

How about we only want animals that start with the letter 'S'. For that we could use the where parameter. That allows us to pass a list of clauses that will get "and"ed together:

{
  filteredAnimals(where: [{path: "name", comparison: startsWith, value: "S"}]) {
    name
    livesIn {
      country: name
    }
  }
}

We also support some basic dotted dereferencing of non-enumerable child properties. This allows us to query all animals that don't live in France:

{
  filteredAnimals(where: [{path: "livesIn.name", comparison: notEqual, value: "France"}]) {
    name
    livesIn {
      country: name
    }
  }
}

Or multiple clauses:

{
  filteredAnimals(where: [{path: "name", comparison: startsWith, value: "C"},
                          {path: "numberOfLegs", comparison: greaterThan, value: "2"}]) {
    name
    livesIn {
      country: name
    }
  }
}

We can also write more complex filters in the query with the filter parameter. For example:

{
  filteredAnimals(filter: "name = `Cat` or numberOfLegs < 4") {
    name
    livesIn {
      country: name
    }
  }
}

For a more comprehensive list of what is possible with the filter parameter, see the wiki docs for System.Linq.Dynamic.Core. The only difference Graphity has is that you can specify strings using backticks as well as double quotes. This is because the filter values are already wrapped in quotes and are very awkward to write. So instead of having to escape the inner quotes like this:

filter: "name = \"Cat\""

You can write

filter: "name = `Cat`"

Perhaps we only want the first 3 animals:

{
  filteredAnimals(take: 3) {
    name
    livesIn {
      country: name
    }
  }
}

Or the second batch of 3 animals:

{
  filteredAnimals(skip: 3, take: 3) {
    name
    livesIn {
      country: name
    }
  }
}

But a skip/take is rarely a good idea without specifying an order, and we can order by the country the animal live:

{
  filteredAnimals(skip: 3, take: 3, orderBy:{path:"livesIn.name"}) {
    name
    livesIn {
      country: name
    }
  }
}

I get an error saying "No service for type..."

This is almost certainly because you have an entity that doesn't have a corresponding DbSet. It's a current limitation of Graphity that means you need to have this in place or you will get errors such as this. You have two options:

  1. Add a DbSet for the missing entity.
  2. Configure your graph to exclude any properties that relate to this entity. See above for an explanation on how to do that.

Samples

There are a few sample projects in the repository if you would like to see Graphity in action.

  1. ZeroConfiguration - This project shows the bare minimum code you need to implement Graphity.
  2. FullConfiguration - This is a more complex configuration showing off a much more complete set of configuration options.
  3. MvcWithAuthorisation - A project with MVC that also demonstrates the authorisation aspect of Graphity.

TODO

Here are some things I'd like to get working:

  • Advanced configuration: The ability to further configure the graph, for example:
    • Name the query.
    • Name the fields.
    • Name the types.
    • Name individual properties. (though this might make the dynamic expression building awkward which makes this low priority)
    • Exclude properties from the graph.
  • Add ordering: Add argument to allow custom ordering.
  • Skip & take: To support pagination of queries.
  • Authentication & authorisation: To protect the query or individual fields.
  • Mutations: Currently Graphity is read-only and a big part of GraphQL is the ability to send changes to your data store. However, I'd like to nail the auth component before tackling this one as making changes might be far more dangerous than reading data.
  • Custom mappings: Allow a custom mapping to be injected that transforms the output into a different format. This would be applied after the data is retrieved from EF so could be very generic. As this would affect the graph, this needs some serious thought putting in and may not really be needed... Hmm, one to ponder.

Contributing

I am open to contributions but please open an issue and discuss it with me before you submit a pull request.

Get in Contact

I love hearing from people who have tried out my projects, really makes it feel like a worthwhile endeavour. If you want to get in touch to say hi or comment about this (or any other projects) or even have any suggestions, please use the details in my profile.