diff --git a/src/FMData.Rest/FileMakerRestClient.cs b/src/FMData.Rest/FileMakerRestClient.cs index c61ab58d..530868bf 100644 --- a/src/FMData.Rest/FileMakerRestClient.cs +++ b/src/FMData.Rest/FileMakerRestClient.cs @@ -46,6 +46,7 @@ public class FileMakerRestClient : FileMakerApiClientBase, IFileMakerRestClient #region FM DATA SPECIFIC private readonly int _tokenExpiration = 15; + private readonly int _maxAuthRetries = 1; private string _dataToken; private AuthenticationHeaderValue _authHeader; private DateTime _dataTokenLastUse = DateTime.MinValue; @@ -666,13 +667,13 @@ public override async Task RunScriptAsync(string layout, string script, { uri += $"?script.param={Uri.EscapeDataString(scriptParameter)}"; } - var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); - // include auth token - requestMessage.Headers.Authorization = _authHeader; - - // run the patch action - var response = await Client.SendAsync(requestMessage).ConfigureAwait(false); + var response = await RetryOnUnauthorizedAsync(async () => + { + var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + requestMessage.Headers.Authorization = _authHeader; + return await Client.SendAsync(requestMessage).ConfigureAwait(false); + }).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { @@ -734,28 +735,9 @@ public async Task ExecuteRequestAsync( // we're about to use the token so update date used, and refresh if needed. await UpdateTokenDateAsync().ConfigureAwait(false); - var str = req.SerializeRequest(); - var httpContent = new StringContent(str, Encoding.UTF8, "application/json"); - - // do not pass character set. - // this is due to fms 18 returning Bad Request when specified - // this hack is backward compatible for FMS17 - httpContent.Headers.ContentType.CharSet = null; - - var httpRequest = new HttpRequestMessage(method, requestUri); - - // don't include body content on requests for http get - if (method != HttpMethod.Get) - { - httpRequest.Content = httpContent; - } - - // include our authorization header - httpRequest.Headers.Authorization = _authHeader; - - // run and return the action - var response = await Client.SendAsync(httpRequest).ConfigureAwait(false); - return response; + return await RetryOnUnauthorizedAsync( + async () => await SendDataApiRequestAsync(method, requestUri, req).ConfigureAwait(false) + ).ConfigureAwait(false); } /// @@ -812,21 +794,19 @@ public override async Task SetGlobalFieldAsync(string baseTable, stri var method = new HttpMethod("PATCH"); - var requestMessage = new HttpRequestMessage(method, $"{BaseEndPoint}/globals") + var response = await RetryOnUnauthorizedAsync(async () => { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - - // include auth token - requestMessage.Headers.Authorization = _authHeader; - - // do not pass character set. - // this is due to fms 18 returning Bad Request when specified - // this hack is backward compatible for FMS17 - requestMessage.Content.Headers.ContentType.CharSet = null; - - // run the patch action - var response = await Client.SendAsync(requestMessage).ConfigureAwait(false); + var requestMessage = new HttpRequestMessage(method, $"{BaseEndPoint}/globals") + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + requestMessage.Headers.Authorization = _authHeader; + // do not pass character set. + // this is due to fms 18 returning Bad Request when specified + // this hack is backward compatible for FMS17 + requestMessage.Content.Headers.ContentType.CharSet = null; + return await Client.SendAsync(requestMessage).ConfigureAwait(false); + }).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { @@ -926,13 +906,13 @@ public override async Task> GetLayoutsAsync( // generate request url var uri = $"{FmsUri}/fmi/data/{_targetVersion}/" + $"databases/{Uri.EscapeDataString(FileName)}/layouts"; - var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); - - // include auth token - requestMessage.Headers.Authorization = _authHeader; - // run the patch action - var response = await Client.SendAsync(requestMessage).ConfigureAwait(false); + var response = await RetryOnUnauthorizedAsync(async () => + { + var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + requestMessage.Headers.Authorization = _authHeader; + return await Client.SendAsync(requestMessage).ConfigureAwait(false); + }).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { @@ -965,13 +945,13 @@ public override async Task> GetScriptsAsync( // generate request url var uri = $"{FmsUri}/fmi/data/{_targetVersion}" + $"/databases/{Uri.EscapeDataString(FileName)}/scripts"; - var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); - - // include auth token - requestMessage.Headers.Authorization = _authHeader; - // run the patch action - var response = await Client.SendAsync(requestMessage).ConfigureAwait(false); + var response = await RetryOnUnauthorizedAsync(async () => + { + var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + requestMessage.Headers.Authorization = _authHeader; + return await Client.SendAsync(requestMessage).ConfigureAwait(false); + }).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { @@ -1011,13 +991,13 @@ public override async Task GetLayoutAsync(string layout, int? re { uri += $"?recordId={recordId}"; } - var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); - // include auth token - requestMessage.Headers.Authorization = _authHeader; - - // run the patch action - var response = await Client.SendAsync(requestMessage).ConfigureAwait(false); + var response = await RetryOnUnauthorizedAsync(async () => + { + var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + requestMessage.Headers.Authorization = _authHeader; + return await Client.SendAsync(requestMessage).ConfigureAwait(false); + }).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { @@ -1061,26 +1041,22 @@ public override async Task UpdateContainerAsync( { await UpdateTokenDateAsync().ConfigureAwait(false); // about to use token, so update - var form = new MultipartFormDataContent(); - - //var stream = new MemoryStream(content); - //var streamContent = new StreamContent(stream); var uri = ContainerEndpoint(layout, recordId, fieldName, repetition); - var containerContent = new ByteArrayContent(content); - containerContent.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); - - form.Add(containerContent, "upload", Path.GetFileName(fileName)); - - var requestMessage = new HttpRequestMessage(HttpMethod.Post, uri) + var response = await RetryOnUnauthorizedAsync(async () => { - Content = form - }; - - // include auth token - requestMessage.Headers.Authorization = _authHeader; + var form = new MultipartFormDataContent(); + var containerContent = new ByteArrayContent(content); + containerContent.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); + form.Add(containerContent, "upload", Path.GetFileName(fileName)); - var response = await Client.SendAsync(requestMessage).ConfigureAwait(false); + var requestMessage = new HttpRequestMessage(HttpMethod.Post, uri) + { + Content = form + }; + requestMessage.Headers.Authorization = _authHeader; + return await Client.SendAsync(requestMessage).ConfigureAwait(false); + }).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { @@ -1132,6 +1108,62 @@ protected override async Task GetContainerOnClient(string containerEndPo } #region Private Helpers and utility methods + /// + /// Invalidates the current token so the next authentication check triggers a refresh. + /// + private void InvalidateToken() + { + _dataToken = null; + _authHeader = null; + } + + /// + /// Serializes an to JSON, builds a fresh , + /// and sends it to the Data API with the current auth header. + /// + private async Task SendDataApiRequestAsync(HttpMethod method, string requestUri, IFileMakerRequest req) + { + var str = req.SerializeRequest(); + var httpContent = new StringContent(str, Encoding.UTF8, "application/json"); + + // do not pass character set. + // this is due to fms 18 returning Bad Request when specified + // this hack is backward compatible for FMS17 + httpContent.Headers.ContentType.CharSet = null; + + var httpRequest = new HttpRequestMessage(method, requestUri); + + // don't include body content on requests for http get + if (method != HttpMethod.Get) + { + httpRequest.Content = httpContent; + } + + // include our authorization header + httpRequest.Headers.Authorization = _authHeader; + + // run and return the action + return await Client.SendAsync(httpRequest).ConfigureAwait(false); + } + + /// + /// Sends a request and retries up to times on 401 Unauthorized, + /// refreshing the auth token before each retry. + /// + private async Task RetryOnUnauthorizedAsync(Func> sendRequest) + { + var response = await sendRequest().ConfigureAwait(false); + var retries = 0; + while (response.StatusCode == HttpStatusCode.Unauthorized && retries < _maxAuthRetries) + { + retries++; + InvalidateToken(); + await RefreshTokenAsync().ConfigureAwait(false); + response = await sendRequest().ConfigureAwait(false); + } + return response; + } + /// /// Converts a JToken instance and maps it to the generic type. /// diff --git a/tests/FMData.Rest.Tests/TokenRetryTests.cs b/tests/FMData.Rest.Tests/TokenRetryTests.cs new file mode 100644 index 00000000..a681349f --- /dev/null +++ b/tests/FMData.Rest.Tests/TokenRetryTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FMData.Rest.Requests; +using FMData.Rest.Tests.TestModels; +using RichardSzalay.MockHttp; +using Xunit; + +namespace FMData.Rest.Tests +{ + public class TokenRetryTests + { + private const string Server = "http://localhost"; + private const string File = "test-file"; + private const string User = "unit"; + private const string Pass = "test"; + private const string Layout = "layout"; + + [Fact(DisplayName = "FindAsync Should Retry On 401 Unauthorized")] + public async Task FindAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + // 1. Initial auth succeeds + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + // 2. Find request returns 401 + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/_find") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + // 3. Re-auth succeeds + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + // 4. Retry find succeeds + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/_find") + .Respond("application/json", DataApiResponses.SuccessfulFind()); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var response = await fdc.FindAsync(Layout, new Dictionary { { "Name", "test" } }); + + Assert.NotNull(response); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact(DisplayName = "CreateAsync Should Retry On 401 Unauthorized")] + public async Task CreateAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records") + .Respond("application/json", DataApiResponses.SuccessfulCreate()); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var req = new CreateRequest> + { + Layout = Layout, + Data = new Dictionary { { "Name", "Test" } } + }; + var response = await fdc.SendAsync(req); + + Assert.NotNull(response); + Assert.Contains(response.Messages, r => r.Message == "OK"); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact(DisplayName = "EditAsync Should Retry On 401 Unauthorized")] + public async Task EditAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(new HttpMethod("PATCH"), $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records/264") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(new HttpMethod("PATCH"), $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records/264") + .Respond("application/json", DataApiResponses.SuccessfulEdit()); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var req = new EditRequest> + { + Layout = Layout, + RecordId = 264, + Data = new Dictionary { { "Name", "Updated" } } + }; + var response = await fdc.SendAsync(req); + + Assert.NotNull(response); + Assert.Contains(response.Messages, r => r.Message == "OK"); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact(DisplayName = "DeleteAsync Should Retry On 401 Unauthorized")] + public async Task DeleteAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Delete, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records/1234") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Delete, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records/1234") + .Respond("application/json", DataApiResponses.SuccessfulDelete()); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var req = new DeleteRequest { Layout = Layout, RecordId = 1234 }; + var response = await fdc.SendAsync(req); + + Assert.NotNull(response); + Assert.Contains(response.Messages, r => r.Message == "OK"); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact(DisplayName = "RunScriptAsync Should Retry On 401 Unauthorized")] + public async Task RunScriptAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Get, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/script/TestScript") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + var scriptResponse = System.IO.File.ReadAllText(Path.Combine("ResponseData", "ScriptResponseOK.json")); + mockHttp.Expect(HttpMethod.Get, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/script/TestScript") + .Respond("application/json", scriptResponse); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var response = await fdc.RunScriptAsync(Layout, "TestScript", null); + + Assert.NotNull(response); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact(DisplayName = "GetLayoutsAsync Should Retry On 401 Unauthorized")] + public async Task GetLayoutsAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Get, $"{Server}/fmi/data/v1/databases/{File}/layouts") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + var layoutData = System.IO.File.ReadAllText(Path.Combine("ResponseData", "LayoutList.json")); + mockHttp.Expect(HttpMethod.Get, $"{Server}/fmi/data/v1/databases/{File}/layouts") + .Respond("application/json", layoutData); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var response = await fdc.GetLayoutsAsync(); + + Assert.NotNull(response); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact(DisplayName = "UpdateContainerAsync Should Retry On 401 Unauthorized")] + public async Task UpdateContainerAsync_ShouldRetry_OnUnauthorized() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records/12/containers/field/1") + .Respond(HttpStatusCode.Unauthorized, "application/json", DataApiResponses.Authentication401()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/sessions") + .Respond("application/json", DataApiResponses.SuccessfulAuthentication()); + + mockHttp.Expect(HttpMethod.Post, $"{Server}/fmi/data/v1/databases/{File}/layouts/{Layout}/records/12/containers/field/1") + .Respond("application/json", DataApiResponses.SuccessfulEdit()); + + using var fdc = new FileMakerRestClient(mockHttp.ToHttpClient(), + new ConnectionInfo { FmsUri = Server, Database = File, Username = User, Password = Pass }); + + var bytes = new byte[] { 0x01, 0x02, 0x03 }; + var response = await fdc.UpdateContainerAsync(Layout, 12, "field", "test.jpg", 1, bytes); + + Assert.NotNull(response); + Assert.Contains(response.Messages, r => r.Message == "OK"); + mockHttp.VerifyNoOutstandingExpectation(); + } + } +}