Skip to content

Commit 38ecfd8

Browse files
author
Andrii Chebukin
committed
WIP Implemented GraphQL error handling using IGQLError instead of Exception and parsing variables as JsonElement
1 parent 21751b3 commit 38ecfd8

60 files changed

Lines changed: 2004 additions & 1715 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Packages.props‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<Project>
33
<PropertyGroup>
4+
<SystemVersion>7.0.*</SystemVersion>
45
<MicrosoftExtensionsVersion>7.0.*</MicrosoftExtensionsVersion>
56
<AspNetCoreVersion>7.0.*</AspNetCoreVersion>
67
<XUnitVersion>2.5.0</XUnitVersion>
@@ -12,11 +13,16 @@
1213
<PackageReference Include="FSharp.Core" Version="$(FSharpCoreVersion)">
1314
<ExcludeAssets>contentFiles</ExcludeAssets>
1415
</PackageReference>
16+
<PackageReference Update="FSharp.Control.FusionTasks" Version="2.6.*" />
17+
<PackageReference Update="FSharp.Control.Reactive" Version="5.0.*" />
18+
<PackageReference Update="FSharp.SystemTextJson" Version="1.*" />
1519
<PackageReference Update="Microsoft.Extensions.Http" Version="$(MicrosoftExtensionsVersion)" />
1620
<PackageReference Update="NuGet.CommandLine" Version="1.*" />
17-
<PackageReference Update="System.CodeDom" Version="6.*" />
21+
<PackageReference Update="System.CodeDom" Version="$(SystemVersion)" />
22+
<PackageReference Update="System.Collections.Immutable" Version="$(SystemVersion)" />
1823
<PackageReference Update="System.Management" Version="6.*" />
1924
<PackageReference Update="System.Reactive" Version="6.*" />
25+
<PackageReference Update="System.Text.Json" Version="$(SystemVersion)" />
2026
</ItemGroup>
2127
<ItemGroup Label="Build">
2228
<PackageReference Update="BlackFox.VsWhere" Version="1.1.0" />

‎samples/star-wars-api/FSharp.Data.GraphQL.Samples.StarWarsApi.fsproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
<ItemGroup>
1818
<None Include="ApplicationInsights.config" />
19+
<Compile Include="JSON.fs" />
1920
<Compile Include="Helpers.fs" />
2021
<Compile Include="Schema.fs" />
2122
<Compile Include="WebSocketMessages.fs" />

‎samples/star-wars-api/Helpers.fs‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@ namespace FSharp.Data.GraphQL.Samples.StarWarsApi
22

33
open System
44
open System.Text
5-
open Newtonsoft.Json
6-
open Newtonsoft.Json.Linq
7-
open Newtonsoft.Json.Serialization
85
open System.Collections.Generic
96

107
[<AutoOpen>]
118
module Helpers =
9+
1210
let tee f x =
1311
f x
1412
x
1513

1614
[<AutoOpen>]
1715
module StringHelpers =
16+
1817
let utf8String (bytes : byte seq) =
1918
bytes
2019
|> Seq.filter (fun i -> i > 0uy)
@@ -26,20 +25,21 @@ module StringHelpers =
2625
let isNullOrWhiteSpace (str : string) = String.IsNullOrWhiteSpace (str)
2726

2827
[<AutoOpen>]
29-
module JsonHelpers =
30-
let tryGetJsonProperty (jobj : JObject) prop =
31-
match jobj.Property (prop) with
32-
| null -> None
33-
| p -> Some (p.Value.ToString ())
34-
35-
let jsonSerializerSettings (converters : JsonConverter seq) =
36-
JsonSerializerSettings ()
37-
|> tee (fun s ->
38-
s.Converters <- List<JsonConverter> (converters)
39-
s.ContractResolver <- CamelCasePropertyNamesContractResolver ())
40-
41-
let jsonSerializer (converters : JsonConverter seq) =
42-
JsonSerializer ()
43-
|> tee (fun c ->
44-
Seq.iter c.Converters.Add converters
45-
c.ContractResolver <- CamelCasePropertyNamesContractResolver ())
28+
module LoggingHelpers =
29+
30+
open Microsoft.Extensions.DependencyInjection
31+
open Microsoft.Extensions.Logging
32+
33+
type IServiceProvider with
34+
member sericeProvider.CreateLogger (``type`` : Type) =
35+
let loggerFactory = sericeProvider.GetRequiredService<ILoggerFactory>()
36+
loggerFactory.CreateLogger(``type``)
37+
38+
[<AutoOpen>]
39+
module ReflectionHelpers =
40+
41+
open Microsoft.FSharp.Quotations.Patterns
42+
43+
let getModuleType = function
44+
| PropertyGet (_, propertyInfo, _) -> propertyInfo.DeclaringType
45+
| _ -> failwith "Expression is no property."
Lines changed: 162 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,180 @@
11
namespace FSharp.Data.GraphQL.Samples.StarWarsApi
22

