Add auto refresh functionality to IServerClient

This commit is contained in:
Jordan Brown
2020-05-24 17:57:28 -04:00
parent 745cd6bb7c
commit 5d00e23a97
8 changed files with 95 additions and 35 deletions
+79 -23
View File
@@ -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)
{