Skip to content

Commit 7055c27

Browse files
committed
Consolidated a logic of GraphQL request handling into a single class
1 parent ce72220 commit 7055c27

File tree

9 files changed

+289
-514
lines changed

9 files changed

+289
-514
lines changed

samples/chat-app/server/Program.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ let main args =
2121
let builder = WebApplication.CreateBuilder (args)
2222
builder.Services
2323
.AddGiraffe()
24-
.AddGraphQLOptions<Root> (Schema.executor, rootFactory)
24+
.AddGraphQL<Root> (Schema.executor, rootFactory)
2525
|> ignore
2626

2727
let app = builder.Build ()

samples/star-wars-api/Startup.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type Startup private () =
2828
member _.ConfigureServices (services : IServiceCollection) =
2929
services
3030
.AddGiraffe()
31-
.AddGraphQLOptions<Root> (Schema.executor, rootFactory, configure = configure)
31+
.AddGraphQL<Root> (Schema.executor, rootFactory, configure = configure)
3232
|> ignore
3333

3434
member _.Configure

src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<Compile Include="GraphQLWebsocketMiddleware.fs" />
2424
<Compile Include="Parser.fs" />
2525
<Compile Include="HttpContext.fs" />
26+
<Compile Include="GraphQLRequest.fs" />
2627
<Compile Include="StartupExtensions.fs" />
2728
</ItemGroup>
2829

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
namespace FSharp.Data.GraphQL.Server.AspNetCore
2+
3+
open System
4+
open System.IO
5+
open System.Text.Json
6+
open System.Text.Json.Serialization
7+
open System.Threading.Tasks
8+
open Microsoft.AspNetCore.Http
9+
open Microsoft.Extensions.Logging
10+
open Microsoft.Extensions.Options
11+
12+
open FSharp.Data.GraphQL
13+
open FsToolkit.ErrorHandling
14+
15+
open FSharp.Data.GraphQL.Server
16+
17+
/// Provides logic to parse and execute GraphQL request
18+
type GraphQLRequest<'Root> (
19+
httpContextAccessor : IHttpContextAccessor,
20+
options : IOptionsMonitor<GraphQLOptions<'Root>>,
21+
logger : ILogger<GraphQLRequest<'Root>>
22+
) =
23+
24+
let ctx = httpContextAccessor.HttpContext
25+
26+
let toResponse { DocumentId = documentId; Content = content; Metadata = metadata } =
27+
28+
let serializeIndented value =
29+
let jsonSerializerOptions = options.Get(GraphQLOptions.IndentedOptionsName).SerializerOptions
30+
JsonSerializer.Serialize(value, jsonSerializerOptions)
31+
32+
match content with
33+
| Direct(data, errs) ->
34+
logger.LogDebug(
35+
$"Produced direct GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}",
36+
documentId,
37+
metadata
38+
)
39+
40+
if logger.IsEnabled LogLevel.Trace then
41+
logger.LogTrace($"GraphQL response data:\n:{{data}}", serializeIndented data)
42+
43+
GQLResponse.Direct(documentId, data, errs)
44+
| Deferred(data, errs, deferred) ->
45+
logger.LogDebug(
46+
$"Produced deferred GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}",
47+
documentId,
48+
metadata
49+
)
50+
51+
if logger.IsEnabled LogLevel.Debug then
52+
deferred
53+
|> Observable.add (function
54+
| DeferredResult(data, path) ->
55+
logger.LogDebug(
56+
"Produced GraphQL deferred result for path: {path}",
57+
path |> Seq.map string |> Seq.toArray |> Path.Join
58+
)
59+
60+
if logger.IsEnabled LogLevel.Trace then
61+
logger.LogTrace(
62+
$"GraphQL deferred data:\n{{data}}",
63+
serializeIndented data
64+
)
65+
| DeferredErrors(null, errors, path) ->
66+
logger.LogDebug(
67+
"Produced GraphQL deferred errors for path: {path}",
68+
path |> Seq.map string |> Seq.toArray |> Path.Join
69+
)
70+
71+
if logger.IsEnabled LogLevel.Trace then
72+
logger.LogTrace($"GraphQL deferred errors:\n{{errors}}", errors)
73+
| DeferredErrors(data, errors, path) ->
74+
logger.LogDebug(
75+
"Produced GraphQL deferred result with errors for path: {path}",
76+
path |> Seq.map string |> Seq.toArray |> Path.Join
77+
)
78+
79+
if logger.IsEnabled LogLevel.Trace then
80+
logger.LogTrace(
81+
$"GraphQL deferred errors:\n{{errors}}\nGraphQL deferred data:\n{{data}}",
82+
errors,
83+
serializeIndented data
84+
))
85+
86+
GQLResponse.Direct(documentId, data, errs)
87+
| Stream stream ->
88+
logger.LogDebug(
89+
$"Produced stream GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}",
90+
documentId,
91+
metadata
92+
)
93+
94+
if logger.IsEnabled LogLevel.Debug then
95+
stream
96+
|> Observable.add (function
97+
| SubscriptionResult data ->
98+
logger.LogDebug("Produced GraphQL subscription result")
99+
100+
if logger.IsEnabled LogLevel.Trace then
101+
logger.LogTrace(
102+
$"GraphQL subscription data:\n{{data}}",
103+
serializeIndented data
104+
)
105+
| SubscriptionErrors(null, errors) ->
106+
logger.LogDebug("Produced GraphQL subscription errors")
107+
108+
if logger.IsEnabled LogLevel.Trace then
109+
logger.LogTrace($"GraphQL subscription errors:\n{{errors}}", errors)
110+
| SubscriptionErrors(data, errors) ->
111+
logger.LogDebug("Produced GraphQL subscription result with errors")
112+
113+
if logger.IsEnabled LogLevel.Trace then
114+
logger.LogTrace(
115+
$"GraphQL subscription errors:\n{{errors}}\nGraphQL deferred data:\n{{data}}",
116+
errors,
117+
serializeIndented data
118+
))
119+
120+
GQLResponse.Stream documentId
121+
| RequestError errs ->
122+
logger.LogWarning(
123+
$"Produced request error GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}",
124+
documentId,
125+
metadata
126+
)
127+
128+
GQLResponse.RequestError(documentId, errs)
129+
130+
/// Checks if the request contains a body
131+
let checkIfHasBody (request: HttpRequest) = task {
132+
if request.Body.CanSeek then
133+
return (request.Body.Length > 0L)
134+
else
135+
request.EnableBuffering()
136+
let body = request.Body
137+
let buffer = Array.zeroCreate 1
138+
let! bytesRead = body.ReadAsync(buffer, 0, 1)
139+
body.Seek(0, SeekOrigin.Begin) |> ignore
140+
return bytesRead > 0
141+
}
142+
143+
/// Execute default or custom introspection query
144+
let executeIntrospectionQuery (executor: Executor<_>) (ast: Ast.Document voption) : Task<IResult> = task {
145+
let! result =
146+
match ast with
147+
| ValueNone -> executor.AsyncExecute IntrospectionQuery.Definition
148+
| ValueSome ast -> executor.AsyncExecute ast
149+
150+
let response = result |> toResponse
151+
return (TypedResults.Ok response) :> IResult
152+
}
153+
154+
/// <summary>Check if the request is an introspection query
155+
/// by first checking on such properties as `GET` method or `empty request body`
156+
/// and lastly by parsing document AST for introspection operation definition.
157+
/// </summary>
158+
/// <returns>Result of check of <see cref="OperationType"/></returns>
159+
let checkOperationType () = taskResult {
160+
161+
let checkAnonymousFieldsOnly (ctx: HttpContext) = taskResult {
162+
let! gqlRequest = ctx.TryBindJsonAsync<GQLRequestContent>(GQLRequestContent.expectedJSON)
163+
let! ast = Parser.parseOrIResult ctx.Request.Path.Value gqlRequest.Query
164+
let operationName = gqlRequest.OperationName |> Skippable.toOption
165+
166+
let createParsedContent() = {
167+
Query = gqlRequest.Query
168+
Ast = ast
169+
OperationName = gqlRequest.OperationName
170+
Variables = gqlRequest.Variables
171+
}
172+
if ast.IsEmpty then
173+
logger.LogTrace(
174+
"Request is not GET, but 'query' field is an empty string. Must be an introspection query"
175+
)
176+
return IntrospectionQuery <| ValueNone
177+
else
178+
match Ast.tryFindOperationByName operationName ast with
179+
| None ->
180+
logger.LogTrace "Document has no operation"
181+
return IntrospectionQuery <| ValueNone
182+
| Some op ->
183+
if not (op.OperationType = Ast.Query) then
184+
logger.LogTrace "Document operation is not of type Query"
185+
return createParsedContent () |> OperationQuery
186+
else
187+
let hasNonMetaFields =
188+
Ast.containsFieldsBeyond
189+
Ast.metaTypeFields
190+
(fun x ->
191+
logger.LogTrace($"Operation Selection in Field with name: {{fieldName}}", x.Name))
192+
(fun _ -> logger.LogTrace "Operation Selection is non-Field type")
193+
op
194+
195+
if hasNonMetaFields then
196+
return createParsedContent() |> OperationQuery
197+
else
198+
return IntrospectionQuery <| ValueSome ast
199+
}
200+
201+
let request = ctx.Request
202+
203+
if HttpMethods.Get = request.Method then
204+
logger.LogTrace("Request is GET. Must be an introspection query")
205+
return IntrospectionQuery <| ValueNone
206+
else
207+
let! hasBody = checkIfHasBody request
208+
209+
if not hasBody then
210+
logger.LogTrace("Request is not GET, but has no body. Must be an introspection query")
211+
return IntrospectionQuery <| ValueNone
212+
else
213+
return! checkAnonymousFieldsOnly ctx
214+
}
215+
216+
abstract ExecuteOperation<'Root> : Executor<'Root> -> ParsedGQLQueryRequestContent -> Task<IResult>
217+
218+
/// Execute the operation for given request
219+
default _.ExecuteOperation<'Root> (executor: Executor<'Root>) content = task {
220+
221+
let operationName = content.OperationName |> Skippable.filter (not << isNull) |> Skippable.toOption
222+
let variables = content.Variables |> Skippable.filter (not << isNull) |> Skippable.toOption
223+
224+
operationName
225+
|> Option.iter (fun on -> logger.LogTrace("GraphQL operation name: '{operationName}'", on))
226+
227+
logger.LogTrace($"Executing GraphQL query:\n{{query}}", content.Query)
228+
229+
variables
230+
|> Option.iter (fun v -> logger.LogTrace($"GraphQL variables:\n{{variables}}", v))
231+
232+
let root = options.CurrentValue.RootFactory ctx
233+
234+
let! result =
235+
Async.StartAsTask(
236+
executor.AsyncExecute(content.Ast, root, ?variables = variables, ?operationName = operationName),
237+
cancellationToken = ctx.RequestAborted
238+
)
239+
240+
let response = result |> toResponse
241+
return (TypedResults.Ok response) :> IResult
242+
}
243+
244+
member request.HandleAsync () : Task<Result<IResult, IResult>> = taskResult {
245+
if ctx.RequestAborted.IsCancellationRequested then
246+
return TypedResults.Empty
247+
else
248+
let executor = options.CurrentValue.SchemaExecutor
249+
match! checkOperationType () with
250+
| IntrospectionQuery optionalAstDocument -> return! executeIntrospectionQuery executor optionalAstDocument
251+
| OperationQuery content -> return! request.ExecuteOperation executor content
252+
}

0 commit comments

Comments
 (0)