Introduction
In this article, we will introduce GraphQL and show an example of implementation in a context with unstructured data. We will show how to implement GraphQL standard in a context of CMS Headless, like RawCMS, where implementation stack uses ASP.NET Core and MongoDB.
Meanwhile, it is quite easy to implement GraphQL exposure when you have structured data (like RDBMS or well-typed entities over NoSQL), it is very complex when you work with untyped data. I mean when you have multiple sets of data and you want the user to add without any restriction. But when do you need it? This is a very unusual scenario, considering our daily life. It could seem a little bit perverse, also. But this is what we had to do, implementing a Headless CMS, RawCMS. If you don't know anything about it, just think about Headless CMS as a tool that allows you to save and read data without writing any line of code, like it was a REST database. Yes, we know this is very simplistic, but this article is not about Headless CMS, so we don't want to go deeper on it (there is a link below if you need to learn more about it).
Well, working on this project, we fall into this challenge: expose non-structured data using GraphQL. Finally, we did it. It cost a lot in terms of reversing engineering on C# GraphQL implementation, but we make it work and now any RawCMS user can query its database using GraphQL.
That's the result we would share with you and take this opportunity to speak a little bit about GraphQL and how to use it in the real world.
GraphQL
What is GraphQL?
In short, GraphQL is an open source query language create by Facebook as an alternative to the common REST architecture. It allows a request for specific data, giving to clients more control over what information is sent.
A GraphQL operation is simply a query (Read), mutation (Write) or subscription (Continuous read). Each of those operations is only a string
that needs to be constructed according to the GraphQL query language specification. Fortunately, GraphQL is evolving all the time, so there may be other operations in the future.
Why GraphQL?
GraphQL is born for resolve overfetching problem.
With RESTful architecture, the backend defines what data is available for each resource on each URL, while the fronted always has to request all the information in a resource, even if only a part of it is needed. In the worst scenario, a client application has to read multiple resources through multiple network request. A query language like GraphQL on server-side and client-side demand to the client decides which data is needed by making a single request to the server. Network usage is reduced by efficient data transfers for the benefit of application usage, mostly for mobile application.
How Does GraphQL Work?
The foundation behind GraphQL is the Scheme. The scheme define all resources that are available for front-end.
On schema, you should define this main object:
- Types
- Query
- Mutation
- Subscription
This is an example of a schema definition:
{
"data": {
"__schema": {
"queryType": {
"name": "Query"
},
"mutationType": null,
"subscriptionType": null,
"types": [
{
"kind": "OBJECT",
"name": "Query",
"description": null,
"fields": [
{
"name": "country",
"description": null,
"args": [
{
"name": "codCountry",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": "null"
}
................................
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
}
]
}
}
}
A GraphQL introspection makes it possible to retrieve the GraphQL schema from a GraphQL API. Since the schema has all the information about data available through the GraphQL API, it is perfect for autogenerating API documentation and it is the base of GraphiQL ( GraphiQL, the equivalent of swagger for REST).
What are the Benefits of GraphQL?
As already mentioned, GraphQL is born to solve overfetching data and demand to client developer the management of necessary data. In addition to this, there are other benefits to implement GraphQL.
Centric Schema
The GraphQL schema is the unique source of capability application and provides a central location in which all data are described.
Multiple Clients
Modern applications increasingly have a server application and many types of clients connected (Phone, PC, Tablet,....). Each type of client can have different needs on data fetching. With GraphQL, it is probable that is not necessary to implement different source on the server for each client.
Versioning
In GraphQL, there are no API versions as there used to be in REST. In REST, it is normal to offer multiple versions of an API (e.g., api.domain.com/v1/, api.domain.com/v2/), because the resources or the structure of the resources may change over time. In GraphQL, it is possible to deprecate the API on a field level. Thus a client receives a deprecation warning when querying a deprecated field. After a while, the deprecated field may be removed from the schema when not many clients are using it anymore. This makes it possible to evolve a GraphQL API over time without the need for versioning.
What are the Disadvantages of GraphQL?
Query Complexity
People often mistake GraphQL as a replacement for server-side databases, but it’s just a query language. Once a query needs to be resolved with data on the server, a GraphQL agnostic implementation usually performs database access. Also, GraphQL doesn’t take away performance bottlenecks when you have to access multiple fields in one query. Whether the request was made in a RESTful architecture or GraphQL, the varied resources and fields still have to be retrieved from a data source. As a result, problems arise when a client requests too many nested fields at once. Frontend developers are not always aware of the work a server-side application has to perform to retrieve data, so there must be a mechanism like maximum query depths, query complexity weighting, avoiding recursion, or persistent queries for stopping inefficient requests from the other side.
Caching
Implementing caching on GraphQL is more difficult than implementing on REST. On GraphQL, all are exposed on single URL on a single verb than any request can be different.
File Upload
The GraphQL specification does not provide anything about file upload. You can send a file on mutation request using multipart, but you should handle file on field resolver.
GraphQL, REST or OData?
All three are today considered a standard protocol for client-server communication and it would be a mistake to think that a protocol will replace another. All protocols have benefits and disadvantages and the choice is often guided by the type of application we are going to develop.
For example, suppose you should develop an enterprise application where a report or business logic is the main feature. In this case, you can't demand client business logic or data aggregation and REST solution may be the best solution. Otherwise, if your requirement is exposed data vertically on the database, how can be it for CMS, GraphQL and OData can be the right choice.
GraphQL on ASP.NET Core
A GraphQL service on any stack will likely include the following:
- A framework for HTTP service
- A database layer
- A GraphQL library
As we can see, the height structure in more similar classic REST structure. The big difference is you have a unique end point that processes all requests through the use of GraphQL library.
Many thanks to Joe McBride that implements a good library for ASP.NET graphql-dotnet.
RawCMS: An Example of Implementation of GraphQL
In this chapter, we will show an example of the real implementation of GraphQL on the context of CMS Headless. We will implement a dedicated Plugin for extending the capability of RawCMS.
Prerequisites
- Visual Studio
- ASP.NET Core 2 SDK
graphql-dotnet
library - MongoDB
Schema
GraphQL is a strongly typed query language. When you work with MongoDB, your data is typically unstructured. For resolving this problem, we have chosen to define a configurable schema to decide what data is exposed with GraphQL.
On MongoDB, we will have a special collection (_schema
) where we will save a schema configuration with this model:
public class CollectionSchema
{
public string CollectionName { get; set; }
public bool AllowNonMappedFields { get; set; }
public List<Field> FieldSettings { get; set; } = new List<Field>();
}
public class Field
{
public string Name { get; set; }
public bool Required { get; set; }
public string Type { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public FieldBaseType BaseType { get; set; }
public JObject Options { get; set; }
}
Now that you have added your collection, we can define our schema:
public class GraphQLQuery : ObjectGraphType<JObject>
{
public GraphQLQuery(GraphQLService graphQLService)
{
Name = "Query";
foreach (var key in graphQLService.Collections.Keys)
{
Library.Schema.CollectionSchema metaColl = graphQLService.Collections[key];
CollectionType type = new CollectionType
(metaColl, graphQLService.Collections, graphQLService);
ListGraphType listType = new ListGraphType(type);
AddField(new FieldType
{
Name = metaColl.CollectionName,
Type = listType.GetType(),
ResolvedType = listType,
Resolver = new JObjectFieldResolver(graphQLService),
Arguments = new QueryArguments(
type.TableArgs
)
});
}
}
}
public class GraphQLSchema : SchemaQL
{
public GraphQLSchema(IDependencyResolver dependencyResolver,
GraphQLQuery graphQLQuery) : base(dependencyResolver)
{
Query = graphQLQuery;
}
}
The schema is built starting from MongoDB collection and adding a mapping for all elements.
GraphQL Type
GraphQL needed to know what types we will work, but RawCMS is a dynamically CMS, then you can't know the structure beforehand. For solving this problem, we defined a generic type that works like JObject
.
public class CollectionType : ObjectGraphType<object>
{
public QueryArguments TableArgs
{
get; set;
}
private IDictionary<FieldBaseType, Type> _fieldTypeToSystemType;
protected IDictionary<FieldBaseType, Type> FieldTypeToSystemType
{
get
{
if (_fieldTypeToSystemType == null)
{
_fieldTypeToSystemType = new Dictionary<FieldBaseType, Type>
{
{ FieldBaseType.Boolean, typeof(bool) },
{ FieldBaseType.Date, typeof(DateTime) },
{ FieldBaseType.Float, typeof(float) },
{ FieldBaseType.ID, typeof(Guid) },
{ FieldBaseType.Int, typeof(int) },
{ FieldBaseType.String, typeof(string) },
{ FieldBaseType.Object, typeof(JObject) }
};
}
return _fieldTypeToSystemType;
}
}
private Type ResolveFieldMetaType(FieldBaseType type)
{
if (FieldTypeToSystemType.ContainsKey(type))
{
return FieldTypeToSystemType[type];
}
return typeof(string);
}
public CollectionType(CollectionSchema collectionSchema, Dictionary<string,
CollectionSchema> collections = null, GraphQLService graphQLService = null)
{
Name = collectionSchema.CollectionName;
foreach (Field field in collectionSchema.FieldSettings)
{
InitGraphField(field, collections, graphQLService);
}
}
private void InitGraphField(Field field, Dictionary<string, CollectionSchema>
collections = null, GraphQLService graphQLService = null)
{
Type graphQLType;
if (field.BaseType == FieldBaseType.Object)
{
var relatedObject = collections[field.Type];
var relatedCollection = new CollectionType(relatedObject, collections);
var listType = new ListGraphType(relatedCollection);
graphQLType = relatedCollection.GetType();
FieldType columnField = Field(
graphQLType,
relatedObject.CollectionName);
columnField.Resolver = new NameFieldResolver();
columnField.Arguments = new QueryArguments(relatedCollection.TableArgs);
foreach(var arg in columnField.Arguments.Where(x=>!(new string[]
{ "pageNumber", "pageSize", "rawQuery", "_id" }.Contains(x.Name))).ToList())
{
arg.Name = $"{relatedObject.CollectionName}_{arg.Name}";
TableArgs.Add(arg);
}
}
else
{
graphQLType =
(ResolveFieldMetaType(field.BaseType)).GetGraphTypeFromType(!field.Required);
FieldType columnField = Field(
graphQLType,
field.Name);
columnField.Resolver = new NameFieldResolver();
FillArgs(field.Name, graphQLType);
}
}
private void FillArgs(string name, Type graphType)
{
if (TableArgs == null)
{
TableArgs = new QueryArguments(
new QueryArgument(graphType)
{
Name = name
}
);
}
else
{
TableArgs.Add(new QueryArgument(graphType) { Name = name });
}
TableArgs.Add(new QueryArgument<IntGraphType> { Name = "pageNumber" });
TableArgs.Add(new QueryArgument<IntGraphType> { Name = "pageSize" });
TableArgs.Add(new QueryArgument<StringGraphType> { Name = "rawQuery" });
}
}
In CollectionType
, we have to define special fields:
pageNumber
, for pagination pageSize
, for pagination rawQuery
, for allowing to write custom MongoDB query on a mapped collection
Graph Resolver
In GraphQL, the schema defines the objects that are available to the client, resolvers are the connections that explain how database structures are mapped on the schema. In our case, we need two resolvers:
JObjectFieldResolver
which deals on mapping from GraphQL query to MongoDB query:
public class JObjectFieldResolver : IFieldResolver
{
private readonly GraphQLService _graphQLService;
public JObjectFieldResolver(GraphQLService graphQLService)
{
_graphQLService = graphQLService;
}
public object Resolve(ResolveFieldContext context)
{
ItemList result;
if (context.Arguments != null && context.Arguments.Count > 0)
{
int pageNumber = 1;
int pageSize = 1000;
if (context.Arguments.ContainsKey("pageNumber"))
{
pageNumber = int.Parse(context.Arguments["pageNumber"].ToString());
if (pageNumber < 1)
{
pageNumber = 1;
}
context.Arguments.Remove("pageNumber");
}
if (context.Arguments.ContainsKey("pageSize"))
{
pageSize = int.Parse(context.Arguments["pageSize"].ToString());
context.Arguments.Remove("pageSize");
}
result = _graphQLService.CrudService.Query
(context.FieldName.ToPascalCase(), new DataQuery()
{
PageNumber = pageNumber,
PageSize = pageSize,
RawQuery = BuildMongoQuery(context.Arguments)
});
}
else
{
result = _graphQLService.CrudService.Query
(context.FieldName.ToPascalCase(), new DataQuery()
{
PageNumber = 1,
PageSize = 1000,
RawQuery = null
});
}
return result.Items.ToObject<List<JObject>>();
}
private string BuildMongoQuery(Dictionary<string, object> arguments)
{
string query = null;
if (arguments != null)
{
JsonSerializerSettings jSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
};
if (arguments.ContainsKey("rawQuery"))
{
query = Convert.ToString(arguments["rawQuery"]);
}else if (arguments.ContainsKey("_id"))
{
query = "{_id: ObjectId(\"" +
Convert.ToString(arguments["_id"]) + "\")}";
}
else
{
jSettings.ContractResolver = new DefaultContractResolver();
Dictionary<string, object> dictionary = new Dictionary<string, object>();
foreach (string key in arguments.Keys)
{
if (arguments[key] is string)
{
JObject reg = new JObject
{
["$regex"] = $"/*{arguments[key]}/*",
["$options"] = "si"
};
dictionary[key.ToPascalCase().Replace("_",".")] = reg;
}
else
{
dictionary[key.ToPascalCase().Replace("_", ".")] = arguments[key];
}
}
query = JsonConvert.SerializeObject(dictionary, jSettings);
}
}
return query;
}
}
and NameFieldResolver
which deals with mapping from MongoDB
query result to GraphQL
result:
public class NameFieldResolver : IFieldResolver
{
public object Resolve(ResolveFieldContext context)
{
object source = context.Source;
if (source == null)
{
return null;
}
string name = char.ToUpperInvariant(context.FieldAst.Name[0]) +
context.FieldAst.Name.Substring(1);
object value = GetPropValue(source, name);
if (value == null)
{
throw new InvalidOperationException($"Expected to find property
{context.FieldAst.Name} on {context.Source.GetType().Name} but it does not exist.");
}
return value;
}
private static object GetPropValue(object src, string propName)
{
JObject source = src as JObject;
source.TryGetValue
(propName, StringComparison.InvariantCultureIgnoreCase, out JToken value);
if (value != null)
{
return value.Value<object>();
}
else
{
return null;
}
}
}
GraphQL Controller
As the final step, we define a controller that enables GraphQL capability on our application:
[AllowAnonymous]
[RawAuthentication]
[Route("api/graphql")]
public class GraphQLController : Controller
{
private readonly IDocumentExecuter _executer;
private readonly IDocumentWriter _writer;
private readonly GraphQLService _service;
private readonly ISchema _schema;
public GraphQLController(IDocumentExecuter executer,
IDocumentWriter writer,
GraphQLService graphQLService,
ISchema schema)
{
_executer = executer;
_writer = writer;
_service = graphQLService;
_schema = schema;
}
public static T Deserialize<T>(Stream s)
{
using (StreamReader reader = new StreamReader(s))
using (JsonTextReader jsonReader = new JsonTextReader(reader))
{
JsonSerializer ser = new JsonSerializer();
return ser.Deserialize<T>(jsonReader);
}
}
[HttpPost]
public async Task<ExecutionResult> Post([FromBody]GraphQLRequest request)
{
GraphQLRequest t = Deserialize<GraphQLRequest>(HttpContext.Request.Body);
DateTime start = DateTime.UtcNow;
ExecutionResult result = await _executer.ExecuteAsync(_ =>
{
_.Schema = _schema;
_.Query = request.Query;
_.OperationName = request.OperationName;
_.Inputs = request.Variables.ToInputs();
_.UserContext = _service.Settings.BuildUserContext?.Invoke(HttpContext);
_.EnableMetrics = _service.Settings.EnableMetrics;
if (_service.Settings.EnableMetrics)
{
_.FieldMiddleware.Use<InstrumentFieldsMiddleware>();
}
});
if (_service.Settings.EnableMetrics)
{
result.EnrichWithApolloTracing(start);
}
return result;
}
}
Register Plugin on RawCMS
The goal of implementation of GraphQL is enabling this functionally on RawCMS context. As described here, you can register capability like a plugin. Next class registers the GraphQL plugin.
public class GraphQLPlugin : RawCMS.Library.Core.Extension.Plugin,
IConfigurablePlugin<GraphQLSettings>
{
public override string Name => "GraphQL";
public override string Description => "Add GraphQL CMS capabilities";
public override void Init()
{
Logger.LogInformation("GraphQL plugin loaded");
}
private GraphQLService graphService = new GraphQLService();
public override void ConfigureServices(IServiceCollection services)
{
base.ConfigureServices(services);
services.AddSingleton<IDependencyResolver>
(s => new FuncDependencyResolver(s.GetRequiredService));
services.AddSingleton<IDocumentExecuter, DocumentExecuter>();
services.AddSingleton<IDocumentWriter, DocumentWriter>();
services.AddScoped<ISchema, GraphQLSchema>();
services.AddSingleton<GraphQLQuery>();
services.AddSingleton(x => graphService);
}
private AppEngine appEngine;
public override void Configure(IApplicationBuilder app, AppEngine appEngine)
{
this.appEngine = appEngine;
graphService.SetCRUDService(this.appEngine.Service);
graphService.SetLogger(this.appEngine.GetLogger(this));
graphService.SetSettings(config);
graphService.SetAppEngine(appEngine);
base.Configure(app, appEngine);
app.UseGraphiQl(config.GraphiQLPath, config.Path);
}
private IConfigurationRoot configuration;
public override void Setup(IConfigurationRoot configuration)
{
base.Setup(configuration);
this.configuration = configuration;
}
public GraphQLSettings GetDefaultConfig()
{
return new GraphQLSettings
{
Path = "/api/graphql",
EnableMetrics = false,
GraphiQLPath = "/graphql"
};
}
private GraphQLSettings config;
public void SetActualConfig(GraphQLSettings config)
{
this.config = config;
}
}
Points of Interest
In this article, we have shown how to implement GraphQL API exposure in a context when you had to work with unstructured data and an example of its application. GraphQL becomes in a few years a standard on API development like REST and OData and, probably in the coming years, we will have the chance to see it more often on applications, mostly in mobile context. Nowadays, GraphQL is mature enough to be taken into account in the business project, but we have to keep in mind that there are many possible limitations. First of all, you need to work with a very well prototyped database, where you can expose the entire database as is or with very simple customization.
This case history is a niche case, so it's hard to spend what we teach with this article tomorrow, at work. But, we hope that this will teach something about GraphQL and its internal, so that will be more simple to integrate GraphQL on our application taking advantage of all its features.