From 2c86a7126b072c426aca90cffc50d23b23d29196 Mon Sep 17 00:00:00 2001 From: "Valber M. Silva de Souza" Date: Sat, 23 Mar 2024 19:41:21 +0100 Subject: [PATCH] FSharp.Data.GraphQL.Server.AspNetCore (#430) Adds ASP.NET Core DI integration using a Giraffe HttpHandler and a WebSocket protocol support Co-authored-by: Andrii Chebukin Co-authored-by: Andrii Chebukin --- .github/workflows/publish_ci.yml | 20 +- .github/workflows/publish_release.yml | 20 +- .gitignore | 5 +- Directory.Build.props | 2 +- FSharp.Data.GraphQL.Integration.sln | 47 +- FSharp.Data.GraphQL.sln | 57 + Packages.props | 1 + Prepare template project for packing.ps1 | 3 +- README.md | 4 + build/Program.fs | 8 + ...Sharp.Data.GraphQL.ProjectTemplates.fsproj | 3 +- .../client/TestData/schema-snapshot.json | 2575 +++++++++++++++++ .../client/TestData/subscription-example.json | 4 + samples/chat-app/server/DomainModel.fs | 93 + samples/chat-app/server/Exceptions.fs | 21 + ...FSharp.Data.GraphQL.Samples.ChatApp.fsproj | 27 + samples/chat-app/server/FakePersistence.fs | 35 + samples/chat-app/server/Program.fs | 44 + .../server/Properties/launchSettings.json | 40 + samples/chat-app/server/Schema.fs | 877 ++++++ .../server/appsettings.Development.json | 8 + samples/chat-app/server/appsettings.json | 9 + ...rp.Data.GraphQL.Samples.StarWarsApi.fsproj | 12 +- samples/star-wars-api/Program.fs | 74 +- samples/star-wars-api/Startup.fs | 69 +- .../star-wars-api/WebSocketJsonConverters.fs | 182 -- samples/star-wars-api/WebSocketMessages.fs | 34 - samples/star-wars-api/WebSocketMiddleware.fs | 167 -- .../Exceptions.fs | 4 + ...harp.Data.GraphQL.Server.AspNetCore.fsproj | 44 + .../GQLRequest.fs | 2 +- .../Giraffe}/Ast.fs | 2 +- .../Giraffe}/HttpContext.fs | 9 +- .../Giraffe}/HttpHandlers.fs | 133 +- .../Giraffe}/Parser.fs | 2 +- .../GraphQLOptions.fs | 30 + .../GraphQLSubscriptionsManagement.fs | 31 + .../GraphQLWebsocketMiddleware.fs | 361 +++ .../Helpers.fs | 2 +- .../Messages.fs | 46 + .../README.md | 99 + .../Serialization}/JSON.fs | 20 +- .../Serialization/JsonConverters.fs | 135 + .../StartupExtensions.fs | 74 + ...ata.GraphQL.IntegrationTests.Server.fsproj | 11 +- .../Startup.fs | 44 +- .../introspection.json | 2 +- .../AspNetCore/InvalidMessageTests.fs | 134 + .../AspNetCore/SerializationTests.fs | 110 + .../AspNetCore/TestSchema.fs | 282 ++ .../FSharp.Data.GraphQL.Tests/ErrorHelpers.fs | 6 +- .../ExecutionTests.fs | 4 +- .../FSharp.Data.GraphQL.Tests.fsproj | 5 +- tests/FSharp.Data.GraphQL.Tests/Helpers.fs | 5 +- .../IntrospectionTests.fs | 2 +- .../MiddlewareTests.fs | 2 +- .../MutationTests.fs | 6 +- .../Relay/CursorTests.fs | 2 +- .../Variables and Inputs/InputComplexTests.fs | 2 +- .../Variables and Inputs/InputEnumTests.fs | 2 +- .../Variables and Inputs/InputListTests.fs | 2 +- .../Variables and Inputs/InputNestedTests.fs | 2 +- .../InputNullableStringTests.fs | 2 +- .../InputObjectValidatorTests.fs | 4 +- .../Variables and Inputs/InputRecordTests.fs | 2 +- .../OptionalsNormalizationTests.fs | 2 +- 66 files changed, 5421 insertions(+), 647 deletions(-) create mode 100644 samples/chat-app/client/TestData/schema-snapshot.json create mode 100644 samples/chat-app/client/TestData/subscription-example.json create mode 100644 samples/chat-app/server/DomainModel.fs create mode 100644 samples/chat-app/server/Exceptions.fs create mode 100644 samples/chat-app/server/FSharp.Data.GraphQL.Samples.ChatApp.fsproj create mode 100644 samples/chat-app/server/FakePersistence.fs create mode 100644 samples/chat-app/server/Program.fs create mode 100644 samples/chat-app/server/Properties/launchSettings.json create mode 100644 samples/chat-app/server/Schema.fs create mode 100644 samples/chat-app/server/appsettings.Development.json create mode 100644 samples/chat-app/server/appsettings.json delete mode 100644 samples/star-wars-api/WebSocketJsonConverters.fs delete mode 100644 samples/star-wars-api/WebSocketMessages.fs delete mode 100644 samples/star-wars-api/WebSocketMiddleware.fs create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/Exceptions.fs create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore}/GQLRequest.fs (94%) rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe}/Ast.fs (95%) rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe}/HttpContext.fs (83%) rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe}/HttpHandlers.fs (69%) rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe}/Parser.fs (83%) create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLOptions.fs create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLSubscriptionsManagement.fs create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore}/Helpers.fs (95%) create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/Messages.fs create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/README.md rename {samples/star-wars-api => src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization}/JSON.fs (61%) create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JsonConverters.fs create mode 100644 src/FSharp.Data.GraphQL.Server.AspNetCore/StartupExtensions.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/AspNetCore/InvalidMessageTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/AspNetCore/SerializationTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/AspNetCore/TestSchema.fs diff --git a/.github/workflows/publish_ci.yml b/.github/workflows/publish_ci.yml index 5b029a20a..1ace85f8a 100644 --- a/.github/workflows/publish_ci.yml +++ b/.github/workflows/publish_ci.yml @@ -59,7 +59,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.Shared project run: | cd src/FSharp.Data.GraphQL.Shared - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Shared project to GitHub run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Shared.${{env.VERSION}}.nupkg -s "github.com" -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate @@ -67,7 +67,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.Client project run: | cd src/FSharp.Data.GraphQL.Client - dotnet pack --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Client project to GitHub run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Client.${{env.VERSION}}.nupkg -s "github.com" -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate @@ -75,15 +75,23 @@ jobs: - name: Pack FSharp.Data.GraphQL.Server project run: | cd src/FSharp.Data.GraphQL.Server - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Server project to GitHub run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Server.${{env.VERSION}}.nupkg -s "github.com" -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate + - name: Pack FSharp.Data.GraphQL.Server.AspNetCore project + run: | + cd src/FSharp.Data.GraphQL.Server.AspNetCore + dotnet pack --no-build --configuration Release /p:IsNuget=true /p:ContinuousIntegrationBuild=true -o ../../nuget + - name: Publish FSharp.Data.GraphQL.Server.AspNetCore project to GitHub + run: | + dotnet nuget push nuget/FSharp.Data.GraphQL.Server.AspNetCore.${{env.VERSION}}.nupkg -s "github.com" -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate + - name: Pack FSharp.Data.GraphQL.Server.Relay project run: | cd src/FSharp.Data.GraphQL.Server.Relay - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Server.Relay project to GitHub run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Server.Relay.${{env.VERSION}}.nupkg -s "github.com" -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate @@ -91,7 +99,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.Server.Middleware project run: | cd src/FSharp.Data.GraphQL.Server.Middleware - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Server.Middleware project to GitHub run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Server.Middleware.${{env.VERSION}}.nupkg -s "github.com" -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate @@ -104,7 +112,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.ProjectTemplates template project run: | cd samples - dotnet pack --nologo --configuration Release /p:ContinuousIntegrationBuild=true -o ../nuget + dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true -o ../nuget - name: Publish FSharp.Data.GraphQL.ProjectTemplates project to GitHub run: | $path = "nuget/FSharp.Data.GraphQL.ProjectTemplates.${{env.VERSION}}.nupkg" diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 71e1deb53..227fb7bf8 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -58,7 +58,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.Shared project run: | cd src/FSharp.Data.GraphQL.Shared - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Shared project to NuGet run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Shared.${{env.VERSION}}.{nupkg,snupkg} -s "nuget.org" -k ${{secrets.NUGET_SECRET}} --skip-duplicate @@ -66,7 +66,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.Client project run: | cd src/FSharp.Data.GraphQL.Client - dotnet pack --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Client project to NuGet run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Client.${{env.VERSION}}.{nupkg,snupkg} -s "nuget.org" -k ${{secrets.NUGET_SECRET}} --skip-duplicate @@ -74,15 +74,23 @@ jobs: - name: Pack FSharp.Data.GraphQL.Server project run: | cd src/FSharp.Data.GraphQL.Server - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Server project to NuGet run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Server.${{env.VERSION}}.{nupkg,snupkg} -s "nuget.org" -k ${{secrets.NUGET_SECRET}} --skip-duplicate + - name: Pack FSharp.Data.GraphQL.Server.AspNetCore project + run: | + cd src/FSharp.Data.GraphQL.Server.AspNetCore + dotnet pack --no-build --configuration Release /p:IsNuget=true /p:ContinuousIntegrationBuild=true -o ../../nuget + - name: Publish FSharp.Data.GraphQL.Server.AspNetCore project to NuGet + run: | + dotnet nuget push nuget/FSharp.Data.GraphQL.Server.AspNetCore.${{env.VERSION}}.{nupkg,snupkg} -s "nuget.org" -k ${{secrets.NUGET_SECRET}} --skip-duplicate + - name: Pack FSharp.Data.GraphQL.Server.Relay project run: | cd src/FSharp.Data.GraphQL.Server.Relay - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Server.Relay project to NuGet run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Server.Relay.${{env.VERSION}}.{nupkg,snupkg} -s "nuget.org" -k ${{secrets.NUGET_SECRET}} --skip-duplicate @@ -90,7 +98,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.Server.Middleware project run: | cd src/FSharp.Data.GraphQL.Server.Middleware - dotnet pack --no-build --nologo --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget + dotnet pack --no-build --configuration Release /p:IsNuGet=true /p:ContinuousIntegrationBuild=true -o ../../nuget - name: Publish FSharp.Data.GraphQL.Server.Middleware project to NuGet run: | dotnet nuget push nuget/FSharp.Data.GraphQL.Server.Middleware.${{env.VERSION}}.{nupkg,snupkg} -s "nuget.org" -k ${{secrets.NUGET_SECRET}} --skip-duplicate @@ -103,7 +111,7 @@ jobs: - name: Pack FSharp.Data.GraphQL.ProjectTemplates template project run: | cd samples - dotnet pack --nologo --configuration Release /p:ContinuousIntegrationBuild=true -o ../nuget + dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true -o ../nuget - name: Publish FSharp.Data.GraphQL.ProjectTemplates project to GitHub run: | $path = "nuget/FSharp.Data.GraphQL.ProjectTemplates.${{env.VERSION}}.nupkg" diff --git a/.gitignore b/.gitignore index 2afa2e272..3ab2b21a2 100644 --- a/.gitignore +++ b/.gitignore @@ -448,7 +448,4 @@ $RECYCLE.BIN/ ## Visual Studio Code ## .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json +*/**/.vscode/* diff --git a/Directory.Build.props b/Directory.Build.props index 829f6d106..8460f3cc5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ - John Bazinga, Andrii Chebukin, Jurii Chebukin, Ismael Carlos Velten, njlr, Garrett Birkel + John Bazinga, Andrii Chebukin, Jurii Chebukin, Ismael Carlos Velten, Valber M. Silva de Souza, njlr, Garrett Birkel FSharp.Data.GraphQL F# implementation of Facebook GraphQL query language diff --git a/FSharp.Data.GraphQL.Integration.sln b/FSharp.Data.GraphQL.Integration.sln index b0a6acbe7..8b44b9d95 100644 --- a/FSharp.Data.GraphQL.Integration.sln +++ b/FSharp.Data.GraphQL.Integration.sln @@ -8,17 +8,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Packages.props = Packages.props EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BA7F22E2-D411-4229-826B-F55FF171D12A}" +EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.IntegrationTests.Server", "tests\FSharp.Data.GraphQL.IntegrationTests.Server\FSharp.Data.GraphQL.IntegrationTests.Server.fsproj", "{E6754A20-FA5E-4C76-AB1B-D35DF9526889}" EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.IntegrationTests", "tests\FSharp.Data.GraphQL.IntegrationTests\FSharp.Data.GraphQL.IntegrationTests.fsproj", "{09D910E6-94EF-46AF-94DF-10A9FEC837C0}" EndProject -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Server", "src\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj", "{CA16AC10-9FF2-4894-AC73-99FBD35BB8CC}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BDE03396-2ED6-4153-B94C-351BAB3F67BD}" EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Shared", "src\FSharp.Data.GraphQL.Shared\FSharp.Data.GraphQL.Shared.fsproj", "{237F9575-6E65-40DD-A77B-BA2882BD5646}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BDE03396-2ED6-4153-B94C-351BAB3F67BD}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Server", "src\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj", "{CA16AC10-9FF2-4894-AC73-99FBD35BB8CC}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BA7F22E2-D411-4229-826B-F55FF171D12A}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Server.AspNetCore", "src\FSharp.Data.GraphQL.Server.AspNetCore\FSharp.Data.GraphQL.Server.AspNetCore.fsproj", "{9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -30,18 +32,6 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x64.ActiveCfg = Debug|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x64.Build.0 = Debug|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x86.ActiveCfg = Debug|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x86.Build.0 = Debug|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|Any CPU.Build.0 = Release|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x64.ActiveCfg = Release|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x64.Build.0 = Release|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x86.ActiveCfg = Release|Any CPU - {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x86.Build.0 = Release|Any CPU {E6754A20-FA5E-4C76-AB1B-D35DF9526889}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E6754A20-FA5E-4C76-AB1B-D35DF9526889}.Debug|Any CPU.Build.0 = Debug|Any CPU {E6754A20-FA5E-4C76-AB1B-D35DF9526889}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -54,6 +44,18 @@ Global {E6754A20-FA5E-4C76-AB1B-D35DF9526889}.Release|x64.Build.0 = Release|Any CPU {E6754A20-FA5E-4C76-AB1B-D35DF9526889}.Release|x86.ActiveCfg = Release|Any CPU {E6754A20-FA5E-4C76-AB1B-D35DF9526889}.Release|x86.Build.0 = Release|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x64.Build.0 = Debug|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Debug|x86.Build.0 = Debug|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|Any CPU.Build.0 = Release|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x64.ActiveCfg = Release|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x64.Build.0 = Release|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x86.ActiveCfg = Release|Any CPU + {09D910E6-94EF-46AF-94DF-10A9FEC837C0}.Release|x86.Build.0 = Release|Any CPU {CA16AC10-9FF2-4894-AC73-99FBD35BB8CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CA16AC10-9FF2-4894-AC73-99FBD35BB8CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {CA16AC10-9FF2-4894-AC73-99FBD35BB8CC}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -78,15 +80,28 @@ Global {237F9575-6E65-40DD-A77B-BA2882BD5646}.Release|x64.Build.0 = Release|Any CPU {237F9575-6E65-40DD-A77B-BA2882BD5646}.Release|x86.ActiveCfg = Release|Any CPU {237F9575-6E65-40DD-A77B-BA2882BD5646}.Release|x86.Build.0 = Release|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Debug|x64.Build.0 = Debug|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Debug|x86.Build.0 = Debug|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Release|Any CPU.Build.0 = Release|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Release|x64.ActiveCfg = Release|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Release|x64.Build.0 = Release|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Release|x86.ActiveCfg = Release|Any CPU + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {09D910E6-94EF-46AF-94DF-10A9FEC837C0} = {BA7F22E2-D411-4229-826B-F55FF171D12A} {E6754A20-FA5E-4C76-AB1B-D35DF9526889} = {BA7F22E2-D411-4229-826B-F55FF171D12A} + {09D910E6-94EF-46AF-94DF-10A9FEC837C0} = {BA7F22E2-D411-4229-826B-F55FF171D12A} {CA16AC10-9FF2-4894-AC73-99FBD35BB8CC} = {BDE03396-2ED6-4153-B94C-351BAB3F67BD} {237F9575-6E65-40DD-A77B-BA2882BD5646} = {BDE03396-2ED6-4153-B94C-351BAB3F67BD} + {9E795521-CC0E-4E9C-9DC1-66CFCC7A31C4} = {BDE03396-2ED6-4153-B94C-351BAB3F67BD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1B712506-56AA-424E-9DB7-47BCF3894516} diff --git a/FSharp.Data.GraphQL.sln b/FSharp.Data.GraphQL.sln index c8e60669f..0adc99af1 100644 --- a/FSharp.Data.GraphQL.sln +++ b/FSharp.Data.GraphQL.sln @@ -40,12 +40,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "star-wars-api", "star-wars- EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Samples.StarWarsApi", "samples\star-wars-api\FSharp.Data.GraphQL.Samples.StarWarsApi.fsproj", "{B837B3ED-83CE-446F-A4E5-44CB06AA6505}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "chat-app", "chat-app", "{24AB1F5A-4996-4DDA-87E0-B82B3A24C13F}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharp.Data.GraphQL.Samples.ChatApp", "samples\chat-app\server\FSharp.Data.GraphQL.Samples.ChatApp.fsproj", "{225B0790-C6B6-425C-9093-F359A4C635D3}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BEFD8748-2467-45F9-A4AD-B450B12D5F78}" EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Shared", "src\FSharp.Data.GraphQL.Shared\FSharp.Data.GraphQL.Shared.fsproj", "{6768EA38-1335-4B8E-BC09-CCDED1F9AAF6}" EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Server", "src\FSharp.Data.GraphQL.Server\FSharp.Data.GraphQL.Server.fsproj", "{474179D3-0090-49E9-88F8-2971C0966077}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharp.Data.GraphQL.Server.AspNetCore", "src\FSharp.Data.GraphQL.Server.AspNetCore\FSharp.Data.GraphQL.Server.AspNetCore.fsproj", "{554A6833-1E72-41B4-AAC1-C19371EC061B}" +EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Server.Relay", "src\FSharp.Data.GraphQL.Server.Relay\FSharp.Data.GraphQL.Server.Relay.fsproj", "{E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}" EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.GraphQL.Server.Middleware", "src\FSharp.Data.GraphQL.Server.Middleware\FSharp.Data.GraphQL.Server.Middleware.fsproj", "{8FB23F61-77CB-42C7-8EEC-B22D7C4E4067}" @@ -262,6 +268,42 @@ Global {F7858DA7-E067-486B-9E9C-697F0A56C620}.Release|x64.Build.0 = Release|Any CPU {F7858DA7-E067-486B-9E9C-697F0A56C620}.Release|x86.ActiveCfg = Release|Any CPU {F7858DA7-E067-486B-9E9C-697F0A56C620}.Release|x86.Build.0 = Release|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Debug|x64.ActiveCfg = Debug|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Debug|x64.Build.0 = Debug|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Debug|x86.ActiveCfg = Debug|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Debug|x86.Build.0 = Debug|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Release|Any CPU.Build.0 = Release|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Release|x64.ActiveCfg = Release|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Release|x64.Build.0 = Release|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Release|x86.ActiveCfg = Release|Any CPU + {B837B3ED-83CE-446F-A4E5-44CB06AA6505}.Release|x86.Build.0 = Release|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Debug|x64.Build.0 = Debug|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Debug|x86.Build.0 = Debug|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Release|Any CPU.Build.0 = Release|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Release|x64.ActiveCfg = Release|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Release|x64.Build.0 = Release|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Release|x86.ActiveCfg = Release|Any CPU + {E011A3B2-3D96-48E3-AF5F-DA544FF5C5FE}.Release|x86.Build.0 = Release|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Debug|x64.ActiveCfg = Debug|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Debug|x64.Build.0 = Debug|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Debug|x86.ActiveCfg = Debug|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Debug|x86.Build.0 = Debug|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Release|Any CPU.Build.0 = Release|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Release|x64.ActiveCfg = Release|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Release|x64.Build.0 = Release|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Release|x86.ActiveCfg = Release|Any CPU + {554A6833-1E72-41B4-AAC1-C19371EC061B}.Release|x86.Build.0 = Release|Any CPU {54AAFE43-FA5F-485A-AD40-0240165FC633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54AAFE43-FA5F-485A-AD40-0240165FC633}.Debug|Any CPU.Build.0 = Debug|Any CPU {54AAFE43-FA5F-485A-AD40-0240165FC633}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -286,6 +328,18 @@ Global {A6A162DF-9FBB-4C2A-913F-FD5FED35A09B}.Release|x64.Build.0 = Release|Any CPU {A6A162DF-9FBB-4C2A-913F-FD5FED35A09B}.Release|x86.ActiveCfg = Release|Any CPU {A6A162DF-9FBB-4C2A-913F-FD5FED35A09B}.Release|x86.Build.0 = Release|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Debug|x64.Build.0 = Debug|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Debug|x86.Build.0 = Debug|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Release|Any CPU.Build.0 = Release|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Release|x64.ActiveCfg = Release|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Release|x64.Build.0 = Release|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Release|x86.ActiveCfg = Release|Any CPU + {225B0790-C6B6-425C-9093-F359A4C635D3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -314,6 +368,9 @@ Global {A8F031E0-2BD5-4BAE-830A-60CBA76A047D} = {600D4BE2-FCE0-4684-AC6F-2DC829B395BA} {6EEA0E79-693F-4D4F-B55B-DB0C64EBDA45} = {600D4BE2-FCE0-4684-AC6F-2DC829B395BA} {7AA3516E-60F5-4969-878F-4E3DCF3E63A3} = {A8F031E0-2BD5-4BAE-830A-60CBA76A047D} + {554A6833-1E72-41B4-AAC1-C19371EC061B} = {BEFD8748-2467-45F9-A4AD-B450B12D5F78} + {24AB1F5A-4996-4DDA-87E0-B82B3A24C13F} = {B0C25450-74BF-40C2-9E02-09AADBAE2C2F} + {225B0790-C6B6-425C-9093-F359A4C635D3} = {24AB1F5A-4996-4DDA-87E0-B82B3A24C13F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C5B9895C-9DF8-4557-8D44-7D0C4C31F86E} diff --git a/Packages.props b/Packages.props index c60993330..1932e5cd7 100644 --- a/Packages.props +++ b/Packages.props @@ -19,6 +19,7 @@ + diff --git a/Prepare template project for packing.ps1 b/Prepare template project for packing.ps1 index e3abcd8de..7590ca361 100644 --- a/Prepare template project for packing.ps1 +++ b/Prepare template project for packing.ps1 @@ -9,6 +9,7 @@ $version = $dirBuildTargets.SelectSingleNode("//PropertyGroup[@Label='NuGet']/Ve [xml]$fsharpPackages = @" + @@ -18,8 +19,6 @@ $version = $dirBuildTargets.SelectSingleNode("//PropertyGroup[@Label='NuGet']/Ve $packagesPropsPath = "Packages.props" [xml]$packagesProps = Get-Content -Path $packagesPropsPath -$giraffeVersion = $packagesProps.SelectSingleNode("//PackageReference[@Update='Giraffe']/@Version") -$starWarsApiProj.SelectSingleNode("//ItemGroup[@Label='PackageReferences']/PackageReference[@Include='Giraffe']").SetAttribute("Version",$giraffeVersion.Value) $packageReferences = $starWarsApiProj.SelectSingleNode("//ItemGroup[@Label='PackageReferences']") foreach($packageReference in $fsharpPackages.DocumentElement.ChildNodes){ $innerNode = $starWarsApiProj.ImportNode($packageReference,$true) diff --git a/README.md b/README.md index a1adf0ccb..582add319 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ let reply = executor.AsyncExecute(Parser.parse "{ firstName, lastName }", johnSn It's type safe. Things like invalid fields or invalid return types will be checked at compile time. +### ASP.NET / Giraffe / WebSocket (for GraphQL subscriptions) usage + +See the [AspNetCore/README.md](src/FSharp.Data.GraphQL.Server.AspNetCore/README.md) + ## Demos ### GraphiQL client diff --git a/build/Program.fs b/build/Program.fs index 7592c2137..833ce9737 100644 --- a/build/Program.fs +++ b/build/Program.fs @@ -299,6 +299,9 @@ Target.create PackSharedTarget <| fun _ -> pack "Shared" let [] PackServerTarget = "PackServer" Target.create PackServerTarget <| fun _ -> pack "Server" +let [] PackServerAspNetCore = "PackServerAspNetCore" +Target.create "PackServerAspNetCore" <| fun _ -> pack "Server.AspNetCore" + let [] PackClientTarget = "PackClient" Target.create PackClientTarget <| fun _ -> pack "Client" @@ -314,6 +317,9 @@ Target.create PushSharedTarget <| fun _ -> push "Shared" let [] PushServerTarget = "PushServer" Target.create PushServerTarget <| fun _ -> push "Server" +let [] PushServerAspNetCore = "PushServerAspNetCore" +Target.create "PushServerAspNetCore" <| fun _ -> push "Server.AspNetCore" + let [] PushClientTarget = "PushClient" Target.create PushClientTarget <| fun _ -> push "Client" @@ -352,6 +358,8 @@ PackSharedTarget ==> PushClientTarget ==> PackServerTarget ==> PushServerTarget + ==> PackServerAspNetCore + ==> PushServerAspNetCore ==> PackMiddlewareTarget ==> PushMiddlewareTarget ==> PackRelayTarget diff --git a/samples/FSharp.Data.GraphQL.ProjectTemplates.fsproj b/samples/FSharp.Data.GraphQL.ProjectTemplates.fsproj index 0277ee20e..112f6a7d4 100644 --- a/samples/FSharp.Data.GraphQL.ProjectTemplates.fsproj +++ b/samples/FSharp.Data.GraphQL.ProjectTemplates.fsproj @@ -12,6 +12,7 @@ true false + false content $(NoWarn);NU5128 true @@ -22,4 +23,4 @@ - \ No newline at end of file + diff --git a/samples/chat-app/client/TestData/schema-snapshot.json b/samples/chat-app/client/TestData/schema-snapshot.json new file mode 100644 index 000000000..071304ace --- /dev/null +++ b/samples/chat-app/client/TestData/schema-snapshot.json @@ -0,0 +1,2575 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "Query" + }, + "mutationType": { + "name": "Mutation" + }, + "subscriptionType": { + "name": "Subscription" + }, + "types": [ + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Boolean", + "description": "The `Boolean` scalar type represents `true` or `false`.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ID", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Date", + "description": "The `Date` scalar type represents a Date value with Time component. The Date type appears in a JSON response as a String representation compatible with ISO-8601 format.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "URI", + "description": "The `URI` scalar type represents a string resource identifier compatible with URI standard. The URI type appears in a JSON response as a String.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "directives", + "description": "A list of all directives supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", + "fields": [ + { + "name": "args", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onField", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onFragment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onOperation", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", + "fields": [ + { + "name": "defaultValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum. Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", + "fields": [ + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "False" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "False" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", + "fields": [ + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", + "fields": [ + { + "name": "args", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given __Type is.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTION", + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Location adjacent to a field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Location adjacent to a schema IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Location adjacent to a scalar IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Location adjacent to an object IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Location adjacent to a field IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Location adjacent to a field argument IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Location adjacent to an interface IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Location adjacent to an union IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Location adjacent to an enum IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Location adjacent to an input object IDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Location adjacent to an input object field IDL definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "fields": [ + { + "name": "organizations", + "description": "gets all available organizations", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Organization", + "description": "An organization as seen from the outside", + "fields": [ + { + "name": "chatRooms", + "description": "chat rooms in this organization", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatRoom", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "the organization's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "members", + "description": "members of this organization", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Member", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the organization's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChatRoom", + "description": "A chat room as viewed from the outside", + "fields": [ + { + "name": "id", + "description": "the chat room's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "members", + "description": "the members in the chat room", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatMember", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the chat room's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Guid", + "description": "The `Guid` scalar type represents a Globaly Unique Identifier value. It's a 128-bit long byte key, that can be serialized to string.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChatMember", + "description": "A chat member is an organization member participating in a chat room", + "fields": [ + { + "name": "id", + "description": "the member's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the member's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role", + "description": "the member's role in the chat", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MemberRoleInChat", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MemberRoleInChat", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ChatAdmin", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ChatGuest", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Member", + "description": "An organization member", + "fields": [ + { + "name": "id", + "description": "the member's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the member's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Subscription", + "description": null, + "fields": [ + { + "name": "chatRoomEvents", + "description": "events related to a specific chat room", + "args": [ + { + "name": "chatRoomId", + "description": "the ID of the chat room to listen to events from", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatRoomEvent", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChatRoomEvent", + "description": "Something that happened in the chat room, like a new message sent", + "fields": [ + { + "name": "chatRoomId", + "description": "the ID of the chat room in which the event happened", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "specificData", + "description": "the event's specific data", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "ChatRoomSpecificEvent", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "time", + "description": "the time the message was received at the server", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "ChatRoomSpecificEvent", + "description": "data which is specific to a certain type of event", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "NewMessage", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "EditedMessage", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "DeletedMessage", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "MemberJoined", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "MemberLeft", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "NewMessage", + "description": "a new public message has been sent in the chat room", + "fields": [ + { + "name": "authorId", + "description": "the member ID of the message's author", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chatRoomId", + "description": "the ID of the chat room the message belongs to", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "date", + "description": "the time the message was received at the server", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "the message's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "text", + "description": "the message's text", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EditedMessage", + "description": "a public message of the chat room has been edited", + "fields": [ + { + "name": "authorId", + "description": "the member ID of the message's author", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chatRoomId", + "description": "the ID of the chat room the message belongs to", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "date", + "description": "the time the message was received at the server", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "the message's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "text", + "description": "the message's text", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DeletedMessage", + "description": "a public message of the chat room has been deleted", + "fields": [ + { + "name": "messageId", + "description": "this is the message ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MemberJoined", + "description": "a member has joined the chat", + "fields": [ + { + "name": "memberId", + "description": "this is the member's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "memberName", + "description": "this is the member's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MemberLeft", + "description": "a member has left the chat", + "fields": [ + { + "name": "memberId", + "description": "this is the member's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "memberName", + "description": "this is the member's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": null, + "fields": [ + { + "name": "createChatRoom", + "description": "creates a new chat room for a user", + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization in which the chat room will be created", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "name", + "description": "the chat room's name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatRoomForMember", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deleteChatMessage", + "description": null, + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization the chat room and member are in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "chatRoomId", + "description": "the chat room's ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "messageId", + "description": "the existing message's ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "editChatMessage", + "description": null, + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization the chat room and member are in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "chatRoomId", + "description": "the chat room's ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "messageId", + "description": "the existing message's ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "text", + "description": "the chat message's contents", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enterChatRoom", + "description": "makes a member enter a chat room", + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization the chat room and member are in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "chatRoomId", + "description": "the ID of the chat room", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatRoomForMember", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enterOrganization", + "description": "makes a new member enter an organization", + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "member", + "description": "the new member's name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "OrganizationForMember", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "leaveChatRoom", + "description": "makes a member leave a chat room", + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization the chat room and member are in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "chatRoomId", + "description": "the ID of the chat room", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sendChatMessage", + "description": null, + "args": [ + { + "name": "organizationId", + "description": "the ID of the organization the chat room and member are in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "chatRoomId", + "description": "the chat room's ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "memberId", + "description": "the member's private ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "text", + "description": "the chat message's contents", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChatRoomForMember", + "description": "A chat room as viewed by a chat room member", + "fields": [ + { + "name": "id", + "description": "the chat room's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "meAsAChatMember", + "description": "the chat member that queried the details", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MeAsAChatMember", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the chat room's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "otherChatMembers", + "description": "the chat members excluding the one who queried the details", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatMember", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MeAsAChatMember", + "description": "A chat member is an organization member participating in a chat room", + "fields": [ + { + "name": "id", + "description": "the member's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the member's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privId", + "description": "the member's private ID used for authenticating their requests", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role", + "description": "the member's role in the chat", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MemberRoleInChat", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "OrganizationForMember", + "description": "An organization as seen by one of the organization's members", + "fields": [ + { + "name": "chatRooms", + "description": "chat rooms in this organization", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChatRoom", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "the organization's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "meAsAMember", + "description": "the member that queried the details", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MeAsAMember", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the organization's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "otherMembers", + "description": "members of this organization", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Member", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MeAsAMember", + "description": "An organization member", + "fields": [ + { + "name": "id", + "description": "the member's ID", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "the member's name", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privId", + "description": "the member's private ID used for authenticating their requests", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Guid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } + ], + "directives": [ + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Skipped when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "defer", + "description": "Defers the resolution of this field or fragment", + "locations": [ + "FIELD", + "FRAGMENT_DEFINITION", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [] + }, + { + "name": "stream", + "description": "Streams the resolution of this field or fragment", + "locations": [ + "FIELD", + "FRAGMENT_DEFINITION", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [] + }, + { + "name": "live", + "description": "Subscribes for live updates of this field or fragment", + "locations": [ + "FIELD", + "FRAGMENT_DEFINITION", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [] + } + ] + } + }, + "documentId": -631396295 +} \ No newline at end of file diff --git a/samples/chat-app/client/TestData/subscription-example.json b/samples/chat-app/client/TestData/subscription-example.json new file mode 100644 index 000000000..b2ca0ce9b --- /dev/null +++ b/samples/chat-app/client/TestData/subscription-example.json @@ -0,0 +1,4 @@ +{ "id": "1", + "type": "subscribe", + "query": "subscription { chatRoomEvents (chatRoomId: \"123\", memberId: \"345\") { chatRoomId time specificData { __typename ... on MemberJoined { memberId memberName } ... on MemberLeft { memberId memberName } ... on NewMessage { id chatRoomId date authorId text } ... on EditedMessage { id chatRoomId date authorId text } ... on DeletedMessage { messageId } } } }" +} \ No newline at end of file diff --git a/samples/chat-app/server/DomainModel.fs b/samples/chat-app/server/DomainModel.fs new file mode 100644 index 000000000..5d88190a6 --- /dev/null +++ b/samples/chat-app/server/DomainModel.fs @@ -0,0 +1,93 @@ +namespace FSharp.Data.GraphQL.Samples.ChatApp + +open System + +// +// Common model +// +type OrganizationId = OrganizationId of Guid +type MemberId = MemberId of Guid +type MemberPrivateId = MemberPrivateId of Guid +type ChatRoomId = ChatRoomId of Guid +type MessageId = MessageId of Guid + +type MemberRoleInChat = + | ChatAdmin + | ChatGuest + +type ChatRoomMessage = { + Id : MessageId + ChatRoomId : ChatRoomId + Date : DateTime + AuthorId : MemberId + Text : string +} + +type ChatRoomSpecificEvent = + | NewMessage of ChatRoomMessage + | EditedMessage of ChatRoomMessage + | DeletedMessage of MessageId + | MemberJoined of MemberId * string + | MemberLeft of MemberId * string + +type ChatRoomEvent = { + ChatRoomId : ChatRoomId + Time : DateTime + SpecificData : ChatRoomSpecificEvent +} + +// +// Persistence model +// +type Member_In_Db = { PrivId : MemberPrivateId; Id : MemberId; Name : string } + +type ChatMember_In_Db = { ChatRoomId : ChatRoomId; MemberId : MemberId; Role : MemberRoleInChat } + +type ChatRoom_In_Db = { Id : ChatRoomId; Name : string; Members : MemberId list } + +type Organization_In_Db = { + Id : OrganizationId + Name : string + Members : MemberId list + ChatRooms : ChatRoomId list +} + +// +// GraphQL models +// +type Member = { Id : MemberId; Name : string } + +type MeAsAMember = { PrivId : MemberPrivateId; Id : MemberId; Name : string } + +type ChatMember = { Id : MemberId; Name : string; Role : MemberRoleInChat } + +type MeAsAChatMember = { + PrivId : MemberPrivateId + Id : MemberId + Name : string + Role : MemberRoleInChat +} + +type ChatRoom = { Id : ChatRoomId; Name : string; Members : ChatMember list } + +type ChatRoomForMember = { + Id : ChatRoomId + Name : string + MeAsAChatMember : MeAsAChatMember + OtherChatMembers : ChatMember list +} + +type Organization = { + Id : OrganizationId + Name : string + Members : Member list + ChatRooms : ChatRoom list +} + +type OrganizationForMember = { + Id : OrganizationId + Name : string + MeAsAMember : MeAsAMember + OtherMembers : Member list + ChatRooms : ChatRoom list +} diff --git a/samples/chat-app/server/Exceptions.fs b/samples/chat-app/server/Exceptions.fs new file mode 100644 index 000000000..674aac1fb --- /dev/null +++ b/samples/chat-app/server/Exceptions.fs @@ -0,0 +1,21 @@ +module FSharp.Data.GraphQL.Samples.ChatApp.Exceptions + +open FSharp.Data.GraphQL + +let Member_With_This_Name_Already_Exists (theName : string) : GraphQLException = + GQLMessageException $"member with name \"%s{theName}\" already exists" + +let Organization_Doesnt_Exist (theId : OrganizationId) : GraphQLException = + match theId with + | OrganizationId x -> GQLMessageException $"organization with ID \"%s{x.ToString ()}\" doesn't exist" + +let ChatRoom_Doesnt_Exist (theId : ChatRoomId) : GraphQLException = + GQLMessageException $"chat room with ID \"%s{theId.ToString ()}\" doesn't exist" + +let PrivMember_Doesnt_Exist (theId : MemberPrivateId) : GraphQLException = + match theId with + | MemberPrivateId x -> GQLMessageException $"member with private ID \"%s{x.ToString ()}\" doesn't exist" + +let Member_Isnt_Part_Of_Org () : GraphQLException = GQLMessageException "this member is not part of this organization" + +let ChatRoom_Isnt_Part_Of_Org () : GraphQLException = GQLMessageException "this chat room is not part of this organization" diff --git a/samples/chat-app/server/FSharp.Data.GraphQL.Samples.ChatApp.fsproj b/samples/chat-app/server/FSharp.Data.GraphQL.Samples.ChatApp.fsproj new file mode 100644 index 000000000..f1f7eef89 --- /dev/null +++ b/samples/chat-app/server/FSharp.Data.GraphQL.Samples.ChatApp.fsproj @@ -0,0 +1,27 @@ + + + + net6.0 + FSharp.Data.GraphQL.Samples.ChatApp + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/chat-app/server/FakePersistence.fs b/samples/chat-app/server/FakePersistence.fs new file mode 100644 index 000000000..e9b2c2f16 --- /dev/null +++ b/samples/chat-app/server/FakePersistence.fs @@ -0,0 +1,35 @@ +namespace FSharp.Data.GraphQL.Samples.ChatApp + +open System + +type FakePersistence () = + + static let mutable members = Map.empty + static let mutable chatMembers = Map.empty + static let mutable chatRoomMessages = Map.empty + static let mutable chatRooms = Map.empty + static let mutable organizations = + let newId = OrganizationId (Guid.Parse ("51f823ef-2294-41dc-9f39-a4b9a237317a")) + (newId, { Organization_In_Db.Id = newId; Name = "Public"; Members = []; ChatRooms = [] }) + |> List.singleton + |> Map.ofList + + static member Members + with get () = members + and set (v) = members <- v + + static member ChatMembers + with get () = chatMembers + and set (v) = chatMembers <- v + + static member ChatRoomMessages + with get () = chatRoomMessages + and set (v) = chatRoomMessages <- v + + static member ChatRooms + with get () = chatRooms + and set (v) = chatRooms <- v + + static member Organizations + with get () = organizations + and set (v) = organizations <- v diff --git a/samples/chat-app/server/Program.fs b/samples/chat-app/server/Program.fs new file mode 100644 index 000000000..5e77bb9cd --- /dev/null +++ b/samples/chat-app/server/Program.fs @@ -0,0 +1,44 @@ +module FSharp.Data.GraphQL.Samples.ChatApp.Program + +open Giraffe +open FSharp.Data.GraphQL.Server.AspNetCore +open FSharp.Data.GraphQL.Server.AspNetCore.Giraffe +open System +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.Logging + +let rootFactory (ctx : HttpContext) : Root = { RequestId = ctx.TraceIdentifier } + +let errorHandler (ex : Exception) (log : ILogger) = + log.LogError (EventId (), ex, "An unhandled exception has occurred while executing this request.") + clearResponse >=> setStatusCode 500 + +[] +let main args = + + let builder = WebApplication.CreateBuilder (args) + builder.Services + .AddGiraffe() + .AddGraphQLOptions (Schema.executor, rootFactory, "/ws") + |> ignore + + let app = builder.Build () + + if app.Environment.IsDevelopment () then + app.UseGraphQLPlayground ("/playground") |> ignore + app.UseGraphQLVoyager ("/voyager") |> ignore + app.UseRouting () |> ignore + app.UseEndpoints (fun endpoints -> endpoints.MapBananaCakePop (PathString "/cakePop") |> ignore) + |> ignore + + app + .UseGiraffeErrorHandler(errorHandler) + .UseWebSockets() + .UseWebSocketsForGraphQL() + .UseGiraffe (HttpHandlers.graphQL) + + app.Run () + + 0 // Exit code diff --git a/samples/chat-app/server/Properties/launchSettings.json b/samples/chat-app/server/Properties/launchSettings.json new file mode 100644 index 000000000..90117f03b --- /dev/null +++ b/samples/chat-app/server/Properties/launchSettings.json @@ -0,0 +1,40 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:7082", + "sslPort": 44311 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "cakePop", + "applicationUrl": "http://localhost:5092", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "cakePop", + "applicationUrl": "https://localhost:7122;http://localhost:5092", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "cakePop", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/chat-app/server/Schema.fs b/samples/chat-app/server/Schema.fs new file mode 100644 index 000000000..52ff5bc95 --- /dev/null +++ b/samples/chat-app/server/Schema.fs @@ -0,0 +1,877 @@ +namespace FSharp.Data.GraphQL.Samples.ChatApp + +open System +open FsToolkit.ErrorHandling + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Types + +type Root = { RequestId : string } + +module MapFrom = + + let memberInDb_To_Member (x : Member_In_Db) : Member = { Id = x.Id; Name = x.Name } + + let memberInDb_To_MeAsAMember (x : Member_In_Db) : MeAsAMember = { PrivId = x.PrivId; Id = x.Id; Name = x.Name } + + let chatMemberInDb_To_ChatMember (membersToGetDetailsFrom : Member_In_Db seq) (x : ChatMember_In_Db) : ChatMember = + let memberDetails = + membersToGetDetailsFrom + |> Seq.find (fun m -> m.Id = x.MemberId) + { Id = x.MemberId; Name = memberDetails.Name; Role = x.Role } + + let chatMemberInDb_To_MeAsAChatMember (membersToGetDetailsFrom : Member_In_Db seq) (x : ChatMember_In_Db) : MeAsAChatMember = + let memberDetails = + membersToGetDetailsFrom + |> Seq.find (fun m -> m.Id = x.MemberId) + { + PrivId = memberDetails.PrivId + Id = x.MemberId + Name = memberDetails.Name + Role = x.Role + } + + let chatRoomInDb_To_ChatRoom (membersToGetDetailsFrom : Member_In_Db seq) (x : ChatRoom_In_Db) : ChatRoom = { + Id = x.Id + Name = x.Name + Members = + FakePersistence.ChatMembers.Values + |> Seq.filter (fun m -> x.Members |> List.contains m.MemberId) + |> Seq.map (chatMemberInDb_To_ChatMember membersToGetDetailsFrom) + |> List.ofSeq + } + + let chatRoomInDb_To_ChatRoomForMember + (membersToGetDetailsFrom : Member_In_Db seq) + (chatMember : ChatMember_In_Db) + (x : ChatRoom_In_Db) + : ChatRoomForMember = + { + Id = x.Id + Name = x.Name + MeAsAChatMember = + chatMember + |> chatMemberInDb_To_MeAsAChatMember membersToGetDetailsFrom + OtherChatMembers = + FakePersistence.ChatMembers.Values + |> Seq.filter (fun m -> + m.MemberId <> chatMember.MemberId + && x.Members |> List.contains m.MemberId) + |> Seq.map (chatMemberInDb_To_ChatMember membersToGetDetailsFrom) + |> List.ofSeq + } + + let organizationInDb_To_Organization (x : Organization_In_Db) : Organization = + let members = + FakePersistence.Members.Values + |> Seq.filter (fun m -> x.Members |> List.contains m.Id) + { + Id = x.Id + Name = x.Name + Members = members |> Seq.map memberInDb_To_Member |> List.ofSeq + ChatRooms = + FakePersistence.ChatRooms.Values + |> Seq.filter (fun c -> x.ChatRooms |> List.contains c.Id) + |> Seq.map (chatRoomInDb_To_ChatRoom members) + |> List.ofSeq + } + + let organizationInDb_To_OrganizationForMember (memberId : MemberId) (x : Organization_In_Db) : OrganizationForMember option = + let mapToOrganizationForMemberForMember (memberInDb : Member_In_Db) = + let organizationStats = x |> organizationInDb_To_Organization + { + OrganizationForMember.Id = x.Id + Name = x.Name + MeAsAMember = memberInDb |> memberInDb_To_MeAsAMember + OtherMembers = + organizationStats.Members + |> List.filter (fun m -> m.Id <> memberInDb.Id) + ChatRooms = organizationStats.ChatRooms + } + FakePersistence.Members.Values + |> Seq.tryFind (fun m -> m.Id = memberId) + |> Option.map mapToOrganizationForMemberForMember + + +module Schema = + + let authenticateMemberInOrganization + (organizationId : OrganizationId) + (memberPrivId : MemberPrivateId) + : Result<(Organization_In_Db * Member_In_Db), GraphQLException> = + let maybeOrganization = FakePersistence.Organizations |> Map.tryFind organizationId + let maybeMember = + FakePersistence.Members.Values + |> Seq.tryFind (fun x -> x.PrivId = memberPrivId) + + match (maybeOrganization, maybeMember) with + | None, _ -> Error (organizationId |> Exceptions.Organization_Doesnt_Exist) + | _, None -> Error (memberPrivId |> Exceptions.PrivMember_Doesnt_Exist) + | Some organization, Some theMember -> + if not (organization.Members |> List.contains theMember.Id) then + Error (Exceptions.Member_Isnt_Part_Of_Org ()) + else + Ok (organization, theMember) + + let validateChatRoomExistence (organization : Organization_In_Db) (chatRoomId : ChatRoomId) : Result = + match FakePersistence.ChatRooms |> Map.tryFind chatRoomId with + | None -> Error <| Exceptions.ChatRoom_Doesnt_Exist chatRoomId + | Some chatRoom -> + if not (organization.ChatRooms |> List.contains chatRoom.Id) then + Error <| Exceptions.ChatRoom_Isnt_Part_Of_Org () + else + Ok chatRoom + + let validateMessageExistence (chatRoom : ChatRoom_In_Db) (messageId : MessageId) : Result = + match + FakePersistence.ChatRoomMessages + |> Map.tryFind (chatRoom.Id, messageId) + with + | None -> Error (GQLMessageException ("chat message doesn't exist (anymore)")) + | Some chatMessage -> Ok chatMessage + + let succeedOrRaiseGraphQLEx<'T> (result : Result<'T, GraphQLException>) : 'T = + match result with + | Error ex -> raise ex + | Ok s -> s + + let chatRoomEvents_subscription_name = "chatRoomEvents" + + let MemberRoleInChatEnumType = + Define.Enum ( + name = nameof MemberRoleInChat, + options = [ + Define.EnumValue (ChatAdmin.ToString (), ChatAdmin) + Define.EnumValue (ChatGuest.ToString (), ChatGuest) + ] + ) + + let MemberType = + Define.Object ( + name = nameof Member, + description = "An organization member", + isTypeOf = (fun o -> o :? Member), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the member's ID", + fun _ (x : Member) -> + match x.Id with + | MemberId theId -> theId + ) + Define.Field ("name", StringType, "the member's name", (fun _ (x : Member) -> x.Name)) + ] + ) + + let MeAsAMemberType = + Define.Object ( + name = nameof MeAsAMember, + description = "An organization member", + isTypeOf = (fun o -> o :? MeAsAMember), + fieldsFn = + fun () -> [ + Define.Field ( + "privId", + GuidType, + "the member's private ID used for authenticating their requests", + fun _ (x : MeAsAMember) -> + match x.PrivId with + | MemberPrivateId theId -> theId + ) + Define.Field ( + "id", + GuidType, + "the member's ID", + fun _ (x : MeAsAMember) -> + match x.Id with + | MemberId theId -> theId + ) + Define.Field ("name", StringType, "the member's name", (fun _ (x : MeAsAMember) -> x.Name)) + ] + ) + + let ChatMemberType = + Define.Object ( + name = nameof ChatMember, + description = "A chat member is an organization member participating in a chat room", + isTypeOf = (fun o -> o :? ChatMember), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the member's ID", + fun _ (x : ChatMember) -> + match x.Id with + | MemberId theId -> theId + ) + Define.Field ("name", StringType, "the member's name", (fun _ (x : ChatMember) -> x.Name)) + Define.Field ("role", MemberRoleInChatEnumType, "the member's role in the chat", (fun _ (x : ChatMember) -> x.Role)) + ] + ) + + let MeAsAChatMemberType = + Define.Object ( + name = nameof MeAsAChatMember, + description = "A chat member is an organization member participating in a chat room", + isTypeOf = (fun o -> o :? MeAsAChatMember), + fieldsFn = + fun () -> [ + Define.Field ( + "privId", + GuidType, + "the member's private ID used for authenticating their requests", + fun _ (x : MeAsAChatMember) -> + match x.PrivId with + | MemberPrivateId theId -> theId + ) + Define.Field ( + "id", + GuidType, + "the member's ID", + fun _ (x : MeAsAChatMember) -> + match x.Id with + | MemberId theId -> theId + ) + Define.Field ("name", StringType, "the member's name", (fun _ (x : MeAsAChatMember) -> x.Name)) + Define.Field ("role", MemberRoleInChatEnumType, "the member's role in the chat", (fun _ (x : MeAsAChatMember) -> x.Role)) + ] + ) + + let ChatRoomStatsType = + Define.Object ( + name = nameof ChatRoom, + description = "A chat room as viewed from the outside", + isTypeOf = (fun o -> o :? ChatRoom), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the chat room's ID", + fun _ (x : ChatRoom) -> + match x.Id with + | ChatRoomId theId -> theId + ) + Define.Field ("name", StringType, "the chat room's name", (fun _ (x : ChatRoom) -> x.Name)) + Define.Field ("members", ListOf ChatMemberType, "the members in the chat room", (fun _ (x : ChatRoom) -> x.Members)) + ] + ) + + let ChatRoomDetailsType = + Define.Object ( + name = nameof ChatRoomForMember, + description = "A chat room as viewed by a chat room member", + isTypeOf = (fun o -> o :? ChatRoomForMember), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the chat room's ID", + fun _ (x : ChatRoomForMember) -> + match x.Id with + | ChatRoomId theId -> theId + ) + Define.Field ("name", StringType, "the chat room's name", (fun _ (x : ChatRoomForMember) -> x.Name)) + Define.Field ( + "meAsAChatMember", + MeAsAChatMemberType, + "the chat member that queried the details", + fun _ (x : ChatRoomForMember) -> x.MeAsAChatMember + ) + Define.Field ( + "otherChatMembers", + ListOf ChatMemberType, + "the chat members excluding the one who queried the details", + fun _ (x : ChatRoomForMember) -> x.OtherChatMembers + ) + ] + ) + + let OrganizationStatsType = + Define.Object ( + name = nameof Organization, + description = "An organization as seen from the outside", + isTypeOf = (fun o -> o :? Organization), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the organization's ID", + fun _ (x : Organization) -> + match x.Id with + | OrganizationId theId -> theId + ) + Define.Field ("name", StringType, "the organization's name", (fun _ (x : Organization) -> x.Name)) + Define.Field ("members", ListOf MemberType, "members of this organization", (fun _ (x : Organization) -> x.Members)) + Define.Field ("chatRooms", ListOf ChatRoomStatsType, "chat rooms in this organization", (fun _ (x : Organization) -> x.ChatRooms)) + ] + ) + + let OrganizationDetailsType = + Define.Object ( + name = nameof OrganizationForMember, + description = "An organization as seen by one of the organization's members", + isTypeOf = (fun o -> o :? OrganizationForMember), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the organization's ID", + fun _ (x : OrganizationForMember) -> + match x.Id with + | OrganizationId theId -> theId + ) + Define.Field ("name", StringType, "the organization's name", (fun _ (x : OrganizationForMember) -> x.Name)) + Define.Field ( + "meAsAMember", + MeAsAMemberType, + "the member that queried the details", + fun _ (x : OrganizationForMember) -> x.MeAsAMember + ) + Define.Field ( + "otherMembers", + ListOf MemberType, + "members of this organization", + fun _ (x : OrganizationForMember) -> x.OtherMembers + ) + Define.Field ( + "chatRooms", + ListOf ChatRoomStatsType, + "chat rooms in this organization", + fun _ (x : OrganizationForMember) -> x.ChatRooms + ) + ] + ) + + let aChatRoomMessageTypeWith description name = + Define.Object ( + name = name, + description = description, + isTypeOf = (fun o -> o :? ChatRoomMessage), + fieldsFn = + fun () -> [ + Define.Field ( + "id", + GuidType, + "the message's ID", + fun _ (x : ChatRoomMessage) -> + match x.Id with + | MessageId theId -> theId + ) + Define.Field ( + "chatRoomId", + GuidType, + "the ID of the chat room the message belongs to", + fun _ (x : ChatRoomMessage) -> + match x.ChatRoomId with + | ChatRoomId theId -> theId + ) + Define.Field ( + "date", + DateTimeOffsetType, + "the time the message was received at the server", + fun _ (x : ChatRoomMessage) -> DateTimeOffset (x.Date, TimeSpan.Zero) + ) + Define.Field ( + "authorId", + GuidType, + "the member ID of the message's author", + fun _ (x : ChatRoomMessage) -> + match x.AuthorId with + | MemberId theId -> theId + ) + Define.Field ("text", StringType, "the message's text", (fun _ (x : ChatRoomMessage) -> x.Text)) + ] + ) + + let anEmptyChatRoomEvent description name = + Define.Object ( + name = name, + description = description, + isTypeOf = (fun o -> o :? unit), + fieldsFn = + fun () -> [ + Define.Field ("doNotUse", BooleanType, "this is just to satify the expected structure of this type", (fun _ _ -> true)) + ] + ) + + let aChatRoomEventForMessageId description name = + Define.Object ( + name = name, + description = description, + isTypeOf = (fun o -> o :? MessageId), + fieldsFn = + (fun () -> [ + Define.Field ( + "messageId", + GuidType, + "this is the message ID", + fun _ (x : MessageId) -> + match x with + | MessageId theId -> theId + ) + ]) + ) + + let aChatRoomEventForMemberIdAndName description name = + Define.Object ( + name = name, + description = description, + isTypeOf = (fun o -> o :? (MemberId * string)), + fieldsFn = + (fun () -> [ + Define.Field ( + "memberId", + GuidType, + "this is the member's ID", + fun _ (mId : MemberId, _ : string) -> + match mId with + | MemberId theId -> theId + ) + Define.Field ("memberName", StringType, "this is the member's name", (fun _ (_ : MemberId, name : string) -> name)) + ]) + ) + + let newMessageDef = + nameof NewMessage + |> aChatRoomMessageTypeWith "a new public message has been sent in the chat room" + let editedMessageDef = + nameof EditedMessage + |> aChatRoomMessageTypeWith "a public message of the chat room has been edited" + let deletedMessageDef = + nameof DeletedMessage + |> aChatRoomEventForMessageId "a public message of the chat room has been deleted" + let memberJoinedDef = + nameof MemberJoined + |> aChatRoomEventForMemberIdAndName "a member has joined the chat" + let memberLeftDef = + nameof MemberLeft + |> aChatRoomEventForMemberIdAndName "a member has left the chat" + + let ChatRoomSpecificEventType = + Define.Union ( + name = nameof ChatRoomSpecificEvent, + options = [ newMessageDef; editedMessageDef; deletedMessageDef; memberJoinedDef; memberLeftDef ], + resolveValue = + (fun o -> + match o with + | NewMessage x -> box x + | EditedMessage x -> upcast x + | DeletedMessage x -> upcast x + | MemberJoined (mId, mName) -> upcast (mId, mName) + | MemberLeft (mId, mName) -> upcast (mId, mName)), + resolveType = + (fun o -> + match o with + | NewMessage _ -> newMessageDef + | EditedMessage _ -> editedMessageDef + | DeletedMessage _ -> deletedMessageDef + | MemberJoined _ -> memberJoinedDef + | MemberLeft _ -> memberLeftDef), + description = "data which is specific to a certain type of event" + ) + + let ChatRoomEventType = + Define.Object ( + name = nameof ChatRoomEvent, + description = "Something that happened in the chat room, like a new message sent", + isTypeOf = (fun o -> o :? ChatRoomEvent), + fieldsFn = + (fun () -> [ + Define.Field ( + "chatRoomId", + GuidType, + "the ID of the chat room in which the event happened", + fun _ (x : ChatRoomEvent) -> + match x.ChatRoomId with + | ChatRoomId theId -> theId + ) + Define.Field ( + "time", + DateTimeOffsetType, + "the time the message was received at the server", + fun _ (x : ChatRoomEvent) -> DateTimeOffset (x.Time, TimeSpan.Zero) + ) + Define.Field ( + "specificData", + ChatRoomSpecificEventType, + "the event's specific data", + fun _ (x : ChatRoomEvent) -> x.SpecificData + ) + ]) + ) + + let QueryType = + Define.Object ( + name = "Query", + fields = [ + Define.Field ( + "organizations", + ListOf OrganizationStatsType, + "gets all available organizations", + fun _ _ -> + FakePersistence.Organizations.Values + |> Seq.map MapFrom.organizationInDb_To_Organization + |> List.ofSeq + ) + ] + ) + + let schemaConfig = SchemaConfig.Default + + let publishChatRoomEvent (specificEvent : ChatRoomSpecificEvent) (chatRoomId : ChatRoomId) : unit = + { + ChatRoomId = chatRoomId + Time = DateTime.UtcNow + SpecificData = specificEvent + } + |> schemaConfig.SubscriptionProvider.Publish chatRoomEvents_subscription_name + + let MutationType = + Define.Object ( + name = "Mutation", + fields = [ + Define.Field ( + "enterOrganization", + OrganizationDetailsType, + "makes a new member enter an organization", + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization") + Define.Input ("member", StringType, description = "the new member's name") + ], + fun ctx root -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let newMemberName : string = ctx.Arg ("member") + let maybeResult = + FakePersistence.Organizations + |> Map.tryFind organizationId + |> Option.map MapFrom.organizationInDb_To_Organization + |> Option.map (fun organization -> + if + organization.Members + |> List.exists (fun m -> m.Name = newMemberName) + then + raise ( + newMemberName + |> Exceptions.Member_With_This_Name_Already_Exists + ) + else + let newMemberPrivId = MemberPrivateId (Guid.NewGuid ()) + let newMemberId = MemberId (Guid.NewGuid ()) + let newMember = { + Member_In_Db.PrivId = newMemberPrivId + Id = newMemberId + Name = newMemberName + } + FakePersistence.Members <- FakePersistence.Members |> Map.add newMemberId newMember + + FakePersistence.Organizations <- + FakePersistence.Organizations + |> Map.change + organizationId + (Option.bind (fun organization -> + Some { organization with Members = newMemberId :: organization.Members })) + FakePersistence.Organizations + |> Map.find organizationId + |> MapFrom.organizationInDb_To_OrganizationForMember newMemberId) + |> Option.flatten + match maybeResult with + | None -> raise (GQLMessageException ("couldn't enter organization (maybe the ID is incorrect?)")) + | Some res -> res + ) + Define.Field ( + "createChatRoom", + ChatRoomDetailsType, + "creates a new chat room for a user", + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization in which the chat room will be created") + Define.Input ("memberId", GuidType, description = "the member's private ID") + Define.Input ("name", StringType, description = "the chat room's name") + ], + fun ctx root -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let memberPrivId = MemberPrivateId (ctx.Arg ("memberId")) + let chatRoomName : string = ctx.Arg ("name") + + memberPrivId + |> authenticateMemberInOrganization organizationId + |> Result.map (fun (organization, theMember) -> + let newChatRoomId = ChatRoomId (Guid.NewGuid ()) + let newChatMember : ChatMember_In_Db = { ChatRoomId = newChatRoomId; MemberId = theMember.Id; Role = ChatAdmin } + let newChatRoom : ChatRoom_In_Db = { Id = newChatRoomId; Name = chatRoomName; Members = [ theMember.Id ] } + FakePersistence.ChatRooms <- + FakePersistence.ChatRooms + |> Map.add newChatRoomId newChatRoom + FakePersistence.ChatMembers <- + FakePersistence.ChatMembers + |> Map.add (newChatRoomId, theMember.Id) newChatMember + FakePersistence.Organizations <- + FakePersistence.Organizations + |> Map.change organizationId (Option.map (fun org -> { org with ChatRooms = newChatRoomId :: org.ChatRooms })) + + MapFrom.chatRoomInDb_To_ChatRoomForMember + (FakePersistence.Members.Values + |> Seq.filter (fun x -> organization.Members |> List.contains x.Id)) + newChatMember + newChatRoom) + |> succeedOrRaiseGraphQLEx + ) + Define.Field ( + "enterChatRoom", + ChatRoomDetailsType, + "makes a member enter a chat room", + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization the chat room and member are in") + Define.Input ("chatRoomId", GuidType, description = "the ID of the chat room") + Define.Input ("memberId", GuidType, description = "the member's private ID") + ], + fun ctx root -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let chatRoomId = ChatRoomId (ctx.Arg ("chatRoomId")) + let memberPrivId = MemberPrivateId (ctx.Arg ("memberId")) + + memberPrivId + |> authenticateMemberInOrganization organizationId + |> Result.bind (fun (organization, theMember) -> + chatRoomId + |> validateChatRoomExistence organization + |> Result.map (fun chatRoom -> (organization, chatRoom, theMember))) + |> Result.map (fun (_, chatRoom, theMember) -> + let newChatMember : ChatMember_In_Db = { ChatRoomId = chatRoom.Id; MemberId = theMember.Id; Role = ChatGuest } + FakePersistence.ChatMembers <- + FakePersistence.ChatMembers + |> Map.add (newChatMember.ChatRoomId, newChatMember.MemberId) newChatMember + FakePersistence.ChatRooms <- + FakePersistence.ChatRooms + |> Map.change + chatRoom.Id + (Option.map (fun theChatRoom -> { theChatRoom with Members = newChatMember.MemberId :: theChatRoom.Members })) + let theChatRoom = FakePersistence.ChatRooms |> Map.find chatRoomId + let result = + MapFrom.chatRoomInDb_To_ChatRoomForMember + (FakePersistence.Members.Values + |> Seq.filter (fun x -> theChatRoom.Members |> List.contains x.Id)) + newChatMember + theChatRoom + + chatRoom.Id + |> publishChatRoomEvent (MemberJoined (theMember.Id, theMember.Name)) + + result) + |> succeedOrRaiseGraphQLEx + ) + Define.Field ( + "leaveChatRoom", + BooleanType, + "makes a member leave a chat room", + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization the chat room and member are in") + Define.Input ("chatRoomId", GuidType, description = "the ID of the chat room") + Define.Input ("memberId", GuidType, description = "the member's private ID") + ], + fun ctx root -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let chatRoomId = ChatRoomId (ctx.Arg ("chatRoomId")) + let memberPrivId = MemberPrivateId (ctx.Arg ("memberId")) + + memberPrivId + |> authenticateMemberInOrganization organizationId + |> Result.bind (fun (organization, theMember) -> + chatRoomId + |> validateChatRoomExistence organization + |> Result.map (fun chatRoom -> (organization, chatRoom, theMember))) + |> Result.map (fun (_, chatRoom, theMember) -> + FakePersistence.ChatMembers <- + FakePersistence.ChatMembers + |> Map.remove (chatRoom.Id, theMember.Id) + FakePersistence.ChatRooms <- + FakePersistence.ChatRooms + |> Map.change + chatRoom.Id + (Option.map (fun theChatRoom -> { + theChatRoom with + Members = + theChatRoom.Members + |> List.filter (fun mId -> mId <> theMember.Id) + })) + true) + |> succeedOrRaiseGraphQLEx + ) + Define.Field ( + "sendChatMessage", + BooleanType, + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization the chat room and member are in") + Define.Input ("chatRoomId", GuidType, description = "the chat room's ID") + Define.Input ("memberId", GuidType, description = "the member's private ID") + Define.Input ("text", StringType, description = "the chat message's contents") + ], + fun ctx _ -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let chatRoomId = ChatRoomId (ctx.Arg ("chatRoomId")) + let memberPrivId = MemberPrivateId (ctx.Arg ("memberId")) + let text : string = ctx.Arg ("text") + + memberPrivId + |> authenticateMemberInOrganization organizationId + |> Result.bind (fun (organization, theMember) -> + chatRoomId + |> validateChatRoomExistence organization + |> Result.map (fun chatRoom -> (organization, chatRoom, theMember))) + |> Result.map (fun (_, chatRoom, theMember) -> + let newChatRoomMessage = { + Id = MessageId (Guid.NewGuid ()) + ChatRoomId = chatRoom.Id + Date = DateTime.UtcNow + AuthorId = theMember.Id + Text = text + } + FakePersistence.ChatRoomMessages <- + FakePersistence.ChatRoomMessages + |> Map.add (chatRoom.Id, newChatRoomMessage.Id) newChatRoomMessage + + chatRoom.Id + |> publishChatRoomEvent (NewMessage newChatRoomMessage) + + true) + |> succeedOrRaiseGraphQLEx + ) + Define.Field ( + "editChatMessage", + BooleanType, + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization the chat room and member are in") + Define.Input ("chatRoomId", GuidType, description = "the chat room's ID") + Define.Input ("memberId", GuidType, description = "the member's private ID") + Define.Input ("messageId", GuidType, description = "the existing message's ID") + Define.Input ("text", StringType, description = "the chat message's contents") + ], + fun ctx _ -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let chatRoomId = ChatRoomId (ctx.Arg ("chatRoomId")) + let memberPrivId = MemberPrivateId (ctx.Arg ("memberId")) + let messageId = MessageId (ctx.Arg ("messageId")) + let text : string = ctx.Arg ("text") + + memberPrivId + |> authenticateMemberInOrganization organizationId + |> Result.bind (fun (organization, theMember) -> + chatRoomId + |> validateChatRoomExistence organization + |> Result.bind (fun chatRoom -> + messageId + |> validateMessageExistence chatRoom + |> Result.map (fun x -> (chatRoom, x))) + |> Result.map (fun (chatRoom, chatMessage) -> (organization, chatRoom, theMember, chatMessage))) + |> Result.map (fun (_, chatRoom, theMember, chatMessage) -> + let newChatRoomMessage = { + Id = chatMessage.Id + ChatRoomId = chatRoom.Id + Date = chatMessage.Date + AuthorId = theMember.Id + Text = text + } + FakePersistence.ChatRoomMessages <- + FakePersistence.ChatRoomMessages + |> Map.change (chatRoom.Id, newChatRoomMessage.Id) (Option.map (fun _ -> newChatRoomMessage)) + + chatRoom.Id + |> publishChatRoomEvent (EditedMessage newChatRoomMessage) + + true) + |> succeedOrRaiseGraphQLEx + ) + Define.Field ( + "deleteChatMessage", + BooleanType, + [ + Define.Input ("organizationId", GuidType, description = "the ID of the organization the chat room and member are in") + Define.Input ("chatRoomId", GuidType, description = "the chat room's ID") + Define.Input ("memberId", GuidType, description = "the member's private ID") + Define.Input ("messageId", GuidType, description = "the existing message's ID") + ], + fun ctx _ -> + let organizationId = OrganizationId (ctx.Arg ("organizationId")) + let chatRoomId = ChatRoomId (ctx.Arg ("chatRoomId")) + let memberPrivId = MemberPrivateId (ctx.Arg ("memberId")) + let messageId = MessageId (ctx.Arg ("messageId")) + + memberPrivId + |> authenticateMemberInOrganization organizationId + |> Result.bind (fun (organization, theMember) -> + chatRoomId + |> validateChatRoomExistence organization + |> Result.bind (fun chatRoom -> + messageId + |> validateMessageExistence chatRoom + |> Result.map (fun x -> (chatRoom, x))) + |> Result.map (fun (chatRoom, chatMessage) -> (organization, chatRoom, theMember, chatMessage))) + |> Result.map (fun (_, chatRoom, theMember, chatMessage) -> + FakePersistence.ChatRoomMessages <- + FakePersistence.ChatRoomMessages + |> Map.remove (chatRoom.Id, chatMessage.Id) + + chatRoom.Id + |> publishChatRoomEvent (DeletedMessage chatMessage.Id) + + true) + |> succeedOrRaiseGraphQLEx + ) + ] + ) + + let RootType = + Define.Object ( + name = "Root", + description = "contains general request information", + isTypeOf = (fun o -> o :? Root), + fieldsFn = + fun () -> [ + Define.Field ("requestId", StringType, "The request's unique ID.", (fun _ (r : Root) -> r.RequestId)) + ] + ) + + let SubscriptionType = + Define.SubscriptionObject ( + name = "Subscription", + fields = [ + Define.SubscriptionField ( + chatRoomEvents_subscription_name, + RootType, + ChatRoomEventType, + "events related to a specific chat room", + [ + Define.Input ("chatRoomId", GuidType, description = "the ID of the chat room to listen to events from") + Define.Input ("memberId", GuidType, description = "the member's private ID") + ], + (fun ctx _ (chatRoomEvent : ChatRoomEvent) -> + let chatRoomIdOfInterest = ChatRoomId (ctx.Arg ("chatRoomId")) + let memberId = MemberPrivateId (ctx.Arg ("memberId")) + + if chatRoomEvent.ChatRoomId <> chatRoomIdOfInterest then + None + else + let chatRoom = + FakePersistence.ChatRooms + |> Map.find chatRoomEvent.ChatRoomId + let chatRoomMembersPrivIds = + FakePersistence.Members.Values + |> Seq.filter (fun m -> chatRoom.Members |> List.contains m.Id) + |> Seq.map (fun m -> m.PrivId) + if not (chatRoomMembersPrivIds |> Seq.contains memberId) then + None + else + Some chatRoomEvent) + ) + ] + ) + + let schema : ISchema = Schema (QueryType, MutationType, SubscriptionType, schemaConfig) + + let executor = Executor (schema, []) diff --git a/samples/chat-app/server/appsettings.Development.json b/samples/chat-app/server/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/samples/chat-app/server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/chat-app/server/appsettings.json b/samples/chat-app/server/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/samples/chat-app/server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/star-wars-api/FSharp.Data.GraphQL.Samples.StarWarsApi.fsproj b/samples/star-wars-api/FSharp.Data.GraphQL.Samples.StarWarsApi.fsproj index ebab7084b..09bfd9f9f 100644 --- a/samples/star-wars-api/FSharp.Data.GraphQL.Samples.StarWarsApi.fsproj +++ b/samples/star-wars-api/FSharp.Data.GraphQL.Samples.StarWarsApi.fsproj @@ -8,7 +8,6 @@ - @@ -16,23 +15,14 @@ - - - - - - - - - - + diff --git a/samples/star-wars-api/Program.fs b/samples/star-wars-api/Program.fs index 8aca32971..7c3d7a624 100644 --- a/samples/star-wars-api/Program.fs +++ b/samples/star-wars-api/Program.fs @@ -1,43 +1,41 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi +module FSharp.Data.GraphQL.Samples.StarWarsApi.Program open Microsoft.AspNetCore open Microsoft.AspNetCore.Hosting open Microsoft.Extensions.Configuration - -module Program = - let exitCode = 0 - - let buildWebHost (args: string array) = - - // Build an initial configuration that takes in both environment and command line settings. - let config = ConfigurationBuilder() - .AddEnvironmentVariables() - .AddCommandLine(args) - .Build() - - // This is done so that an environment specified on the command line with "--environment" is respected, - // when we look for appsettings.*.json files. - let configureAppConfiguration (context: WebHostBuilderContext) (config: IConfigurationBuilder) = - - // The default IFileProvider has the working directory set to the same as the DLL. - // We'll use this to re-set the FileProvider in the configuration builder below. - let fileProvider = ConfigurationBuilder().GetFileProvider() - - // Extract the environment name from the configuration that was already built above - let envName = context.HostingEnvironment.EnvironmentName - // Use the name to find the additional appsettings file, if present. - config.SetFileProvider(fileProvider) - .AddJsonFile("appsettings.json", false, true) - .AddJsonFile($"appsettings.{envName}.json", true) |> ignore - - WebHost - .CreateDefaultBuilder(args) - .UseConfiguration(config) - .ConfigureAppConfiguration(configureAppConfiguration) - .UseStartup() - - [] - let main args = - buildWebHost(args).Build().Run() - exitCode +let exitCode = 0 + +let buildWebHost (args: string array) = + + // Build an initial configuration that takes in both environment and command line settings. + let config = ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build() + + // This is done so that an environment specified on the command line with "--environment" is respected, + // when we look for appsettings.*.json files. + let configureAppConfiguration (context: WebHostBuilderContext) (config: IConfigurationBuilder) = + + // The default IFileProvider has the working directory set to the same as the DLL. + // We'll use this to re-set the FileProvider in the configuration builder below. + let fileProvider = ConfigurationBuilder().GetFileProvider() + + // Extract the environment name from the configuration that was already built above + let envName = context.HostingEnvironment.EnvironmentName + // Use the name to find the additional appsettings file, if present. + config.SetFileProvider(fileProvider) + .AddJsonFile("appsettings.json", false, true) + .AddJsonFile($"appsettings.{envName}.json", true) |> ignore + + WebHost + .CreateDefaultBuilder(args) + .UseConfiguration(config) + .ConfigureAppConfiguration(configureAppConfiguration) + .UseStartup() + +[] +let main args = + buildWebHost(args).Build().Run() + exitCode diff --git a/samples/star-wars-api/Startup.fs b/samples/star-wars-api/Startup.fs index 74c5f3369..f47b66040 100644 --- a/samples/star-wars-api/Startup.fs +++ b/samples/star-wars-api/Startup.fs @@ -3,61 +3,54 @@ namespace FSharp.Data.GraphQL.Samples.StarWarsApi open System open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Http -open Microsoft.AspNetCore.Http.Json -open Microsoft.AspNetCore.Server.Kestrel.Core open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection -open Microsoft.Extensions.Hosting open Microsoft.Extensions.Logging -open Microsoft.Extensions.Options +open Microsoft.Extensions.Hosting open Giraffe +open FSharp.Data.GraphQL.Server.AspNetCore +open FSharp.Data.GraphQL.Server.AspNetCore.Giraffe type Startup private () = - new (configuration: IConfiguration) as this = - Startup() then - this.Configuration <- configuration - member _.ConfigureServices(services: IServiceCollection) = + let rootFactory (ctx) : Root = Root (ctx) + + new (configuration : IConfiguration) as this = + Startup () + then this.Configuration <- configuration + + member _.ConfigureServices (services : IServiceCollection) = services .AddGiraffe() - .Configure(Action(fun x -> x.AllowSynchronousIO <- true)) - .Configure(Action(fun x -> x.AllowSynchronousIO <- true)) - // Surprisingly minimal APIs use Microsoft.AspNetCore.Http.Json.JsonOptions - // Use if you want to return HTTP responses using minmal APIs IResult interface - .Configure( - Action(fun o -> - Json.configureDefaultSerializerOptions Seq.empty o.SerializerOptions - ) - ) - // Use for pretty printing in logs - .Configure( - Constants.Idented, - Action(fun o -> - Json.configureDefaultSerializerOptions Seq.empty o.SerializerOptions - o.SerializerOptions.WriteIndented <- true - ) - ) - // Replace Newtonsoft.Json and use the same settings in Giraffe - .AddSingleton(fun sp -> - let options = sp.GetService>() - SystemTextJson.Serializer(options.Value.SerializerOptions)) + .AddGraphQLOptions (Schema.executor, rootFactory, "/ws") |> ignore - member _.Configure(app: IApplicationBuilder, env: IHostEnvironment) = + member _.Configure + ( + app : IApplicationBuilder, + env : IHostEnvironment + ) = let errorHandler (ex : Exception) (log : ILogger) = - log.LogError(EventId(), ex, "An unhandled exception has occurred while executing the request.") + log.LogError (EventId (), ex, "An unhandled exception has occurred while executing the request.") clearResponse >=> setStatusCode 500 - if env.IsDevelopment() then - app.UseGraphQLPlayground("/playground") |> ignore - app.UseGraphQLVoyager("/voyager") |> ignore - app.UseRouting() |> ignore - app.UseEndpoints(fun endpoints -> endpoints.MapBananaCakePop(PathString "/cakePop") |> ignore) |> ignore + if env.IsDevelopment () then + app.UseGraphQLPlayground ("/playground") |> ignore + app.UseGraphQLVoyager ("/voyager") |> ignore + app.UseRouting () |> ignore + app.UseEndpoints (fun endpoints -> endpoints.MapBananaCakePop (PathString "/cakePop") |> ignore) + |> ignore app .UseGiraffeErrorHandler(errorHandler) .UseWebSockets() - //.UseMiddleware>(Schema.executor, fun () -> { RequestId = Guid.NewGuid().ToString() }) - .UseGiraffe HttpHandlers.webApp + .UseWebSocketsForGraphQL() + .UseGiraffe ( + // Set CORS to allow external servers (React samples) to call this API + setHttpHeader "Access-Control-Allow-Origin" "*" + >=> setHttpHeader "Access-Control-Allow-Headers" "content-type" + >=> (setHttpHeader "Request-Type" "Classic") // For integration testing purposes + >=> HttpHandlers.graphQL + ) member val Configuration : IConfiguration = null with get, set diff --git a/samples/star-wars-api/WebSocketJsonConverters.fs b/samples/star-wars-api/WebSocketJsonConverters.fs deleted file mode 100644 index e0cee347d..000000000 --- a/samples/star-wars-api/WebSocketJsonConverters.fs +++ /dev/null @@ -1,182 +0,0 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi - -open System -open System.Collections.Generic -open System.Collections.Immutable -open System.Text.Json -open System.Text.Json.Nodes -open System.Text.Json.Serialization - -open FSharp.Data.GraphQL -open FSharp.Data.GraphQL.Types - -type private GQLEditableRequestContent = - { Query : string - OperationName : string Skippable - Variables : JsonObject Skippable } - -[] -module JsonNodeExtensions = - - open System.Buffers - - type JsonNode with - - static member Create (object: 'T) = - let bufferWriter = new ArrayBufferWriter(); - use writer = new Utf8JsonWriter(bufferWriter) - JsonSerializer.Serialize<'T>(writer, object, Json.serializerOptions) - JsonSerializer.Deserialize(bufferWriter.WrittenSpan, Json.serializerOptions) - - member node.AsJsonElement() = - let bufferWriter = new ArrayBufferWriter() - use writer = new Utf8JsonWriter(bufferWriter) - node.WriteTo (writer, Json.serializerOptions) - let bytes = bufferWriter.WrittenSpan - let mutable reader = new Utf8JsonReader(bytes) - JsonDocument.ParseValue(&reader).RootElement - -[] -type GraphQLQueryConverter<'a>(executor : Executor<'a>, replacements: Map, ?meta : Metadata) = - inherit JsonConverter() - - /// Active pattern to match GraphQL type defintion with nullable / optional types. - let (|Nullable|_|) (tdef : TypeDef) = - match tdef with - | :? NullableDef as x -> Some x.OfType - | _ -> None - - override __.CanConvert(t) = t = typeof - - override __.Write(_, _, _) = raise <| System.NotSupportedException() - - override __.Read(reader, _, options) = - - let request = JsonSerializer.Deserialize(&reader, options) - let result = - let query = request.Query - match meta with - | Some meta -> executor.CreateExecutionPlan(query, meta = meta) - | None -> executor.CreateExecutionPlan(query) - match result with - | Result.Error struct (_, errors) -> - failwith (String.concat Environment.NewLine (errors |> Seq.map (fun error -> error.Message))) - | Ok executionPlan when executionPlan.Variables = [] -> { ExecutionPlan = executionPlan; Variables = ImmutableDictionary.Empty } - | Ok executionPlan -> - match request.Variables with - | Skip -> failwith "No variables provided" - | Include vars -> - // For multipart requests, we need to replace some variables - // TODO: Implement JSON path - Map.iter (fun path rep -> - vars.Remove path |> ignore - vars.Add(path, JsonNode.Create rep)) - replacements - //Map.iter(fun path rep -> vars.SelectToken(path).Replace(JObject.FromObject(rep))) replacements - let variables = - executionPlan.Variables - |> List.fold (fun (acc: ImmutableDictionary.Builder) (vdef: VarDef) -> - match vars.TryGetPropertyValue vdef.Name with - | true, jsonNode -> - let jsonElement = jsonNode.AsJsonElement() - acc.Add (vdef.Name, jsonElement) - | false, _ -> - match vdef.DefaultValue, vdef.TypeDef with - | Some _, _ -> () - | _, Nullable _ -> () - | None, _ -> failwithf "A variable '$%s' has no default value and is missing!" vdef.Name - acc) - (ImmutableDictionary.CreateBuilder()) - { ExecutionPlan = executionPlan; Variables = variables.ToImmutable() } - -[] -module private GraphQLSubscriptionFields = - let [] FIELD_Type = "type" - let [] FIELD_Id = "id" - let [] FIELD_Payload = "payload" - let [] FIELD_Error = "error" - -[] -type WebSocketClientMessageConverter<'a>(executor : Executor<'a>, replacements: Map, ?meta : Metadata) = - inherit JsonConverter() - - override __.CanConvert(t) = t = typeof - - override __.Write(_, _, _) = raise <| NotSupportedException() - - override __.Read(reader, _, options) = - let properties = JsonSerializer.Deserialize>(&reader, options) - let typ = properties.["type"] - if typ.ValueKind = JsonValueKind.String then - let value = typ.GetString() - match value with - | "connection_init" -> ConnectionInit - | "connection_terminate" -> ConnectionTerminate - | "start" -> - let id = - match properties.TryGetValue FIELD_Id with - | true, value -> ValueSome <| value.GetString () - | false, _ -> ValueNone - let payload = - match properties.TryGetValue FIELD_Payload with - | true, value -> ValueSome <| value - | false, _ -> ValueNone - match id, payload with - | ValueSome id, ValueSome payload -> - try - let queryConverter = - match meta with - | Some meta -> GraphQLQueryConverter(executor, replacements, meta) :> JsonConverter - | None -> GraphQLQueryConverter(executor, replacements) :> JsonConverter - let options' = Json.getSerializerOptions (Seq.singleton queryConverter) - let req = payload.Deserialize options' - Start(id, req) - with e -> ParseError(Some id, "Parse Failed with Exception: " + e.Message) - | ValueNone, _ -> ParseError(None, "Malformed GQL_START message, expected id field but found none") - | _, ValueNone -> ParseError(None, "Malformed GQL_START message, expected payload field but found none") - | "stop" -> - match properties.TryGetValue FIELD_Id with - | true, id -> Stop(id.GetString ()) - | false, _ -> ParseError(None, "Malformed GQL_STOP message, expected id field but found none") - | _ -> - ParseError(None, $"Message Type '%s{typ.GetRawText()}' is not supported!") - else - ParseError(None, $"Message Type must be string but got {Environment.NewLine}%s{typ.GetRawText()}") - -[] -type WebSocketServerMessageConverter() = - inherit JsonConverter() - - override __.CanConvert(t) = t = typedefof || t.DeclaringType = typedefof - - override __.Read(_, _, _) = raise <| NotSupportedException() - - override __.Write(writer, value, options) = - writer.WriteStartObject() - match value with - | ConnectionAck -> - writer.WriteString(FIELD_Type, "connection_ack") - | ConnectionError(err) -> - writer.WriteString(FIELD_Type, "connection_error") - writer.WritePropertyName(FIELD_Payload) - writer.WriteStartObject() - writer.WriteString(FIELD_Error, err) - writer.WriteEndObject() - | Error(id, err) -> - writer.WriteString(FIELD_Type, "error") - writer.WritePropertyName(FIELD_Payload) - writer.WriteStartObject() - writer.WriteString(FIELD_Error, err) - writer.WriteEndObject() - match id with - | Some id -> writer.WriteString (FIELD_Id, id) - | None -> writer.WriteNull(FIELD_Id) - | Data(id, result) -> - writer.WriteString(FIELD_Type, "data") - writer.WriteString(FIELD_Id, id) - writer.WritePropertyName(FIELD_Payload) - JsonSerializer.Serialize(writer, result, options) - | Complete(id) -> - writer.WriteString(FIELD_Type, "complete") - writer.WriteString(FIELD_Id, id) - writer.WriteEndObject() diff --git a/samples/star-wars-api/WebSocketMessages.fs b/samples/star-wars-api/WebSocketMessages.fs deleted file mode 100644 index a018ae83f..000000000 --- a/samples/star-wars-api/WebSocketMessages.fs +++ /dev/null @@ -1,34 +0,0 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi - -open System.Collections.Immutable -open System.Text.Json -open FSharp.Data.GraphQL -open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Types - -type GraphQLQuery = - { ExecutionPlan : ExecutionPlan - Variables : ImmutableDictionary } - -type WebSocketClientMessage = - | ConnectionInit - | ConnectionTerminate - | Start of id : string * payload : GraphQLQuery - | Stop of id : string - | ParseError of id : string option * err : string - -type WebSocketServerMessage = - | ConnectionAck - | ConnectionError of err : string - | Data of id : string * payload : Output - | Error of id : string option * err : string - | Complete of id : string -with - static member OfResponseContent(id, subscription : GQLSubscriptionResponseContent) = - match subscription with - | SubscriptionResult data -> Data (id, data) - | SubscriptionErrors (data, errors) -> Error (Some id, JsonSerializer.Serialize(errors, Json.serializerOptions)) - static member OfResponseContent(id, deferred : GQLDeferredResponseContent) = - match deferred with - | DeferredResult (data, path) -> Data (id, Map.ofList [ "data", data; "path", path ]) - | DeferredErrors (data, errors, path) -> Error (Some id, JsonSerializer.Serialize(errors, Json.serializerOptions)) diff --git a/samples/star-wars-api/WebSocketMiddleware.fs b/samples/star-wars-api/WebSocketMiddleware.fs deleted file mode 100644 index 6ede1a3a1..000000000 --- a/samples/star-wars-api/WebSocketMiddleware.fs +++ /dev/null @@ -1,167 +0,0 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi - -open System -open System.Collections.Concurrent -open System.Collections.Generic -open System.IO -open System.Net.WebSockets -open System.Text.Json -open System.Text.Json.Serialization -open System.Threading -open System.Threading.Tasks -open Microsoft.AspNetCore.Http -open FSharp.Control.Reactive - -open FSharp.Data.GraphQL -open FSharp.Data.GraphQL.Execution - -type GraphQLWebSocket(innerSocket : WebSocket) = - inherit WebSocket() - - let subscriptions = ConcurrentDictionary() :> IDictionary - let id = System.Guid.NewGuid() - - override _.CloseStatus = innerSocket.CloseStatus - - override _.CloseStatusDescription = innerSocket.CloseStatusDescription - - override _.State = innerSocket.State - - override _.SubProtocol = innerSocket.SubProtocol - - override _.CloseAsync(status, description, ct) = innerSocket.CloseAsync(status, description, ct) - - override _.CloseOutputAsync(status, description, ct) = innerSocket.CloseOutputAsync(status, description, ct) - - override this.Dispose() = - this.UnsubscribeAll() - innerSocket.Dispose() - - override _.ReceiveAsync(buffer : ArraySegment, ct) = innerSocket.ReceiveAsync(buffer, ct) - - override _.SendAsync(buffer : ArraySegment, msgType, endOfMsg, ct) = innerSocket.SendAsync(buffer, msgType, endOfMsg, ct) - - override _.Abort() = innerSocket.Abort() - - member _.Subscribe(id : string, unsubscriber : IDisposable) = - subscriptions.Add(id, unsubscriber) - - member _.Unsubscribe(id : string) = - match subscriptions.ContainsKey(id) with - | true -> - subscriptions.[id].Dispose() - subscriptions.Remove(id) |> ignore - | false -> () - - member _.UnsubscribeAll() = - subscriptions - |> Seq.iter (fun x -> x.Value.Dispose()) - subscriptions.Clear() - - member _.Id = id - -module SocketManager = - let private sockets = ConcurrentDictionary() :> IDictionary - - let private disposeSocket (socket : GraphQLWebSocket) = - sockets.Remove(socket.Id) |> ignore - socket.Dispose() - - let private sendMessage (socket : GraphQLWebSocket) cancellationToken (message : WebSocketServerMessage) = task { - let options = - WebSocketServerMessageConverter() :> JsonConverter - |> Seq.singleton - |> Json.getSerializerOptions - use ms = new MemoryStream() - do! JsonSerializer.SerializeAsync(ms, message, options, cancellationToken) - ms.Seek(0L, SeekOrigin.Begin) |> ignore - let segment = new ArraySegment(ms.ToArray()) - if socket.State = WebSocketState.Open then - do! socket.SendAsync(segment, WebSocketMessageType.Text, true, cancellationToken) - else - disposeSocket socket - } - - let private receiveMessage (executor : Executor<'Root>) (replacements : Map) cancellationToken (socket : WebSocket) = task { - use ms = new MemoryStream(4096) - let segment = ArraySegment(ms.ToArray()) - let! result = socket.ReceiveAsync(segment, cancellationToken) - if result.Count = 0 - then - return ValueNone - else - let options = - WebSocketClientMessageConverter(executor, replacements) :> JsonConverter - |> Seq.singleton - |> Json.getSerializerOptions - return JsonSerializer.Deserialize(ms, options) |> ValueSome - } - - let private handleMessages (executor : Executor<'Root>) (root : unit -> 'Root) cancellationToken (socket : GraphQLWebSocket) = task { - - let send id output = Data (id, output) |> sendMessage socket cancellationToken - - let sendMessage = sendMessage socket cancellationToken - - let sendDelayed message = task { - do! Task.Delay 5000 - do! sendMessage message - } - - let handleGQLResponseContent id = - function - | Stream output -> task { - let unsubscriber = output |> Observable.subscribe (fun o -> sendMessage (WebSocketServerMessage.OfResponseContent(id, o)) |> Task.WaitAll) - socket.Subscribe(id, unsubscriber) - } - | Deferred (data, _, output) -> task { - do! send id data - let unsubscriber = output |> Observable.subscribe (fun o -> sendDelayed (WebSocketServerMessage.OfResponseContent(id, o)) |> Task.WaitAll) - socket.Subscribe(id, unsubscriber) - } - - | RequestError errs -> - task { Task.Delay 1 |> ignore } // TODO GBirkel: Placeholder to make build succeed. Replace! - - | Direct (data, _) -> - send id data - try - let mutable loop = true - while loop do - let! message = socket |> receiveMessage executor Map.empty cancellationToken - match message with - | ValueSome ConnectionInit -> - do! sendMessage ConnectionAck - | ValueSome (Start (id, payload)) -> - let! result = executor.AsyncExecute(payload.ExecutionPlan, root(), payload.Variables) - do! handleGQLResponseContent id result - do! Data (id, Dictionary()) |> sendMessage - | ValueSome ConnectionTerminate -> - do! socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None) |> Async.AwaitTask - disposeSocket socket - loop <- false - | ValueSome (ParseError (id, _)) -> - do! Error (id, "Invalid message type!") |> sendMessage - | ValueSome (Stop id) -> - socket.Unsubscribe(id) - do! Complete id |> sendMessage - | ValueNone -> () - with - | _ -> disposeSocket socket - } - - let startSocket (socket : GraphQLWebSocket) (executor : Executor<'Root>) (root : unit -> 'Root) cancellationToken = - sockets.Add(socket.Id, socket) - handleMessages executor root cancellationToken socket - -type GraphQLWebSocketMiddleware<'Root>(next : RequestDelegate, executor : Executor<'Root>, root : unit -> 'Root) = - member _.Invoke(ctx : HttpContext) = - task { - match ctx.WebSockets.IsWebSocketRequest with - | true -> - let! socket = ctx.WebSockets.AcceptWebSocketAsync("graphql-ws") - use socket = new GraphQLWebSocket(socket) - do! SocketManager.startSocket socket executor root CancellationToken.None //ctx.RequestAborted - | false -> - next.Invoke(ctx) |> ignore - } diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/Exceptions.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Exceptions.fs new file mode 100644 index 000000000..a7252b3ac --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Exceptions.fs @@ -0,0 +1,4 @@ +namespace FSharp.Data.GraphQL.Server.AspNetCore + +type InvalidWebsocketMessageException (explanation : string) = + inherit System.Exception (explanation) diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj b/src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj new file mode 100644 index 000000000..330736346 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj @@ -0,0 +1,44 @@ + + + + $(PackageTargetFrameworks) + true + true + FSharp implementation of Facebook GraphQL query language (Application Infrastructure) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/star-wars-api/GQLRequest.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GQLRequest.fs similarity index 94% rename from samples/star-wars-api/GQLRequest.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/GQLRequest.fs index 5b247e293..87aa9589d 100644 --- a/samples/star-wars-api/GQLRequest.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GQLRequest.fs @@ -1,4 +1,4 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi +namespace FSharp.Data.GraphQL.Server.AspNetCore open System.Collections.Immutable open System.Text.Json diff --git a/samples/star-wars-api/Ast.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/Ast.fs similarity index 95% rename from samples/star-wars-api/Ast.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/Ast.fs index 5f0abeaff..202496773 100644 --- a/samples/star-wars-api/Ast.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/Ast.fs @@ -1,4 +1,4 @@ -module FSharp.Data.GraphQL.Samples.StarWarsApi.Ast +module FSharp.Data.GraphQL.Server.AspNetCore.Ast open System.Collections.Immutable open FSharp.Data.GraphQL diff --git a/samples/star-wars-api/HttpContext.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/HttpContext.fs similarity index 83% rename from samples/star-wars-api/HttpContext.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/HttpContext.fs index f40ea94a6..9a5492301 100644 --- a/samples/star-wars-api/HttpContext.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/HttpContext.fs @@ -1,5 +1,5 @@ [] -module Microsoft.AspNetCore.Http.HttpContextExtensions +module FSharp.Data.GraphQL.Server.AspNetCore.HttpContextExtensions open System.Collections.Generic open System.Collections.Immutable @@ -7,10 +7,11 @@ open System.IO open System.Runtime.CompilerServices open System.Text.Json open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Options open FSharp.Core open FsToolkit.ErrorHandling -open Giraffe type HttpContext with @@ -26,14 +27,14 @@ type HttpContext with /// [] member ctx.TryBindJsonAsync<'T>(expectedJson) = taskResult { - let serializer = ctx.GetJsonSerializer() + let serializerOptions = ctx.RequestServices.GetRequiredService>().Value.SerializerOptions let request = ctx.Request try if not request.Body.CanSeek then request.EnableBuffering() - return! serializer.DeserializeAsync<'T> request.Body + return! JsonSerializer.DeserializeAsync<'T>(request.Body, serializerOptions, ctx.RequestAborted) with :? JsonException -> let body = request.Body body.Seek(0, SeekOrigin.Begin) |> ignore diff --git a/samples/star-wars-api/HttpHandlers.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/HttpHandlers.fs similarity index 69% rename from samples/star-wars-api/HttpHandlers.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/HttpHandlers.fs index 6d4822447..6434951af 100644 --- a/samples/star-wars-api/HttpHandlers.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/HttpHandlers.fs @@ -1,13 +1,11 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi +namespace FSharp.Data.GraphQL.Server.AspNetCore.Giraffe open System -open System.Collections.Immutable open System.IO open System.Text.Json open System.Text.Json.Serialization open System.Threading.Tasks open Microsoft.AspNetCore.Http -open Microsoft.AspNetCore.Http.Json open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging open Microsoft.Extensions.Options @@ -17,20 +15,14 @@ open Giraffe open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Ast - -module Constants = - - let [] Idented = "Idented" +open FSharp.Data.GraphQL.Server.AspNetCore type HttpHandler = HttpFunc -> HttpContext -> HttpFuncResult -// See https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.jsonoptions -type MvcJsonOptions = Microsoft.AspNetCore.Mvc.JsonOptions -// See https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.json.jsonoptions -type HttpClientJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions - module HttpHandlers = + let [] internal IndentedOptionsName = "Indented" + let rec private moduleType = getModuleType <@ moduleType @> let ofTaskIResult ctx (taskRes: Task) : HttpFuncResult = task { @@ -44,123 +36,116 @@ module HttpHandlers = |> TaskResult.defaultWith id |> ofTaskIResult ctx - /// Set CORS to allow external servers (React samples) to call this API - let setCorsHeaders : HttpHandler = - setHttpHeader "Access-Control-Allow-Origin" "*" - >=> setHttpHeader "Access-Control-Allow-Headers" "content-type" - - let private graphQL (next : HttpFunc) (ctx : HttpContext) = + let private handleGraphQL<'Root> (next : HttpFunc) (ctx : HttpContext) = let sp = ctx.RequestServices let logger = sp.CreateLogger moduleType + let options = sp.GetRequiredService>>() + let toResponse { DocumentId = documentId; Content = content; Metadata = metadata } = - let serializeIdented value = - let jsonSerializerOptions = - sp - .GetRequiredService>() - .Get(Constants.Idented) - .SerializerOptions + let serializeIndented value = + let jsonSerializerOptions = options.Get(IndentedOptionsName).SerializerOptions JsonSerializer.Serialize(value, jsonSerializerOptions) match content with - | RequestError errs -> - logger.LogInformation( - $"Produced request error GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", - documentId, - metadata - ) - - GQLResponse.RequestError(documentId, errs) | Direct(data, errs) -> - logger.LogInformation( - $"Produced direct GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", + logger.LogDebug( + $"Produced direct GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}", documentId, metadata ) if logger.IsEnabled LogLevel.Trace then - logger.LogTrace($"GraphQL response data:{Environment.NewLine}:{{data}}", serializeIdented data) + logger.LogTrace($"GraphQL response data:\n:{{data}}", serializeIndented data) GQLResponse.Direct(documentId, data, errs) | Deferred(data, errs, deferred) -> - logger.LogInformation( - $"Produced deferred GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", + logger.LogDebug( + $"Produced deferred GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}", documentId, metadata ) - if logger.IsEnabled LogLevel.Information then + if logger.IsEnabled LogLevel.Debug then deferred |> Observable.add (function | DeferredResult(data, path) -> - logger.LogInformation( + logger.LogDebug( "Produced GraphQL deferred result for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join ) if logger.IsEnabled LogLevel.Trace then logger.LogTrace( - $"GraphQL deferred data:{Environment.NewLine}{{data}}", - serializeIdented data + $"GraphQL deferred data:\n{{data}}", + serializeIndented data ) | DeferredErrors(null, errors, path) -> - logger.LogInformation( + logger.LogDebug( "Produced GraphQL deferred errors for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join ) if logger.IsEnabled LogLevel.Trace then - logger.LogTrace($"GraphQL deferred errors:{Environment.NewLine}{{errors}}", errors) + logger.LogTrace($"GraphQL deferred errors:\n{{errors}}", errors) | DeferredErrors(data, errors, path) -> - logger.LogInformation( + logger.LogDebug( "Produced GraphQL deferred result with errors for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join ) if logger.IsEnabled LogLevel.Trace then logger.LogTrace( - $"GraphQL deferred errors:{Environment.NewLine}{{errors}}{Environment.NewLine}GraphQL deferred data:{Environment.NewLine}{{data}}", + $"GraphQL deferred errors:\n{{errors}}\nGraphQL deferred data:\n{{data}}", errors, - serializeIdented data + serializeIndented data )) GQLResponse.Direct(documentId, data, errs) | Stream stream -> - logger.LogInformation( - $"Produced stream GraphQL response with documentId = '{{documentId}}' and metadata:{Environment.NewLine}{{metadata}}", + logger.LogDebug( + $"Produced stream GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}", documentId, metadata ) - if logger.IsEnabled LogLevel.Information then + if logger.IsEnabled LogLevel.Debug then stream |> Observable.add (function | SubscriptionResult data -> - logger.LogInformation("Produced GraphQL subscription result") + logger.LogDebug("Produced GraphQL subscription result") if logger.IsEnabled LogLevel.Trace then logger.LogTrace( - $"GraphQL subscription data:{Environment.NewLine}{{data}}", - serializeIdented data + $"GraphQL subscription data:\n{{data}}", + serializeIndented data ) | SubscriptionErrors(null, errors) -> - logger.LogInformation("Produced GraphQL subscription errors") + logger.LogDebug("Produced GraphQL subscription errors") if logger.IsEnabled LogLevel.Trace then - logger.LogTrace($"GraphQL subscription errors:{Environment.NewLine}{{errors}}", errors) + logger.LogTrace($"GraphQL subscription errors:\n{{errors}}", errors) | SubscriptionErrors(data, errors) -> - logger.LogInformation("Produced GraphQL subscription result with errors") + logger.LogDebug("Produced GraphQL subscription result with errors") if logger.IsEnabled LogLevel.Trace then logger.LogTrace( - $"GraphQL subscription errors:{Environment.NewLine}{{errors}}{Environment.NewLine}GraphQL deferred data:{Environment.NewLine}{{data}}", + $"GraphQL subscription errors:\n{{errors}}\nGraphQL deferred data:\n{{data}}", errors, - serializeIdented data + serializeIndented data )) GQLResponse.Stream documentId + | RequestError errs -> + logger.LogWarning( + $"Produced request error GraphQL response with documentId = '{{documentId}}' and metadata:\n{{metadata}}", + documentId, + metadata + ) + + GQLResponse.RequestError(documentId, errs) /// Checks if the request contains a body let checkIfHasBody (request: HttpRequest) = task { @@ -179,7 +164,7 @@ module HttpHandlers = /// by first checking on such properties as `GET` method or `empty request body` /// and lastly by parsing document AST for introspection operation definition. /// - /// Result of check of + /// Result of check of let checkOperationType (ctx: HttpContext) = taskResult { let checkAnonymousFieldsOnly (ctx: HttpContext) = taskResult { @@ -256,26 +241,32 @@ module HttpHandlers = operationName |> Option.iter (fun on -> logger.LogTrace("GraphQL operation name: '{operationName}'", on)) - logger.LogTrace($"Executing GraphQL query:{Environment.NewLine}{{query}}", content.Query) + logger.LogTrace($"Executing GraphQL query:\n{{query}}", content.Query) variables - |> Option.iter (fun v -> logger.LogTrace($"GraphQL variables:{Environment.NewLine}{{variables}}", v)) + |> Option.iter (fun v -> logger.LogTrace($"GraphQL variables:\n{{variables}}", v)) - let root = Root ctx + let root = options.CurrentValue.RootFactory ctx - let! result = executor.AsyncExecute(content.Ast, root, ?variables = variables, ?operationName = operationName) + let! result = + Async.StartAsTask( + executor.AsyncExecute(content.Ast, root, ?variables = variables, ?operationName = operationName), + cancellationToken = ctx.RequestAborted + ) let response = result |> toResponse return Results.Ok response } - taskResult { - let executor = Schema.executor - ctx.Response.Headers.Add("Request-Type", "Classic") // For integration testing purposes - match! checkOperationType ctx with - | IntrospectionQuery optionalAstDocument -> return! executeIntrospectionQuery executor optionalAstDocument - | OperationQuery content -> return! executeOperation executor content - } - |> ofTaskIResult2 ctx + if ctx.RequestAborted.IsCancellationRequested then + Task.FromResult None + else + taskResult { + let executor = options.CurrentValue.SchemaExecutor + match! checkOperationType ctx with + | IntrospectionQuery optionalAstDocument -> return! executeIntrospectionQuery executor optionalAstDocument + | OperationQuery content -> return! executeOperation executor content + } + |> ofTaskIResult2 ctx - let webApp : HttpHandler = setCorsHeaders >=> choose [ POST; GET ] >=> graphQL + let graphQL<'Root> : HttpHandler = choose [ POST; GET ] >=> handleGraphQL<'Root> diff --git a/samples/star-wars-api/Parser.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/Parser.fs similarity index 83% rename from samples/star-wars-api/Parser.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/Parser.fs index d22399d03..b338a45cd 100644 --- a/samples/star-wars-api/Parser.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Giraffe/Parser.fs @@ -1,4 +1,4 @@ -module FSharp.Data.GraphQL.Samples.StarWarsApi.Parser +module FSharp.Data.GraphQL.Server.AspNetCore.Parser open Microsoft.AspNetCore.Http open FSharp.Data.GraphQL diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLOptions.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLOptions.fs new file mode 100644 index 000000000..537db5fdc --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLOptions.fs @@ -0,0 +1,30 @@ +namespace FSharp.Data.GraphQL.Server.AspNetCore + +open FSharp.Data.GraphQL +open System +open System.Text.Json +open System.Threading.Tasks +open Microsoft.AspNetCore.Http + +type PingHandler = IServiceProvider -> JsonDocument voption -> Task + +type GraphQLTransportWSOptions = { + EndpointUrl : string + ConnectionInitTimeoutInMs : int + CustomPingHandler : PingHandler voption +} + +type IGraphQLOptions = + abstract member SerializerOptions : JsonSerializerOptions + abstract member WebsocketOptions : GraphQLTransportWSOptions + +type GraphQLOptions<'Root> = { + SchemaExecutor : Executor<'Root> + RootFactory : HttpContext -> 'Root + SerializerOptions : JsonSerializerOptions + WebsocketOptions : GraphQLTransportWSOptions +} with + + interface IGraphQLOptions with + member this.SerializerOptions = this.SerializerOptions + member this.WebsocketOptions = this.WebsocketOptions diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLSubscriptionsManagement.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLSubscriptionsManagement.fs new file mode 100644 index 000000000..9e9e7ab84 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLSubscriptionsManagement.fs @@ -0,0 +1,31 @@ +module internal FSharp.Data.GraphQL.Server.AspNetCore.GraphQLSubscriptionsManagement + +open FSharp.Data.GraphQL.Server.AspNetCore.WebSockets + +let addSubscription + (id : SubscriptionId, unsubscriber : SubscriptionUnsubscriber, onUnsubscribe : OnUnsubscribeAction) + (subscriptions : SubscriptionsDict) + = + subscriptions.Add (id, (unsubscriber, onUnsubscribe)) + +let isIdTaken (id : SubscriptionId) (subscriptions : SubscriptionsDict) = subscriptions.ContainsKey (id) + +let executeOnUnsubscribeAndDispose (id : SubscriptionId) (subscription : SubscriptionUnsubscriber * OnUnsubscribeAction) = + match subscription with + | unsubscriber, onUnsubscribe -> + try + id |> onUnsubscribe + finally + unsubscriber.Dispose () + +let removeSubscription (id : SubscriptionId) (subscriptions : SubscriptionsDict) = + if subscriptions.ContainsKey (id) then + subscriptions.[id] |> executeOnUnsubscribeAndDispose id + subscriptions.Remove (id) |> ignore + +let removeAllSubscriptions (subscriptions : SubscriptionsDict) = + subscriptions + |> Seq.iter (fun subscription -> + subscription.Value + |> executeOnUnsubscribeAndDispose subscription.Key) + subscriptions.Clear () diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs new file mode 100644 index 000000000..6713725a7 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs @@ -0,0 +1,361 @@ +namespace FSharp.Data.GraphQL.Server.AspNetCore + +open System +open System.Collections.Generic +open System.Net.WebSockets +open System.Text.Json +open System.Text.Json.Serialization +open System.Threading +open System.Threading.Tasks +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.Logging +open Microsoft.Extensions.Options +open FsToolkit.ErrorHandling + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Execution +open FSharp.Data.GraphQL.Server.AspNetCore.WebSockets + +type GraphQLWebSocketMiddleware<'Root> + ( + next : RequestDelegate, + applicationLifetime : IHostApplicationLifetime, + serviceProvider : IServiceProvider, + logger : ILogger>, + options : IOptions> + ) = + + let options = options.Value + let serializerOptions = options.SerializerOptions + let pingHandler = options.WebsocketOptions.CustomPingHandler + let endpointUrl = PathString options.WebsocketOptions.EndpointUrl + let connectionInitTimeout = options.WebsocketOptions.ConnectionInitTimeoutInMs + + let serializeServerMessage (jsonSerializerOptions : JsonSerializerOptions) (serverMessage : ServerMessage) = task { + let raw = + 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 } + | Error (id, errMsgs) -> { Id = ValueSome id; Type = "error"; Payload = ValueSome <| ErrorMessages errMsgs } + return JsonSerializer.Serialize (raw, jsonSerializerOptions) + } + + static let invalidJsonInClientMessageError = + Result.Error <| InvalidMessage (4400, "Invalid json in client message") + + let deserializeClientMessage (serializerOptions : JsonSerializerOptions) (msg : string) = taskResult { + try + return JsonSerializer.Deserialize (msg, serializerOptions) + with + | :? InvalidWebsocketMessageException as ex -> + logger.LogError(ex, "Invalid websocket message:\n{payload}", msg) + return! Result.Error <| InvalidMessage (4400, ex.Message.ToString ()) + | :? JsonException as ex when logger.IsEnabled(LogLevel.Trace) -> + logger.LogError(ex, "Cannot deserialize WebSocket message:\n{payload}", msg) + return! invalidJsonInClientMessageError + | :? JsonException as ex -> + logger.LogError(ex, "Cannot deserialize WebSocket message") + return! invalidJsonInClientMessageError + | ex -> + logger.LogError(ex, $"Unexpected exception '{ex.GetType().Name}' in GraphQLWebsocketMiddleware") + return! invalidJsonInClientMessageError + } + + let isSocketOpen (theSocket : WebSocket) = + not (theSocket.State = WebSocketState.Aborted) + && not (theSocket.State = WebSocketState.Closed) + && not (theSocket.State = WebSocketState.CloseReceived) + + let canCloseSocket (theSocket : WebSocket) = + not (theSocket.State = WebSocketState.Aborted) + && not (theSocket.State = WebSocketState.Closed) + + let receiveMessageViaSocket (cancellationToken : CancellationToken) (serializerOptions : JsonSerializerOptions) (socket : WebSocket) = taskResult { + let buffer = Array.zeroCreate 4096 + let completeMessage = new List () + let mutable segmentResponse : WebSocketReceiveResult = null + while (not cancellationToken.IsCancellationRequested) + && socket |> isSocketOpen + && ((segmentResponse = null) + || (not segmentResponse.EndOfMessage)) do + try + let! r = socket.ReceiveAsync (new ArraySegment (buffer), cancellationToken) + segmentResponse <- r + completeMessage.AddRange (new ArraySegment (buffer, 0, r.Count)) + with :? OperationCanceledException -> + () + + // TODO: Allocate string only if a debugger is attached + let message = + completeMessage + |> Seq.filter (fun x -> x > 0uy) + |> Array.ofSeq + |> System.Text.Encoding.UTF8.GetString + if String.IsNullOrWhiteSpace message then + return ValueNone + else + let! result = message |> deserializeClientMessage serializerOptions + return ValueSome result + } + + let sendMessageViaSocket (jsonSerializerOptions) (socket : WebSocket) (message : ServerMessage) : Task = task { + if not (socket.State = WebSocketState.Open) then + logger.LogTrace ($"Ignoring message to be sent via socket, since its state is not '{nameof WebSocketState.Open}', but '{{state}}'", socket.State) + else + // TODO: Allocate string only if a debugger is attached + let! serializedMessage = message |> serializeServerMessage jsonSerializerOptions + let segment = new ArraySegment (System.Text.Encoding.UTF8.GetBytes (serializedMessage)) + if not (socket.State = WebSocketState.Open) then + logger.LogTrace ($"Ignoring message to be sent via socket, since its state is not '{nameof WebSocketState.Open}', but '{{state}}'", socket.State) + else + do! socket.SendAsync (segment, WebSocketMessageType.Text, endOfMessage = true, cancellationToken = CancellationToken.None) + + logger.LogTrace ("<- Response: {response}", message) + } + + let addClientSubscription + (id : SubscriptionId) + (howToSendDataOnNext : SubscriptionId -> 'ResponseContent -> Task) + (subscriptions : SubscriptionsDict, + socket : WebSocket, + streamSource : IObservable<'ResponseContent>, + jsonSerializerOptions : JsonSerializerOptions) + = + let observer = + new Reactive.AnonymousObserver<'ResponseContent> ( + onNext = (fun theOutput -> (howToSendDataOnNext id theOutput).Wait ()), + onError = (fun ex -> logger.LogError (ex, "Error on subscription with Id = '{id}'", id)), + onCompleted = + (fun () -> + (sendMessageViaSocket jsonSerializerOptions socket (Complete id)).Wait () + subscriptions + |> GraphQLSubscriptionsManagement.removeSubscription (id)) + ) + + let unsubscriber = streamSource.Subscribe (observer) + + subscriptions + |> GraphQLSubscriptionsManagement.addSubscription (id, unsubscriber, (fun _ -> ())) + + let tryToGracefullyCloseSocket (code, message) theSocket = + if theSocket |> canCloseSocket then + theSocket.CloseAsync (code, message, CancellationToken.None) + else + Task.CompletedTask + + let tryToGracefullyCloseSocketWithDefaultBehavior = + tryToGracefullyCloseSocket (WebSocketCloseStatus.NormalClosure, "Normal Closure") + + let handleMessages (cancellationToken : CancellationToken) (httpContext : HttpContext) (socket : WebSocket) : Task = + let subscriptions = new Dictionary () + // ----------> + // Helpers --> + // ----------> + let rcvMsgViaSocket = receiveMessageViaSocket (CancellationToken.None) + + let sendMsg = sendMessageViaSocket serializerOptions socket + let rcv () = socket |> rcvMsgViaSocket serializerOptions + + let sendOutput id (output : Output) = + match output.TryGetValue "errors" with + | true, theValue -> + // The specification says: "This message terminates the operation and no further messages will be sent." + subscriptions + |> GraphQLSubscriptionsManagement.removeSubscription (id) + sendMsg (Error (id, unbox theValue)) + | false, _ -> sendMsg (Next (id, output)) + + let sendSubscriptionResponseOutput id subscriptionResult = + match subscriptionResult with + | SubscriptionResult output -> output |> sendOutput id + | SubscriptionErrors (output, errors) -> + logger.LogWarning ("Subscription errors: {subscriptionErrors}", (String.Join ('\n', errors |> Seq.map (fun x -> $"- %s{x.Message}")))) + Task.FromResult () + + let sendDeferredResponseOutput id deferredResult = + match deferredResult with + | DeferredResult (obj, path) -> + let output = obj :?> Dictionary + output |> sendOutput id + | DeferredErrors (obj, errors, _) -> + logger.LogWarning ( + "Deferred response errors: {deferredErrors}", + (String.Join ('\n', errors |> Seq.map (fun x -> $"- %s{x.Message}"))) + ) + Task.FromResult () + + let sendDeferredResultDelayedBy (ct : CancellationToken) (ms : int) id deferredResult : Task = task { + do! Task.Delay (ms, ct) + do! deferredResult |> sendDeferredResponseOutput id + } + + let applyPlanExecutionResult (id : SubscriptionId) (socket) (executionResult : GQLExecutionResult) : Task = task { + match executionResult with + | Stream observableOutput -> + (subscriptions, socket, observableOutput, serializerOptions) + |> addClientSubscription id sendSubscriptionResponseOutput + | Deferred (data, errors, observableOutput) -> + do! data |> sendOutput id + if errors.IsEmpty then + (subscriptions, socket, observableOutput, serializerOptions) + |> addClientSubscription id (sendDeferredResultDelayedBy cancellationToken 5000) + else + () + | Direct (data, _) -> do! data |> sendOutput id + | RequestError problemDetails -> + logger.LogWarning("Request errors:\n{errors}", problemDetails) + + } + + let logMsgReceivedWithOptionalPayload optionalPayload (msgAsStr : string) = + match optionalPayload with + | ValueSome payload -> logger.LogTrace ($"{msgAsStr} with payload\n{{messageAddendum}}", (payload : 'Payload)) + | ValueNone -> logger.LogTrace (msgAsStr) + + let logMsgWithIdReceived (id : string) (msgAsStr : string) = logger.LogTrace ($"{msgAsStr}. Id = '{{messageId}}'", id) + + // <-------------- + // <-- Helpers --| + // <-------------- + + // -------> + // Main --> + // -------> + task { + try + while not cancellationToken.IsCancellationRequested + && socket |> isSocketOpen do + let! receivedMessage = rcv () + match receivedMessage with + | Result.Error failureMsgs -> + nameof InvalidMessage + |> logMsgReceivedWithOptionalPayload ValueNone + match failureMsgs with + | InvalidMessage (code, explanation) -> do! socket.CloseAsync (enum code, explanation, CancellationToken.None) + | Ok ValueNone -> logger.LogTrace ("WebSocket received empty message! State = '{socketState}'", socket.State) + | Ok (ValueSome msg) -> + match msg with + | ConnectionInit p -> + nameof ConnectionInit |> logMsgReceivedWithOptionalPayload p + do! + socket.CloseAsync ( + enum CustomWebSocketStatus.TooManyInitializationRequests, + "Too many initialization requests", + CancellationToken.None + ) + | ClientPing p -> + nameof ClientPing |> logMsgReceivedWithOptionalPayload p + match pingHandler with + | ValueSome func -> + let! customP = p |> func serviceProvider + do! ServerPong customP |> sendMsg + | ValueNone -> do! ServerPong p |> sendMsg + | ClientPong p -> nameof ClientPong |> logMsgReceivedWithOptionalPayload p + | Subscribe (id, query) -> + nameof Subscribe |> logMsgWithIdReceived id + if subscriptions |> GraphQLSubscriptionsManagement.isIdTaken id then + do! + let warningMsg : FormattableString = $"Subscriber for Id = '{id}' already exists" + logger.LogWarning (String.Format (warningMsg.Format, "id"), id) + socket.CloseAsync ( + enum CustomWebSocketStatus.SubscriberAlreadyExists, + warningMsg.ToString (), + CancellationToken.None + ) + else + let variables = query.Variables |> Skippable.toOption + let! planExecutionResult = + let root = options.RootFactory httpContext + options.SchemaExecutor.AsyncExecute (query.Query, root, ?variables = variables) + do! planExecutionResult |> applyPlanExecutionResult id socket + | ClientComplete id -> + "ClientComplete" |> logMsgWithIdReceived id + subscriptions + |> GraphQLSubscriptionsManagement.removeSubscription (id) + logger.LogTrace "Leaving the 'graphql-ws' connection loop..." + do! socket |> tryToGracefullyCloseSocketWithDefaultBehavior + with ex -> + logger.LogError (ex, "Cannot handle a message; dropping a websocket connection") + // at this point, only something really weird must have happened. + // In order to avoid faulty state scenarios and unimagined damages, + // just close the socket without further ado. + do! socket |> tryToGracefullyCloseSocketWithDefaultBehavior + } + + // <-------- + // <-- Main + // <-------- + + let waitForConnectionInitAndRespondToClient (socket : WebSocket) : TaskResult = task { + let timerTokenSource = new CancellationTokenSource () + timerTokenSource.CancelAfter connectionInitTimeout + let detonationRegistration = + timerTokenSource.Token.Register (fun _ -> + (socket + |> tryToGracefullyCloseSocket (enum CustomWebSocketStatus.ConnectionTimeout, "Connection initialization timeout")) + .Wait ()) + + let! connectionInitSucceeded = + TaskResult.Run ( + (fun _ -> task { + logger.LogDebug ($"Waiting for {nameof ConnectionInit}...") + let! receivedMessage = receiveMessageViaSocket CancellationToken.None serializerOptions socket + match receivedMessage with + | Ok (ValueSome (ConnectionInit _)) -> + logger.LogDebug ($"Valid {nameof ConnectionInit} received! Responding with ACK!") + detonationRegistration.Unregister () |> ignore + do! + ConnectionAck + |> sendMessageViaSocket serializerOptions socket + return true + | Ok (ValueSome (Subscribe _)) -> + do! + socket + |> tryToGracefullyCloseSocket (enum CustomWebSocketStatus.Unauthorized, "Unauthorized") + return false + | Result.Error (InvalidMessage (code, explanation)) -> + do! + socket + |> tryToGracefullyCloseSocket (enum code, explanation) + return false + | _ -> + do! socket |> tryToGracefullyCloseSocketWithDefaultBehavior + return false + }), + timerTokenSource.Token + ) + if (not timerTokenSource.Token.IsCancellationRequested) then + if connectionInitSucceeded then + return Ok () + else + return Result.Error ($"{nameof ConnectionInit} failed (not because of timeout)") + else + return Result.Error <| "{nameof ConnectionInit} timeout" + } + + member __.InvokeAsync (ctx : HttpContext) = task { + if not (ctx.Request.Path = endpointUrl) then + do! next.Invoke (ctx) + else if ctx.WebSockets.IsWebSocketRequest then + use! socket = ctx.WebSockets.AcceptWebSocketAsync ("graphql-transport-ws") + let! connectionInitResult = socket |> waitForConnectionInitAndRespondToClient + match connectionInitResult with + | Result.Error errMsg -> logger.LogWarning errMsg + | Ok _ -> + let longRunningCancellationToken = + (CancellationTokenSource + .CreateLinkedTokenSource(ctx.RequestAborted, applicationLifetime.ApplicationStopping) + .Token) + longRunningCancellationToken.Register (fun _ -> (socket |> tryToGracefullyCloseSocketWithDefaultBehavior).Wait ()) + |> ignore + try + do! socket |> handleMessages longRunningCancellationToken ctx + with ex -> + logger.LogError (ex, "Cannot handle WebSocket message.") + else + do! next.Invoke (ctx) + } diff --git a/samples/star-wars-api/Helpers.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Helpers.fs similarity index 95% rename from samples/star-wars-api/Helpers.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/Helpers.fs index f68e31494..bfe415ae8 100644 --- a/samples/star-wars-api/Helpers.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Helpers.fs @@ -1,4 +1,4 @@ -namespace FSharp.Data.GraphQL.Samples.StarWarsApi +namespace FSharp.Data.GraphQL.Server.AspNetCore open System open System.Text diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/Messages.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Messages.fs new file mode 100644 index 000000000..93db7cd40 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Messages.fs @@ -0,0 +1,46 @@ +namespace FSharp.Data.GraphQL.Server.AspNetCore.WebSockets + +open System +open System.Collections.Generic +open System.Text.Json +open FSharp.Data.GraphQL.Execution +open FSharp.Data.GraphQL.Server.AspNetCore + +type SubscriptionId = string +type SubscriptionUnsubscriber = IDisposable +type OnUnsubscribeAction = SubscriptionId -> unit +type SubscriptionsDict = IDictionary + +type RawMessage = { Id : string voption; Type : string; Payload : JsonDocument voption } + +type ServerRawPayload = + | ExecutionResult of Output + | ErrorMessages of NameValueLookup list + | CustomResponse of JsonDocument + +type RawServerMessage = { Id : string voption; Type : string; Payload : ServerRawPayload voption } + +type ClientMessage = + | ConnectionInit of payload : JsonDocument voption + | ClientPing of payload : JsonDocument voption + | ClientPong of payload : JsonDocument voption + | Subscribe of id : string * query : GQLRequestContent + | ClientComplete of id : string + +type ClientMessageProtocolFailure = InvalidMessage of code : int * explanation : string + +type ServerMessage = + | ConnectionAck + | ServerPing + | ServerPong of JsonDocument voption + | Next of id : string * payload : Output + | Error of id : string * err : NameValueLookup list + | Complete of id : string + +module CustomWebSocketStatus = + + let InvalidMessage = 4400 + let Unauthorized = 4401 + let ConnectionTimeout = 4408 + let SubscriberAlreadyExists = 4409 + let TooManyInitializationRequests = 4429 diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/README.md b/src/FSharp.Data.GraphQL.Server.AspNetCore/README.md new file mode 100644 index 000000000..547f9d9a3 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/README.md @@ -0,0 +1,99 @@ +## Usage + +### Server + +In a `Startup` class... +```fsharp +namespace MyApp + +open Giraffe +open FSharp.Data.GraphQL.Server.AspNetCore.Giraffe +open FSharp.Data.GraphQL.Server.AspNetCore +open Microsoft.AspNetCore.Server.Kestrel.Core +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.Logging +open System +open System.Text.Json + +type Startup private () = + // Factory for object holding request-wide info. You define Root somewhere else. + let rootFactory () : Root = + { RequestId = Guid.NewGuid().ToString() } + + new (configuration: IConfiguration) as this = + Startup() then + this.Configuration <- configuration + + member _.ConfigureServices(services: IServiceCollection) = + services.AddGiraffe() + .AddGraphQLOptions( // STEP 1: Setting the options + Schema.executor, // --> Schema.executor is defined by you somewhere else (in another file) + rootFactory, + "/ws" // --> endpoint for websocket connections + ) + |> ignore + + member _.Configure(app: IApplicationBuilder, applicationLifetime : IHostApplicationLifetime, loggerFactory : ILoggerFactory) = + let errorHandler (ex : Exception) (log : ILogger) = + log.LogError(EventId(), ex, "An unhandled exception has occurred while executing the request.") + clearResponse >=> setStatusCode 500 + app + .UseGiraffeErrorHandler(errorHandler) + .UseWebSockets() + .UseWebSocketsForGraphQL() // STEP 2: using the GraphQL websocket middleware + .UseGiraffe + (HttpHandlers.handleGraphQL + applicationLifetime.ApplicationStopping + (loggerFactory.CreateLogger("FSharp.Data.GraphQL.Server.AspNetCore.HttpHandlers.handleGraphQL")) + ) + + member val Configuration : IConfiguration = null with get, set + +``` + +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", String) ], + (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", String); Define.Input("isMoon", Boolean) ], + 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))]) +``` + +Finally run the server (e.g. make it listen at `localhost:8086`). + +There's a demo chat application backend in the `samples/chat-app` folder that showcases the use of `FSharp.Data.GraphQL.Server.AspNetCore` in a real-time application scenario, that is: with usage of GraphQL subscriptions (but not only). +The tried and trusted `star-wars-api` also shows how to use subscriptions, but is a more basic example in that regard. As a side note, the implementation in `star-wars-api` was used as a starting point for the development of `FSharp.Data.GraphQL.Server.AspNetCore`. + +### 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 `localhost:8086/graphql` (as per the example above) and, as long as the client implements the `graphql-transport-ws` subprotocol, subscriptions should work. diff --git a/samples/star-wars-api/JSON.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JSON.fs similarity index 61% rename from samples/star-wars-api/JSON.fs rename to src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JSON.fs index 9c8403182..2d949f2ac 100644 --- a/samples/star-wars-api/JSON.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JSON.fs @@ -1,5 +1,5 @@ [] -module FSharp.Data.GraphQL.Samples.StarWarsApi.Json +module FSharp.Data.GraphQL.Server.AspNetCore.Json open System.Text.Json open System.Text.Json.Serialization @@ -11,11 +11,19 @@ let configureSerializerOptions (jsonFSharpOptions: JsonFSharpOptions) (additiona options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase options.PropertyNameCaseInsensitive <- true let converters = options.Converters - converters.Add (JsonStringEnumConverter ()) + converters.Add (new JsonStringEnumConverter ()) //converters.Add (JsonSerializerOptionsState (options)) // Dahomey.Json additionalConverters |> Seq.iter converters.Add jsonFSharpOptions.AddToJsonSerializerOptions options +let configureWSSerializerOptions (jsonFSharpOptions: JsonFSharpOptions) (additionalConverters: JsonConverter seq) (options : JsonSerializerOptions) = + let additionalConverters = seq { + yield new ClientMessageConverter () :> JsonConverter + yield new RawServerMessageConverter () + yield! additionalConverters + } + configureSerializerOptions jsonFSharpOptions additionalConverters options + let defaultJsonFSharpOptions = JsonFSharpOptions( JsonUnionEncoding.InternalTag @@ -29,10 +37,16 @@ let defaultJsonFSharpOptions = allowOverride = true) let configureDefaultSerializerOptions = configureSerializerOptions defaultJsonFSharpOptions +let configureDefaultWSSerializerOptions = configureWSSerializerOptions defaultJsonFSharpOptions let getSerializerOptions (additionalConverters: JsonConverter seq) = let options = JsonSerializerOptions () options |> configureDefaultSerializerOptions additionalConverters options -let serializerOptions = getSerializerOptions Seq.empty +let getWSSerializerOptions (additionalConverters: JsonConverter seq) = + let options = JsonSerializerOptions () + options |> configureDefaultWSSerializerOptions additionalConverters + options + +let serializerOptions = getWSSerializerOptions Seq.empty diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JsonConverters.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JsonConverters.fs new file mode 100644 index 000000000..69253da7b --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/Serialization/JsonConverters.fs @@ -0,0 +1,135 @@ +namespace FSharp.Data.GraphQL.Server.AspNetCore + +open System +open System.Text.Json +open System.Text.Json.Serialization + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Server.AspNetCore.WebSockets + +[] +type ClientMessageConverter () = + inherit JsonConverter () + + let raiseInvalidMsg explanation = raise <| InvalidWebsocketMessageException explanation + + /// From the spec: "Receiving a message of a type or format which is not specified in this document will result in an immediate socket closure with the event 4400: <error-message>. + /// The <error-message> can be vaguely descriptive on why the received message is invalid." + let invalidMsg (explanation : string) = InvalidMessage (4400, explanation) |> Result.Error + + let errMsgToStr (struct (docId : int, graphQLErrorMsgs : GQLProblemDetails list)) = + String.Join ('\n', graphQLErrorMsgs |> Seq.map (fun err -> err.Message)) + + let unpackRopResult ropResult = + match ropResult with + | Ok x -> x + | Result.Error (InvalidMessage (_, explanation : string)) -> raiseInvalidMsg explanation + + let getOptionalString (reader : byref) = + if reader.TokenType.Equals (JsonTokenType.Null) then + ValueNone + else + ValueSome (reader.GetString ()) + + let readPropertyValueAsAString (propertyName : string) (reader : byref) = + if reader.Read () then + getOptionalString (&reader) + else + raiseInvalidMsg + <| $"Was expecting a value for property \"%s{propertyName}\"" + + let requireId (raw : RawMessage) : Result = + match raw.Id with + | ValueSome s -> Ok s + | ValueNone -> + invalidMsg + <| "Property \"id\" is required for this message but was not present." + + let requireSubscribePayload + (serializerOptions : JsonSerializerOptions) + (payload : JsonDocument voption) + : Result = + match payload with + | ValueNone -> + invalidMsg + <| "Payload is required for this message, but none was present." + | ValueSome p -> + try + JsonSerializer.Deserialize(p, serializerOptions) |> Ok + with + | :? JsonException as ex -> + invalidMsg + <| $"Invalid payload received: {ex.Message}." + + let readRawMessage (reader : byref, options : JsonSerializerOptions) : RawMessage = + if not (reader.TokenType.Equals (JsonTokenType.StartObject)) then + raise (new JsonException ($"reader's first token was not \"%A{JsonTokenType.StartObject}\", but \"%A{reader.TokenType}\"")) + else + let mutable id : string voption = ValueNone + let mutable theType : string voption = ValueNone + let mutable payload : JsonDocument voption = ValueNone + while reader.Read () + && (not (reader.TokenType.Equals (JsonTokenType.EndObject))) do + match reader.GetString () with + | "id" -> id <- readPropertyValueAsAString "id" &reader + | "type" -> theType <- readPropertyValueAsAString "type" &reader + | "payload" -> payload <- ValueSome <| JsonDocument.ParseValue (&reader) + | other -> raiseInvalidMsg <| $"Unknown property \"%s{other}\"" + + match theType with + | ValueNone -> raiseInvalidMsg "Property \"type\" is missing" + | ValueSome msgType -> { Id = id; Type = msgType; Payload = payload } + + override __.Read (reader : byref, typeToConvert : Type, options : JsonSerializerOptions) : ClientMessage = + let raw = readRawMessage (&reader, options) + match raw.Type with + | "connection_init" -> ConnectionInit raw.Payload + | "ping" -> ClientPing raw.Payload + | "pong" -> ClientPong raw.Payload + | "complete" -> + raw + |> requireId + |> Result.map ClientComplete + |> unpackRopResult + | "subscribe" -> + raw + |> requireId + |> Result.bind (fun id -> + raw.Payload + |> requireSubscribePayload options + |> Result.map (fun payload -> (id, payload))) + |> Result.map Subscribe + |> unpackRopResult + | other -> + raiseInvalidMsg + <| $"Invalid type \"%s{other}\" specified by client." + + + override __.Write (writer : Utf8JsonWriter, value : ClientMessage, options : JsonSerializerOptions) = + raise (NotSupportedException "Serializing a WebSocketClientMessage is not supported (yet(?))") + +[] +type RawServerMessageConverter () = + inherit JsonConverter () + + override __.Read (reader : byref, typeToConvert : Type, options : JsonSerializerOptions) : RawServerMessage = + raise (NotSupportedException "deserializing a RawServerMessage is not supported (yet(?))") + + override __.Write (writer : Utf8JsonWriter, value : RawServerMessage, options : JsonSerializerOptions) = + writer.WriteStartObject () + writer.WriteString ("type", value.Type) + match value.Id with + | ValueNone -> () + | ValueSome id -> writer.WriteString ("id", id) + + match value.Payload with + | ValueNone -> () + | ValueSome serverRawPayload -> + match serverRawPayload with + | ExecutionResult output -> + writer.WritePropertyName ("payload") + JsonSerializer.Serialize (writer, output, options) + | ErrorMessages msgs -> JsonSerializer.Serialize (writer, msgs, options) + | CustomResponse jsonDocument -> jsonDocument.WriteTo (writer) + + writer.WriteEndObject () diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/StartupExtensions.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/StartupExtensions.fs new file mode 100644 index 000000000..f539b73b7 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/StartupExtensions.fs @@ -0,0 +1,74 @@ +namespace FSharp.Data.GraphQL.Server.AspNetCore + +open System +open System.Runtime.InteropServices +open System.Runtime.CompilerServices +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection +open FSharp.Data.GraphQL +open Microsoft.Extensions.Options + +[] +module ServiceCollectionExtensions = + + let createStandardOptions executor rootFactory endpointUrl = { + SchemaExecutor = executor + RootFactory = rootFactory + SerializerOptions = Json.serializerOptions + WebsocketOptions = { + EndpointUrl = endpointUrl + ConnectionInitTimeoutInMs = 3000 + CustomPingHandler = ValueNone + } + } + + // See https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.jsonoptions + type MvcJsonOptions = Microsoft.AspNetCore.Mvc.JsonOptions + // See https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.json.jsonoptions + type HttpClientJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions + + type IServiceCollection with + + [] + member services.AddGraphQLOptions<'Root> + ( + executor : Executor<'Root>, + rootFactory : HttpContext -> 'Root, + endpointUrl : string, + [] configure : Func, GraphQLOptions<'Root>> + ) = + let options = + let options = createStandardOptions executor rootFactory endpointUrl + match configure with + | null -> options + | _ -> configure.Invoke options + services + // We need this for output serialization purposes as we use + // Surprisingly minimal APIs use Microsoft.AspNetCore.Http.Json.JsonOptions + // Use if you want to return HTTP responses using minmal APIs IResult interface + .Configure( + Action(fun o -> + Json.configureDefaultSerializerOptions Seq.empty o.SerializerOptions + ) + ) + .AddSingleton>>( + { new IOptionsFactory> with + member this.Create name = options + } + ) + .Configure>(Giraffe.HttpHandlers.IndentedOptionsName, (fun o -> o.SerializerOptions.WriteIndented <- true)) + .AddSingleton>(fun sp -> + { new IOptionsFactory with + member this.Create name = + sp.GetRequiredService>>().Get(name) + } + ) + +[] +module ApplicationBuilderExtensions = + + type IApplicationBuilder with + + [] + member builder.UseWebSocketsForGraphQL<'Root> () = builder.UseMiddleware> () diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests.Server/FSharp.Data.GraphQL.IntegrationTests.Server.fsproj b/tests/FSharp.Data.GraphQL.IntegrationTests.Server/FSharp.Data.GraphQL.IntegrationTests.Server.fsproj index 679dfc155..0c3c27d86 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests.Server/FSharp.Data.GraphQL.IntegrationTests.Server.fsproj +++ b/tests/FSharp.Data.GraphQL.IntegrationTests.Server/FSharp.Data.GraphQL.IntegrationTests.Server.fsproj @@ -16,19 +16,9 @@ - - - - - - - - - - @@ -36,5 +26,6 @@ + diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests.Server/Startup.fs b/tests/FSharp.Data.GraphQL.IntegrationTests.Server/Startup.fs index 2680d31e8..f61c83cb9 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests.Server/Startup.fs +++ b/tests/FSharp.Data.GraphQL.IntegrationTests.Server/Startup.fs @@ -2,16 +2,25 @@ namespace FSharp.Data.GraphQL.IntegrationTests.Server open System open Microsoft.AspNetCore.Builder -open Microsoft.AspNetCore.Server.Kestrel.Core +open Microsoft.AspNetCore.Http open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging -open Microsoft.Extensions.Options open Giraffe +open FSharp.Data.GraphQL.Server.AspNetCore +open FSharp.Data.GraphQL.Server.AspNetCore.Giraffe open FSharp.Data.GraphQL.Samples.StarWarsApi +open Microsoft.Extensions.Hosting + +module Constants = + let [] Indented = "Indented" type Startup private () = + + let rootFactory (httpContext: HttpContext) : Root = + Root(httpContext) + new (configuration: IConfiguration) as this = Startup() then this.Configuration <- configuration @@ -19,36 +28,23 @@ type Startup private () = member __.ConfigureServices(services: IServiceCollection) = services .AddGiraffe() - .Configure(Action(fun x -> x.AllowSynchronousIO <- true)) - .Configure(Action(fun x -> x.AllowSynchronousIO <- true)) - // Surprisingly minimal APIs use Microsoft.AspNetCore.Http.Json.JsonOptions - // Use if you want to return HTTP responses using minmal APIs IResult interface - .Configure( - Action(fun o -> - Json.configureDefaultSerializerOptions Seq.empty o.SerializerOptions - ) + .AddGraphQLOptions( + Schema.executor, + rootFactory, + "/ws" ) - // Use for pretty printing in logs - .Configure( - Constants.Idented, - Action(fun o -> - Json.configureDefaultSerializerOptions Seq.empty o.SerializerOptions - o.SerializerOptions.WriteIndented <- true - ) - ) - // Replace Newtonsoft.Json and use the same settings in Giraffe - .AddSingleton(fun sp -> - let options = sp.GetService>() - SystemTextJson.Serializer(options.Value.SerializerOptions)) - |> ignore member __.Configure(app: IApplicationBuilder) = let errorHandler (ex : Exception) (log : ILogger) = log.LogError(EventId(), ex, "An unhandled exception has occurred while executing the request.") clearResponse >=> setStatusCode 500 + let applicationLifeTime = app.ApplicationServices.GetRequiredService() + let loggerFactory = app.ApplicationServices.GetRequiredService() app .UseGiraffeErrorHandler(errorHandler) - .UseGiraffe HttpHandlers.webApp + .UseGiraffe ( + (setHttpHeader "Request-Type" "Classic") + >=> HttpHandlers.graphQL) member val Configuration : IConfiguration = null with get, set diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json index 3caa72376..c3fe6fa10 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json @@ -1,5 +1,5 @@ { - "documentId": 1360354553, + "documentId": -727244275, "data": { "__schema": { "queryType": { diff --git a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/InvalidMessageTests.fs b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/InvalidMessageTests.fs new file mode 100644 index 000000000..7075962f9 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/InvalidMessageTests.fs @@ -0,0 +1,134 @@ +module FSharp.Data.GraphQL.Tests.AspNetCore.InvalidMessageTests + +open System.Text.Json +open Xunit +open FSharp.Data.GraphQL.Server.AspNetCore +open FSharp.Data.GraphQL.Server.AspNetCore.WebSockets + +let toClientMessage (theInput : string) = + let serializerOptions = Json.serializerOptions + JsonSerializer.Deserialize (theInput, serializerOptions) + +let willResultInInvalidMessage expectedExplanation input = + try + let result = input |> toClientMessage + Assert.Fail (sprintf "should have failed, but succeeded with result: '%A'" result) + with + | :? JsonException as ex -> Assert.Equal (expectedExplanation, ex.Message) + | :? InvalidWebsocketMessageException as ex -> Assert.Equal (expectedExplanation, ex.Message) + +let willResultInJsonException input = + try + input |> toClientMessage |> ignore + Assert.Fail ("Expected that a JsonException would have already been thrown at this point") + with :? JsonException as ex -> + Assert.True (true) + +[] +let ``Unknown message type will result in invalid message`` () = + """{ + "type": "connection_start" + } + """ + |> willResultInInvalidMessage "Invalid type \"connection_start\" specified by client." + +[] +let ``Type not specified will result in invalid message`` () = + """{ + "payload": "hello, let us connect" + } + """ + |> willResultInInvalidMessage "Property \"type\" is missing" + +[] +let ``No payload in subscribe message will result in invalid message`` () = + """{ + "type": "subscribe", + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6" + } + """ + |> willResultInInvalidMessage "Payload is required for this message, but none was present." + +[] +let ``Null payload json in subscribe message will result in invalid message`` () = + """{ + "type": "subscribe", + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", + "payload": null + } + """ + |> willResultInInvalidMessage "Invalid payload received: Failed to parse type FSharp.Data.GraphQL.Server.AspNetCore.GQLRequestContent: expected JSON object, found Null." + +[] +let ``Payload type of number in subscribe message will result in invalid message`` () = + """{ + "type": "subscribe", + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", + "payload": 42 + } + """ + |> willResultInInvalidMessage + "Invalid payload received: Failed to parse type FSharp.Data.GraphQL.Server.AspNetCore.GQLRequestContent: expected JSON object, found Number." + +[] +let ``No id in subscribe message will result in invalid message`` () = + """{ + "type": "subscribe", + "payload": { + "query": "subscription { watchMoon(id: \"1\") { id name isMoon } }" + } + } + """ + |> willResultInInvalidMessage "Property \"id\" is required for this message but was not present." + +[] +let ``String payload wrongly used in subscribe will result in invalid message`` () = + """{ + "type": "subscribe", + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", + "payload": "{\"query\": \"subscription { watchMoon(id: \\\"1\\\") { id name isMoon } }\"}" + } + """ + |> willResultInInvalidMessage + "Invalid payload received: Failed to parse type FSharp.Data.GraphQL.Server.AspNetCore.GQLRequestContent: expected JSON object, found String." + +[] +let ``Id is incorrectly a number in a subscribe message will result in JsonException`` () = + """{ + "type": "subscribe", + "id": 42, + "payload": { + "query": "subscription { watchMoon(id: \"1\") { id name isMoon } }" + } + } + """ + |> willResultInJsonException + +[] +let ``Typo in one of the messages root properties will result in invalid message`` () = + """{ + "typo": "subscribe", + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", + "payload": { + "query": "subscription { watchMoon(id: \"1\") { id name isMoon } }" + } + } + """ + |> willResultInInvalidMessage "Unknown property \"typo\"" + +[] +let ``Complete message without an id will result in invalid message`` () = + """{ + "type": "complete" + } + """ + |> willResultInInvalidMessage "Property \"id\" is required for this message but was not present." + +[] +let ``Complete message with a null id will result in invalid message`` () = + """{ + "type": "complete", + "id": null + } + """ + |> willResultInInvalidMessage "Property \"id\" is required for this message but was not present." diff --git a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/SerializationTests.fs b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/SerializationTests.fs new file mode 100644 index 000000000..05bcab916 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/SerializationTests.fs @@ -0,0 +1,110 @@ +module FSharp.Data.GraphQL.Tests.AspNetCore.SerializationTests + +open Xunit +open System.Text.Json +open FSharp.Data.GraphQL.Ast +open FSharp.Data.GraphQL.Server.AspNetCore +open FSharp.Data.GraphQL.Server.AspNetCore.WebSockets +open System.Text.Json.Serialization + +let serializerOptions = Json.serializerOptions + +[] +let ``Deserializes ConnectionInit correctly`` () = + + let input = "{\"type\":\"connection_init\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ConnectionInit ValueNone -> () // <-- expected + | other -> Assert.Fail ($"unexpected actual value: '%A{other}'") + +[] +let ``Deserializes ConnectionInit with payload correctly`` () = + + let input = "{\"type\":\"connection_init\", \"payload\":\"hello\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ConnectionInit _ -> () // <-- expected + | other -> Assert.Fail ($"unexpected actual value: '%A{other}'") + +[] +let ``Deserializes ClientPing correctly`` () = + + let input = "{\"type\":\"ping\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ClientPing ValueNone -> () // <-- expected + | other -> Assert.Fail ($"unexpected actual value '%A{other}'") + +[] +let ``Deserializes ClientPing with payload correctly`` () = + + let input = "{\"type\":\"ping\", \"payload\":\"ping!\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ClientPing _ -> () // <-- expected + | other -> Assert.Fail ($"unexpected actual value '%A{other}'") + +[] +let ``Deserializes ClientPong correctly`` () = + + let input = "{\"type\":\"pong\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ClientPong ValueNone -> () // <-- expected + | other -> Assert.Fail ($"unexpected actual value: '%A{other}'") + +[] +let ``Deserializes ClientPong with payload correctly`` () = + + let input = "{\"type\":\"pong\", \"payload\": \"pong!\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ClientPong _ -> () // <-- expected + | other -> Assert.Fail ($"unexpected actual value: '%A{other}'") + +[] +let ``Deserializes ClientComplete correctly`` () = + + let input = "{\"id\": \"65fca2b5-f149-4a70-a055-5123dea4628f\", \"type\":\"complete\"}" + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | ClientComplete id -> Assert.Equal ("65fca2b5-f149-4a70-a055-5123dea4628f", id) + | other -> Assert.Fail ($"unexpected actual value: '%A{other}'") + +[] +let ``Deserializes client subscription correctly`` () = + + let input = + """{ + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", + "type": "subscribe", + "payload" : { + "query": "subscription { watchMoon(id: \"1\") { id name isMoon } }" + } + } + """ + + let result = JsonSerializer.Deserialize (input, serializerOptions) + + match result with + | Subscribe (id, payload) -> + Assert.Equal ("b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", id) + Assert.Equal ("subscription { watchMoon(id: \"1\") { id name isMoon } }", payload.Query) + Assert.Equal (Skip, payload.OperationName) + Assert.Equal (Skip, payload.Variables) + | other -> Assert.Fail ($"unexpected actual value: '%A{other}'") diff --git a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/TestSchema.fs b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/TestSchema.fs new file mode 100644 index 000000000..a7899c66f --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/TestSchema.fs @@ -0,0 +1,282 @@ +namespace FSharp.Data.GraphQL.Tests.AspNetCore + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Types + +#nowarn "40" + +type Episode = + | NewHope = 1 + | Empire = 2 + | Jedi = 3 + +type Human = { + Id : string + Name : string option + Friends : string list + AppearsIn : Episode list + HomePlanet : string option +} + +type Droid = { + Id : string + Name : string option + Friends : string list + AppearsIn : Episode list + PrimaryFunction : string option +} + +type Planet = { + Id : string + Name : string option + mutable IsMoon : bool option +} with + + member x.SetMoon b = + x.IsMoon <- b + x + +type Root = { RequestId : string } + +type Character = + | Human of Human + | Droid of Droid + +module TestSchema = + let humans = [ + { + Id = "1000" + Name = Some "Luke Skywalker" + Friends = [ "1002"; "1003"; "2000"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = Some "Tatooine" + } + { + Id = "1001" + Name = Some "Darth Vader" + Friends = [ "1004" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = Some "Tatooine" + } + { + Id = "1002" + Name = Some "Han Solo" + Friends = [ "1000"; "1003"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = None + } + { + Id = "1003" + Name = Some "Leia Organa" + Friends = [ "1000"; "1002"; "2000"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = Some "Alderaan" + } + { + Id = "1004" + Name = Some "Wilhuff Tarkin" + Friends = [ "1001" ] + AppearsIn = [ Episode.NewHope ] + HomePlanet = None + } + ] + + let droids = [ + { + Id = "2000" + Name = Some "C-3PO" + Friends = [ "1000"; "1002"; "1003"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + PrimaryFunction = Some "Protocol" + } + { + Id = "2001" + Name = Some "R2-D2" + Friends = [ "1000"; "1002"; "1003" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + PrimaryFunction = Some "Astromech" + } + ] + + let planets = [ + { Id = "1"; Name = Some "Tatooine"; IsMoon = Some false } + { Id = "2"; Name = Some "Endor"; IsMoon = Some true } + { Id = "3"; Name = Some "Death Star"; IsMoon = Some false } + ] + + let getHuman id = humans |> List.tryFind (fun h -> h.Id = id) + + let getDroid id = droids |> List.tryFind (fun d -> d.Id = id) + + let getPlanet id = planets |> List.tryFind (fun p -> p.Id = id) + + let characters = (humans |> List.map Human) @ (droids |> List.map Droid) + + let matchesId id = + function + | Human h -> h.Id = id + | Droid d -> d.Id = id + + let getCharacter id = characters |> List.tryFind (matchesId id) + + let EpisodeType = + Define.Enum ( + name = "Episode", + description = "One of the films in the Star Wars Trilogy.", + options = [ + Define.EnumValue ("NewHope", Episode.NewHope, "Released in 1977.") + Define.EnumValue ("Empire", Episode.Empire, "Released in 1980.") + Define.EnumValue ("Jedi", Episode.Jedi, "Released in 1983.") + ] + ) + + let rec CharacterType = + Define.Union ( + name = "Character", + description = "A character in the Star Wars Trilogy.", + options = [ HumanType; DroidType ], + resolveValue = + (fun o -> + match o with + | Human h -> box h + | Droid d -> upcast d), + resolveType = + (fun o -> + match o with + | Human _ -> upcast HumanType + | Droid _ -> upcast DroidType) + ) + + and HumanType : ObjectDef = + Define.Object ( + name = "Human", + description = "A humanoid creature in the Star Wars universe.", + isTypeOf = (fun o -> o :? Human), + fieldsFn = + fun () -> [ + Define.Field ("id", StringType, "The id of the human.", (fun _ (h : Human) -> h.Id)) + Define.Field ("name", Nullable StringType, "The name of the human.", (fun _ (h : Human) -> h.Name)) + Define.Field ( + "friends", + ListOf (Nullable CharacterType), + "The friends of the human, or an empty list if they have none.", + fun _ (h : Human) -> h.Friends |> List.map getCharacter |> List.toSeq + ) + Define.Field ("appearsIn", ListOf EpisodeType, "Which movies they appear in.", (fun _ (h : Human) -> h.AppearsIn)) + Define.Field ("homePlanet", Nullable StringType, "The home planet of the human, or null if unknown.", (fun _ h -> h.HomePlanet)) + ] + ) + + and DroidType = + Define.Object ( + name = "Droid", + description = "A mechanical creature in the Star Wars universe.", + isTypeOf = (fun o -> o :? Droid), + fieldsFn = + fun () -> [ + Define.Field ("id", StringType, "The id of the droid.", (fun _ (d : Droid) -> d.Id)) + Define.Field ("name", Nullable StringType, "The name of the Droid.", (fun _ (d : Droid) -> d.Name)) + Define.Field ( + "friends", + ListOf (Nullable CharacterType), + "The friends of the Droid, or an empty list if they have none.", + fun _ (d : Droid) -> d.Friends |> List.map getCharacter |> List.toSeq + ) + Define.Field ("appearsIn", ListOf EpisodeType, "Which movies they appear in.", (fun _ d -> d.AppearsIn)) + Define.Field ("primaryFunction", Nullable StringType, "The primary function of the droid.", (fun _ d -> d.PrimaryFunction)) + ] + ) + + and PlanetType = + Define.Object ( + name = "Planet", + description = "A planet in the Star Wars universe.", + isTypeOf = (fun o -> o :? Planet), + fieldsFn = + fun () -> [ + Define.Field ("id", StringType, "The id of the planet", (fun _ p -> p.Id)) + Define.Field ("name", Nullable StringType, "The name of the planet.", (fun _ p -> p.Name)) + Define.Field ("isMoon", Nullable BooleanType, "Is that a moon?", (fun _ p -> p.IsMoon)) + ] + ) + + and RootType = + Define.Object ( + name = "Root", + description = "The Root type to be passed to all our resolvers.", + isTypeOf = (fun o -> o :? Root), + fieldsFn = + fun () -> [ + Define.Field ("requestId", StringType, "The ID of the client.", (fun _ (r : Root) -> r.RequestId)) + ] + ) + + let Query = + Define.Object ( + name = "Query", + fields = [ + Define.Field ( + "hero", + Nullable HumanType, + "Gets human hero", + [ Define.Input ("id", StringType) ], + fun ctx _ -> getHuman (ctx.Arg ("id")) + ) + Define.Field ( + "droid", + Nullable DroidType, + "Gets droid", + [ Define.Input ("id", StringType) ], + fun ctx _ -> getDroid (ctx.Arg ("id")) + ) + Define.Field ( + "planet", + Nullable PlanetType, + "Gets planet", + [ Define.Input ("id", StringType) ], + fun ctx _ -> getPlanet (ctx.Arg ("id")) + ) + Define.Field ("characters", ListOf CharacterType, "Gets characters", (fun _ _ -> characters)) + ] + ) + + 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) + ) + ] + ) + + let schemaConfig = SchemaConfig.Default + + 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 + schemaConfig.LiveFieldSubscriptionProvider.Publish "Planet" "isMoon" x + x) + ) + ] + ) + + let schema : ISchema = upcast Schema (Query, Mutation, Subscription, schemaConfig) + + let executor = Executor (schema, []) diff --git a/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs b/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs index 2feb909ff..2b8a7407b 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs @@ -14,17 +14,17 @@ type ErrorSource = let ensureDeferred (result : GQLExecutionResult) (onDeferred : Output -> GQLProblemDetails list -> IObservable -> unit) : unit = match result.Content with | Deferred(data, errors, deferred) -> onDeferred data errors deferred - | response -> fail $"Expected a Deferred GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected a 'Deferred' GQLResponse but got\n{response}" let ensureDirect (result : GQLExecutionResult) (onDirect : Output -> GQLProblemDetails list -> unit) : unit = match result.Content with | Direct(data, errors) -> onDirect data errors - | response -> fail $"Expected a Direct GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" let ensureRequestError (result : GQLExecutionResult) (onRequestError : GQLProblemDetails list -> unit) : unit = match result.Content with | RequestError errors -> onRequestError errors - | response -> fail $"Expected RequestError GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected 'RequestError' GQLResponse but got\n{response}" let ensureValidationError (message : string) (path : FieldPath) (error : GQLProblemDetails) = equals message error.Message diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index dac698e39..99ffafa01 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -16,7 +16,7 @@ open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore type TestSubject = { a: string @@ -380,7 +380,7 @@ let ``Execution when querying returns unique document id with response`` () = | Direct(data1, errors1), Direct(data2, errors2) -> equals data1 data2 equals errors1 errors2 - | response -> fail $"Expected a Direct GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" type InnerNullableTest = { Kaboom : string } type NullableTest = { Inner : InnerNullableTest } diff --git a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj index 8abaab60a..cc744167e 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -24,7 +24,6 @@ - @@ -67,6 +66,9 @@ + + + @@ -78,6 +80,7 @@ + \ No newline at end of file diff --git a/tests/FSharp.Data.GraphQL.Tests/Helpers.fs b/tests/FSharp.Data.GraphQL.Tests/Helpers.fs index a50199789..ebed4962d 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Helpers.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Helpers.fs @@ -18,7 +18,7 @@ let isDict<'k, 'v> actual = isSeq> actual let isNameValueDict actual = isDict actual let fail (message: string) = Assert.Fail message let equals (expected : 'x) (actual : 'x) = - if not (actual = expected) then fail <| $"expected %A{expected}{Environment.NewLine}but got %A{actual}" + if not (actual = expected) then fail <| $"expected %A{expected}\nbut got %A{actual}" let notEquals (expected : 'x) (actual : 'x) = if actual = expected then fail <| $"unexpected %+A{expected}" let noErrors (result: IDictionary) = @@ -74,7 +74,7 @@ let greaterThanOrEqual expected actual = open System.Text.Json open FSharp.Data.GraphQL.Types -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore let stringifyArg name (ctx : ResolveFieldContext) () = let arg = ctx.TryArg name |> Option.toObj @@ -167,7 +167,6 @@ module Observer = new TestObserver<'T>(sub, onReceive) open System.Runtime.CompilerServices -open FSharp.Data.GraphQL.Types [] type ExecutorExtensions = diff --git a/tests/FSharp.Data.GraphQL.Tests/IntrospectionTests.fs b/tests/FSharp.Data.GraphQL.Tests/IntrospectionTests.fs index 6828af3ed..195e24a11 100644 --- a/tests/FSharp.Data.GraphQL.Tests/IntrospectionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/IntrospectionTests.fs @@ -7,7 +7,7 @@ open Xunit open System open System.Text.Json open System.Text.Json.Serialization -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore #nowarn "25" diff --git a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs index 473dc8c0b..aaf31f99e 100644 --- a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs @@ -455,7 +455,7 @@ let ``Inline fragment query : Should not pass when above threshold``() = let result = execute query match result with | RequestError errors -> errors |> equals expectedErrors - | response -> fail $"Expected RequestError GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected 'RequestError' GQLResponse but got\n{response}" ensureRequestError result <| fun errors -> errors |> equals expectedErrors diff --git a/tests/FSharp.Data.GraphQL.Tests/MutationTests.fs b/tests/FSharp.Data.GraphQL.Tests/MutationTests.fs index c2ad52cc2..def1055f7 100644 --- a/tests/FSharp.Data.GraphQL.Tests/MutationTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/MutationTests.fs @@ -76,7 +76,7 @@ let ``Execute handles mutation execution ordering: evaluates mutations serially` | Direct(data, errors) -> empty errors data |> equals (upcast expected) - | response -> fail $"Expected a Direct GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" [] let ``Execute handles mutation execution ordering: evaluates mutations correctly in the presense of failures`` () = @@ -117,7 +117,7 @@ let ``Execute handles mutation execution ordering: evaluates mutations correctly | Direct(data, errors) -> data |> equals (upcast expected) List.length errors |> equals 2 - | response -> fail $"Expected a Direct GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" //[] //let ``Execute handles mutation with multiple arguments`` () = @@ -136,4 +136,4 @@ let ``Execute handles mutation execution ordering: evaluates mutations correctly // | Direct(data, errors) -> // empty errors // data |> equals (upcast expected) -// | response -> fail $"Expected a Direct GQLResponse but got {Environment.NewLine}{response}" +// | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" diff --git a/tests/FSharp.Data.GraphQL.Tests/Relay/CursorTests.fs b/tests/FSharp.Data.GraphQL.Tests/Relay/CursorTests.fs index ea295b44f..74172dac6 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Relay/CursorTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Relay/CursorTests.fs @@ -99,4 +99,4 @@ let ``Relay cursor works for types with nested fileds`` () = match result with | Direct (_, errors) -> empty errors - | response -> fail $"Expected a Direct GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs index 5262ee3ab..ecebe84e4 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs @@ -16,7 +16,7 @@ open FSharp.Data.GraphQL.Ast open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore open ErrorHelpers let TestComplexScalar = diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputEnumTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputEnumTests.fs index 333644a31..5229b190b 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputEnumTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputEnumTests.fs @@ -14,7 +14,7 @@ open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore let stringifyArg name (ctx : ResolveFieldContext) () = let arg = ctx.TryArg name |> Option.toObj diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputListTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputListTests.fs index e475fd407..d5b8de7eb 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputListTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputListTests.fs @@ -14,7 +14,7 @@ open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore open ErrorHelpers let stringifyArg name (ctx : ResolveFieldContext) () = diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs index a024c78c9..2ba48355d 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs @@ -13,7 +13,7 @@ open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore let InputArrayOf (innerDef : #TypeDef<'Val>) : ListOfDef<'Val, 'Val array> = ListOf innerDef diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNullableStringTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNullableStringTests.fs index 1bace1186..9ae2386b7 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNullableStringTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNullableStringTests.fs @@ -14,7 +14,7 @@ open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore open ErrorHelpers let stringifyArg name (ctx : ResolveFieldContext) () = diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputObjectValidatorTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputObjectValidatorTests.fs index 255fba572..d9bb21a16 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputObjectValidatorTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputObjectValidatorTests.fs @@ -15,7 +15,7 @@ open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution open FSharp.Data.GraphQL.Validation open FSharp.Data.GraphQL.Validation.ValidationResult -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore open ErrorHelpers type InputRecord = { Country : string; ZipCode : string; City : string } @@ -144,7 +144,7 @@ let ``Execute handles validation of invalid inline input records with all fields | RequestError [ zipCodeError ; addressError ] -> zipCodeError |> ensureInputObjectValidationError (Argument "record") "ZipCode must be 5 characters for US" [] "InputRecord!" addressError |> ensureInputObjectValidationError (Argument "recordNested") "HomeAddress and MailingAddress must be different" [] "InputRecordNested" - | response -> fail $"Expected RequestError GQLResponse but got {Environment.NewLine}{response}" + | response -> fail $"Expected 'RequestError' GQLResponse but got\n{response}" let variablesWithAllInputs (record, record1, record2, record3) = diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs index af9f028a0..cf38f0ac7 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs @@ -11,7 +11,7 @@ open System.Text.Json open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore type InputRecord = { a : string; b : string; c : string } diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs index 34717d2ad..1fb88fc2e 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs @@ -13,7 +13,7 @@ open System.Text.Json open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser -open FSharp.Data.GraphQL.Samples.StarWarsApi +open FSharp.Data.GraphQL.Server.AspNetCore module Phantom =