Skip to content

Commit 787a2a7

Browse files
Copilotxperiandri
andcommitted
Add Suave GraphQL server package
Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/1f4b9b83-930c-43b3-a8a5-8cf795269b9d Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>
1 parent 66b0df3 commit 787a2a7

8 files changed

Lines changed: 432 additions & 0 deletions

File tree

FSharp.Data.GraphQL.Integration.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<Project Path="src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj" />
1919
<Project Path="src/FSharp.Data.GraphQL.Server.Oxpecker/FSharp.Data.GraphQL.Server.Oxpecker.fsproj" />
2020
<Project Path="src/FSharp.Data.GraphQL.Server.Relay/FSharp.Data.GraphQL.Server.Relay.fsproj" />
21+
<Project Path="src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj" />
2122
<Project Path="src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj" />
2223
<Project Path="src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj" />
2324
</Folder>

FSharp.Data.GraphQL.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
<Project Path="src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj" />
123123
<Project Path="src/FSharp.Data.GraphQL.Server.Oxpecker/FSharp.Data.GraphQL.Server.Oxpecker.fsproj" />
124124
<Project Path="src/FSharp.Data.GraphQL.Server.Relay/FSharp.Data.GraphQL.Server.Relay.fsproj" />
125+
<Project Path="src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj" />
125126
<Project Path="src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj" />
126127
<Project Path="src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj" />
127128
</Folder>

Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<PackageReference Update="Microsoft.NETCore.Platforms" Version="$(SystemVersion)" />
2727
<PackageReference Update="NuGet.CommandLine" Version="1.*" />
2828
<PackageReference Update="Oxpecker" Version="2.*" />
29+
<PackageReference Update="Suave" Version="3.3.0" NoWarn="NU1608" />
2930
<PackageReference Update="System.CodeDom" Version="$(SystemVersion)" />
3031
<PackageReference Update="System.Collections.Immutable" Version="$(SystemVersion)" />
3132
<PackageReference Update="System.Diagnostics.DiagnosticSource" Version="$(SystemVersion)" />

build/Program.fs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,9 @@ Target.create "PackServerGiraffe" <| fun _ -> pack "Server.Giraffe"
317317
let [<Literal>] PackServerOxpecker = "PackServerOxpecker"
318318
Target.create "PackServerOxpecker" <| fun _ -> pack "Server.Oxpecker"
319319

320+
let [<Literal>] PackServerSuave = "PackServerSuave"
321+
Target.create "PackServerSuave" <| fun _ -> pack "Server.Suave"
322+
320323
let [<Literal>] PackClientTarget = "PackClient"
321324
Target.create PackClientTarget <| fun _ -> pack "Client"
322325

@@ -341,6 +344,9 @@ Target.create "PushServerGiraffe" <| fun _ -> push "Server.Giraffe"
341344
let [<Literal>] PushServerOxpecker = "PushServerOxpecker"
342345
Target.create "PushServerOxpecker" <| fun _ -> push "Server.Oxpecker"
343346

347+
let [<Literal>] PushServerSuave = "PushServerSuave"
348+
Target.create "PushServerSuave" <| fun _ -> push "Server.Suave"
349+
344350
let [<Literal>] PushClientTarget = "PushClient"
345351
Target.create PushClientTarget <| fun _ -> push "Client"
346352

