-
Notifications
You must be signed in to change notification settings - Fork 5
QuickStart
This document is a quick walk-thru guide for implementing a simple GraphQL server with NGrqphQL. We will be using a StarWars sample app as a source of code samples.
You define GraphQL schema objects using POCO (plain old c# objects) classes, decorated with a few attributes. These classes can be used both on server side to define the schema, and with the Client to retrieve data as strongly-typed objects. If you plan to use strongly-typed client-side objects (ex: Blazor), we recommend to define GraphQL types in a .NET Standard library referencing only NGraphQL package and no other dependencies.
Create a .NET Standard class library project and add references to NGraphQL. Start defining your GraphQL API model - classes, fields/methods etc. GraphQL types are defined as POCO classes/interfaces decorated with attributes:
using NGraphQL.CodeFirst;
/// <summary>A starship. </summary>
[GraphQLName("Starship")]
public class StarshipType {
/// <summary>The ID of the starship </summary>
[Scalar("ID")]
public string Id { get; set; }
/// <summary>The name of the starship </summary>
public string Name { get; set; }
/// <summary>Category, optional </summary>
[Null] public string Category { get; set; }
/// <summary>Length of the starship, along the longest axis </summary>
[GraphQLName("length")]
public float? GetLength(LengthUnit unit = LengthUnit.Meter) { return default; }
}
The StarWars example defines a pretend business app with 'real' entity classes like Human, Starship etc. The GraphQL API project defines matching classes that define the GraphQL types: HumanType, StarshipType. The Type suffix is just a convention we follow to avoid name collision. But in GraphQL schema the type name will be just Starship - we use GraphQLName attribute to set the name we want. This attribute is optional, use it only if the GraphQL type name does not match the class name.
The code above defines an Object-Type GraphQL type. The XML comments will appear in the Schema document as GraphQL descriptions. You can use attributes defined by NGraphQL to specify extra type details. GraphQL fields with arguments are defined as empty methods returning default values. Use [GraphQLName] attribute to set explicit name for a field - useful for fields with args like length/GetLength pair.
Field names will be automatically converted to camelCase GraphQL style from .NET PascalCase. Field and argument types in GraphQL schema are derived from the .NET types. You can specify an explicit Scalar type for a field using the [Scalar] attribute.
Nullability is inferred - everything is non-nullable by default, but Nullable<T> types like int? are mapped to nullable GraphQL types. Use the [Null] attribute to mark string or object type field as nullable.
GraphQL Input types are also defined as classes; the distinction comes later with the registration in the model - they are registered in different collections. Interface types are just .NET interfaces, with Object-type classes implementing them.
You can use .NET Enum types as-is, NGraphQL converts value names into appropriate strings automatically. A pascal-cased value ValueOne in c# translates into a GraphQL-style string VALUE_ONE, and all conversions at runtime are handled automatically. Additionally, the engine translates the [Flags] enums into GraphQL List-of-Enum types.
You can use business model entity classes directly as GraphQL types when you see fit. Most common cases are enums and Input types.
The GraphQL Union types are defined using the special predifined set of Union<,,..> generic types, with variable number of type arguments:
public class SearchResult : Union<HumanType, DroidType, StarshipType> { }
This code defines a SearchResult GraphQL type of Kind Union, combining three other GraphQL types.
The top-level Query, Mutation types are defined as interfaces:
interface IStarWarsQuery {
[Resolver("GetStarships")]
IList<StarshipType> Starships { get; }
[GraphQLName("starship"), Resolver("GetStarshipAsync")]
StarshipType GetStarship([Scalar("ID")] string id);
}
Once you defined all types, interfaces, unions etc, you combine them in a GraphQL module:
public class StarWarsApiModule: GraphQLModule {
public StarWarsApiModule() {
// Register types
this.EnumTypes.Add(typeof(Episode), typeof(LengthUnit), typeof(Emojis));
this.ObjectTypes.Add(typeof(HumanType), typeof(DroidType),
typeof(StarshipType), typeof(ReviewType));
this.InterfaceTypes.Add(typeof(ICharacter));
this.UnionTypes.Add(typeof(SearchResult));
this.InputTypes.Add(typeof(ReviewInput));
this.QueryType = typeof(IStarWarsQuery);
this.MutationType = typeof(IStarWarsMutation);
// skipped: resolver registration, mappings
}
}
We simply add our GraphQL classes to the appropriate collections. The QueryType and MutationType are special - they will not be presented directly in the schema. The final GraphQL API may combine several modules, so the Query type for the API will included ALL fields from the QueryType of each module; the same for Mutations. These 2 types are containers for fields that will all be combined in the final Query and Mutation types for the entire GraphQL API.
Notice the resolver type registered at the end. We will discuss the resolver classes later. Now we turn to type mappings.
Once we defined and registered all classes for GraphQL schema in a module constructor, we have a fully defined GraphQL schema that server can publish, and it can be used for parsing incoming queries. However, we are missing one important element - where the actual data declared in schema types is coming from? We need to hook the schema and its types to the underlying 'business' application.
There are two ways to connect GraphQL types to the underlying application data:
- Map business entities to the corresponding GraphQL types
- Provide resolver methods for GraphQL fields.
Note that fields/methods in top Query and Mutation types cannot be mapped, they all must have resolvers. Also, only GraphQL fields without arguments (c# true fields or properties) can be mapped; methods with arguments need resolvers.
In this section we describe the mappings, resolvers will be explained next. The following is an example of mapping code:
// in module constructor
MapEntity<Starship>().To<StarshipType>();
MapEntity<Human>().To<HumanType>(h => new HumanType() { Mass = h.MassKg });
Most of the fields are matched by name. For mismatch cases, or when you need to call a function or do a conversion, you can provide an explicit expression as shown in the code above for the Mass-MassKg pair. More complex mappings should be handled by resolvers.
Resolvers are methods that 'handle' the GraphQL field. A resolver is just an instance method in a dedicated resolver class; the class is registered with GraphQL module (see above). You can associate a field with resolver using one of the following methods:
- Default matching by name - if the method declaring the field (not necessarily the field name in GraphQLName attribute) - if it matches a method in one of the resolver classes with the exact same name, the resolver method is linked to the field.
- On the GraphQL field/method using the [Resolver] attribute - it specifies the name of the method explicitly, and optionally the resolver class type
- On the resolver method itself using the [ResolvesField] attribute.
The advantage of using the third method, with attribute on resolver method itself, is that it allows to keep the GraphQL type definition free of any 'references' to resolver classes, that in turn are heavily dependent on your business application. This is an advantage if you want to place all GraphQL types in a light assembly that can be reused on the client (think Blazor) for strongly typed data retrieval through GraphQL client (implemented by NGraphQL as well).
You register resolver class in a module constructor:
this.ResolverTypes.Add(typeof(StarWarsResolvers));
Here is a resolver method for the length field of the Starship type:
public float? GetLength(IFieldContext fieldContext, Starship starship, LengthUnit unit) {
if (starship.Length == null)
return null;
else if (unit == LengthUnit.Meter)
return starship.Length;
else
return starship.Length * 3.28f; // feet
}
A few requirements for resolvers:
- It can be a sync or async method returning Task.
- Resolver returns either primitive .NET value like the resolver above, or (for object types) - an entity type (from business app) or list of entities that are mapped to the output GraphQL type of the field.
- The first parameter of the resolver method is always IFieldContext.
- If the resolver handles the field in the GraphQL Object type (not top Query or mutation), then the second parameter is the instance of the business entity representing this type. This is the case in the code above.
- The rest of the parameters must match the arguments of the field in GraphQL type.
Note that the resolvers for object-type fields always return business entities, not the GraphQL type of the field. The engine will convert the output entity instance to a subset of field values that is requested in the selection list in the GraphQL query. The GraphQL type is actually NEVER instantiated - the engine cherry-picks the values from the entity object and puts it into the output name-value dictionary.
You define all resolver methods in one or more resolver classes, and register the class(es) with GraphQL module. There is an extra interface IResolverClass that the class can implement to handle start/end request events. The resolver instances are created per-request. There might be multiple instances of the same resolver created for a request, to handle parallel request execution. The following are basic rules for resolver instances:
- The mutation requests are always executed sequentially, so only a single resolver of certain type is created for the entire request, and it is reused for all calls to resolver methods in the class during the request lifetime. The request might touch fields with resolvers in different classes - in this case instance of each class will be created when needed.
- The query requests are executed in parallel. For every top query field a separate thread is assigned, and execution proceeds for each field on its own thread. Within the top-field thread the execution is sequential - all nested fields and selection subsets are executed sequentially. The resolver instance is created when needed for each thread, without sharing between threads.
One important case is execution of mutation request with multiple mutating methods. If your data store is a relational database, you need to start/commit the transaction. You can simply start a transaction in the IResolverClass.BeginRequest and commit it in the IResolverClass.EndRequest implementation method of the resolver class. See the GraphQL server in the VITA repo for an example of transaction control.
This completes a brief introduction to mappings and resolvers. Having defined all these elements, we can now create the Server instance.
NGraphQL defines two GraphQL server components:
- GraphQLServer class - a full implementation of GraphQL server but without Http transport and associated JSon serialization/deserialization. It accepts a request as a strongly-typed .NET object, and returns the response data as a tree of nested dictionaries. This component implements the majority of GraphQL handling - parsing, mapping, execution of resolvers and forming the response. It assembles the GraphQL model from multiple modules.
- GraphQLHttpServer - a GraphQL server with HTTP transport and Json encoding of inputs/outputs. This component is a wrapper around GraphQLServer instance, translating its input and output streams; it also provides integration with ASP.NET Core services.
The basic GraphQLServer is a convenient to use for testing your API components, it does not carry overhead of Web/Json extras. You can also use it for creating a server with external protocol other than HTTP. The following section shows how to setup the HTTP server and start it in ASP.NET Core Application.
Create a new ASP.NET Core API project, add references to NGraphQL, NGraphQL.Server_, NGraphQL.Server.AspNetCore packages. Addd reference to the project containing the GraphQL classes we just defined. Add the following code to the Startup class:
private GraphQLHttpServer CreateGraphQLHttpServer() {
var app = new StarWarsApp(); // or ref to your biz app
var server = new GraphQLServer(app);
server.RegisterModules(new StarWarsApiModule()); //this is the module we defined
return new GraphQLHttpServer(server);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... skipped
app.UseRouting();
_graphQLHttpServer = CreateGraphQLHttpServer();
app.UseEndpoints(endpoints => {
endpoints.MapPost("graphql", HandleRequest);
endpoints.MapGet("graphql", HandleRequest);
endpoints.MapGet("graphql/schema", HandleRequest);
});
// Use GraphiQL UI
app.UseGraphiQLServer();
}
GraphQLHttpServer _graphQLHttpServer;
private Task HandleRequest(HttpContext context) {
return _graphQLHttpServer.HandleGraphQLHttpRequestAsync(context);
}
We create HTTP server instance and setup the standard GraphQL HTTP endpoints. Launch the project - the GraphQL server will start and will respond on the configured endpoint. You can send requests to it using NGraphQL client (next section), or explore it using GraphQL tools like Graphiql.
The NGraphQL.Client package provides a GraphQL HTTP client implementation. Install the package into your client-side project.
Next, create the client:
var client = new GraphQLClient("http://127.0.0.1:5500/graphql");
Now you can send GraphQL queries and receive the data:
query = " query { starships { id, name, length } } ";
var response = await client.PostAsync(query);
var ships = response.data.starships;
foreach(var sh in ships)
Console.WriteLine($"Starship {sh.name}, length: {sh.length}");
The response.data field is of dynamic data type, so we can browse the returned object as a tree. This is convenient as we do not need strong types for the data returned by the server, and use a convenient syntax supported by c#.
However, you can share the type definitions from your model with the client, and then retrieve the returned object(s) as a type:
var ships = response.GetTopField<StarshipType[]>("starships");
StarshipType sh = ships[0];
The returned strongly-typed objects will be partially populated, based on selection sets in your query.
You can use queries with variables:
query = @" query ($id: ID) {
starship(id: $id) {id, name, length}
} ";
var vars = new Dictionary<string, object>() { { "id", "3001" } };
var response = await client.PostAsync(query, vars);
var shipName = response.data.starship.name; //should be X-Wing
NGraphQL fully supports Introspection - both on server and client side. A good thing for the client is that Introspection classes like __Type are available on the client, so you can run introspection queries and retrieve results as strongly-typed objects:
query = @"query {
type: __type(name: ""Starship"") {
name kind
}} ";
resp = await client.PostAsync(query);
resp.EnsureNoErrors();
// Retrieve as strongly-typed object
var type = resp.GetTopField<__Type>("type");
Console.WriteLine($"Type {type.Name}", kind: {type.Kind}");
One of the big advantages of GraphQL is a well defined protocol of returning errors. NGrapQL provides a set of helper methods that make it easy to validate input data and save failures as errors that will be returned to the client in the response.
Let's look at the example of resolver method with validation at AddReview method from the BookStore GraphQL Server in VITA ORM source repo:
public IBookReview AddReview(IFieldContext context, BookReviewInput review) {
var book = _session.GetEntity<IBook>(review.BookId); // lookup in Db
var user = _session.GetEntity<IUser>(review.UserId);
context.AddErrorIf(book == null, "Invalid book Id, book not found.");
context.AddErrorIf(user == null, "Invalid user Id, user not found.");
context.AddErrorIf(string.IsNullOrWhiteSpace(review.Caption),
"Caption may not be empty");
context.AddErrorIf(string.IsNullOrWhiteSpace(review.Review),
"Review text may not be empty");
context.AddErrorIf(review.Caption != null && review.Caption.Length > 100,
"Caption is too long, must be less than 100 chars.");
context.AddErrorIf(review.Rating < 1 || review.Rating > 5,
$"Invalid rating value ({review.Rating}), must be between 1 and 5");
context.AbortIfErrors();
// actually create the review record, save and return it
}
The code is self-explanatory. If the input object review is not valid, the execution will be aborted and all discovered errors will be returned to the client in errors collection of the response.
The so-called (N+1) problem in GraphQL implementations is about efficient data retrieval from back-end storage, to fill the response trees of complex multi-level queries. Let's look at an example with humans and starships from StarWars sample. Each human has an associated collection of starships that he/she piloted.
The following query searches for humans with name starting with 'a', and it expects list of ships for each associated human.
query {
searchHumans (text: "a") {
id name
starships {
id name
}
}
}
As the first step, the server will retrieve the list of matching human objects using some respository search method, which is no interest for us now. As the next step the engine would through the list of humans and retrieve name and starhips objects. The Human.starship field might have a resolver like this:
public IList<Starship> GetStarships(IFieldContext fieldContext, Human human) {
return human.Starships;
}
Note that Starship and Human are business entities from your business logic layer, not our GraphQL types (like StarshipType and HumanType). Remember, resolvers in NGraphQL always work with business entities, they return business entities that the engine maps to the output by picking requested fields.
The resolver is quite simple; probably the Human entity has lazy-loaded Starships propery, so the list is loaded from data store when we try to return it as a function result. For our query, the engine needs to call this resolver for every human in the list from step 1. This results in N queries for starships plus 1 first query for humans, hence the (N+1) name.
There is more than one way to solve the problem of queries like that. NGraphQL offers a really simple and efficient solution - batching, done directly in the resolver. Here is the same resolver with batching:
public IList<Starship> GetStarshipsBatched(IFieldContext fieldContext, Human human) {
IList<Human> allHumans = fieldContext.GetAllParentEntities<Human>();
repo.LoadStarshipsFor(allHumans); // your custom load method for a list of humans
var shipsByHuman = allHumans.ToDictionary(h => h, h => h.Starships);
fieldContext.SetBatchedResults<Human, IList<Starship>>(
shipsByHuman, valueForMissingKeys: new List<Starship>());
return null;
}
This resolver is actually called only once, only for the first human in the list. In the resolver, rather than returning a single Starship list for a human, we try to get the lists for all humans that are in the parent list and post back to the engine the dictionary with list for each human. As a result, the engine will not call the resolver anymore, it will pickup values from the dictionary that the first call provided. We do not even need to return the real value - the engine will also get it from the dictionary.
Notice the valueForMissingKeys parameter - it allows you to specify an 'empty' value for keys (humans) not present in the dictionary. It is convenient when you use Select+GroupBy SQL, and humans that have no starships are not present in the output at all. The engine will detect these and use the provided empty list as the output for the field for these humans.
To sum it up: the resolver code can get the entire list of parents for which it WILL be called, and make one batch call to backend to preload field values for all of them.
There is an interesting twist with the batching approach just described. If you are using an ORM to access the backend database, then a smart enough ORM can do this batch loading automatcially in many cases. The example is VITA ORM. The source repo there contains a sample GraphQL server for a BookStore fictional app that shows how it is done. This feature is code named SmartLoad, and is opt-in currently - you have to enable it explicitly with a special flag when opening the Entity session.
The trick is that a full-featured ORM like VITA (we are not talking about light-weight ORM like Dapper), it has a facility called entity tracking - all entities loaded through a session (connection) are tracked internally - the engine keeps weak references to all loaded entities. The purpose of tracking is to know the list of modified entities when the SaveChanges call is fired.
So let's say we want to return a list of books with their authors - each Book has a child list of Authors. The GraphQL engine (or resolver code) first fires the query to retrieve the books. Next, the engine starts going through the list, picking up each Book's properties and reading the Authors list. Right on the first book, when book.Authors property is touched, the lazy-load process is invoked as usual. But the SmartLoad facility intercepts, and does some extra 'reasoning' - looks like we are retrieving this book's Authors list; since we just retrieved a bunch of books, it is likely to happen again for all others, so let's just go and retrieve authors for ALL of them (we have the list, we keep tracking entities inside the session). So it goes and fires SQL that loads author lists for all loaded books, and fills the book.Authors properties for each of them. As a result, when the engine iterates through the book list, no other queries will be fired. We will end up with just 2 queries - one for books and one for authors. The resolver code does not have to do anything special - all hapens automagically.
For now, this magic works only for related lists that are explicitly declared on parent entity like Book.Authors. There are other cases like book-> Orders - in this case the list is generally huge and it's not feasible to materialize it as a direct property. So in this case the resolver has to implement the batching explicitly, as described in the previous secion. I believe it is actually possible to support cases like this inside the ORM, with some extra smart code - I am working on it.