diff --git a/QuickbaseNet.Examples/Program.cs b/QuickbaseNet.Examples/Program.cs index ef44106..cec746d 100644 --- a/QuickbaseNet.Examples/Program.cs +++ b/QuickbaseNet.Examples/Program.cs @@ -13,7 +13,7 @@ static async Task Main(string[] args) var query = new QuickbaseQueryBuilder() .From("bmycek2xq") .Select(3, 7, 14, 75, 150, 157, 354, 355, 367, 538, 539, 540, 541, 542, 543) - .Where("{'7'.'CT'.'10136'}") + .Where("{'7'.'EX'.'10136'}") .Build(); string jsonRequest = JsonConvert.SerializeObject(query); @@ -22,12 +22,12 @@ static async Task Main(string[] args) if (result.IsSuccess) { Console.WriteLine("Success!"); - Console.WriteLine(JsonConvert.SerializeObject(result.Response, Formatting.Indented)); + Console.WriteLine(JsonConvert.SerializeObject(result.Value, Formatting.Indented)); } else { Console.WriteLine("Error!"); - Console.WriteLine(JsonConvert.SerializeObject(result.Error, Formatting.Indented)); + Console.WriteLine(JsonConvert.SerializeObject(result.QuickbaseError, Formatting.Indented)); } // var recordBuilder = new QuickbaseCommandBuilder() diff --git a/QuickbaseNet.UnitTests/GlobalUsings.cs b/QuickbaseNet.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/QuickbaseNet.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/QuickbaseNet.UnitTests/Mocks/MockHttpMessageHandler.cs b/QuickbaseNet.UnitTests/Mocks/MockHttpMessageHandler.cs new file mode 100644 index 0000000..39a241c --- /dev/null +++ b/QuickbaseNet.UnitTests/Mocks/MockHttpMessageHandler.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace QuickbaseNet.UnitTests.Mocks; + +public class MockHttpMessageHandler : HttpMessageHandler +{ + public string ResponseContent { get; set; } + public HttpStatusCode ResponseStatusCode { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return await Task.FromResult(new HttpResponseMessage + { + StatusCode = ResponseStatusCode, + Content = new StringContent(ResponseContent) + }); + } +} \ No newline at end of file diff --git a/QuickbaseNet.UnitTests/QuickbaseClientTests/QuickbaseClientTests.cs b/QuickbaseNet.UnitTests/QuickbaseClientTests/QuickbaseClientTests.cs new file mode 100644 index 0000000..17ad5c8 --- /dev/null +++ b/QuickbaseNet.UnitTests/QuickbaseClientTests/QuickbaseClientTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Text.Json; +using Bogus; +using QuickbaseNet.Requests; +using QuickbaseNet.Responses; +using QuickbaseNet.Services; +using QuickbaseNet.UnitTests.Mocks; + +namespace QuickbaseNet.UnitTests.QuickbaseClientTests; + +public class QuickbaseClientTests +{ + private readonly MockHttpMessageHandler _mockHandler; + private readonly QuickbaseClient _client; + private const string TestRealm = "testRealm"; + private const string TestToken = "testToken"; + private const string BaseUrl = "http://localhost/"; + + public QuickbaseClientTests() + { + _mockHandler = SetupMockHandlerWithSuccessResponse(); + _client = CreateConfiguredQuickbaseClient(); + } + + [Fact] + public async Task QueryRecords_ReturnsSuccessResponse_WhenCalled() + { + // Arrange + var request = new QuickbaseQueryRequest(); + + // Act + var response = await _client.QueryRecords(request); + + // Assert + Assert.True(response.IsSuccess); + Assert.NotNull(response.Value); + Assert.NotEmpty(response.Value.Data); + Assert.NotEmpty(response.Value.Fields); + Assert.NotNull(response.Value.Metadata); + } + + [Fact] + public async Task QueryRecords_ReturnsErrorResponse_WhenBadRequestOccurs() + { + // Arrange + SetupMockHandlerWithErrorResponse(); + var request = new QuickbaseQueryRequest + { + From = "tableId", + Where = "{1.CT.'query'}", + Select = [1, 2, 3] + }; + + // Act + var actualResponse = await _client.QueryRecords(request); + + // Assert + Assert.False(actualResponse.IsSuccess); + Assert.NotNull(actualResponse.QuickbaseError); + } + + #region Helpers + private void SetupMockHandlerWithErrorResponse() + { + var faker = new Faker(); + var errorResponse = new QuickbaseErrorResponse + { + Message = faker.Random.Words(), + Description = faker.Lorem.Paragraph() + }; + + _mockHandler.ResponseContent = JsonSerializer.Serialize(errorResponse); + _mockHandler.ResponseStatusCode = HttpStatusCode.BadRequest; + } + + private MockHttpMessageHandler SetupMockHandlerWithSuccessResponse() + { + var mockJsonResponse = GetMockJsonResponse(); + return new MockHttpMessageHandler + { + ResponseContent = mockJsonResponse, + ResponseStatusCode = HttpStatusCode.OK + }; + } + + private QuickbaseClient CreateConfiguredQuickbaseClient() + { + var httpClient = new HttpClient(_mockHandler) + { + BaseAddress = new Uri(BaseUrl) + }; + return new QuickbaseClient(TestRealm, TestToken) + { + Client = httpClient + }; + } + + private static string GetMockJsonResponse() + { + // JSON response string... + return @"{ + ""data"": [ + { + ""6"": { ""value"": ""Andre Harris"" }, + ""7"": { ""value"": 10 }, + ""8"": { ""value"": ""2019-12-18T08:00:00Z"" } + } + ], + ""fields"": [ + { ""id"": 6, ""label"": ""Full Name"", ""type"": ""text"" }, + { ""id"": 7, ""label"": ""Amount"", ""type"": ""numeric"" }, + { ""id"": 8, ""label"": ""Date time"", ""type"": ""date time"" } + ], + ""metadata"": { + ""totalRecords"": 10, + ""numRecords"": 1, + ""numFields"": 3, + ""skip"": 0 + } + }"; + } + + #endregion +} \ No newline at end of file diff --git a/QuickbaseNet.UnitTests/QuickbaseNet.UnitTests.csproj b/QuickbaseNet.UnitTests/QuickbaseNet.UnitTests.csproj new file mode 100644 index 0000000..23e0299 --- /dev/null +++ b/QuickbaseNet.UnitTests/QuickbaseNet.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/QuickbaseNet.sln b/QuickbaseNet.sln index a16d799..456a5a9 100644 --- a/QuickbaseNet.sln +++ b/QuickbaseNet.sln @@ -3,9 +3,11 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.8.34322.80 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickbaseNet", "QuickbaseNet\QuickbaseNet.csproj", "{375B33E5-C837-4915-844C-52057055E84C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QuickbaseNet", "QuickbaseNet\QuickbaseNet.csproj", "{375B33E5-C837-4915-844C-52057055E84C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickbaseNet.Examples", "QuickbaseNet.Examples\QuickbaseNet.Examples.csproj", "{F92A2FF7-450E-4672-8781-BC648ACE2ACF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QuickbaseNet.Examples", "QuickbaseNet.Examples\QuickbaseNet.Examples.csproj", "{F92A2FF7-450E-4672-8781-BC648ACE2ACF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickbaseNet.UnitTests", "QuickbaseNet.UnitTests\QuickbaseNet.UnitTests.csproj", "{E2654CA5-971C-43D0-912E-D4445F1EB4B0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -21,6 +23,10 @@ Global {F92A2FF7-450E-4672-8781-BC648ACE2ACF}.Debug|Any CPU.Build.0 = Debug|Any CPU {F92A2FF7-450E-4672-8781-BC648ACE2ACF}.Release|Any CPU.ActiveCfg = Release|Any CPU {F92A2FF7-450E-4672-8781-BC648ACE2ACF}.Release|Any CPU.Build.0 = Release|Any CPU + {E2654CA5-971C-43D0-912E-D4445F1EB4B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2654CA5-971C-43D0-912E-D4445F1EB4B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2654CA5-971C-43D0-912E-D4445F1EB4B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2654CA5-971C-43D0-912E-D4445F1EB4B0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/QuickbaseNet/Errors/QuickbaseError.cs b/QuickbaseNet/Errors/QuickbaseError.cs new file mode 100644 index 0000000..bce12b5 --- /dev/null +++ b/QuickbaseNet/Errors/QuickbaseError.cs @@ -0,0 +1,36 @@ +namespace QuickbaseNet.Errors +{ + public class QuickbaseError + { + public static readonly QuickbaseError None = new QuickbaseError(string.Empty, string.Empty, string.Empty, QuickbaseErrorType.Failure); + public static readonly QuickbaseError NullValue = new QuickbaseError("Error.NullValue", "Null value was provided", string.Empty, QuickbaseErrorType.Failure); + + public QuickbaseError(string code, string message, string description, QuickbaseErrorType quickbaseErrorType) + { + Code = code; + Message = message; + Description = description; + Type = quickbaseErrorType; + } + + public string Code { get; private set; } + + public string Message { get; private set; } + + public string Description { get; private set; } + + public QuickbaseErrorType Type { get; private set; } + + public static QuickbaseError NotFound(string code, string message, string description) => + new QuickbaseError(code, message, description, QuickbaseErrorType.NotFound); + + public static QuickbaseError Failure(string code, string message, string description) => + new QuickbaseError(code, message, description, QuickbaseErrorType.Failure); + + public static QuickbaseError ClientError(string code, string message, string description) => + new QuickbaseError(code, message, description, QuickbaseErrorType.ClientError); + + public static QuickbaseError ServerError(string code, string message, string description) => + new QuickbaseError(code, message, description, QuickbaseErrorType.ServerError); + } +} \ No newline at end of file diff --git a/QuickbaseNet/Errors/QuickbaseErrorType.cs b/QuickbaseNet/Errors/QuickbaseErrorType.cs new file mode 100644 index 0000000..0f3cd9b --- /dev/null +++ b/QuickbaseNet/Errors/QuickbaseErrorType.cs @@ -0,0 +1,10 @@ +namespace QuickbaseNet.Errors +{ + public enum QuickbaseErrorType + { + Failure = 0, + NotFound = 1, + ClientError = 3, + ServerError = 4 + } +} \ No newline at end of file diff --git a/QuickbaseNet/Models/FieldValue.cs b/QuickbaseNet/Models/FieldValue.cs index 9788243..8a2654b 100644 --- a/QuickbaseNet/Models/FieldValue.cs +++ b/QuickbaseNet/Models/FieldValue.cs @@ -1,10 +1,16 @@ using Newtonsoft.Json; +using System; namespace QuickbaseNet.Models { public class FieldValue { - [JsonProperty("value")] - public dynamic Value { get; set; } + [JsonProperty("value", NullValueHandling = NullValueHandling.Ignore)] + public object Value { get; set; } + + public T GetValue() + { + return Value == null ? default : (T)Convert.ChangeType(Value, typeof(T)); + } } } \ No newline at end of file diff --git a/QuickbaseNet/QuickbaseNet.csproj b/QuickbaseNet/QuickbaseNet.csproj index 95540a2..8516a0c 100644 --- a/QuickbaseNet/QuickbaseNet.csproj +++ b/QuickbaseNet/QuickbaseNet.csproj @@ -3,6 +3,7 @@ netstandard2.0;netstandard2.1;net5.0;net6.0;net48 True + QuickbaseNet.UnitTests diff --git a/QuickbaseNet/QuickbaseResult.cs b/QuickbaseNet/QuickbaseResult.cs new file mode 100644 index 0000000..45cf79c --- /dev/null +++ b/QuickbaseNet/QuickbaseResult.cs @@ -0,0 +1,53 @@ +using System; +using QuickbaseNet.Errors; + +namespace QuickbaseNet +{ + public class QuickbaseResult + { + protected internal QuickbaseResult(bool isSuccess, QuickbaseError quickbaseError) + { + if (isSuccess && quickbaseError != QuickbaseError.None || + !isSuccess && quickbaseError == QuickbaseError.None) + { + throw new ArgumentException("Invalid error", nameof(quickbaseError)); + } + + IsSuccess = isSuccess; + QuickbaseError = quickbaseError; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public QuickbaseError QuickbaseError { get; } + + public static QuickbaseResult Success() => new QuickbaseResult(true, QuickbaseError.None); + + public static QuickbaseResult Success(TValue value) => new QuickbaseResult(value, true, QuickbaseError.None); + + public static QuickbaseResult Failure(QuickbaseError quickbaseError) => new QuickbaseResult(false, quickbaseError); + + public static QuickbaseResult Failure(QuickbaseError quickbaseError) => new QuickbaseResult(default, false, quickbaseError); + } + + public class QuickbaseResult : QuickbaseResult + { + private readonly TValue _value; + + protected internal QuickbaseResult(TValue value, bool isSuccess, QuickbaseError quickbaseError) + : base(isSuccess, quickbaseError) + { + _value = value; + } + + public TValue Value => IsSuccess + ? _value + : throw new InvalidOperationException("The value of a failure result can't be accessed."); + + public static implicit operator QuickbaseResult(TValue value) => + !(value == null) ? Success(value) : Failure(QuickbaseError.NullValue); + } + +} \ No newline at end of file diff --git a/QuickbaseNet/Services/QuickbaseClient.cs b/QuickbaseNet/Services/QuickbaseClient.cs index c0134c6..49d7061 100644 --- a/QuickbaseNet/Services/QuickbaseClient.cs +++ b/QuickbaseNet/Services/QuickbaseClient.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; +using QuickbaseNet.Errors; using QuickbaseNet.Requests; using QuickbaseNet.Responses; @@ -13,30 +14,51 @@ public class QuickbaseClient private const string BaseUrl = "https://api.quickbase.com"; private const string UserAgent = "QuickbaseNet/0.1.1"; - private readonly HttpClient _httpClient = new HttpClient(); + public HttpClient Client { get; set; } = new HttpClient(); public QuickbaseClient(string realm, string userToken) { - _httpClient.BaseAddress = new Uri(BaseUrl); - _httpClient.DefaultRequestHeaders.Add("QB-Realm-Hostname", $"{realm}.quickbase.com"); - _httpClient.DefaultRequestHeaders.Add("Authorization", $"QB-USER-TOKEN {userToken}"); - _httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + Client.BaseAddress = new Uri(BaseUrl); + Client.DefaultRequestHeaders.Add("QB-Realm-Hostname", $"{realm}.quickbase.com"); + Client.DefaultRequestHeaders.Add("Authorization", $"QB-USER-TOKEN {userToken}"); + Client.DefaultRequestHeaders.Add("User-Agent", UserAgent); } - public async Task<(QuickbaseQueryResponse Response, QuickbaseErrorResponse Error, bool IsSuccess)> QueryRecords(QuickbaseQueryRequest quickBaseRequest) + public async Task> QueryRecords(QuickbaseQueryRequest quickBaseRequest) { HttpContent content = new StringContent(JsonConvert.SerializeObject(quickBaseRequest), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("/v1/records/query", content); + var response = await Client.PostAsync("/v1/records/query", content); + var jsonResponse = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - return (JsonConvert.DeserializeObject(jsonResponse), null, true); + var queryResponse = JsonConvert.DeserializeObject(jsonResponse); + + // Check if data is null or empty + return queryResponse.Data.Count == 0 + ? QuickbaseResult.Failure(QuickbaseError.NotFound("QuickbaseNet.Failure", + "No records found", $"The query did not find any records matching that criteria")) + : QuickbaseResult.Success(queryResponse); } - var errorResponse = await response.Content.ReadAsStringAsync(); - return (null, JsonConvert.DeserializeObject(errorResponse), false); + var errorResponse = JsonConvert.DeserializeObject(jsonResponse); + + // Check if its 4xx error + if (response.StatusCode >= System.Net.HttpStatusCode.BadRequest && + response.StatusCode < System.Net.HttpStatusCode.InternalServerError) + { + return QuickbaseResult.Failure(QuickbaseError.ClientError("QuickbaseNet.ClientError", errorResponse.Message, errorResponse.Description)); + } + + // Check if its 5xx error + if (response.StatusCode >= System.Net.HttpStatusCode.InternalServerError) + { + return QuickbaseResult.Failure(QuickbaseError.ServerError("QuickbaseNet.ServerError", errorResponse.Message, errorResponse.Description)); + } + + // Return generic failure + return QuickbaseResult.Failure(QuickbaseError.Failure("QuickbaseNet.Failure", errorResponse.Message, errorResponse.Description)); } internal async Task<(QuickbaseRecordUpdateResponse Response, QuickbaseErrorResponse Error, bool IsSuccess)> @@ -44,7 +66,7 @@ public QuickbaseClient(string realm, string userToken) { HttpContent content = new StringContent(JsonConvert.SerializeObject(quickBaseRequest), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("/v1/records", content); + var response = await Client.PostAsync("/v1/records", content); if (response.IsSuccessStatusCode) { @@ -61,7 +83,7 @@ public QuickbaseClient(string realm, string userToken) { HttpContent content = new StringContent(JsonConvert.SerializeObject(quickBaseRequest), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("/v1/records", content); + var response = await Client.PostAsync("/v1/records", content); if (response.IsSuccessStatusCode) {