From c96b6c4cd7c165e0e6581b27204ec30f3dd3adfb Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Thu, 18 Aug 2022 17:53:42 -0700 Subject: [PATCH 1/4] updated input object documentation --- docs/types/input-objects.md | 70 ++++++++++++++++++++++++++++++------- website/pages/en/index.js | 2 +- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/types/input-objects.md b/docs/types/input-objects.md index 43f176f..a68ed3a 100644 --- a/docs/types/input-objects.md +++ b/docs/types/input-objects.md @@ -4,17 +4,13 @@ title: Input Objects sidebar_label: Input Objects --- -`INPUT_OBJECT` graph types function in much the same way as [object graph types](./objects) do. -While GraphQL is doing its discovery of controllers and graph types, whenever it comes across a class used as a parameter to a method it will attempt to generate the appropriate input type definition. +`INPUT_OBJECT` graph types represent complex data supplied to field arguments or directives. Anytime you want to pass more data than a single string or a number, perhaps an Address or a new Employee, you use an INPUT_OBJECT to represent that entity in GraphQL. When the system scans your controllers, if it comes across a class used as a parameter to a method it will attempt to generate the appropriate input type definition to represent that class. -The rules surrounding naming, field declarations, exclusions, use of `[GraphSkip]` etc. apply to input objects but with a few key differences. +The rules surrounding naming, field declarations, exclusions, use of `[GraphSkip]` etc. apply to input objects but with a few key differences: -By Default: - -- An input object is named the same as its `class` name, prefixed with `Input_` - - i.e. `Input_Donut`, `Input_Employee` -- All public properties with a `get` and `set` statement will be included. - - The return type of the property must be an acceptable type or it will be skipped +- Unless overridden, an input object is named the same as its `class` name, prefixed with `Input_` (e.g. `Input_Address`, `Input_Employee`) +- Only public properties with a `get` and `set` will be included. + - Properties cannot return a `Task`, an `interface` and cannot implements `IGraphUnionProxy` or `IGraphActionResult`. Such properties are always skipped. - Methods are always skipped. ## Names @@ -78,7 +74,7 @@ public class DonutModel } ``` -Because of this restriction it can be helpful to separate your classes between "input" and "output" types much is the same way we do with `ViewModel` vs. `BindingModel` objects in MVC. This is optional, mix and match as needed by your use case. +Because of this restriction it can be helpful to separate your classes between "input" and "output" types much is the same way we do with `ViewModel` vs. `BindingModel` objects with REST queries in ASP.NET. This is optional, mix and match as needed by your use case. ## Properties Must Have a Public Setter @@ -158,9 +154,59 @@ input Input_Donut {
+## Required Fields And Default Values +Add `[Required]` (from System.ComponentModel) to any property to force a user to supply the field in a query document. The type expression will also automatically become non-nullable if it otherwise would have been nullable and no default value will be assigned to the field. For example string fields are nullable by default, adding `[Required]` will convert the type expression of the property to `String!` automatically. + +Any non-required field will automatically be assigned a default value that will be made available to introspection queries. This default value is equivilant to the property value of the object when the object is instantiated via its default constructor. Use the constructor to set any default values you wish to surface. + +
+
+ +```csharp +// Donut.cs +public class Donut +{ + public Donut() + { + this.Type = "Vanilla"; + this.Price = 2.99; + this.IsAvailable = true; + } + + [Required] + public string Name { get; set; } + + public int Id { get; set; } + public string Type { get; set; } + public Bakery Bakery { get;set; } + public decimal Price { get; set; } + public decimal IsAvailable { get; set; } +} +``` + +
+
+ +```ruby +# GraphQL Type Definition +input Input_Donut { + name: String! + id: Int! = 0 + type: DonutType! = "Vanilla" + bakery: Input_Bakery = null + price: Decimal! = 2.99 + isAvailable: Boolean! = true +} +``` + +
+
+
+ + ## Working With Lists -When constructing a set of items as an input value, GraphQL will instantiate a `List` and fill it with the appropriate data, be that another list, another input object or a scalar. While you can declare a regular array (e.g. `Donut[]`, `int[]` etc.) as your list structure for an input argument, graphql has to rebuild its internal list structure as an array (or nested arrays) to meet the requirements of your method. In some cases, especially with nested lists, this results in a linear increase in processing time. It is recommended to use `IEnumerable` or `IList` to avoid this performance bottleneck when sending a lot of items as input arguments. +When constructing a set of items as an input value, GraphQL will instantiate a `List` and fill it with the appropriate data, be that another list, another input object or a scalar. While you can declare an array (e.g. `Donut[]`, `int[]` etc.) as your list structure for an input argument, graphql has to rebuild its internal representation as an array (or nested arrays) to meet the requirements of your method. In some cases, especially with nested lists, this results in a linear increase in processing time. It is recommended to use `IEnumerable` or `IList` to avoid this performance bottleneck when sending a lot of items as input arguments. This example shows various ways of accepting collections of data as inputs to controller actions. @@ -203,4 +249,4 @@ public class DonutCollection public List Donuts { get; set; } } -``` +``` \ No newline at end of file diff --git a/website/pages/en/index.js b/website/pages/en/index.js index 42a7c09..3418b80 100644 --- a/website/pages/en/index.js +++ b/website/pages/en/index.js @@ -31,7 +31,7 @@ class HomeSplash extends React.Component {

