diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index a6205409..f8183d5a 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -22,6 +22,7 @@
│ ├── FSharp.Data.GraphQL.Server.AspNetCore/ – ASP.NET Core integration (HTTP and WebSocket)
│ ├── FSharp.Data.GraphQL.Server.Giraffe/ – Giraffe integration
│ ├── FSharp.Data.GraphQL.Server.Oxpecker/ – Oxpecker integration
+│ ├── FSharp.Data.GraphQL.Server.Suave/ – Suave integration
│ ├── FSharp.Data.GraphQL.Client/ – client type provider runtime
│ └── FSharp.Data.GraphQL.Client.DesignTime/ – client type provider design-time component
├── tests/
diff --git a/FSharp.Data.GraphQL.slnx b/FSharp.Data.GraphQL.slnx
index 271e8c90..bf5edfe3 100644
--- a/FSharp.Data.GraphQL.slnx
+++ b/FSharp.Data.GraphQL.slnx
@@ -34,6 +34,7 @@
+
@@ -123,6 +124,7 @@
+
diff --git a/Packages.props b/Packages.props
index 1f810695..84d5e3dd 100644
--- a/Packages.props
+++ b/Packages.props
@@ -28,6 +28,7 @@
+
diff --git a/README.md b/README.md
index 1c5b35bf..826a8178 100644
--- a/README.md
+++ b/README.md
@@ -279,17 +279,19 @@ type OperationExecutionMiddleware =
ExecutionContext -> (ExecutionContext -> AsyncVal) -> AsyncVal
type IExecutorMiddleware =
- abstract CompileSchema : SchemaCompileMiddleware option
- abstract PlanOperation : OperationPlanningMiddleware option
- abstract ExecuteOperationAsync : OperationExecutionMiddleware option
+ abstract CompileSchema : SchemaCompileMiddleware voption
+ abstract PostCompileSchema : SchemaPostCompileMiddleware voption
+ abstract PlanOperation : OperationPlanningMiddleware voption
+ abstract ExecuteOperationAsync : OperationExecutionMiddleware voption
```
Optionally, for ease of implementation, concrete class to derive from can be used, receiving only the optional sub-middleware functions in the constructor:
```fsharp
-type ExecutorMiddleware(?compile, ?plan, ?execute) =
+type ExecutorMiddleware([] ?compile, [] ?postCompile, [] ?plan, [] ?execute) =
interface IExecutorMiddleware with
member _.CompileSchema = compile
+ member _.PostCompileSchema = postCompile
member _.PlanOperation = plan
member _.ExecuteOperationAsync = execute
```
@@ -297,7 +299,7 @@ type ExecutorMiddleware(?compile, ?plan, ?execute) =
Each of the middleware functions act like an intercept function, with two parameters: the context of the phase, the function of the next middleware (or the actual phase itself, which is the last to run), and the return value. Those functions can be passed as an argument to the constructor of the `Executor<'Root>` object:
```fsharp
-let middleware = [ ExecutorMiddleware(compileFn, planningFn, executionFn) ]
+let middleware = [ ExecutorMiddleware(compile = compileFn, plan = planningFn, execute = executionFn) ]
let executor = Executor(schema, middleware)
```
diff --git a/samples/magic-eight-ball/GraphiQL.fs b/samples/magic-eight-ball/GraphiQL.fs
new file mode 100644
index 00000000..ee0f3ffc
--- /dev/null
+++ b/samples/magic-eight-ball/GraphiQL.fs
@@ -0,0 +1,303 @@
+module FSharp.Data.GraphQL.Samples.MagicEightBall.GraphiQL
+
+open Suave
+open Suave.Http
+open Suave.Operators
+
+// `GraphQL.Server.Ui.GraphiQL` (used by the other samples) only ships an ASP.NET Core middleware, so it can't be
+// composed into a Suave `WebPart`. Its `GraphiQLPageModel.Render()` method produces static HTML for a fixed set of
+// options (GraphQL endpoint `/`, subscriptions endpoint `/ws`, `graphql-ws` subscriptions), so that output is
+// hardcoded here instead of invoking the internal renderer through reflection.
+let private page =
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading...
+
+
+
+
+"""
+
+/// A that serves the GraphiQL IDE at /graphiql, pointed at the GraphQL API served at /.
+let webPart : WebPart =
+ Filters.path "/graphiql"
+ >=> Filters.GET
+ >=> Writers.setMimeType "text/html; charset=utf-8"
+ >=> Successful.OK page
diff --git a/samples/magic-eight-ball/Program.fs b/samples/magic-eight-ball/Program.fs
new file mode 100644
index 00000000..d6c3e547
--- /dev/null
+++ b/samples/magic-eight-ball/Program.fs
@@ -0,0 +1,22 @@
+module FSharp.Data.GraphQL.Samples.MagicEightBall.Program
+
+open System
+open Suave
+open FSharp.Data.GraphQL
+open FSharp.Data.GraphQL.Server.Suave
+open FSharp.Data.GraphQL.Samples.MagicEightBall.GraphiQL
+open FSharp.Data.GraphQL.Samples.MagicEightBall.Schema
+
+[]
+let main _ =
+ let random = Random ()
+
+ let rootFactory (_ctx : HttpContext) : Root = { Random = random }
+
+ let executor = Executor (schema)
+
+ let app = choose [ webPart; GraphQL.graphQL executor rootFactory ]
+
+ startWebServer defaultConfig app
+
+ 0
diff --git a/samples/magic-eight-ball/README.md b/samples/magic-eight-ball/README.md
new file mode 100644
index 00000000..971ddbc7
--- /dev/null
+++ b/samples/magic-eight-ball/README.md
@@ -0,0 +1,36 @@
+# Magic Eight Ball
+
+A minimal example of hosting a GraphQL schema with the `FSharp.Data.GraphQL.Server.Suave` package.
+
+## Running
+
+```bash
+dotnet run --project samples/magic-eight-ball/magic-eight-ball.fsproj
+```
+
+The server listens on `http://localhost:8080` (Suave's `defaultConfig`). Ask it a question:
+
+```bash
+curl http://localhost:8080 \
+ -H "Content-Type: application/json" \
+ -d '{ "query": "{ ask(question: \"Will it build?\") }" }'
+```
+
+Or open [http://localhost:8080/graphiql](http://localhost:8080/graphiql) to ask it from the GraphiQL IDE.
+
+## Subscriptions (WebSockets)
+
+The `shake` mutation notifies anyone subscribed to `onShake` with the new answer, over the `graphql-transport-ws`
+protocol at `ws://localhost:8080/ws`. Open two tabs of the GraphiQL IDE above to see it live: run
+
+```graphql
+subscription { onShake }
+```
+
+in one, then run
+
+```graphql
+mutation { shake }
+```
+
+in the other - the answer shows up in the first tab as soon as it's published.
diff --git a/samples/magic-eight-ball/Schema.fs b/samples/magic-eight-ball/Schema.fs
new file mode 100644
index 00000000..34b8773d
--- /dev/null
+++ b/samples/magic-eight-ball/Schema.fs
@@ -0,0 +1,78 @@
+module FSharp.Data.GraphQL.Samples.MagicEightBall.Schema
+
+open System
+open FSharp.Data.GraphQL
+open FSharp.Data.GraphQL.Types
+
+let private answers = [|
+ "It is certain."
+ "It is decidedly so."
+ "Without a doubt."
+ "Yes, definitely."
+ "You may rely on it."
+ "As I see it, yes."
+ "Most likely."
+ "Outlook good."
+ "Yes."
+ "Signs point to yes."
+ "Reply hazy, try again."
+ "Ask again later."
+ "Better not tell you now."
+ "Cannot predict now."
+ "Concentrate and ask again."
+ "Don't count on it."
+ "My reply is no."
+ "My sources say no."
+ "Outlook not so good."
+ "Very doubtful."
+|]
+
+type Root = { Random : Random }
+
+let private shake (random : Random) = answers[random.Next answers.Length]
+
+let Query =
+ Define.Object(
+ name = "Query",
+ fields = [
+ Define.Field (
+ "ask",
+ StringType,
+ "Shakes the magic eight ball and returns its answer to the given question.",
+ [ Define.Input ("question", StringType) ],
+ fun _ root -> shake (root.Random)
+ )
+ ]
+ )
+
+let Mutation =
+ Define.Object(
+ name = "Mutation",
+ fields = [
+ Define.Field (
+ "shake",
+ StringType,
+ "Shakes the magic eight ball and notifies everyone subscribed to `onShake` with the new answer.",
+ fun ctx root ->
+ let answer = shake (root.Random)
+ ctx.Schema.SubscriptionProvider.Publish "onShake" answer
+ answer
+ )
+ ]
+ )
+
+let Subscription =
+ Define.SubscriptionObject(
+ name = "Subscription",
+ fields = [
+ Define.SubscriptionField (
+ "onShake",
+ Query,
+ StringType,
+ "Notified with a new answer whenever anyone shakes the ball via the `shake` mutation.",
+ fun _ _ (answer : string) -> Some answer
+ )
+ ]
+ )
+
+let schema : ISchema = upcast Schema (Query, Mutation, Subscription)
diff --git a/samples/magic-eight-ball/magic-eight-ball.fsproj b/samples/magic-eight-ball/magic-eight-ball.fsproj
new file mode 100644
index 00000000..ed0257dd
--- /dev/null
+++ b/samples/magic-eight-ball/magic-eight-ball.fsproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ $(DotNetVersion)
+ FSharp.Data.GraphQL.Samples.MagicEightBall
+ FSharp.Data.GraphQL.Samples.MagicEightBall
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/DefineExtensions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/DefineExtensions.fs
index eb2af4a6..b7f6c899 100644
--- a/src/FSharp.Data.GraphQL.Server.Middleware/DefineExtensions.fs
+++ b/src/FSharp.Data.GraphQL.Server.Middleware/DefineExtensions.fs
@@ -18,8 +18,8 @@ module DefineExtensions =
/// A boolean flag indicating if the values of the threshold and the weight of the current query should
/// be reported to the metadata object in the GQLResponse.
///
- static member QueryWeightMiddleware(threshold : float, ?reportToMetadata : bool) : IExecutorMiddleware =
- let reportToMetadata = defaultArg reportToMetadata false
+ static member QueryWeightMiddleware(threshold : float, [] ?reportToMetadata : bool) : IExecutorMiddleware =
+ let reportToMetadata = defaultValueArg reportToMetadata false
upcast QueryWeightMiddleware(threshold, reportToMetadata)
///
@@ -35,8 +35,8 @@ module DefineExtensions =
/// This argument can be used on the query to specify a filter with operations like "less than", "equals", etc. on the
/// field of the specified object of 'ObjectType type.
///
- static member ObjectListFilterMiddleware<'ObjectType, 'ListType>(?reportToMetadata : bool) : IExecutorMiddleware =
- let reportToMetadata = defaultArg reportToMetadata false
+ static member ObjectListFilterMiddleware<'ObjectType, 'ListType>([] ?reportToMetadata : bool) : IExecutorMiddleware =
+ let reportToMetadata = defaultValueArg reportToMetadata false
upcast ObjectListFilterMiddleware<'ObjectType, 'ListType>(reportToMetadata)
///
@@ -47,6 +47,6 @@ module DefineExtensions =
/// An optional function to resolve the name of the identity field based on the object definition.
/// If no function is provided, it takes the default "Id" value as the identity field.
///
- static member LiveQueryMiddleware(?identityName : IdentityNameResolver) : IExecutorMiddleware =
- let identityName = defaultArg identityName (fun _ -> "Id")
+ static member LiveQueryMiddleware([] ?identityName : IdentityNameResolver) : IExecutorMiddleware =
+ let identityName = defaultValueArg identityName (fun _ -> "Id")
upcast LiveQueryMiddleware(identityName)
diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/MiddlewareDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/MiddlewareDefinitions.fs
index 6d0f182c..4aae7f4a 100644
--- a/src/FSharp.Data.GraphQL.Server.Middleware/MiddlewareDefinitions.fs
+++ b/src/FSharp.Data.GraphQL.Server.Middleware/MiddlewareDefinitions.fs
@@ -76,10 +76,10 @@ type internal QueryWeightMiddleware (threshold : float, reportToMetadata : bool)
if pass then next ctx else error ctx
interface IExecutorMiddleware with
- member _.CompileSchema = None
- member _.PostCompileSchema = None
- member _.PlanOperation = None
- member _.ExecuteOperationAsync = Some (middleware threshold)
+ member _.CompileSchema = ValueNone
+ member _.PostCompileSchema = ValueNone
+ member _.PlanOperation = ValueNone
+ member _.ExecuteOperationAsync = ValueSome (middleware threshold)
type internal ObjectListFilterMiddleware<'ObjectType, 'ListType> (reportToMetadata : bool) =
@@ -153,10 +153,10 @@ type internal ObjectListFilterMiddleware<'ObjectType, 'ListType> (reportToMetada
return GQLExecutionResult.RequestError (ctx.ExecutionPlan.DocumentId, (errs |> List.map GQLProblemDetails.OfError), ctx.Metadata)
}
interface IExecutorMiddleware with
- member _.CompileSchema = Some compileMiddleware
- member _.PostCompileSchema = None
- member _.PlanOperation = None
- member _.ExecuteOperationAsync = Some reportMiddleware
+ member _.CompileSchema = ValueSome compileMiddleware
+ member _.PostCompileSchema = ValueNone
+ member _.PlanOperation = ValueNone
+ member _.ExecuteOperationAsync = ValueSome reportMiddleware
/// A function that resolves an identity name for a schema object, based on a object definition of it.
type IdentityNameResolver = ObjectDef -> string
@@ -203,7 +203,7 @@ type internal LiveQueryMiddleware (identityNameResolver : IdentityNameResolver)
next ctx
interface IExecutorMiddleware with
- member _.CompileSchema = Some middleware
- member _.PostCompileSchema = None
- member _.PlanOperation = None
- member _.ExecuteOperationAsync = None
+ member _.CompileSchema = ValueSome middleware
+ member _.PostCompileSchema = ValueNone
+ member _.PlanOperation = ValueNone
+ member _.ExecuteOperationAsync = ValueNone
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj b/src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj
new file mode 100644
index 00000000..4c8e79ea
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj
@@ -0,0 +1,32 @@
+
+
+
+ $(DotNetVersion)
+ true
+ true
+ FSharp implementation of Facebook GraphQL query language (Suave integration)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/GraphQL.fs b/src/FSharp.Data.GraphQL.Server.Suave/GraphQL.fs
new file mode 100644
index 00000000..5d2bf122
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/GraphQL.fs
@@ -0,0 +1,33 @@
+///
+/// Suave s that serve a GraphQL schema over HTTP and, for subscriptions, over the
+/// graphql-transport-ws WebSocket protocol.
+///
+module FSharp.Data.GraphQL.Server.Suave.GraphQL
+
+open Suave
+open Suave.Http
+open Suave.Operators
+
+open FSharp.Data.GraphQL
+
+///
+/// Builds a that serves GraphQL queries, mutations and introspection over HTTP
+/// (GET and POST, including the GraphQL multipart request specification for file uploads), and
+/// GraphQL subscriptions over the graphql-transport-ws WebSocket protocol, at the WebSocket endpoint
+/// configured in .
+///
+let graphQLWithOptions (options : GraphQLOptions<'Root>) : WebPart =
+ choose [
+ Filters.path options.WebsocketOptions.EndpointUrl
+ >=> GraphQLWebSocketHandler.handleGraphQLWebSocket options
+ (Filters.GET <|> Filters.POST)
+ >=> GraphQLHttpHandler.handleGraphQL options
+ ]
+
+///
+/// Builds a that serves GraphQL queries, mutations, introspection and (over the
+/// graphql-transport-ws WebSocket protocol, at the default /ws endpoint) subscriptions, for the given
+/// schema executor and root value factory.
+///
+let graphQL (executor : Executor<'Root>) (rootFactory : HttpContext -> 'Root) : WebPart =
+ graphQLWithOptions (GraphQLOptions.create executor rootFactory)
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/GraphQLHttpHandler.fs b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLHttpHandler.fs
new file mode 100644
index 00000000..5203f448
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLHttpHandler.fs
@@ -0,0 +1,75 @@
+module internal FSharp.Data.GraphQL.Server.Suave.GraphQLHttpHandler
+
+open System.Text.Json
+open System.Text.Json.Serialization
+open Suave
+open Suave.Http
+open Suave.Operators
+open Suave.Utils
+
+open FSharp.Data.GraphQL
+open FSharp.Data.GraphQL.Shared
+
+let private jsonMimeType = "application/json; charset=utf-8"
+
+let private jsonResponse (serializerOptions : JsonSerializerOptions) (statusCode : HttpCode) (value : 'T) : WebPart =
+ let json = JsonSerializer.Serialize (value, serializerOptions)
+ Writers.setMimeType jsonMimeType
+ >=> Response.response statusCode (UTF8.bytes json)
+
+let private toResponse ({ DocumentId = documentId; Content = content } : GQLExecutionResult) =
+ match content with
+ // `GQLResponse` carries a null `data` for a result whose non-null root field failed
+ | Direct (data, errs) -> GQLResponse.Direct (documentId, data |> ValueOption.toObj, errs)
+ | Deferred (data, errs, _deferred) -> GQLResponse.Direct (documentId, data, errs)
+ | Stream _stream -> GQLResponse.Stream documentId
+ | RequestError errs -> GQLResponse.RequestError (documentId, errs)
+
+let private executeIntrospectionQuery (options : GraphQLOptions<'Root>) (ctx : HttpContext) (ast : Ast.Document voption) : Async = async {
+ let getInputContext () = SuaveInputExecutionContext (ctx.request) :> IInputExecutionContext
+ let executor = options.SchemaExecutor
+
+ let! result =
+ match ast with
+ | ValueNone -> executor.AsyncExecute (IntrospectionQuery.Definition, getInputContext)
+ | ValueSome ast -> executor.AsyncExecute (ast, getInputContext)
+
+ return toResponse result
+}
+
+let private executeOperation (options : GraphQLOptions<'Root>) (ctx : HttpContext) (content : ParsedGQLQueryRequestContent) : Async =
+ async {
+ let getInputContext () = SuaveInputExecutionContext (ctx.request) :> IInputExecutionContext
+
+ let operationName =
+ content.OperationName
+ |> Skippable.filter (not << isNull)
+ |> Skippable.toValueOption
+
+ let variables =
+ content.Variables
+ |> Skippable.filter (not << isNull)
+ |> Skippable.toValueOption
+
+ let root = options.RootFactory ctx
+
+ let! result =
+ options.SchemaExecutor.AsyncExecute (content.Ast, getInputContext, root, ?variables = variables, ?operationName = operationName)
+
+ return toResponse result
+ }
+
+/// A that parses and executes GraphQL requests over HTTP (both GET introspection
+/// queries and POST queries/mutations, including the GraphQL multipart request specification for file uploads).
+let handleGraphQL (options : GraphQLOptions<'Root>) : WebPart =
+ fun (ctx : HttpContext) -> async {
+ match RequestParsing.checkOperationType options.SerializerOptions ctx.request with
+ | Ok (IntrospectionQuery ast) ->
+ let! response = executeIntrospectionQuery options ctx ast
+ return! jsonResponse options.SerializerOptions HTTP_200 response ctx
+ | Ok (OperationQuery content) ->
+ let! response = executeOperation options ctx content
+ return! jsonResponse options.SerializerOptions HTTP_200 response ctx
+ | Error errorMessage ->
+ return! jsonResponse options.SerializerOptions HTTP_400 (GQLResponse.RequestError (0, [ GQLProblemDetails.Create errorMessage ])) ctx
+ }
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/GraphQLOptions.fs b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLOptions.fs
new file mode 100644
index 00000000..1dc06d15
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLOptions.fs
@@ -0,0 +1,64 @@
+namespace FSharp.Data.GraphQL.Server.Suave
+
+open System
+open System.Text.Json
+open Suave.Http
+
+open FSharp.Data.GraphQL
+open FSharp.Data.GraphQL.Shared
+
+/// A custom handler invoked for the ping/pong messages of the graphql-transport-ws protocol.
+type PingHandler = JsonDocument voption -> Async
+
+/// Default values used to configure .
+[]
+module GraphQLOptionsDefaults =
+
+ /// The default path at which graphql-transport-ws WebSocket connections are accepted.
+ []
+ let WebSocketEndpoint = "/ws"
+
+ /// The default timeout, in milliseconds, to wait for a connection_init message before closing the socket.
+ []
+ let WebSocketConnectionInitTimeoutInMs = 3000.0
+
+/// Options that configure the graphql-transport-ws WebSocket protocol.
+[]
+[]
+type GraphQLTransportWSOptions = {
+ /// The path at which WebSocket connections for GraphQL subscriptions are accepted.
+ EndpointUrl : string
+ /// How long to wait for a connection_init message from the client before closing the socket.
+ ConnectionInitTimeout : TimeSpan
+ /// An optional custom handler invoked for ping/pong messages.
+ CustomPingHandler : PingHandler voption
+}
+
+/// Options used to configure the GraphQL s exposed by this library.
+[]
+[]
+type GraphQLOptions<'Root> = {
+ /// The schema executor used to run GraphQL operations.
+ SchemaExecutor : Executor<'Root>
+ /// Builds the root value used to execute a GraphQL operation from the current .
+ RootFactory : HttpContext -> 'Root
+ /// The used to (de)serialize GraphQL requests, responses and WebSocket messages.
+ SerializerOptions : JsonSerializerOptions
+ /// Options for the graphql-transport-ws WebSocket protocol.
+ WebsocketOptions : GraphQLTransportWSOptions
+}
+
+/// Functions to create .
+module GraphQLOptions =
+
+ /// Creates with default settings for the given executor and root factory.
+ let create (executor : Executor<'Root>) (rootFactory : HttpContext -> 'Root) : GraphQLOptions<'Root> = {
+ SchemaExecutor = executor
+ RootFactory = rootFactory
+ SerializerOptions = Json.getWSSerializerOptions Seq.empty
+ WebsocketOptions = {
+ EndpointUrl = GraphQLOptionsDefaults.WebSocketEndpoint
+ ConnectionInitTimeout = TimeSpan.FromMilliseconds GraphQLOptionsDefaults.WebSocketConnectionInitTimeoutInMs
+ CustomPingHandler = ValueNone
+ }
+ }
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/GraphQLSubscriptionsManagement.fs b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLSubscriptionsManagement.fs
new file mode 100644
index 00000000..33f2281c
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLSubscriptionsManagement.fs
@@ -0,0 +1,37 @@
+module internal FSharp.Data.GraphQL.Server.Suave.GraphQLSubscriptionsManagement
+
+open System
+open System.Collections.Generic
+
+open FSharp.Data.GraphQL.Shared.WebSockets
+
+/// The active subscriptions of one connection, keyed by the id the client gave each of them, with the handle that
+/// unsubscribes from its source.
+type SubscriptionsDict = Dictionary
+
+// `subscriptions` is mutated both from the WebSocket's sequential message loop and from the `IObserver` callbacks of
+// each active subscription's stream, which can fire on an arbitrary thread. `Dictionary` is not safe for concurrent
+// access, so every operation here locks on the dictionary instance itself - the same instance is shared by every
+// caller for a given connection, so this serializes all of them against each other.
+let createSubscriptions () = SubscriptionsDict (StringComparer.Ordinal)
+
+let addSubscription (id : SubscriptionId, unsubscriber : IDisposable) (subscriptions : SubscriptionsDict) =
+ lock subscriptions (fun () -> subscriptions.Add (id, unsubscriber))
+
+let isIdTaken (id : SubscriptionId) (subscriptions : SubscriptionsDict) = lock subscriptions (fun () -> subscriptions.ContainsKey id)
+
+let removeSubscription (id : SubscriptionId) (subscriptions : SubscriptionsDict) =
+ lock subscriptions (fun () ->
+ match subscriptions.TryGetValue id with
+ | true, unsubscriber ->
+ subscriptions.Remove id |> ignore
+ unsubscriber.Dispose ()
+ | false, _ -> ())
+
+let removeAllSubscriptions (subscriptions : SubscriptionsDict) =
+ lock subscriptions (fun () ->
+ try
+ for unsubscriber in subscriptions.Values do
+ unsubscriber.Dispose ()
+ finally
+ subscriptions.Clear ())
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/GraphQLWebSocketHandler.fs b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLWebSocketHandler.fs
new file mode 100644
index 00000000..086d32d1
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/GraphQLWebSocketHandler.fs
@@ -0,0 +1,300 @@
+module internal FSharp.Data.GraphQL.Server.Suave.GraphQLWebSocketHandler
+
+open System
+open System.Collections.Generic
+open System.Text
+open System.Text.Json
+open System.Text.Json.Serialization
+
+open Suave
+open Suave.Http
+open Suave.Logging
+open Suave.Sockets
+open Suave.WebSocket
+
+open FSharp.Data.GraphQL
+open FSharp.Data.GraphQL.Execution
+open FSharp.Data.GraphQL.Shared
+open FSharp.Data.GraphQL.Shared.WebSockets
+
+/// The graphql-transport-ws subprotocol, as defined by
+/// the graphql-ws protocol specification.
+[]
+let private Subprotocol = "graphql-transport-ws"
+
+let private serializeServerMessage (serializerOptions : JsonSerializerOptions) (serverMessage : ServerMessage) : string =
+ let raw : RawServerMessage =
+ match serverMessage with
+ | ConnectionAck -> { Id = ValueNone; Type = "connection_ack"; Payload = ValueNone }
+ | ServerPing -> { Id = ValueNone; Type = "ping"; Payload = ValueNone }
+ | ServerPong p -> { Id = ValueNone; Type = "pong"; Payload = p |> ValueOption.map CustomResponse }
+ | Next (id, payload) -> {
+ Id = ValueSome id
+ Type = "next"
+ Payload = ValueSome (ExecutionResult payload)
+ }
+ | Complete id -> { Id = ValueSome id; Type = "complete"; Payload = ValueNone }
+ | ServerError (id, errMessages) -> {
+ Id = ValueSome id
+ Type = "error"
+ Payload = ValueSome (ErrorMessages errMessages)
+ }
+
+ JsonSerializer.Serialize (raw, serializerOptions)
+
+let private invalidJsonInClientMessageError = InvalidMessage (4400, "Invalid json in client message")
+
+let private deserializeClientMessage
+ (serializerOptions : JsonSerializerOptions)
+ (bytes : byte[])
+ : Result =
+ try
+ Ok (JsonSerializer.Deserialize(bytes, serializerOptions))
+ with
+ | :? InvalidWebsocketMessageException as ex -> Result.Error (InvalidMessage (4400, ex.Message))
+ | :? JsonException -> Result.Error invalidJsonInClientMessageError
+
+let private sendMessage (serializerOptions : JsonSerializerOptions) (webSocket : WebSocket) (message : ServerMessage) : Async = async {
+ let bytes =
+ message
+ |> serializeServerMessage serializerOptions
+ |> Encoding.UTF8.GetBytes
+ let! _ = webSocket.send Text (ArraySegment bytes) true
+ ()
+}
+
+let private closeSocket (webSocket : WebSocket) (code : int) (reason : string) : Async = async {
+ let codeBytes =
+ let bytes = BitConverter.GetBytes (uint16 code)
+ if BitConverter.IsLittleEndian then
+ Array.rev bytes
+ else
+ bytes
+
+ let payload = Array.append codeBytes (Encoding.UTF8.GetBytes reason)
+ let! _ = webSocket.send Close (ArraySegment payload) true
+ ()
+}
+
+let private closeSocketNormally (webSocket : WebSocket) = closeSocket webSocket CloseCode.CLOSE_NORMAL.code "Normal Closure"
+
+/// Awaits the given computation, giving up (and returning ) after the given timeout has elapsed.
+let private withTimeout (timeout : TimeSpan) (computation : Async<'T>) : Async<'T option> = async {
+ let! child = Async.StartChild (computation, int timeout.TotalMilliseconds)
+
+ try
+ let! result = child
+ return Some result
+ with :? TimeoutException ->
+ return None
+}
+
+[]
+type private RawFrame =
+ | FrameData of byte[]
+ | FrameClosed
+
+let rec private readFullFrame (webSocket : WebSocket) (acc : byte[] list) : Async = async {
+ let! frame = webSocket.read ()
+
+ match frame with
+ | Choice1Of2 (Close, _, _) -> return FrameClosed
+ | Choice1Of2 (Ping, _, _)
+ | Choice1Of2 (Pong, _, _) -> return! readFullFrame webSocket acc
+ | Choice1Of2 (_, data, fin) ->
+ let acc = data :: acc
+ if fin then
+ return FrameData (acc |> List.rev |> Array.concat)
+ else
+ return! readFullFrame webSocket acc
+ | Choice2Of2 _error -> return FrameClosed
+}
+
+type private ReceivedMessage =
+ | ClientMsg of ClientMessage
+ | ProtocolFailure of ClientMessageProtocolFailure
+ | NoOp
+ | SocketClosed
+
+let private receiveMessage (serializerOptions : JsonSerializerOptions) (webSocket : WebSocket) : Async = async {
+ let! frame = readFullFrame webSocket []
+
+ match frame with
+ | FrameClosed -> return SocketClosed
+ | FrameData bytes when bytes.Length = 0 -> return NoOp
+ | FrameData bytes ->
+ match deserializeClientMessage serializerOptions bytes with
+ | Ok msg -> return ClientMsg msg
+ | Result.Error failure -> return ProtocolFailure failure
+}
+
+/// Waits for a connection_init message and acknowledges it, per the graphql-transport-ws protocol.
+let private waitForConnectionInitAndRespondToClient (options : GraphQLOptions<'Root>) (webSocket : WebSocket) : Async> = async {
+ let! received =
+ withTimeout options.WebsocketOptions.ConnectionInitTimeout (receiveMessage options.SerializerOptions webSocket)
+
+ match received with
+ | None ->
+ do! closeSocket webSocket CustomWebSocketStatus.ConnectionTimeout "Connection initialization timeout"
+ return Result.Error $"{nameof ConnectionInit} timeout"
+ | Some (ClientMsg (ConnectionInit _)) ->
+ do! sendMessage options.SerializerOptions webSocket ConnectionAck
+ return Ok ()
+ | Some (ClientMsg (Subscribe _)) ->
+ do! closeSocket webSocket CustomWebSocketStatus.Unauthorized "Unauthorized"
+ return Result.Error "Unauthorized"
+ | Some (ProtocolFailure (InvalidMessage (code, explanation))) ->
+ do! closeSocket webSocket code explanation
+ return Result.Error explanation
+ | Some _ ->
+ do! closeSocketNormally webSocket
+ return Result.Error $"{nameof ConnectionInit} failed (not because of timeout)"
+}
+
+///
+/// Converts an exception raised by a subscription's source into the errors of its terminal error message,
+/// exposing only messages of GraphQL-facing errors.
+///
+let private problemDetailsOfSourceError (ex : exn) : GQLProblemDetails list =
+ match box ex with
+ | :? IGQLError as error -> [ GQLProblemDetails.OfError error ]
+ | _ -> [ GQLProblemDetails.Create "Unexpected error during subscription" ]
+
+/// Runs the graphql-transport-ws message loop for an already-initialized connection.
+let private handleMessages (options : GraphQLOptions<'Root>) (ctx : HttpContext) (webSocket : WebSocket) : Async =
+ let serializerOptions = options.SerializerOptions
+ let subscriptions = GraphQLSubscriptionsManagement.createSubscriptions ()
+ let sendMsg msg = sendMessage serializerOptions webSocket msg
+
+ let sendOutput id (output : SubscriptionExecutionResult) = sendMsg (Next (id, output))
+
+ let sendSubscriptionResponseOutput id subscriptionResult =
+ match subscriptionResult with
+ | SubscriptionResult output -> sendOutput id (SubscriptionExecutionResult.Create (ValueSome output, []))
+ // The executor may still have resolved partial data alongside the field errors; it is forwarded as-is
+ | SubscriptionErrors (ValueSome output, errors) -> sendOutput id (SubscriptionExecutionResult.Create (ValueSome output, errors))
+ | SubscriptionErrors (ValueNone, errors) -> sendOutput id (SubscriptionExecutionResult.CreateErrors errors)
+
+ let addClientSubscription
+ (id : SubscriptionId)
+ (howToSendDataOnNext : SubscriptionId -> 'ResponseContent -> Async)
+ (streamSource : IObservable<'ResponseContent>)
+ =
+ // Set once the source has ended, so that a source ending synchronously inside `Subscribe` does not leave its id
+ // registered after the end has already tried to remove it
+ let mutable ended = false
+
+ let endSubscription (message : ServerMessage) =
+ ended <- true
+ sendMsg message |> Async.RunSynchronously
+ subscriptions
+ |> GraphQLSubscriptionsManagement.removeSubscription id
+
+ let observer = {
+ new IObserver<'ResponseContent> with
+ member _.OnNext value = howToSendDataOnNext id value |> Async.RunSynchronously
+ member _.OnError ex =
+ ctx.runtime.logger.info (Suave.Logging.Message.eventX $"Error on subscription with Id = '{id}': {ex}")
+ endSubscription (ServerError (id, problemDetailsOfSourceError ex))
+ member _.OnCompleted () = endSubscription (Complete id)
+ }
+
+ let unsubscriber = streamSource.Subscribe observer
+ lock subscriptions (fun () ->
+ if ended then
+ unsubscriber.Dispose ()
+ else
+ subscriptions
+ |> GraphQLSubscriptionsManagement.addSubscription (id, unsubscriber))
+
+ let applyPlanExecutionResult (id : SubscriptionId) (executionResult : GQLExecutionResult) = async {
+ match executionResult.Content with
+ | Stream observableOutput ->
+ observableOutput
+ |> addClientSubscription id sendSubscriptionResponseOutput
+ | Deferred (data, errors, _deferred) ->
+ // TODO: deliver the deferred and streamed payloads in the incremental delivery format, as the ASP.NET Core
+ // integration does through its `IncrementalDelivery`; until then only the initial result is sent
+ do! sendOutput id (SubscriptionExecutionResult.Create (ValueSome data, errors))
+ do! sendMsg (Complete id)
+ | Direct (data, errors) ->
+ // An execution result, whose data is null when a non-null root field failed during execution; still a
+ // result, so it is sent as Next + Complete like any other, not as the terminal Error
+ do! sendOutput id (SubscriptionExecutionResult.Create (data, errors))
+ // The graphql-transport-ws protocol requires Complete after the single Next of a query or mutation
+ do! sendMsg (Complete id)
+ | RequestError problemDetails ->
+ // The request was rejected before execution, so it is not a result: the protocol requires it to be sent
+ // as the terminal Error message instead of a Next followed by Complete
+ do! sendMsg (ServerError (id, problemDetails))
+ }
+
+ let handleSubscribe (id : SubscriptionId) (query : GQLRequestContent) = async {
+ if subscriptions |> GraphQLSubscriptionsManagement.isIdTaken id then
+ do! closeSocket webSocket CustomWebSocketStatus.SubscriberAlreadyExists $"Subscriber for Id = '{id}' already exists"
+ else
+ try
+ let variables = query.Variables |> Skippable.toValueOption
+ let getInputContext () = SuaveInputExecutionContext (ctx.request) :> IInputExecutionContext
+ let root = options.RootFactory ctx
+ let! planExecutionResult =
+ options.SchemaExecutor.AsyncExecute (query.Query, getInputContext, root, ?variables = variables)
+ do! applyPlanExecutionResult id planExecutionResult
+ with ex ->
+ ctx.runtime.logger.info (Suave.Logging.Message.eventX $"Unexpected error during subscription with id '{id}': {ex}")
+ do! sendMsg (ServerError (id, [ GQLProblemDetails.Create "Unexpected error during subscription" ]))
+ }
+
+ let rec loop () = async {
+ let! received = receiveMessage serializerOptions webSocket
+
+ match received with
+ | SocketClosed -> return ()
+ | NoOp -> return! loop ()
+ | ProtocolFailure (InvalidMessage (code, explanation)) ->
+ do! closeSocket webSocket code explanation
+ return ()
+ | ClientMsg (ConnectionInit _) ->
+ do! closeSocket webSocket CustomWebSocketStatus.TooManyInitializationRequests "Too many initialization requests"
+ return ()
+ | ClientMsg (ClientPing p) ->
+ match options.WebsocketOptions.CustomPingHandler with
+ | ValueSome handler ->
+ let! customP = handler p
+ do! sendMsg (ServerPong customP)
+ | ValueNone -> do! sendMsg (ServerPong p)
+ return! loop ()
+ | ClientMsg (ClientPong _) -> return! loop ()
+ | ClientMsg (Subscribe (id, query)) ->
+ do! handleSubscribe id query
+ return! loop ()
+ | ClientMsg (ClientComplete id) ->
+ subscriptions
+ |> GraphQLSubscriptionsManagement.removeSubscription id
+ return! loop ()
+ }
+
+ async {
+ try
+ do! loop ()
+ finally
+ subscriptions
+ |> GraphQLSubscriptionsManagement.removeAllSubscriptions
+ }
+
+/// A that accepts graphql-transport-ws WebSocket connections and handles
+/// GraphQL subscriptions (as well as queries and mutations) over them.
+let handleGraphQLWebSocket (options : GraphQLOptions<'Root>) : WebPart =
+ fun (ctx : HttpContext) ->
+ let continuation (webSocket : WebSocket) (ctx : HttpContext) : SocketOp =
+ SocketOp.ofAsync (
+ async {
+ let! initResult = waitForConnectionInitAndRespondToClient options webSocket
+
+ match initResult with
+ | Result.Error _ -> ()
+ | Ok () -> do! handleMessages options ctx webSocket
+ }
+ )
+
+ handShakeWithSubprotocol (chooseSubprotocol Subprotocol) continuation ctx
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/README.md b/src/FSharp.Data.GraphQL.Server.Suave/README.md
new file mode 100644
index 00000000..67093e6b
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/README.md
@@ -0,0 +1,77 @@
+## Usage
+
+### Server
+
+```fsharp
+open Suave
+open FSharp.Data.GraphQL.Server.Suave
+
+// Factory for object holding request-wide info. You define Root somewhere else.
+let rootFactory (_ctx : Http.HttpContext) : Root =
+ { RequestId = System.Guid.NewGuid().ToString() }
+
+// --> Schema.executor is defined by yourself somewhere else (in another file)
+let app : WebPart = GraphQL.graphQL Schema.executor rootFactory
+
+startWebServer defaultConfig app
+```
+
+This single `WebPart` serves GraphQL queries, mutations and introspection for every `GET`/`POST` request it
+receives (including the GraphQL multipart request specification for file uploads), and accepts
+`graphql-transport-ws` WebSocket connections (used for subscriptions) at `/ws` by default. Compose it with Suave's
+usual routing combinators (`path`, `pathStarts`, `choose`, ...) the way you would any other `WebPart` – just make
+sure the WebSocket endpoint configured in `GraphQLOptions.WebsocketOptions.EndpointUrl` (an absolute path) is
+still reachable through whatever routing you put in front of it.
+
+To customize the WebSocket endpoint path, the connection init timeout, a custom `ping`/`pong` handler or the
+JSON serializer options, build a `GraphQLOptions<'Root>` and call `GraphQL.graphQLWithOptions` instead:
+
+```fsharp
+let defaultOptions = GraphQLOptions.create Schema.executor rootFactory
+
+let options =
+ { defaultOptions with
+ WebsocketOptions = { defaultOptions.WebsocketOptions with EndpointUrl = "/subscriptions" } }
+
+let app : WebPart = GraphQL.graphQLWithOptions options
+```
+
+In your schema, you'll want to define a subscription, like in (example taken from the star-wars-api sample in the "samples/" folder):
+
+```fsharp
+ let Subscription =
+ Define.SubscriptionObject(
+ name = "Subscription",
+ fields = [
+ Define.SubscriptionField(
+ "watchMoon",
+ RootType,
+ PlanetType,
+ "Watches to see if a planet is a moon.",
+ [ Define.Input("id", StringType) ],
+ (fun ctx _ p -> if ctx.Arg("id") = p.Id then Some p else None)) ])
+```
+
+Don't forget to notify subscribers about new values:
+
+```fsharp
+ let Mutation =
+ Define.Object(
+ name = "Mutation",
+ fields = [
+ Define.Field(
+ "setMoon",
+ Nullable PlanetType,
+ "Defines if a planet is actually a moon or not.",
+ [ Define.Input("id", StringType); Define.Input("isMoon", BooleanType) ],
+ fun ctx _ ->
+ getPlanet (ctx.Arg("id"))
+ |> Option.map (fun x ->
+ x.SetMoon(Some(ctx.Arg("isMoon"))) |> ignore
+ schemaConfig.SubscriptionProvider.Publish "watchMoon" x // here you notify the subscribers upon a mutation
+ x))])
+```
+
+### Client
+
+Using your favorite (or not :)) client library (e.g.: [Apollo Client](https://www.apollographql.com/docs/react/get-started), [Relay](https://relay.dev), [Strawberry Shake](https://chillicream.com/docs/strawberryshake/v13), [elm-graphql](https://github.com/dillonkearns/elm-graphql) ❤️), just point to your server's address (`localhost:8080` for `defaultConfig`, as per the example above) and, as long as the client implements the `graphql-transport-ws` subprotocol, subscriptions should work.
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/RequestParsing.fs b/src/FSharp.Data.GraphQL.Server.Suave/RequestParsing.fs
new file mode 100644
index 00000000..cf894146
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/RequestParsing.fs
@@ -0,0 +1,85 @@
+module internal FSharp.Data.GraphQL.Server.Suave.RequestParsing
+
+open System.Text
+open System.Text.Json
+open System.Text.Json.Serialization
+open FsToolkit.ErrorHandling
+open Suave.Http
+
+open FSharp.Data.GraphQL.Shared
+
+module ServerAst = FSharp.Data.GraphQL.Server.Ast
+
+/// Reads the raw GraphQL request body, honoring the multipart request specification
+/// (the "operations" field carries the GraphQL request when the request is multipart).
+let private hasBody (request : HttpRequest) =
+ request.rawForm.Length > 0
+ || not request.multiPartFields.IsEmpty
+ || not request.files.IsEmpty
+
+let private tryBindGQLRequestContent (serializerOptions : JsonSerializerOptions) (request : HttpRequest) : Result =
+ let json =
+ if request.multiPartFields.IsEmpty then
+ Encoding.UTF8.GetString request.rawForm
+ else
+ request.multiPartFields
+ |> List.tryFind (fst >> (=) "operations")
+ |> Option.map snd
+ |> Option.defaultValue "{}"
+
+ try
+ Ok (JsonSerializer.Deserialize(json, serializerOptions))
+ with :? JsonException as ex ->
+ Error
+ $"Expected JSON similar to value in '%s{GQLRequestContent.expectedJSON}', but could not parse the received request body. \
+ Error: %s{ex.Message}"
+
+let private parseOrError (query : string) =
+ query
+ |> FSharp.Data.GraphQL.Parser.tryParse
+ |> Result.mapError (fun errorMessage -> $"Cannot parse GraphQL query: %s{errorMessage}")
+
+let private checkAnonymousFieldsOnly (serializerOptions : JsonSerializerOptions) (request : HttpRequest) : Result = result {
+ let! gqlRequest = tryBindGQLRequestContent serializerOptions request
+ let! ast = parseOrError gqlRequest.Query
+ let operationName = gqlRequest.OperationName |> Skippable.toValueOption
+
+ let createParsedContent () : ParsedGQLQueryRequestContent = {
+ Query = gqlRequest.Query
+ Ast = ast
+ OperationName = gqlRequest.OperationName
+ Variables = gqlRequest.Variables
+ }
+
+ if ast.IsEmpty then
+ return IntrospectionQuery ValueNone
+ else
+ match ServerAst.tryFindOperationByName operationName ast with
+ | None -> return IntrospectionQuery ValueNone
+ | Some op ->
+ if
+ op.OperationType
+ <> FSharp.Data.GraphQL.Ast.OperationType.Query
+ then
+ return createParsedContent () |> OperationQuery
+ else
+ let hasNonMetaFields = ServerAst.containsFieldsBeyond ServerAst.metaTypeFields ignore ignore op
+
+ if hasNonMetaFields then
+ return createParsedContent () |> OperationQuery
+ else
+ return IntrospectionQuery (ValueSome ast)
+}
+
+///
+/// Checks if the request is an introspection query by first checking on such properties as
+/// GET method or an empty request body, and lastly by parsing the document AST for an
+/// introspection operation definition.
+///
+let checkOperationType (serializerOptions : JsonSerializerOptions) (request : HttpRequest) : Result =
+ if request.method = HttpMethod.GET then
+ Ok (IntrospectionQuery ValueNone)
+ elif not (hasBody request) then
+ Ok (IntrospectionQuery ValueNone)
+ else
+ checkAnonymousFieldsOnly serializerOptions request
diff --git a/src/FSharp.Data.GraphQL.Server.Suave/SuaveInputExecutionContext.fs b/src/FSharp.Data.GraphQL.Server.Suave/SuaveInputExecutionContext.fs
new file mode 100644
index 00000000..e3e77c97
--- /dev/null
+++ b/src/FSharp.Data.GraphQL.Server.Suave/SuaveInputExecutionContext.fs
@@ -0,0 +1,30 @@
+namespace FSharp.Data.GraphQL.Server.Suave
+
+open System.IO
+open Suave.Http
+
+open FSharp.Data.GraphQL
+
+///
+/// implementation that resolves uploaded files from a Suave ,
+/// following the GraphQL multipart request specification.
+///
+type SuaveInputExecutionContext (request : HttpRequest) =
+
+ interface IInputExecutionContext with
+
+ member _.GetFile (key) =
+ match
+ request.files
+ |> List.tryFind (fun file -> file.fieldName = key)
+ with
+ | Some file ->
+ try
+ let memoryStream = new MemoryStream ()
+ use fileStream = File.OpenRead file.tempFilePath
+ fileStream.CopyTo memoryStream
+ memoryStream.Seek (0L, SeekOrigin.Begin) |> ignore
+ Ok { FileName = file.fileName; Stream = memoryStream; ContentType = file.mimeType }
+ with ex ->
+ Error ex.Message
+ | None -> Error $"File with key '%s{key}' not found"
diff --git a/src/FSharp.Data.GraphQL.Server/DefineExtensions.fs b/src/FSharp.Data.GraphQL.Server/DefineExtensions.fs
index 621188c3..f37623db 100644
--- a/src/FSharp.Data.GraphQL.Server/DefineExtensions.fs
+++ b/src/FSharp.Data.GraphQL.Server/DefineExtensions.fs
@@ -14,7 +14,7 @@ module DefineExtensions =
/// The schema post-compile sub-middleware function.
/// The operation planning sub-middleware function.
/// The operation execution sub-middleware function.
- static member ExecutorMiddleware(?compile, ?postCompile, ?plan, ?execute) : IExecutorMiddleware =
+ static member ExecutorMiddleware([] ?compile, [] ?postCompile, [] ?plan, [] ?execute) : IExecutorMiddleware =
{ new IExecutorMiddleware with
member _.CompileSchema = compile
member _.PostCompileSchema = postCompile
diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs
index 5fe48700..eb54f0e4 100644
--- a/src/FSharp.Data.GraphQL.Server/Executor.fs
+++ b/src/FSharp.Data.GraphQL.Server/Executor.fs
@@ -42,16 +42,16 @@ type OperationExecutionMiddleware =
/// A middleware can have one to three sub-middlewares, one for each phase of the query execution.
type IExecutorMiddleware =
/// Defines the sub-middleware that intercepts the schema compile process of the Executor.
- abstract CompileSchema : SchemaCompileMiddleware option
+ abstract CompileSchema : SchemaCompileMiddleware voption
/// Defines the sub-middleware that executes after the schema compilation phase of the Executor is complete.
- abstract PostCompileSchema : SchemaPostCompileMiddleware option
+ abstract PostCompileSchema : SchemaPostCompileMiddleware voption
/// Defines the sub-middleware that intercepts the operation planning phase of the Executor.
- abstract PlanOperation : OperationPlanningMiddleware option
+ abstract PlanOperation : OperationPlanningMiddleware voption
/// Defines the sub-middleware that intercepts the operation execution phase of the Executor.
- abstract ExecuteOperationAsync : OperationExecutionMiddleware option
+ abstract ExecuteOperationAsync : OperationExecutionMiddleware voption
/// A simple, concrete implementation for the IExecutorMiddleware interface.
-type ExecutorMiddleware(?compile, ?postCompile, ?plan, ?execute) =
+type ExecutorMiddleware([] ?compile, [] ?postCompile, [] ?plan, [] ?execute) =
interface IExecutorMiddleware with
member _.CompileSchema = compile
member _.PostCompileSchema = postCompile
@@ -77,7 +77,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
let middlewaresList = Seq.toList middlewares
- let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) option)
+ let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) voption)
(initialCtx : 'ctx)
(onComplete : 'ctx -> 'res)
: 'res =
@@ -86,8 +86,8 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
| [] -> onComplete ctx
| m :: ms ->
match (phaseSel m) with
- | Some f -> f ctx (fun ctx' -> go ctx' ms)
- | None -> go ctx ms
+ | ValueSome f -> f ctx (fun ctx' -> go ctx' ms)
+ | ValueNone -> go ctx ms
go initialCtx middlewaresList
do
@@ -124,7 +124,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
FieldExecuteMap = fieldExecuteMap
Metadata = executionPlan.Metadata }
let executorMiddlewareFunc = fun (executorMiddleware : IExecutorMiddleware) ->
- executorMiddleware.ExecuteOperationAsync |> Option.map (fun middleware -> middleware(getInputContext))
+ executorMiddleware.ExecuteOperationAsync |> ValueOption.map (fun middleware -> middleware(getInputContext))
let! res = runMiddlewares executorMiddlewareFunc executionCtx executeOperation |> AsyncVal.toAsync
return prepareOutput res
with
diff --git a/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj b/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj
index c409a5d0..2be8baa1 100644
--- a/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj
+++ b/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj
@@ -18,6 +18,7 @@
+
diff --git a/src/FSharp.Data.GraphQL.Server/Schema.fs b/src/FSharp.Data.GraphQL.Server/Schema.fs
index 93503639..8f9c87d1 100644
--- a/src/FSharp.Data.GraphQL.Server/Schema.fs
+++ b/src/FSharp.Data.GraphQL.Server/Schema.fs
@@ -169,12 +169,9 @@ type SchemaConfig =
Directives = [ IncludeDirective; SkipDirective; DeferDirective; streamDirective; LiveDirective ] }
/// GraphQL server schema. Defines the complete type system to be used by GraphQL queries.
-type Schema<'Root> (query: ObjectDef<'Root>, [] ?mutation: ObjectDef<'Root>, [] ?subscription: SubscriptionObjectDef<'Root>, ?config: SchemaConfig) =
+type Schema<'Root> (query: ObjectDef<'Root>, [] ?mutation: ObjectDef<'Root>, [] ?subscription: SubscriptionObjectDef<'Root>, [] ?config: SchemaConfig) =
- let schemaConfig =
- match config with
- | None -> SchemaConfig.Default
- | Some c -> c
+ let schemaConfig = defaultValueArg config SchemaConfig.Default
let typeMap : TypeMap =
diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutorMiddlewareTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutorMiddlewareTests.fs
index 77aece82..0bd6ea8f 100644
--- a/tests/FSharp.Data.GraphQL.Tests/ExecutorMiddlewareTests.fs
+++ b/tests/FSharp.Data.GraphQL.Tests/ExecutorMiddlewareTests.fs
@@ -99,10 +99,10 @@ let executionMiddleware (inputContext : InputExecutionContextProvider) (ctx : Ex
let middleware =
{ new IExecutorMiddleware with
- member _.CompileSchema = Some compileMiddleware
- member _.PostCompileSchema = Some postCompileMiddleware
- member _.PlanOperation = Some planningMiddleware
- member _.ExecuteOperationAsync = Some executionMiddleware }
+ member _.CompileSchema = ValueSome compileMiddleware
+ member _.PostCompileSchema = ValueSome postCompileMiddleware
+ member _.PlanOperation = ValueSome planningMiddleware
+ member _.ExecuteOperationAsync = ValueSome executionMiddleware }
let executor = Executor(schema, [ middleware ])