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