mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-27 07:04:57 +01:00
Flesh out GraphQL client
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StrawberryShake;
|
||||
|
||||
using Tgstation.Server.Common.Extensions;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <inheritdoc cref="IAuthenticatedGraphQLServerClient" />
|
||||
sealed class AuthenticatedGraphQLServerClient : GraphQLServerClient, IAuthenticatedGraphQLServerClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ITransferClient TransferClient => restClient!.Transfer;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="Func{T, TResult}"/> that takes a bearer token as input and outputs a <see cref="ITransferClient"/> that uses it.
|
||||
/// </summary>
|
||||
readonly Func<string, IRestServerClient>? getRestClientForToken;
|
||||
|
||||
/// <summary>
|
||||
/// The current <see cref="IRestServerClient"/>.
|
||||
/// </summary>
|
||||
IRestServerClient? restClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticatedGraphQLServerClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="graphQLClient">The <see cref="IGraphQLClient"/> to use.</param>
|
||||
/// <param name="serviceProvider">The <see cref="IAsyncDisposable"/> to use.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
|
||||
/// <param name="restClient">The value of <see cref="restClient"/>.</param>
|
||||
public AuthenticatedGraphQLServerClient(
|
||||
IGraphQLClient graphQLClient,
|
||||
IAsyncDisposable serviceProvider,
|
||||
ILogger<GraphQLServerClient> logger,
|
||||
IRestServerClient restClient)
|
||||
: base(graphQLClient, serviceProvider, logger)
|
||||
{
|
||||
this.restClient = restClient ?? throw new ArgumentNullException(nameof(restClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticatedGraphQLServerClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="graphQLClient">The <see cref="IGraphQLClient"/> to use.</param>
|
||||
/// <param name="serviceProvider">The <see cref="IAsyncDisposable"/> to use.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
|
||||
/// <param name="setAuthenticationHeader">The <see cref="Action{T}"/> to call to set the async local <see cref="AuthenticationHeaderValue"/> for requests.</param>
|
||||
/// <param name="basicCredentialsHeader">The basic <see cref="AuthenticationHeaderValue"/> to use for reauthentication.</param>
|
||||
/// <param name="loginResult">The <see cref="ILoginResult"/> <see cref="IOperationResult{TResultData}"/> containing the initial JWT to use.</param>
|
||||
/// <param name="getRestClientForToken">The value of <see cref="getRestClientForToken"/>.</param>
|
||||
public AuthenticatedGraphQLServerClient(
|
||||
IGraphQLClient graphQLClient,
|
||||
IAsyncDisposable serviceProvider,
|
||||
ILogger<GraphQLServerClient> logger,
|
||||
Action<AuthenticationHeaderValue> setAuthenticationHeader,
|
||||
AuthenticationHeaderValue? basicCredentialsHeader,
|
||||
IOperationResult<ILoginResult> loginResult,
|
||||
Func<string, IRestServerClient> getRestClientForToken)
|
||||
: base(
|
||||
graphQLClient,
|
||||
serviceProvider,
|
||||
logger,
|
||||
setAuthenticationHeader,
|
||||
basicCredentialsHeader,
|
||||
loginResult)
|
||||
{
|
||||
this.getRestClientForToken = getRestClientForToken ?? throw new ArgumentNullException(nameof(getRestClientForToken));
|
||||
restClient = getRestClientForToken(loginResult.Data!.Login.Bearer!.EncodedToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed override ValueTask DisposeAsync()
|
||||
#pragma warning disable CA2012 // Use ValueTasks correctly
|
||||
=> ValueTaskExtensions.WhenAll(
|
||||
base.DisposeAsync(),
|
||||
restClient!.DisposeAsync());
|
||||
#pragma warning restore CA2012 // Use ValueTasks correctly
|
||||
|
||||
/// <inheritdoc />
|
||||
protected sealed override async ValueTask<AuthenticationHeaderValue> CreateUpdatedAuthenticationHeader(string bearer)
|
||||
{
|
||||
var baseTask = base.CreateUpdatedAuthenticationHeader(bearer);
|
||||
if (restClient != null)
|
||||
await restClient.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
if (getRestClientForToken != null)
|
||||
restClient = getRestClientForToken(bearer);
|
||||
|
||||
return await baseTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="Exception"/> thrown when automatic <see cref="IGraphQLServerClient"/> authentication fails.
|
||||
/// </summary>
|
||||
public sealed class AuthenticationException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ILogin_Login_Errors_ErrorMessageError"/>.
|
||||
/// </summary>
|
||||
public ILogin_Login_Errors_ErrorMessageError? ErrorMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">The value of <see cref="ErrorMessage"/>.</param>
|
||||
public AuthenticationException(ILogin_Login_Errors_ErrorMessageError errorMessage)
|
||||
: base(errorMessage?.Message)
|
||||
{
|
||||
ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
|
||||
/// </summary>
|
||||
public AuthenticationException()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The <see cref="Exception.Message"/>.</param>
|
||||
public AuthenticationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticationException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The <see cref="Exception.Message"/>.</param>
|
||||
/// <param name="innerException">The <see cref="Exception.InnerException"/>.</param>
|
||||
public AuthenticationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="DelegatingHandler"/> that applies the <see cref="AuthenticationHeaderValue"/>.
|
||||
/// </summary>
|
||||
sealed class AuthorizationMessageHandler : DelegatingHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="AsyncLocal{T}"/> <see cref="AuthenticationHeaderValue"/> to be applied.
|
||||
/// </summary>
|
||||
public static AsyncLocal<AuthenticationHeaderValue?> Header { get; } = new AsyncLocal<AuthenticationHeaderValue?>();
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="class"/> override for <see cref="Header"/>.
|
||||
/// </summary>
|
||||
readonly AuthenticationHeaderValue? headerOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthorizationMessageHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="headerOverride">The value of <see cref="headerOverride"/>.</param>
|
||||
public AuthorizationMessageHandler(AuthenticationHeaderValue? headerOverride)
|
||||
{
|
||||
this.headerOverride = headerOverride;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAuthHeader = headerOverride ?? Header.Value;
|
||||
if (currentAuthHeader != null)
|
||||
request.Headers.Authorization = currentAuthHeader;
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
query OAuthInformation {
|
||||
swarm {
|
||||
currentNode {
|
||||
gateway {
|
||||
information {
|
||||
oAuthProviderInfos {
|
||||
key
|
||||
value {
|
||||
clientId
|
||||
redirectUri
|
||||
serverUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query ServerVersion {
|
||||
swarm {
|
||||
currentNode {
|
||||
gateway {
|
||||
information {
|
||||
version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,35 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StrawberryShake;
|
||||
|
||||
using Tgstation.Server.Api;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <inheritdoc />
|
||||
class GraphQLServerClient : IGraphQLServerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// If the <see cref="GraphQLServerClient"/> was initially authenticated.
|
||||
/// </summary>
|
||||
[MemberNotNullWhen(true, nameof(setAuthenticationHeader))]
|
||||
[MemberNotNullWhen(true, nameof(bearerCredentialsTask))]
|
||||
bool Authenticated => basicCredentialsHeader != null;
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="GraphQLServerClient"/> supports reauthentication.
|
||||
/// </summary>
|
||||
[MemberNotNullWhen(true, nameof(bearerCredentialsHeaderTaskLock))]
|
||||
[MemberNotNullWhen(true, nameof(basicCredentialsHeader))]
|
||||
bool CanReauthenticate => basicCredentialsHeader != null;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IGraphQLClient"/> for the <see cref="GraphQLServerClient"/>.
|
||||
/// </summary>
|
||||
@@ -16,27 +40,232 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
/// </summary>
|
||||
readonly IAsyncDisposable serviceProvider;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ILogger"/> for the <see cref="GraphQLServerClient"/>.
|
||||
/// </summary>
|
||||
readonly ILogger<GraphQLServerClient> logger;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="Action{T}"/> which sets the <see cref="AuthenticationHeaderValue"/> for HTTP request in the current async context.
|
||||
/// </summary>
|
||||
readonly Action<AuthenticationHeaderValue>? setAuthenticationHeader;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="AuthenticationHeaderValue"/> containing the authenticated user's password credentials.
|
||||
/// </summary>
|
||||
readonly AuthenticationHeaderValue? basicCredentialsHeader;
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="lock"/> <see cref="object"/> used to synchronize access to <see cref="bearerCredentialsTask"/>.
|
||||
/// </summary>
|
||||
readonly object? bearerCredentialsHeaderTaskLock;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="Task{TResult}"/> resulting in a <see cref="ValueTuple{T1, T2}"/> containing the current <see cref="AuthenticationHeaderValue"/> for the <see cref="ApiHeaders.BearerAuthenticationScheme"/> and the <see cref="DateTime"/> it expires.
|
||||
/// </summary>
|
||||
Task<(AuthenticationHeaderValue Header, DateTime Exp)?>? bearerCredentialsTask;
|
||||
|
||||
/// <summary>
|
||||
/// Throws an <see cref="AuthenticationException"/> for a login error that previously occured outside of the current call context.
|
||||
/// </summary>
|
||||
/// <exception cref="AuthenticationException">Always thrown.</exception>
|
||||
[DoesNotReturn]
|
||||
static void ThrowOtherCallerFailedAuthException()
|
||||
=> throw new AuthenticationException("Another caller failed to authenticate!");
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a given <paramref name="operationResult"/> errored out with authentication errors.
|
||||
/// </summary>
|
||||
/// <param name="operationResult">The <see cref="IOperationResult"/>.</param>
|
||||
/// <returns><see langword="true"/> if <paramref name="operationResult"/> errored due to authentication issues, <see langword="false"/> otherwise.</returns>
|
||||
static bool IsAuthenticationError(IOperationResult operationResult)
|
||||
=> operationResult.Data == null
|
||||
&& operationResult.Errors.Any(
|
||||
error => error.Extensions?.TryGetValue(
|
||||
"code",
|
||||
out object? codeExtension) == true
|
||||
&& codeExtension is string codeExtensionString
|
||||
&& codeExtensionString == "AUTH_NOT_AUTHENTICATED");
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GraphQLServerClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="graphQLClient">The value of <see cref="graphQLClient"/>.</param>
|
||||
/// <param name="serviceProvider">The value of <see cref="serviceProvider"/>.</param>
|
||||
/// <param name="logger">The value of <see cref="logger"/>.</param>
|
||||
public GraphQLServerClient(
|
||||
IGraphQLClient graphQLClient,
|
||||
IAsyncDisposable serviceProvider)
|
||||
IAsyncDisposable serviceProvider,
|
||||
ILogger<GraphQLServerClient> logger)
|
||||
{
|
||||
this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient));
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GraphQLServerClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="graphQLClient">The value of <see cref="graphQLClient"/>.</param>
|
||||
/// <param name="serviceProvider">The value of <see cref="serviceProvider"/>.</param>
|
||||
/// <param name="logger">The value of <see cref="logger"/>.</param>
|
||||
/// <param name="setAuthenticationHeader">The value of <see cref="setAuthenticationHeader"/>.</param>
|
||||
/// <param name="basicCredentialsHeader">The value of <see cref="basicCredentialsHeader"/>.</param>
|
||||
/// <param name="loginResult">The <see cref="ILoginResult"/> <see cref="IOperationResult{TResultData}"/> containing the initial JWT to use.</param>
|
||||
protected GraphQLServerClient(
|
||||
IGraphQLClient graphQLClient,
|
||||
IAsyncDisposable serviceProvider,
|
||||
ILogger<GraphQLServerClient> logger,
|
||||
Action<AuthenticationHeaderValue> setAuthenticationHeader,
|
||||
AuthenticationHeaderValue? basicCredentialsHeader,
|
||||
IOperationResult<ILoginResult> loginResult)
|
||||
: this(graphQLClient, serviceProvider, logger)
|
||||
{
|
||||
this.setAuthenticationHeader = setAuthenticationHeader ?? throw new ArgumentNullException(nameof(setAuthenticationHeader));
|
||||
ArgumentNullException.ThrowIfNull(loginResult);
|
||||
this.basicCredentialsHeader = basicCredentialsHeader;
|
||||
|
||||
var task = CreateCredentialsTuple(loginResult);
|
||||
if (!task.IsCompleted)
|
||||
throw new InvalidOperationException($"Expected {nameof(CreateCredentialsTuple)} to not await in constructor!");
|
||||
|
||||
bearerCredentialsTask = Task.FromResult<(AuthenticationHeaderValue Header, DateTime Exp)?>(task.Result);
|
||||
|
||||
if (Authenticated)
|
||||
bearerCredentialsHeaderTaskLock = new object();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => serviceProvider.DisposeAsync();
|
||||
public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ValueTask RunQuery(Func<IGraphQLClient, ValueTask> queryExector)
|
||||
public ValueTask<IOperationResult<TResultData>> RunOperationAsync<TResultData>(Func<IGraphQLClient, ValueTask<IOperationResult<TResultData>>> queryExector, CancellationToken cancellationToken)
|
||||
where TResultData : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(queryExector);
|
||||
return queryExector(graphQLClient);
|
||||
return WrapAuthentication(queryExector, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IOperationResult<TResultData>> RunOperation<TResultData>(Func<IGraphQLClient, Task<IOperationResult<TResultData>>> queryExector, CancellationToken cancellationToken)
|
||||
where TResultData : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(queryExector);
|
||||
return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="AuthenticationHeaderValue"/> from a given <paramref name="bearer"/> token.
|
||||
/// </summary>
|
||||
/// <param name="bearer">The <see cref="ApiHeaders.BearerAuthenticationScheme"/> <see cref="string"/>.</param>
|
||||
/// <returns>A new <see cref="AuthenticationHeaderValue"/>.</returns>
|
||||
protected virtual ValueTask<AuthenticationHeaderValue> CreateUpdatedAuthenticationHeader(string bearer)
|
||||
=> ValueTask.FromResult(
|
||||
new AuthenticationHeaderValue(
|
||||
ApiHeaders.BearerAuthenticationScheme,
|
||||
bearer));
|
||||
|
||||
/// <summary>
|
||||
/// Executes a given <paramref name="operationExecutor"/>, potentially accounting for authentication issues.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResultData">The <see cref="Type"/> of the <see cref="IOperationResult{TResultData}"/>'s <see cref="IOperationResult{TResultData}.Data"/>.</typeparam>
|
||||
/// <param name="operationExecutor">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResultData"/> <see cref="IOperationResult{TResultData}"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IOperationResult{TResultData}"/>.</returns>
|
||||
async ValueTask<IOperationResult<TResultData>> WrapAuthentication<TResultData>(Func<IGraphQLClient, ValueTask<IOperationResult<TResultData>>> operationExecutor, CancellationToken cancellationToken)
|
||||
where TResultData : class
|
||||
{
|
||||
if (!Authenticated)
|
||||
return await operationExecutor(graphQLClient).ConfigureAwait(false);
|
||||
|
||||
var tuple = await bearerCredentialsTask.ConfigureAwait(false);
|
||||
if (!tuple.HasValue)
|
||||
ThrowOtherCallerFailedAuthException();
|
||||
|
||||
async ValueTask<AuthenticationHeaderValue> Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanReauthenticate)
|
||||
throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate.");
|
||||
|
||||
TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null;
|
||||
do
|
||||
{
|
||||
var bearerCredentialsTaskLocal = bearerCredentialsTask;
|
||||
if (!bearerCredentialsTaskLocal!.IsCompleted)
|
||||
{
|
||||
var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false);
|
||||
if (!currentTuple.HasValue)
|
||||
ThrowOtherCallerFailedAuthException();
|
||||
|
||||
return currentTuple.Value.Header;
|
||||
}
|
||||
|
||||
lock (bearerCredentialsHeaderTaskLock!)
|
||||
{
|
||||
if (bearerCredentialsTask == bearerCredentialsTaskLocal)
|
||||
{
|
||||
var result = bearerCredentialsTaskLocal.Result;
|
||||
if (result?.Header != currentToken)
|
||||
{
|
||||
if (!result.HasValue)
|
||||
ThrowOtherCallerFailedAuthException();
|
||||
|
||||
return result.Value.Header;
|
||||
}
|
||||
|
||||
tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>();
|
||||
bearerCredentialsTask = tcs.Task;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (tcs == null);
|
||||
|
||||
setAuthenticationHeader!(basicCredentialsHeader!);
|
||||
var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false);
|
||||
tcs.SetResult(tuple);
|
||||
return tuple.Header;
|
||||
}
|
||||
catch (AuthenticationException)
|
||||
{
|
||||
tcs.SetResult(null);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var (currentAuthHeader, expires) = tuple.Value;
|
||||
if (expires <= DateTimeOffset.UtcNow)
|
||||
currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
setAuthenticationHeader(currentAuthHeader);
|
||||
|
||||
var operationResult = await operationExecutor(graphQLClient);
|
||||
|
||||
if (IsAuthenticationError(operationResult))
|
||||
{
|
||||
currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false);
|
||||
setAuthenticationHeader(currentAuthHeader);
|
||||
return await operationExecutor(graphQLClient);
|
||||
}
|
||||
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to create the <see cref="ValueTuple{T1, T2}"/> for <see cref="bearerCredentialsTask"/>.
|
||||
/// </summary>
|
||||
/// <param name="loginResult">The <see cref="ILoginResult"/> <see cref="IOperationResult{TResultData}"/> to process.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new credentials <see cref="ValueTuple{T1, T2}"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown if the <paramref name="loginResult"/> errored.</exception>
|
||||
async ValueTask<(AuthenticationHeaderValue Header, DateTime Exp)> CreateCredentialsTuple(IOperationResult<ILoginResult> loginResult)
|
||||
{
|
||||
var bearer = loginResult.EnsureSuccess(logger);
|
||||
|
||||
var header = await CreateUpdatedAuthenticationHeader(bearer.EncodedToken);
|
||||
|
||||
return (Header: header, Exp: bearer.ValidTo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
using System;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StrawberryShake;
|
||||
|
||||
using Tgstation.Server.Api;
|
||||
using Tgstation.Server.Api.Models.Response;
|
||||
using Tgstation.Server.Client.GraphQL.Serializers;
|
||||
using Tgstation.Server.Common.Extensions;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
@@ -17,6 +24,45 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
/// </summary>
|
||||
readonly IRestServerClientFactory restClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a <see cref="ServiceProvider"/> for providing the <see cref="IGraphQLClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="host">The <see cref="Uri"/> of the target tgstation-server.</param>
|
||||
/// <param name="addAuthorizationHandler">If the <see cref="AuthorizationMessageHandler"/> should be configured.</param>
|
||||
/// <param name="headerOverride">The <see cref="AuthenticationHeaderValue"/> override for the <see cref="AuthorizationMessageHandler"/>.</param>
|
||||
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>, if any.</param>
|
||||
/// <returns>A new <see cref="ServiceProvider"/>.</returns>
|
||||
static ServiceProvider SetupServiceProvider(Uri host, bool addAuthorizationHandler, AuthenticationHeaderValue? headerOverride = null, OAuthProvider? oAuthProvider = null)
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
|
||||
var clientBuilder = serviceCollection
|
||||
.AddGraphQLClient();
|
||||
var graphQLEndpoint = new Uri(host, Routes.GraphQL);
|
||||
|
||||
clientBuilder.ConfigureHttpClient(
|
||||
client =>
|
||||
{
|
||||
client.BaseAddress = graphQLEndpoint;
|
||||
client.DefaultRequestHeaders.Add(ApiHeaders.ApiVersionHeader, $"Tgstation.Server.Api/{ApiHeaders.Version.Semver()}");
|
||||
if (oAuthProvider.HasValue)
|
||||
{
|
||||
client.DefaultRequestHeaders.Add(ApiHeaders.OAuthProviderHeader, oAuthProvider.ToString());
|
||||
}
|
||||
},
|
||||
clientBuilder =>
|
||||
{
|
||||
if (addAuthorizationHandler)
|
||||
clientBuilder.AddHttpMessageHandler(() => new AuthorizationMessageHandler(headerOverride));
|
||||
});
|
||||
|
||||
serviceCollection.AddSerializer<UnsignedIntSerializer>();
|
||||
serviceCollection.AddSerializer<SemverSerializer>();
|
||||
serviceCollection.AddSerializer<JwtSerializer>();
|
||||
|
||||
return serviceCollection.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GraphQLServerClientFactory"/> class.
|
||||
/// </summary>
|
||||
@@ -29,39 +75,138 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IAuthenticatedGraphQLServerClient> CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
var basicCredentials = new AuthenticationHeaderValue(
|
||||
ApiHeaders.BasicAuthenticationScheme,
|
||||
Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{username}:{password}")));
|
||||
|
||||
return CreateWithAuthCall(
|
||||
host,
|
||||
basicCredentials,
|
||||
null,
|
||||
attemptLoginRefresh,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IAuthenticatedGraphQLServerClient> CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
var oAuthCredentials = new AuthenticationHeaderValue(
|
||||
ApiHeaders.OAuthAuthenticationScheme,
|
||||
oAuthCode);
|
||||
|
||||
return CreateWithAuthCall(
|
||||
host,
|
||||
oAuthCredentials,
|
||||
oAuthProvider,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
var authenticationHeader = new AuthenticationHeaderValue(
|
||||
ApiHeaders.BearerAuthenticationScheme,
|
||||
token);
|
||||
|
||||
var serviceProvider = SetupServiceProvider(
|
||||
host,
|
||||
true,
|
||||
authenticationHeader);
|
||||
|
||||
return new AuthenticatedGraphQLServerClient(
|
||||
serviceProvider.GetRequiredService<IGraphQLClient>(),
|
||||
serviceProvider,
|
||||
serviceProvider.GetRequiredService<ILogger<GraphQLServerClient>>(),
|
||||
CreateAuthenticatedTransferClient(host, token));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<UnsignedIntSerializer>();
|
||||
serviceCollection.AddSerializer<SemverSerializer>();
|
||||
|
||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
var serviceProvider = SetupServiceProvider(host, false);
|
||||
|
||||
return new GraphQLServerClient(
|
||||
serviceProvider.GetRequiredService<IGraphQLClient>(),
|
||||
serviceProvider);
|
||||
serviceProvider,
|
||||
serviceProvider.GetRequiredService<ILogger<GraphQLServerClient>>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an <see cref="IAuthenticatedGraphQLServerClient"/> from a remote login call.
|
||||
/// </summary>
|
||||
/// <param name="host">The URL to access TGS.</param>
|
||||
/// <param name="initialCredentials">The initial <see cref="AuthenticationHeaderValue"/> to use to login.</param>
|
||||
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>, if any.</param>
|
||||
/// <param name="attemptLoginRefresh">If the client should attempt to renew its sessions with the <paramref name="initialCredentials"/>.</param>
|
||||
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown when authentication fails.</exception>
|
||||
async ValueTask<IAuthenticatedGraphQLServerClient> CreateWithAuthCall(
|
||||
Uri host,
|
||||
AuthenticationHeaderValue initialCredentials,
|
||||
OAuthProvider? oAuthProvider,
|
||||
bool attemptLoginRefresh,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceProvider = SetupServiceProvider(
|
||||
host,
|
||||
true,
|
||||
oAuthProvider: oAuthProvider);
|
||||
try
|
||||
{
|
||||
var client = serviceProvider.GetRequiredService<IGraphQLClient>();
|
||||
|
||||
IOperationResult<ILoginResult> result;
|
||||
|
||||
var previousAuthHeader = AuthorizationMessageHandler.Header.Value;
|
||||
AuthorizationMessageHandler.Header.Value = initialCredentials;
|
||||
try
|
||||
{
|
||||
result = await client.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AuthorizationMessageHandler.Header.Value = previousAuthHeader;
|
||||
}
|
||||
|
||||
var serverClient = new AuthenticatedGraphQLServerClient(
|
||||
client,
|
||||
serviceProvider,
|
||||
serviceProvider.GetRequiredService<ILogger<GraphQLServerClient>>(),
|
||||
newHeader => AuthorizationMessageHandler.Header.Value = newHeader,
|
||||
attemptLoginRefresh ? initialCredentials : null,
|
||||
result,
|
||||
bearer => CreateAuthenticatedTransferClient(host, bearer));
|
||||
|
||||
await Task.Yield();
|
||||
|
||||
return serverClient;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await serviceProvider.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="ITransferClient"/> for a given <paramref name="host"/> and <paramref name="bearer"/> token.
|
||||
/// </summary>
|
||||
/// <param name="host">The URL to access TGS.</param>
|
||||
/// <param name="bearer">The bearer token to access the API with.</param>
|
||||
/// <returns>A new <see cref="IRestServerClient"/>.</returns>
|
||||
IRestServerClient CreateAuthenticatedTransferClient(Uri host, string bearer)
|
||||
{
|
||||
var restClient = restClientFactory.CreateFromToken(
|
||||
host,
|
||||
new TokenResponse
|
||||
{
|
||||
Bearer = bearer,
|
||||
});
|
||||
|
||||
return restClient;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using StrawberryShake;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <summary>
|
||||
@@ -9,10 +12,25 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
public interface IGraphQLServerClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a given <paramref name="queryExector"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/>.
|
||||
/// Runs a given <paramref name="operationExecutor"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/> if reauthentication is required.
|
||||
/// </summary>
|
||||
/// <param name="queryExector">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask"/> representing the running operation.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
|
||||
ValueTask RunQuery(Func<IGraphQLClient, ValueTask> queryExector);
|
||||
/// <typeparam name="TResultData">The <see cref="Type"/> of the <see cref="IOperationResult{TResultData}"/>'s <see cref="IOperationResult{TResultData}.Data"/>.</typeparam>
|
||||
/// <param name="operationExecutor">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResultData"/> <see cref="IOperationResult{TResultData}"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IOperationResult{TResultData}"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown when automatic reauthentication fails.</exception>
|
||||
ValueTask<IOperationResult<TResultData>> RunOperationAsync<TResultData>(Func<IGraphQLClient, ValueTask<IOperationResult<TResultData>>> operationExecutor, CancellationToken cancellationToken)
|
||||
where TResultData : class;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a given <paramref name="operationExecutor"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/> if reauthentication is required.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResultData">The <see cref="Type"/> of the <see cref="IOperationResult{TResultData}"/>'s <see cref="IOperationResult{TResultData}.Data"/>.</typeparam>
|
||||
/// <param name="operationExecutor">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResultData"/> <see cref="IOperationResult{TResultData}"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IOperationResult{TResultData}"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown when automatic reauthentication fails.</exception>
|
||||
ValueTask<IOperationResult<TResultData>> RunOperation<TResultData>(Func<IGraphQLClient, Task<IOperationResult<TResultData>>> operationExecutor, CancellationToken cancellationToken)
|
||||
where TResultData : class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Tgstation.Server.Api.Models.Response;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <summary>
|
||||
@@ -24,9 +22,10 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
/// <param name="host">The URL to access TGS.</param>
|
||||
/// <param name="username">The username to for the <see cref="IGraphQLServerClient"/>.</param>
|
||||
/// <param name="password">The password for the <see cref="IGraphQLServerClient"/>.</param>
|
||||
/// <param name="attemptLoginRefresh">Attempt to refresh the received <see cref="TokenResponse"/> when it expires or becomes invalid. <paramref name="username"/> and <paramref name="password"/> will be stored in memory if this is <see langword="true"/>.</param>
|
||||
/// <param name="attemptLoginRefresh">Attempt to refresh the received bearer token when it expires or becomes invalid. <paramref name="username"/> and <paramref name="password"/> will be stored in memory if this is <see langword="true"/>.</param>
|
||||
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown when authentication fails.</exception>
|
||||
ValueTask<IAuthenticatedGraphQLServerClient> CreateFromLogin(
|
||||
Uri host,
|
||||
string username,
|
||||
@@ -42,6 +41,7 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>.</param>
|
||||
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown when authentication fails.</exception>
|
||||
ValueTask<IAuthenticatedGraphQLServerClient> CreateFromOAuth(
|
||||
Uri host,
|
||||
string oAuthCode,
|
||||
@@ -52,7 +52,7 @@ namespace Tgstation.Server.Client.GraphQL
|
||||
/// Create a <see cref="IRestServerClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="host">The URL to access TGS.</param>
|
||||
/// <param name="token">The <see cref="TokenResponse"/> to access the API with.</param>
|
||||
/// <param name="token">The bearer token to access the API with.</param>
|
||||
/// <returns>A new <see cref="IGraphQLServerClient"/>.</returns>
|
||||
IAuthenticatedGraphQLServerClient CreateFromToken(
|
||||
Uri host,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using StrawberryShake;
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions for <see cref="ILoginResult"/>.
|
||||
/// </summary>
|
||||
static class LoginResultExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Check a given <paramref name="loginResult"/> for errors.
|
||||
/// </summary>
|
||||
/// <param name="loginResult">The <see cref="IOperationResult{TResultData}"/> containing the <see cref="ILoginResult"/>.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> to write to.</param>
|
||||
/// <returns>The <see cref="JsonWebToken"/> from the successful <paramref name="loginResult"/>.</returns>
|
||||
/// <exception cref="AuthenticationException">Thrown when the <paramref name="loginResult"/> is errored.</exception>
|
||||
public static JsonWebToken EnsureSuccess(this IOperationResult<ILoginResult> loginResult, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loginResult);
|
||||
|
||||
try
|
||||
{
|
||||
loginResult.EnsureNoErrors();
|
||||
}
|
||||
catch (GraphQLClientException ex)
|
||||
{
|
||||
throw new AuthenticationException("Login attempt errored at the GraphQL level!", ex);
|
||||
}
|
||||
|
||||
var data = loginResult.Data!.Login;
|
||||
var errors = data.Errors;
|
||||
if (errors != null)
|
||||
{
|
||||
foreach (var error in errors)
|
||||
{
|
||||
if (error is ILogin_Login_Errors_ErrorMessageError errorMessageError)
|
||||
logger.LogError(
|
||||
"Authentication error ({code}): {message}{additionalData}",
|
||||
errorMessageError.ErrorCode?.ToString() ?? "No Code",
|
||||
errorMessageError.Message,
|
||||
errorMessageError.AdditionalData != null
|
||||
? $"{Environment.NewLine}{errorMessageError.AdditionalData}"
|
||||
: String.Empty);
|
||||
else
|
||||
logger.LogError(
|
||||
"Unknown authentication error: {error}",
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
var bearer = data.Bearer;
|
||||
if (bearer == null)
|
||||
{
|
||||
if (errors != null)
|
||||
{
|
||||
var errorMessage = errors.OfType<ILogin_Login_Errors_ErrorMessageError>().FirstOrDefault();
|
||||
if (errorMessage != null)
|
||||
throw new AuthenticationException(errorMessage);
|
||||
|
||||
throw new AuthenticationException($"Null bearer field and {errors.Count} non-ErrorMessage errors:{(errors.Count > 0 ? $"{Environment.NewLine}\t- {String.Join($"{Environment.NewLine}\t- ", errors)}" : String.Empty)}");
|
||||
}
|
||||
|
||||
throw new AuthenticationException($"Null bearer and error fields!");
|
||||
}
|
||||
|
||||
return bearer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
|
||||
using StrawberryShake.Serialization;
|
||||
|
||||
#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL.Serializers
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="JsonWebToken"/>s.
|
||||
/// </summary>
|
||||
sealed class JwtSerializer : ScalarSerializer<string, JsonWebToken>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JwtSerializer"/> class.
|
||||
/// </summary>
|
||||
public JwtSerializer()
|
||||
: base("Jwt")
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonWebToken Parse(string serializedValue)
|
||||
=> new(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue)));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string Format(JsonWebToken runtimeValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeValue);
|
||||
return runtimeValue.EncodedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ using StrawberryShake.Serialization;
|
||||
|
||||
using Tgstation.Server.Common.Extensions;
|
||||
|
||||
#pragma warning disable CA1812 // not detecting service provider usage
|
||||
#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL.Serializers
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="UInt32"/>s.
|
||||
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="Version"/>s.
|
||||
/// </summary>
|
||||
sealed class SemverSerializer : ScalarSerializer<string, Version>
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
using StrawberryShake.Serialization;
|
||||
|
||||
#pragma warning disable CA1812 // not detecting service provider usage
|
||||
#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql
|
||||
|
||||
namespace Tgstation.Server.Client.GraphQL.Serializers
|
||||
{
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>$(TgsFrameworkVersion)</TargetFramework>
|
||||
<Version>$(TgsApiVersion)</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- GraphQL connector and code generator -->
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
|
||||
<PackageReference Include="StrawberryShake.Server" Version="13.9.12" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ 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")
|
||||
extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken")
|
||||
|
||||
@@ -273,7 +273,11 @@ namespace Tgstation.Server.Host.Authority
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable CA1502
|
||||
#pragma warning disable CA1506 // TODO: Decomplexify
|
||||
public async ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
|
||||
#pragma warning restore CA1502
|
||||
#pragma warning restore CA1506
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(model);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using HotChocolate;
|
||||
using Tgstation.Server.Api.Models.Response;
|
||||
using Tgstation.Server.Host.GraphQL.Types.Scalars;
|
||||
using Tgstation.Server.Host.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
|
||||
@@ -12,6 +13,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
|
||||
/// <summary>
|
||||
/// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time.
|
||||
/// </summary>
|
||||
[GraphQLType<JwtType>]
|
||||
public required string Bearer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
using HotChocolate.Language;
|
||||
using HotChocolate.Types;
|
||||
|
||||
namespace Tgstation.Server.Host.GraphQL.Types.Scalars
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="ScalarType{TRuntimeType, TLiteral}"/> for encoded JSON Web Tokens.
|
||||
/// </summary>
|
||||
public sealed class JwtType : ScalarType<string, StringValueNode>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JwtType"/> class.
|
||||
/// </summary>
|
||||
public JwtType()
|
||||
: base("Jwt")
|
||||
{
|
||||
Description = "Represents an encoded JSON Web Token";
|
||||
SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IValueNode ParseResult(object? resultValue)
|
||||
=> ParseValue(resultValue);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ParseLiteral(StringValueNode valueSyntax)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(valueSyntax);
|
||||
return valueSyntax.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override StringValueNode ParseValue(string runtimeValue)
|
||||
=> new(runtimeValue);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ namespace Tgstation.Server.Host.GraphQL.Types.Scalars
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override StringValueNode ParseValue(Version runtimeValue)
|
||||
=> new StringValueNode(runtimeValue.Semver().ToString());
|
||||
=> new(runtimeValue.Semver().ToString());
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool IsInstanceOfType(StringValueNode valueSyntax)
|
||||
|
||||
@@ -1,48 +1,57 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
using StrawberryShake;
|
||||
|
||||
using Tgstation.Server.Client;
|
||||
using Tgstation.Server.Client.GraphQL;
|
||||
using Tgstation.Server.Common.Extensions;
|
||||
|
||||
namespace Tgstation.Server.Tests.Live
|
||||
{
|
||||
sealed class MultiServerClient
|
||||
sealed class MultiServerClient : IAsyncDisposable
|
||||
{
|
||||
readonly IRestServerClient restServerClient;
|
||||
readonly IGraphQLServerClient graphQLServerClient;
|
||||
|
||||
readonly bool useGraphQL;
|
||||
public IRestServerClient RestClient { get; }
|
||||
public IGraphQLServerClient GraphQLClient { get; }
|
||||
|
||||
public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient)
|
||||
{
|
||||
this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient));
|
||||
this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient));
|
||||
this.useGraphQL = Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result;
|
||||
RestClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient));
|
||||
GraphQLClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient));
|
||||
}
|
||||
|
||||
public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
=> ValueTaskExtensions.WhenAll(
|
||||
RestClient.DisposeAsync(),
|
||||
GraphQLClient.DisposeAsync());
|
||||
|
||||
public ValueTask Execute(
|
||||
Func<IRestServerClient, ValueTask> restAction,
|
||||
Func<IGraphQLClient, ValueTask> graphQLAction)
|
||||
Func<IGraphQLServerClient, ValueTask> graphQLAction)
|
||||
{
|
||||
if (useGraphQL)
|
||||
return graphQLServerClient.RunQuery(graphQLAction);
|
||||
if (UseGraphQL)
|
||||
return graphQLAction(GraphQLClient);
|
||||
|
||||
return restAction(restServerClient);
|
||||
return restAction(RestClient);
|
||||
}
|
||||
|
||||
public async ValueTask ExecuteReadOnlyConfirmEquivalence<TRestResult, TGraphQLResult>(
|
||||
Func<IRestServerClient, ValueTask<TRestResult>> restAction,
|
||||
Func<IGraphQLClient, ValueTask<TGraphQLResult>> graphQLAction,
|
||||
Func<TRestResult, TGraphQLResult, bool> comparison)
|
||||
Func<IGraphQLClient, Task<IOperationResult<TGraphQLResult>>> graphQLAction,
|
||||
Func<TRestResult, TGraphQLResult, bool> comparison,
|
||||
CancellationToken cancellationToken)
|
||||
where TGraphQLResult : class
|
||||
{
|
||||
var restTask = restAction(this.restServerClient);
|
||||
TGraphQLResult graphQLResult = default;
|
||||
await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient));
|
||||
var restTask = restAction(RestClient);
|
||||
var graphQLResult = await GraphQLClient.RunOperation(graphQLAction, cancellationToken);
|
||||
|
||||
var restResult = await restTask;
|
||||
Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!");
|
||||
Assert.IsTrue(comparison(restResult, graphQLResult.Data), "REST/GraphQL results differ!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,11 +463,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
static async Task TestGraphQLLogin(IRestServerClientFactory clientFactory, IRestServerClient restClient, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var gqlClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(restClient.Url);
|
||||
IOperationResult<ILoginResult> result = null;
|
||||
await gqlClient.RunQuery(async client =>
|
||||
{
|
||||
result = await client.Login.ExecuteAsync(cancellationToken);
|
||||
});
|
||||
var result = await gqlClient.RunOperation(client => client.Login.ExecuteAsync(cancellationToken), cancellationToken);
|
||||
|
||||
Assert.IsNotNull(result.Data);
|
||||
Assert.IsNull(result.Data.Login.Bearer);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -27,6 +27,8 @@ using Newtonsoft.Json;
|
||||
|
||||
using Npgsql;
|
||||
|
||||
using StrawberryShake;
|
||||
|
||||
using Tgstation.Server.Api;
|
||||
using Tgstation.Server.Api.Extensions;
|
||||
using Tgstation.Server.Api.Models;
|
||||
@@ -73,7 +75,14 @@ namespace Tgstation.Server.Tests.Live
|
||||
_ = mainDMPort.Value;
|
||||
}
|
||||
|
||||
readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
|
||||
readonly RestServerClientFactory restClientFactory;
|
||||
readonly GraphQLServerClientFactory graphQLClientFactory;
|
||||
|
||||
public TestLiveServer()
|
||||
{
|
||||
restClientFactory = new(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
|
||||
graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory);
|
||||
}
|
||||
|
||||
public static List<System.Diagnostics.Process> GetEngineServerProcessesOnPort(EngineType engineType, ushort? port)
|
||||
{
|
||||
@@ -295,7 +304,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
|
||||
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.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.RestClient.Token.Bearer);
|
||||
request.Headers.Add(ApiHeaders.OAuthProviderHeader, Api.Models.OAuthProvider.GitHub.ToString());
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
@@ -306,7 +315,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
//attempt to update to stable
|
||||
var responseModel = await TestWithoutAndWithPermission(
|
||||
() => adminClient.Administration.Update(
|
||||
() => adminClient.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion,
|
||||
@@ -314,7 +323,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
},
|
||||
null,
|
||||
cancellationToken),
|
||||
adminClient,
|
||||
adminClient.RestClient,
|
||||
AdministrationRights.ChangeVersion);
|
||||
|
||||
Assert.IsNotNull(responseModel);
|
||||
@@ -323,7 +332,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
try
|
||||
{
|
||||
var serverInfoTask = adminClient.ServerInformation(cancellationToken).AsTask();
|
||||
var serverInfoTask = adminClient.RestClient.ServerInformation(cancellationToken).AsTask();
|
||||
var completedTask = await Task.WhenAny(serverTask, serverInfoTask);
|
||||
if (completedTask == serverInfoTask)
|
||||
{
|
||||
@@ -376,7 +385,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
var downloadStream = await download.GetResult(cancellationToken);
|
||||
var responseModel = await TestWithoutAndWithPermission(
|
||||
() => adminClient.Administration.Update(
|
||||
() => adminClient.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion,
|
||||
@@ -384,7 +393,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
},
|
||||
downloadStream,
|
||||
cancellationToken),
|
||||
adminClient,
|
||||
adminClient.RestClient,
|
||||
AdministrationRights.UploadVersion);
|
||||
|
||||
Assert.IsNotNull(responseModel);
|
||||
@@ -424,7 +433,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
var testUpdateVersion = new Version(5, 11, 20);
|
||||
await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
|
||||
await ApiAssert.ThrowsException<ConflictException, ServerUpdateResponse>(
|
||||
() => adminClient.Administration.Update(
|
||||
() => adminClient.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = testUpdateVersion
|
||||
@@ -474,7 +483,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
{
|
||||
await using var controllerClient = await CreateAdminClient(controller.ApiUrl, cancellationToken);
|
||||
|
||||
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
|
||||
var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
|
||||
|
||||
static void CheckInfo(ServerInformationResponse serverInformation)
|
||||
{
|
||||
@@ -489,7 +498,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
CheckInfo(controllerInfo);
|
||||
|
||||
// test update
|
||||
var responseModel = await controllerClient.Administration.Update(
|
||||
var responseModel = await controllerClient.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion
|
||||
@@ -577,7 +586,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken);
|
||||
await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken);
|
||||
|
||||
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
|
||||
var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
|
||||
|
||||
async Task WaitForSwarmServerUpdate()
|
||||
{
|
||||
@@ -585,7 +594,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
do
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
serverInformation = await node1Client.ServerInformation(cancellationToken);
|
||||
serverInformation = await node1Client.RestClient.ServerInformation(cancellationToken);
|
||||
}
|
||||
while (serverInformation.SwarmServers.Count == 1);
|
||||
}
|
||||
@@ -618,13 +627,13 @@ namespace Tgstation.Server.Tests.Live
|
||||
WaitForSwarmServerUpdate(),
|
||||
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
|
||||
|
||||
var node2Info = await node2Client.ServerInformation(cancellationToken);
|
||||
var node1Info = await node1Client.ServerInformation(cancellationToken);
|
||||
var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken);
|
||||
var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken);
|
||||
CheckInfo(node1Info);
|
||||
CheckInfo(node2Info);
|
||||
|
||||
// check user info is shared
|
||||
var newUser = await node2Client.Users.Create(new UserCreateRequest
|
||||
var newUser = await node2Client.RestClient.Users.Create(new UserCreateRequest
|
||||
{
|
||||
Name = "asdf",
|
||||
Password = "asdfasdfasdfasdf",
|
||||
@@ -635,20 +644,20 @@ namespace Tgstation.Server.Tests.Live
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
var node1User = await node1Client.Users.GetId(newUser, cancellationToken);
|
||||
var node1User = await node1Client.RestClient.Users.GetId(newUser, cancellationToken);
|
||||
Assert.AreEqual(newUser.Name, node1User.Name);
|
||||
Assert.AreEqual(newUser.Enabled, node1User.Enabled);
|
||||
|
||||
await using var controllerUserClient = await clientFactory.CreateFromLogin(
|
||||
await using var controllerUserClient = await restClientFactory.CreateFromLogin(
|
||||
controllerAddress,
|
||||
newUser.Name,
|
||||
"asdfasdfasdfasdf");
|
||||
|
||||
await using var node1TokenCopiedClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
|
||||
await using var node1TokenCopiedClient = restClientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
|
||||
await node1TokenCopiedClient.Administration.Read(false, cancellationToken);
|
||||
|
||||
// check instance info is not shared
|
||||
var controllerInstance = await controllerClient.Instances.CreateOrAttach(
|
||||
var controllerInstance = await controllerClient.RestClient.Instances.CreateOrAttach(
|
||||
new InstanceCreateRequest
|
||||
{
|
||||
Name = "ControllerInstance",
|
||||
@@ -656,27 +665,27 @@ namespace Tgstation.Server.Tests.Live
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var node2Instance = await node2Client.Instances.CreateOrAttach(
|
||||
var node2Instance = await node2Client.RestClient.Instances.CreateOrAttach(
|
||||
new InstanceCreateRequest
|
||||
{
|
||||
Name = "Node2Instance",
|
||||
Path = Path.Combine(node2.Directory, "Node2Instance")
|
||||
},
|
||||
cancellationToken);
|
||||
var node2InstanceList = await node2Client.Instances.List(null, cancellationToken);
|
||||
var node2InstanceList = await node2Client.RestClient.Instances.List(null, cancellationToken);
|
||||
Assert.AreEqual(1, node2InstanceList.Count);
|
||||
Assert.AreEqual(node2Instance.Id, node2InstanceList[0].Id);
|
||||
Assert.IsNotNull(await node2Client.Instances.GetId(node2Instance, cancellationToken));
|
||||
var controllerInstanceList = await controllerClient.Instances.List(null, cancellationToken);
|
||||
Assert.IsNotNull(await node2Client.RestClient.Instances.GetId(node2Instance, cancellationToken));
|
||||
var controllerInstanceList = await controllerClient.RestClient.Instances.List(null, cancellationToken);
|
||||
Assert.AreEqual(1, controllerInstanceList.Count);
|
||||
Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id);
|
||||
Assert.IsNotNull(await controllerClient.Instances.GetId(controllerInstance, cancellationToken));
|
||||
Assert.IsNotNull(await controllerClient.RestClient.Instances.GetId(controllerInstance, cancellationToken));
|
||||
|
||||
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => controllerClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
|
||||
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
|
||||
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => controllerClient.RestClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
|
||||
await ApiAssert.ThrowsException<ConflictException, InstanceResponse>(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent);
|
||||
|
||||
// test update
|
||||
await node1Client.Administration.Update(
|
||||
await node1Client.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion
|
||||
@@ -717,7 +726,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken);
|
||||
await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken);
|
||||
|
||||
await ApiAssert.ThrowsException<ApiConflictException, ServerUpdateResponse>(() => controllerClient2.Administration.Update(
|
||||
await ApiAssert.ThrowsException<ApiConflictException, ServerUpdateResponse>(() => controllerClient2.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion
|
||||
@@ -738,7 +747,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
do
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
serverInformation = await node2Client2.ServerInformation(cancellationToken);
|
||||
serverInformation = await node2Client2.RestClient.ServerInformation(cancellationToken);
|
||||
}
|
||||
while (serverInformation.SwarmServers.Count == 1);
|
||||
}
|
||||
@@ -747,8 +756,8 @@ namespace Tgstation.Server.Tests.Live
|
||||
WaitForSwarmServerUpdate2(),
|
||||
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
|
||||
|
||||
var node2Info2 = await node2Client2.ServerInformation(cancellationToken);
|
||||
var node1Info2 = await node1Client2.ServerInformation(cancellationToken);
|
||||
var node2Info2 = await node2Client2.RestClient.ServerInformation(cancellationToken);
|
||||
var node1Info2 = await node1Client2.RestClient.ServerInformation(cancellationToken);
|
||||
CheckInfo(node1Info2);
|
||||
CheckInfo(node2Info2);
|
||||
|
||||
@@ -765,7 +774,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
gitHubToken);
|
||||
|
||||
var downloadStream = await download.GetResult(cancellationToken);
|
||||
var responseModel = await controllerClient2.Administration.Update(
|
||||
var responseModel = await controllerClient2.RestClient.Administration.Update(
|
||||
new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion,
|
||||
@@ -848,7 +857,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken);
|
||||
await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken);
|
||||
|
||||
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
|
||||
var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
|
||||
|
||||
async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount)
|
||||
{
|
||||
@@ -889,11 +898,11 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
// wait a few minutes for the updated server list to dispatch
|
||||
await Task.WhenAny(
|
||||
WaitForSwarmServerUpdate(node1Client, 1),
|
||||
WaitForSwarmServerUpdate(node1Client.RestClient, 1),
|
||||
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
|
||||
|
||||
var node2Info = await node2Client.ServerInformation(cancellationToken);
|
||||
var node1Info = await node1Client.ServerInformation(cancellationToken);
|
||||
var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken);
|
||||
var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken);
|
||||
CheckInfo(node1Info);
|
||||
CheckInfo(node2Info);
|
||||
|
||||
@@ -905,21 +914,21 @@ namespace Tgstation.Server.Tests.Live
|
||||
Assert.IsTrue(node1Task.IsCompleted);
|
||||
|
||||
// it should unregister
|
||||
controllerInfo = await controllerClient.ServerInformation(cancellationToken);
|
||||
controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken);
|
||||
Assert.AreEqual(2, controllerInfo.SwarmServers.Count);
|
||||
Assert.IsFalse(controllerInfo.SwarmServers.Any(x => x.Identifier == "node1"));
|
||||
|
||||
// wait a few minutes for the updated server list to dispatch
|
||||
await Task.WhenAny(
|
||||
WaitForSwarmServerUpdate(node2Client, 3),
|
||||
WaitForSwarmServerUpdate(node2Client.RestClient, 3),
|
||||
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
|
||||
|
||||
node2Info = await node2Client.ServerInformation(cancellationToken);
|
||||
node2Info = await node2Client.RestClient.ServerInformation(cancellationToken);
|
||||
Assert.AreEqual(2, node2Info.SwarmServers.Count);
|
||||
Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1"));
|
||||
|
||||
// restart the controller
|
||||
await controllerClient.Administration.Restart(cancellationToken);
|
||||
await controllerClient.RestClient.Administration.Restart(cancellationToken);
|
||||
await Task.WhenAny(
|
||||
controllerTask,
|
||||
Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
|
||||
@@ -930,10 +939,10 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
// node 2 should reconnect once it's health check triggers
|
||||
await Task.WhenAny(
|
||||
WaitForSwarmServerUpdate(controllerClient2, 1),
|
||||
WaitForSwarmServerUpdate(controllerClient2.RestClient, 1),
|
||||
Task.Delay(TimeSpan.FromMinutes(5), cancellationToken));
|
||||
|
||||
controllerInfo = await controllerClient2.ServerInformation(cancellationToken);
|
||||
controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken);
|
||||
Assert.AreEqual(2, controllerInfo.SwarmServers.Count);
|
||||
Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2"));
|
||||
|
||||
@@ -941,20 +950,20 @@ namespace Tgstation.Server.Tests.Live
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
|
||||
|
||||
// restart node2
|
||||
await node2Client.Administration.Restart(cancellationToken);
|
||||
await node2Client.RestClient.Administration.Restart(cancellationToken);
|
||||
await Task.WhenAny(
|
||||
node2Task,
|
||||
Task.Delay(TimeSpan.FromMinutes(1)));
|
||||
Assert.IsTrue(node1Task.IsCompleted);
|
||||
|
||||
// should have unregistered
|
||||
controllerInfo = await controllerClient2.ServerInformation(cancellationToken);
|
||||
controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken);
|
||||
Assert.AreEqual(1, controllerInfo.SwarmServers.Count);
|
||||
Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2"));
|
||||
|
||||
// update should fail
|
||||
await ApiAssert.ThrowsException<ApiConflictException, ServerUpdateResponse>(
|
||||
() => controllerClient2.Administration.Update(new ServerUpdateRequest
|
||||
() => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest
|
||||
{
|
||||
NewVersion = TestUpdateVersion
|
||||
},
|
||||
@@ -967,10 +976,10 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
// should re-register
|
||||
await Task.WhenAny(
|
||||
WaitForSwarmServerUpdate(node2Client2, 1),
|
||||
WaitForSwarmServerUpdate(node2Client2.RestClient, 1),
|
||||
Task.Delay(TimeSpan.FromMinutes(4), cancellationToken));
|
||||
|
||||
node2Info = await node2Client2.ServerInformation(cancellationToken);
|
||||
node2Info = await node2Client2.RestClient.ServerInformation(cancellationToken);
|
||||
Assert.AreEqual(2, node2Info.SwarmServers.Count);
|
||||
Assert.IsNotNull(node2Info.SwarmServers.SingleOrDefault(x => x.Identifier == "controller"));
|
||||
}
|
||||
@@ -1021,10 +1030,10 @@ namespace Tgstation.Server.Tests.Live
|
||||
try
|
||||
{
|
||||
await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
|
||||
|
||||
var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory);
|
||||
var restAdminClient = adminClient.RestClient;
|
||||
var instanceManagerTest = new InstanceManagerTest(restAdminClient, server.Directory);
|
||||
var instance = await instanceManagerTest.CreateTestInstance("TgTestInstance", cancellationToken);
|
||||
var instanceClient = adminClient.Instances.CreateClient(instance);
|
||||
var instanceClient = restAdminClient.Instances.CreateClient(instance);
|
||||
|
||||
|
||||
var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest
|
||||
@@ -1327,34 +1336,48 @@ namespace Tgstation.Server.Tests.Live
|
||||
var serverTask = server.Run(cancellationToken).AsTask();
|
||||
|
||||
var fileDownloader = ((Host.Server)server.RealServer).Host.Services.GetRequiredService<Host.IO.IFileDownloader>();
|
||||
var graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory);
|
||||
try
|
||||
{
|
||||
Api.Models.Instance instance;
|
||||
long initialStaged, initialActive, initialSessionId;
|
||||
|
||||
await using var firstAdminClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
|
||||
await using (var tokenOnlyClient = clientFactory.CreateFromToken(server.RootUrl, firstAdminClient.Token))
|
||||
await using var firstAdminMultiClient = await CreateAdminClient(server.ApiUrl, cancellationToken);
|
||||
|
||||
var firstAdminRestClient = firstAdminMultiClient.RestClient;
|
||||
await using (var tokenOnlyRestClient = restClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token))
|
||||
{
|
||||
// regression test for password change issue
|
||||
var currentUser = await tokenOnlyClient.Users.Read(cancellationToken);
|
||||
var updatedUser = await tokenOnlyClient.Users.Update(new UserUpdateRequest
|
||||
var currentUser = await tokenOnlyRestClient.Users.Read(cancellationToken);
|
||||
var updatedUser = await tokenOnlyRestClient.Users.Update(new UserUpdateRequest
|
||||
{
|
||||
Id = currentUser.Id,
|
||||
Password = DefaultCredentials.DefaultAdminUserPassword,
|
||||
}, cancellationToken);
|
||||
|
||||
await ApiAssert.ThrowsException<UnauthorizedException, UserResponse>(() => tokenOnlyClient.Users.Read(cancellationToken), null);
|
||||
await ApiAssert.ThrowsException<UnauthorizedException, UserResponse>(() => tokenOnlyRestClient.Users.Read(cancellationToken), null);
|
||||
}
|
||||
|
||||
await using (var tokenOnlyGraphQLClient = graphQLClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token.Bearer))
|
||||
{
|
||||
// just testing auth works the same here
|
||||
var result = await tokenOnlyGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken);
|
||||
Assert.IsTrue(result.IsSuccessResult());
|
||||
}
|
||||
|
||||
// basic graphql test, to be used everywhere eventually
|
||||
await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl))
|
||||
await using (var unauthenticatedGraphQLClient = graphQLClientFactory.CreateUnauthenticated(server.RootUrl))
|
||||
{
|
||||
// test getting server info
|
||||
var multiClient = new MultiServerClient(firstAdminClient, graphQLClient);
|
||||
// check auth works as expected
|
||||
var result = await unauthenticatedGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken);
|
||||
Assert.IsTrue(result.IsErrorResult());
|
||||
|
||||
await multiClient.ExecuteReadOnlyConfirmEquivalence(
|
||||
// test getting server info
|
||||
var unAuthedMultiClient = new MultiServerClient(firstAdminRestClient, unauthenticatedGraphQLClient);
|
||||
|
||||
await unAuthedMultiClient.ExecuteReadOnlyConfirmEquivalence(
|
||||
restClient => restClient.ServerInformation(cancellationToken),
|
||||
async gqlClient => (await gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken)).Data,
|
||||
gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken),
|
||||
(restServerInfo, gqlServerInfo) => restServerInfo.ApiVersion.Major == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MajorApiVersion
|
||||
&& (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos
|
||||
|| restServerInfo.OAuthProviderInfos.All(kvp =>
|
||||
@@ -1364,7 +1387,8 @@ namespace Tgstation.Server.Tests.Live
|
||||
&& info.Value.ServerUrl == kvp.Value.ServerUrl
|
||||
&& info.Value.ClientId == kvp.Value.ClientId
|
||||
&& info.Value.RedirectUri == kvp.Value.RedirectUri;
|
||||
})));
|
||||
})),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
async ValueTask<IRestServerClient> CreateUserWithNoInstancePerms()
|
||||
@@ -1380,13 +1404,13 @@ namespace Tgstation.Server.Tests.Live
|
||||
}
|
||||
};
|
||||
|
||||
var user = await firstAdminClient.Users.Create(createRequest, cancellationToken);
|
||||
var user = await firstAdminRestClient.Users.Create(createRequest, cancellationToken);
|
||||
Assert.IsTrue(user.Enabled);
|
||||
|
||||
return await clientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken);
|
||||
return await restClientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms());
|
||||
var jobsHubTest = new JobsHubTests(firstAdminRestClient, await CreateUserWithNoInstancePerms());
|
||||
Task jobsHubTestTask;
|
||||
{
|
||||
if (server.DumpOpenApiSpecpath)
|
||||
@@ -1422,11 +1446,11 @@ namespace Tgstation.Server.Tests.Live
|
||||
if (!openDreamOnly)
|
||||
{
|
||||
jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task<Task>
|
||||
var rootTest = FailFast(RawRequestTests.Run(clientFactory, firstAdminClient, cancellationToken));
|
||||
var adminTest = FailFast(new AdministrationTest(firstAdminClient.Administration).Run(cancellationToken));
|
||||
var usersTest = FailFast(new UsersTest(firstAdminClient).Run(cancellationToken));
|
||||
var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken));
|
||||
var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken));
|
||||
var usersTest = FailFast(new UsersTest(firstAdminRestClient).Run(cancellationToken));
|
||||
|
||||
var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory);
|
||||
var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory);
|
||||
var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken);
|
||||
var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken);
|
||||
var byondApiCompatInstanceTask = instanceManagerTest.CreateTestInstance("BCAPITestsInstance", cancellationToken);
|
||||
@@ -1436,7 +1460,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
var byondApiCompatInstance = await byondApiCompatInstanceTask;
|
||||
var instancesTest = FailFast(instanceManagerTest.RunPreTest(cancellationToken));
|
||||
Assert.IsTrue(Directory.Exists(instance.Path));
|
||||
instanceClient = firstAdminClient.Instances.CreateClient(instance);
|
||||
instanceClient = firstAdminRestClient.Instances.CreateClient(instance);
|
||||
|
||||
Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path));
|
||||
nonInstanceTests = Task.WhenAll(instancesTest, adminTest, rootTest, usersTest);
|
||||
@@ -1447,13 +1471,13 @@ namespace Tgstation.Server.Tests.Live
|
||||
nonInstanceTests = Task.CompletedTask;
|
||||
jobsHubTestTask = null;
|
||||
instance = null;
|
||||
var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory);
|
||||
var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory);
|
||||
var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken);
|
||||
odInstance = await odInstanceTask;
|
||||
}
|
||||
|
||||
var instanceTest = new InstanceTest(
|
||||
firstAdminClient.Instances,
|
||||
firstAdminRestClient.Instances,
|
||||
fileDownloader,
|
||||
GetInstanceManager(),
|
||||
(ushort)server.ApiUrl.Port);
|
||||
@@ -1482,7 +1506,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
.RunCompatTests(
|
||||
await edgeODVersionTask,
|
||||
server.OpenDreamUrl,
|
||||
firstAdminClient.Instances.CreateClient(odInstance),
|
||||
firstAdminRestClient.Instances.CreateClient(odInstance),
|
||||
odDMPort.Value,
|
||||
odDDPort.Value,
|
||||
server.HighPriorityDreamDaemon,
|
||||
@@ -1509,7 +1533,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
: new Version(512, 1451) // http://www.byond.com/forum/?forum=5&command=search&scope=local&text=resolved%3a512.1451
|
||||
},
|
||||
server.OpenDreamUrl,
|
||||
firstAdminClient.Instances.CreateClient(compatInstance),
|
||||
firstAdminRestClient.Instances.CreateClient(compatInstance),
|
||||
compatDMPort.Value,
|
||||
compatDDPort.Value,
|
||||
server.HighPriorityDreamDaemon,
|
||||
@@ -1550,7 +1574,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
initialSessionId = dd.SessionId.Value;
|
||||
|
||||
jobsHubTest.ExpectShutdown();
|
||||
await firstAdminClient.Administration.Restart(cancellationToken);
|
||||
await firstAdminRestClient.Administration.Restart(cancellationToken);
|
||||
}
|
||||
|
||||
await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
|
||||
@@ -1599,8 +1623,10 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
// chat bot start and DD reattach test
|
||||
serverTask = server.Run(cancellationToken).AsTask();
|
||||
await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
|
||||
await using (var multiClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
|
||||
{
|
||||
var adminClient = multiClient.RestClient;
|
||||
|
||||
await jobsHubTest.WaitForReconnect(cancellationToken);
|
||||
var instanceClient = adminClient.Instances.CreateClient(instance);
|
||||
|
||||
@@ -1704,8 +1730,9 @@ namespace Tgstation.Server.Tests.Live
|
||||
var edgeVersion = await EngineTest.GetEdgeVersion(EngineType.Byond, fileDownloader, cancellationToken);
|
||||
await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
|
||||
{
|
||||
var restAdminClient = adminClient.RestClient;
|
||||
await jobsHubTest.WaitForReconnect(cancellationToken);
|
||||
var instanceClient = adminClient.Instances.CreateClient(instance);
|
||||
var instanceClient = restAdminClient.Instances.CreateClient(instance);
|
||||
await WaitForInitialJobs(instanceClient);
|
||||
|
||||
var dd = await instanceClient.DreamDaemon.Read(cancellationToken);
|
||||
@@ -1737,7 +1764,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
expectedStaged = compileJob.Id.Value;
|
||||
|
||||
jobsHubTest.ExpectShutdown();
|
||||
await adminClient.Administration.Restart(cancellationToken);
|
||||
await restAdminClient.Administration.Restart(cancellationToken);
|
||||
}
|
||||
|
||||
await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
|
||||
@@ -1747,8 +1774,9 @@ namespace Tgstation.Server.Tests.Live
|
||||
serverTask = server.Run(cancellationToken).AsTask();
|
||||
await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken))
|
||||
{
|
||||
var restAdminClient = adminClient.RestClient;
|
||||
await jobsHubTest.WaitForReconnect(cancellationToken);
|
||||
var instanceClient = adminClient.Instances.CreateClient(instance);
|
||||
var instanceClient = restAdminClient.Instances.CreateClient(instance);
|
||||
await WaitForInitialJobs(instanceClient);
|
||||
|
||||
var currentDD = await instanceClient.DreamDaemon.Read(cancellationToken);
|
||||
@@ -1763,7 +1791,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
|
||||
await using var repoTestObj = new RepositoryTest(instanceClient.Repository, instanceClient.Jobs);
|
||||
var repoTest = repoTestObj.RunPostTest(cancellationToken);
|
||||
await using var chatTestObj = new ChatTest(instanceClient.ChatBots, adminClient.Instances, instanceClient.Jobs, instance);
|
||||
await using var chatTestObj = new ChatTest(instanceClient.ChatBots, restAdminClient.Instances, instanceClient.Jobs, instance);
|
||||
await chatTestObj.RunPostTest(cancellationToken);
|
||||
await repoTest;
|
||||
|
||||
@@ -1772,7 +1800,7 @@ namespace Tgstation.Server.Tests.Live
|
||||
jobsHubTest.CompleteNow();
|
||||
await jobsHubTestTask;
|
||||
|
||||
await new InstanceManagerTest(adminClient, server.Directory).RunPostTest(instance, cancellationToken);
|
||||
await new InstanceManagerTest(restAdminClient, server.Directory).RunPostTest(instance, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
@@ -1807,20 +1835,64 @@ namespace Tgstation.Server.Tests.Live
|
||||
await serverTask;
|
||||
}
|
||||
|
||||
async Task<IRestServerClient> CreateAdminClient(Uri url, CancellationToken cancellationToken)
|
||||
async ValueTask<MultiServerClient> CreateAdminClient(Uri url, CancellationToken cancellationToken)
|
||||
{
|
||||
url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty));
|
||||
var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2);
|
||||
for (var I = 1; ; ++I)
|
||||
{
|
||||
ValueTask<IRestServerClient> restClientTask;
|
||||
ValueTask<IAuthenticatedGraphQLServerClient> graphQLClientTask;
|
||||
try
|
||||
{
|
||||
System.Console.WriteLine($"TEST: CreateAdminClient attempt {I}...");
|
||||
return await clientFactory.CreateFromLogin(
|
||||
Console.WriteLine($"TEST: CreateAdminClient attempt {I}...");
|
||||
|
||||
restClientTask = restClientFactory.CreateFromLogin(
|
||||
url,
|
||||
DefaultCredentials.AdminUserName,
|
||||
DefaultCredentials.DefaultAdminUserPassword,
|
||||
cancellationToken: cancellationToken);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
graphQLClientTask = graphQLClientFactory.CreateFromLogin(
|
||||
url,
|
||||
DefaultCredentials.AdminUserName,
|
||||
DefaultCredentials.DefaultAdminUserPassword,
|
||||
cancellationToken: cts.Token);
|
||||
|
||||
IRestServerClient restClient;
|
||||
try
|
||||
{
|
||||
restClient = await restClientTask;
|
||||
}
|
||||
catch (Exception restException) when (restException is not HttpRequestException && restException is not ServiceUnavailableException)
|
||||
{
|
||||
cts.Cancel();
|
||||
try
|
||||
{
|
||||
await (await graphQLClientTask).DisposeAsync();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception graphQLException)
|
||||
{
|
||||
throw new AggregateException(restException, graphQLException);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new MultiServerClient(
|
||||
restClient,
|
||||
await graphQLClientTask);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await restClient.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user