Custom Scalars
You can extend your schema with your own custom scalars. Conceptually, a scalar must implement the following operations:
-
Serialization: Transforms a scalar from its server-side representation to a representation suitable for the client.
-
Value Parsing: Transforms a scalar from its client-side representation as a variable to its server-side representation.
-
Literal Parsing: Transforms a scalar from its client-side representation as an argument to its server-side representation.
Parsing for arguments and variables are handled separately because while arguments must always be expressed in GraphQL query syntax, variable format is transport-specific (usually JSON). You can find more information about these methods here.
You may wish to read more about how scalars work at the following links:
Vector3 sample with string parsing and serialization
The following example shows how to create a custom scalar in GraphQL.NET. You will create a 3D Vector which will be exchanged
between server and client as a comma-separated string (ex. "34, 61, 12"). The example assumes the GraphQL schema is implemented
in an ASP.NET Core project using the Microsoft.Extensions.DependencyInjection
package, though only minor modifications would
be required for other project types.
Assume the following schema
scalar Vector3
schema {
query: {
getVector: Vector3!
}
mutation: {
addVector(vector3: Vector3!): Vector3
}
}
The goal is to execute mutations with both arguments:
mutation {
addVector(vector3: "23,43,66")
}
and also variables:
mutation AddVector($vector3: Vector3!) {
addVector(vector3: $vector3)
}
//variables
{
"vector3": "23,43,66"
}
Vectors should be returned in the same format:
{
"data": {
"getVector": "23,43,66"
}
}
1. Create the class/struct for the server-side representation.
This is not necessary if you intend to use already existing classes (string
, int
, etc.)
for the server-side representation.
public struct Vector3
{
public Vector3(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
}
2. Create a graph type for the scalar by inheriting ScalarGraphType
.
using GraphQL;
using GraphQL.Types;
using GraphQLParser.AST;
public class Vector3Type : ScalarGraphType
{
public Vector3Type()
{
Name = "Vector3";
}
public override object? ParseLiteral(GraphQLValue value)
{
throw new NotImplementedException();
}
public override object? ParseValue(object? value)
{
throw new NotImplementedException();
}
public override object? Serialize(object? value)
{
throw new NotImplementedException();
}
}
3. Register the graph type with the DI container.
// In Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<Vector3Type>();
// Other schema registrations...
}
4. Prepare to accept Vector3
inputs from query arguments. Implement ScalarGraphType.ParseLiteral
.
Keep in mind that AST parsing may present values as any of the following types:
GraphQLIntValue
- integer valuesGraphQLFloatValue
- floating-point values that can be represented within adouble
GraphQLStringValue
- string valuesGraphQLEnumValue
- enumeration valuesGraphQLBooleanValue
- boolean values -true
andfalse
GraphQLNullValue
- representingnull
- must be handled by all scalarsGraphQLObjectValue
- complex values with nested levelsGraphQLListValue
- lists of values
If your custom scalar accepts floating-point values, you must be sure to handle GraphQLIntValue
as well, since queries like { field(arg: 3) }
is parsed as an GraphQLIntValue
even though
it is also a valid floating-point number.
In the sample below, only GraphQLNullValue
and GraphQLStringValue
need to be handled.
For any type that is not handled, or when the value cannot be parsed, you must throw an exception.
ThrowLiteralConversionError
is provided as a convenient method to facilitate throwing an exception
when the type does not match.
//in Vector3Type
public override object? ParseLiteral(GraphQLValue value)
{
if (value is GraphQLNullValue)
return null;
if (value is GraphQLStringValue stringValue)
return ParseValue((string)stringValue.Value);
return ThrowLiteralConversionError(value);
}
Once the raw string is extracted from the value node, normal parsing can proceed.
5. Prepare to accept Vector3
inputs from query variables. Implement ScalarGraphType.ParseValue
.
Similar to ParseLiteral
, you must keep in mind the expected format of values that are likely
to be presented to this method. For instance, if you are using a JSON deserializer, you may be
presented with values of any of these types (or more, depending on your deserializer configuration):
int
long
ulong
BigInteger
double
decimal
string
null
- must be handled by all scalars
On top of that, if you are calling this method from ParseLiteral
, you must handle the types
passed from it. So if your scalar needs to handle floating-point values, you likely need to handle
int
, long
, ulong
, BigInteger
, double
and decimal
types.
For any type that is not handled, or when the value cannot be parsed, you must throw an exception.
ThrowValueConversionError
is provided as a convenient method to facilitate throwing an exception
when the type does not match.
In the sample below, only null
and string
types need to be handled.
// In Vector3Type
public override object? ParseValue(object? value)
{
if (value == null)
return null;
if (value is string vector3InputString)
{
try
{
var vector3Parts = vector3InputString.Split(',');
var x = float.Parse(vector3Parts[0]);
var y = float.Parse(vector3Parts[1]);
var z = float.Parse(vector3Parts[2]);
return new Vector3(x, y, z);
}
catch
{
throw new FormatException($"Failed to parse {nameof(Vector3)} from input '{vector3InputString}'. Input should be a string of three comma-separated floats in X Y Z order, ex. 1.0,2.0,3.0");
}
}
return ThrowValueConversionError(value);
}
6. Implement ScalarGraphType.Serialize
so Vector3
instances can be sent to the client.
Keep in mind that a null
value also required to be handled.
It is recommended that the type of data this method returns match the same type as
is handled within ParseValue
. In this case, serialization of Vector3
is serialized as
a string, which matches the expected data type within ParseValue
.
// In Vector3Type
public override object? Serialize(object? value)
{
if (value == null)
return null;
if (value is Vector3 vector3)
{
return $"{vector3.X},{vector3.Y},{vector3.Z}";
}
return ThrowSerializationError(value);
}
7. Override ScalarGraphType.ToAST
if necessary.
The infrastructure converts default field values to AST representations during initialization
in order to verify that the default values are valid within an AST tree. The default implementation
calls Serialize
to convert the value to its client-side equivalent, then embeds it into an
appropriate AST node based on the data type returned from Serialize
. It is likely you will
only need to override this method if you are creating a custom scalar that returns enumeration
values, or if you are returning structured data.
8. Code-first: Register the .NET type mapping with the schema (optional).
In order to allow simple field mappings like Field(x => x.Vector)
, you need to register the
custom scalar within the schema so that the proper graph type can be selected for the field.
Call RegisterTypeMapping
with the CLR data model type and the graph type you want it mapped to.
public class MySchema
{
public MySchema()
{
...
this.RegisterTypeMapping<Vector3, Vector3Type>();
}
}
This is not necessary if you use the alternate Field<T>
syntax which specifies the graph type
to be used for the field, or if your scalar data type is marked with the
[InputType]
and/or [OutputType]
attributes.
In this example, you created a custom scalar. In summary:
- Create (if necessary) a data class/struct for the server-side representation of the scalar
- Implement a
ScalarGraphType
which handles variable parsing, literal parsing, and serialization - Register the
ScalarGraphType
within the DI container
You can also choose to override CanParseLiteral
, CanParseValue
or IsValidDefault
for
enhanced performance. The default implementations call ParseLiteral
, ParseValue
and
ToAST
respectively, returning false
if an exception is caught, or true
otherwise.
If you do choose to implement these methods, note that those methods must not throw an
exception, and that they are not always called when executing a document.
Vector3 sample with combined string/structured parsing and serialization
Keep in mind that the serialized value returned by custom scalar can be anything that the environment allows. For example it can be a structured object, rather than a simple value.
So to extend our sample, let's assume that we want the Vector3 scalar to instead accept and return data in a more structured format, in addition to supporting the string format for literals.
Here is a sample of a variable supporting a more structured format:
mutation AddVector($vector3: Vector3!) {
addVector(vector3: $vector3)
}
//variables
{
"vector3": {
"x":"23",
"y":"43",
"z":"66"
}
}
And a sample of a response with a vector in a more structured format:
{
"data": {
"getVector": {
"x":"23",
"y":"43",
"z":"66"
}
}
}
1. Change ParseLiteral
to accept strings or structured data:
// In Vector3Type
public override object? ParseLiteral(GraphQLValue value)
{
if (value is GraphQLNullValue)
return null;
if (value is GraphQLStringValue stringValue)
return ParseValue((string)stringValue.Value);
if (value is GraphQLObjectValue objectValue)
{
var entries = objectValue.Fields.ToDictionary(x => x.Name.Value, x => _floatScalar.ParseLiteral(x.Value));
if (entries.Count != 3)
return ThrowLiteralConversionError(value);
var x = (double)entries["x"];
var y = (double)entries["y"];
var z = (double)entries["z"];
return new Vector3((float)x, (float)y, (float)z);
}
return ThrowLiteralConversionError(value);
}
2. Change ParseValue
to accept strings or structured data:
// In Vector3Type
public override object? ParseValue(object? value)
{
if (value == null)
return null;
if (value is string vector3InputString)
{
try
{
var vector3Parts = vector3InputString.Split(',');
var x = float.Parse(vector3Parts[0]);
var y = float.Parse(vector3Parts[1]);
var z = float.Parse(vector3Parts[2]);
return new Vector3(x, y, z);
}
catch
{
throw new FormatException($"Failed to parse {nameof(Vector3)} from input '{vector3InputString}'. Input should be a string of three comma-separated floats in X Y Z order, ex. 1.0,2.0,3.0");
}
}
if (value is IDictionary<string, object> dictionary)
{
try
{
var x = Convert.ToSingle(dictionary["x"]);
var y = Convert.ToSingle(dictionary["y"]);
var z = Convert.ToSingle(dictionary["z"]);
if (dictionary.Count > 3)
return ThrowValueConversionError(value);
return new Vector3(x, y, z);
}
catch
{
throw new FormatException($"Failed to parse {nameof(Vector3)} from object. Input should be an object of three floats named X Y and Z");
}
}
return ThrowValueConversionError(value);
}
3. Change Serialize
to return structured data.
// In Vector3Type
public override object? Serialize(object? value)
{
if (value == null)
return null;
if (value is Vector3 vector3)
{
return new
{
x = vector3.X,
y = vector3.Y,
z = vector3.Z
};
}
return ThrowSerializationError(value);
}
4. Change ToAST
to return an AST literal that represents the data.
Since Serialize
no longer returns a type that can be converted to an AST node, it is
necessary to override this method.
// In Vector3Type
public override GraphQLValue ToAST(object? value)
{
if (value == null)
return new GraphQLNullValue();
if (value is Vector3 vector3)
{
return new GraphQLObjectValue
{
Fields = new List<GraphQLObjectField>
{
new GraphQLObjectField
{
Name = new GraphQLName("x"),
Value = new GraphQLFloatValue(vector3.X))
},
new GraphQLObjectField
{
Name = new GraphQLName("y"),
Value = new GraphQLFloatValue(vector3.Y))
},
new GraphQLObjectField
{
Name = new GraphQLName("z"),
Value = new GraphQLFloatValue(vector3.Z))
}
}
};
}
return ThrowASTConversionError(value);
}
With these changes, a literal or variable can be parsed as a string or as a structured object, and is always returned as a structured object.
ValueConverter
When GetArgument<T>
is called, the argument value is coerced to the requested type via the ValueConverter
.
No conversion takes place when the requested type matches the type of the object or scalar (the type returned from
ParseLiteral
or ParseValue
). But you can also use the value converter to assist with input deserialization.
For instance, you may be using IdGraphType
within your schema as unique identifiers for your data objects. Pursuant
to the GraphQL specification, these identifiers may be passed as strings such as in the below example:
{
widget (id: "3") {
name
}
}
However, your code may use integer identifiers in the data access layer. So when you call context.GetArgument<int>("id")
,
GraphQL.NET calls the value converter to convert the string to an integer.
The value converter can be extended globally by calling the static method Register
as follows:
ValueConverter.Register<Vector3, string>(v => $"{v.X},{v.Y},{v.Z}");
The above method registers a conversion from the Vector3
struct to a string
. Since the registration is static,
it should only be done once per application lifetime. For instance, in a static constructor of your schema.
public class MySchema : Schema
{
static MySchema()
{
ValueConverter.Register<Vector3, string>(v => $"{v.X},{v.Y},{v.Z}");
}
...
}
Null values
Custom scalars process and handle null values during serialization and deserialization. For instance, let's say you have a database where database ID values of 0 should be represented as GraphQL 'null' values. You could of course write code within the field resolver to translate the values, or you may utilize a custom scalar to translate the values for you. The below example of a custom scalar does this and also rejects external values that are not strings containing integers, and rejects 0 or negative values:
public class DbIdGraphType : ScalarGraphType
{
public DbIdGraphType()
{
Name = "DbId";
}
public override object? ParseLiteral(GraphQLValue value) => value switch
{
GraphQLStringValue s => int.TryParse(s.Value, out int i) && i > 0 ? i : throw new FormatException($"'{s.Value}' is not a valid identifier."),
GraphQLNullValue _ => 0,
_ => ThrowLiteralConversionError(value)
};
public override object? ParseValue(object? value) => value switch
{
string s => int.TryParse(s, out int i) && i > 0 ? i : throw new FormatException($"'{s}' is not a valid identifier."),
null => 0,
_ => ThrowValueConversionError(value)
};
public override object? Serialize(object? value) => value switch
{
int i => i > 0 ? i.ToString() : i == 0 ? null : ThrowSerializationError(value),
_ => ThrowSerializationError(value)
};
}
Replacing built-in scalar types
In some cases you may want or need to replace the functionality of the built-in graph types. This can be accomplished by registering a replacement scalar before the schema has been initialized. Keep in mind that replacing a built-in type may affect the operation of introspection queries.
In order to replace a built-in scalar graph type, the new scalar graph type must:
- inherit from the scalar graph type it is replacing; and
- have the
Name
property set to the name of the built-in graph type.
You may then override any of the members to provide custom implementations. Note that most of the
built-in scalars override CanParseLiteral
, so it may be necessary to override that method if you
override ParseLiteral
. Check the source code for the built-in scalar type you are overriding for
further reference.
Below is a sample of how to replace the built-in BooleanGraphType
so it will accept 0 and non-zero
values to represent false
and true
.
1. Create a new scalar graph type MyBooleanGraphType
Inherit from BooleanGraphType
and set the name to be Boolean
.
public class MyBooleanGraphType : BooleanGraphType
{
public MyBooleanGraphType()
{
Name = "Boolean";
}
}
2. Override the methods as necessary
In this case we must override all of them except Serialize
and IsValidDefault
.
public class MyBooleanGraphType : BooleanGraphType
{
public MyBooleanGraphType()
{
Name = "Boolean";
}
public override object? ParseLiteral(GraphQLValue value) => value switch
{
GraphQLBooleanValue b => b.BoolValue,
GraphQLIntValue i => ParseValue(ParseDoubleAccordingSpec(i)),
GraphQLFloatValue f => ParseValue(ParseDoubleAccordingSpec(f)),
GraphQLStringValue s => ParseValue((string)s.Value),
GraphQLNullValue _ => null,
_ => ThrowLiteralConversionError(value)
}
public override bool CanParseLiteral(GraphQLValue value)
{
try
{
_ = ParseLiteral(value);
return true;
}
catch
{
return false;
}
}
public override object? ParseValue(object? value) => value switch
{
bool _ => value,
byte b => b != 0,
sbyte sb => sb != 0,
short s => s != 0,
ushort us => us != 0,
int i => i != 0,
uint ui => ui != 0,
long l => l != 0,
ulong ul => ul != 0,
BigInteger bi => bi != 0,
float f => f != 0,
double d => d != 0,
decimal d => d != 0,
string s => bool.Parse(s),
null => null,
_ => ThrowValueConversionError(value)
}
public override bool CanParseValue(object? value)
{
try
{
_ = ParseValue(value);
return true;
}
catch
{
return false;
}
}
public override GraphQLValue ToAST(object? value) => Serialize(value) switch
{
bool b => new GraphQLBooleanValue(b),
null => new GraphQLNullValue(),
_ => ThrowASTConversionError(value)
};
}
3. Register the custom scalar within your schema.
The final step is to register an instance of the custom scalar within the schema. This can be
done for code-first or schema-first schemas. For code-first schemas, register it within
your constructor via RegisterType
, as follows:
public class MySchema : Schema
{
public void MySchema()
{
Query = ....;
RegisterType(new MyBooleanGraphType());
}
}
Alternatively, you may register the type within your DI engine.
services.AddSingleton<BooleanGraphType, MyBooleanGraphType>();
For schema-first schemas, register it immediately after calling Schema.For
to create the schema.
Immediately after calling Schema.For
the schema is not yet initialized, therefore allowing registration of types.
var schema = Schema.For(...);
schema.RegisterType(new MyBooleanGraphType());
Now all BooleanGraphType
references in your schema will utilize the new MyBooleanGraphType
registered within the schema. This technique can be used to replace any of the built-in graph types.
Note that if you set the ResolvedType
property of a field or argument to an instance of a built-in
type, or provide an instance of a built-in type to an applicable constructor, it will not be replaced
with your registered replacement built-in type. For example, consider this code:
Field<StringGraphType>("sample")
.Argument<BooleanGraphType>("argNewBehavior") // will be replaced with MyBooleanGraphType
.Arguments(new QueryArgument(new BooleanGraphType()) { Name = "argOldBehavior" }) // will retain default behavior
.Resolve(...);
This is by design. However, you can call the ReplaceScalar
extension method after the schema is
built, which will walk through all the graph types configured on the schema and replace any remaining
references of the "Boolean" scalar graph type with references to your replacement scalar graph type.
The scalar to be replaced is matched based on the name of the scalar.
var schema = Schema.For(...);
schema.ReplaceScalar(new MyBooleanGraphType());