diff --git a/.github/workflows/branches-ubuntu.yml b/.github/workflows/branches-ubuntu.yml new file mode 100644 index 00000000..488f6596 --- /dev/null +++ b/.github/workflows/branches-ubuntu.yml @@ -0,0 +1,70 @@ +name: Branch workflow (Ubuntu) +on: + push: + branches-ignore: + - develop + - 'release/**' + - 'releases/**' +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true +jobs: + generateVersionInfo: + name: GenerateVersionInfo + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Restore dotnet tools + run: dotnet tool restore + - name: Fetch complete repository + run: git fetch + - name: Generate version info from git history + run: dotnet gitversion /output json > gitversion.json + - name: Upload version info file + uses: actions/upload-artifact@v1 + with: + name: gitversion + path: gitversion.json + + build: + name: Build + needs: generateVersionInfo + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Download version info file + uses: actions/download-artifact@v1 + with: + name: gitversion + path: ./ + - name: Inject version info into environment + run: jq -r 'to_entries|map("::set-env name=GitVersion_\(.key)::\(.value|tostring)")|.[]' gitversion.json + - name: Build solution + run: echo "Current version is \"$GitVersion_SemVer\"" && dotnet build -c Release + - name: Create NuGet packages + run: dotnet pack -c Release --no-build -o nupkg + - name: Upload nuget packages + uses: actions/upload-artifact@v1 + with: + name: nupkg + path: nupkg + + test: + name: Test + needs: [build, generateVersionInfo] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Download version info file + uses: actions/download-artifact@v1 + with: + name: gitversion + path: ./ + - name: Inject version info into environment + run: jq -r 'to_entries|map("::set-env name=GitVersion_\(.key)::\(.value|tostring)")|.[]' gitversion.json + - name: Run tests + run: echo "Current version is \"$GitVersion_SemVer\"" && dotnet test -c Release diff --git a/.github/workflows/branches.yml b/.github/workflows/branches-windows.yml similarity index 97% rename from .github/workflows/branches.yml rename to .github/workflows/branches-windows.yml index a896d523..430b2121 100644 --- a/.github/workflows/branches.yml +++ b/.github/workflows/branches-windows.yml @@ -1,10 +1,8 @@ name: Branch workflow -on: +on: push: branches-ignore: - - develop - - 'release/**' - - 'releases/**' + - '**' jobs: generateVersionInfo: name: GenerateVersionInfo diff --git a/.github/workflows/main-ubuntu.yml b/.github/workflows/main-ubuntu.yml new file mode 100644 index 00000000..bb58dcff --- /dev/null +++ b/.github/workflows/main-ubuntu.yml @@ -0,0 +1,92 @@ +name: Main workflow (Ubuntu) +on: + push: + branches: + - develop + - 'release/**' + - 'releases/**' + tags: + - v* + - V* +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true +jobs: + generateVersionInfo: + name: GenerateVersionInfo + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Restore dotnet tools + run: dotnet tool restore + - name: Fetch complete repository + run: git fetch + - name: Generate version info from git history + run: dotnet gitversion /output json > gitversion.json + - name: Upload version info file + uses: actions/upload-artifact@v1 + with: + name: gitversion + path: gitversion.json + + build: + name: Build + needs: generateVersionInfo + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Download version info file + uses: actions/download-artifact@v1 + with: + name: gitversion + path: ./ + - name: Inject version info into environment + run: jq -r 'to_entries|map("::set-env name=GitVersion_\(.key)::\(.value|tostring)")|.[]' gitversion.json + - name: Build solution + run: echo "Current version is \"$GitVersion_SemVer\"" && dotnet build -c Release + - name: Create NuGet packages + run: dotnet pack -c Release --no-build -o nupkg + - name: Upload nuget packages + uses: actions/upload-artifact@v1 + with: + name: nupkg + path: nupkg + + test: + name: Test + needs: [build, generateVersionInfo] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Download version info file + uses: actions/download-artifact@v1 + with: + name: gitversion + path: ./ + - name: Inject version info into environment + run: jq -r 'to_entries|map("::set-env name=GitVersion_\(.key)::\(.value|tostring)")|.[]' gitversion.json + - name: Run tests + run: echo "Current version is \"$GitVersion_SemVer\"" && dotnet test -c Release + + publish: + name: Publish + needs: [test] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Download nuget packages + uses: actions/download-artifact@v1 + with: + name: nupkg + - name: Publish the package to GPR + # using workaround with CURL because of non-functioning upload via dotnet nuget (https://stackoverflow.com/a/58943251) + run: | + for f in ./nupkg/*.nupkg + do + curl -vX PUT -u "graphql-dotnet:${{secrets.GITHUB_TOKEN}}" -F package=@$f https://nuget.pkg.github.com/graphql-dotnet/ + done diff --git a/.github/workflows/main.yml b/.github/workflows/main-windows.yml similarity index 96% rename from .github/workflows/main.yml rename to .github/workflows/main-windows.yml index 2ed05127..8ab30600 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main-windows.yml @@ -1,13 +1,8 @@ name: Main workflow -on: +on: push: - branches: - - develop - - 'release/**' - - 'releases/**' - tags: - - v* - - V* + branches-ignore: + - '**' jobs: generateVersionInfo: name: GenerateVersionInfo diff --git a/GitVersion.yml b/GitVersion.yml index 4b1b821f..2210324b 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,3 +1 @@ -branches: - release: - mode: ContinuousDeployment +mode: ContinuousDeployment diff --git a/GraphQL.Client.sln b/GraphQL.Client.sln index 1b6cc421..e1943dde 100644 --- a/GraphQL.Client.sln +++ b/GraphQL.Client.sln @@ -32,12 +32,10 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Server.Test", "tests\GraphQL.Server.Test\GraphQL.Server.Test.csproj", "{E95A1258-F666-4D4E-9101-E0C46F6A3CB3}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{C42106CF-F685-4F29-BC18-A70616BD68A0}" - ProjectSection(SolutionItems) = preProject - .github\FUNDING.yml = .github\FUNDING.yml - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{05CAF9B2-981E-40C0-AE31-5FA56E351F12}" ProjectSection(SolutionItems) = preProject + .github\workflows\branches-ubuntu.yml = .github\workflows\branches-ubuntu.yml .github\workflows\branches.yml = .github\workflows\branches.yml .github\workflows\main.yml = .github\workflows\main.yml EndProjectSection @@ -58,6 +56,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Abstractions EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client", "src\GraphQL.Client\GraphQL.Client.csproj", "{ED3541C9-D2B2-4D06-A464-38E404A3919A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Abstractions.Websocket", "src\GraphQL.Client.Abstractions.Websocket\GraphQL.Client.Abstractions.Websocket.csproj", "{4D581CE1-523D-46BF-BAA5-F7D79A1B7654}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Serializer.Newtonsoft", "src\GraphQL.Client.Serializer.Newtonsoft\GraphQL.Client.Serializer.Newtonsoft.csproj", "{11F28E78-ADE4-4153-B97C-56136EB7BD5B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.LocalExecution", "src\GraphQL.Client.LocalExecution\GraphQL.Client.LocalExecution.csproj", "{2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Serializer.Tests", "tests\GraphQL.Client.Serializer.Tests\GraphQL.Client.Serializer.Tests.csproj", "{CA842D18-FC4A-4281-B1FF-080FA91887B8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Tests.Common", "tests\GraphQL.Client.Tests.Common\GraphQL.Client.Tests.Common.csproj", "{0D307BAD-27AE-4A5D-8764-4AA2620B01E9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Serializer.SystemTextJson", "src\GraphQL.Client.Serializer.SystemTextJson\GraphQL.Client.Serializer.SystemTextJson.csproj", "{7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,12 +106,37 @@ Global {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Debug|Any CPU.Build.0 = Debug|Any CPU {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Release|Any CPU.ActiveCfg = Release|Any CPU {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Release|Any CPU.Build.0 = Release|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Release|Any CPU.Build.0 = Release|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Release|Any CPU.Build.0 = Release|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Release|Any CPU.Build.0 = Release|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Release|Any CPU.Build.0 = Release|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Release|Any CPU.Build.0 = Release|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {E95A1258-F666-4D4E-9101-E0C46F6A3CB3} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {05CAF9B2-981E-40C0-AE31-5FA56E351F12} = {C42106CF-F685-4F29-BC18-A70616BD68A0} {95D78D57-3232-491D-BAD6-F373D76EA34D} = {D61415CA-D822-43DD-9AE7-993B8B60E855} {87FC440E-6A4D-47D8-9EB2-416FC31CC4A6} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} {C212983F-67DB-44EB-BFB0-5DA75A86DF55} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} @@ -109,6 +144,12 @@ Global {C68C26EB-7659-402A-93D1-E6E248DA5427} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} {76E622F6-7CDD-4B1F-AD06-FFABF37C55E5} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} {ED3541C9-D2B2-4D06-A464-38E404A3919A} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {11F28E78-ADE4-4153-B97C-56136EB7BD5B} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {CA842D18-FC4A-4281-B1FF-080FA91887B8} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {387AC1AC-F90C-4EF8-955A-04D495C75AF4} diff --git a/README.md b/README.md index f55b48dd..95d46939 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ var heroAndFriendsRequest = new GraphQLRequest { }; ``` +Be careful when using `byte[]` in your variables object, as most JSON serializers will treat that as binary data! If you really need to send a *list of bytes* with a `byte[]` as a source, then convert it to a `List` first, which will tell the serializer to output a list of numbers instead of a base64-encoded string. + ### Execute Query/Mutation: ```csharp -var graphQLClient = new GraphQLClient("https://swapi.apis.guru/"); +var graphQLClient = new GraphQLHttpClient("https://swapi.apis.guru/"); public class HeroAndFriendsResponse { public Hero Hero {get; set;} @@ -62,6 +64,8 @@ var graphQLResponse = await graphQLClient.SendQueryAsync var heroName = graphQLResponse.Data.Hero.Name; ``` + + ### Use Subscriptions ```csharp diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQL.Client.Abstractions.Websocket.csproj b/src/GraphQL.Client.Abstractions.Websocket/GraphQL.Client.Abstractions.Websocket.csproj new file mode 100644 index 00000000..8623791f --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQL.Client.Abstractions.Websocket.csproj @@ -0,0 +1,13 @@ + + + + + netstandard2.0 + 8.0 + + + + + + + diff --git a/src/GraphQL.Client/Websocket/GraphQLWebSocketMessageType.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs similarity index 98% rename from src/GraphQL.Client/Websocket/GraphQLWebSocketMessageType.cs rename to src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs index 755a075b..6bf17748 100644 --- a/src/GraphQL.Client/Websocket/GraphQLWebSocketMessageType.cs +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs @@ -1,4 +1,4 @@ -namespace GraphQL.Client.Http.Websocket { +namespace GraphQL.Client.Abstractions.Websocket { public static class GraphQLWebSocketMessageType { /// diff --git a/src/GraphQL.Client/Websocket/GraphQLWebSocketRequest.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketRequest.cs similarity index 85% rename from src/GraphQL.Client/Websocket/GraphQLWebSocketRequest.cs rename to src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketRequest.cs index 01dcf173..0f486482 100644 --- a/src/GraphQL.Client/Websocket/GraphQLWebSocketRequest.cs +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketRequest.cs @@ -1,28 +1,35 @@ using System; using System.Collections.Generic; +using System.Runtime.Serialization; using System.Threading.Tasks; -namespace GraphQL.Client.Http.Websocket { +namespace GraphQL.Client.Abstractions.Websocket { /// /// A Subscription Request /// public class GraphQLWebSocketRequest : IEquatable { + public const string IdKey = "id"; + public const string TypeKey = "type"; + public const string PayloadKey = "payload"; /// /// The Identifier of the Response /// - public string Id { get; set; } + [DataMember(Name = IdKey)] + public virtual string Id { get; set; } /// /// The Type of the Request /// - public string Type { get; set; } + [DataMember(Name = TypeKey)] + public virtual string Type { get; set; } /// /// The payload of the websocket request /// - public GraphQLRequest Payload { get; set; } + [DataMember(Name = PayloadKey)] + public virtual GraphQLRequest Payload { get; set; } private TaskCompletionSource _tcs = new TaskCompletionSource(); diff --git a/src/GraphQL.Client/Websocket/GraphQLWebSocketResponse.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketResponse.cs similarity index 84% rename from src/GraphQL.Client/Websocket/GraphQLWebSocketResponse.cs rename to src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketResponse.cs index 08171a6a..c0fd7bd8 100644 --- a/src/GraphQL.Client/Websocket/GraphQLWebSocketResponse.cs +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketResponse.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace GraphQL.Client.Http.Websocket { +namespace GraphQL.Client.Abstractions.Websocket { /// /// A Subscription Response @@ -60,10 +60,10 @@ public override int GetHashCode() { } - public class GraphQLWebSocketResponse : GraphQLWebSocketResponse, IEquatable> { - public GraphQLHttpResponse Payload { get; set; } + public class GraphQLWebSocketResponse : GraphQLWebSocketResponse, IEquatable> { + public TPayload Payload { get; set; } - public bool Equals(GraphQLWebSocketResponse? other) { + public bool Equals(GraphQLWebSocketResponse? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return base.Equals(other) && Payload.Equals(other.Payload); @@ -73,7 +73,7 @@ public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; - return Equals((GraphQLWebSocketResponse)obj); + return Equals((GraphQLWebSocketResponse)obj); } public override int GetHashCode() { diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebsocketConnectionState.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebsocketConnectionState.cs new file mode 100644 index 00000000..3ab5a0e2 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebsocketConnectionState.cs @@ -0,0 +1,7 @@ +namespace GraphQL.Client.Abstractions.Websocket { + public enum GraphQLWebsocketConnectionState { + Disconnected, + Connecting, + Connected + } +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs b/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs new file mode 100644 index 00000000..e2f445f5 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs @@ -0,0 +1,17 @@ +using System.IO; +using System.Threading.Tasks; + +namespace GraphQL.Client.Abstractions.Websocket +{ + /// + /// The json serializer interface for the graphql-dotnet http client. + /// Implementations should provide a parameterless constructor for convenient usage + /// + public interface IGraphQLWebsocketJsonSerializer: IGraphQLJsonSerializer { + byte[] SerializeToBytes(GraphQLWebSocketRequest request); + + Task DeserializeToWebsocketResponseWrapperAsync(Stream stream); + GraphQLWebSocketResponse> DeserializeToWebsocketResponse(byte[] bytes); + + } +} diff --git a/src/GraphQL.Client/Websocket/WebsocketResponseWrapper.cs b/src/GraphQL.Client.Abstractions.Websocket/WebsocketResponseWrapper.cs similarity index 77% rename from src/GraphQL.Client/Websocket/WebsocketResponseWrapper.cs rename to src/GraphQL.Client.Abstractions.Websocket/WebsocketResponseWrapper.cs index fd91ef78..5e90a38f 100644 --- a/src/GraphQL.Client/Websocket/WebsocketResponseWrapper.cs +++ b/src/GraphQL.Client.Abstractions.Websocket/WebsocketResponseWrapper.cs @@ -1,6 +1,6 @@ using System.Runtime.Serialization; -namespace GraphQL.Client.Http.Websocket { +namespace GraphQL.Client.Abstractions.Websocket { public class WebsocketResponseWrapper : GraphQLWebSocketResponse { [IgnoreDataMember] diff --git a/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj b/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj index 6430f4b7..78972572 100644 --- a/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj +++ b/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/GraphQL.Client.Abstractions/GraphQLJsonSerializerExtensions.cs b/src/GraphQL.Client.Abstractions/GraphQLJsonSerializerExtensions.cs new file mode 100644 index 00000000..960f687f --- /dev/null +++ b/src/GraphQL.Client.Abstractions/GraphQLJsonSerializerExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Diagnostics; +using System.Linq; + +namespace GraphQL.Client.Abstractions { + public static class GraphQLJsonSerializerExtensions { + public static TSerializerInterface EnsureAssigned(this TSerializerInterface jsonSerializer) where TSerializerInterface: IGraphQLJsonSerializer { + // if no serializer was assigned + if (jsonSerializer == null) { + // try to find one in the assembly and assign that + var type = typeof(TSerializerInterface); + var serializerType = AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(s => s.GetTypes()) + .FirstOrDefault(p => type.IsAssignableFrom(p) && !p.IsInterface && !p.IsAbstract); + if (serializerType == null) + throw new InvalidOperationException($"no implementation of \"{type}\" found"); + + jsonSerializer = (TSerializerInterface)Activator.CreateInstance(serializerType); + } + + return jsonSerializer; + } + + public static TOptions New(this Action configure) => + configure.AndReturn(Activator.CreateInstance()); + + public static TOptions AndReturn(this Action configure, TOptions options) { + configure(options); + return options; + } + } +} diff --git a/src/GraphQL.Client.Abstractions/IGraphQLClient.cs b/src/GraphQL.Client.Abstractions/IGraphQLClient.cs index 3b47a932..6889a179 100644 --- a/src/GraphQL.Client.Abstractions/IGraphQLClient.cs +++ b/src/GraphQL.Client.Abstractions/IGraphQLClient.cs @@ -31,11 +31,6 @@ public interface IGraphQLClient : IDisposable { /// an external handler for all s occuring within the sequence /// an observable stream for the specified subscription IObservable> CreateSubscriptionStream(GraphQLRequest request, Action exceptionHandler); - - /// - /// Publishes all exceptions which occur inside the websocket receive stream (i.e. for logging purposes) - /// - IObservable WebSocketReceiveErrors { get; } } } diff --git a/src/GraphQL.Client.Abstractions/IGraphQLJsonSerializer.cs b/src/GraphQL.Client.Abstractions/IGraphQLJsonSerializer.cs new file mode 100644 index 00000000..d29378ca --- /dev/null +++ b/src/GraphQL.Client.Abstractions/IGraphQLJsonSerializer.cs @@ -0,0 +1,14 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphQL.Client.Abstractions { + public interface IGraphQLJsonSerializer { + string SerializeToString(GraphQLRequest request); + + Task> DeserializeFromUtf8StreamAsync(Stream stream, + CancellationToken cancellationToken); + } + + +} diff --git a/src/GraphQL.Client.Http/Websocket/IGraphQLWebSocketJsonSerializer.cs b/src/GraphQL.Client.Http/Websocket/IGraphQLWebSocketJsonSerializer.cs new file mode 100644 index 00000000..5f89191d --- /dev/null +++ b/src/GraphQL.Client.Http/Websocket/IGraphQLWebSocketJsonSerializer.cs @@ -0,0 +1,5 @@ +namespace GraphQL.Client.Http.Websocket { + public interface IGraphQLWebSocketJsonSerializer: IGraphQLJsonSerializer { + GraphQLWebSocketResponse DeserializeWebSocketResponse(byte[] utf8bytes); + } +} diff --git a/src/GraphQL.Client.LocalExecution/ExecutionResultExtensions.cs b/src/GraphQL.Client.LocalExecution/ExecutionResultExtensions.cs new file mode 100644 index 00000000..a8ce44d8 --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/ExecutionResultExtensions.cs @@ -0,0 +1,5 @@ +namespace GraphQL.Client.LocalExecution { + public class ExecutionResultExtensions { + + } +} diff --git a/src/GraphQL.Client.LocalExecution/GraphQL.Client.LocalExecution.csproj b/src/GraphQL.Client.LocalExecution/GraphQL.Client.LocalExecution.csproj new file mode 100644 index 00000000..3cd19e04 --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/GraphQL.Client.LocalExecution.csproj @@ -0,0 +1,20 @@ + + + + + + A GraphQL Client which executes the queries directly on a provided C# schema + netstandard2.0 + + + + + + + + + + + + + diff --git a/src/GraphQL.Client.LocalExecution/GraphQLEnumConverter.cs b/src/GraphQL.Client.LocalExecution/GraphQLEnumConverter.cs new file mode 100644 index 00000000..a9919454 --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/GraphQLEnumConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Reflection; +using GraphQL.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace GraphQL.Client.LocalExecution { + public class GraphQLEnumConverter : StringEnumConverter { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { + if (value == null) { + writer.WriteNull(); + } + else { + var enumString = ((Enum)value).ToString("G"); + var memberName = value.GetType() + .GetMember(enumString, BindingFlags.DeclaredOnly | BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault()?.Name; + if (string.IsNullOrEmpty(memberName)) { + if (!AllowIntegerValues) + throw new JsonSerializationException($"Integer value {value} is not allowed."); + writer.WriteValue(value); + } + else { + writer.WriteValue(StringUtils.ToConstantCase(memberName)); + } + } + } + } + +} diff --git a/src/GraphQL.Client.LocalExecution/GraphQLLocalExecutionClient.cs b/src/GraphQL.Client.LocalExecution/GraphQLLocalExecutionClient.cs new file mode 100644 index 00000000..d73e2cc6 --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/GraphQLLocalExecutionClient.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GraphQL.Client.Abstractions; +using GraphQL.Subscription; +using GraphQL.Types; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace GraphQL.Client.LocalExecution +{ + public static class GraphQLLocalExecutionClient { + public static GraphQLLocalExecutionClient New(TSchema schema) where TSchema : ISchema + => new GraphQLLocalExecutionClient(schema); + + public static GraphQLLocalExecutionClient New(TSchema schema, IGraphQLJsonSerializer serializer) where TSchema : ISchema + => new GraphQLLocalExecutionClient(schema, serializer); + } + + + public class GraphQLLocalExecutionClient: IGraphQLClient where TSchema: ISchema { + + private static readonly JsonSerializerSettings VariablesSerializerSettings = new JsonSerializerSettings { + Formatting = Formatting.Indented, + DateTimeZoneHandling = DateTimeZoneHandling.Local, + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new List + { + new GraphQLEnumConverter() + } + }; + + public TSchema Schema { get; } + public IGraphQLJsonSerializer Serializer { get; } + + + private readonly DocumentExecuter documentExecuter; + + public GraphQLLocalExecutionClient(TSchema schema) { + Serializer.EnsureAssigned(); + Schema = schema; + if (!Schema.Initialized) Schema.Initialize(); + documentExecuter = new DocumentExecuter(); + } + + public GraphQLLocalExecutionClient(TSchema schema, IGraphQLJsonSerializer serializer) : this(schema) { + Serializer = serializer; + } + + public void Dispose() { } + + public Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + => ExecuteQueryAsync(request, cancellationToken); + + public Task> SendMutationAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + => ExecuteQueryAsync(request, cancellationToken); + + public IObservable> CreateSubscriptionStream(GraphQLRequest request) { + return Observable.Defer(() => ExecuteSubscriptionAsync(request).ToObservable()) + .Concat() + .Publish() + .RefCount(); + } + + public IObservable> CreateSubscriptionStream(GraphQLRequest request, + Action exceptionHandler) + => CreateSubscriptionStream(request); + + #region Private Methods + + private async Task> ExecuteQueryAsync(GraphQLRequest request, CancellationToken cancellationToken) { + var executionResult = await ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + return await ExecutionResultToGraphQLResponse(executionResult, cancellationToken).ConfigureAwait(false); + } + private async Task>> ExecuteSubscriptionAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { + var result = await ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + return ((SubscriptionExecutionResult)result).Streams?.Values.SingleOrDefault()? + .SelectMany(executionResult => Observable.FromAsync(token => ExecutionResultToGraphQLResponse(executionResult, token))); + } + + private async Task ExecuteAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { + var serializedRequest = Serializer.SerializeToString(request); + + var deserializedRequest = JsonConvert.DeserializeObject(serializedRequest); + var inputs = deserializedRequest.Variables != null + ? (JObject.FromObject(request.Variables, JsonSerializer.Create(VariablesSerializerSettings)) as JObject) + .ToInputs() + : null; + + var result = await documentExecuter.ExecuteAsync(options => { + options.Schema = Schema; + options.OperationName = request.OperationName; + options.Query = request.Query; + options.Inputs = inputs; + options.CancellationToken = cancellationToken; + }).ConfigureAwait(false); + + return result; + } + + private Task> ExecutionResultToGraphQLResponse(ExecutionResult executionResult, CancellationToken cancellationToken = default) { + // serialize result into utf8 byte stream + var resultStream = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(executionResult, VariablesSerializerSettings))); + // deserialize using the provided serializer + return Serializer.DeserializeFromUtf8StreamAsync(resultStream, cancellationToken); + } + + #endregion + } +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/GraphQL.Client.Serializer.Newtonsoft.csproj b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQL.Client.Serializer.Newtonsoft.csproj new file mode 100644 index 00000000..4c6e0e77 --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQL.Client.Serializer.Newtonsoft.csproj @@ -0,0 +1,17 @@ + + + + + netstandard2.0 + 8.0 + + + + + + + + + + + diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLExtensionsConverter.cs b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLExtensionsConverter.cs new file mode 100644 index 00000000..0ff13a0f --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLExtensionsConverter.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GraphQL.Client.Serializer.Newtonsoft { + public class GraphQLExtensionsConverter: JsonConverter { + public override void WriteJson(JsonWriter writer, GraphQLExtensionsType value, JsonSerializer serializer) { + throw new NotImplementedException( + "This converter currently is only intended to be used to read a JSON object into a strongly-typed representation."); + } + + public override GraphQLExtensionsType ReadJson(JsonReader reader, Type objectType, GraphQLExtensionsType existingValue, + bool hasExistingValue, JsonSerializer serializer) { + var rootToken = JToken.ReadFrom(reader); + if (rootToken is JObject) { + return ReadDictionary(rootToken); + } + else + throw new ArgumentException("This converter can only parse when the root element is a JSON Object."); + } + + private object ReadToken(JToken? token) { + switch (token.Type) { + case JTokenType.Undefined: + case JTokenType.None: + return null; + case JTokenType.Object: + return ReadDictionary>(token); + case JTokenType.Array: + return ReadArray(token); + case JTokenType.Integer: + return token.Value(); + case JTokenType.Float: + return token.Value(); + case JTokenType.Raw: + case JTokenType.String: + case JTokenType.Uri: + return token.Value(); + case JTokenType.Boolean: + return token.Value(); + case JTokenType.Date: + return token.Value(); + case JTokenType.Bytes: + return token.Value(); + case JTokenType.Guid: + return token.Value(); + case JTokenType.TimeSpan: + return token.Value(); + case JTokenType.Constructor: + case JTokenType.Property: + case JTokenType.Comment: + default: + throw new ArgumentOutOfRangeException(); + } + } + + private TDictionary ReadDictionary(JToken element) where TDictionary : Dictionary { + var result = Activator.CreateInstance(); + foreach (var property in ((JObject)element).Properties()) { + result[property.Name] = ReadToken(property.Value); + } + return result; + } + + private IEnumerable ReadArray(JToken element) { + foreach (var item in element.Values()) { + yield return ReadToken(item); + } + } + } +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLRequest.cs b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLRequest.cs new file mode 100644 index 00000000..cf012c92 --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLRequest.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace GraphQL.Client.Serializer.Newtonsoft { + public class GraphQLRequest: GraphQL.GraphQLRequest { + [JsonProperty(QueryKey)] + public override string Query { get; set; } + [JsonProperty(OperationNameKey)] + public override string? OperationName { get; set; } + [JsonProperty(VariablesKey)] + public override object? Variables { get; set; } + + public GraphQLRequest() { } + + public GraphQLRequest(GraphQL.GraphQLRequest other) { + Query = other.Query; + OperationName = other.OperationName; + Variables = other.Variables; + } + + } +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLWebSocketRequest.cs b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLWebSocketRequest.cs new file mode 100644 index 00000000..c575b9d3 --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQLWebSocketRequest.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace GraphQL.Client.Serializer.Newtonsoft { + public class GraphQLWebSocketRequest: Abstractions.Websocket.GraphQLWebSocketRequest { + + [JsonProperty(IdKey)] + public override string Id { get; set; } + [JsonProperty(TypeKey)] + public override string Type { get; set; } + [JsonProperty(PayloadKey)] + public override GraphQL.GraphQLRequest Payload { get; set; } + + public GraphQLWebSocketRequest() + { + } + + public GraphQLWebSocketRequest(Abstractions.Websocket.GraphQLWebSocketRequest other) { + Id = other.Id; + Type = other.Type; + Payload = other.Payload != null ? new GraphQLRequest(other.Payload) : null; // create serializer-specific type + } + } +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs b/src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs new file mode 100644 index 00000000..a40ad456 --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace GraphQL.Client.Serializer.Newtonsoft +{ + public class NewtonsoftJsonSerializer: IGraphQLWebsocketJsonSerializer + { + public static JsonSerializerSettings DefaultJsonSerializerSettings => new JsonSerializerSettings { + ContractResolver = new CamelCasePropertyNamesContractResolver { IgnoreIsSpecifiedMembers = true }, + MissingMemberHandling = MissingMemberHandling.Ignore + }; + + public JsonSerializerSettings JsonSerializerSettings { get; } + + public NewtonsoftJsonSerializer() : this(DefaultJsonSerializerSettings) { } + + public NewtonsoftJsonSerializer(Action configure) : this(configure.AndReturn(DefaultJsonSerializerSettings)) { } + + public NewtonsoftJsonSerializer(JsonSerializerSettings jsonSerializerSettings) { + JsonSerializerSettings = jsonSerializerSettings; + ConfigureMandatorySerializerOptions(); + } + + private void ConfigureMandatorySerializerOptions() { + // deserialize extensions to Dictionary + JsonSerializerSettings.Converters.Insert(0, new GraphQLExtensionsConverter()); + } + + public string SerializeToString(GraphQL.GraphQLRequest request) { + return JsonConvert.SerializeObject(new GraphQLRequest(request), JsonSerializerSettings); + } + + public byte[] SerializeToBytes(Abstractions.Websocket.GraphQLWebSocketRequest request) { + var json = JsonConvert.SerializeObject(new GraphQLWebSocketRequest(request), JsonSerializerSettings); + return Encoding.UTF8.GetBytes(json); + } + + public Task DeserializeToWebsocketResponseWrapperAsync(Stream stream) { + return DeserializeFromUtf8Stream(stream); + } + + public GraphQLWebSocketResponse> DeserializeToWebsocketResponse(byte[] bytes) { + return JsonConvert.DeserializeObject>>(Encoding.UTF8.GetString(bytes), + JsonSerializerSettings); + } + + public Task> DeserializeFromUtf8StreamAsync(Stream stream, CancellationToken cancellationToken) { + return DeserializeFromUtf8Stream>(stream); + } + + + private Task DeserializeFromUtf8Stream(Stream stream) { + using StreamReader sr = new StreamReader(stream); + using JsonReader reader = new JsonTextReader(sr); + JsonSerializer serializer = JsonSerializer.Create(JsonSerializerSettings); + return Task.FromResult(serializer.Deserialize(reader)); + } + + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/GraphQL.Client.Serializer.SystemTextJson.csproj b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQL.Client.Serializer.SystemTextJson.csproj new file mode 100644 index 00000000..024b0681 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQL.Client.Serializer.SystemTextJson.csproj @@ -0,0 +1,21 @@ + + + + + netstandard2.0;netcoreapp3.1 + 8.0 + + + + + + + + + + + + + + + diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLExtensionsConverter.cs b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLExtensionsConverter.cs new file mode 100644 index 00000000..e10c8635 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLExtensionsConverter.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GraphQL.Client.Serializer.SystemTextJson { + /// + /// A custom JsonConverter for reading the extension fields of and . + /// + /// + /// Taken and modified from GraphQL.SystemTextJson.ObjectDictionaryConverter (GraphQL.NET) + /// + public class GraphQLExtensionsConverter : JsonConverter { + public override GraphQLExtensionsType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + using var doc = JsonDocument.ParseValue(ref reader); + + if (doc?.RootElement == null || doc?.RootElement.ValueKind != JsonValueKind.Object) { + throw new ArgumentException("This converter can only parse when the root element is a JSON Object."); + } + + return ReadDictionary(doc.RootElement); + } + + public override void Write(Utf8JsonWriter writer, GraphQLExtensionsType value, JsonSerializerOptions options) + => throw new NotImplementedException( + "This converter currently is only intended to be used to read a JSON object into a strongly-typed representation."); + + private TDictionary ReadDictionary(JsonElement element) where TDictionary: Dictionary { + var result = Activator.CreateInstance(); + foreach (var property in element.EnumerateObject()) { + result[property.Name] = ReadValue(property.Value); + } + return result; + } + + private IEnumerable ReadArray(JsonElement value) { + foreach (var item in value.EnumerateArray()) { + yield return ReadValue(item); + } + } + + private object ReadValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Array => ReadArray(value).ToList(), + JsonValueKind.Object => ReadDictionary>(value), + JsonValueKind.Number => ReadNumber(value), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => throw new InvalidOperationException($"Unexpected value kind: {value.ValueKind}") + }; + + private object ReadNumber(JsonElement value) { + if (value.TryGetInt32(out var i)) + return i; + else if (value.TryGetInt64(out var l)) + return l; + else if (BigInteger.TryParse(value.GetRawText(), out var bi)) + return bi; + else if (value.TryGetDouble(out var d)) + return d; + else if (value.TryGetDecimal(out var dd)) + return dd; + + throw new NotImplementedException($"Unexpected Number value. Raw text was: {value.GetRawText()}"); + } + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLRequest.cs b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLRequest.cs new file mode 100644 index 00000000..1f3b7908 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace GraphQL.Client.Serializer.SystemTextJson { + public class GraphQLRequest: GraphQL.GraphQLRequest { + [JsonPropertyName(QueryKey)] + public override string Query { get; set; } + [JsonPropertyName(OperationNameKey)] + public override string? OperationName { get; set; } + [JsonPropertyName(VariablesKey)] + public override object? Variables { get; set; } + + public GraphQLRequest() { } + + public GraphQLRequest(GraphQL.GraphQLRequest other) { + Query = other.Query; + OperationName = other.OperationName; + Variables = other.Variables; + } + + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLWebSocketRequest.cs b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLWebSocketRequest.cs new file mode 100644 index 00000000..4961e00d --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQLWebSocketRequest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace GraphQL.Client.Serializer.SystemTextJson { + public class GraphQLWebSocketRequest: Abstractions.Websocket.GraphQLWebSocketRequest { + + [JsonPropertyName(IdKey)] + public override string Id { get; set; } + [JsonPropertyName(TypeKey)] + public override string Type { get; set; } + [JsonPropertyName(PayloadKey)] + public override GraphQL.GraphQLRequest Payload { get; set; } + + public GraphQLWebSocketRequest() + { + } + + public GraphQLWebSocketRequest(Abstractions.Websocket.GraphQLWebSocketRequest other) { + Id = other.Id; + Type = other.Type; + Payload = other.Payload != null ? new GraphQLRequest(other.Payload) : null; // create serializer-specific type; + } + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs b/src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs new file mode 100644 index 00000000..420a8522 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Dahomey.Json; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Serializer.SystemTextJson +{ + public class SystemTextJsonSerializer: IGraphQLWebsocketJsonSerializer + { + public static JsonSerializerOptions DefaultJsonSerializerOptions => new JsonSerializerOptions { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }.SetupExtensions(); + + public JsonSerializerOptions Options { get; } + + public SystemTextJsonSerializer() : this(DefaultJsonSerializerOptions) { } + + public SystemTextJsonSerializer(Action configure) : this(configure.AndReturn(DefaultJsonSerializerOptions)) { } + + public SystemTextJsonSerializer(JsonSerializerOptions options) { + Options = options; + ConfigureMandatorySerializerOptions(); + } + + private void ConfigureMandatorySerializerOptions() { + // deserialize extensions to Dictionary + Options.Converters.Insert(0, new GraphQLExtensionsConverter()); + // allow the JSON field "data" to match the property "Data" even without JsonNamingPolicy.CamelCase + Options.PropertyNameCaseInsensitive = true; + } + + public string SerializeToString(GraphQL.GraphQLRequest request) { + return JsonSerializer.Serialize(new GraphQLRequest(request), Options); + } + + public Task> DeserializeFromUtf8StreamAsync(Stream stream, CancellationToken cancellationToken) { + return JsonSerializer.DeserializeAsync>(stream, Options, cancellationToken).AsTask(); + } + + public byte[] SerializeToBytes(Abstractions.Websocket.GraphQLWebSocketRequest request) { + return JsonSerializer.SerializeToUtf8Bytes(new GraphQLWebSocketRequest(request), Options); + } + + public Task DeserializeToWebsocketResponseWrapperAsync(Stream stream) { + return JsonSerializer.DeserializeAsync(stream, Options).AsTask(); + } + + public GraphQLWebSocketResponse> DeserializeToWebsocketResponse(byte[] bytes) { + return JsonSerializer.Deserialize>>(new ReadOnlySpan(bytes), + Options); + } + + } +} diff --git a/src/GraphQL.Client/GraphQL.Client.csproj b/src/GraphQL.Client/GraphQL.Client.csproj index 2dfd5ad8..3b11e942 100644 --- a/src/GraphQL.Client/GraphQL.Client.csproj +++ b/src/GraphQL.Client/GraphQL.Client.csproj @@ -15,9 +15,18 @@ NETFRAMEWORK + + + + + + + + + @@ -25,7 +34,12 @@ + + + + + diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs index 15ff6e8f..dbf4bcb7 100644 --- a/src/GraphQL.Client/GraphQLHttpClient.cs +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; using GraphQL.Client.Http.Websocket; namespace GraphQL.Client.Http { @@ -14,6 +15,7 @@ public class GraphQLHttpClient : IGraphQLClient { private readonly GraphQLHttpWebSocket graphQlHttpWebSocket; private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); private readonly ConcurrentDictionary, object> subscriptionStreams = new ConcurrentDictionary, object>(); + private IGraphQLWebsocketJsonSerializer JsonSerializer => Options.JsonSerializer; /// /// the instance of which is used internally @@ -25,32 +27,45 @@ public class GraphQLHttpClient : IGraphQLClient { /// public GraphQLHttpClientOptions Options { get; } - /// + /// + /// Publishes all exceptions which occur inside the websocket receive stream (i.e. for logging purposes) + /// public IObservable WebSocketReceiveErrors => graphQlHttpWebSocket.ReceiveErrors; + /// + /// the websocket connection state + /// + public IObservable WebsocketConnectionState => + graphQlHttpWebSocket.ConnectionState; + + #region Constructors + public GraphQLHttpClient(string endPoint) : this(new Uri(endPoint)) { } public GraphQLHttpClient(Uri endPoint) : this(o => o.EndPoint = endPoint) { } - public GraphQLHttpClient(Action configure) { - Options = new GraphQLHttpClientOptions(); - configure(Options); - this.HttpClient = new HttpClient(Options.HttpMessageHandler); - this.graphQlHttpWebSocket = new GraphQLHttpWebSocket(GetWebSocketUri(), Options); - } + public GraphQLHttpClient(Action configure) : this(configure.New()) { } - public GraphQLHttpClient(GraphQLHttpClientOptions options) { - Options = options; - this.HttpClient = new HttpClient(Options.HttpMessageHandler); - this.graphQlHttpWebSocket = new GraphQLHttpWebSocket(GetWebSocketUri(), Options); - } + public GraphQLHttpClient(GraphQLHttpClientOptions options) : this(options, new HttpClient(options.HttpMessageHandler)) { } public GraphQLHttpClient(GraphQLHttpClientOptions options, HttpClient httpClient) { Options = options; - this.HttpClient = httpClient; - this.graphQlHttpWebSocket = new GraphQLHttpWebSocket(GetWebSocketUri(), Options); + this.HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + this.graphQlHttpWebSocket = new GraphQLHttpWebSocket(GetWebSocketUri(), this); + Options.JsonSerializer = JsonSerializer.EnsureAssigned(); + } + + public GraphQLHttpClient(GraphQLHttpClientOptions options, HttpClient httpClient, IGraphQLWebsocketJsonSerializer serializer) { + Options = options ?? throw new ArgumentNullException(nameof(options)); + Options.JsonSerializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + this.HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + this.graphQlHttpWebSocket = new GraphQLHttpWebSocket(GetWebSocketUri(), this); } + #endregion + + #region IGraphQLClient + /// public Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { return Options.UseWebSocketForQueriesAndMutations @@ -94,6 +109,8 @@ public IObservable> CreateSubscriptionStream /// explicitly opens the websocket connection. Will be closed again on disposing the last subscription /// @@ -111,12 +128,12 @@ private async Task> SendHttpPostRequestAsync>(Options, cancellationToken); + return await JsonSerializer.DeserializeFromUtf8StreamAsync(bodyStream, cancellationToken); } private HttpRequestMessage GenerateHttpRequestMessage(GraphQLRequest request) { var message = new HttpRequestMessage(HttpMethod.Post, this.Options.EndPoint) { - Content = new StringContent(request.SerializeToJson(Options), Encoding.UTF8, Options.MediaType) + Content = new StringContent(JsonSerializer.SerializeToString(request), Encoding.UTF8, Options.MediaType) }; if (request is GraphQLHttpRequest httpRequest) diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs index dcb0a37e..a52c772c 100644 --- a/src/GraphQL.Client/GraphQLHttpClientOptions.cs +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -2,8 +2,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; +using GraphQL.Client.Abstractions.Websocket; namespace GraphQL.Client.Http { @@ -18,11 +17,9 @@ public class GraphQLHttpClientOptions { public Uri EndPoint { get; set; } /// - /// The that is going to be used + /// the json serializer /// - public JsonSerializerSettings JsonSerializerSettings { get; set; } = new JsonSerializerSettings { - ContractResolver = new CamelCasePropertyNamesContractResolver() - }; + public IGraphQLWebsocketJsonSerializer JsonSerializer { get; set; } /// /// The that is going to be used @@ -52,5 +49,10 @@ public class GraphQLHttpClientOptions { /// Request preprocessing function. Can be used i.e. to inject authorization info into a GraphQL request payload. /// public Func> PreprocessRequest { get; set; } = (request, client) => Task.FromResult(request); + + /// + /// This callback is called after successfully establishing a websocket connection but before any regular request is made. + /// + public Func OnWebsocketConnected { get; set; } = client => Task.CompletedTask; } } diff --git a/src/GraphQL.Client/GraphQLHttpResponse.cs b/src/GraphQL.Client/GraphQLHttpResponse.cs index 1e7c8424..1dbfea30 100644 --- a/src/GraphQL.Client/GraphQLHttpResponse.cs +++ b/src/GraphQL.Client/GraphQLHttpResponse.cs @@ -2,8 +2,4 @@ namespace GraphQL.Client.Http { public class GraphQLHttpResponse : GraphQLResponse { } - - public class GraphQLHttpResponse : GraphQLHttpResponse { - } - } diff --git a/src/GraphQL.Client/GraphQLSerializationExtensions.cs b/src/GraphQL.Client/GraphQLSerializationExtensions.cs index 649d2c73..8d702204 100644 --- a/src/GraphQL.Client/GraphQLSerializationExtensions.cs +++ b/src/GraphQL.Client/GraphQLSerializationExtensions.cs @@ -2,6 +2,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using GraphQL.Client.Abstractions.Websocket; using GraphQL.Client.Http.Websocket; using Newtonsoft.Json; @@ -18,20 +19,15 @@ public static byte[] SerializeToBytes(this GraphQLRequest request, var json = JsonConvert.SerializeObject(request, options.JsonSerializerSettings); return Encoding.UTF8.GetBytes(json); } - public static byte[] SerializeToBytes(this GraphQLWebSocketRequest request, - GraphQLHttpClientOptions options) { - var json = JsonConvert.SerializeObject(request, options.JsonSerializerSettings); - return Encoding.UTF8.GetBytes(json); - } public static TGraphQLResponse DeserializeFromJson(this string jsonString, GraphQLHttpClientOptions options) { return JsonConvert.DeserializeObject(jsonString, options.JsonSerializerSettings); } - public static TObject DeserializeFromBytes(this byte[] utf8bytes, + public static TObject DeserializeFromBytes(this byte[] utf8Bytes, GraphQLHttpClientOptions options) { - return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(utf8bytes), options.JsonSerializerSettings); + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(utf8Bytes), options.JsonSerializerSettings); } diff --git a/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs b/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs index 5a4e76c2..832fb4cf 100644 --- a/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs +++ b/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs @@ -9,62 +9,68 @@ using System.Reactive.Threading.Tasks; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; +using GraphQL.Client.Abstractions.Websocket; namespace GraphQL.Client.Http.Websocket { internal class GraphQLHttpWebSocket : IDisposable { private readonly Uri webSocketUri; - private readonly GraphQLHttpClientOptions _options; + private readonly GraphQLHttpClient client; private readonly ArraySegment buffer; - private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + private readonly Subject requestSubject = new Subject(); + private readonly Subject exceptionSubject = new Subject(); + private readonly BehaviorSubject stateSubject = + new BehaviorSubject(GraphQLWebsocketConnectionState.Disconnected); + private readonly IDisposable requestSubscription; - private Subject _responseSubject; - private readonly Subject _requestSubject = new Subject(); - private readonly Subject _exceptionSubject = new Subject(); - private IDisposable _requestSubscription; - - public WebSocketState WebSocketState => clientWebSocket?.State ?? WebSocketState.None; + private int connectionAttempt = 0; + private Subject responseSubject; + private GraphQLHttpClientOptions Options => client.Options; #if NETFRAMEWORK private WebSocket clientWebSocket = null; #else private ClientWebSocket clientWebSocket = null; #endif - private int _connectionAttempt = 0; - public GraphQLHttpWebSocket(Uri webSocketUri, GraphQLHttpClientOptions options) { + + public WebSocketState WebSocketState => clientWebSocket?.State ?? WebSocketState.None; + public IObservable ReceiveErrors => exceptionSubject.AsObservable(); + public IObservable ConnectionState => stateSubject.DistinctUntilChanged(); + + public IObservable ResponseStream { get; } + + public GraphQLHttpWebSocket(Uri webSocketUri, GraphQLHttpClient client) { this.webSocketUri = webSocketUri; - _options = options; + this.client = client; buffer = new ArraySegment(new byte[8192]); - _responseStream = _createResponseStream(); + ResponseStream = _createResponseStream(); - _requestSubscription = _requestSubject.Select(request => Observable.FromAsync(() => _sendWebSocketRequest(request))).Concat().Subscribe(); + requestSubscription = requestSubject.Select(request => Observable.FromAsync(() => _sendWebSocketRequest(request))).Concat().Subscribe(); } - public IObservable ReceiveErrors => _exceptionSubject.AsObservable(); - public IObservable ResponseStream => _responseStream; - public readonly IObservable _responseStream; + #region Send requests public Task SendWebSocketRequest(GraphQLWebSocketRequest request) { - _requestSubject.OnNext(request); + requestSubject.OnNext(request); return request.SendTask(); } private async Task _sendWebSocketRequest(GraphQLWebSocketRequest request) { try { - if (_cancellationTokenSource.Token.IsCancellationRequested) { + if (cancellationTokenSource.Token.IsCancellationRequested) { request.SendCanceled(); return; } await InitializeWebSocket().ConfigureAwait(false); - var requestBytes = request.SerializeToBytes(_options); + var requestBytes = Options.JsonSerializer.SerializeToBytes(request); await this.clientWebSocket.SendAsync( new ArraySegment(requestBytes), WebSocketMessageType.Text, true, - _cancellationTokenSource.Token).ConfigureAwait(false); + cancellationTokenSource.Token).ConfigureAwait(false); request.SendCompleted(); } catch (Exception e) { @@ -72,40 +78,28 @@ await this.clientWebSocket.SendAsync( } } - public Task InitializeWebSocketTask { get; private set; } = Task.CompletedTask; - - private readonly object _initializeLock = new object(); - -#region Private Methods - - private Task _backOff() { - _connectionAttempt++; - - if (_connectionAttempt == 1) return Task.CompletedTask; - - var delay = _options.BackOffStrategy(_connectionAttempt - 1); - Debug.WriteLine($"connection attempt #{_connectionAttempt}, backing off for {delay.TotalSeconds} s"); - return Task.Delay(delay); - } + #endregion + private Task initializeWebSocketTask = Task.CompletedTask; + private readonly object initializeLock = new object(); + public Task InitializeWebSocket() { // do not attempt to initialize if cancellation is requested - if (_disposed != null) + if (Completion != null) throw new OperationCanceledException(); - lock (_initializeLock) { + lock (initializeLock) { // if an initialization task is already running, return that - if (InitializeWebSocketTask != null && - !InitializeWebSocketTask.IsFaulted && - !InitializeWebSocketTask.IsCompleted) - return InitializeWebSocketTask; + if (initializeWebSocketTask != null && + !initializeWebSocketTask.IsFaulted && + !initializeWebSocketTask.IsCompleted) + return initializeWebSocketTask; // if the websocket is open, return a completed task if (clientWebSocket != null && clientWebSocket.State == WebSocketState.Open) return Task.CompletedTask; // else (re-)create websocket and connect - //_responseStreamConnection?.Dispose(); clientWebSocket?.Dispose(); #if NETFRAMEWORK @@ -115,13 +109,13 @@ public Task InitializeWebSocket() { switch (clientWebSocket) { case ClientWebSocket nativeWebSocket: nativeWebSocket.Options.AddSubProtocol("graphql-ws"); - nativeWebSocket.Options.ClientCertificates = ((HttpClientHandler)_options.HttpMessageHandler).ClientCertificates; - nativeWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)_options.HttpMessageHandler).UseDefaultCredentials; + nativeWebSocket.Options.ClientCertificates = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates; + nativeWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials; break; case System.Net.WebSockets.Managed.ClientWebSocket managedWebSocket: managedWebSocket.Options.AddSubProtocol("graphql-ws"); - managedWebSocket.Options.ClientCertificates = ((HttpClientHandler)_options.HttpMessageHandler).ClientCertificates; - managedWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)_options.HttpMessageHandler).UseDefaultCredentials; + managedWebSocket.Options.ClientCertificates = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates; + managedWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials; break; default: throw new NotSupportedException($"unknown websocket type {clientWebSocket.GetType().Name}"); @@ -129,13 +123,47 @@ public Task InitializeWebSocket() { #else clientWebSocket = new ClientWebSocket(); clientWebSocket.Options.AddSubProtocol("graphql-ws"); - clientWebSocket.Options.ClientCertificates = ((HttpClientHandler)_options.HttpMessageHandler).ClientCertificates; - clientWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)_options.HttpMessageHandler).UseDefaultCredentials; + clientWebSocket.Options.ClientCertificates = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates; + clientWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials; #endif - return InitializeWebSocketTask = _connectAsync(_cancellationTokenSource.Token); + return initializeWebSocketTask = _connectAsync(cancellationTokenSource.Token); } } + private async Task _connectAsync(CancellationToken token) { + try { + await _backOff().ConfigureAwait(false); + stateSubject.OnNext(GraphQLWebsocketConnectionState.Connecting); + Debug.WriteLine($"opening websocket {clientWebSocket.GetHashCode()}"); + await clientWebSocket.ConnectAsync(webSocketUri, token).ConfigureAwait(false); + stateSubject.OnNext(GraphQLWebsocketConnectionState.Connected); + Debug.WriteLine($"connection established on websocket {clientWebSocket.GetHashCode()}, invoking Options.OnWebsocketConnected()"); + await (Options.OnWebsocketConnected?.Invoke(client) ?? Task.CompletedTask); + Debug.WriteLine($"invoking Options.OnWebsocketConnected() on websocket {clientWebSocket.GetHashCode()}"); + connectionAttempt = 1; + } + catch (Exception e) { + stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); + exceptionSubject.OnNext(e); + throw; + } + } + + /// + /// delay the next connection attempt using + /// + /// + private Task _backOff() { + connectionAttempt++; + + if (connectionAttempt == 1) return Task.CompletedTask; + + var delay = Options.BackOffStrategy?.Invoke(connectionAttempt - 1) ?? TimeSpan.FromSeconds(5); + Debug.WriteLine($"connection attempt #{connectionAttempt}, backing off for {delay.TotalSeconds} s"); + return Task.Delay(delay); + } + + private IObservable _createResponseStream() { return Observable.Create(_createResultStream) // complete sequence on OperationCanceledException, this is triggered by the cancellation token on disposal @@ -144,68 +172,60 @@ private IObservable _createResponseStream() { } private async Task _createResultStream(IObserver observer, CancellationToken token) { - if (_responseSubject == null || _responseSubject.IsDisposed) { - _responseSubject = new Subject(); - var observable = await _getReceiveResultStream().ConfigureAwait(false); - observable.Subscribe(_responseSubject); - - _responseSubject.Subscribe(_ => { }, ex => { - _exceptionSubject.OnNext(ex); - _responseSubject?.Dispose(); - _responseSubject = null; + if (responseSubject == null || responseSubject.IsDisposed) { + // create new response subject + responseSubject = new Subject(); + + // initialize and connect websocket + await InitializeWebSocket().ConfigureAwait(false); + + // loop the receive task and subscribe the created subject to the results + Observable.Defer(() => _getReceiveTask().ToObservable()).Repeat().Subscribe(responseSubject); + + // dispose the subject on any error or completion (will be recreated) + responseSubject.Subscribe(_ => { }, ex => { + exceptionSubject.OnNext(ex); + responseSubject?.Dispose(); + responseSubject = null; + stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); }, () => { - _responseSubject?.Dispose(); - _responseSubject = null; + responseSubject?.Dispose(); + responseSubject = null; + stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); }); } return new CompositeDisposable ( - _responseSubject.Subscribe(observer), + responseSubject.Subscribe(observer), Disposable.Create(() => { Debug.WriteLine("response stream disposed"); }) ); } - private async Task> _getReceiveResultStream() { - await InitializeWebSocket().ConfigureAwait(false); - return Observable.Defer(() => _getReceiveTask().ToObservable()).Repeat(); - } - - private async Task _connectAsync(CancellationToken token) { - try { - await _backOff().ConfigureAwait(false); - Debug.WriteLine($"opening websocket {clientWebSocket.GetHashCode()}"); - await clientWebSocket.ConnectAsync(webSocketUri, token).ConfigureAwait(false); - Debug.WriteLine($"connection established on websocket {clientWebSocket.GetHashCode()}"); - _connectionAttempt = 1; - } - catch (Exception e) { - _exceptionSubject.OnNext(e); - throw; - } - } - - - private Task _receiveAsyncTask = null; - private readonly object _receiveTaskLocker = new object(); + private Task receiveAsyncTask = null; + private readonly object receiveTaskLocker = new object(); /// /// wrapper method to pick up the existing request task if already running /// /// private Task _getReceiveTask() { - lock (_receiveTaskLocker) { - if (_receiveAsyncTask == null || - _receiveAsyncTask.IsFaulted || - _receiveAsyncTask.IsCompleted) - _receiveAsyncTask = _receiveResultAsync(); + lock (receiveTaskLocker) { + if (receiveAsyncTask == null || + receiveAsyncTask.IsFaulted || + receiveAsyncTask.IsCompleted) + receiveAsyncTask = _receiveResultAsync(); } - return _receiveAsyncTask; + return receiveAsyncTask; } + /// + /// read a single message from the websocket + /// + /// private async Task _receiveResultAsync() { try { Debug.WriteLine($"receiving data on websocket {clientWebSocket.GetHashCode()} ..."); @@ -213,24 +233,19 @@ private async Task _receiveResultAsync() { using (var ms = new MemoryStream()) { WebSocketReceiveResult webSocketReceiveResult = null; do { - _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + cancellationTokenSource.Token.ThrowIfCancellationRequested(); webSocketReceiveResult = await clientWebSocket.ReceiveAsync(buffer, CancellationToken.None); ms.Write(buffer.Array, buffer.Offset, webSocketReceiveResult.Count); } while (!webSocketReceiveResult.EndOfMessage); - _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + cancellationTokenSource.Token.ThrowIfCancellationRequested(); ms.Seek(0, SeekOrigin.Begin); if (webSocketReceiveResult.MessageType == WebSocketMessageType.Text) { - using (StreamReader sr = new StreamReader(ms)) - using (JsonReader reader = new JsonTextReader(sr)) { - JsonSerializer serializer = JsonSerializer.Create(_options.JsonSerializerSettings); - - var response = serializer.Deserialize(reader); - response.MessageBytes = ms.ToArray(); - return response; - } + var response = await Options.JsonSerializer.DeserializeToWebsocketResponseWrapperAsync(ms); + response.MessageBytes = ms.ToArray(); + return response; } else { throw new NotSupportedException("binary websocket messages are not supported"); @@ -257,28 +272,36 @@ private async Task _closeAsync(CancellationToken cancellationToken = default) { Debug.WriteLine($"closing websocket {clientWebSocket.GetHashCode()}"); await this.clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", cancellationToken).ConfigureAwait(false); + stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); } -#endregion - -#region IDisposable + #region IDisposable + public void Dispose() => Complete(); - private Task _disposed; - private object _disposedLocker = new object(); - public void Dispose() { - // Async disposal as recommended by Stephen Cleary (https://blog.stephencleary.com/2013/03/async-oop-6-disposal.html) - lock (_disposedLocker) { - if (_disposed == null) _disposed = DisposeAsync(); + /// + /// Cancels the current operation, closes the websocket connection and disposes of internal resources. + /// + public void Complete() { + lock (completedLocker) { + if (Completion == null) Completion = CompleteAsync(); } } - private async Task DisposeAsync() { + /// + /// Task to await the completion (a.k.a. disposal) of this websocket. + /// + /// Async disposal as recommended by Stephen Cleary (https://blog.stephencleary.com/2013/03/async-oop-6-disposal.html) + public Task Completion { get; private set; } + + private readonly object completedLocker = new object(); + private async Task CompleteAsync() { Debug.WriteLine($"disposing websocket {clientWebSocket.GetHashCode()}..."); - if (!_cancellationTokenSource.IsCancellationRequested) - _cancellationTokenSource.Cancel(); + if (!cancellationTokenSource.IsCancellationRequested) + cancellationTokenSource.Cancel(); await _closeAsync().ConfigureAwait(false); + requestSubscription?.Dispose(); clientWebSocket?.Dispose(); - _cancellationTokenSource.Dispose(); + cancellationTokenSource.Dispose(); Debug.WriteLine($"websocket {clientWebSocket.GetHashCode()} disposed"); } #endregion diff --git a/src/GraphQL.Client/Websocket/GraphQLHttpWebsocketHelpers.cs b/src/GraphQL.Client/Websocket/GraphQLHttpWebsocketHelpers.cs index f3cb5854..9148a727 100644 --- a/src/GraphQL.Client/Websocket/GraphQLHttpWebsocketHelpers.cs +++ b/src/GraphQL.Client/Websocket/GraphQLHttpWebsocketHelpers.cs @@ -6,7 +6,7 @@ using System.Reactive.Threading.Tasks; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; +using GraphQL.Client.Abstractions.Websocket; namespace GraphQL.Client.Http.Websocket { public static class GraphQLHttpWebsocketHelpers { @@ -48,7 +48,8 @@ internal static IObservable> CreateSubscriptionStream // post the GraphQLResponse to the stream (even if a GraphQL error occurred) Debug.WriteLine($"received payload on subscription {startRequest.Id}"); var typedResponse = - response.MessageBytes.DeserializeFromBytes>(client.Options); + client.Options.JsonSerializer.DeserializeToWebsocketResponse( + response.MessageBytes); o.OnNext(typedResponse.Payload); // in case of a GraphQL error, terminate the sequence after the response has been posted @@ -167,9 +168,10 @@ internal static Task> SendRequest( .Where(response => response != null && response.Id == websocketRequest.Id) .TakeUntil(response => response.Type == GraphQLWebSocketMessageType.GQL_COMPLETE) .Select(response => { - Debug.WriteLine($"received response for request {websocketRequest.Id}"); ; + Debug.WriteLine($"received response for request {websocketRequest.Id}"); var typedResponse = - response.MessageBytes.DeserializeFromBytes>(client.Options); + client.Options.JsonSerializer.DeserializeToWebsocketResponse( + response.MessageBytes); return typedResponse.Payload; }); diff --git a/src/GraphQL.Primitives/GraphQLError.cs b/src/GraphQL.Primitives/GraphQLError.cs index c1b159a4..3392e97c 100644 --- a/src/GraphQL.Primitives/GraphQLError.cs +++ b/src/GraphQL.Primitives/GraphQLError.cs @@ -10,12 +10,6 @@ namespace GraphQL { /// public class GraphQLError : IEquatable { - /// - /// The extensions of the error - /// - [DataMember(Name = "extensions")] - public IDictionary? Extensions { get; set; } - /// /// The locations of the error /// @@ -34,6 +28,12 @@ public class GraphQLError : IEquatable { [DataMember(Name = "path")] public object[]? Path { get; set; } + /// + /// The extensions of the error + /// + [DataMember(Name = "extensions")] + public GraphQLExtensionsType? Extensions { get; set; } + /// /// Returns a value that indicates whether this instance is equal to a specified object /// @@ -50,7 +50,6 @@ public override bool Equals(object? obj) => public bool Equals(GraphQLError? other) { if (other == null) { return false; } if (ReferenceEquals(this, other)) { return true; } - if (!EqualityComparer?>.Default.Equals(this.Extensions, other.Extensions)) { return false; } { if (this.Locations != null && other.Locations != null) { if (!this.Locations.SequenceEqual(other.Locations)) { return false; } @@ -74,9 +73,6 @@ public bool Equals(GraphQLError? other) { /// public override int GetHashCode() { var hashCode = 0; - if (this.Extensions != null) { - hashCode = hashCode ^ EqualityComparer?>.Default.GetHashCode(this.Extensions); - } if (this.Locations != null) { hashCode = hashCode ^ EqualityComparer.Default.GetHashCode(this.Locations); } diff --git a/src/GraphQL.Primitives/GraphQLExtensionsType.cs b/src/GraphQL.Primitives/GraphQLExtensionsType.cs new file mode 100644 index 00000000..2de718d0 --- /dev/null +++ b/src/GraphQL.Primitives/GraphQLExtensionsType.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace GraphQL { + + /// + /// The GraphQL extensions type. Create a custom json converter for this class to customize your serializers behaviour + /// + public class GraphQLExtensionsType: Dictionary { } +} diff --git a/src/GraphQL.Primitives/GraphQLRequest.cs b/src/GraphQL.Primitives/GraphQLRequest.cs index 874b0352..f7e48e24 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -8,29 +8,29 @@ namespace GraphQL { /// A GraphQL request /// public class GraphQLRequest : IEquatable { + public const string OperationNameKey = "operationName"; + public const string QueryKey = "query"; + public const string VariablesKey = "variables"; /// /// The Query /// - /// - [DataMember(Name = "query")] - public string Query { get; set; } + [DataMember(Name = QueryKey)] + public virtual string Query { get; set; } /// /// The name of the Operation /// - [DataMember(Name = "operationName")] - public string? OperationName { get; set; } + [DataMember(Name = OperationNameKey)] + public virtual string? OperationName { get; set; } /// /// Represents the request variables /// - [DataMember(Name = "variables")] + [DataMember(Name = VariablesKey)] public virtual object? Variables { get; set; } - - public GraphQLRequest() { - } + public GraphQLRequest() { } public GraphQLRequest(string query, object? variables = null, string? operationName = null) { Query = query; diff --git a/src/GraphQL.Primitives/GraphQLResponse.cs b/src/GraphQL.Primitives/GraphQLResponse.cs index 4ae09061..088be7bd 100644 --- a/src/GraphQL.Primitives/GraphQLResponse.cs +++ b/src/GraphQL.Primitives/GraphQLResponse.cs @@ -14,7 +14,7 @@ public class GraphQLResponse : IEquatable?> { public GraphQLError[]? Errors { get; set; } [DataMember(Name = "extensions")] - public IDictionary? Extensions { get; set; } + public GraphQLExtensionsType? Extensions { get; set; } public override bool Equals(object? obj) => this.Equals(obj as GraphQLResponse); @@ -29,7 +29,6 @@ public bool Equals(GraphQLResponse? other) { else if (this.Errors != null && other.Errors == null) { return false; } else if (this.Errors == null && other.Errors != null) { return false; } } - if (!EqualityComparer?>.Default.Equals(this.Extensions, other.Extensions)) { return false; } return true; } @@ -46,7 +45,6 @@ public override int GetHashCode() { hashCode = (hashCode * 397) ^ 0; } } - hashCode = (hashCode * 397) ^ EqualityComparer?>.Default.GetHashCode(this.Extensions); return hashCode; } } diff --git a/src/src.props b/src/src.props index 19e1fafb..e2e2512b 100644 --- a/src/src.props +++ b/src/src.props @@ -5,6 +5,7 @@ true + 8.0 diff --git a/tests/GraphQL.Client.Serializer.Tests/BaseSerializeNoCamelCaseTest.cs b/tests/GraphQL.Client.Serializer.Tests/BaseSerializeNoCamelCaseTest.cs new file mode 100644 index 00000000..13106fcd --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/BaseSerializeNoCamelCaseTest.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Text; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.LocalExecution; +using GraphQL.Client.Serializer.Tests.TestData; +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Helpers; +using Newtonsoft.Json; +using Xunit; + +namespace GraphQL.Client.Serializer.Tests { + public abstract class BaseSerializeNoCamelCaseTest { + + public IGraphQLWebsocketJsonSerializer Serializer { get; } + public IGraphQLClient ChatClient { get; } + public IGraphQLClient StarWarsClient { get; } + + protected BaseSerializeNoCamelCaseTest(IGraphQLWebsocketJsonSerializer serializer) { + Serializer = serializer; + ChatClient = GraphQLLocalExecutionClient.New(Common.GetChatSchema(), serializer); + StarWarsClient = GraphQLLocalExecutionClient.New(Common.GetStarWarsSchema(), serializer); + } + + [Theory] + [ClassData(typeof(SerializeToStringTestData))] + public void SerializeToStringTest(string expectedJson, GraphQLRequest request) { + var json = Serializer.SerializeToString(request).RemoveWhitespace(); + json.Should().Be(expectedJson.RemoveWhitespace()); + } + + [Theory] + [ClassData(typeof(SerializeToBytesTestData))] + public void SerializeToBytesTest(string expectedJson, GraphQLWebSocketRequest request) { + var json = Encoding.UTF8.GetString(Serializer.SerializeToBytes(request)).RemoveWhitespace(); + json.Should().Be(expectedJson.RemoveWhitespace()); + } + + + [Fact] + public async void WorksWithoutCamelCaseNamingStrategy() { + + const string message = "some random testing message"; + var graphQLRequest = new GraphQLRequest( + @"mutation($input: MessageInputType){ + addMessage(message: $input){ + content + } + }", + new { + input = new { + fromId = "2", + content = message, + sentAt = DateTime.Now + } + }); + var response = await ChatClient.SendMutationAsync(graphQLRequest, () => new { addMessage = new { content = "" } }); + + Assert.Equal(message, response.Data.addMessage.content); + } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/BaseSerializerTest.cs b/tests/GraphQL.Client.Serializer.Tests/BaseSerializerTest.cs new file mode 100644 index 00000000..a07f38f1 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/BaseSerializerTest.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.LocalExecution; +using GraphQL.Client.Serializer.Tests.TestData; +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Chat; +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.Client.Tests.Common.Helpers; +using GraphQL.Client.Tests.Common.StarWars; +using Xunit; + +namespace GraphQL.Client.Serializer.Tests +{ + public abstract class BaseSerializerTest + { + public IGraphQLWebsocketJsonSerializer Serializer { get; } + public IGraphQLClient ChatClient { get; } + public IGraphQLClient StarWarsClient { get; } + + protected BaseSerializerTest(IGraphQLWebsocketJsonSerializer serializer) { + Serializer = serializer; + ChatClient = GraphQLLocalExecutionClient.New(Common.GetChatSchema(), serializer); + StarWarsClient = GraphQLLocalExecutionClient.New(Common.GetStarWarsSchema(), serializer); + } + + [Theory] + [ClassData(typeof(SerializeToStringTestData))] + public void SerializeToStringTest(string expectedJson, GraphQLRequest request) { + var json = Serializer.SerializeToString(request).RemoveWhitespace(); + json.Should().BeEquivalentTo(expectedJson.RemoveWhitespace()); + } + + [Theory] + [ClassData(typeof(SerializeToBytesTestData))] + public void SerializeToBytesTest(string expectedJson, GraphQLWebSocketRequest request) { + var json = Encoding.UTF8.GetString(Serializer.SerializeToBytes(request)).RemoveWhitespace(); + json.Should().BeEquivalentTo(expectedJson.RemoveWhitespace()); + } + + [Fact] + public async void CanDeserializeExtensions() { + + var response = await ChatClient.SendQueryAsync(new GraphQLRequest("query { extensionsTest }"), + () => new { extensionsTest = "" }) + .ConfigureAwait(false); + + response.Errors.Should().NotBeNull(); + response.Errors.Should().ContainSingle(); + response.Errors[0].Extensions.Should().NotBeNull(); + response.Errors[0].Extensions.Should().ContainKey("data"); + + response.Errors[0].Extensions["data"].Should().BeEquivalentTo(ChatQuery.TestExtensions); + } + + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void CanDoSerializationWithAnonymousTypes(int id, string name) { + var graphQLRequest = new GraphQLRequest(@" + query Human($id: String!){ + human(id: $id) { + name + } + } + + query Droid($id: String!) { + droid(id: $id) { + name + } + }", + new { id = id.ToString() }, + "Human"); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }) + .ConfigureAwait(false); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + } + + [Fact] + public async void CanDoSerializationWithPredefinedTypes() { + const string message = "some random testing message"; + var response = await ChatClient.AddMessageAsync(message).ConfigureAwait(false); + + Assert.Equal(message, response.Data.AddMessage.Content); + } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/GraphQL.Client.Serializer.Tests.csproj b/tests/GraphQL.Client.Serializer.Tests/GraphQL.Client.Serializer.Tests.csproj new file mode 100644 index 00000000..b61c1466 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/GraphQL.Client.Serializer.Tests.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp3.1 + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/GraphQL.Client.Serializer.Tests/NewtonsoftSerializerTest.cs b/tests/GraphQL.Client.Serializer.Tests/NewtonsoftSerializerTest.cs new file mode 100644 index 00000000..84bb5e84 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/NewtonsoftSerializerTest.cs @@ -0,0 +1,13 @@ +using GraphQL.Client.Serializer.Newtonsoft; +using Newtonsoft.Json; + +namespace GraphQL.Client.Serializer.Tests { + public class NewtonsoftSerializerTest : BaseSerializerTest { + public NewtonsoftSerializerTest() : base(new NewtonsoftJsonSerializer()) { } + } + + public class NewtonsoftSerializeNoCamelCaseTest : BaseSerializeNoCamelCaseTest { + public NewtonsoftSerializeNoCamelCaseTest() + : base(new NewtonsoftJsonSerializer(new JsonSerializerSettings())) { } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/SystemTextJsonSerializerTests.cs b/tests/GraphQL.Client.Serializer.Tests/SystemTextJsonSerializerTests.cs new file mode 100644 index 00000000..a3d393dc --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/SystemTextJsonSerializerTests.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using Dahomey.Json; +using GraphQL.Client.Serializer.SystemTextJson; + +namespace GraphQL.Client.Serializer.Tests { + public class SystemTextJsonSerializerTests: BaseSerializerTest { + public SystemTextJsonSerializerTests() : base(new SystemTextJsonSerializer()) { } + } + + public class SystemTextJsonSerializeNoCamelCaseTest : BaseSerializeNoCamelCaseTest { + public SystemTextJsonSerializeNoCamelCaseTest() + : base(new SystemTextJsonSerializer(new JsonSerializerOptions().SetupExtensions())) { } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToBytesTestData.cs b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToBytesTestData.cs new file mode 100644 index 00000000..41d15ce0 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToBytesTestData.cs @@ -0,0 +1,32 @@ +using System.Collections; +using System.Collections.Generic; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.Tests.Common.Chat; + +namespace GraphQL.Client.Serializer.Tests.TestData { + public class SerializeToBytesTestData : IEnumerable { + public IEnumerator GetEnumerator() { + yield return new object[] { + "{\"id\":\"1234567\",\"type\":\"start\",\"payload\":{\"query\":\"simplequerystring\",\"operationName\":null,\"variables\":null}}", + new GraphQLWebSocketRequest { + Id = "1234567", + Type = GraphQLWebSocketMessageType.GQL_START, + Payload = new GraphQLRequest("simplequerystring") + } + }; + yield return new object[] { + "{\"id\":\"34476567\",\"type\":\"start\",\"payload\":{\"query\":\"simplequerystring\",\"operationName\":null,\"variables\":{\"camelCaseProperty\":\"camelCase\",\"PascalCaseProperty\":\"PascalCase\"}}}", + new GraphQLWebSocketRequest { + Id = "34476567", + Type = GraphQLWebSocketMessageType.GQL_START, + Payload = new GraphQLRequest("simple query string", new { camelCaseProperty = "camelCase", PascalCaseProperty = "PascalCase"}) + } + + }; + } + + IEnumerator IEnumerable.GetEnumerator() { + return GetEnumerator(); + } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToStringTestData.cs b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToStringTestData.cs new file mode 100644 index 00000000..86d1ae9f --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToStringTestData.cs @@ -0,0 +1,22 @@ +using System.Collections; +using System.Collections.Generic; +using GraphQL.Client.Tests.Common.Chat; + +namespace GraphQL.Client.Serializer.Tests.TestData { + public class SerializeToStringTestData : IEnumerable { + public IEnumerator GetEnumerator() { + yield return new object[] { + "{\"query\":\"simplequerystring\",\"operationName\":null,\"variables\":null}", + new GraphQLRequest("simple query string") + }; + yield return new object[] { + "{\"query\":\"simplequerystring\",\"operationName\":null,\"variables\":{\"camelCaseProperty\":\"camelCase\",\"PascalCaseProperty\":\"PascalCase\"}}", + new GraphQLRequest("simple query string", new { camelCaseProperty = "camelCase", PascalCaseProperty = "PascalCase"}) + }; + } + + IEnumerator IEnumerable.GetEnumerator() { + return GetEnumerator(); + } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/AddMessageMutationResult.cs b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageMutationResult.cs new file mode 100644 index 00000000..e3aaaf1c --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageMutationResult.cs @@ -0,0 +1,9 @@ +namespace GraphQL.Client.Tests.Common.Chat +{ + public class AddMessageMutationResult { + public AddMessageContent AddMessage { get; set; } + public class AddMessageContent { + public string Content { get; set; } + } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/AddMessageVariables.cs b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageVariables.cs new file mode 100644 index 00000000..129c5c2d --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageVariables.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GraphQL.Client.Tests.Common.Chat { + public class AddMessageVariables { + + public AddMessageInput Input { get; set; } + public class AddMessageInput { + public string FromId { get; set; } + public string Content { get; set; } + public DateTime SentAt { get; set; } + } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/GraphQLClientChatExtensions.cs b/tests/GraphQL.Client.Tests.Common/Chat/GraphQLClientChatExtensions.cs new file mode 100644 index 00000000..f3d95cf7 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/GraphQLClientChatExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using GraphQL.Client.Abstractions; + +namespace GraphQL.Client.Tests.Common.Chat { + public static class GraphQLClientChatExtensions { + public const string AddMessageQuery = +@"mutation($input: MessageInputType){ + addMessage(message: $input){ + content + } +}"; + + public static Task> AddMessageAsync(this IGraphQLClient client, string message) { + var variables = new AddMessageVariables { + Input = new AddMessageVariables.AddMessageInput { + FromId = "2", + Content = message, + SentAt = DateTime.Now + } + }; + + var graphQLRequest = new GraphQLRequest(AddMessageQuery, variables); + return client.SendMutationAsync(graphQLRequest); + } + + public static Task> JoinDeveloperUser(this IGraphQLClient client) { + var graphQLRequest = new GraphQLRequest(@" + mutation($userId: String){ + join(userId: $userId){ + displayName + id + } + }", + new { + userId = "1" + }); + return client.SendMutationAsync(graphQLRequest); + } + + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/JoinDeveloperMutationResult.cs b/tests/GraphQL.Client.Tests.Common/Chat/JoinDeveloperMutationResult.cs new file mode 100644 index 00000000..2807a5b2 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/JoinDeveloperMutationResult.cs @@ -0,0 +1,10 @@ +namespace GraphQL.Client.Tests.Common.Chat +{ + public class JoinDeveloperMutationResult { + public JoinContent Join { get; set; } + public class JoinContent { + public string DisplayName { get; set; } + public string Id { get; set; } + } + } +} diff --git a/tests/IntegrationTestServer/ChatSchema/CapitalizedFieldsGraphType.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/CapitalizedFieldsGraphType.cs similarity index 58% rename from tests/IntegrationTestServer/ChatSchema/CapitalizedFieldsGraphType.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/CapitalizedFieldsGraphType.cs index 32b235e5..ddcd1238 100644 --- a/tests/IntegrationTestServer/ChatSchema/CapitalizedFieldsGraphType.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/CapitalizedFieldsGraphType.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection.Metadata.Ecma335; -using System.Threading.Tasks; using GraphQL.Types; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class CapitalizedFieldsGraphType: ObjectGraphType { public CapitalizedFieldsGraphType() { Name = "CapitalizedFields"; diff --git a/tests/IntegrationTestServer/ChatSchema/ChatMutation.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatMutation.cs similarity index 94% rename from tests/IntegrationTestServer/ChatSchema/ChatMutation.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatMutation.cs index 43895aec..384639ab 100644 --- a/tests/IntegrationTestServer/ChatSchema/ChatMutation.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatMutation.cs @@ -1,6 +1,6 @@ using GraphQL.Types; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class ChatMutation : ObjectGraphType { public ChatMutation(IChat chat) { Field("addMessage", diff --git a/tests/IntegrationTestServer/ChatSchema/ChatQuery.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatQuery.cs similarity index 91% rename from tests/IntegrationTestServer/ChatSchema/ChatQuery.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatQuery.cs index 8b423f95..32357338 100644 --- a/tests/IntegrationTestServer/ChatSchema/ChatQuery.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatQuery.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; using System.Linq; -using GraphQL; using GraphQL.Types; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class ChatQuery : ObjectGraphType { public static readonly Dictionary TestExtensions = new Dictionary { diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSchema.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSchema.cs new file mode 100644 index 00000000..8ef13e43 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSchema.cs @@ -0,0 +1,10 @@ +namespace GraphQL.Client.Tests.Common.Chat.Schema { + public class ChatSchema : Types.Schema { + public ChatSchema(IDependencyResolver resolver) + : base(resolver) { + Query = resolver.Resolve(); + Mutation = resolver.Resolve(); + Subscription = resolver.Resolve(); + } + } +} diff --git a/tests/IntegrationTestServer/ChatSchema/ChatSubscriptions.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSubscriptions.cs similarity index 97% rename from tests/IntegrationTestServer/ChatSchema/ChatSubscriptions.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSubscriptions.cs index 8f3c43ed..f7df7dd5 100644 --- a/tests/IntegrationTestServer/ChatSchema/ChatSubscriptions.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSubscriptions.cs @@ -2,13 +2,12 @@ using System.Linq; using System.Reactive.Linq; using System.Security.Claims; -using GraphQL; using GraphQL.Resolvers; using GraphQL.Server.Transports.Subscriptions.Abstractions; using GraphQL.Subscription; using GraphQL.Types; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class ChatSubscriptions : ObjectGraphType { private readonly IChat _chat; diff --git a/tests/IntegrationTestServer/ChatSchema/IChat.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/IChat.cs similarity index 97% rename from tests/IntegrationTestServer/ChatSchema/IChat.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/IChat.cs index ef1bd08f..2e31e8b4 100644 --- a/tests/IntegrationTestServer/ChatSchema/IChat.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/IChat.cs @@ -3,7 +3,7 @@ using System.Reactive.Linq; using System.Reactive.Subjects; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public interface IChat { ConcurrentStack AllMessages { get; } diff --git a/tests/IntegrationTestServer/ChatSchema/Message.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/Message.cs similarity index 79% rename from tests/IntegrationTestServer/ChatSchema/Message.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/Message.cs index 93451aaa..1b68c7e4 100644 --- a/tests/IntegrationTestServer/ChatSchema/Message.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/Message.cs @@ -1,6 +1,6 @@ using System; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class Message { public MessageFrom From { get; set; } diff --git a/tests/IntegrationTestServer/ChatSchema/MessageFrom.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFrom.cs similarity index 67% rename from tests/IntegrationTestServer/ChatSchema/MessageFrom.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFrom.cs index 8dff8852..b6bc81a4 100644 --- a/tests/IntegrationTestServer/ChatSchema/MessageFrom.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFrom.cs @@ -1,4 +1,4 @@ -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class MessageFrom { public string Id { get; set; } diff --git a/tests/IntegrationTestServer/ChatSchema/MessageFromType.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFromType.cs similarity index 76% rename from tests/IntegrationTestServer/ChatSchema/MessageFromType.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFromType.cs index da243ee9..4d82c69e 100644 --- a/tests/IntegrationTestServer/ChatSchema/MessageFromType.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFromType.cs @@ -1,6 +1,6 @@ using GraphQL.Types; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class MessageFromType : ObjectGraphType { public MessageFromType() { Field(o => o.Id); diff --git a/tests/IntegrationTestServer/ChatSchema/MessageType.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageType.cs similarity index 88% rename from tests/IntegrationTestServer/ChatSchema/MessageType.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageType.cs index 6740189a..c5172cf3 100644 --- a/tests/IntegrationTestServer/ChatSchema/MessageType.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageType.cs @@ -1,6 +1,6 @@ using GraphQL.Types; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class MessageType : ObjectGraphType { public MessageType() { Field(o => o.Content); diff --git a/tests/IntegrationTestServer/ChatSchema/ReceivedMessage.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ReceivedMessage.cs similarity index 76% rename from tests/IntegrationTestServer/ChatSchema/ReceivedMessage.cs rename to tests/GraphQL.Client.Tests.Common/Chat/Schema/ReceivedMessage.cs index a3d9025b..ab1b05f8 100644 --- a/tests/IntegrationTestServer/ChatSchema/ReceivedMessage.cs +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ReceivedMessage.cs @@ -1,6 +1,6 @@ using System; -namespace IntegrationTestServer.ChatSchema { +namespace GraphQL.Client.Tests.Common.Chat.Schema { public class ReceivedMessage { public string FromId { get; set; } diff --git a/tests/GraphQL.Client.Tests.Common/Common.cs b/tests/GraphQL.Client.Tests.Common/Common.cs new file mode 100644 index 00000000..e912d907 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Common.cs @@ -0,0 +1,46 @@ +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.StarWars; +using GraphQL.StarWars.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace GraphQL.Client.Tests.Common +{ + public static class Common + { + public static StarWarsSchema GetStarWarsSchema() { + var services = new ServiceCollection(); + services.AddTransient(provider => new FuncDependencyResolver(provider.GetService)); + services.AddStarWarsSchema(); + return services.BuildServiceProvider().GetRequiredService(); + } + public static ChatSchema GetChatSchema() { + var services = new ServiceCollection(); + services.AddTransient(provider => new FuncDependencyResolver(provider.GetService)); + services.AddChatSchema(); + return services.BuildServiceProvider().GetRequiredService(); + } + + public static void AddStarWarsSchema(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } + + public static void AddChatSchema(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/GraphQL.Client.Tests.Common.csproj b/tests/GraphQL.Client.Tests.Common/GraphQL.Client.Tests.Common.csproj new file mode 100644 index 00000000..dfbb9146 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/GraphQL.Client.Tests.Common.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + false + + + + + + + + + + + + + + + + + + diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/AvailableJsonSerializers.cs b/tests/GraphQL.Client.Tests.Common/Helpers/AvailableJsonSerializers.cs new file mode 100644 index 00000000..637c5861 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/AvailableJsonSerializers.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using GraphQL.Client.Abstractions; + +namespace GraphQL.Client.Tests.Common.Helpers { + public class AvailableJsonSerializers : IEnumerable where TSerializerInterface : IGraphQLJsonSerializer { + public IEnumerator GetEnumerator() { + // try to find one in the assembly and assign that + var type = typeof(TSerializerInterface); + return AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => type.IsAssignableFrom(p) && !p.IsInterface && !p.IsAbstract) + .Select(serializerType => new object[]{Activator.CreateInstance(serializerType)}) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { + return GetEnumerator(); + } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/CallbackMonitor.cs b/tests/GraphQL.Client.Tests.Common/Helpers/CallbackMonitor.cs new file mode 100644 index 00000000..4b623ac6 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/CallbackMonitor.cs @@ -0,0 +1,115 @@ +using System; +using System.Threading; +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; + +namespace GraphQL.Client.Tests.Common.Helpers { + public class CallbackMonitor { + private readonly ManualResetEventSlim callbackInvoked = new ManualResetEventSlim(); + + /// + /// The timeout for . Defaults to 1 s + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Indicates that an update has been received since the last + /// + public bool CallbackInvoked => callbackInvoked.IsSet; + /// + /// The last payload which was received. + /// + public T LastPayload { get; private set; } + + public void Invoke(T param) { + LastPayload = param; + callbackInvoked.Set(); + } + + /// + /// Asserts that a new update has been pushed to the within the configured since the last . + /// If supplied, the action is executed on the submitted payload. + /// + /// action to assert the contents of the payload + public void CallbackShouldHaveBeenInvoked(Action assertPayload = null, TimeSpan? timeout = null) { + try { + callbackInvoked.Wait(timeout ?? Timeout).Should().BeTrue("because the callback method should have been invoked (timeout: {0} s)", + (timeout ?? Timeout).TotalSeconds); + + assertPayload?.Invoke(LastPayload); + } + finally { + Reset(); + } + } + + /// + /// Asserts that no new update has been pushed within the given since the last + /// + /// the time in ms in which no new update must be pushed to the . defaults to 100 + public void CallbackShouldNotHaveBeenInvoked(TimeSpan? timeout = null) { + if (!timeout.HasValue) timeout = TimeSpan.FromMilliseconds(100); + try { + callbackInvoked.Wait(timeout.Value).Should().BeFalse("because the callback method should not have been invoked"); + } + finally { + Reset(); + } + } + + /// + /// Resets the tester class. Should be called before triggering the potential update + /// + public void Reset() { + LastPayload = default(T); + callbackInvoked.Reset(); + } + + + public CallbackAssertions Should() { + return new CallbackAssertions(this); + } + + public class CallbackAssertions : ReferenceTypeAssertions, CallbackAssertions> { + public CallbackAssertions(CallbackMonitor tester) { + Subject = tester; + } + + protected override string Identifier => "callback"; + + public AndWhichConstraint, TPayload> HaveBeenInvokedWithPayload(TimeSpan timeout, + string because = "", params object[] becauseArgs) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject.callbackInvoked.Wait(timeout)) + .ForCondition(isSet => isSet) + .FailWith("Expected {context:callback} to be invoked{reason}, but did not receive a call within {0}", timeout); + + Subject.callbackInvoked.Reset(); + return new AndWhichConstraint, TPayload>(this, Subject.LastPayload); + } + public AndWhichConstraint, TPayload> HaveBeenInvokedWithPayload(string because = "", params object[] becauseArgs) + => HaveBeenInvokedWithPayload(Subject.Timeout, because, becauseArgs); + + public AndConstraint> HaveBeenInvoked(TimeSpan timeout, string because = "", params object[] becauseArgs) + => HaveBeenInvokedWithPayload(timeout, because, becauseArgs); + public AndConstraint> HaveBeenInvoked(string because = "", params object[] becauseArgs) + => HaveBeenInvokedWithPayload(Subject.Timeout, because, becauseArgs); + + public AndConstraint> NotHaveBeenInvoked(TimeSpan timeout, + string because = "", params object[] becauseArgs) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject.callbackInvoked.Wait(timeout)) + .ForCondition(isSet => !isSet) + .FailWith("Expected {context:callback} to not be invoked{reason}, but did receive a call: {0}", Subject.LastPayload); + + Subject.callbackInvoked.Reset(); + return new AndConstraint>(this); + } + public AndConstraint> NotHaveBeenInvoked(string because = "", params object[] becauseArgs) + => NotHaveBeenInvoked(TimeSpan.FromMilliseconds(100), because, becauseArgs); + } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/MiscellaneousExtensions.cs b/tests/GraphQL.Client.Tests.Common/Helpers/MiscellaneousExtensions.cs new file mode 100644 index 00000000..0e254672 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/MiscellaneousExtensions.cs @@ -0,0 +1,25 @@ +using System.Linq; +using System.Threading.Tasks; +using GraphQL.Client.Http; + +namespace GraphQL.Client.Tests.Common.Helpers { + public static class MiscellaneousExtensions { + public static string RemoveWhitespace(this string input) { + return new string(input.ToCharArray() + .Where(c => !char.IsWhiteSpace(c)) + .ToArray()); + } + + public static CallbackMonitor ConfigureMonitorForOnWebsocketConnected( + this GraphQLHttpClient client) { + var tester = new CallbackMonitor(); + client.Options.OnWebsocketConnected = c => { + tester.Invoke(c); + return Task.CompletedTask; + }; + return tester; + } + + + } +} diff --git a/tests/GraphQL.Integration.Tests/Helpers/NetworkHelpers.cs b/tests/GraphQL.Client.Tests.Common/Helpers/NetworkHelpers.cs similarity index 85% rename from tests/GraphQL.Integration.Tests/Helpers/NetworkHelpers.cs rename to tests/GraphQL.Client.Tests.Common/Helpers/NetworkHelpers.cs index 6995ee54..ec08de02 100644 --- a/tests/GraphQL.Integration.Tests/Helpers/NetworkHelpers.cs +++ b/tests/GraphQL.Client.Tests.Common/Helpers/NetworkHelpers.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Sockets; -namespace GraphQL.Integration.Tests.Helpers { +namespace GraphQL.Client.Tests.Common.Helpers { public static class NetworkHelpers { public static int GetFreeTcpPortNumber() { var l = new TcpListener(IPAddress.Loopback, 0); diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/ObservableTester.cs b/tests/GraphQL.Client.Tests.Common/Helpers/ObservableTester.cs new file mode 100644 index 00000000..43bcbe18 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/ObservableTester.cs @@ -0,0 +1,136 @@ +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading; +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; + +namespace GraphQL.Client.Tests.Common.Helpers { + public class ObservableTester : IDisposable { + private readonly IDisposable subscription; + private readonly ManualResetEventSlim updateReceived = new ManualResetEventSlim(); + private readonly ManualResetEventSlim completed = new ManualResetEventSlim(); + private readonly ManualResetEventSlim error = new ManualResetEventSlim(); + + /// + /// The timeout for . Defaults to 1 s + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Indicates that an update has been received since the last + /// + public bool UpdateReceived => updateReceived.IsSet; + /// + /// The last payload which was received. + /// + public TSubscriptionPayload LastPayload { get; private set; } + + public Exception Error { get; private set; } + + /// + /// Creates a new which subscribes to the supplied + /// + /// the under test + public ObservableTester(IObservable observable) { + subscription = observable.ObserveOn(TaskPoolScheduler.Default).Subscribe( + obj => { + LastPayload = obj; + updateReceived.Set(); + }, + ex => { + Error = ex; + error.Set(); + }, + () => completed.Set() + ); + } + + /// + /// Resets the tester class. Should be called before triggering the potential update + /// + private void Reset() { + updateReceived.Reset(); + } + + /// + public void Dispose() { + subscription?.Dispose(); + } + + public SubscriptionAssertions Should() { + return new SubscriptionAssertions(this); + } + + public class SubscriptionAssertions : ReferenceTypeAssertions, SubscriptionAssertions> { + public SubscriptionAssertions(ObservableTester tester) { + Subject = tester; + } + + protected override string Identifier => "Subscription"; + + public AndWhichConstraint, TPayload> HaveReceivedPayload(TimeSpan timeout, + string because = "", params object[] becauseArgs) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject.updateReceived.Wait(timeout)) + .ForCondition(isSet => isSet) + .FailWith("Expected {context:Subscription} to receive new payload{reason}, but did not receive an update within {0}", timeout); + + Subject.updateReceived.Reset(); + return new AndWhichConstraint, TPayload>(this, Subject.LastPayload); + } + public AndWhichConstraint, TPayload> HaveReceivedPayload(string because = "", params object[] becauseArgs) + => HaveReceivedPayload(Subject.Timeout, because, becauseArgs); + + public AndConstraint> NotHaveReceivedPayload(TimeSpan timeout, + string because = "", params object[] becauseArgs) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject.updateReceived.Wait(timeout)) + .ForCondition(isSet => !isSet) + .FailWith("Expected {context:Subscription} to not receive a new payload{reason}, but did receive an update: {0}", Subject.LastPayload); + + Subject.updateReceived.Reset(); + return new AndConstraint>(this); + } + public AndConstraint> NotHaveReceivedPayload(string because = "", params object[] becauseArgs) + => NotHaveReceivedPayload(TimeSpan.FromMilliseconds(100), because, becauseArgs); + + public AndWhichConstraint, Exception> HaveReceivedError(TimeSpan timeout, + string because = "", params object[] becauseArgs) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject.error.Wait(timeout)) + .ForCondition(isSet => isSet) + .FailWith("Expected {context:Subscription} to fail{reason}, but did not receive an error within {0}", timeout); + + return new AndWhichConstraint, Exception>(this, Subject.Error); + } + public AndWhichConstraint, Exception> HaveReceivedError(string because = "", params object[] becauseArgs) + => HaveReceivedError(Subject.Timeout, because, becauseArgs); + + + public AndConstraint> HaveCompleted(TimeSpan timeout, + string because = "", params object[] becauseArgs) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject.completed.Wait(timeout)) + .ForCondition(isSet => isSet) + .FailWith("Expected {context:Subscription} to complete{reason}, but did not complete within {0}", timeout); + + return new AndConstraint>(this); + } + public AndConstraint> HaveCompleted(string because = "", params object[] becauseArgs) + => HaveCompleted(Subject.Timeout, because, becauseArgs); + } + } + + public static class ObservableExtensions { + public static ObservableTester Monitor(this IObservable observable) { + return new ObservableTester(observable); + } + } + +} diff --git a/tests/GraphQL.Integration.Tests/TestData/StarWarsHumans.cs b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsHumans.cs similarity index 87% rename from tests/GraphQL.Integration.Tests/TestData/StarWarsHumans.cs rename to tests/GraphQL.Client.Tests.Common/StarWars/StarWarsHumans.cs index 99312c8c..44a9f6bd 100644 --- a/tests/GraphQL.Integration.Tests/TestData/StarWarsHumans.cs +++ b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsHumans.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Collections.Generic; -namespace GraphQL.Integration.Tests.TestData { +namespace GraphQL.Client.Tests.Common.StarWars { public class StarWarsHumans: IEnumerable { public IEnumerator GetEnumerator() { yield return new object[] { 1, "Luke" }; diff --git a/tests/GraphQL.Integration.Tests/Extensions/GraphQLClientTestExtensions.cs b/tests/GraphQL.Integration.Tests/Extensions/GraphQLClientTestExtensions.cs deleted file mode 100644 index f698b247..00000000 --- a/tests/GraphQL.Integration.Tests/Extensions/GraphQLClientTestExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Threading.Tasks; -using GraphQL.Client; -using GraphQL.Client.Http; - -namespace GraphQL.Integration.Tests.Extensions { - public static class GraphQLClientTestExtensions { - public static Task> AddMessageAsync(this GraphQLHttpClient client, string message) { - var graphQLRequest = new GraphQLRequest( - @"mutation($input: MessageInputType){ - addMessage(message: $input){ - content - } - }", - new { - input = new { - fromId = "2", - content = message, - sentAt = DateTime.Now - } - }); - return client.SendMutationAsync(graphQLRequest); - } - - public static Task> JoinDeveloperUser(this GraphQLHttpClient client) { - var graphQLRequest = new GraphQLRequest(@" - mutation($userId: String){ - join(userId: $userId){ - displayName - id - } - }", - new { - userId = "1" - }); - return client.SendMutationAsync(graphQLRequest); - } - - } - - - public class AddMessageMutationResult { - public AddMessageContent AddMessage { get; set; } - public class AddMessageContent { - public string Content { get; set; } - } - } - - public class JoinDeveloperMutationResult { - public JoinContent Join { get; set; } - public class JoinContent { - public string DisplayName { get; set; } - public string Id { get; set; } - } - } -} diff --git a/tests/GraphQL.Integration.Tests/ExtensionsTest.cs b/tests/GraphQL.Integration.Tests/ExtensionsTest.cs deleted file mode 100644 index fff1b6e4..00000000 --- a/tests/GraphQL.Integration.Tests/ExtensionsTest.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Text.Json; -using FluentAssertions; -using GraphQL.Client.Abstractions; -using GraphQL.Integration.Tests.Helpers; -using IntegrationTestServer; -using IntegrationTestServer.ChatSchema; -using Newtonsoft.Json; -using Xunit; - -namespace GraphQL.Integration.Tests { - public class ExtensionsTest { - private static TestServerSetup SetupTest(bool requestsViaWebsocket = false) => - WebHostHelpers.SetupTest(requestsViaWebsocket); - - [Fact] - public async void CanDeserializeExtensions() { - - using var setup = SetupTest(); - var response = await setup.Client.SendQueryAsync(new GraphQLRequest("query { extensionsTest }"), - () => new {extensionsTest = ""}) - .ConfigureAwait(false); - - response.Errors.Should().NotBeNull(); - response.Errors.Should().ContainSingle(); - response.Errors[0].Extensions.Should().NotBeNull(); - response.Errors[0].Extensions.Should().ContainKey("data"); - - foreach (var item in ChatQuery.TestExtensions) { - - - } - } - - [Fact] - public async void DontNeedToUseCamelCaseNamingStrategy() { - - using var setup = SetupTest(); - setup.Client.Options.JsonSerializerSettings = new JsonSerializerSettings(); - - const string message = "some random testing message"; - var graphQLRequest = new GraphQLRequest( - @"mutation($input: MessageInputType){ - addMessage(message: $input){ - content - } - }", - new { - input = new { - fromId = "2", - content = message, - sentAt = DateTime.Now - } - }); - var response = await setup.Client.SendMutationAsync(graphQLRequest, () => new { addMessage = new { content = "" } }); - - Assert.Equal(message, response.Data.addMessage.content); - } - } -} diff --git a/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj b/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj index b1731cc1..3273adae 100644 --- a/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj +++ b/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj @@ -21,6 +21,8 @@ + + diff --git a/tests/GraphQL.Integration.Tests/Helpers/CallbackTester.cs b/tests/GraphQL.Integration.Tests/Helpers/CallbackTester.cs deleted file mode 100644 index e0d46b5f..00000000 --- a/tests/GraphQL.Integration.Tests/Helpers/CallbackTester.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Threading; -using Xunit; - -namespace GraphQL.Integration.Tests.Helpers { - public class CallbackTester { - private ManualResetEventSlim _callbackInvoked { get; } = new ManualResetEventSlim(); - - /// - /// The timeout for . Defaults to 1 s - /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); - - /// - /// Indicates that an update has been received since the last - /// - public bool CallbackInvoked => _callbackInvoked.IsSet; - /// - /// The last payload which was received. - /// - public T LastPayload { get; private set; } - - public void Callback(T param) { - LastPayload = param; - _callbackInvoked.Set(); - } - - /// - /// Asserts that a new update has been pushed to the within the configured since the last . - /// If supplied, the action is executed on the submitted payload. - /// - /// action to assert the contents of the payload - public void CallbackShouldHaveBeenInvoked(Action assertPayload = null, TimeSpan? timeout = null) { - try { - if (!_callbackInvoked.Wait(timeout ?? Timeout)) - Assert.True(false, $"callback not invoked within {(timeout ?? Timeout).TotalSeconds} s!"); - - assertPayload?.Invoke(LastPayload); - } - finally { - Reset(); - } - } - - /// - /// Asserts that no new update has been pushed within the given since the last - /// - /// the time in ms in which no new update must be pushed to the . defaults to 100 - public void CallbackShouldNotHaveBeenInvoked(TimeSpan? timeout = null) { - if (!timeout.HasValue) timeout = TimeSpan.FromMilliseconds(100); - try { - if (_callbackInvoked.Wait(timeout.Value)) - Assert.True(false, "callback was inadvertently invoked pushed!"); - } - finally { - Reset(); - } - } - - /// - /// Resets the tester class. Should be called before triggering the potential update - /// - public void Reset() { - LastPayload = default(T); - _callbackInvoked.Reset(); - } - } -} diff --git a/tests/GraphQL.Integration.Tests/Helpers/ObservableTester.cs b/tests/GraphQL.Integration.Tests/Helpers/ObservableTester.cs deleted file mode 100644 index a650237b..00000000 --- a/tests/GraphQL.Integration.Tests/Helpers/ObservableTester.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Threading; -using Xunit; - -namespace GraphQL.Integration.Tests.Helpers { - public class ObservableTester : IDisposable { - private readonly IDisposable _subscription; - private ManualResetEventSlim _updateReceived { get; } = new ManualResetEventSlim(); - private ManualResetEventSlim _completed { get; } = new ManualResetEventSlim(); - private ManualResetEventSlim _error { get; } = new ManualResetEventSlim(); - - /// - /// The timeout for . Defaults to 1 s - /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(3); - - /// - /// Indicates that an update has been received since the last - /// - public bool UpdateReceived => _updateReceived.IsSet; - /// - /// The last payload which was received. - /// - public T LastPayload { get; private set; } - - public Exception Error { get; private set; } - - /// - /// Creates a new which subscribes to the supplied - /// - /// the under test - public ObservableTester(IObservable observable) { - _subscription = observable.Subscribe( - obj => { - LastPayload = obj; - _updateReceived.Set(); - }, - ex => { - Error = ex; - _error.Set(); - }, - () => _completed.Set() - ); - } - - /// - /// Asserts that a new update has been pushed to the within the configured since the last . - /// If supplied, the action is executed on the submitted payload. - /// - /// action to assert the contents of the payload - public void ShouldHaveReceivedUpdate(Action assertPayload = null, TimeSpan? timeout = null) { - try { - if (!_updateReceived.Wait(timeout ?? Timeout)) - Assert.True(false, $"no update received within {(timeout ?? Timeout).TotalSeconds} s!"); - - assertPayload?.Invoke(LastPayload); - } - finally { - _reset(); - } - } - - /// - /// Asserts that no new update has been pushed within the given since the last - /// - /// the time in ms in which no new update must be pushed to the . defaults to 100 - public void ShouldNotHaveReceivedUpdate(TimeSpan? timeout = null) { - if (!timeout.HasValue) timeout = TimeSpan.FromMilliseconds(100); - try { - if (_updateReceived.Wait(timeout.Value)) - Assert.True(false, "update was inadvertently pushed!"); - } - finally { - _reset(); - } - } - - /// - /// Asserts that the subscription has completed within the configured since the last - /// - public void ShouldHaveCompleted(TimeSpan? timeout = null) { - try { - if (!_completed.Wait(timeout ?? Timeout)) - Assert.True(false, $"subscription did not complete within {(timeout ?? Timeout).TotalSeconds} s!"); - } - finally { - _reset(); - } - } - - /// - /// Asserts that the subscription has completed within the configured since the last - /// - public void ShouldHaveThrownError(Action assertError = null, TimeSpan? timeout = null) { - try { - if (!_error.Wait(timeout ?? Timeout)) - Assert.True(false, $"subscription did not throw an error within {(timeout ?? Timeout).TotalSeconds} s!"); - - assertError?.Invoke(Error); - } - finally { - _reset(); - } - } - - /// - /// Resets the tester class. Should be called before triggering the potential update - /// - private void _reset() { - //if (_completed.IsSet) - // throw new InvalidOperationException( - // "the subscription sequence has completed. this tester instance cannot be reused"); - - LastPayload = default(T); - _updateReceived.Reset(); - } - - /// - public void Dispose() { - _subscription?.Dispose(); - } - } - - public static class ObservableExtensions { - public static ObservableTester SubscribeTester(this IObservable observable) { - return new ObservableTester(observable); - } - } -} diff --git a/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs b/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs index 8c32f558..0ee54a08 100644 --- a/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs +++ b/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs @@ -1,6 +1,9 @@ using System; using GraphQL.Client; +using GraphQL.Client.Abstractions.Websocket; using GraphQL.Client.Http; +using GraphQL.Client.Serializer.Newtonsoft; +using GraphQL.Client.Tests.Common.Helpers; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -31,18 +34,20 @@ public static IWebHost CreateServer(int port) where TStartup : class } - public static GraphQLHttpClient GetGraphQLClient(int port, bool requestsViaWebsocket = false) + public static GraphQLHttpClient GetGraphQLClient(int port, bool requestsViaWebsocket = false, IGraphQLWebsocketJsonSerializer serializer = null) => new GraphQLHttpClient(new GraphQLHttpClientOptions { EndPoint = new Uri($"http://localhost:{port}/graphql"), - UseWebSocketForQueriesAndMutations = requestsViaWebsocket + UseWebSocketForQueriesAndMutations = requestsViaWebsocket, + JsonSerializer = serializer ?? new NewtonsoftJsonSerializer() }); - public static TestServerSetup SetupTest(bool requestsViaWebsocket = false) where TStartup : class + public static TestServerSetup SetupTest(bool requestsViaWebsocket = false, IGraphQLWebsocketJsonSerializer serializer = null) + where TStartup : class { var port = NetworkHelpers.GetFreeTcpPortNumber(); return new TestServerSetup { Server = CreateServer(port), - Client = GetGraphQLClient(port) + Client = GetGraphQLClient(port, requestsViaWebsocket, serializer) }; } } diff --git a/tests/GraphQL.Integration.Tests/QueryAndMutationTests.cs b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Base.cs similarity index 86% rename from tests/GraphQL.Integration.Tests/QueryAndMutationTests.cs rename to tests/GraphQL.Integration.Tests/QueryAndMutationTests/Base.cs index d897884f..16e73c92 100644 --- a/tests/GraphQL.Integration.Tests/QueryAndMutationTests.cs +++ b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Base.cs @@ -1,18 +1,25 @@ using System.Net.Http; -using System.Text.Json; using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; using GraphQL.Client.Http; +using GraphQL.Client.Tests.Common.Helpers; +using GraphQL.Client.Tests.Common.StarWars; using GraphQL.Integration.Tests.Helpers; -using GraphQL.Integration.Tests.TestData; using IntegrationTestServer; -using Newtonsoft.Json.Linq; using Xunit; -namespace GraphQL.Integration.Tests { - public class QueryAndMutationTests { +namespace GraphQL.Integration.Tests.QueryAndMutationTests { + + public abstract class Base { + + protected IGraphQLWebsocketJsonSerializer serializer; + + private TestServerSetup SetupTest(bool requestsViaWebsocket = false) => WebHostHelpers.SetupTest(requestsViaWebsocket, serializer); + + protected Base(IGraphQLWebsocketJsonSerializer serializer) { + this.serializer = serializer; + } - private static TestServerSetup SetupTest(bool requestsViaWebsocket = false) => WebHostHelpers.SetupTest(requestsViaWebsocket); - [Theory] [ClassData(typeof(StarWarsHumans))] public async void QueryTheory(int id, string name) { @@ -132,9 +139,9 @@ query Human($id: String!){ [Fact] public async void PreprocessHttpRequestMessageIsCalled() { - var callbackTester = new CallbackTester(); + var callbackTester = new CallbackMonitor(); var graphQLRequest = new GraphQLHttpRequest($"{{ human(id: \"1\") {{ name }} }}") { - PreprocessHttpRequestMessage = callbackTester.Callback + PreprocessHttpRequestMessage = callbackTester.Invoke }; using (var setup = SetupTest()) { diff --git a/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Newtonsoft.cs b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Newtonsoft.cs new file mode 100644 index 00000000..1046ed6d --- /dev/null +++ b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Newtonsoft.cs @@ -0,0 +1,9 @@ +using GraphQL.Client.Serializer.Newtonsoft; + +namespace GraphQL.Integration.Tests.QueryAndMutationTests { + public class Newtonsoft: Base { + public Newtonsoft() : base(new NewtonsoftJsonSerializer()) + { + } + } +} diff --git a/tests/GraphQL.Integration.Tests/QueryAndMutationTests/SystemTextJson.cs b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/SystemTextJson.cs new file mode 100644 index 00000000..dd725b4d --- /dev/null +++ b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/SystemTextJson.cs @@ -0,0 +1,9 @@ +using GraphQL.Client.Serializer.SystemTextJson; + +namespace GraphQL.Integration.Tests.QueryAndMutationTests { + public class SystemTextJson: Base { + public SystemTextJson() : base(new SystemTextJsonSerializer()) + { + } + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTest.cs b/tests/GraphQL.Integration.Tests/WebsocketTest.cs deleted file mode 100644 index 4bd1fade..00000000 --- a/tests/GraphQL.Integration.Tests/WebsocketTest.cs +++ /dev/null @@ -1,331 +0,0 @@ -using System; -using System.Diagnostics; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; -using GraphQL.Client.Abstractions; -using GraphQL.Integration.Tests.Extensions; -using GraphQL.Integration.Tests.Helpers; -using IntegrationTestServer; -using Microsoft.AspNetCore.Hosting; -using Xunit; -using Xunit.Abstractions; - -namespace GraphQL.Integration.Tests { - public class WebsocketTest { - private readonly ITestOutputHelper output; - - private static IWebHost CreateServer(int port) => WebHostHelpers.CreateServer(port); - - public WebsocketTest(ITestOutputHelper output) { - this.output = output; - } - - [Fact] - public async void AssertTestingHarness() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port); - - const string message = "some random testing message"; - var response = await client.AddMessageAsync(message).ConfigureAwait(false); - - Assert.Equal(message, response.Data.AddMessage.Content); - } - } - - [Fact] - public async void CanSendRequestViaWebsocket() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port, true); - const string message = "some random testing message"; - var response = await client.AddMessageAsync(message).ConfigureAwait(false); - - Assert.Equal(message, response.Data.AddMessage.Content); - } - } - - [Fact] - public async void CanHandleRequestErrorViaWebsocket() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port, true); - var response = await client.SendQueryAsync("this query is formatted quite badly").ConfigureAwait(false); - - Assert.Single(response.Errors); - } - } - - private const string SubscriptionQuery = @" - subscription { - messageAdded{ - content - } - }"; - - private readonly GraphQLRequest SubscriptionRequest = new GraphQLRequest(SubscriptionQuery); - - [Fact] - public async void CanCreateObservableSubscription() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port); - await client.InitializeWebsocketConnection(); - - Debug.WriteLine("creating subscription stream"); - IObservable> observable = client.CreateSubscriptionStream(SubscriptionRequest); - - Debug.WriteLine("subscribing..."); - var tester = observable.SubscribeTester(); - const string message1 = "Hello World"; - - var response = await client.AddMessageAsync(message1).ConfigureAwait(false); - Assert.Equal(message1, response.Data.AddMessage.Content); - - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message1, gqlResponse.Data.MessageAdded.Content); - }); - - const string message2 = "lorem ipsum dolor si amet"; - response = await client.AddMessageAsync(message2).ConfigureAwait(false); - Assert.Equal(message2, response.Data.AddMessage.Content); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message2, gqlResponse.Data.MessageAdded.Content); - }); - - // disposing the client should throw a TaskCanceledException on the subscription - client.Dispose(); - tester.ShouldHaveCompleted(); - } - } - - public class MessageAddedSubscriptionResult { - public MessageAddedContent MessageAdded { get; set; } - - public class MessageAddedContent { - public string Content { get; set; } - } - } - - [Fact] - public async void CanReconnectWithSameObservable() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port); - await client.InitializeWebsocketConnection(); - - Debug.WriteLine("creating subscription stream"); - IObservable> observable = client.CreateSubscriptionStream(SubscriptionRequest); - - Debug.WriteLine("subscribing..."); - var tester = observable.SubscribeTester(); - - const string message1 = "Hello World"; - var response = await client.AddMessageAsync(message1).ConfigureAwait(false); - Assert.Equal(message1, response.Data.AddMessage.Content); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message1, gqlResponse.Data.MessageAdded.Content); - }); - - const string message2 = "How are you?"; - response = await client.AddMessageAsync(message2).ConfigureAwait(false); - Assert.Equal(message2, response.Data.AddMessage.Content); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message2, gqlResponse.Data.MessageAdded.Content); - }); - - Debug.WriteLine("disposing subscription..."); - tester.Dispose(); - await Task.Delay(500); - await client.InitializeWebsocketConnection(); - - Debug.WriteLine("creating new subscription..."); - tester = observable.SubscribeTester(); - tester.ShouldHaveReceivedUpdate( - gqlResponse => { Assert.Equal(message2, gqlResponse.Data.MessageAdded.Content); }, - TimeSpan.FromSeconds(10)); - const string message3 = "lorem ipsum dolor si amet"; - response = await client.AddMessageAsync(message3).ConfigureAwait(false); - Assert.Equal(message3, response.Data.AddMessage.Content); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message3, gqlResponse.Data.MessageAdded.Content); - }); - - // disposing the client should complete the subscription - client.Dispose(); - tester.ShouldHaveCompleted(); - } - } - - private const string SubscriptionQuery2 = @" - subscription { - userJoined{ - displayName - id - } - }"; - - public class UserJoinedSubscriptionResult { - public UserJoinedContent UserJoined { get; set; } - - public class UserJoinedContent { - public string DisplayName { get; set; } - public string Id { get; set; } - } - - } - - private readonly GraphQLRequest SubscriptionRequest2 = new GraphQLRequest(SubscriptionQuery2); - - [Fact] - public async void CanConnectTwoSubscriptionsSimultaneously() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - var callbackTester = new CallbackTester(); - var callbackTester2 = new CallbackTester(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port); - await client.InitializeWebsocketConnection(); - - Debug.WriteLine("creating subscription stream"); - IObservable> observable1 = client.CreateSubscriptionStream(SubscriptionRequest, callbackTester.Callback); - IObservable> observable2 = client.CreateSubscriptionStream(SubscriptionRequest2, callbackTester2.Callback); - - Debug.WriteLine("subscribing..."); - var tester = observable1.SubscribeTester(); - var tester2 = observable2.SubscribeTester(); - - const string message1 = "Hello World"; - var response = await client.AddMessageAsync(message1).ConfigureAwait(false); - Assert.Equal(message1, response.Data.AddMessage.Content); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message1, gqlResponse.Data.MessageAdded.Content); - }); - - await Task.Delay(500); // ToDo: can be removed after https://github.com/graphql-dotnet/server/pull/199 was merged and released - - var joinResponse = await client.JoinDeveloperUser().ConfigureAwait(false); - Assert.Equal("developer", joinResponse.Data.Join.DisplayName); - - tester2.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal("1", gqlResponse.Data.UserJoined.Id); - Assert.Equal("developer", gqlResponse.Data.UserJoined.DisplayName); - }); - - Debug.WriteLine("disposing subscription..."); - tester2.Dispose(); - - const string message3 = "lorem ipsum dolor si amet"; - response = await client.AddMessageAsync(message3).ConfigureAwait(false); - Assert.Equal(message3, response.Data.AddMessage.Content); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message3, gqlResponse.Data.MessageAdded.Content); - }); - - // disposing the client should complete the subscription - client.Dispose(); - tester.ShouldHaveCompleted(); - } - } - - [Fact] - public async void CanHandleConnectionTimeout() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - var server = CreateServer(port); - var callbackTester = new CallbackTester(); - - var client = WebHostHelpers.GetGraphQLClient(port); - await client.InitializeWebsocketConnection(); - Debug.WriteLine("creating subscription stream"); - IObservable> observable = client.CreateSubscriptionStream(SubscriptionRequest, callbackTester.Callback); - - Debug.WriteLine("subscribing..."); - var tester = observable.SubscribeTester(); - const string message1 = "Hello World"; - - var response = await client.AddMessageAsync(message1).ConfigureAwait(false); - Assert.Equal(message1, response.Data.AddMessage.Content); - - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Equal(message1, gqlResponse.Data.MessageAdded.Content); - }); - - Debug.WriteLine("stopping web host..."); - await server.StopAsync(CancellationToken.None).ConfigureAwait(false); - Debug.WriteLine("web host stopped..."); - - callbackTester.CallbackShouldHaveBeenInvoked(exception => { - Assert.IsType(exception); - }, TimeSpan.FromSeconds(10)); - - try { - server.Start(); - } - catch (Exception e) { - output.WriteLine($"failed to restart server: {e}"); - } - - // disposing the client should complete the subscription - client.Dispose(); - tester.ShouldHaveCompleted(TimeSpan.FromSeconds(5)); - server.Dispose(); - } - - [Fact] - public async void CanHandleSubscriptionError() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - var client = WebHostHelpers.GetGraphQLClient(port); - await client.InitializeWebsocketConnection(); - Debug.WriteLine("creating subscription stream"); - IObservable> observable = client.CreateSubscriptionStream( - new GraphQLRequest(@" - subscription { - failImmediately { - content - } - }") - ); - - Debug.WriteLine("subscribing..."); - var tester = observable.SubscribeTester(); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Single(gqlResponse.Errors); - }); - tester.ShouldHaveCompleted(); - - client.Dispose(); - } - } - - [Fact] - public async void CanHandleQueryErrorInSubscription() { - var port = NetworkHelpers.GetFreeTcpPortNumber(); - using (CreateServer(port)) { - - var test = new GraphQLRequest("tset", new { test = "blaa" }); - - var client = WebHostHelpers.GetGraphQLClient(port); - await client.InitializeWebsocketConnection(); - Debug.WriteLine("creating subscription stream"); - IObservable> observable = client.CreateSubscriptionStream( - new GraphQLRequest(@" - subscription { - fieldDoesNotExist { - content - } - }") - ); - - Debug.WriteLine("subscribing..."); - var tester = observable.SubscribeTester(); - tester.ShouldHaveReceivedUpdate(gqlResponse => { - Assert.Single(gqlResponse.Errors); - }); - tester.ShouldHaveCompleted(); - - client.Dispose(); - } - } - } -} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/Base.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/Base.cs new file mode 100644 index 00000000..6112a00c --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/Base.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.WebSockets; +using System.Threading; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.Tests.Common.Chat; +using GraphQL.Client.Tests.Common.Helpers; +using GraphQL.Integration.Tests.Helpers; +using IntegrationTestServer; +using Microsoft.AspNetCore.Hosting; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests { + public abstract class Base { + protected readonly ITestOutputHelper Output; + protected readonly IGraphQLWebsocketJsonSerializer Serializer; + protected IWebHost CreateServer(int port) => WebHostHelpers.CreateServer(port); + + protected Base(ITestOutputHelper output, IGraphQLWebsocketJsonSerializer serializer) { + this.Output = output; + this.Serializer = serializer; + } + + [Fact] + public async void AssertTestingHarness() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)) { + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + + const string message = "some random testing message"; + var response = await client.AddMessageAsync(message).ConfigureAwait(false); + + Assert.Equal(message, response.Data.AddMessage.Content); + } + } + + + [Fact] + public async void CanSendRequestViaWebsocket() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)) { + var client = WebHostHelpers.GetGraphQLClient(port, true, Serializer); + const string message = "some random testing message"; + var response = await client.AddMessageAsync(message).ConfigureAwait(false); + + Assert.Equal(message, response.Data.AddMessage.Content); + } + } + + [Fact] + public async void CanHandleRequestErrorViaWebsocket() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)) { + var client = WebHostHelpers.GetGraphQLClient(port, true, Serializer); + var response = await client.SendQueryAsync("this query is formatted quite badly").ConfigureAwait(false); + + Assert.Single(response.Errors); + } + } + + private const string SubscriptionQuery = @" + subscription { + messageAdded{ + content + } + }"; + + private readonly GraphQLRequest SubscriptionRequest = new GraphQLRequest(SubscriptionQuery); + + + [Fact] + public async void CanCreateObservableSubscription() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)){ + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + var callbackMonitor = client.ConfigureMonitorForOnWebsocketConnected(); + await client.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + Debug.WriteLine("creating subscription stream"); + IObservable> observable = client.CreateSubscriptionStream(SubscriptionRequest); + + Debug.WriteLine("subscribing..."); + using (var tester = observable.Monitor()) { + const string message1 = "Hello World"; + + var response = await client.AddMessageAsync(message1).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message1); + tester.Should().HaveReceivedPayload(TimeSpan.FromSeconds(3)) + .Which.Data.MessageAdded.Content.Should().Be(message1); + + const string message2 = "lorem ipsum dolor si amet"; + response = await client.AddMessageAsync(message2).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message2); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message2); + + // disposing the client should throw a TaskCanceledException on the subscription + client.Dispose(); + tester.Should().HaveCompleted(); + } + } + } + + public class MessageAddedSubscriptionResult { + public MessageAddedContent MessageAdded { get; set; } + + public class MessageAddedContent { + public string Content { get; set; } + } + } + + + [Fact] + public async void CanReconnectWithSameObservable() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)) { + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + var callbackMonitor = client.ConfigureMonitorForOnWebsocketConnected(); + + Debug.WriteLine("creating subscription stream"); + var observable = client.CreateSubscriptionStream(SubscriptionRequest); + + Debug.WriteLine("subscribing..."); + var tester = observable.Monitor(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + const string message1 = "Hello World"; + var response = await client.AddMessageAsync(message1).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message1); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message1); + + const string message2 = "How are you?"; + response = await client.AddMessageAsync(message2).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message2); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message2); + + Debug.WriteLine("disposing subscription..."); + tester.Dispose(); // does not close the websocket connection + + Debug.WriteLine("creating new subscription..."); + tester = observable.Monitor(); + tester.Should().HaveReceivedPayload(TimeSpan.FromSeconds(10)) + .Which.Data.MessageAdded.Content.Should().Be(message2); + + const string message3 = "lorem ipsum dolor si amet"; + response = await client.AddMessageAsync(message3).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message3); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message3); + + // disposing the client should complete the subscription + client.Dispose(); + tester.Should().HaveCompleted(); + } + } + + private const string SubscriptionQuery2 = @" + subscription { + userJoined{ + displayName + id + } + }"; + + public class UserJoinedSubscriptionResult { + public UserJoinedContent UserJoined { get; set; } + + public class UserJoinedContent { + public string DisplayName { get; set; } + public string Id { get; set; } + } + + } + + private readonly GraphQLRequest SubscriptionRequest2 = new GraphQLRequest(SubscriptionQuery2); + + + [Fact] + public async void CanConnectTwoSubscriptionsSimultaneously() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + var callbackTester = new CallbackMonitor(); + var callbackTester2 = new CallbackMonitor(); + using (CreateServer(port)) { + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + var callbackMonitor = client.ConfigureMonitorForOnWebsocketConnected(); + await client.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + Debug.WriteLine("creating subscription stream"); + IObservable> observable1 = + client.CreateSubscriptionStream(SubscriptionRequest, callbackTester.Invoke); + IObservable> observable2 = + client.CreateSubscriptionStream(SubscriptionRequest2, callbackTester2.Invoke); + + Debug.WriteLine("subscribing..."); + var tester = observable1.Monitor(); + var tester2 = observable2.Monitor(); + + const string message1 = "Hello World"; + var response = await client.AddMessageAsync(message1).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message1); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message1); + + var joinResponse = await client.JoinDeveloperUser().ConfigureAwait(false); + joinResponse.Data.Join.DisplayName.Should().Be("developer", "because that's the display name of user \"1\""); + + var payload = tester2.Should().HaveReceivedPayload().Subject; + payload.Data.UserJoined.Id.Should().Be("1", "because that's the id we sent with our mutation request"); + payload.Data.UserJoined.DisplayName.Should().Be("developer", "because that's the display name of user \"1\""); + + Debug.WriteLine("disposing subscription..."); + tester2.Dispose(); + + const string message3 = "lorem ipsum dolor si amet"; + response = await client.AddMessageAsync(message3).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message3); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message3); + + // disposing the client should complete the subscription + client.Dispose(); + tester.Should().HaveCompleted(); + } + } + + + [Fact] + public async void CanHandleConnectionTimeout() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + var server = CreateServer(port); + var errorMonitor = new CallbackMonitor(); + var reconnectBlocker = new ManualResetEventSlim(false); + + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + var callbackMonitor = client.ConfigureMonitorForOnWebsocketConnected(); + // configure back-off strategy to allow it to be controlled from within the unit test + client.Options.BackOffStrategy = i => { + reconnectBlocker.Wait(); + return TimeSpan.Zero; + }; + + var websocketStates = new ConcurrentQueue(); + + using (client.WebsocketConnectionState.Subscribe(websocketStates.Enqueue)) { + websocketStates.Should().ContainSingle(state => state == GraphQLWebsocketConnectionState.Disconnected); + + Debug.WriteLine("creating subscription stream"); + IObservable> observable = + client.CreateSubscriptionStream(SubscriptionRequest, + errorMonitor.Invoke); + + Debug.WriteLine("subscribing..."); + var tester = observable.Monitor(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + websocketStates.Should().ContainInOrder( + GraphQLWebsocketConnectionState.Disconnected, + GraphQLWebsocketConnectionState.Connecting, + GraphQLWebsocketConnectionState.Connected); + // clear the collection so the next tests on the collection work as expected + websocketStates.Clear(); + + const string message1 = "Hello World"; + var response = await client.AddMessageAsync(message1).ConfigureAwait(false); + response.Data.AddMessage.Content.Should().Be(message1); + tester.Should().HaveReceivedPayload() + .Which.Data.MessageAdded.Content.Should().Be(message1); + + Debug.WriteLine("stopping web host..."); + await server.StopAsync(CancellationToken.None).ConfigureAwait(false); + server.Dispose(); + Debug.WriteLine("web host stopped..."); + + errorMonitor.Should().HaveBeenInvokedWithPayload(TimeSpan.FromSeconds(10)) + .Which.Should().BeOfType(); + websocketStates.Should().Contain(GraphQLWebsocketConnectionState.Disconnected); + + server = CreateServer(port); + reconnectBlocker.Set(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + websocketStates.Should().ContainInOrder( + GraphQLWebsocketConnectionState.Disconnected, + GraphQLWebsocketConnectionState.Connecting, + GraphQLWebsocketConnectionState.Connected); + + // disposing the client should complete the subscription + client.Dispose(); + tester.Should().HaveCompleted(TimeSpan.FromSeconds(5)); + server.Dispose(); + } + } + + + [Fact] + public async void CanHandleSubscriptionError() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)) { + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + var callbackMonitor = client.ConfigureMonitorForOnWebsocketConnected(); + await client.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + Debug.WriteLine("creating subscription stream"); + IObservable> observable = client.CreateSubscriptionStream( + new GraphQLRequest(@" + subscription { + failImmediately { + content + } + }") + ); + + Debug.WriteLine("subscribing..."); + using (var tester = observable.Monitor()) { + tester.Should().HaveReceivedPayload(TimeSpan.FromSeconds(3)) + .Which.Errors.Should().ContainSingle(); + tester.Should().HaveCompleted(); + client.Dispose(); + } + } + } + + + [Fact] + public async void CanHandleQueryErrorInSubscription() { + var port = NetworkHelpers.GetFreeTcpPortNumber(); + using (CreateServer(port)) { + + var test = new GraphQLRequest("tset", new { test = "blaa" }); + + var client = WebHostHelpers.GetGraphQLClient(port, serializer: Serializer); + var callbackMonitor = client.ConfigureMonitorForOnWebsocketConnected(); + await client.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + Debug.WriteLine("creating subscription stream"); + IObservable> observable = client.CreateSubscriptionStream( + new GraphQLRequest(@" + subscription { + fieldDoesNotExist { + content + } + }") + ); + + Debug.WriteLine("subscribing..."); + using (var tester = observable.Monitor()) { + tester.Should().HaveReceivedPayload() + .Which.Errors.Should().ContainSingle(); + tester.Should().HaveCompleted(); + client.Dispose(); + } + } + } + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/Newtonsoft.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/Newtonsoft.cs new file mode 100644 index 00000000..02a0b030 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/Newtonsoft.cs @@ -0,0 +1,10 @@ +using GraphQL.Client.Serializer.Newtonsoft; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests { + public class Newtonsoft: Base { + public Newtonsoft(ITestOutputHelper output) : base(output, new NewtonsoftJsonSerializer()) + { + } + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJson.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJson.cs new file mode 100644 index 00000000..3a7882e4 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJson.cs @@ -0,0 +1,10 @@ +using GraphQL.Client.Serializer.SystemTextJson; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests { + public class SystemTextJson: Base { + public SystemTextJson(ITestOutputHelper output) : base(output, new SystemTextJsonSerializer()) + { + } + } +} diff --git a/tests/IntegrationTestServer/ChatSchema/ChatSchema.cs b/tests/IntegrationTestServer/ChatSchema/ChatSchema.cs deleted file mode 100644 index 3878bcf5..00000000 --- a/tests/IntegrationTestServer/ChatSchema/ChatSchema.cs +++ /dev/null @@ -1,11 +0,0 @@ -using GraphQL.Types; - -namespace IntegrationTestServer.ChatSchema { - public class ChatSchema : Schema { - public ChatSchema(IChat chat) { - Query = new ChatQuery(chat); - Mutation = new ChatMutation(chat); - Subscription = new ChatSubscriptions(chat); - } - } -} diff --git a/tests/IntegrationTestServer/IntegrationTestServer.csproj b/tests/IntegrationTestServer/IntegrationTestServer.csproj index 6ecc6a43..a1f28376 100644 --- a/tests/IntegrationTestServer/IntegrationTestServer.csproj +++ b/tests/IntegrationTestServer/IntegrationTestServer.csproj @@ -16,8 +16,12 @@ - - + + + + + + diff --git a/tests/IntegrationTestServer/StartupChat.cs b/tests/IntegrationTestServer/StartupChat.cs index 37c205e7..2a782991 100644 --- a/tests/IntegrationTestServer/StartupChat.cs +++ b/tests/IntegrationTestServer/StartupChat.cs @@ -1,5 +1,6 @@ +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Chat.Schema; using GraphQL.Server; -using IntegrationTestServer.ChatSchema; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -10,18 +11,12 @@ public class StartupChat: Startup { public StartupChat(IConfiguration configuration, IWebHostEnvironment environment): base(configuration, environment) { } public override void ConfigureGraphQLSchemaServices(IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddChatSchema(); } public override void ConfigureGraphQLSchema(IApplicationBuilder app) { - app.UseGraphQLWebSockets("/graphql"); - app.UseGraphQL("/graphql"); + app.UseGraphQLWebSockets("/graphql"); + app.UseGraphQL("/graphql"); } } } diff --git a/tests/IntegrationTestServer/StartupStarWars.cs b/tests/IntegrationTestServer/StartupStarWars.cs index c62dc5f2..15609515 100644 --- a/tests/IntegrationTestServer/StartupStarWars.cs +++ b/tests/IntegrationTestServer/StartupStarWars.cs @@ -1,6 +1,6 @@ +using GraphQL.Client.Tests.Common; using GraphQL.Server; using GraphQL.StarWars; -using GraphQL.StarWars.Types; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -11,16 +11,7 @@ public class StartupStarWars: Startup { public StartupStarWars(IConfiguration configuration, IWebHostEnvironment environment): base(configuration, environment) { } public override void ConfigureGraphQLSchemaServices(IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddStarWarsSchema(); } public override void ConfigureGraphQLSchema(IApplicationBuilder app) {