mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-23 13:07:07 +01:00
Add auto refresh functionality to IServerClient
This commit is contained in:
@@ -44,6 +44,16 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
readonly List<IRequestLogger> requestLoggers;
|
||||
|
||||
/// <summary>
|
||||
/// Backing field for <see cref="Headers"/>
|
||||
/// </summary>
|
||||
readonly ApiHeaders? tokenRefreshHeaders;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SemaphoreSlim"/> for <see cref="Token"/> refreshes.
|
||||
/// </summary>
|
||||
readonly SemaphoreSlim semaphoreSlim;
|
||||
|
||||
/// <summary>
|
||||
/// Backing field for <see cref="Headers"/>
|
||||
/// </summary>
|
||||
@@ -101,18 +111,25 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The value of <see cref="httpClient"/></param>
|
||||
/// <param name="url">The value of <see cref="Url"/></param>
|
||||
/// <param name="apiHeaders">The value of <see cref="ApiHeaders"/></param>
|
||||
public ApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders)
|
||||
/// <param name="apiHeaders">The value of <see cref="Headers"/></param>
|
||||
/// <param name="tokenRefreshHeaders">The value of <see cref="tokenRefreshHeaders"/></param>
|
||||
public ApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
Url = url ?? throw new ArgumentNullException(nameof(url));
|
||||
headers = apiHeaders ?? throw new ArgumentNullException(nameof(apiHeaders));
|
||||
this.tokenRefreshHeaders = tokenRefreshHeaders;
|
||||
|
||||
requestLoggers = new List<IRequestLogger>();
|
||||
semaphoreSlim = new SemaphoreSlim(1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => httpClient.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
httpClient.Dispose();
|
||||
semaphoreSlim.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main request method
|
||||
@@ -122,9 +139,10 @@ namespace Tgstation.Server.Client
|
||||
/// <param name="body">The body of the request</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="instanceId">The optional <see cref="Instance.Id"/> for the request</param>
|
||||
/// <param name="tokenRefresh">If this is a token refresh operation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the response on success</returns>
|
||||
async Task<TResult> RunRequest<TResult>(string route, object? body, HttpMethod method, long? instanceId, CancellationToken cancellationToken)
|
||||
async Task<TResult> RunRequest<TResult>(string route, object? body, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken)
|
||||
{
|
||||
if (route == null)
|
||||
throw new ArgumentNullException(nameof(route));
|
||||
@@ -141,11 +159,25 @@ namespace Tgstation.Server.Client
|
||||
if (body != null)
|
||||
request.Content = new StringContent(JsonConvert.SerializeObject(body, serializerSettings), Encoding.UTF8, ApiHeaders.ApplicationJson);
|
||||
|
||||
headers.SetRequestHeaders(request.Headers, instanceId);
|
||||
var headersToUse = tokenRefresh ? tokenRefreshHeaders! : headers;
|
||||
headersToUse.SetRequestHeaders(request.Headers, instanceId);
|
||||
|
||||
await Task.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false);
|
||||
// This is meant to be a gate against token refresh operations
|
||||
await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if(!tokenRefresh)
|
||||
semaphoreSlim.Release();
|
||||
|
||||
response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false);
|
||||
|
||||
response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tokenRefresh)
|
||||
semaphoreSlim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
using (response)
|
||||
@@ -155,7 +187,13 @@ namespace Tgstation.Server.Client
|
||||
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (!tokenRefresh
|
||||
&& response.StatusCode == HttpStatusCode.Unauthorized
|
||||
&& await RefreshToken(cancellationToken).ConfigureAwait(false))
|
||||
return await RunRequest<TResult>(route, body, method, instanceId, false, cancellationToken).ConfigureAwait(false);
|
||||
HandleBadResponse(response, json);
|
||||
}
|
||||
|
||||
if (String.IsNullOrWhiteSpace(json))
|
||||
json = JsonConvert.SerializeObject(new object());
|
||||
@@ -171,50 +209,68 @@ namespace Tgstation.Server.Client
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Create<TResult>(string route, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Put, null, cancellationToken);
|
||||
async Task<bool> RefreshToken(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenRefreshHeaders == null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var token = await RunRequest<Token>(Routes.Root, null, HttpMethod.Post, null, true, cancellationToken);
|
||||
headers = new ApiHeaders(headers.UserAgent!, token.Bearer!);
|
||||
}
|
||||
catch (ClientException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Read<TResult>(string route, CancellationToken cancellationToken) => RunRequest<TResult>(route, null, HttpMethod.Get, null, cancellationToken);
|
||||
public Task<TResult> Create<TResult>(string route, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Put, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Update<TResult>(string route, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Post, null, cancellationToken);
|
||||
public Task<TResult> Read<TResult>(string route, CancellationToken cancellationToken) => RunRequest<TResult>(route, null, HttpMethod.Get, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Update<TBody, TResult>(string route, TBody body, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Post, null, cancellationToken);
|
||||
public Task<TResult> Update<TResult>(string route, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Post, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task Update<TBody>(string route, TBody body, CancellationToken cancellationToken) => RunRequest<object>(route, body, HttpMethod.Post, null, cancellationToken);
|
||||
public Task<TResult> Update<TBody, TResult>(string route, TBody body, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Post, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Create<TBody, TResult>(string route, TBody body, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Put, null, cancellationToken);
|
||||
public Task Update<TBody>(string route, TBody body, CancellationToken cancellationToken) => RunRequest<object>(route, body, HttpMethod.Post, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task Delete(string route, CancellationToken cancellationToken) => RunRequest<object>(route, null, HttpMethod.Delete, null, cancellationToken);
|
||||
public Task<TResult> Create<TBody, TResult>(string route, TBody body, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Put, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Create<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Put, instanceId, cancellationToken);
|
||||
public Task Delete(string route, CancellationToken cancellationToken) => RunRequest<object>(route, null, HttpMethod.Delete, null, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Read<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, null, HttpMethod.Get, instanceId, cancellationToken);
|
||||
public Task<TResult> Create<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Put, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Update<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Post, instanceId, cancellationToken);
|
||||
public Task<TResult> Read<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, null, HttpMethod.Get, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<object>(route, null, HttpMethod.Delete, instanceId, cancellationToken);
|
||||
public Task<TResult> Update<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, body, HttpMethod.Post, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task Delete<TBody>(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest<object>(route, body, HttpMethod.Delete, instanceId, cancellationToken);
|
||||
public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<object>(route, null, HttpMethod.Delete, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Delete<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, null, HttpMethod.Delete, instanceId, cancellationToken);
|
||||
public Task Delete<TBody>(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest<object>(route, body, HttpMethod.Delete, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Create<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Put, instanceId, cancellationToken);
|
||||
public Task<TResult> Delete<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, null, HttpMethod.Delete, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Patch<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), new HttpMethod("PATCH"), instanceId, cancellationToken);
|
||||
public Task<TResult> Create<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Put, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TResult> Patch<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Patch, instanceId, false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddRequestLogger(IRequestLogger requestLogger) => requestLoggers.Add(requestLogger ?? throw new ArgumentNullException(nameof(requestLogger)));
|
||||
|
||||
@@ -7,6 +7,6 @@ namespace Tgstation.Server.Client
|
||||
sealed class ApiClientFactory : IApiClientFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders) => new ApiClient(new HttpClient(), url, apiHeaders);
|
||||
public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) => new ApiClient(new HttpClient(), url, apiHeaders, tokenRefreshHeaders);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ namespace Tgstation.Server.Client
|
||||
/// </summary>
|
||||
/// <param name="url">The base <see cref="Uri"/></param>
|
||||
/// <param name="apiHeaders">The <see cref="ApiHeaders"/> for the <see cref="IApiClient"/></param>
|
||||
/// <param name="tokenRefreshHeaders">The <see cref="ApiHeaders"/> to use to generate a new <see cref="Api.Models.Token"/>.</param>
|
||||
/// <returns>A new <see cref="IApiClient"/></returns>
|
||||
IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders);
|
||||
IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Tgstation.Server.Client
|
||||
/// <param name="password">The password for the <see cref="IServerClient"/></param>
|
||||
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IServerClient"/>.</param>
|
||||
/// <param name="timeout">Optional <see cref="TimeSpan"/> representing timeout for the connection</param>
|
||||
/// <param name="attemptLoginRefresh">Attempt to refresh the received <see cref="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="Task{TResult}"/> resulting in a new <see cref="IServerClient"/></returns>
|
||||
Task<IServerClient> CreateFromLogin(
|
||||
@@ -27,6 +28,7 @@ namespace Tgstation.Server.Client
|
||||
string password,
|
||||
IEnumerable<IRequestLogger>? requestLoggers = null,
|
||||
TimeSpan? timeout = null,
|
||||
bool attemptLoginRefresh = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace Tgstation.Server.Client
|
||||
string password,
|
||||
IEnumerable<IRequestLogger>? requestLoggers = null,
|
||||
TimeSpan? timeout = null,
|
||||
bool attemptRefreshLogin = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (host == null)
|
||||
@@ -50,7 +51,8 @@ namespace Tgstation.Server.Client
|
||||
requestLoggers ??= Enumerable.Empty<IRequestLogger>();
|
||||
|
||||
Token token;
|
||||
using (var api = ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, username, password)))
|
||||
var loginHeaders = new ApiHeaders(productHeaderValue, username, password);
|
||||
using (var api = ApiClientFactory.CreateApiClient(host, loginHeaders, null))
|
||||
{
|
||||
foreach (var requestLogger in requestLoggers)
|
||||
api.AddRequestLogger(requestLogger);
|
||||
@@ -64,7 +66,9 @@ namespace Tgstation.Server.Client
|
||||
if (timeout.HasValue)
|
||||
client.Timeout = timeout.Value;
|
||||
|
||||
return client;
|
||||
var apiHeaders = new ApiHeaders(productHeaderValue, token.Bearer!);
|
||||
|
||||
return new ServerClient(ApiClientFactory.CreateApiClient(host, apiHeaders, loginHeaders), token);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -77,7 +81,7 @@ namespace Tgstation.Server.Client
|
||||
if (token.Bearer == null)
|
||||
throw new ArgumentException("token.Bearer should not be null!", nameof(token));
|
||||
|
||||
return new ServerClient(ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer)), token);
|
||||
return new ServerClient(ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer), null), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace Tgstation.Server.Client.Tests
|
||||
var httpClient = new Mock<IHttpClient>();
|
||||
httpClient.Setup(x => x.SendAsync(It.IsNotNull<HttpRequestMessage>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(response));
|
||||
|
||||
var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"));
|
||||
var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"), null);
|
||||
|
||||
var result = await client.Read<Byond>(Routes.Byond, default).ConfigureAwait(false);
|
||||
Assert.AreEqual(sample.Version, result.Version);
|
||||
@@ -64,7 +64,7 @@ namespace Tgstation.Server.Client.Tests
|
||||
var httpClient = new Mock<IHttpClient>();
|
||||
httpClient.Setup(x => x.SendAsync(It.IsNotNull<HttpRequestMessage>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(response));
|
||||
|
||||
var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"));
|
||||
var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"), null);
|
||||
|
||||
await Assert.ThrowsExceptionAsync<UnrecognizedResponseException>(() => client.Read<Byond>(Routes.Byond, default)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ namespace Tgstation.Server.Host.Setup.Tests
|
||||
var mockFailCommand = new Mock<DbCommand>();
|
||||
mockFailCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny<CancellationToken>())).Throws(new Exception()).Verifiable();
|
||||
|
||||
void SetDbCommandCreator(Mock<DbConnection> mock, Func<DbCommand> creator) => mock.Protected().Setup<DbCommand>("CreateDbCommand").Returns(creator).Verifiable();
|
||||
static void SetDbCommandCreator(Mock<DbConnection> mock, Func<DbCommand> creator) => mock.Protected().Setup<DbCommand>("CreateDbCommand").Returns(creator).Verifiable();
|
||||
|
||||
var mockGoodDbConnection = new Mock<DbConnection>();
|
||||
mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask).Verifiable();
|
||||
@@ -144,7 +144,6 @@ namespace Tgstation.Server.Host.Setup.Tests
|
||||
"-27",
|
||||
"5000",
|
||||
"fake token",
|
||||
"y",
|
||||
//logging config
|
||||
"no",
|
||||
//cp config
|
||||
@@ -167,7 +166,6 @@ namespace Tgstation.Server.Host.Setup.Tests
|
||||
String.Empty,
|
||||
String.Empty,
|
||||
"n",
|
||||
"y",
|
||||
//logging config
|
||||
"y",
|
||||
"not actually verified because lol mocks /../!@#$%^&*()/..///.",
|
||||
@@ -191,7 +189,6 @@ namespace Tgstation.Server.Host.Setup.Tests
|
||||
String.Empty,
|
||||
String.Empty,
|
||||
"y",
|
||||
"y",
|
||||
"will faile",
|
||||
String.Empty,
|
||||
String.Empty,
|
||||
|
||||
@@ -144,7 +144,7 @@ namespace Tgstation.Server.Tests
|
||||
{
|
||||
try
|
||||
{
|
||||
return await clientFactory.CreateFromLogin(server.Url, User.AdminName, User.DefaultAdminPassword).ConfigureAwait(false);
|
||||
return await clientFactory.CreateFromLogin(server.Url, User.AdminName, User.DefaultAdminPassword, attemptLoginRefresh: false).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user