3+
open System
4+
open System.Collections.Immutable
35
open System.IO
4-
open System.Text
5-
open Giraffe
6+
open System.Text.Json
7+
open System.Text.Json.Serialization
8+
open System.Threading.Tasks
69
open Microsoft.AspNetCore.Http
7-
open Newtonsoft.Json
8-
open Newtonsoft.Json.Linq
9-
open FSharp.Data.GraphQL.Execution
10+
open Microsoft.AspNetCore.Http.Json
11+
open Microsoft.Extensions.DependencyInjection
12+
open Microsoft.Extensions.Logging
13+
open Microsoft.Extensions.Options
1014
open FSharp.Data.GraphQL
1115
open FSharp.Data.GraphQL.Types
16+
open Giraffe
1217

1318
type HttpHandler = HttpFunc -> HttpContext -> HttpFuncResult
1419

15-
module HttpHandlers =
20+
type private GQLRequestContent =
21+
{ Query : string
22+
OperationName : Skippable<string>
23+
Variables : Skippable<ImmutableDictionary<string, JsonElement>> }
1624

17-
let private converters : JsonConverter[] = [| OptionConverter () |]
18-
let private jsonSettings = jsonSerializerSettings converters
25+
[<Struct>]
26+
type private OperationType =
27+
| IntrospectionQuery of introspection: string voption
28+
| OperationQuery of operation: GQLRequestContent
1929

20-
let internalServerError : HttpHandler = setStatusCode 500
30+
module HttpHandlers =
2131

22-
let okWithStr str : HttpHandler = setStatusCode 200 >=> text str
32+
let rec private moduleType = getModuleType <@ moduleType @>
2333

34+
let ofTaskIResult ctx (taskRes: Task<IResult>) : HttpFuncResult = task {
35+
let! res = taskRes
36+
do! res.ExecuteAsync(ctx)
37+
return Some ctx
38+
}
39+
40+
/// Set CORS to allow external servers (React samples) to call this API
2441
let setCorsHeaders : HttpHandler =
2542
setHttpHeader "Access-Control-Allow-Origin" "*"
2643
>=> setHttpHeader "Access-Control-Allow-Headers" "content-type"
2744