@@ -380,6 +386,8 @@ PackSharedTarget
380386
==> PushServerGiraffe
381387
==> PackServerOxpecker
382388
==> PushServerOxpecker
389+
==> PackServerSuave
390+
==> PushServerSuave
383391
==> PackMiddlewareTarget
384392
==> PushMiddlewareTarget
385393
==> PackRelayTarget
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>$(DotNetVersion)</TargetFrameworks>
5+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
6+
<Deterministic>true</Deterministic>
7+
<Description>FSharp implementation of Facebook GraphQL query language (Suave integration)</Description>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<Compile Include="HttpHandlers.fs" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Condition="$(IsNuGet) == ''" Include="..\FSharp.Data.GraphQL.Shared\FSharp.Data.GraphQL.Shared.fsproj" />
16+
<ProjectReference Condition="$(IsNuGet) == ''" Include="..\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj" />
17+
<PackageReference Condition="$(IsNuGet) != ''" Include="FSharp.Data.GraphQL.Shared" VersionOverride="$(Version)" />
18+
<PackageReference Condition="$(IsNuGet) != ''" Include="FSharp.Data.GraphQL.Server" VersionOverride="$(Version)" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<PackageReference Include="Suave" NoWarn="NU1608" />
23+
</ItemGroup>
24+
25+
</Project>
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
namespace FSharp.Data.GraphQL.Server.Suave
2+
3+
open System
4+
open System.IO
5+
open System.Net.Mime
6+
open System.Text
7+
open System.Text.Json
8+
open System.Text.Json.Serialization
9+
10+
open Suave
11+
open Suave.Filters
12+
open Suave.Http
13+
open Suave.Operators
14+
open Suave.RequestErrors
15+
open Suave.Successful
16+
open Suave.Writers
17+
18+
open FSharp.Data.GraphQL
19+
open FSharp.Data.GraphQL.Server
20+
open FSharp.Data.GraphQL.Shared
21+
22+
type private SuaveRequestExecutionContext (httpContext : HttpContext) =
23+
24+
interface IInputExecutionContext with
25+
26+
member _.GetFile key =
27+
match httpContext.request.files |> Seq.tryFind (fun file -> file.fieldName = key) with
28+
| Some file ->
29+
use source = File.OpenRead file.tempFilePath
30+
let stream = new MemoryStream ()
31+
source.CopyTo stream
32+
stream.Seek (0L, SeekOrigin.Begin) |> ignore
33+
34+
Ok {
35+
FileName = file.fileName
36+
Stream = stream
37+
ContentType = file.mimeType
38+
}
39+
| None -> Result.Error $"File with key '{key}' not found"
40+
41+
module HttpHandlers =
42+
43+
let private jsonMimeType = "application/json; charset=utf-8"
44+
let private problemJsonMimeType = "application/problem+json; charset=utf-8"
45+
let private serializerOptions = Shared.Json.getSerializerOptions Seq.empty
46+
47+
let private toResponse { DocumentId = documentId; Content = content } =
48+
match content with
49+
| Direct (data, errs) -> GQLResponse.Direct (documentId, data, errs)
50+
| Deferred (data, errs, _) -> GQLResponse.Direct (documentId, data, errs)
51+
| Stream _ -> GQLResponse.Stream documentId
52+
| RequestError errs -> GQLResponse.RequestError (documentId, errs)
53+
54+
let private okJson (payload : string) =
55+
setMimeType jsonMimeType
56+
>=> ok (Encoding.UTF8.GetBytes payload)
57+
58+
let private badRequestJson (payload : string) =
59+
setMimeType problemJsonMimeType
60+
>=> bad_request (Encoding.UTF8.GetBytes payload)
61+
62+
let private problemDetails title detail instance =
63+
JsonSerializer.Serialize (
64+
{| title = title
65+
detail = detail
66+
status = 400
67+
instance = instance |},
68+
serializerOptions
69+
)
70+
71+
let private isMultipartRequest (request : HttpRequest) =
72+
request.headers
73+
|> Seq.exists (fun (key, value) ->
74+
String.Equals (key, "Content-Type", StringComparison.OrdinalIgnoreCase)
75+
&& value.Contains (MediaTypeNames.Multipart.FormData, StringComparison.OrdinalIgnoreCase)
76+
)
77+
78+
let private tryGetMultipartField name (request : HttpRequest) =
79+
request.multiPartFields
80+
|> Seq.tryPick (fun (fieldName, value) ->
81+
if String.Equals (fieldName, name, StringComparison.Ordinal) then
82+
Some value
83+
else
84+
None
85+
)
86+
87+
let private requestBody (request : HttpRequest) =
88+
if isMultipartRequest request then
89+
tryGetMultipartField "operations" request
90+
|> Option.defaultValue ""
91+
else
92+
request.rawForm
93+
|> Encoding.UTF8.GetString
94+
95+
let private operationNameAsValueOption (operationName : string Skippable) =
96+
match operationName with
97+
| Include value when not (isNull value) -> ValueSome value
98+
| _ -> ValueNone
99+
100+
let private operationNameAsOption (operationName : string Skippable) =
101+
match operationName with
102+
| Include value when not (isNull value) -> Some value
103+
| _ -> None
104+
105+
let private variablesAsOption (variables : _ Skippable) =
106+
match variables with
107+
| Include value when not (isNull value) -> Some value
108+
| _ -> None
109+
110+
let private executeIntrospectionQuery (executor : Executor<'Root>) (optionalAstDocument : Ast.Document voption) = task {
111+
let inputContext () : IInputExecutionContext =
112+
SuaveRequestExecutionContext (HttpContext.empty) :> IInputExecutionContext
113+
114+
let! result =
115+
match optionalAstDocument with
116+
| ValueSome ast -> executor.AsyncExecute (ast, inputContext) |> Async.StartAsTask
117+
| ValueNone -> executor.AsyncExecute (IntrospectionQuery.Definition, inputContext) |> Async.StartAsTask
118+
119+
let payload = result |> toResponse |> fun response -> JsonSerializer.Serialize (response, serializerOptions)
120+
return okJson payload
121+
}
122+
123+
let private executeOperation
124+
(executor : Executor<'Root>)
125+
(rootFactory : HttpContext -> 'Root)
126+
(httpContext : HttpContext)
127+
(content : ParsedGQLQueryRequestContent)
128+
=
129+
task {
130+
let root = rootFactory httpContext
131+
132+
let inputContext () : IInputExecutionContext =
133+
SuaveRequestExecutionContext (httpContext) :> IInputExecutionContext
134+
135+
let operationName = operationNameAsOption content.OperationName
136+
let variables = variablesAsOption content.Variables
137+
138+
let! result =
139+
executor.AsyncExecute (content.Ast, inputContext, root, ?variables = variables, ?operationName = operationName)
140+
|> Async.StartAsTask
141+
142+
let payload = result |> toResponse |> fun response -> JsonSerializer.Serialize (response, serializerOptions)
143+
return okJson payload
144+
}
145+
146+
let private checkOperationType (httpContext : HttpContext) =
147+
let request = httpContext.request
148+
149+
if request.method = HttpMethod.GET then
150+
Result.Ok (OperationType.IntrospectionQuery ValueNone)
151+
else
152+
let body = requestBody request
153+
154+
if String.IsNullOrWhiteSpace body then
155+
Result.Ok (OperationType.IntrospectionQuery ValueNone)
156+
else
157+
try
158+
let gqlRequest = JsonSerializer.Deserialize<GQLRequestContent> (body, serializerOptions)
159+
let ast = gqlRequest.Query |> Parser.tryParse |> Result.mapError (fun message -> problemDetails "Cannot parse GraphQL query" message request.path)
160+
161+
ast
162+
|> Result.map (fun ast ->
163+
let parsedContent () = {
164+
Query = gqlRequest.Query
165+
Ast = ast
166+
OperationName = gqlRequest.OperationName
167+
Variables = gqlRequest.Variables
168+
}
169+
170+
if ast.IsEmpty then
171+
OperationType.IntrospectionQuery ValueNone
172+
else
173+
match Ast.tryFindOperationByName (operationNameAsValueOption gqlRequest.OperationName) ast with
174+
| None -> OperationType.IntrospectionQuery ValueNone
175+
| Some _ -> parsedContent () |> OperationType.OperationQuery
176+
)
177+
with :? JsonException ->
178+
Result.Error (
179+
problemDetails
180+
"Invalid JSON body"
181+
$"Expected JSON similar to value in 'expected', but received value as in 'received': {body}"
182+
request.path
183+
)
184+
185+
let private handleGraphQL<'Root> (executor : Executor<'Root>) (rootFactory : HttpContext -> 'Root) (httpContext : HttpContext) =
186+
async {
187+
let! webPart =
188+
task {
189+
match checkOperationType httpContext with
190+
| Result.Error payload -> return badRequestJson payload
191+
| Result.Ok (OperationType.IntrospectionQuery optionalAstDocument) -> return! executeIntrospectionQuery executor optionalAstDocument
192+
| Result.Ok (OperationType.OperationQuery content) -> return! executeOperation executor rootFactory httpContext content
193+
}
194+
|> Async.AwaitTask
195+
196+
return! webPart httpContext
197+
}
198+
199+
/// <summary>
200+
/// Sets the <c>Request-Type</c> response header to <c>Multipart</c> for multipart form requests and <c>Classic</c> otherwise.
201+
/// </summary>
202+
let setRequestType : WebPart =
203+
fun httpContext ->
204+
let requestType =
205+
if isMultipartRequest httpContext.request then
206+
"Multipart"
207+
else
208+
"Classic"
209+
210+
setHeader "Request-Type" requestType httpContext
211+
212+
/// <summary>
213+
/// Creates a Suave <see cref="T:Suave.WebPart.WebPart`1" /> that handles GraphQL GET and POST requests.
214+
/// </summary>
215+
/// <param name="executor">The GraphQL executor.</param>
216+
/// <param name="rootFactory">Creates the GraphQL root object from the current Suave HTTP context.</param>
217+
let graphQL<'Root> (executor : Executor<'Root>) (rootFactory : HttpContext -> 'Root) : WebPart =
218+
choose [ Filters.GET; Filters.POST ] >=> handleGraphQL executor rootFactory

tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
<Compile Include="AspNetCore/TestSchema.fs" />
8787
<Compile Include="AspNetCore/InvalidMessageTests.fs" />
8888
<Compile Include="AspNetCore/SerializationTests.fs" />
89+
<Compile Include="Suave/HttpHandlersTests.fs" />
8990
<Compile Include="Program.fs" />
9091
</ItemGroup>
9192

@@ -99,5 +100,6 @@
99100
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj" />
100101
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server.AspNetCore\FSharp.Data.GraphQL.Server.AspNetCore.fsproj" />
101102
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server.Middleware\FSharp.Data.GraphQL.Server.Middleware.fsproj" />
103+
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server.Suave\FSharp.Data.GraphQL.Server.Suave.fsproj" />
102104
</ItemGroup>
103105
</Project>

0 commit comments

Comments
 (0)