Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 75 additions & 81 deletions docs/advanced/custom-scalars.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This can be done for any value that can be represented as a simple set of charac

Lets say we wanted to build a scalar called `Money` that can handle both an amount and currency symbol. We might accept it in a query like this:

```csharp
```csharp title="Declaring a Money Scalar"
public class InventoryController : GraphController
{
[QueryRoot("search")]
Expand Down Expand Up @@ -49,38 +49,35 @@ query {
```


The query supplies the data as a quoted string, `"$18.45"`, but our action method receives a `Money` object. Internally, GraphQL senses that the value should be `Money` from the schema definition and invokes the correct resolver to parse the value and generate the .NET object that can be passed to our action method.
The query supplies the data as a quoted string, `"$18.45"`, but our action method receives a `Money` object. Internally, GraphQL senses that the supplied string value should be `Money` from the schema definition and invokes the correct resolver to parse the value and generate the .NET object that can be passed to our action method.

## Implement IScalarGraphType

To create a scalar graph type we need to implement `IScalarGraphType` and register it with GraphQL. The methods and properties of `IScalarGraphType` are as follows:
To create a scalar we need to implement `IScalarGraphType` and register it with GraphQL. The methods and properties of `IScalarGraphType` are as follows:

```csharp title="IScalarGraphType.cs"
public interface IScalarGraphType
{
string Name { get; }
string InternalName { get; }
string Description { get; }
string SpecifiedByUrl { get; }
TypeKind Kind { get; }
bool Publish { get; }
ScalarValueType ValueType { get; }
Type ObjectType { get; }
TypeCollection OtherKnownTypes { get; }
ILeafValueResolver SourceResolver { get; }
IScalarValueSerializer Serializer { get; }

object Serialize(object item);
string SerializeToQueryLanguage(object item);
bool ValidateObject(object item);
}

public interface ILeafValueResolver
{
object Resolve(ReadOnlySpan<char> data);
}

public interface IScalarValueSerializer
{
object Serialize(object item);
}
```

### IScalarGraphType Members
Expand All @@ -91,17 +88,26 @@ public interface IScalarValueSerializer
- `Kind`: Scalars must always be declared as `TypeKind.SCALAR`.
- `Publish`: Indicates if the scalar should be published for introspection queries. Unless there is a very strong reason not to, scalars should always be published. Set this value to `true`.
- `ValueType`: A set of flags indicating what type of source data, read from a query, this scalar is capable of processing (string, number or boolean). GraphQL will do a preemptive check and if the query document does not supply the data in the correct format it will not attempt to resolve the scalar. Most custom scalars will use `ScalarValueType.String`.
- `SpecifiedByUrl`: A url, formatted as a string, pointing to information or the specification that defines this scalar. (optional, can be null)
- `ObjectType`: The primary, internal type representing the scalar in .NET. In our example above we would set this to `typeof(Money)`.
- `OtherKnownTypes`: A collection of other potential types that could be used to represent the scalar in a controller class. For instance, integers can be expressed as `int` or `int?`. Most scalars will provide an empty list (e.g. `TypeCollection.Empty`).
- `SourceResolver`: An object that implements `ILeafValueResolver` which can convert raw input data into the scalar's primary `ObjectType`.
- `Serializer`: An object that implements `IScalarValueSerializer` that converts the internal representation of the scalar (a class or struct) to a valid, serialized output (a number, string or boolean).
- `ValidateObject(object)`: A method used when validating data returned from a a field resolver. GraphQL will call this method and provide the value from the resolver to determine if its acceptable and should continue resolving child fields.

> `ValidateObject(object)` should not attempt to enforce nullability rules. In general, all scalars should return `true` if the provided object is `null`.
- `Serialize(object)`: A method that converts an instance of your scalar to a leaf value that is serializable in a query response
- This method must return a `number`, `string`, `bool` or `null`.
- When converting to a number this can be any C# number value type (int, float, decimal etc.).
- `SerializeToQueryLanguage(object)`: A method that converts an instance of your scalar to a string representing it if it were declared as part of a schema language type definition.
- This method is used when generated default values for field arguments and input object fields via introspection queries.
- This method must return a value exactly as it would appear in a schema type definition For example, strings must be surrounded by quotes.

- `ValidateObject(object)`: A method used when validating data returned from a a field resolver. GraphQL will call this method and provide an object instance to determine if its acceptable and can be used in a query.

:::note
`ValidateObject(object)` should not attempt to enforce nullability rules. In general, all scalars should return `true` for a validation result if the provided object is `null`.
:::

### ILeafValueResolver Members

- `Resolve(ReadOnlySpan<char>)`: A resolver function capable of converting an array of characters into the internal representation of the type.
- `Resolve(ReadOnlySpan<char>)`: A resolver function capable of converting an array of characters into the internal representation of the scalar.

#### Dealing with Escaped Strings

Expand All @@ -113,7 +119,7 @@ Example string data:
- `"""triple quoted string"""`
- `"With \"\u03A3scaped ch\u03B1racters\""`;

The `StringScalarType` provides a handy static method for unescaping the data if you don't need to do anything special with it, `StringScalarType.UnescapeAndTrimDelimiters`.
The static type `GraphQLStrings` provides a handy static method for unescaping the data if you don't need to do anything special with it, `GraphQLStrings.UnescapeAndTrimDelimiters`.

Calling `UnescapeAndTrimDelimiters` with the previous examples produces:

Expand All @@ -123,35 +129,13 @@ Calling `UnescapeAndTrimDelimiters` with the previous examples produces:

#### Indicating an Error

When resolving input values with `Resolve()`, if the provided value is not usable and must be rejected then the entire query document must be rejected. For instance, if a document contained the value `"$15.R0"` for our money scalar. Throw an exception and GraphQL will automatically generate a response error with the correct origin information indicating the line and column in the query document where the error occurred.

If you throw `UnresolvedValueException` your error message will be delivered verbatim to the requestor as a normal error message. GraphQL will obfuscate any other exception type to a generic message and only expose your exception details if allowed by the [schema configuration](../reference/schema-configuration).

### IScalarValueSerializer Members
When resolving input values with `Resolve()`, if the provided value is not usable and must be rejected then the entire query document must be rejected. For instance, if a document contained the value `"$15.R0"` for our money scalar it would need to be rejected because `15.R0` cannot be converted to a decimal decimal.

- `Serialize(object)`: A serializer that converts the internal representation of the scalar to a [graphql compliant scalar value](https://graphql.github.io/graphql-spec/October2021/#sec-Scalars); a `number`, `string`, `bool` or `null`.
- When converting to a number this can be any number value type (int, float, decimal etc.).
Throw an exception when this happens and GraphQL will automatically generate an appropriate response with the correct origin information indicating the line and column in the query document where the error occurred. However, like with any other encounterd exception, GraphQL will obfuscate it to a generic message and only expose your exception details if allowed by the [schema configuration](../reference/schema-configuration).

> `Serialize(object)` must return a string, any primative number or a boolean.

Taking a look at the at the serializer for the `Guid` scalar type we can see that while internally the `System.Guid` struct represents the value we convert it to a string when serializing it. Most scalar implementations will serialize to a string.

```csharp title="GuidScalarSerializer.cs"
public class GuidScalarSerializer : IScalarValueSerializer
{
public object Serialize(object item)
{
if (item == null)
return item;

return ((Guid)item).ToString();
}
}
```

> The `Serialize()` method will only be given an object of the approved types for the scalar or null.

---
:::tip Pro Tip!
If you throw `UnresolvedValueException` your error message will be delivered verbatim to the requestor as part of the response message instead of being obfuscated.
:::

### Example: Money Scalar
The completed Money custom scalar type
Expand All @@ -176,9 +160,27 @@ The completed Money custom scalar type

public TypeCollection OtherKnownTypes => TypeCollection.Empty;

public ILeafValueResolver SourceResolver { get; } = new MoneyLeafTypeResolver();
public ILeafValueResolver SourceResolver { get; } = new MoneyValueResolver();

public object Serialize(object item)
{
if (item == null)
return item;

var money = (Money)item;
return $"{money.Symbol}{money.Price}";
}

public string SerializeToQueryLanguage(object item)
{
// convert to a string first
var serialized = this.Serialize(item);
if (serialized == null)
return "null";

public IScalarValueSerializer Serializer { get; } = new MoneyScalarTypeSerializer()
// return value as quoted
return $"\"{serialized}\"";
}

public bool ValidateObject(object item)
{
Expand All @@ -189,30 +191,19 @@ The completed Money custom scalar type
}
}

public class MoneyLeafTypeResolver : ILeafValueResolver
public class MoneyValueResolver : ILeafValueResolver
{
public object Resolve(ReadOnlySpan<char> data)
{
// example only, more validation code is needed to fully validate
// the data
var sanitizedMoney = StringScalarType.UnescapeAndTrimDelimiters(data);
var sanitizedMoney = GraphQLStrings.UnescapeAndTrimDelimiters(data);
if(sanitizedMoney == null || sanitizedMoney.Length < 2)
throw new UnresolvedValueException("Money must be at least 2 characters");

return new Money(sanitizedMoney[0], Decimal.Parse(sanitizedMoney.Substring(1)));
}
}
public class MoneyScalarTypeSerializer : IScalarValueSerializer
{
public override object Serialize(object item)
{
if (item == null)
return item;

var money = (Money)item;
return $"{money.Symbol}{money.Price}";
}
}
```

## Registering A Scalar
Expand All @@ -233,33 +224,43 @@ Since our scalar is represented by a .NET class, if we don't pre-register it Gra

## @specifiedBy Directive

GraphQL provides a special, built-in directive called `@specifiedBy` that allows you to supply a URL pointing to a the specification for your custom scalar. This url is used by various tools to additional data to your customers so they know how to interact with your scalar type. It is entirely optional.
GraphQL provides a special, built-in directive called `@specifiedBy` that allows you to supply a URL pointing to a the specification for your custom scalar. This url is used by various tools to link to additional data for you or your customers so they know how to interact with your scalar type. It is entirely optional.

The @specifiedBy directive can be applied to a scalar in all the same ways as other type system directives or by use of the special `[SpecifiedBy]` attribute.

```csharp title="Apply the @specifiedBy"
// apply the directive to a single schema
GraphQLProviders.ScalarProvider.RegisterCustomScalar(typeof(MoneyScalarType));
```csharp title="Applying the @specifiedBy"
// apply the directive to a single schema at startup
services.AddGraphQL(o => {
// highlight-start
o.ApplyDirective("@specifiedBy")
.WithArguments("https://myurl.com")
.ToItems(item => item.Name == "Money");
// highlight-end
});

// via the ApplyDirective attribute
// via the [ApplyDirective] attribute
// for all schemas
// highlight-next-line
[ApplyDirective("@specifiedBy", "https://myurl.com")]
public class MoneyScalarType : IScalarType
{
// ...
}
public class MoneyScalarType : IScalarGraphType
{}

// via the special SpecifiedBy attribute
// via the special [SpecifiedBy] attribute
// for all schemas
// highlight-next-line
[SpecifiedBy("https://myurl.com")]
public class MoneyScalarType : IScalarType
public class MoneyScalarType : IScalarGraphType
{}

// as part of the contructor
// for all schemas
public class MoneyScalarType : IScalarGraphType
{
// ...
public MoneyScalarType()
{
// highlight-next-line
this.SpecifiedByUrl = "https://myurl.com";
}
}
```

Expand All @@ -272,16 +273,13 @@ A few points about designing your scalar:
- The runtime will pass a new instance of your scalar graph type to each registered schema. It must be declared with a public, parameterless constructor.
- Scalar types should be simple and work in isolation.
- The `ReadOnlySpan<char>` provided to `ILeafValueResolver.Resolve` should be all the data needed to generate a value, there should be no need to perform side effects or fetch additional data.
- Scalar types should not track any state or depend on any stateful objects.
- `ILeafValueResolver.Resolve` must be **FAST**! Since your resolver is used to construct an initial query plan from a text document, it'll be called orders of magnitude more often than any other method.
- Scalar types should not track any state, depend on any stateful objects, or attempt to use any sort of dependency injection.
- `ILeafValueResolver.Resolve` must be **FAST**! Since your resolver is used to construct an initial query plan from a text document, it'll be called many orders of magnitude more often than any other method.

### Aim for Fewer Scalars

Avoid the urge to start declaring a lot of custom scalars. In fact, chances are that you'll never need to create one. In our example we could have represented our money scalar as an INPUT_OBJECT graph type:

<div class="sideBySideCode hljs">
<div>

```csharp title="Money as an Input Object Graph Type"
public class InventoryController : GraphController
{
Expand All @@ -302,9 +300,6 @@ public class Money
}
```

</div>
<div>

```graphql title="Using the Money Input Object"
query {
search(minPrice: {
Expand All @@ -316,10 +311,9 @@ query {
}
```

</div>
</div>
<br/>

This is a lot more flexible. We can add more properties to `Money` when needed and not break existing queries. Whereas with a scalar if we change the acceptable format of the string data any existing query text will now be invalid. It is almost always better to represent your data as an object or input object rather than a scalar.

> Creating a custom scalar should be a last resort, not a first option.
:::caution Be Careful
Creating a custom scalar should be a last resort, not a first option.
:::
Loading