{GraphQL ASP.NET} {/*{siteConfig.tagline}*/} - v0.11.0-beta + v0.12.0-beta

); From eb29d0cde87be17b4e9a672f66164d0c7a2d2fc7 Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Sat, 20 Aug 2022 19:18:08 -0700 Subject: [PATCH 2/4] WIP, additional updates to action results, actions, input object docs --- docs/advanced/graph-action-results.md | 12 +- docs/controllers/actions.md | 55 ++++++++- docs/types/input-objects.md | 155 +++++++++++++++++++------- 3 files changed, 172 insertions(+), 50 deletions(-) diff --git a/docs/advanced/graph-action-results.md b/docs/advanced/graph-action-results.md index 1f57471..ae3b0f4 100644 --- a/docs/advanced/graph-action-results.md +++ b/docs/advanced/graph-action-results.md @@ -40,7 +40,7 @@ public class BakeryController : Controller You can either return the data itself or some alternate `IActionResult` to tell MVC how to render a response. -Common Action Results for MVC: +Some common ASP.NET MVC action results: - `this.Ok()` : Everything worked fine, return status 200. - `this.NotFound()` : The item doesn't exist, return status 404. @@ -49,7 +49,7 @@ Common Action Results for MVC: This works the same way in GraphQL ASP.NET. The available actions are slightly different (GraphQL won't stream files) but the usage is the same. You can even write your own action results. -## Common Graph Action Results +## Common Action Results Instead of `IActionResult` we use `IGraphActionResult` from a controller action method. Both [directives](../directives) and controller [action methods](../controllers/actions) can return action results. @@ -62,9 +62,13 @@ Built in Controller Action Methods: - `this.BadRequest()`: Commonly used in conjunction with `this.ModelState`. This result indicates the data supplied to the method is not valid for the operation. If given the model state collection an error for each validation error is rendered. - `this.InternalServerError()`: Indicates an unintended error, such as an exception occurred. The supplied message will be added to the response and no child fields will be resolved. -[Directives](../directives) have one Additional Action Result: +## Directive Action Results +[Directives](../directives) have two built in Action Results: -- `this.Cancel()`: When returned as part of a method that executes before field resolution, this action will cancel the field execution pipeline. No error is returned, but the field is dropped from the request. +- `this.Ok()`: Indicates that the directive completed its expected operation successfully and query processing can continue. +- `this.Cancel()`: Indicates that the directive did NOT complete its operation successfully. + - If this is a type system directive, the schema will fail to complete and the server will not start. + - If this is an execution directive, the query will be abandoned and the user will receive an error message. ## Custom Graph Action Results diff --git a/docs/controllers/actions.md b/docs/controllers/actions.md index 214a73e..a8032f2 100644 --- a/docs/controllers/actions.md +++ b/docs/controllers/actions.md @@ -418,7 +418,7 @@ query {
-Note that there is a difference between "nullable" and "not required". If we have a nullable int as an input parameter, without a default value we still have to pass it to the field, even if we pass it as null. +Note that there is a difference between "nullable" and "not required". If we have a nullable int as an input parameter, without a default value we still have to pass it to the field, even if we pass it as null just like if we were to invoke the method from our C# code.
@@ -491,6 +491,53 @@ query {
+### Working With Lists + +When constructing a set of items as an argument to an action method, GraphQL will instantiate a `List` and fill it with the appropriate data, be that another list, another input object, a scalar etc. While you can declare an array (e.g. `Donut[]`, `int[]` etc.) as your list structure for an input argument, graphql has to rebuild its internal representation as an array (or nested arrays) to meet the requirements of your method. In some cases, especially with nested lists, this results in a linear increase in processing time. It is recommended to use `IEnumerable` or `IList` to avoid this performance bottleneck when sending lots of items as input data. + +This example shows various ways of accepting collections of data as inputs to controller actions. + +```csharp +public class BakeryController : GraphController +{ + // a list of donuts + // schema syntax: [Donut] + [Mutation("createDonuts")] + public bool CreateDonuts(IEnumerable donuts) + {/*....*/} + + // when used as a "list of list" + // schema syntax: [[Donut]] + [Mutation("createDonutsBySet")] + public bool CreateDonuts(List> donuts) + {/*....*/} + + // when supplied as a regular array + // schema syntax: [Donut] + [Mutation("donutsAsAnArray")] + public bool DonutsAsAnArray(Donut[] donuts) + {/*....*/} + + // This is a valid nested list + // schema syntax: [[[Donut]]] + [Mutation("mixedDonuts")] + public bool MixedDonuts(List> donuts) + {/*....*/} + + // when used as a field of another input object + [Mutation("createDonutCollection")] + public bool CreateDonuts(DonutCollection donutCollection) + {/*....*/} + +} + +public class DonutCollection +{ + public List Donuts { get; set; } +} + +``` + ### Don't Use Dictionaries You might be tempted to use a dictionary as a parameter to accept arbitrary key value pairs into your methods. GraphQL will reject it and throw a declaration exception when your schema is created: @@ -501,7 +548,7 @@ You might be tempted to use a dictionary as a parameter to accept arbitrary key ```csharp public class BakeryController : GraphController { - // ERROR, a GraphDeclarationException + // ERROR, a GraphTypeDeclarationException // will be thrown. [QueryRoot] public IEnumerable @@ -515,7 +562,7 @@ public class BakeryController : GraphController ```javascript query { - searchDonuts( + searchDonuts(searchParams: name: "jelly*" filled: true dayOld: false){ @@ -529,7 +576,7 @@ query {
-At runtime, GraphQL will try to validate every parameter passed on a query against the type expression it has stored in the target schema. No where have we we declared `filled` to be a boolean or `name` to be a string. +At runtime, GraphQL will try to validate every parameter passed on a query against the type expression it has stored in the target schema. No where have we declared an argument `filled` to be a boolean or `name` to be a string. One might think, well it should be passed as an object reference to the dictionary parameter: diff --git a/docs/types/input-objects.md b/docs/types/input-objects.md index a68ed3a..8fda79a 100644 --- a/docs/types/input-objects.md +++ b/docs/types/input-objects.md @@ -4,18 +4,18 @@ title: Input Objects sidebar_label: Input Objects --- -`INPUT_OBJECT` graph types represent complex data supplied to field arguments or directives. Anytime you want to pass more data than a single string or a number, perhaps an Address or a new Employee, you use an INPUT_OBJECT to represent that entity in GraphQL. When the system scans your controllers, if it comes across a class used as a parameter to a method it will attempt to generate the appropriate input type definition to represent that class. +`INPUT_OBJECT` graph types (a.k.a. input objects) represent complex data supplied to arguments on fields or directives. Anytime you want to pass more data than a single string or a number, perhaps an Address or a new Employee, you use an INPUT_OBJECT to represent that entity in GraphQL. When the system scans your controllers, if it comes across a class used as a parameter to a method it will attempt to generate the appropriate input type definition to represent that class. The rules surrounding naming, field declarations, exclusions, use of `[GraphSkip]` etc. apply to input objects but with a few key differences: -- Unless overridden, an input object is named the same as its `class` name, prefixed with `Input_` (e.g. `Input_Address`, `Input_Employee`) +- Unless overridden, an input object is named the same as its class name, prefixed with `Input_` (e.g. `Input_Address`, `Input_Employee`) - Only public properties with a `get` and `set` will be included. - - Properties cannot return a `Task`, an `interface` and cannot implements `IGraphUnionProxy` or `IGraphActionResult`. Such properties are always skipped. + - Property return types cannot be `Task`, an `interface` and cannot implement `IGraphUnionProxy` or `IGraphActionResult`. Such properties are always skipped. - Methods are always skipped. -## Names +## Customized Type Names -Input object types can be given customized names, just like with object types, using the `[GraphType]` attribute. +Input objects can be given customized names, just like with object types, using the `[GraphType]` attribute.
@@ -49,6 +49,8 @@ input NewDonutModel {

+>Not the specific callout to `InputName` in the attribution. + ## Use an Empty Constructor When GraphQL executes a query it will attempt to create an instance of your input object then assign the key/value pairs received on the query to the properties. In order to do the initial instantiation it requires a public parameterless constructor to do so. @@ -124,7 +126,8 @@ While its possible to have methods be exposed as resolvable fields on regular `O public class Donut { [GraphField("salesTax")] - public decimal CalculateSalesTax(decimal taxPercentage) + public decimal CalculateSalesTax( + decimal taxPercentage) { return this.Price * taxPercentage; } @@ -155,9 +158,9 @@ input Input_Donut {
## Required Fields And Default Values -Add `[Required]` (from System.ComponentModel) to any property to force a user to supply the field in a query document. The type expression will also automatically become non-nullable if it otherwise would have been nullable and no default value will be assigned to the field. For example string fields are nullable by default, adding `[Required]` will convert the type expression of the property to `String!` automatically. +Add `[Required]` (from System.ComponentModel) to any property to force a user to supply the field in a query document. -Any non-required field will automatically be assigned a default value that will be made available to introspection queries. This default value is equivilant to the property value of the object when the object is instantiated via its default constructor. Use the constructor to set any default values you wish to surface. +Any non-required field will automatically be assigned a default value if not supplied. This default value is equivilant to the property value of the object when its instantiated via its public, parameterless constructor.
@@ -168,7 +171,8 @@ public class Donut { public Donut() { - this.Type = "Vanilla"; + // set custom defaults if needed + this.Type = DonutType.Vanilla; this.Price = 2.99; this.IsAvailable = true; } @@ -177,7 +181,7 @@ public class Donut public string Name { get; set; } public int Id { get; set; } - public string Type { get; set; } + public DonutType Type { get; set; } public Bakery Bakery { get;set; } public decimal Price { get; set; } public decimal IsAvailable { get; set; } @@ -192,7 +196,7 @@ public class Donut input Input_Donut { name: String! id: Int! = 0 - type: DonutType! = "Vanilla" + type: DonutType! = VANILLA bakery: Input_Bakery = null price: Decimal! = 2.99 isAvailable: Boolean! = true @@ -203,50 +207,117 @@ input Input_Donut {

+## Non-Nullability +By default, all properties that are reference types (i.e. classes) are nullable and all value types (primatives, structs etc.) are non-nullable + -## Working With Lists +
+ +```csharp +// Donut.cs +public class Bakery +{ + // a reference to another object + public Person Owner { get; set; } +} +``` -When constructing a set of items as an input value, GraphQL will instantiate a `List` and fill it with the appropriate data, be that another list, another input object or a scalar. While you can declare an array (e.g. `Donut[]`, `int[]` etc.) as your list structure for an input argument, graphql has to rebuild its internal representation as an array (or nested arrays) to meet the requirements of your method. In some cases, especially with nested lists, this results in a linear increase in processing time. It is recommended to use `IEnumerable` or `IList` to avoid this performance bottleneck when sending a lot of items as input arguments. +```ruby +# GraphQL Type Definition +input Input_Donut { + owner: Input_Person = null +} +``` + +
+
-This example shows various ways of accepting collections of data as inputs to controller actions. +If you want to force a value to be supplied (either on a query document or by default) you can use the [GraphField] attribute to augment the field. + + +
```csharp -public class BakeryController : GraphController +// Donut.cs +public class Bakery { - // a list of donuts - // schema syntax: [Donut] - [Mutation("createDonuts")] - public bool CreateDonuts(IEnumerable donuts) - {/*....*/} + public Bakery() + { + this.Owner = new Person("Bob Smith"); + } - // when used as a "list of list" - // schema syntax: [[Donut]] - [Mutation("createDonutsBySet")] - public bool CreateDonuts(List> donuts) - {/*....*/} + // a reference to another object + [GraphField(TypeExpression = TypeExpressions.IsNotNull)] + public Person Owner { get; set; } +} +``` +```ruby +# GraphQL Type Definition +input Input_Donut { + owner: Input_Person! = { name: "Bob Smith" } +} +``` +
+
- // when supplied as a regular array - // schema syntax: [Donut] - [Mutation("donutsAsAnArray")] - public bool DonutsAsAnArray(Donut[] donuts) - {/*....*/} - // This is a valid nested list - // schema syntax: [[[Donut]]] - [Mutation("mixedDonuts")] - public bool MixedDonuts(List> donuts) - {/*....*/} +> Any field explicitly or implicitly declared as non-nullable, that is not required, MUST have a default value assigned to it that is not `null`. - // when used as a field of another input object - [Mutation("createDonutCollection")] - public bool CreateDonuts(DonutCollection donutCollection) - {/*....*/} +Add the [Required] attribute to force a user to supply a non-null value for the field on a query document. + + +
+ +```csharp +// Donut.cs +public class Bakery +{ + public Bakery() + { + } + + // a reference to another object + [Required] + [[GraphField(TypeExpression = TypeExpressions.IsNotNull)]] + public Person Owner { get; set; } +} +``` +```ruby +# GraphQL Type Definition +input Input_Donut { + owner: Input_Person! +} +``` +
+
+ +## Default Values Must be Coercible +Any default value declared for an input field must be coercible by its target graph type in the target schema. + +### Enum Values + +Take a look at this example of an enum and input object: + +```csharp +public class Donut +{ + public string Name{ get; set; } + public DonutFlavor Flavor { get; set; } } -public class DonutCollection +public enum DonutFlavor { - public List Donuts { get; set; } + [GraphSkip] + Vanilla = 0, + Chocolate = 1, + } +``` + +When `Donut` is instantiated the value of Flavor will be `Vanilla` because +thats the default value (0) of the enum's underlying data type (int). However, the enum value `Vanilla` is marked as being skipped in the schema. + +Because of this mismatch, a `GraphTypeDeclarationException` will be thrown when the introspection data for your schema is built. As a result, the server will fail to start until the problem is corrected. -``` \ No newline at end of file +> Enum values used for the default value of input object properties MUST also exist as values in the schema or an exception will be thrown. From 93fcebafb1a269a55991d69ec0d21abe856891f7 Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Mon, 5 Sep 2022 15:12:20 -0700 Subject: [PATCH 3/4] WIP, added sections on cancellation tokens --- docs/advanced/subscriptions.md | 11 +++++++ docs/controllers/actions.md | 40 ++++++++++++++++++++++++-- docs/reference/schema-configuration.md | 4 +-- website/pages/en/index.js | 2 +- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/advanced/subscriptions.md b/docs/advanced/subscriptions.md index 4c3690a..d4ee0db 100644 --- a/docs/advanced/subscriptions.md +++ b/docs/advanced/subscriptions.md @@ -245,5 +245,16 @@ This is different than the default behavior when subscriptions are not enabled. **Note:** Allowing `PerField` authorization for subscriptions is slated for a future release. +## Query Timeouts +By default GraphQL does not define a timeout for an executed query. The query will run as long as the underlying HTTP connection is open. This is true for subscriptions as well. Given that the websocket connection is never closed, a query executed through a subscriptions will be allowed to run for an infinite amount of time which can have some unintended side effects and consume resources unecessarily. +Optionally, you can define a query timeout for a given schema, which the subscription server will obey: +```csharp +// startup.cs +services.AddGraphQL(o => +{ + // define a 2 minute timeout per query executed. + o.ExecutionOptions.QueryTimeout = TimeSpan.FromMinutes(2); +}) +``` diff --git a/docs/controllers/actions.md b/docs/controllers/actions.md index a8032f2..cc527ad 100644 --- a/docs/controllers/actions.md +++ b/docs/controllers/actions.md @@ -580,7 +580,7 @@ At runtime, GraphQL will try to validate every parameter passed on a query again One might think, well it should be passed as an object reference to the dictionary parameter: -```javascript +```ruby query { searchDonuts( searchParams : {name: "jelly*" filled: true dayOld: false }){ id @@ -608,8 +608,6 @@ public class DonutSearchParams // BakeryController.cs public class BakeryController : GraphController { - // ERROR, a GraphDeclarationException - // will be thrown. [QueryRoot] public IEnumerable SearchDonuts(DonutSearchParams searchParams) @@ -626,3 +624,39 @@ query { ```
+ +## Cancellation Tokens + +As with REST based ASP.NET action methods, your graph controller action methods can accept an optional cancellation token. This is useful when doing some long running activities such as IO, database queries, API orchestration etc. To make use of a cancellation token simply add it as a parameter to your method. GraphQL will automatically wire up the token for you: + +```csharp +// BakeryController.cs +public class BakeryController : GraphController +{ + // Add a CancellationToken to + [QueryRoot(typeof(IEnumerable))] + public async Task FindDonuts(string name, CancellationToken cancelToken) + {/* ... */} +} +``` + +> Depending on your usage of the cancellation token a `TaskCanceledException` may be thrown. GraphQL will not attempt to intercept this exception and will log it as an error-level, unhandled exception if allowed to propegate. The query will still be cancelled as expected. + +### Defining a Query Timeout +By default GraphQL does not define a timeout for an executed query. The query will run as long as the underlying HTTP connection is open. In fact, the `CancellationToken` passed to your action methods is the same Cancellation Token offered on the HttpContext when it receives the initial post request. + +Optionally, you can define a query timeout for a given schema: + +```csharp +// startup.cs +services.AddGraphQL(o => +{ + // define a 2 minute timeout per query executed. + o.ExecutionOptions.QueryTimeout = TimeSpan.FromMinutes(2); +}) +``` + +When a timeout is defined, the token passed to your action methods is a combined token representing the HttpContext as well as the timeout operation. That is to say the token will indicate a cancellation if the query time expires or the http connection is closed which ever comes first. When the timeout expires the caller will receive a response indicating the timeout. However, if the its the HTTP connection that is closed, the operation is simply halted and no result is produced. + +### Timeouts and Subscriptions +The same rules for cancellation tokens apply to subscriptions as well. Since the websocket connection is a long running operation it will never be closed until the connection is closed. To prevent some processes from spinning out of control its a good idea to define a query timeout when implementing a subscription server. This way, even though the connection remains open the query will terminate and release resources if something goes awry. \ No newline at end of file diff --git a/docs/reference/schema-configuration.md b/docs/reference/schema-configuration.md index deee657..8976edb 100644 --- a/docs/reference/schema-configuration.md +++ b/docs/reference/schema-configuration.md @@ -175,9 +175,9 @@ schemaOptions.ExecutionOptions.QueryTimeout = TimeSpan.FromMinutes(2); | Default Value | Acceptable Values | | ------------- | -------------------------- | -| 1 Minute | Minimum of 10 milliseconds | +| null | Minimum of 10 milliseconds | -The amount of time an individual query will be given to run to completion before being abandoned and canceled by the runtime. +The amount of time an individual query will be given to run before being abandoned and canceled by the runtime. By default, the timeout is disabled and a query will continue to execute as long as the underlying HTTP request is also executing. ### DebugMode diff --git a/website/pages/en/index.js b/website/pages/en/index.js index 3418b80..bf02807 100644 --- a/website/pages/en/index.js +++ b/website/pages/en/index.js @@ -31,7 +31,7 @@ class HomeSplash extends React.Component {

{GraphQL ASP.NET} {/*{siteConfig.tagline}*/} - v0.12.0-beta + v0.12.1-beta

); From 989b97207d074d1c69479411a3b2af0a465df769 Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Sat, 17 Sep 2022 13:53:35 -0700 Subject: [PATCH 4/4] word choice update --- docs/advanced/subscriptions.md | 46 +++++++++++++++++++++------------- docs/controllers/actions.md | 18 +++++++------ 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/docs/advanced/subscriptions.md b/docs/advanced/subscriptions.md index d4ee0db..aff7ba8 100644 --- a/docs/advanced/subscriptions.md +++ b/docs/advanced/subscriptions.md @@ -6,9 +6,10 @@ sidebar_label: Subscriptions ## Initial Setup -Successfully handling subscriptions in your GraphQL AspNet server can be straight forward for single server environments or very complicated for multi-server and scalable solutions. First we'll look at adding subscriptions for a single server. +Successfully handling subscriptions in your GraphQL AspNet server can be straight forward for single server environments or very complicated for multi-server and scalable solutions. First we'll look at adding subscriptions for a single server. ### Install the Subscriptions Package + The first step to using subscriptions is to install the subscription server package. ```Powershell @@ -23,7 +24,6 @@ You must configure web socket support for your Asp.Net server instance separatel After web sockets are added to your server, add subscription support to the graphql registration. - ```C# // startup.cs @@ -47,6 +47,7 @@ After web sockets are added to your server, add subscription support to the grap > Don't forget to add `UseWebsockets` in the `Configure` method of startup.cs ### Create a Subscription + Declaring a subscription is the same as declaring a query or mutation on a controller but with `[Subscription]` and `[SubscriptionRoot]` attributes. ```C# @@ -63,11 +64,13 @@ public class SubscriptionController : GraphController } } ``` + > Subscriptions can be asyncronous and return a Task<IGraphActionResult> as well. Here we've declared a new subscription the server will respond to, one that takes in a `filter` parameter to restrict the data that any subscribers receive. A query to invoke this subscription may look like this: + ```javascript subscription { onWidgetChanged(filter: "Big"){ @@ -77,12 +80,13 @@ subscription { } } ``` + Any updated widgets that start with the phrase "Big" will then be sent to the requestor as they are changed on the server. + ### Publish a Subscription Event In order for the subscription server to send data to any subscribers it has to be notified when something changes. It does this via named Subscription Events. These are internal, unique keys that identify when something happened, usually via a mutation. Once the mutation publishes an event, the subscription server will inspect the published data and, assuming the data type matches the expected data for the subscription, it will execute the subscription method for any connected subscribers and deliver the results as necessary. - ```C# public class MutationController : GraphController { @@ -106,6 +110,7 @@ public class MutationController : GraphController > Notice that the event name used in `PublishSubscriptionEvent` is the same as the `EventName` property on the `[SubscriptionRoot]` attribute. The subscription server will use the published event name to match which registered subscriptions need to receive the data being published. ### Subscription Event Source Data + In the example above, the data sent with `PublishSubscriptionEvent` is the same as the first input parameter called `eventData` which is the same as the field return type of the controller method. By default, the subscription will look for a parameter with the same data type as its field return type and use that as the event data source. You can explicitly flag a different parameter, or a parameter of a different data type to be the expected event source with the `[SubscriptionSource]` attribute. @@ -127,11 +132,13 @@ public class SubscriptionController : GraphController } } ``` -Here the subscription expects that an event is published using a `WidgetInternal` data type that it will internally convert to a `Widget` and send to any subscribers. This can be useful if you wish to share internal objects between your mutations and subscriptions that you don't want publicly exposed. +Here the subscription expects that an event is published using a `WidgetInternal` data type that it will internally convert to a `Widget` and send to any subscribers. This can be useful if you wish to share internal objects between your mutations and subscriptions that you don't want publicly exposed. ### Summary + That's all there is for a basic subscription server setup. + 1. Add the package reference and update startup.cs 2. Create a new subscription using `[Subscription]` or `[SubscriptionRoot]` 3. Publish an event from a mutation @@ -141,16 +148,19 @@ That's all there is for a basic subscription server setup. A complete example of single instance subscription server including a react app that utilizes the Apollo Client is available in the [demo projects](../reference/demo-projects) section. ## Scaling Subscription Servers + Using web sockets has a natural limitation in that any each server instance has a maximum number of socket connections that it can handle. Once that limit is reached no additional clients can register subscriptions. -Ok no problem, just scale horizontally, spin up additional ASP.NET server instances, add a load balancer and have the new requests open a web socket connection to these additional server instances, right? Not so fast. +Ok no problem, just scale horizontally, spin up additional ASP.NET server instances, add a load balancer and have the new requests open a web socket connection to these additional server instances, right? Not so fast. With the examples above events published by any mutation using `PublishSubscriptionEvent` are routed internally directly to the local subscription server meaning only those clients connected to the server where the event was raised will receive it. Clients connected to other server instances will never know an event was raised. This represents a big problem for large scale websites, so what do we do? ### Custom Event Publishing -Instead of publishing events internally, within the server instance, we need to publish our events to some intermediate source such that any server can be notified of the change. There are a variety of technologies to handle this scenario; be it a common database or messaging technologies like RabbitMQ, Azure Service Bus etc. + +Instead of publishing events internally, within the server instance, we need to publish our events to some intermediate source such that any server can be notified of the change. There are a variety of technologies to handle this scenario; be it a common database or messaging technologies like RabbitMQ, Azure Service Bus etc. #### Implement `ISubscriptionEventPublisher` + Whatever your technology of choice the first step is to create and register a custom publisher. How your individual class functions will vary widely depending on your implementation. ```C# @@ -166,6 +176,7 @@ Whatever your technology of choice the first step is to create and register a cu ``` Register your publisher with the DI container BEFORE calling `.AddGraphQL()` + ```C# // startup.cs @@ -185,7 +196,8 @@ Register your publisher with the DI container BEFORE calling `.AddGraphQL()` Publishing your SubscriptionEvents externally is not trivial. You'll have to deal with concerns like data serialization, package size etc.. ### Consuming Published Events -At this point, we've successfully published our events to some external data source. Now we need to consume them. How that occurs is, again, implementation specific. Perhaps you run a background hosted service to watch for messages on an Azure Service Bus topic or perhaps you periodically pole a database table to look for new events. The ways in which data may be shared is endless. + +At this point, we've successfully published our events to some external data source. Now we need to consume them. How that occurs is, again, implementation specific. Perhaps you run a background hosted service to watch for messages on an Azure Service Bus topic or perhaps you periodically pole a database table to look for new events. The ways in which data may be shared is endless. Once you rematerialize a `SubscriptionEvent` you need to let GraphQL know that it occurred. this is done using the `ISubscriptionEventRouter`. In general, you won't need to implement your own router, just inject it into your listener service then call `RaiseEvent` and GraphQL will take it from there. @@ -211,17 +223,18 @@ Once you rematerialize a `SubscriptionEvent` you need to let GraphQL know that i } } ``` + The router will take care of figuring out which schema the event is destined for, which local subscription servers are registered to receive that event and forward the data as necessary for processing. ### Azure Service Bus Example A complete example of a scalable subscription configuration including serialization and deserialization using the Azure Service Bus is available in the [demo projects](../reference/demo-projects) section. - ## Subscription Server Configuration ->See [schema configuration](../reference/schema-configuration#subscription-server-options) for information on individual subscription server configuration options. -Currently, when using the `.AddSubscriptions()` extension method two seperate operations occur: +> See [schema configuration](../reference/schema-configuration#subscription-server-options) for information on individual subscription server configuration options. + +Currently, when using the `.AddSubscriptions()` extension method two seperate operations occur: 1. The subscription server components are registered to the DI container, the graphql execution pipeline is modified to support registering subscriptions and a middleware component is appended to the ASP.NET pipeline to intercept web sockets and forward client connections to the the subscription server component. @@ -231,28 +244,27 @@ Some applications may wish to split these operations in different server instanc The following more granular configuration options are available: -* `.AddSubscriptionServer()` :: Only configures the ASP.NET pipeline to intercept websockets and adds the subscription server components to the DI container. - -* `.AddSubscriptionPublishing()` :: Only configures the graphql execution pipeline and the `ISubscriptionEventPublisher`. Subscription registration and Websocket support is **NOT** enabled. +- `.AddSubscriptionServer()` :: Only configures the ASP.NET pipeline to intercept websockets and adds the subscription server components to the DI container. +- `.AddSubscriptionPublishing()` :: Only configures the graphql execution pipeline and the `ISubscriptionEventPublisher`. Subscription registration and Websocket support is **NOT** enabled. ## Security & Query Authorization Because subscriptions are long running and registered before any data is processed, the subscription server requires a [query authorization method](../reference/schema-configuration#authorization-options) of `PerRequest`. This allows the subscription query to be fully validated before its registered with the server. This authorization method is set globally at startup and will apply to queries and mutations as well. -This is different than the default behavior when subscriptions are not enabled. Queries and mutations, by default, will follow a `PerField` method allowing for partial query resolutions. +This is different than the default behavior when subscriptions are not enabled. Queries and mutations, by default, will follow a `PerField` method allowing for partial query resolutions. **Note:** Allowing `PerField` authorization for subscriptions is slated for a future release. - ## Query Timeouts -By default GraphQL does not define a timeout for an executed query. The query will run as long as the underlying HTTP connection is open. This is true for subscriptions as well. Given that the websocket connection is never closed, a query executed through a subscriptions will be allowed to run for an infinite amount of time which can have some unintended side effects and consume resources unecessarily. + +By default GraphQL does not define a timeout for an executed query. The query will run as long as the underlying HTTP connection is open. This is true for subscriptions as well. Given that the websocket connection is never closed while the end user is connected, any query executed through the websocket will be allowed to run for an infinite amount of time which can have some unintended side effects and consume resources unecessarily. Optionally, you can define a query timeout for a given schema, which the subscription server will obey: ```csharp // startup.cs -services.AddGraphQL(o => +services.AddGraphQL(o => { // define a 2 minute timeout per query executed. o.ExecutionOptions.QueryTimeout = TimeSpan.FromMinutes(2); diff --git a/docs/controllers/actions.md b/docs/controllers/actions.md index cc527ad..c7596a1 100644 --- a/docs/controllers/actions.md +++ b/docs/controllers/actions.md @@ -516,7 +516,7 @@ public class BakeryController : GraphController // schema syntax: [Donut] [Mutation("donutsAsAnArray")] public bool DonutsAsAnArray(Donut[] donuts) - {/*....*/} + {/*....*/} // This is a valid nested list // schema syntax: [[[Donut]]] @@ -562,7 +562,7 @@ public class BakeryController : GraphController ```javascript query { - searchDonuts(searchParams: + searchDonuts(searchParams: name: "jelly*" filled: true dayOld: false){ @@ -633,9 +633,9 @@ As with REST based ASP.NET action methods, your graph controller action methods // BakeryController.cs public class BakeryController : GraphController { - // Add a CancellationToken to + // Add a CancellationToken to your controller method [QueryRoot(typeof(IEnumerable))] - public async Task FindDonuts(string name, CancellationToken cancelToken) + public async Task SearchDonuts(string name, CancellationToken cancelToken) {/* ... */} } ``` @@ -643,20 +643,22 @@ public class BakeryController : GraphController > Depending on your usage of the cancellation token a `TaskCanceledException` may be thrown. GraphQL will not attempt to intercept this exception and will log it as an error-level, unhandled exception if allowed to propegate. The query will still be cancelled as expected. ### Defining a Query Timeout -By default GraphQL does not define a timeout for an executed query. The query will run as long as the underlying HTTP connection is open. In fact, the `CancellationToken` passed to your action methods is the same Cancellation Token offered on the HttpContext when it receives the initial post request. + +By default GraphQL does not define a timeout for an executed query. The query will run as long as the underlying HTTP connection is open. In fact, the `CancellationToken` passed to your action methods is the same Cancellation Token offered on the HttpContext when it receives the initial post request. Optionally, you can define a query timeout for a given schema: ```csharp // startup.cs -services.AddGraphQL(o => +services.AddGraphQL(o => { // define a 2 minute timeout per query executed. o.ExecutionOptions.QueryTimeout = TimeSpan.FromMinutes(2); }) ``` -When a timeout is defined, the token passed to your action methods is a combined token representing the HttpContext as well as the timeout operation. That is to say the token will indicate a cancellation if the query time expires or the http connection is closed which ever comes first. When the timeout expires the caller will receive a response indicating the timeout. However, if the its the HTTP connection that is closed, the operation is simply halted and no result is produced. +When a timeout is defined, the token passed to your action methods is a combined token representing the HttpContext as well as the timeout operation. That is to say the token will indicate a cancellation if the alloted query time expires or the http connection is closed which ever comes first. When the timeout expires the caller will receive a response indicating the timeout. However, if the its the HTTP connection that is closed, the operation is simply halted and no result is produced. ### Timeouts and Subscriptions -The same rules for cancellation tokens apply to subscriptions as well. Since the websocket connection is a long running operation it will never be closed until the connection is closed. To prevent some processes from spinning out of control its a good idea to define a query timeout when implementing a subscription server. This way, even though the connection remains open the query will terminate and release resources if something goes awry. \ No newline at end of file + +The same rules for cancellation tokens apply to subscriptions as well. Since the websocket connection is a long running operation it will never be closed until the connection is closed. To prevent some processes from spinning out of control its a good idea to define a query timeout when implementing a subscription server. This way, even though the connection remains open the query will terminate and release resources if something goes awry.