Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
66b743b
Migrate integration tests to in-process hosts via `WebApplicationFact…
Copilot May 17, 2026
1506a22
Enforce input/output kind safety for `ListOf`/`Nullable` wrappers at …
Copilot May 17, 2026
075018b
Pin CI workflows and build script to .NET SDK 10.0.300 (#578)
Copilot May 17, 2026
19d11c3
Pin CI workflows and build script to .NET SDK 10.0.301
xperiandri Jun 13, 2026
cc5fc0f
Fixed Repo Assist incorrectly encoded characters
xperiandri Jun 13, 2026
50b4569
Bump lodash and babel-cli in /samples/graphiql-client (#584)
dependabot[bot] Jun 13, 2026
d8aba70
Publish CI test results into PR discussions, harden artifact download…
Copilot Jun 13, 2026
45046d7
Fixed NuGet packaging of DesignTime DLL for F# provider
xperiandri Jun 13, 2026
e643b8d
Updated `FSharp.TypeProviders.SDK` to `8.10.0`
xperiandri Jun 13, 2026
bdd7297
fixup! Updated `FSharp.TypeProviders.SDK` to `8.10.0`
xperiandri Jun 14, 2026
15df50a
High severity vulnerability fix: upgrade `SQLitePCLRaw.lib.e_sqlite3`…
Copilot Jul 8, 2026
c1e8510
Support case-insensitive comparison with `ObjectListFilter` (#582)
Copilot Jul 9, 2026
2d94c67
`ObjectListFilter` filter values to target type coercion (#589)
xperiandri Jul 9, 2026
454a9e0
Migrated to modern `InternalsVisibleTo` syntax
xperiandri Jul 16, 2026
8e34237
Implemented support of `Guid` and value object scalars as `InputValue`
xperiandri Jul 17, 2026
78d21c1
Implemented tests for `ObjectListFilter` with empty arrays
xperiandri Aug 9, 2026
060fd26
Updated SDK to `10.0.302`
xperiandri Aug 9, 2026
d971701
Updated `Fantomas` to `7.0.5`
xperiandri Aug 9, 2026
eada5c2
Updated `FSDocs-Tools` to `22.1.0`
xperiandri Aug 9, 2026
b9ce3ad
Implemented robust interface field covariance validation & tests
xperiandri Aug 9, 2026
be74f88
Bump @protobufjs/utf8 from 1.1.0 to 1.1.1 in /samples/client-provider…
dependabot[bot] Aug 9, 2026
c072f8e
Bump brace-expansion from 1.1.14 to 1.1.18 in /samples/graphiql-clien…
dependabot[bot] Aug 10, 2026
7de55d4
Bump ws from 7.5.7 to 7.5.13 in /samples/client-provider/file-upload/…
dependabot[bot] Aug 11, 2026
e1626b6
Add Suave GraphQL server package
Copilot May 17, 2026
d019699
Polish Suave integration tests
Copilot May 17, 2026
c4cfcc3
Fix Suave handler XML docs
Copilot May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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>
  • Loading branch information
Copilot and xperiandri committed Aug 11, 2026
commit e1626b601098a3dd7398d256bf852bd3bbb727e6
1 change: 1 addition & 0 deletions FSharp.Data.GraphQL.Integration.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<Project Path="src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server.Oxpecker/FSharp.Data.GraphQL.Server.Oxpecker.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server.Relay/FSharp.Data.GraphQL.Server.Relay.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj" />
</Folder>
Expand Down
1 change: 1 addition & 0 deletions FSharp.Data.GraphQL.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
<Project Path="src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server.Oxpecker/FSharp.Data.GraphQL.Server.Oxpecker.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server.Relay/FSharp.Data.GraphQL.Server.Relay.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server.Suave/FSharp.Data.GraphQL.Server.Suave.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj" />
<Project Path="src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj" />
</Folder>
Expand Down
1 change: 1 addition & 0 deletions Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<PackageReference Update="Microsoft.NETCore.Platforms" Version="$(SystemVersion)" />
<PackageReference Update="NuGet.CommandLine" Version="1.*" />
<PackageReference Update="Oxpecker" Version="2.*" />
<PackageReference Update="Suave" Version="3.3.0" NoWarn="NU1608" />
<PackageReference Update="System.CodeDom" Version="$(SystemVersion)" />
<PackageReference Update="System.Collections.Immutable" Version="$(SystemVersion)" />
<PackageReference Update="System.Diagnostics.DiagnosticSource" Version="$(SystemVersion)" />
Expand Down
8 changes: 8 additions & 0 deletions build/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@ Target.create "PackServerGiraffe" <| fun _ -> pack "Server.Giraffe"
let [<Literal>] PackServerOxpecker = "PackServerOxpecker"
Target.create "PackServerOxpecker" <| fun _ -> pack "Server.Oxpecker"

let [<Literal>] PackServerSuave = "PackServerSuave"
Target.create "PackServerSuave" <| fun _ -> pack "Server.Suave"

let [<Literal>] PackClientTarget = "PackClient"
Target.create PackClientTarget <| fun _ -> pack "Client"

Expand All @@ -364,6 +367,9 @@ Target.create "PushServerGiraffe" <| fun _ -> push "Server.Giraffe"
let [<Literal>] PushServerOxpecker = "PushServerOxpecker"
Target.create "PushServerOxpecker" <| fun _ -> push "Server.Oxpecker"

let [<Literal>] PushServerSuave = "PushServerSuave"
Target.create "PushServerSuave" <| fun _ -> push "Server.Suave"

let [<Literal>] PushClientTarget = "PushClient"
Target.create PushClientTarget <| fun _ -> push "Client"

Expand Down Expand Up @@ -403,6 +409,8 @@ PackSharedTarget
==> PushServerGiraffe
==> PackServerOxpecker
==> PushServerOxpecker
==> PackServerSuave
==> PushServerSuave
==> PackMiddlewareTarget
==> PushMiddlewareTarget
==> PackRelayTarget
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(DotNetVersion)</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Deterministic>true</Deterministic>
<Description>FSharp implementation of Facebook GraphQL query language (Suave integration)</Description>
</PropertyGroup>

<ItemGroup>
<Compile Include="HttpHandlers.fs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Condition="$(IsNuGet) == ''" Include="..\FSharp.Data.GraphQL.Shared\FSharp.Data.GraphQL.Shared.fsproj" />
<ProjectReference Condition="$(IsNuGet) == ''" Include="..\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj" />
<PackageReference Condition="$(IsNuGet) != ''" Include="FSharp.Data.GraphQL.Shared" VersionOverride="$(Version)" />
<PackageReference Condition="$(IsNuGet) != ''" Include="FSharp.Data.GraphQL.Server" VersionOverride="$(Version)" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Suave" NoWarn="NU1608" />
</ItemGroup>

</Project>
218 changes: 218 additions & 0 deletions src/FSharp.Data.GraphQL.Server.Suave/HttpHandlers.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
namespace FSharp.Data.GraphQL.Server.Suave

open System
open System.IO
open System.Net.Mime
open System.Text
open System.Text.Json
open System.Text.Json.Serialization

open Suave
open Suave.Filters
open Suave.Http
open Suave.Operators
open Suave.RequestErrors
open Suave.Successful
open Suave.Writers

open FSharp.Data.GraphQL
open FSharp.Data.GraphQL.Server
open FSharp.Data.GraphQL.Shared

type private SuaveRequestExecutionContext (httpContext : HttpContext) =

interface IInputExecutionContext with

member _.GetFile key =
match httpContext.request.files |> Seq.tryFind (fun file -> file.fieldName = key) with
| Some file ->
use source = File.OpenRead file.tempFilePath
let stream = new MemoryStream ()
source.CopyTo stream
stream.Seek (0L, SeekOrigin.Begin) |> ignore

Ok {
FileName = file.fileName
Stream = stream
ContentType = file.mimeType
}
| None -> Result.Error $"File with key '{key}' not found"

module HttpHandlers =

let private jsonMimeType = "application/json; charset=utf-8"
let private problemJsonMimeType = "application/problem+json; charset=utf-8"
let private serializerOptions = Shared.Json.getSerializerOptions Seq.empty

let private toResponse { DocumentId = documentId; Content = content } =
match content with
| Direct (data, errs) -> GQLResponse.Direct (documentId, data, errs)
| Deferred (data, errs, _) -> GQLResponse.Direct (documentId, data, errs)
| Stream _ -> GQLResponse.Stream documentId
| RequestError errs -> GQLResponse.RequestError (documentId, errs)

let private okJson (payload : string) =
setMimeType jsonMimeType
>=> ok (Encoding.UTF8.GetBytes payload)

let private badRequestJson (payload : string) =
setMimeType problemJsonMimeType
>=> bad_request (Encoding.UTF8.GetBytes payload)

let private problemDetails title detail instance =
JsonSerializer.Serialize (
{| title = title
detail = detail
status = 400
instance = instance |},
serializerOptions
)

let private isMultipartRequest (request : HttpRequest) =
request.headers
|> Seq.exists (fun (key, value) ->
String.Equals (key, "Content-Type", StringComparison.OrdinalIgnoreCase)
&& value.Contains (MediaTypeNames.Multipart.FormData, StringComparison.OrdinalIgnoreCase)
)

let private tryGetMultipartField name (request : HttpRequest) =
request.multiPartFields
|> Seq.tryPick (fun (fieldName, value) ->
if String.Equals (fieldName, name, StringComparison.Ordinal) then
Some value
else
None
)

let private requestBody (request : HttpRequest) =
if isMultipartRequest request then
tryGetMultipartField "operations" request
|> Option.defaultValue ""
else
request.rawForm
|> Encoding.UTF8.GetString

let private operationNameAsValueOption (operationName : string Skippable) =
match operationName with
| Include value when not (isNull value) -> ValueSome value
| _ -> ValueNone

let private operationNameAsOption (operationName : string Skippable) =
match operationName with
| Include value when not (isNull value) -> Some value
| _ -> None

let private variablesAsOption (variables : _ Skippable) =
match variables with
| Include value when not (isNull value) -> Some value
| _ -> None

let private executeIntrospectionQuery (executor : Executor<'Root>) (optionalAstDocument : Ast.Document voption) = task {
let inputContext () : IInputExecutionContext =
SuaveRequestExecutionContext (HttpContext.empty) :> IInputExecutionContext

let! result =
match optionalAstDocument with
| ValueSome ast -> executor.AsyncExecute (ast, inputContext) |> Async.StartAsTask
| ValueNone -> executor.AsyncExecute (IntrospectionQuery.Definition, inputContext) |> Async.StartAsTask

let payload = result |> toResponse |> fun response -> JsonSerializer.Serialize (response, serializerOptions)
return okJson payload
}

let private executeOperation
(executor : Executor<'Root>)
(rootFactory : HttpContext -> 'Root)
(httpContext : HttpContext)
(content : ParsedGQLQueryRequestContent)
=
task {
let root = rootFactory httpContext

let inputContext () : IInputExecutionContext =
SuaveRequestExecutionContext (httpContext) :> IInputExecutionContext

let operationName = operationNameAsOption content.OperationName
let variables = variablesAsOption content.Variables

let! result =
executor.AsyncExecute (content.Ast, inputContext, root, ?variables = variables, ?operationName = operationName)
|> Async.StartAsTask

let payload = result |> toResponse |> fun response -> JsonSerializer.Serialize (response, serializerOptions)
return okJson payload
}

let private checkOperationType (httpContext : HttpContext) =
let request = httpContext.request

if request.method = HttpMethod.GET then
Result.Ok (OperationType.IntrospectionQuery ValueNone)
else
let body = requestBody request

if String.IsNullOrWhiteSpace body then
Result.Ok (OperationType.IntrospectionQuery ValueNone)
else
try
let gqlRequest = JsonSerializer.Deserialize<GQLRequestContent> (body, serializerOptions)
let ast = gqlRequest.Query |> Parser.tryParse |> Result.mapError (fun message -> problemDetails "Cannot parse GraphQL query" message request.path)

ast
|> Result.map (fun ast ->
let parsedContent () = {
Query = gqlRequest.Query
Ast = ast
OperationName = gqlRequest.OperationName
Variables = gqlRequest.Variables
}

if ast.IsEmpty then
OperationType.IntrospectionQuery ValueNone
else
match Ast.tryFindOperationByName (operationNameAsValueOption gqlRequest.OperationName) ast with
| None -> OperationType.IntrospectionQuery ValueNone
| Some _ -> parsedContent () |> OperationType.OperationQuery
)
with :? JsonException ->
Result.Error (
problemDetails
"Invalid JSON body"
$"Expected JSON similar to value in 'expected', but received value as in 'received': {body}"
request.path
)

let private handleGraphQL<'Root> (executor : Executor<'Root>) (rootFactory : HttpContext -> 'Root) (httpContext : HttpContext) =
async {
let! webPart =
task {
match checkOperationType httpContext with
| Result.Error payload -> return badRequestJson payload
| Result.Ok (OperationType.IntrospectionQuery optionalAstDocument) -> return! executeIntrospectionQuery executor optionalAstDocument
| Result.Ok (OperationType.OperationQuery content) -> return! executeOperation executor rootFactory httpContext content
}
|> Async.AwaitTask

return! webPart httpContext
}

/// <summary>
/// Sets the <c>Request-Type</c> response header to <c>Multipart</c> for multipart form requests and <c>Classic</c> otherwise.
/// </summary>
let setRequestType : WebPart =
fun httpContext ->
let requestType =
if isMultipartRequest httpContext.request then
"Multipart"
else
"Classic"

setHeader "Request-Type" requestType httpContext

/// <summary>
/// Creates a Suave <see cref="T:Suave.WebPart.WebPart`1" /> that handles GraphQL GET and POST requests.
/// </summary>
/// <param name="executor">The GraphQL executor.</param>
/// <param name="rootFactory">Creates the GraphQL root object from the current Suave HTTP context.</param>
let graphQL<'Root> (executor : Executor<'Root>) (rootFactory : HttpContext -> 'Root) : WebPart =
choose [ Filters.GET; Filters.POST ] >=> handleGraphQL executor rootFactory
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
<Compile Include="AspNetCore/TestSchema.fs" />
<Compile Include="AspNetCore/InvalidMessageTests.fs" />
<Compile Include="AspNetCore/SerializationTests.fs" />
<Compile Include="Suave/HttpHandlersTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

Expand All @@ -107,5 +108,6 @@
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj" />
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server.AspNetCore\FSharp.Data.GraphQL.Server.AspNetCore.fsproj" />
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server.Middleware\FSharp.Data.GraphQL.Server.Middleware.fsproj" />
<ProjectReference Include="..\..\src\FSharp.Data.GraphQL.Server.Suave\FSharp.Data.GraphQL.Server.Suave.fsproj" />
</ItemGroup>
</Project>
Loading