28-
let setContentTypeAsJson : HttpHandler = setHttpHeader "Content-Type" "application/json"
29-
30-
// TODO: Rewrite completely to async code
31-
let private graphQL (next : HttpFunc) (ctx : HttpContext) = task {
32-
let serialize d = JsonConvert.SerializeObject (d, jsonSettings)
33-
34-
let deserialize (data : string) =
35-
let getMap (token : JToken) =
36-
let rec mapper (name : string) (token : JToken) =
37-
match name, token.Type with
38-
| "variables", JTokenType.Object -> token.Children<JProperty> () |> Seq.map (fun x -> x.Name, mapper x.Name x.Value) |> Map.ofSeq |> box
39-
| name , JTokenType.Array -> token |> Seq.map (fun x -> mapper name x) |> Array.ofSeq |> box
40-
| _ -> (token :?> JValue).Value
41-
42-
token.Children<JProperty> ()
43-
|> Seq.map (fun x -> x.Name, mapper x.Name x.Value)
44-
|> Map.ofSeq
45-
46-
if System.String.IsNullOrWhiteSpace (data)
47-
then None
48-
else data |> JToken.Parse |> getMap |> Some
49-
50-
let json =
51-
function
52-
| Direct (data, _) ->
53-
JsonConvert.SerializeObject (data, jsonSettings)
54-
| Deferred (data, _, deferred) ->
55-
deferred |> Observable.add (fun d -> printfn "Deferred: %s" (serialize d))
56-
JsonConvert.SerializeObject (data, jsonSettings)
57-
| Stream data ->
58-
data |> Observable.add (fun d -> printfn "Subscription data: %s" (serialize d))
59-
"{}"
60-
61-
let removeWhitespacesAndLineBreaks (str : string) = str.Trim().Replace ("\r\n", " ")
62-
63-
let readStream (s : Stream) =
64-
use ms = new MemoryStream (4096)
65-
s.CopyTo (ms)
66-
ms.ToArray ()
67-
68-
let data = Encoding.UTF8.GetString (readStream ctx.Request.Body) |> deserialize
69-
70-
let query =
71-
data
72-
|> Option.bind (fun data ->
73-
if data.ContainsKey ("query")
74-
then
75-
match data.["query"] with
76-
| :? string as x -> Some x
77-
| _ -> failwith "Failure deserializing repsonse. Could not read query - it is not stringified in request."
78-
else
79-
None)
80-
81-
let variables =
82-
data
83-
|> Option.bind (fun data ->
84-
if data.ContainsKey ("variables")
85-
then
86-
match data.["variables"] with
87-
| null -> None
88-
| :? string as x -> deserialize x
89-
| :? Map<string, obj> as x -> Some x
90-
| _ -> failwith "Failure deserializing response. Could not read variables - it is not a object in the request."
45+
let private graphQL (next : HttpFunc) (ctx : HttpContext) =
46+
task {
47+
let logger = ctx.RequestServices.CreateLogger moduleType
48+
let jsonSerializerOptions = ctx.RequestServices.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions
49+
let request = ctx.Request
50+
51+
let isGet = request.Method = HttpMethods.Get
52+
53+
// TODO: validate the result
54+
/// Resolve response type and wrap it into an appropriate object
55+
let toResponse { DocumentId = documentId; Content = content; Metadata = metadata } =
56+
match content with
57+
| Direct (data, errs) ->
58+
logger.LogInformation($"Produced direct GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", documentId, metadata)
59+
if logger.IsEnabled LogLevel.Trace then
60+
logger.LogTrace($"GraphQL response data:{Environment.NewLine}:{{data}}", JsonSerializer.Serialize(data, jsonSerializerOptions))
61+
GQLResponse.Direct (documentId, data, errs)
62+
63+
| Deferred (data, errs, deferred) ->
64+
logger.LogInformation($"Produced deferred GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", documentId, metadata)
65+
if logger.IsEnabled LogLevel.Information then
66+
deferred |> Observable.add
67+
(function
68+
| DeferredResult (data, path) ->
69+
logger.LogInformation("Produced GraphQL deferred result for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join)
70+
if logger.IsEnabled LogLevel.Trace then
71+
logger.LogTrace($"GraphQL deferred data:{Environment.NewLine}{{data}}", (JsonSerializer.Serialize(data, jsonSerializerOptions)))
72+
| DeferredErrors (null, errors, path) ->
73+
logger.LogInformation("Produced GraphQL deferred errors for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join)
74+
if logger.IsEnabled LogLevel.Trace then
75+
logger.LogTrace($"GraphQL deferred errors:{Environment.NewLine}{{errors}}", errors)
76+
| DeferredErrors (data, errors, path) ->
77+
logger.LogInformation("Produced GraphQL deferred result with errors for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join)
78+
if logger.IsEnabled LogLevel.Trace then
79+
logger.LogTrace($"GraphQL deferred errors:{Environment.NewLine}{{errors}}{Environment.NewLine}GraphQL deferred data:{Environment.NewLine}{{data}}", errors, (JsonSerializer.Serialize(data, jsonSerializerOptions)))
80+
)
81+
GQLResponse.Direct (documentId, data, errs)
82+
83+
| Stream stream ->
84+
logger.LogInformation($"Produced stream GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", documentId, metadata)
85+
if logger.IsEnabled LogLevel.Information then
86+
stream |> Observable.add
87+
(function
88+
| SubscriptionResult data ->
89+
logger.LogInformation("Produced GraphQL subscription result")
90+
if logger.IsEnabled LogLevel.Trace then
91+
logger.LogTrace($"GraphQL subscription data:{Environment.NewLine}{{data}}", (JsonSerializer.Serialize(data, jsonSerializerOptions)))
92+
| SubscriptionErrors (null, errors) ->
93+
logger.LogInformation("Produced GraphQL subscription errors")
94+
if logger.IsEnabled LogLevel.Trace then
95+
logger.LogTrace($"GraphQL subscription errors:{Environment.NewLine}{{errors}}", errors)
96+
| SubscriptionErrors (data, errors) ->
97+
logger.LogInformation("Produced GraphQL subscription result with errors")
98+
if logger.IsEnabled LogLevel.Trace then
99+
logger.LogTrace($"GraphQL subscription errors:{Environment.NewLine}{{errors}}{Environment.NewLine}GraphQL deferred data:{Environment.NewLine}{{data}}", errors, (JsonSerializer.Serialize(data, jsonSerializerOptions)))
100+
)
101+
GQLResponse.Stream documentId
102+
103+
| RequestError errs ->
104+
logger.LogInformation(
105+
$"Produced request error GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}",
106+
documentId,
107+
metadata
108+
)
109+
GQLResponse.RequestError (documentId, errs)
110+
111+
let removeWhitespacesAndLineBreaks (str : string) = str.Trim().Replace ("\r\n", " ")
112+
113+
/// Check if the request contains body or not
114+
let checkIfHasBody () = task {
115+
match request.Body.CanSeek with
116+
| true -> return (request.Body.Length > 0L)
117+
| false ->
118+
// EnableBuffering allows us to read the Body even if it's been read already somewhere else.
119+
// See https://devblogs.microsoft.com/dotnet/re-reading-asp-net-core-request-bodies-with-enablebuffering/
120+
request.EnableBuffering()
121+
let body = request.Body
122+
let buffer = Array.zeroCreate 1
123+
let! bytesRead = body.ReadAsync(buffer, 0, 1)
124+
body.Seek(0, SeekOrigin.Begin) |> ignore
125+
return bytesRead > 0
126+
}
127+
128+
let detectIntrospectionQuery () = task {
129+
/// Check for the conditions that would make this an introspection query
130+
if isGet then return IntrospectionQuery ValueNone
91131
else
92-
None)
93-
94-
match query, variables with
95-
| Some query, Some variables ->
96-
printfn "Received query: %s" query
97-
printfn "Received variables: %A" variables
98-
let query = removeWhitespacesAndLineBreaks query
99-
let root = { RequestId = System.Guid.NewGuid().ToString () }
100-
let result = Schema.executor.AsyncExecute (query, root, variables) |> Async.RunSynchronously
101-
printfn "Result metadata: %A" result.Metadata
102-
return! okWithStr (json result) next ctx
103-
| Some query, None ->
104-
printfn "Received query: %s" query
105-
let query = removeWhitespacesAndLineBreaks query
106-
let result = Schema.executor.AsyncExecute (query) |> Async.RunSynchronously
107-
printfn "Result metadata: %A" result.Metadata
108-
return! okWithStr (json result) next ctx
109-
| None, _ ->
110-
let result = Schema.executor.AsyncExecute (Introspection.IntrospectionQuery) |> Async.RunSynchronously
111-
printfn "Result metadata: %A" result.Metadata
112-
return! okWithStr (json result) next ctx
113-
}
114-
115-
let webApp : HttpHandler = setCorsHeaders >=> graphQL >=> setContentTypeAsJson
132+
let! hasBody = checkIfHasBody()
133+
if not hasBody then return IntrospectionQuery ValueNone
134+
else
135+
let! request = ctx.BindJsonAsync<GQLRequestContent>()
136+
if Introspection.IntrospectionQuery.Contains request.Query
137+
then return ValueSome request.Query |> IntrospectionQuery
138+
else return OperationQuery request
139+
}
140+
141+
/// Execute default or custom introspection query
142+
let executeIntrospectionQuery (query: string voption) = task {
143+
let! result =
144+
match query with
145+
| ValueNone ->
146+
logger.LogInformation("Executing default GraphQL introspection query")
147+
Schema.executor.AsyncExecute(Introspection.IntrospectionQuery)
148+
| ValueSome query ->
149+
logger.LogInformation($"Executing GraphQL introspection query:{Environment.NewLine}{query}", query)
150+
Schema.executor.AsyncExecute query
151+
let response = result |> toResponse
152+
return Results.Ok response
153+
}
154+
155+
/// Execute the operation for given request
156+
let executeOperation request = task {
157+
// let! request = ctx.BindJsonAsync<GQLRequestContent>()
158+
let query = request.Query
159+
160+
logger.LogTrace($"Executing GraphQL query:{Environment.NewLine}{query}", query)
161+
let operationName = request.OperationName |> Skippable.toOption
162+
operationName |> Option.iter (fun on -> logger.LogTrace($"GraphQL operation name: '{operationName}'", on))
163+
164+
let variables = request.Variables |> Skippable.toOption
165+
variables |> Option.iter (fun vars -> logger.LogTrace($"GraphQL variables:{Environment.NewLine}{variables}", variables))
166+
167+
let root = { RequestId = System.Guid.NewGuid() |> string }
168+
let query = removeWhitespacesAndLineBreaks query
169+
let! result =
170+
Schema.executor.AsyncExecute (query, root, ?variables = variables, ?operationName = operationName)
171+
let response = result |> toResponse
172+
return Results.Ok response
173+
}
174+
175+
match! detectIntrospectionQuery() with
176+
| IntrospectionQuery query -> return! executeIntrospectionQuery query
177+
| OperationQuery gqlRequestContent -> return! executeOperation gqlRequestContent
178+
} |> ofTaskIResult ctx
179+
180+
let webApp : HttpHandler = setCorsHeaders >=> choose [ POST; GET ] >=> graphQL

0 commit comments

Comments
 (0)