From d89461d19da722677042e5588e76d53d07890fcf Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 8 Sep 2024 20:51:04 -0400 Subject: [PATCH] GraphQL API Initial Implementation - Build API via annotations using HotChocolate. - Export to `artifacts` on build. - Repurpose REST API types where possible, but many have to be reimplemented. - Transfer controller and bridge controller will definitely have to stay forever. - Added `Tgstation.Server.Client.GraphQL` built from schema using StrawberryShake. - Added equivalent server information query. - Setup semver and u32 (de)serialization. - Begin transitioning to multi-client type live tests. - Add GraphQL route `/api/graphql` - Rename ServerClient likes to RestServerClient equivalent. - Major API/Client library version bumps. --- .github/workflows/ci-pipeline.yml | 1 + .gitignore | 1 + build/Version.props | 4 +- .../Models/Internal/LocalServerInformation.cs | 20 +++++ .../Models/Internal/SwarmServer.cs | 32 ++++++- .../Models/Internal/SwarmServerInformation.cs | 39 +++++++++ .../Models/Internal/UpdateInformation.cs | 20 +++++ .../Models/Response/AdministrationResponse.cs | 14 +--- .../Response/ServerInformationResponse.cs | 12 +-- .../Models/Response/SwarmServerResponse.cs | 9 +- src/Tgstation.Server.Api/Routes.cs | 5 ++ .../.config/dotnet-tools.json | 12 +++ .../.graphqlrc.json | 21 +++++ .../Queries/ServerInformationQuery.graphql | 34 ++++++++ .../GraphQLServerClient.cs | 42 ++++++++++ .../GraphQLServerClientFactory.cs | 67 +++++++++++++++ .../IAuthenticatedGraphQLServerClient.cs | 13 +++ .../IGraphQLServerClient.cs | 18 ++++ .../IGraphQLServerClientFactory.cs | 61 ++++++++++++++ src/Tgstation.Server.Client.GraphQL/README.md | 9 ++ .../Serializers/SemverSerializer.cs | 35 ++++++++ .../Serializers/UnsignedIntSerializer.cs | 22 +++++ .../Tgstation.Server.Client.GraphQL.csproj | 38 +++++++++ .../schema.extensions.graphql | 17 ++++ .../ClientException.cs | 2 +- src/Tgstation.Server.Client/IApiClient.cs | 23 +---- ...{IServerClient.cs => IRestServerClient.cs} | 12 ++- ...Factory.cs => IRestServerClientFactory.cs} | 30 +++---- .../ITransferClient.cs | 31 +++++++ .../{ServerClient.cs => RestServerClient.cs} | 11 ++- ...tFactory.cs => RestServerClientFactory.cs} | 32 +++---- .../Configuration/InternalConfiguration.cs | 5 ++ .../Controllers/ApiRootController.cs | 5 +- src/Tgstation.Server.Host/Core/Application.cs | 14 ++++ src/Tgstation.Server.Host/GraphQL/Query.cs | 16 ++++ .../GraphQL/Types/Entity.cs | 22 +++++ .../GraphQL/Types/LocalServer.cs | 49 +++++++++++ .../GraphQL/Types/NamedEntity.cs | 26 ++++++ .../GraphQL/Types/OAuthConnection.cs | 33 ++++++++ .../GraphQL/Types/PermissionSet.cs | 33 ++++++++ .../GraphQL/Types/Scalars/SemverType.cs | 79 +++++++++++++++++ .../GraphQL/Types/ServerSwarm.cs | 50 +++++++++++ .../GraphQL/Types/SwarmMetadata.cs | 46 ++++++++++ .../GraphQL/Types/User.cs | 84 +++++++++++++++++++ .../GraphQL/Types/UserGroup.cs | 49 +++++++++++ src/Tgstation.Server.Host/Server.cs | 32 +++++++ .../Swarm/ISwarmOperations.cs | 10 +-- .../Swarm/ISwarmService.cs | 8 +- .../Swarm/SwarmServersUpdateRequest.cs | 6 +- .../Swarm/SwarmService.cs | 59 ++++++------- .../Swarm/SwarmUpdateOperation.cs | 9 +- .../Tgstation.Server.Host.csproj | 22 +++-- .../TestServerClientFactory.cs | 4 +- .../Live/Instance/JobsHubTests.cs | 6 +- .../Live/InstanceManagerTest.cs | 4 +- .../Live/MultiServerClient.cs | 48 +++++++++++ .../Live/RawRequestTests.cs | 16 ++-- .../Live/TestLiveServer.cs | 59 ++++++++++--- .../Tgstation.Server.Tests/Live/UsersTest.cs | 4 +- tests/Tgstation.Server.Tests/TestVersions.cs | 2 +- .../Tgstation.Server.Tests.csproj | 2 +- tgstation-server.sln | 17 ++++ .../Program.cs | 2 +- tools/Tgstation.Server.Migrator/Program.cs | 4 +- 64 files changed, 1338 insertions(+), 174 deletions(-) create mode 100644 src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs create mode 100644 src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs create mode 100644 src/Tgstation.Server.Api/Models/Internal/UpdateInformation.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/.config/dotnet-tools.json create mode 100644 src/Tgstation.Server.Client.GraphQL/.graphqlrc.json create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/IAuthenticatedGraphQLServerClient.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/README.md create mode 100644 src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj create mode 100644 src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql rename src/Tgstation.Server.Client/{IServerClient.cs => IRestServerClient.cs} (88%) rename src/Tgstation.Server.Client/{IServerClientFactory.cs => IRestServerClientFactory.cs} (78%) create mode 100644 src/Tgstation.Server.Client/ITransferClient.cs rename src/Tgstation.Server.Client/{ServerClient.cs => RestServerClient.cs} (87%) rename src/Tgstation.Server.Client/{ServerClientFactory.cs => RestServerClientFactory.cs} (82%) create mode 100644 src/Tgstation.Server.Host/GraphQL/Query.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Entity.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/User.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs create mode 100644 tests/Tgstation.Server.Tests/Live/MultiServerClient.cs diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 07491565d2..def3136a44 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -577,6 +577,7 @@ jobs: configuration: ["Debug", "Release"] env: TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt + TGS_TEST_GRAPHQL: true runs-on: windows-latest steps: - name: Setup dotnet diff --git a/.gitignore b/.gitignore index 9494ed94ad..87e88b7691 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ artifacts/ *.dmb *.int *.lk +/src/Tgstation.Server.Client.GraphQL/schema.graphql /src/Tgstation.Server.Host/appsettings.*.json /src/Tgstation.Server.Host/appsettings.*.yml /src/Tgstation.Server.Host/wwwroot diff --git a/build/Version.props b/build/Version.props index 17a15a5068..725412560b 100644 --- a/build/Version.props +++ b/build/Version.props @@ -7,8 +7,8 @@ 5.2.0 10.8.0 7.0.0 - 14.0.0 - 17.0.0 + 15.0.0 + 18.0.0 7.3.0 5.10.0 1.5.0 diff --git a/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs new file mode 100644 index 0000000000..ae8d5ca34e --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Information about the local tgstation-server. + /// + public class LocalServerInformation : ServerInformationBase + { + /// + /// If the server is running on a windows operating system. + /// + public bool WindowsHost { get; set; } + + /// + /// Map of to the for them. + /// + public Dictionary? OAuthProviderInfos { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs index 4dfb80287b..9205e58a09 100644 --- a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Information about a server in the swarm. /// - public abstract class SwarmServer + public abstract class SwarmServer : IEquatable { /// /// The public address of the server. @@ -24,5 +24,35 @@ namespace Tgstation.Server.Api.Models.Internal /// [Required] public string? Identifier { get; set; } + + /// + /// Initializes a new instance of the class. + /// + protected SwarmServer() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + protected SwarmServer(SwarmServer copy) + { + if (copy == null) + { + throw new ArgumentNullException(nameof(copy)); + } + + Address = copy.Address; + PublicAddress = copy.PublicAddress; + Identifier = copy.Identifier; + } + + /// + public bool Equals(SwarmServer other) + => other != null + && other.Identifier == Identifier + && other.PublicAddress == PublicAddress + && other.Address == Address; } } diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs new file mode 100644 index 0000000000..fb24f74ad1 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs @@ -0,0 +1,39 @@ +using System; + +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents information about a running . + /// + public class SwarmServerInformation : SwarmServer, IEquatable + { + /// + /// If the is the controller. + /// + public bool Controller { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public SwarmServerInformation() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + public SwarmServerInformation(SwarmServerInformation copy) + : base(copy) + { + Controller = copy.Controller; + } + + /// + public bool Equals(SwarmServerInformation other) + => base.Equals(other) + && other.Controller == Controller; + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/UpdateInformation.cs b/src/Tgstation.Server.Api/Models/Internal/UpdateInformation.cs new file mode 100644 index 0000000000..9f049e4814 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/UpdateInformation.cs @@ -0,0 +1,20 @@ +using System; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Indicates data from the TGS update source. + /// + public class UpdateInformation + { + /// + /// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If is less than 4 the update cannot be applied due to API changes. + /// + public Version? LatestVersion { get; set; } + + /// + /// This response is cached. This field indicates the the was generated. + /// + public DateTimeOffset? GeneratedAt { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs b/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs index 2db5d354a7..fbdd1f00a1 100644 --- a/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs @@ -1,25 +1,17 @@ using System; +using Tgstation.Server.Api.Models.Internal; + namespace Tgstation.Server.Api.Models.Response { /// /// Represents administrative server information. /// - public sealed class AdministrationResponse + public sealed class AdministrationResponse : UpdateInformation { /// /// The GitHub repository the server is built to receive updates from. /// public Uri? TrackedRepositoryUrl { get; set; } - - /// - /// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If is not equal to 4 the update cannot be applied due to API changes. - /// - public Version? LatestVersion { get; set; } - - /// - /// This response is cached. This field indicates the when it was generated. - /// - public DateTimeOffset? GeneratedAt { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs index 96b5306702..a33b2b8be2 100644 --- a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Response /// /// Represents basic server information. /// - public sealed class ServerInformationResponse : Internal.ServerInformationBase + public sealed class ServerInformationResponse : Internal.LocalServerInformation { /// /// The version of the host. @@ -23,11 +23,6 @@ namespace Tgstation.Server.Api.Models.Response /// public Version? DMApiVersion { get; set; } - /// - /// If the server is running on a windows operating system. - /// - public bool WindowsHost { get; set; } - /// /// If there is a server update in progress. /// @@ -38,10 +33,5 @@ namespace Tgstation.Server.Api.Models.Response /// [ResponseOptions] public ICollection? SwarmServers { get; set; } - - /// - /// Map of to the for them. - /// - public IDictionary? OAuthProviderInfos { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Response/SwarmServerResponse.cs b/src/Tgstation.Server.Api/Models/Response/SwarmServerResponse.cs index a9e5ae9cb0..9ea6dcba19 100644 --- a/src/Tgstation.Server.Api/Models/Response/SwarmServerResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/SwarmServerResponse.cs @@ -3,11 +3,14 @@ namespace Tgstation.Server.Api.Models.Response { /// - public sealed class SwarmServerResponse : SwarmServer + public sealed class SwarmServerResponse : SwarmServerInformation { /// - /// If the is the controller. + /// Initializes a new instance of the class. /// - public bool Controller { get; set; } + /// The to copy. + public SwarmServerResponse(SwarmServerInformation swarmServerInfo) + { + } } } diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index 4372929ed7..b0cd136227 100644 --- a/src/Tgstation.Server.Api/Routes.cs +++ b/src/Tgstation.Server.Api/Routes.cs @@ -12,6 +12,11 @@ namespace Tgstation.Server.Api /// public const string ApiRoot = "/api/"; + /// + /// The GraphQL route. + /// + public const string GraphQL = ApiRoot + "graphql"; + /// /// The root route of all hubs. /// diff --git a/src/Tgstation.Server.Client.GraphQL/.config/dotnet-tools.json b/src/Tgstation.Server.Client.GraphQL/.config/dotnet-tools.json new file mode 100644 index 0000000000..e2caf3a36a --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "strawberryshake.tools": { + "version": "13.9.12", + "commands": [ + "dotnet-graphql" + ] + } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json new file mode 100644 index 0000000000..8b40dd050c --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json @@ -0,0 +1,21 @@ +{ + "schema": "schema.graphql", + "documents": "**/*.graphql", + "extensions": { + "strawberryShake": { + "name": "GraphQLClient", + "url": "../../artifacts/tgs-api.gql", + "namespace": "Tgstation.Server.Client.GraphQL", + "records": { + "inputs": false, + "entities": false + }, + "transportProfiles": [ + { + "default": "Http", + "subscription": "WebSocket" + } + ] + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql new file mode 100644 index 0000000000..6f130e4d24 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql @@ -0,0 +1,34 @@ +query ServerInformationQuery { + swarm { + metadata { + apiVersion + dmApiVersion + updateInProgress + version + } + localServer { + information { + instanceLimit + minimumPasswordLength + userGroupLimit + userLimit + validInstancePaths + windowsHost + oAuthProviderInfos { + key + value { + clientId + redirectUri + serverUrl + } + } + } + } + servers { + address + controller + identifier + publicAddress + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs new file mode 100644 index 0000000000..b359139b69 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + class GraphQLServerClient : IGraphQLServerClient + { + /// + /// The for the . + /// + readonly IGraphQLClient graphQLClient; + + /// + /// The to be 'd with the . + /// + readonly IAsyncDisposable serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public GraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider) + { + this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient)); + this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); + + /// + public virtual ValueTask RunQuery(Func queryExector) + { + ArgumentNullException.ThrowIfNull(queryExector); + return queryExector(graphQLClient); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs new file mode 100644 index 0000000000..bbcd7f2f6e --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; + +using Tgstation.Server.Api; +using Tgstation.Server.Client.GraphQL.Serializers; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + public sealed class GraphQLServerClientFactory : IGraphQLServerClientFactory + { + /// + /// The for the . + /// + readonly IRestServerClientFactory restClientFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public GraphQLServerClientFactory(IRestServerClientFactory restClientFactory) + { + this.restClientFactory = restClientFactory ?? throw new ArgumentNullException(nameof(restClientFactory)); + } + + /// + public ValueTask CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// + public ValueTask CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// + public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token) + { + throw new NotImplementedException(); + } + + /// + public IGraphQLServerClient CreateUnauthenticated(Uri host) + { + var serviceCollection = new ServiceCollection(); + + var clientBuilder = serviceCollection + .AddGraphQLClient(); + var graphQLEndpoint = new Uri(host, Routes.GraphQL); + clientBuilder.ConfigureHttpClient(client => client.BaseAddress = graphQLEndpoint); + + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + + return new GraphQLServerClient( + serviceProvider.GetRequiredService(), + serviceProvider); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/IAuthenticatedGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IAuthenticatedGraphQLServerClient.cs new file mode 100644 index 0000000000..36ca809afc --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/IAuthenticatedGraphQLServerClient.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// A known to be authenticated. + /// + public interface IAuthenticatedGraphQLServerClient : IGraphQLServerClient + { + /// + /// The REST . + /// + ITransferClient TransferClient { get; } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs new file mode 100644 index 0000000000..8c6d2ff2fe --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Wrapper for using a TGS . + /// + public interface IGraphQLServerClient : IAsyncDisposable + { + /// + /// Runs a given . It may be invoked multiple times depending on the behavior of the . + /// + /// A which executes a single query on a given and returns a representing the running operation. + /// A representing the running operation. + ValueTask RunQuery(Func queryExector); + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs new file mode 100644 index 0000000000..06faa66adb --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Factory for creating s. + /// + public interface IGraphQLServerClientFactory + { + /// + /// Create an unauthenticated . + /// + /// The of tgstation-server. + /// A new . + IGraphQLServerClient CreateUnauthenticated(Uri host); + + /// + /// Create a using a password login. + /// + /// The URL to access TGS. + /// The username to for the . + /// The password for the . + /// Attempt to refresh the received when it expires or becomes invalid. and will be stored in memory if this is . + /// Optional for the operation. + /// A resulting in a new . + ValueTask CreateFromLogin( + Uri host, + string username, + string password, + bool attemptLoginRefresh = true, + CancellationToken cancellationToken = default); + + /// + /// Create a using an OAuth login. + /// + /// The URL to access TGS. + /// The OAuth code used to complete the flow. + /// The . + /// Optional for the operation. + /// A resulting in a new . + ValueTask CreateFromOAuth( + Uri host, + string oAuthCode, + OAuthProvider oAuthProvider, + CancellationToken cancellationToken = default); + + /// + /// Create a . + /// + /// The URL to access TGS. + /// The to access the API with. + /// A new . + IAuthenticatedGraphQLServerClient CreateFromToken( + Uri host, + string token); + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/README.md b/src/Tgstation.Server.Client.GraphQL/README.md new file mode 100644 index 0000000000..a618ddd2f8 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/README.md @@ -0,0 +1,9 @@ +# tgstation-server GraphQL Client Library + +This GraphQL library is used for accessing [tgstation-server](https://github.com/tgstation/tgstation-server) instances via .NET code. + +## Examples + +### Connecting to a Server: + +!!!TODO!!! diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs new file mode 100644 index 0000000000..cd7c6f378e --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs @@ -0,0 +1,35 @@ +using System; + +using StrawberryShake.Serialization; + +using Tgstation.Server.Common.Extensions; + +#pragma warning disable CA1812 // not detecting service provider usage + +namespace Tgstation.Server.Client.GraphQL.Serializers +{ + /// + /// for s. + /// + sealed class SemverSerializer : ScalarSerializer + { + /// + /// Initializes a new instance of the class. + /// + public SemverSerializer() + : base("Semver") + { + } + + /// + public override Version Parse(string serializedValue) + => Version.Parse(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue))); + + /// + protected override string Format(Version runtimeValue) + { + ArgumentNullException.ThrowIfNull(runtimeValue); + return runtimeValue.Semver().ToString(); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs new file mode 100644 index 0000000000..8656438b11 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs @@ -0,0 +1,22 @@ +using System; + +using StrawberryShake.Serialization; + +#pragma warning disable CA1812 // not detecting service provider usage + +namespace Tgstation.Server.Client.GraphQL.Serializers +{ + /// + /// for s. + /// + sealed class UnsignedIntSerializer : ScalarSerializer + { + /// + /// Initializes a new instance of the class. + /// + public UnsignedIntSerializer() + : base("UnsignedInt") + { + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj new file mode 100644 index 0000000000..f02166323b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj @@ -0,0 +1,38 @@ + + + + + $(TgsFrameworkVersion) + $(TgsApiVersion) + + + + + + + + + + + + + + + + + + + + + + + + + + $(IntermediateOutputPath)berry/GraphQLClient.Client.cs + $(IntermediateOutputPath)berry/GraphQLClient.Client.cs + + + + + diff --git a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql new file mode 100644 index 0000000000..023788fd33 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql @@ -0,0 +1,17 @@ +scalar _KeyFieldSet + +directive @key(fields: _KeyFieldSet!) on SCHEMA | OBJECT + +directive @serializationType(name: String!) on SCALAR + +directive @runtimeType(name: String!) on SCALAR + +directive @enumValue(value: String!) on ENUM_VALUE + +directive @rename(name: String!) on INPUT_FIELD_DEFINITION | INPUT_OBJECT | ENUM | ENUM_VALUE + +extend schema @key(fields: "id") + + +extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32") +extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version") diff --git a/src/Tgstation.Server.Client/ClientException.cs b/src/Tgstation.Server.Client/ClientException.cs index 7f2a119119..056bf18c8d 100644 --- a/src/Tgstation.Server.Client/ClientException.cs +++ b/src/Tgstation.Server.Client/ClientException.cs @@ -5,7 +5,7 @@ using System.Net.Http; namespace Tgstation.Server.Client { /// - /// Exceptions thrown by s. + /// Exceptions thrown by s. /// public abstract class ClientException : Exception { diff --git a/src/Tgstation.Server.Client/IApiClient.cs b/src/Tgstation.Server.Client/IApiClient.cs index 10d6477e3e..fe737d4085 100644 --- a/src/Tgstation.Server.Client/IApiClient.cs +++ b/src/Tgstation.Server.Client/IApiClient.cs @@ -1,5 +1,4 @@ using System; -using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,14 +7,13 @@ using Microsoft.Extensions.Logging; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client { /// /// Web interface for the API. /// - interface IApiClient : IAsyncDisposable + interface IApiClient : ITransferClient, IAsyncDisposable { /// /// The the uses. @@ -39,7 +37,7 @@ namespace Tgstation.Server.Client void AddRequestLogger(IRequestLogger requestLogger); /// - /// Subscribe to all job updates available to the . + /// Subscribe to all job updates available to the . /// /// The of the hub being implemented. /// The to use for proxying the methods of the hub connection. @@ -239,22 +237,5 @@ namespace Tgstation.Server.Client /// A representing the running operation. ValueTask Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class; - - /// - /// Downloads a file for a given . - /// - /// The to download. - /// The for the operation. - /// A resulting in the downloaded . - ValueTask Download(FileTicketResponse ticket, CancellationToken cancellationToken); - - /// - /// Uploads a given for a given . - /// - /// The to download. - /// The to upload. represents an empty file. - /// The for the operation. - /// A representing the running operation. - ValueTask Upload(FileTicketResponse ticket, Stream? uploadStream, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/IServerClient.cs b/src/Tgstation.Server.Client/IRestServerClient.cs similarity index 88% rename from src/Tgstation.Server.Client/IServerClient.cs rename to src/Tgstation.Server.Client/IRestServerClient.cs index 317e012ed0..8d2280c764 100644 --- a/src/Tgstation.Server.Client/IServerClient.cs +++ b/src/Tgstation.Server.Client/IRestServerClient.cs @@ -13,7 +13,7 @@ namespace Tgstation.Server.Client /// /// Main client for communicating with a server. /// - public interface IServerClient : IAsyncDisposable + public interface IRestServerClient : IAsyncDisposable { /// /// The connected server's root . @@ -51,14 +51,20 @@ namespace Tgstation.Server.Client IUserGroupsClient Groups { get; } /// - /// The of the . + /// Access the . + /// + /// Most client methods handle transfers in their invocations. There is rarely any reason to use the directly. + ITransferClient Transfer { get; } + + /// + /// The of the . /// /// The for the operation. /// A resulting in the of the target server. ValueTask ServerInformation(CancellationToken cancellationToken); /// - /// Subscribe to all job updates available to the . + /// Subscribe to all job updates available to the . /// /// The to use to subscribe to updates. /// The optional to use for the backing connection. The default retry policy waits for 1, 2, 4, 8, and 16 seconds, then 30s repeatedly. diff --git a/src/Tgstation.Server.Client/IServerClientFactory.cs b/src/Tgstation.Server.Client/IRestServerClientFactory.cs similarity index 78% rename from src/Tgstation.Server.Client/IServerClientFactory.cs rename to src/Tgstation.Server.Client/IRestServerClientFactory.cs index 8cba768866..13ac3ac635 100644 --- a/src/Tgstation.Server.Client/IServerClientFactory.cs +++ b/src/Tgstation.Server.Client/IRestServerClientFactory.cs @@ -9,9 +9,9 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client { /// - /// Factory for creating s. + /// Factory for creating s. /// - public interface IServerClientFactory + public interface IRestServerClientFactory { /// /// Gets the for a given . @@ -28,17 +28,17 @@ namespace Tgstation.Server.Client CancellationToken cancellationToken = default); /// - /// Create a using a password login. + /// Create a using a password login. /// /// The URL to access TGS. - /// The username to for the . - /// The password for the . - /// Optional initial s to add to the . + /// The username to for the . + /// The password for the . + /// Optional initial s to add to the . /// Optional representing timeout for the connection. /// Attempt to refresh the received when it expires or becomes invalid. and will be stored in memory if this is . /// Optional for the operation. - /// A resulting in a new . - ValueTask CreateFromLogin( + /// A resulting in a new . + ValueTask CreateFromLogin( Uri host, string username, string password, @@ -48,16 +48,16 @@ namespace Tgstation.Server.Client CancellationToken cancellationToken = default); /// - /// Create a using am OAuth login. + /// Create a using an OAuth login. /// /// The URL to access TGS. /// The OAuth code used to complete the flow. /// The . - /// Optional initial s to add to the . + /// Optional initial s to add to the . /// Optional representing timeout for the connection. /// Optional for the operation. - /// A resulting in a new . - ValueTask CreateFromOAuth( + /// A resulting in a new . + ValueTask CreateFromOAuth( Uri host, string oAuthCode, OAuthProvider oAuthProvider, @@ -66,12 +66,12 @@ namespace Tgstation.Server.Client CancellationToken cancellationToken = default); /// - /// Create a . + /// Create a . /// /// The URL to access TGS. /// The to access the API with. - /// A new . - IServerClient CreateFromToken( + /// A new . + IRestServerClient CreateFromToken( Uri host, TokenResponse token); } diff --git a/src/Tgstation.Server.Client/ITransferClient.cs b/src/Tgstation.Server.Client/ITransferClient.cs new file mode 100644 index 0000000000..52892062ac --- /dev/null +++ b/src/Tgstation.Server.Client/ITransferClient.cs @@ -0,0 +1,31 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Client +{ + /// + /// For transferring data s. + /// + public interface ITransferClient + { + /// + /// Downloads a file for a given . + /// + /// The to download. + /// The for the operation. + /// A resulting in the downloaded . + ValueTask Download(FileTicketResponse ticket, CancellationToken cancellationToken); + + /// + /// Uploads a given for a given . + /// + /// The to download. + /// The to upload. represents an empty file. + /// The for the operation. + /// A representing the running operation. + ValueTask Upload(FileTicketResponse ticket, Stream? uploadStream, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/ServerClient.cs b/src/Tgstation.Server.Client/RestServerClient.cs similarity index 87% rename from src/Tgstation.Server.Client/ServerClient.cs rename to src/Tgstation.Server.Client/RestServerClient.cs index 6fce35fcf9..0e835cfd7c 100644 --- a/src/Tgstation.Server.Client/ServerClient.cs +++ b/src/Tgstation.Server.Client/RestServerClient.cs @@ -12,7 +12,7 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client { /// - sealed class ServerClient : IServerClient + sealed class RestServerClient : IRestServerClient { /// public Uri Url => apiClient.Url; @@ -43,16 +43,19 @@ namespace Tgstation.Server.Client /// public IUserGroupsClient Groups { get; } + /// + public ITransferClient Transfer => apiClient; + /// - /// The for the . + /// The for the . /// readonly IApiClient apiClient; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The value of . - public ServerClient(IApiClient apiClient) + public RestServerClient(IApiClient apiClient) { this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); diff --git a/src/Tgstation.Server.Client/ServerClientFactory.cs b/src/Tgstation.Server.Client/RestServerClientFactory.cs similarity index 82% rename from src/Tgstation.Server.Client/ServerClientFactory.cs rename to src/Tgstation.Server.Client/RestServerClientFactory.cs index 98a51e8dc5..68520a71cf 100644 --- a/src/Tgstation.Server.Client/ServerClientFactory.cs +++ b/src/Tgstation.Server.Client/RestServerClientFactory.cs @@ -12,37 +12,37 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client { /// - public sealed class ServerClientFactory : IServerClientFactory + public sealed class RestServerClientFactory : IRestServerClientFactory { /// - /// The for the . + /// The for the . /// internal static IApiClientFactory ApiClientFactory { get; set; } /// - /// The for the . + /// The for the . /// readonly ProductHeaderValue productHeaderValue; /// - /// Initializes static members of the class. + /// Initializes static members of the class. /// - static ServerClientFactory() + static RestServerClientFactory() { ApiClientFactory = new ApiClientFactory(); } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The value of . - public ServerClientFactory(ProductHeaderValue productHeaderValue) + public RestServerClientFactory(ProductHeaderValue productHeaderValue) { this.productHeaderValue = productHeaderValue ?? throw new ArgumentNullException(nameof(productHeaderValue)); } /// - public ValueTask CreateFromLogin( + public ValueTask CreateFromLogin( Uri host, string username, string password, @@ -69,7 +69,7 @@ namespace Tgstation.Server.Client } /// - public ValueTask CreateFromOAuth( + public ValueTask CreateFromOAuth( Uri host, string oAuthCode, OAuthProvider oAuthProvider, @@ -93,7 +93,7 @@ namespace Tgstation.Server.Client } /// - public IServerClient CreateFromToken(Uri host, TokenResponse token) + public IRestServerClient CreateFromToken(Uri host, TokenResponse token) { if (host == null) throw new ArgumentNullException(nameof(host)); @@ -102,7 +102,7 @@ namespace Tgstation.Server.Client if (token.Bearer == null) throw new InvalidOperationException("token.Bearer should not be null!"); - var serverClient = new ServerClient( + var serverClient = new RestServerClient( ApiClientFactory.CreateApiClient( host, new ApiHeaders( @@ -142,16 +142,16 @@ namespace Tgstation.Server.Client } /// - /// Creates a from a login operation. + /// Creates a from a login operation. /// /// The URL to access TGS. /// The to use for the login operation. - /// Optional initial s to add to the . + /// Optional initial s to add to the . /// Optional representing timeout for the connection. /// If may be used to re-login in the future. /// Optional for the operation. - /// A resulting in a new . - async ValueTask CreateWithNewToken( + /// A resulting in a new . + async ValueTask CreateWithNewToken( Uri host, ApiHeaders loginHeaders, IEnumerable? requestLoggers, @@ -173,7 +173,7 @@ namespace Tgstation.Server.Client } var apiHeaders = new ApiHeaders(productHeaderValue, token); - var client = new ServerClient( + var client = new RestServerClient( ApiClientFactory.CreateApiClient( host, apiHeaders, diff --git a/src/Tgstation.Server.Host/Configuration/InternalConfiguration.cs b/src/Tgstation.Server.Host/Configuration/InternalConfiguration.cs index 1d2723e26d..009c766cd8 100644 --- a/src/Tgstation.Server.Host/Configuration/InternalConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/InternalConfiguration.cs @@ -30,6 +30,11 @@ /// public bool UsingDocker { get; set; } + /// + /// Used at compile time to write the GraphQL API schema to this path and exit. + /// + public string? DumpGraphQLApiPath { get; set; } + /// /// The base path for the app settings configuration files. /// diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 9bf56a0e4d..626a0a71b1 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -181,7 +181,10 @@ namespace Tgstation.Server.Host.Controllers UserGroupLimit = generalConfiguration.UserGroupLimit, ValidInstancePaths = generalConfiguration.ValidInstancePaths, WindowsHost = platformIdentifier.IsWindows, - SwarmServers = swarmService.GetSwarmServers(), + SwarmServers = swarmService + .GetSwarmServers() + ?.Select(swarmServerInfo => new SwarmServerResponse(swarmServerInfo)) + .ToList(), OAuthProviderInfos = oAuthProviders.ProviderInfos(), UpdateInProgress = serverControl.UpdateInProgress, }); diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 7e981066c0..cfdd54f969 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -8,6 +8,8 @@ using Cyberboss.AspNetCore.AsyncInitializer; using Elastic.CommonSchema.Serilog; +using HotChocolate.Types; + using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; @@ -50,6 +52,8 @@ using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.GraphQL.Types.Scalars; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Properties; @@ -283,6 +287,14 @@ namespace Tgstation.Server.Host.Core services.AddHttpClient(); services.AddSingleton(); + // configure graphql + services + .AddGraphQLServer() + .AddAuthorization() + .AddType() + .BindRuntimeType() + .AddQueryType(); + void AddTypedContext() where TContext : DatabaseContext { @@ -596,6 +608,8 @@ namespace Tgstation.Server.Host.Core // majority of handling is done in the controllers endpoints.MapControllers(); + + endpoints.MapGraphQL(Routes.GraphQL); }); // 404 anything that gets this far diff --git a/src/Tgstation.Server.Host/GraphQL/Query.cs b/src/Tgstation.Server.Host/GraphQL/Query.cs new file mode 100644 index 0000000000..577cced304 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Query.cs @@ -0,0 +1,16 @@ +#pragma warning disable CA1724 + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// GraphQL query . + /// + public sealed class Query + { + /// + /// Gets the . + /// + /// A new . + public ServerSwarm Swarm() => new(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs new file mode 100644 index 0000000000..20c8a08b80 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs @@ -0,0 +1,22 @@ +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a database entity. + /// + public abstract class Entity + { + /// + /// The ID of the . + /// + public long Id { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + protected Entity(long id) + { + Id = id; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs new file mode 100644 index 0000000000..fab7784385 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs @@ -0,0 +1,49 @@ +using System; + +using HotChocolate; +using HotChocolate.Authorization; + +using Microsoft.Extensions.Options; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents the local tgstation-server. + /// + public sealed class LocalServer + { + /// + /// Gets . + /// + /// The to use. + /// The to use. + /// The containing the to use. + /// A new . + [AllowAnonymous] + public LocalServerInformation Information( + [Service] IOAuthProviders oAuthProviders, + [Service] IPlatformIdentifier platformIdentifier, + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(oAuthProviders); + ArgumentNullException.ThrowIfNull(platformIdentifier); + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + var generalConfiguration = generalConfigurationOptions.Value; + return new LocalServerInformation + { + MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, + InstanceLimit = generalConfiguration.InstanceLimit, + UserLimit = generalConfiguration.UserLimit, + UserGroupLimit = generalConfiguration.UserGroupLimit, + ValidInstancePaths = generalConfiguration.ValidInstancePaths, + WindowsHost = platformIdentifier.IsWindows, + OAuthProviderInfos = oAuthProviders.ProviderInfos(), + }; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs new file mode 100644 index 0000000000..e48c18ce14 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs @@ -0,0 +1,26 @@ +using System; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// An with a . + /// + public abstract class NamedEntity : Entity + { + /// + /// The name of the . + /// + public string Name { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The ID for the . + /// The value of . + protected NamedEntity(long id, string name) + : base(id) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs new file mode 100644 index 0000000000..7b0732fe92 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs @@ -0,0 +1,33 @@ +using System; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a valid OAuth connection. + /// + public sealed class OAuthConnection + { + /// + /// The of the . + /// ] + public OAuthProvider Provider { get; } + + /// + /// The ID of the user in the . + /// + public string ExternalUserId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public OAuthConnection(string externalUserId, OAuthProvider provider) + { + ExternalUserId = externalUserId ?? throw new ArgumentNullException(nameof(externalUserId)); + Provider = provider; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs new file mode 100644 index 0000000000..6d7b0d6b5f --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -0,0 +1,33 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a set of permissions for the server. + /// + public sealed class PermissionSet : Entity + { + /// + /// The for the . + /// + public AdministrationRights AdministrationRights { get; } + + /// + /// The for the . + /// + public InstanceManagerRights InstanceManagerRights { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The value of . + /// The value of . + public PermissionSet(long id, AdministrationRights administrationRights, InstanceManagerRights instanceManagerRights) + : base(id) + { + AdministrationRights = administrationRights; + InstanceManagerRights = instanceManagerRights; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs new file mode 100644 index 0000000000..76fbb55eed --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs @@ -0,0 +1,79 @@ +using System; + +using HotChocolate.Language; +using HotChocolate.Types; +using Tgstation.Server.Common.Extensions; + +namespace Tgstation.Server.Host.GraphQL.Types.Scalars +{ + /// + /// A for semantic s. + /// + public sealed class SemverType : ScalarType + { + /// + /// Initializes a new instance of the class. + /// + public SemverType() + : base("Semver") + { + Description = "Represents a version in semver format as defined by https://semver.org/spec/v2.0.0.html"; + } + + /// + public override IValueNode ParseResult(object? resultValue) + => ParseValue(resultValue); + + /// + public override bool TryDeserialize(object? resultValue, out object? runtimeValue) + { + if (resultValue is not string resultString) + { + runtimeValue = null; + return false; + } + + var result = Version.TryParse(resultString, out var resultVersion); + runtimeValue = resultVersion; + return result; + } + + /// + public override bool TrySerialize(object? runtimeValue, out object? resultValue) + { + if (runtimeValue is not Version runtimeVersion) + { + resultValue = null; + return false; + } + + resultValue = runtimeVersion.Semver().ToString(); + return true; + } + + /// + protected override Version ParseLiteral(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return Version.Parse(valueSyntax.Value); + } + + /// + protected override StringValueNode ParseValue(Version runtimeValue) + => new StringValueNode(runtimeValue.Semver().ToString()); + + /// + protected override bool IsInstanceOfType(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return IsInstanceOfType(valueSyntax.Value); + } + + /// + protected override bool IsInstanceOfType(Version runtimeValue) + { + ArgumentNullException.ThrowIfNull(runtimeValue); + return runtimeValue.Build != -1 && runtimeValue.Revision == -1; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs new file mode 100644 index 0000000000..e4ddf59dd2 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +using HotChocolate; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Swarm; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a tgstation-server swarm. + /// + public sealed class ServerSwarm + { + /// + /// Gets the for the swarm. + /// + /// The to use. + /// The to use. + /// A new . + public SwarmMetadata Metadata( + [Service] IAssemblyInformationProvider assemblyInformationProvider, + [Service] IServerControl serverControl) + { + ArgumentNullException.ThrowIfNull(assemblyInformationProvider); + ArgumentNullException.ThrowIfNull(serverControl); + return new SwarmMetadata(assemblyInformationProvider, serverControl.UpdateInProgress); + } + + /// + /// Gets the local . + /// + /// A new . + public LocalServer LocalServer() => new(); + + /// + /// Gets the for all servers in a swarm. + /// + /// The to use. + /// A of s if the local server is part of a swarm, otherwise. + public List? Servers( + [Service] ISwarmService swarmService) + { + ArgumentNullException.ThrowIfNull(swarmService); + return swarmService.GetSwarmServers(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs new file mode 100644 index 0000000000..19fe192b23 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs @@ -0,0 +1,46 @@ +using System; + +using Tgstation.Server.Api; +using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents information that is constant across all servers in a . + /// + public sealed class SwarmMetadata + { + /// + /// The version of the host. + /// + public Version Version { get; } + + /// + /// The version of the host. + /// + public Version ApiVersion => ApiHeaders.Version; + + /// + /// The DMAPI interop version the server uses. + /// + public Version DMApiVersion => DMApiConstants.InteropVersion; + + /// + /// If there is a server update in progress. + /// + public bool UpdateInProgress { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The used to derive the . + /// The value of . + public SwarmMetadata(IAssemblyInformationProvider assemblyInformationProvider, bool updateInProgress) + { + ArgumentNullException.ThrowIfNull(assemblyInformationProvider); + Version = assemblyInformationProvider.Version; + UpdateInProgress = updateInProgress; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs new file mode 100644 index 0000000000..93badc8165 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// A user registered in the server. + /// + public sealed class User : NamedEntity + { + /// + /// If the is enabled since users cannot be deleted. System users cannot be disabled. + /// + public bool Enabled { get; } + + /// + /// When the was created. + /// + public DateTimeOffset CreatedAt { get; } + + /// + /// The SID/UID of the on Windows/POSIX respectively. + /// + public string? SystemIdentifier { get; } + + /// + /// The of the . + /// + readonly long createdById; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public User( + long id, + string name, + string? systemIdentifier, + DateTimeOffset createdAt, + long createdById, + bool enabled) + : base(id, name) + { + SystemIdentifier = systemIdentifier; + CreatedAt = createdAt; + this.createdById = createdById; + Enabled = enabled; + } + + /// + /// The who created this . + /// + /// A resulting in the who created this , if any. + public ValueTask CreatedBy() + => throw new NotImplementedException(); + + /// + /// List of s associated with the user if OAuth is configured. + /// + /// A resulting in a new of s for the if OAuth is configured. + public ValueTask>? OAuthConnections() + => throw new NotImplementedException(); + + /// + /// The directly associated with the , if any. + /// + /// A resulting in the directly associated with the , if any. + public ValueTask PermissionSet() + => throw new NotImplementedException(); + + /// + /// The asociated with the user, if any. + /// + /// A resulting in the associated with the , if any. + public ValueTask Group() + => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs new file mode 100644 index 0000000000..82b6242221 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using HotChocolate.Types; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a group of s. + /// + public sealed class UserGroup : NamedEntity + { + /// + /// The of the . + /// + readonly long permissionSetId; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The value of . + public UserGroup( + long id, + string name, + long permissionSetId) + : base(id, name) + { + this.permissionSetId = permissionSetId; + } + + /// + /// The of the . + /// + /// A resulting in the for the . + public ValueTask PermissionSet() + => throw new NotImplementedException(); + + /// + /// Gets the s in the . + /// + /// A resulting in a new of s in the . + [UsePaging(IncludeTotalCount = true)] + public List Users() + => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 751cd2d453..57a26f669b 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -2,9 +2,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; +using HotChocolate.Execution; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -13,6 +16,7 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host @@ -137,6 +141,9 @@ namespace Tgstation.Server.Host { using (cancellationToken.Register(() => logger.LogInformation("Server termination requested!"))) { + if (await DumpGraphQLSchemaIfRequested(Host.Services, cancellationToken)) + return; + var generalConfigurationOptions = Host.Services.GetRequiredService>(); generalConfiguration = generalConfigurationOptions.Value; await Host.RunAsync(cancellationTokenSource.Token); @@ -264,6 +271,31 @@ namespace Tgstation.Server.Host return ValueTask.CompletedTask; } + /// + /// Checks if is set and dumps the GraphQL API Schema to it if so. + /// + /// The to resolve services from. + /// The for the operation. + /// A resulting in if the GraphQL API was dumped, otherwise. + async ValueTask DumpGraphQLSchemaIfRequested(IServiceProvider services, CancellationToken cancellationToken) + { + var internalConfigurationOptions = services.GetRequiredService>(); + var apiDumpPath = internalConfigurationOptions.Value.DumpGraphQLApiPath; + if (String.IsNullOrWhiteSpace(apiDumpPath)) + return false; + + logger!.LogInformation("Dumping GraphQL API spec to {path} and exiting...", apiDumpPath); + + // https://github.com/ChilliCream/graphql-platform/discussions/5885 + var resolver = services.GetRequiredService(); + var executor = await resolver.GetRequestExecutorAsync(cancellationToken: cancellationToken); + var sdl = executor.Schema.Print(); + + var ioManager = services.GetRequiredService(); + await ioManager.WriteAllBytes(apiDumpPath, Encoding.UTF8.GetBytes(sdl), cancellationToken); + return true; + } + /// /// Throws an if the cannot be used. /// diff --git a/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs b/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs index dd9c01b783..5a0889b7e9 100644 --- a/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs +++ b/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Models.Internal; namespace Tgstation.Server.Host.Swarm { @@ -15,8 +15,8 @@ namespace Tgstation.Server.Host.Swarm /// /// Pass in an updated list of to the node. /// - /// An of the updated s. - void UpdateSwarmServersList(IEnumerable swarmServers); + /// An of the updated s. + void UpdateSwarmServersList(IEnumerable swarmServers); /// /// Notify the node of an update request from the controller. @@ -36,11 +36,11 @@ namespace Tgstation.Server.Host.Swarm /// /// Attempt to register a given with the controller. /// - /// The that is registering. + /// The that is registering. /// The registration . /// The for the operation. /// A resulting in if the registration was successful, otherwise. - ValueTask RegisterNode(Api.Models.Internal.SwarmServer node, Guid registrationId, CancellationToken cancellationToken); + ValueTask RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken); /// /// Attempt to unregister a node with a given with the controller. diff --git a/src/Tgstation.Server.Host/Swarm/ISwarmService.cs b/src/Tgstation.Server.Host/Swarm/ISwarmService.cs index a07a8478d4..eb294d251d 100644 --- a/src/Tgstation.Server.Host/Swarm/ISwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/ISwarmService.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.IO; namespace Tgstation.Server.Host.Swarm @@ -35,9 +35,9 @@ namespace Tgstation.Server.Host.Swarm ValueTask CommitUpdate(CancellationToken cancellationToken); /// - /// Gets the list of s in the swarm, including the current one. + /// Gets the list of s in the swarm, including the current one. /// - /// A of s in the swarm. If the server is not part of a swarm, will be returned. - List? GetSwarmServers(); + /// A of s in the swarm. If the server is not part of a swarm, will be returned. + List? GetSwarmServers(); } } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs b/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs index 06fb6260bb..fae210c6cb 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Models.Internal; namespace Tgstation.Server.Host.Swarm { @@ -11,9 +11,9 @@ namespace Tgstation.Server.Host.Swarm public sealed class SwarmServersUpdateRequest { /// - /// The of updated s. + /// The of updated s. /// [Required] - public ICollection? SwarmServers { get; set; } + public ICollection? SwarmServers { get; set; } } } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs index 3e9ac7d151..ea77de6a3c 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; +using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Common.Http; @@ -104,12 +105,12 @@ namespace Tgstation.Server.Host.Swarm readonly CancellationTokenSource? serverHealthCheckCancellationTokenSource; /// - /// of connected s. + /// of connected s. /// - readonly List? swarmServers; + readonly List? swarmServers; /// - /// of s to registration s and when they were created. + /// of s to registration s and when they were created. /// readonly Dictionary? registrationIdsAndTimes; @@ -196,9 +197,9 @@ namespace Tgstation.Server.Host.Swarm serverHealthCheckCancellationTokenSource = new CancellationTokenSource(); forceHealthCheckTcs = new TaskCompletionSource(); - swarmServers = new List + swarmServers = new List { - new SwarmServerResponse + new SwarmServerInformation { Address = swarmConfiguration.Address, PublicAddress = swarmConfiguration.PublicAddress, @@ -315,7 +316,7 @@ namespace Tgstation.Server.Host.Swarm // on the controller, we first need to signal for nodes to go ahead // if anything fails at this point, there's nothing we can do logger.LogDebug("Sending remote commit message to nodes..."); - async ValueTask SendRemoteCommitUpdate(SwarmServerResponse swarmServer) + async ValueTask SendRemoteCommitUpdate(SwarmServerInformation swarmServer) { using var request = PrepareSwarmRequest( swarmServer, @@ -349,7 +350,7 @@ namespace Tgstation.Server.Host.Swarm } /// - public List? GetSwarmServers() + public List? GetSwarmServers() { if (!SwarmMode) return null; @@ -427,7 +428,7 @@ namespace Tgstation.Server.Host.Swarm { logger.LogTrace("Begin Shutdown"); - async ValueTask SendUnregistrationRequest(SwarmServerResponse? swarmServer) + async ValueTask SendUnregistrationRequest(SwarmServerInformation? swarmServer) { using var httpClient = httpClientFactory.CreateClient(); using var request = PrepareSwarmRequest( @@ -503,7 +504,7 @@ namespace Tgstation.Server.Host.Swarm } /// - public void UpdateSwarmServersList(IEnumerable swarmServers) + public void UpdateSwarmServersList(IEnumerable swarmServers) { ArgumentNullException.ThrowIfNull(swarmServers); @@ -539,7 +540,7 @@ namespace Tgstation.Server.Host.Swarm } /// - public async ValueTask RegisterNode(Api.Models.Internal.SwarmServer node, Guid registrationId, CancellationToken cancellationToken) + public async ValueTask RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(node); @@ -586,7 +587,7 @@ namespace Tgstation.Server.Host.Swarm registrationIdsAndTimes.Remove(node.Identifier); } - swarmServers.Add(new SwarmServerResponse + swarmServers.Add(new SwarmServerInformation { PublicAddress = node.PublicAddress, Address = node.Address, @@ -693,7 +694,7 @@ namespace Tgstation.Server.Host.Swarm logger.LogInformation("Aborting swarm update!"); using var httpClient = httpClientFactory.CreateClient(); - async ValueTask SendRemoteAbort(SwarmServerResponse swarmServer) + async ValueTask SendRemoteAbort(SwarmServerInformation swarmServer) { using var request = PrepareSwarmRequest( swarmServer, @@ -719,7 +720,7 @@ namespace Tgstation.Server.Host.Swarm } if (!swarmController) - return SendRemoteAbort(new SwarmServerResponse + return SendRemoteAbort(new SwarmServerInformation { Address = swarmConfiguration.ControllerAddress, }); @@ -735,10 +736,10 @@ namespace Tgstation.Server.Host.Swarm /// /// Create the for an update package retrieval from a given . /// - /// The to download the update package from. + /// The to download the update package from. /// The to use for the download. /// A new for the update package. - RequestFileStreamProvider CreateUpdateStreamProvider(SwarmServerResponse sourceNode, FileTicketResponse ticket) + RequestFileStreamProvider CreateUpdateStreamProvider(SwarmServerInformation sourceNode, FileTicketResponse ticket) { var httpClient = httpClientFactory.CreateClient(); try @@ -791,8 +792,8 @@ namespace Tgstation.Server.Host.Swarm SwarmUpdateOperation localUpdateOperation; try { - SwarmServerResponse? sourceNode = null; - List currentNodes; + SwarmServerInformation? sourceNode = null; + List currentNodes; lock (swarmServers) { currentNodes = swarmServers @@ -1082,12 +1083,12 @@ namespace Tgstation.Server.Host.Swarm /// Create a for downloading the content of a given for the rest of the swarm nodes. /// /// The containing the server update package. - /// An of the involved . + /// An of the involved . /// The for the operation. - /// A resulting in a new of unique s keyed by their . + /// A resulting in a new of unique s keyed by their . async ValueTask> CreateDownloadTickets( ISeekableFileStreamProvider initiatorProvider, - IReadOnlyCollection involvedServers, + IReadOnlyCollection involvedServers, CancellationToken cancellationToken) { // we need to ensure this thing is loaded before we start providing downloads or it'll create unnecessary delays @@ -1126,12 +1127,12 @@ namespace Tgstation.Server.Host.Swarm { using var httpClient = httpClientFactory.CreateClient(); - List currentSwarmServers; + List currentSwarmServers; lock (swarmServers!) currentSwarmServers = swarmServers.ToList(); var registrationIdsAndTimes = this.registrationIdsAndTimes!; - async ValueTask HealthRequestForServer(SwarmServerResponse swarmServer) + async ValueTask HealthRequestForServer(SwarmServerInformation swarmServer) { using var request = PrepareSwarmRequest( swarmServer, @@ -1320,7 +1321,7 @@ namespace Tgstation.Server.Host.Swarm /// A representing the running operation. async ValueTask SendUpdatedServerListToNodes(CancellationToken cancellationToken) { - List currentSwarmServers; + List currentSwarmServers; lock (swarmServers!) { serversDirty = false; @@ -1336,7 +1337,7 @@ namespace Tgstation.Server.Host.Swarm logger.LogDebug("Sending updated server list to all {nodeCount} nodes...", currentSwarmServers.Count - 1); using var httpClient = httpClientFactory.CreateClient(); - async ValueTask UpdateRequestForServer(SwarmServerResponse swarmServer) + async ValueTask UpdateRequestForServer(SwarmServerInformation swarmServer) { using var request = PrepareSwarmRequest( swarmServer, @@ -1374,20 +1375,20 @@ namespace Tgstation.Server.Host.Swarm /// /// Prepares a for swarm communication. /// - /// The the message is for. Must have and set. If , will be sent to swarm controller. + /// The the message is for. Must have and set. If , will be sent to swarm controller. /// The . /// The route on to use. /// The body if any. /// An optional override to the . /// A new . HttpRequestMessage PrepareSwarmRequest( - SwarmServerResponse? swarmServer, + SwarmServerInformation? swarmServer, HttpMethod httpMethod, string route, object? body, Guid? registrationIdOverride = null) { - swarmServer ??= new SwarmServerResponse + swarmServer ??= new SwarmServerInformation { Address = swarmConfiguration.ControllerAddress, }; @@ -1518,10 +1519,10 @@ namespace Tgstation.Server.Host.Swarm } /// - /// Gets the from a given . + /// Gets the from a given . /// /// The registration . - /// The registered or if it does not exist. + /// The registered or if it does not exist. string? NodeIdentifierFromRegistration(Guid registrationId) { if (!swarmController) diff --git a/src/Tgstation.Server.Host/Swarm/SwarmUpdateOperation.cs b/src/Tgstation.Server.Host/Swarm/SwarmUpdateOperation.cs index 67d65686ca..74d8748755 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmUpdateOperation.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmUpdateOperation.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Host.Swarm { @@ -16,7 +15,7 @@ namespace Tgstation.Server.Host.Swarm /// /// All of the s that are involved in the updates. /// - public IReadOnlyList InvolvedServers => initialInvolvedServers ?? throw new InvalidOperationException("This property can only be checked on controller SwarmUpdateOperations!"); + public IReadOnlyList InvolvedServers => initialInvolvedServers ?? throw new InvalidOperationException("This property can only be checked on controller SwarmUpdateOperations!"); /// /// The being updated to. @@ -31,7 +30,7 @@ namespace Tgstation.Server.Host.Swarm /// /// Backing field for . /// - readonly IReadOnlyList? initialInvolvedServers; + readonly IReadOnlyList? initialInvolvedServers; /// /// The backing for . @@ -58,9 +57,9 @@ namespace Tgstation.Server.Host.Swarm /// Initializes a new instance of the class. /// /// The value of . - /// An of the controller's current nodes as s. Must have and set. + /// An of the controller's current nodes as s. Must have and set. /// This is the variant for use by the controller. - public SwarmUpdateOperation(Version targetVersion, IEnumerable currentNodes) + public SwarmUpdateOperation(Version targetVersion, IEnumerable currentNodes) : this(targetVersion) { initialInvolvedServers = currentNodes?.ToList() ?? throw new ArgumentNullException(nameof(currentNodes)); diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 423d11d5ea..ed2426e3fd 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -38,6 +38,11 @@ + + + + + @@ -61,11 +66,8 @@ - - + + @@ -79,7 +81,7 @@ - + @@ -97,6 +99,12 @@ + + + + + + @@ -172,6 +180,8 @@ + + diff --git a/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs b/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs index 9b36d0a1a0..c9af0484da 100644 --- a/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs +++ b/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs @@ -10,8 +10,8 @@ namespace Tgstation.Server.Client.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new ServerClientFactory(null)); - new ServerClientFactory(new ProductHeaderValue("Tgstation.Server.Client.Tests", GetType().Assembly.GetName().Version.ToString())); + Assert.ThrowsException(() => new RestServerClientFactory(null)); + new RestServerClientFactory(new ProductHeaderValue("Tgstation.Server.Client.Tests", GetType().Assembly.GetName().Version.ToString())); } } } diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index 70b7e9c030..dc8c71753d 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -20,8 +20,8 @@ namespace Tgstation.Server.Tests.Live.Instance { sealed class JobsHubTests : IJobsHub { - readonly IServerClient permedUser; - readonly IServerClient permlessUser; + readonly IRestServerClient permedUser; + readonly IRestServerClient permlessUser; readonly TaskCompletionSource finishTcs; @@ -34,7 +34,7 @@ namespace Tgstation.Server.Tests.Live.Instance long? permlessPsId; - public JobsHubTests(IServerClient permedUser, IServerClient permlessUser) + public JobsHubTests(IRestServerClient permedUser, IRestServerClient permlessUser) { this.permedUser = permedUser; this.permlessUser = permlessUser; diff --git a/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs b/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs index a346b8b0ed..c83b859f41 100644 --- a/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs +++ b/tests/Tgstation.Server.Tests/Live/InstanceManagerTest.cs @@ -24,12 +24,12 @@ namespace Tgstation.Server.Tests.Live { public const string TestInstanceName = "IntegrationTestInstance"; - readonly IServerClient serverClient; + readonly IRestServerClient serverClient; readonly IInstanceManagerClient instanceManagerClient; readonly IUsersClient usersClient; readonly string testRootPath; - public InstanceManagerTest(IServerClient serverClient, string testRootPath) + public InstanceManagerTest(IRestServerClient serverClient, string testRootPath) { this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient)); instanceManagerClient = serverClient.Instances; diff --git a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs new file mode 100644 index 0000000000..8251758820 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Tgstation.Server.Client; +using Tgstation.Server.Client.GraphQL; + +namespace Tgstation.Server.Tests.Live +{ + sealed class MultiServerClient + { + readonly IRestServerClient restServerClient; + readonly IGraphQLServerClient graphQLServerClient; + + readonly bool useGraphQL; + + public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient, bool useGraphQL) + { + this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); + this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); + this.useGraphQL = useGraphQL; + } + + public ValueTask Execute( + Func restAction, + Func graphQLAction) + { + if (useGraphQL) + return graphQLServerClient.RunQuery(graphQLAction); + + return restAction(restServerClient); + } + + public async ValueTask ExecuteReadOnlyConfirmEquivalence( + Func> restAction, + Func> graphQLAction, + Func comparison) + { + var restTask = restAction(this.restServerClient); + TGraphQLResult graphQLResult = default; + await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient)); + + var restResult = await restTask; + Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!"); + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 30e6b9e78e..5083ff6956 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -31,7 +31,7 @@ namespace Tgstation.Server.Tests.Live { static class RawRequestTests { - static async Task TestRequestValidation(IServerClient serverClient, CancellationToken cancellationToken) + static async Task TestRequestValidation(IRestServerClient serverClient, CancellationToken cancellationToken) { var url = serverClient.Url; var token = serverClient.Token.Bearer; @@ -196,7 +196,7 @@ namespace Tgstation.Server.Tests.Live } } - static async Task TestServerInformation(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) + static async Task TestServerInformation(IRestServerClientFactory clientFactory, IRestServerClient serverClient, CancellationToken cancellationToken) { var serverInfo = await serverClient.ServerInformation(default); @@ -220,7 +220,7 @@ namespace Tgstation.Server.Tests.Live await ApiAssert.ThrowsException(() => badClient.ServerInformation(cancellationToken)); } - static async Task TestOAuthFails(IServerClient serverClient, CancellationToken cancellationToken) + static async Task TestOAuthFails(IRestServerClient serverClient, CancellationToken cancellationToken) { var url = serverClient.Url; var token = serverClient.Token.Bearer; @@ -242,7 +242,7 @@ namespace Tgstation.Server.Tests.Live } } - static async Task TestInvalidTransfers(IServerClient serverClient, CancellationToken cancellationToken) + static async Task TestInvalidTransfers(IRestServerClient serverClient, CancellationToken cancellationToken) { var url = serverClient.Url; var token = serverClient.Token.Bearer; @@ -317,7 +317,7 @@ namespace Tgstation.Server.Tests.Live } } - static async Task RegressionTestForLeakedPasswordHashesBug(IServerClient serverClient, CancellationToken cancellationToken) + static async Task RegressionTestForLeakedPasswordHashesBug(IRestServerClient serverClient, CancellationToken cancellationToken) { // See what https://github.com/tgstation/tgstation-server/commit/6c8dc87c4af36620885b262175d7974aca2b3c2b fixed @@ -361,7 +361,7 @@ namespace Tgstation.Server.Tests.Live => ProxyFunc(job, cancellationToken); } - static async Task TestSignalRUsage(IServerClientFactory serverClientFactory, IServerClient serverClient, CancellationToken cancellationToken) + static async Task TestSignalRUsage(IRestServerClientFactory serverClientFactory, IRestServerClient serverClient, CancellationToken cancellationToken) { // test regular creation works without error var hubConnectionBuilder = new HubConnectionBuilder(); @@ -374,7 +374,7 @@ namespace Tgstation.Server.Tests.Live options => { options.AccessTokenProvider = () => tokenRetrivalFunc(); - ((IApiClient)typeof(ServerClient) + ((IApiClient)typeof(RestServerClient) .GetField( "apiClient", BindingFlags.NonPublic | BindingFlags.Instance) @@ -457,7 +457,7 @@ namespace Tgstation.Server.Tests.Live } } - public static Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) + public static Task Run(IRestServerClientFactory clientFactory, IRestServerClient serverClient, CancellationToken cancellationToken) => Task.WhenAll( TestRequestValidation(serverClient, cancellationToken), TestOAuthFails(serverClient, cancellationToken), diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 7ead802091..9dcb363e63 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -35,6 +35,7 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Client; using Tgstation.Server.Client.Components; +using Tgstation.Server.Client.GraphQL; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Configuration; @@ -71,7 +72,7 @@ namespace Tgstation.Server.Tests.Live _ = mainDMPort.Value; } - readonly ServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); public static List GetEngineServerProcessesOnPort(EngineType engineType, ushort? port) { @@ -205,7 +206,7 @@ namespace Tgstation.Server.Tests.Live await CachingFileDownloader.InitializeAndInjectForLiveTests(default); DummyChatProvider.RandomDisconnections(true); - ServerClientFactory.ApiClientFactory = new RateLimitRetryingApiClientFactory(); + RestServerClientFactory.ApiClientFactory = new RateLimitRetryingApiClientFactory(); var connectionString = Environment.GetEnvironmentVariable("TGS_TEST_CONNECTION_STRING"); if (String.IsNullOrWhiteSpace(connectionString)) @@ -290,7 +291,7 @@ namespace Tgstation.Server.Tests.Live var serverTask = server.Run(cancellationToken).AsTask(); try { - async ValueTask TestWithoutAndWithPermission(Func> action, IServerClient client, AdministrationRights right) + async ValueTask TestWithoutAndWithPermission(Func> action, IRestServerClient client, AdministrationRights right) { var ourUser = await client.Users.Read(cancellationToken); var update = new UserUpdateRequest @@ -321,7 +322,7 @@ namespace Tgstation.Server.Tests.Live request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version); request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.Token.Bearer); - request.Headers.Add(ApiHeaders.OAuthProviderHeader, OAuthProvider.GitHub.ToString()); + request.Headers.Add(ApiHeaders.OAuthProviderHeader, Api.Models.OAuthProvider.GitHub.ToString()); using var response = await httpClient.SendAsync(request, cancellationToken); Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); @@ -875,7 +876,7 @@ namespace Tgstation.Server.Tests.Live var controllerInfo = await controllerClient.ServerInformation(cancellationToken); - async Task WaitForSwarmServerUpdate(IServerClient client, int currentServerCount) + async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount) { ServerInformationResponse serverInformation; do @@ -1371,7 +1372,43 @@ namespace Tgstation.Server.Tests.Live await ApiAssert.ThrowsException(() => tokenOnlyClient.Users.Read(cancellationToken), null); } - async ValueTask CreateUserWithNoInstancePerms() + // basic graphql test, to be used everywhere eventually + await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl)) + { + // test getting server info + var multiClient = new MultiServerClient(firstAdminClient, graphQLClient); + + await multiClient.ExecuteReadOnlyConfirmEquivalence( + restClient => restClient.ServerInformation(cancellationToken), + async gqlClient => (await gqlClient.ServerInformationQuery.ExecuteAsync(cancellationToken)).Data, + (restServerInfo, gqlServerInfo) => restServerInfo.UpdateInProgress == gqlServerInfo.Swarm.Metadata.UpdateInProgress + && restServerInfo.Version == gqlServerInfo.Swarm.Metadata.Version + && restServerInfo.DMApiVersion == gqlServerInfo.Swarm.Metadata.DmApiVersion + && restServerInfo.InstanceLimit == gqlServerInfo.Swarm.LocalServer.Information.InstanceLimit + && restServerInfo.UserGroupLimit == gqlServerInfo.Swarm.LocalServer.Information.UserGroupLimit + && restServerInfo.ValidInstancePaths.SequenceEqual(gqlServerInfo.Swarm.LocalServer.Information.ValidInstancePaths) + && restServerInfo.UserLimit == gqlServerInfo.Swarm.LocalServer.Information.UserLimit + && restServerInfo.MinimumPasswordLength == gqlServerInfo.Swarm.LocalServer.Information.MinimumPasswordLength + && (restServerInfo.SwarmServers == gqlServerInfo.Swarm.Servers + || restServerInfo.SwarmServers.SequenceEqual(gqlServerInfo.Swarm.Servers.Select(x => new SwarmServerResponse(new Api.Models.Internal.SwarmServerInformation + { + Address = x.Address, + PublicAddress = x.PublicAddress, + Controller = x.Controller, + Identifier = x.Identifier, + })))) + && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos + || restServerInfo.OAuthProviderInfos.All(kvp => + { + var info = gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos.FirstOrDefault(x => (int)x.Key == (int)kvp.Key); + return info != null + && info.Value.ServerUrl == kvp.Value.ServerUrl + && info.Value.ClientId == kvp.Value.ClientId + && info.Value.RedirectUri == kvp.Value.RedirectUri; + }))); + } + + async ValueTask CreateUserWithNoInstancePerms() { var createRequest = new UserCreateRequest() { @@ -1457,10 +1494,10 @@ namespace Tgstation.Server.Tests.Live } var instanceTest = new InstanceTest( - firstAdminClient.Instances, - fileDownloader, - GetInstanceManager(), - (ushort)server.ApiUrl.Port); + firstAdminClient.Instances, + fileDownloader, + GetInstanceManager(), + (ushort)server.ApiUrl.Port); async Task RunInstanceTests() { @@ -1811,7 +1848,7 @@ namespace Tgstation.Server.Tests.Live await serverTask; } - async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) + async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) { url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty)); var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2); diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index 1a20849971..ceebac4356 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -17,9 +17,9 @@ namespace Tgstation.Server.Tests.Live { sealed class UsersTest { - readonly IServerClient serverClient; + readonly IRestServerClient serverClient; - public UsersTest(IServerClient serverClient) + public UsersTest(IRestServerClient serverClient) { this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient)); } diff --git a/tests/Tgstation.Server.Tests/TestVersions.cs b/tests/Tgstation.Server.Tests/TestVersions.cs index adadd4bfcb..6da26320aa 100644 --- a/tests/Tgstation.Server.Tests/TestVersions.cs +++ b/tests/Tgstation.Server.Tests/TestVersions.cs @@ -270,7 +270,7 @@ namespace Tgstation.Server.Tests var versionString = versionsPropertyGroup.Element(xmlNamespace + "TgsClientVersion").Value + ".0"; Assert.IsNotNull(versionString); Assert.IsTrue(Version.TryParse(versionString, out var expected)); - var actual = typeof(ServerClientFactory).Assembly.GetName().Version; + var actual = typeof(RestServerClientFactory).Assembly.GetName().Version; Assert.AreEqual(expected, actual); } diff --git a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj index bbcbbc86ed..e2651d56a2 100644 --- a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj +++ b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/tgstation-server.sln b/tgstation-server.sln index 81bcb0fb81..5b39bf1b3c 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -268,6 +268,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Shared", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Shared.Tests", "tests\Tgstation.Server.Shared.Tests\Tgstation.Server.Shared.Tests.csproj", "{EAB84FD0-5514-4254-B188-7D90ACB7284D}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Client.GraphQL", "src\Tgstation.Server.Client.GraphQL\Tgstation.Server.Client.GraphQL.csproj", "{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}" + ProjectSection(ProjectDependencies) = postProject + {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A} = {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -562,6 +567,18 @@ Global {EAB84FD0-5514-4254-B188-7D90ACB7284D}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU {EAB84FD0-5514-4254-B188-7D90ACB7284D}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU {EAB84FD0-5514-4254-B188-7D90ACB7284D}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWindows|Any CPU.ActiveCfg = Debug|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWindows|Any CPU.Build.0 = Debug|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWix|Any CPU.ActiveCfg = Debug|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWix|Any CPU.Build.0 = Debug|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Release|Any CPU.Build.0 = Release|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWindows|Any CPU.ActiveCfg = Release|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU + {8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/tools/Tgstation.Server.Migrator.Comms/Program.cs b/tools/Tgstation.Server.Migrator.Comms/Program.cs index 326de7cf93..3c46d8a1cc 100644 --- a/tools/Tgstation.Server.Migrator.Comms/Program.cs +++ b/tools/Tgstation.Server.Migrator.Comms/Program.cs @@ -72,7 +72,7 @@ static class Program assemblyName.Version!.Semver().ToString()); var serverUrl = new Uri($"http://localhost:{apiPort}"); - var clientFactory = new ServerClientFactory(productInfoHeaderValue.Product); + var clientFactory = new RestServerClientFactory(productInfoHeaderValue.Product); var TGS6Client = await clientFactory.CreateFromLogin( serverUrl, DefaultCredentials.AdminUserName, diff --git a/tools/Tgstation.Server.Migrator/Program.cs b/tools/Tgstation.Server.Migrator/Program.cs index 651c5e1852..86cabd419e 100644 --- a/tools/Tgstation.Server.Migrator/Program.cs +++ b/tools/Tgstation.Server.Migrator/Program.cs @@ -476,8 +476,8 @@ try var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(MaxWaitMinutes); var serverUrl = new Uri($"http://localhost:{configuredApiPort}"); - var clientFactory = new ServerClientFactory(productInfoHeaderValue.Product); - IServerClient TGS6Client; + var clientFactory = new RestServerClientFactory(productInfoHeaderValue.Product); + IRestServerClient TGS6Client; for (var I = 1; ; ++I) { try