diff --git a/APIMatic.Core.Test/Api/ApiCallTest.cs b/APIMatic.Core.Test/Api/ApiCallTest.cs index b140509d..23dd2b46 100644 --- a/APIMatic.Core.Test/Api/ApiCallTest.cs +++ b/APIMatic.Core.Test/Api/ApiCallTest.cs @@ -11,9 +11,9 @@ namespace APIMatic.Core.Test.Api { public class ApiCallTest : TestBase { - private static readonly MockServer _server = MockServer.Server1; - private static readonly CompatibilityFactory _compatibilityFactory = new(); - private static readonly Dictionary> _globalErrors = new() + private static readonly MockServer Server = MockServer.Server1; + private static readonly CompatibilityFactory CompatibilityFactory = new(); + private static readonly Dictionary> GlobalErrors = new() { { "400", CreateErrorCase("400 Global Child 1", (reason, context) => new Child1Exception(reason, context)) }, { "402", CreateErrorCase("402 Global Child 2", (reason, context) => new Child2Exception(reason, context)) }, @@ -25,34 +25,34 @@ public class ApiCallTest : TestBase protected static ErrorCase CreateErrorCase(string reason, Func error, bool isErrorTemplate = false) => new(reason, error, isErrorTemplate); - protected ApiCall, T> CreateApiCall() + protected static ApiCall, T> CreateApiCall() => new ApiCall, T>( LazyGlobalConfiguration.Value, - _compatibilityFactory, - globalErrors: _globalErrors, + CompatibilityFactory, + globalErrors: GlobalErrors, returnTypeCreator: (response, result) => new ApiResponse(response.StatusCode, response.Headers, result) - ).Server(_server); + ).Server(Server); - protected ApiCall, T> CreateApiCallWithoutReturnTypeCreator() + protected static ApiCall, T> CreateApiCallWithoutReturnTypeCreator() => new ApiCall, T>( LazyGlobalConfiguration.Value, - _compatibilityFactory - ).Server(_server); + CompatibilityFactory + ).Server(Server); - protected ApiCall CreateSimpleApiCall() + protected static ApiCall CreateSimpleApiCall() => new ApiCall( LazyGlobalConfiguration.Value, - _compatibilityFactory, - globalErrors: _globalErrors - ).Server(_server); + CompatibilityFactory, + globalErrors: GlobalErrors + ).Server(Server); - protected ApiCall CreateSimpleApiCallWithoutErrors() + protected static ApiCall CreateSimpleApiCallWithoutErrors() => new ApiCall( LazyGlobalConfiguration.Value, - _compatibilityFactory - ).Server(_server); - - protected string GetCompleteUrl(string path) + CompatibilityFactory + ).Server(Server); + + protected static string GetCompleteUrl(string path) { return $"{LazyGlobalConfiguration.Value.ServerUrl()}{path}"; } diff --git a/APIMatic.Core.Test/Api/HttpPost/ApiCallPostXMLTest.cs b/APIMatic.Core.Test/Api/HttpPost/ApiCallPostXmlTest.cs similarity index 92% rename from APIMatic.Core.Test/Api/HttpPost/ApiCallPostXMLTest.cs rename to APIMatic.Core.Test/Api/HttpPost/ApiCallPostXmlTest.cs index 852e0409..7b24cc8d 100644 --- a/APIMatic.Core.Test/Api/HttpPost/ApiCallPostXMLTest.cs +++ b/APIMatic.Core.Test/Api/HttpPost/ApiCallPostXmlTest.cs @@ -9,14 +9,14 @@ namespace APIMatic.Core.Test.Api.HttpPost { [TestFixture] - internal class ApiCallPostXMLTest : ApiCallTest + internal class ApiCallPostXmlTest : ApiCallTest { [Test] public void ApiCall_PostXmlLong_OKResponse() { //Arrange - long body = 100; - var url = "/apicall/post-xml-long/200"; + const long body = 100; + const string url = "/apicall/post-xml-long/200"; var expected = new ServerResponse() { @@ -28,7 +28,7 @@ public void ApiCall_PostXmlLong_OKResponse() handlerMock.When(GetCompleteUrl(url)) .With(req => { - Assert.AreEqual(expected.Message, req.Content.ReadAsStringAsync().Result); + Assert.AreEqual(expected.Message, req.Content?.ReadAsStringAsync().Result); return true; }) .Respond(HttpStatusCode.OK, content); @@ -57,7 +57,7 @@ public void ApiCall_PostXmlLong_OKResponse() public void ApiCall_PostXmlString_OKResponse() { //Arrange - string body = "This is a xml body."; + var body = "This is a xml body."; var url = "/apicall/post-xml-string/200"; var expected = new ServerResponse() @@ -70,7 +70,7 @@ public void ApiCall_PostXmlString_OKResponse() handlerMock.When(GetCompleteUrl(url)) .With(req => { - Assert.AreEqual(expected.Message, req.Content.ReadAsStringAsync().Result); + Assert.AreEqual(expected.Message, req.Content?.ReadAsStringAsync().Result); return true; }) .Respond(HttpStatusCode.OK, content); diff --git a/APIMatic.Core.Test/AuthenticationTest.cs b/APIMatic.Core.Test/AuthenticationTest.cs index eeaf652c..4cf147b9 100644 --- a/APIMatic.Core.Test/AuthenticationTest.cs +++ b/APIMatic.Core.Test/AuthenticationTest.cs @@ -112,7 +112,7 @@ public void Multiple_Authentication_OR_Validation_Failure() } [Test] - public async Task Multiple_Authentication_AND_Validation_Failure() + public void Multiple_Authentication_AND_Validation_Failure() { var globalConfiguration = new GlobalConfiguration.Builder() .ServerUrls(new Dictionary @@ -137,15 +137,6 @@ public async Task Multiple_Authentication_AND_Validation_Failure() Assert.AreEqual("Following authentication credentials are required:\n" + "-> Missing required header field: TOKEN", exp.Message); - - async void AuthCode() - { - await globalConfiguration.GlobalRequestBuilder() - .Setup(HttpMethod.Get, "/auth") - .WithAndAuth(auth => auth.Add("query") - .Add("header")) - .Build(); - } } [Test] diff --git a/APIMatic.Core.Test/MockTypes/Models/Pagination/Transaction.cs b/APIMatic.Core.Test/MockTypes/Models/Pagination/Transaction.cs new file mode 100644 index 00000000..04de2e5d --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Models/Pagination/Transaction.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using APIMatic.Core.Test.MockTypes.Convertors; +using Newtonsoft.Json; + +namespace APIMatic.Core.Test.MockTypes.Models.Pagination +{ + public class Transaction + { + /// + /// Initializes a new instance of the class. + /// + /// id. + /// amount. + /// timestamp. + public Transaction( + string id = null, + double? amount = null, + DateTime? timestamp = null) + { + this.Id = id; + this.Amount = amount; + this.Timestamp = timestamp; + } + + /// + /// Gets or sets Id. + /// + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] + public string Id { get; set; } + + /// + /// Gets or sets Amount. + /// + [JsonProperty("amount", NullValueHandling = NullValueHandling.Ignore)] + public double? Amount { get; set; } + + /// + /// Gets or sets Timestamp. + /// + [JsonConverter(typeof(CustomDateTimeConverter), "r")] + [JsonProperty("timestamp", NullValueHandling = NullValueHandling.Ignore)] + public DateTime? Timestamp { get; set; } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsCursored.cs b/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsCursored.cs new file mode 100644 index 00000000..bbea5e37 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsCursored.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace APIMatic.Core.Test.MockTypes.Models.Pagination +{ + public class TransactionsCursored + { + private string nextCursor; + private readonly Dictionary shouldSerialize = new Dictionary + { + { "nextCursor", false }, + }; + + /// + /// Initializes a new instance of the class. + /// + /// data. + /// nextCursor. + public TransactionsCursored( + List data = null, + string nextCursor = null) + { + this.Data = data; + + if (nextCursor != null) + { + this.NextCursor = nextCursor; + } + } + + /// + /// Gets or sets Data. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public List Data { get; set; } + + /// + /// Cursor for the next page of results. + /// + [JsonProperty("nextCursor")] + public string NextCursor + { + get + { + return this.nextCursor; + } + + set + { + this.shouldSerialize["nextCursor"] = true; + this.nextCursor = value; + } + } + + /// + /// Marks the field to not be serialized. + /// + public void UnsetNextCursor() + { + this.shouldSerialize["nextCursor"] = false; + } + + /// + /// Checks if the field should be serialized or not. + /// + /// A boolean weather the field should be serialized or not. + public bool ShouldSerializeNextCursor() + { + return this.shouldSerialize["nextCursor"]; + } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsLinked.cs b/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsLinked.cs new file mode 100644 index 00000000..d1b2ccc0 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsLinked.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace APIMatic.Core.Test.MockTypes.Models.Pagination +{ + public class TransactionsLinked + { + /// + /// Initializes a new instance of the class. + /// + /// data. + /// links. + public TransactionsLinked( + List data = null, + Links links = null) + { + this.Data = data; + this.Links = links; + } + + /// + /// Gets or sets Data. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public List Data { get; set; } + + /// + /// Gets or sets Links. + /// + [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] + public Links Links { get; set; } + } + + public class Links + { + /// + /// Initializes a new instance of the class. + /// + /// first. + /// last. + /// prev. + /// next. + public Links( + string first = null, + string last = null, + string prev = null, + string next = null) + { + this.First = first; + this.Last = last; + this.Prev = prev; + this.Next = next; + } + + /// + /// Gets or sets First. + /// + [JsonProperty("first", NullValueHandling = NullValueHandling.Ignore)] + public string First { get; set; } + + /// + /// Gets or sets Last. + /// + [JsonProperty("last", NullValueHandling = NullValueHandling.Ignore)] + public string Last { get; set; } + + /// + /// Gets or sets Prev. + /// + [JsonProperty("prev", NullValueHandling = NullValueHandling.Ignore)] + public string Prev { get; set; } + + /// + /// Gets or sets Next. + /// + [JsonProperty("next", NullValueHandling = NullValueHandling.Ignore)] + public string Next { get; set; } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsOffset.cs b/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsOffset.cs new file mode 100644 index 00000000..eab67504 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Models/Pagination/TransactionsOffset.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace APIMatic.Core.Test.MockTypes.Models.Pagination +{ + public class TransactionsOffset + { + /// + /// Initializes a new instance of the class. + /// + /// data. + public TransactionsOffset( + List data = null) + { + this.Data = data; + } + + /// + /// Gets or sets Data. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public List Data { get; set; } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Models/Pagination/User.cs b/APIMatic.Core.Test/MockTypes/Models/Pagination/User.cs new file mode 100644 index 00000000..292a35ef --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Models/Pagination/User.cs @@ -0,0 +1,8 @@ +namespace APIMatic.Core.Test.MockTypes.Models.Pagination +{ + internal sealed class User + { + public int Id { get; init; } + public string Name { get; init; } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Models/Person.cs b/APIMatic.Core.Test/MockTypes/Models/Person.cs new file mode 100644 index 00000000..b4c2a61c --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Models/Person.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace APIMatic.Core.Test.MockTypes.Models +{ + public class Person + { + [JsonProperty("name")] + public string Name { get; init; } + + [JsonProperty("age")] + public int Age { get; init; } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/AsyncPaginatorFactory.cs b/APIMatic.Core.Test/MockTypes/Pagination/AsyncPaginatorFactory.cs new file mode 100644 index 00000000..d0bb04ba --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/AsyncPaginatorFactory.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Request; + +namespace APIMatic.Core.Test.MockTypes.Pagination +{ + internal static class AsyncPaginatorFactory + { + public static AsyncPaginator Create( + Func>> apiCallExecutor, + RequestBuilder requestBuilder, + Func> pagedResponseItemConverter, + params IPaginationStrategy[] dataManagers) + => new(apiCallExecutor, requestBuilder, pagedResponseItemConverter, dataManagers); + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/BasePagedResponse.cs b/APIMatic.Core.Test/MockTypes/Pagination/BasePagedResponse.cs new file mode 100644 index 00000000..abce28a0 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/BasePagedResponse.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Test.MockTypes.Http.Response; +using APIMatic.Core.Test.MockTypes.Pagination.MetaData; + +namespace APIMatic.Core.Test.MockTypes.Pagination +{ + public class BasePagedResponse : ApiResponse + { + protected BasePagedResponse(ApiResponse pageData, Func> pageToItems) : base( + pageData.StatusCode, + pageData.Headers, pageData.Data) + { + this.Data = pageData.Data; + this.Items = pageToItems(pageData.Data); + } + + public new TPage Data { get; } + + public IEnumerable Items { get; } + } + + internal static class BasePagedResponseFactory + { + public static BasePagedResponse Create( + ApiResponse pageData, + IPaginationStrategy manager, + Func> pageToItems) + { + return manager switch + { + OffsetPagination offsetPagination => OffsetPagedResponseFactory.Create(pageData, + offsetPagination, pageToItems), + CursorPagination cursorPagination => CursorPagedResponseFactory.Create(pageData, + cursorPagination, pageToItems), + NumberPagination pagePagination => NumberPagedResponseFactory.Create(pageData, + pagePagination, pageToItems), + LinkPagination linkPagination => LinkPagedResponseFactory.Create(pageData, linkPagination, + pageToItems), + _ => throw new NotImplementedException("Unknown pagination type") + }; + } + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/MetaData/CursorPagedResponse.cs b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/CursorPagedResponse.cs new file mode 100644 index 00000000..96400dd8 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/CursorPagedResponse.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Test.MockTypes.Http.Response; + +namespace APIMatic.Core.Test.MockTypes.Pagination.MetaData +{ + public class CursorPagedResponse : BasePagedResponse + { + public CursorPagedResponse(ApiResponse pageData, CursorPagination manager, + Func> pageToItems) : base( + pageData, pageToItems) + { + NextCursor = manager.CursorValue; + } + + public string NextCursor { get; } + } + + internal static class CursorPagedResponseFactory + { + public static CursorPagedResponse Create( + ApiResponse pageData, + IPaginationStrategy manager, + Func> pageToItems) => new(pageData, manager as CursorPagination, pageToItems); + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/MetaData/LinkPagedResponse.cs b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/LinkPagedResponse.cs new file mode 100644 index 00000000..697c021d --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/LinkPagedResponse.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Test.MockTypes.Http.Response; + +namespace APIMatic.Core.Test.MockTypes.Pagination.MetaData +{ + public class LinkPagedResponse : BasePagedResponse + { + public LinkPagedResponse(ApiResponse pageData, LinkPagination manager, + Func> pageToItems) : base( + pageData, pageToItems) + { + NextLink = manager.CurrentLinkValue; + } + + public string NextLink { get; } + } + + internal static class LinkPagedResponseFactory + { + public static LinkPagedResponse Create( + ApiResponse pageData, + IPaginationStrategy manager, + Func> pageToItems) => new(pageData, manager as LinkPagination, pageToItems); + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/MetaData/NumberPagedResponse.cs b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/NumberPagedResponse.cs new file mode 100644 index 00000000..f36abca9 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/NumberPagedResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Test.MockTypes.Http.Response; + +namespace APIMatic.Core.Test.MockTypes.Pagination.MetaData +{ + public class NumberPagedResponse : BasePagedResponse + { + public NumberPagedResponse(ApiResponse pageData, NumberPagination manager, Func> pageToItems) : base( + pageData, pageToItems) + { + PageNumber = manager.CurrentPage; + } + + public int PageNumber { get; } + } + + internal static class NumberPagedResponseFactory + { + public static NumberPagedResponse Create(ApiResponse pageData, + IPaginationStrategy manager, Func> pageToItems) => + new(pageData, manager as NumberPagination, pageToItems); + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/MetaData/OffsetPagedResponse.cs b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/OffsetPagedResponse.cs new file mode 100644 index 00000000..6d0eece8 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/MetaData/OffsetPagedResponse.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Test.MockTypes.Http.Response; + +namespace APIMatic.Core.Test.MockTypes.Pagination.MetaData +{ + public class OffsetPagedResponse : BasePagedResponse + { + public OffsetPagedResponse(ApiResponse pageData, OffsetPagination manager, + Func> pageToItems) : base( + pageData, pageToItems) + { + Offset = manager.OffsetValue; + } + + public int Offset { get; } + } + + internal static class OffsetPagedResponseFactory + { + public static OffsetPagedResponse Create(ApiResponse pageData, + IPaginationStrategy manager, Func> pageToItems) => + new(pageData, manager as OffsetPagination, pageToItems); + } +} diff --git a/APIMatic.Core.Test/MockTypes/Pagination/PaginatorFactory.cs b/APIMatic.Core.Test/MockTypes/Pagination/PaginatorFactory.cs new file mode 100644 index 00000000..385d2ac8 --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Pagination/PaginatorFactory.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Request; + +namespace APIMatic.Core.Test.MockTypes.Pagination +{ + internal static class PaginatorFactory + { + public static Paginator Create( + Func>> apiCallExecutor, + RequestBuilder requestBuilder, + Func> responseToItems, + params IPaginationStrategy[] dataManagers) + => new(apiCallExecutor, requestBuilder, responseToItems, dataManagers); + } +} diff --git a/APIMatic.Core.Test/Pagination/AsyncPaginatorTests.cs b/APIMatic.Core.Test/Pagination/AsyncPaginatorTests.cs new file mode 100644 index 00000000..2c9f55d0 --- /dev/null +++ b/APIMatic.Core.Test/Pagination/AsyncPaginatorTests.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Request; +using APIMatic.Core.Test.Api; +using APIMatic.Core.Test.MockTypes.Models.Pagination; +using APIMatic.Core.Test.MockTypes.Pagination; +using APIMatic.Core.Test.MockTypes.Pagination.MetaData; +using APIMatic.Core.Test.Utilities; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Pagination +{ + [TestFixture] + public class AsyncPaginatorTests : ApiCallTest + { + [Test] + public void Constructor_ShouldThrowArgumentException_WhenPaginationStrategiesIsEmpty() + { + // Arrange + Func>> + apiCallExecutor = + (rb, s, ct) => Task.FromResult>(null); + + Func> pagedResponseItemConverter = _ => Enumerable.Empty(); + var requestBuilder = new RequestBuilder(LazyGlobalConfiguration.Value, string.Empty); + var emptyStrategies = Array.Empty(); + + // Act & Assert + var ex = Assert.Throws(() => + { + new AsyncPaginator( + apiCallExecutor, + requestBuilder, + pagedResponseItemConverter, + emptyStrategies); + }); + + Assert.That(ex?.ParamName, Is.EqualTo("paginationStrategies")); + Assert.That(ex.Message, Does.StartWith("At least one pagination strategy must be provided.")); + } + + [Test] + public async Task PaginateAsync_QueryOffsetPaginationYieldsAllItems_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions/offset"; + + var transactionOffsets = PaginationHelper.ChunkTransactions(PaginationHelper.AllTransactions, limit); + handlerMock.MockOffsetPaginatedResponses(transactionOffsets, GetCompleteUrl(url)); + + var asyncPaginator = PaginationFactory.CreateHandler( + CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("offset", 0)) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + OffsetPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new OffsetPagination("$request.query#/offset") + ) + ) + .Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public async Task PaginateAsync_QueryOffsetPagination_YieldsNoItems() + { + // Arrange + const int limit = 2; + const string url = "/transactions/offset/noitems"; + + var emptyTransactionOffsets = new List { new() }; + handlerMock.MockOffsetPaginatedResponses(emptyTransactionOffsets, GetCompleteUrl(url)); + + var asyncPaginator = PaginationFactory.CreateHandler( + CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("offset", 0)) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + OffsetPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new OffsetPagination("$request.query#/offset") + ) + ) + .Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(0, collected.Count); + } + + [Test] + public async Task PaginateAsync_QueryCursorPaginationYieldsAllItems_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions"; + + var transactionsWithCursor = PaginationHelper.ChunkTransactionsWithCursor(PaginationHelper.AllTransactions, limit); + handlerMock.MockCursorPaginatedResponses(transactionsWithCursor, $"{GetCompleteUrl(url)}?limit={limit}"); + + var asyncPaginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("cursor", "cursor0")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + CursorPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new CursorPagination("$response.body#/nextCursor", "$request.query#/cursor") + ) + ).Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public async Task PaginateAsync_QueryCursorPaginationInvalidPointer_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions"; + + var transactionsWithCursor = PaginationHelper.ChunkTransactionsWithCursor(PaginationHelper.AllTransactions, limit); + handlerMock.MockCursorPaginatedResponses(transactionsWithCursor, $"{GetCompleteUrl(url)}?limit={limit}"); + + var asyncPaginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("cursor", "cursor0")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + CursorPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new CursorPagination("$response.body/nextCursor", "$request.query#/cursor") + ) + ).Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(limit, collected.Count); + var expected = PaginationHelper.AllTransactions.Take(limit).ToList(); + CollectionAssert.AreEquivalent(expected.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public async Task PaginateAsync_QueryLinkPaginationYieldsAllItems_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions"; + + var linkedPages = PaginationHelper.ChunkTransactionsWithLinks(PaginationHelper.AllTransactions, limit, url); + handlerMock.MockLinkedPaginatedResponses(linkedPages, GetCompleteUrl(string.Empty)); + + var asyncPaginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("page", "1")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + LinkPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new LinkPagination("$response.body#/links/next") + ) + ).Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public async Task PaginateAsync_QueryPagePaginationYieldsAllItemsAcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions/page"; + + var pages = PaginationHelper.ChunkTransactions(PaginationHelper.AllTransactions, limit); + handlerMock.MockPagePaginatedResponse(pages, GetCompleteUrl(url), limit); + + var asyncPaginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("page", 0)) // Initial page + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + NumberPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new NumberPagination("$request.query#/page") + ) + ).Paginate(); + + var collected = new List(); + + // Act + await foreach (var tx in asyncPaginator) + { + collected.Add(tx); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public async Task PaginateAsync_QueryMultiPaginationYieldsAllItems_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/multi/transactions/multi/pagination/strategy"; + + var linkedPages = PaginationHelper.ChunkTransactionsWithLinksAndPages(PaginationHelper.AllTransactions, limit, url); + handlerMock.MockMultiPaginatedResponses(linkedPages, GetCompleteUrl(string.Empty), url, limit); + + var asyncPaginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("page", "1")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + BasePagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new LinkPagination("$response.body#/links/next"), + new NumberPagination("$request.query#/page") + ) + ).Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public async Task PaginateAsync_QuerySinglePagination_YieldsFirstPageItemsOnly() + { + // Arrange + const int limit = 2; + const string url = "/multi/transactions/single/pagination/strategy"; + + var linkedPages = PaginationHelper.ChunkTransactionsWithLinksAndPages(PaginationHelper.AllTransactions, limit, url); + handlerMock.MockMultiPaginatedResponses(linkedPages, GetCompleteUrl(string.Empty), url, limit); + + var asyncPaginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("page", "1")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + BasePagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + AsyncPaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new LinkPagination("$response.body#/links/next") + ) + ).Paginate(); + + var collected = new List(); + + // Act + await foreach (var item in asyncPaginator) + { + collected.Add(item); + } + + // Assert + var firstPageItems = PaginationHelper.AllTransactions.Take(2).ToList(); + Assert.AreEqual(firstPageItems.Count, collected.Count); + CollectionAssert.AreEquivalent(firstPageItems.Select(t => t.Id), collected.Select(t => t.Id)); + } + } +} diff --git a/APIMatic.Core.Test/Pagination/PaginatorTests.cs b/APIMatic.Core.Test/Pagination/PaginatorTests.cs new file mode 100644 index 00000000..3a7cab86 --- /dev/null +++ b/APIMatic.Core.Test/Pagination/PaginatorTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Request; +using APIMatic.Core.Test.Api; +using APIMatic.Core.Test.MockTypes.Models.Pagination; +using APIMatic.Core.Test.MockTypes.Pagination; +using APIMatic.Core.Test.MockTypes.Pagination.MetaData; +using APIMatic.Core.Test.Utilities; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Pagination +{ + [TestFixture] + public class PaginatorTests : ApiCallTest + { + [Test] + public void Constructor_ShouldThrowArgumentException_WhenPaginationStrategiesIsEmpty() + { + // Arrange + Func>> + apiCallExecutor = + (rb, s, ct) => Task.FromResult>(null); + + Func> pagedResponseItemConverter = _ => Enumerable.Empty(); + var requestBuilder = new RequestBuilder(LazyGlobalConfiguration.Value, string.Empty); + var emptyStrategies = Array.Empty(); + + // Act & Assert + var ex = Assert.Throws(() => + { + new Paginator( + apiCallExecutor, + requestBuilder, + pagedResponseItemConverter, + emptyStrategies); + }); + + Assert.That(ex?.ParamName, Is.EqualTo("paginationStrategies")); + Assert.That(ex.Message, Does.StartWith("At least one pagination strategy must be provided.")); + } + + [Test] + public void Paginate_QueryOffsetPaginationYieldsAllItems_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions/offset"; + + var transactionOffsets = PaginationHelper.ChunkTransactions(PaginationHelper.AllTransactions, limit); + handlerMock.MockOffsetPaginatedResponses(transactionOffsets, GetCompleteUrl(url)); + + var paginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("offset", 0)) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => OffsetPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => PaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new OffsetPagination("$request.query#/offset") + ) + ).Paginate(); + + var enumerator = ((IEnumerable)paginator).GetEnumerator(); + var collected = new List(); + while (enumerator.MoveNext()) + { + collected.Add((Transaction)enumerator.Current); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public void Paginate_QueryOffsetPagination_YieldsNoItems() + { + // Arrange + const int limit = 2; + const string url = "/transactions/offset/noitems"; + + var emptyTransactionOffsets = new List { new() }; + handlerMock.MockOffsetPaginatedResponses(emptyTransactionOffsets, GetCompleteUrl(url)); + + var paginator = PaginationFactory.CreateHandler( + CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("offset", 0)) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + OffsetPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + PaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new OffsetPagination("$request.query#/offset") + ) + ) + .Paginate(); + + var collected = new List(); + + // Act + foreach (var item in paginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(0, collected.Count); + } + + [Test] + public void Paginate_QueryLinkPaginationYieldsAllItems_AcrossPages() + { + // Arrange + const int limit = 2; + const string url = "/transactions"; + + var linkedPages = PaginationHelper.ChunkTransactionsWithLinks(PaginationHelper.AllTransactions, limit, url); + handlerMock.MockLinkedPaginatedResponses(linkedPages, GetCompleteUrl(string.Empty)); + + var paginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("page", "1")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => LinkPagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => PaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new LinkPagination("$response.body#/links/next") + ) + ).Paginate(); + + var collected = new List(); + + // Act + foreach (var item in paginator) + { + collected.Add(item); + } + + // Assert + Assert.AreEqual(PaginationHelper.AllTransactions.Count, collected.Count); + CollectionAssert.AreEquivalent(PaginationHelper.AllTransactions.Select(t => t.Id), collected.Select(t => t.Id)); + } + + [Test] + public void Paginate_QuerySinglePagination_YieldsFirstPageItemsOnly() + { + // Arrange + const int limit = 2; + const string url = "/multi/transactions/single/pagination/strategy"; + + var linkedPages = PaginationHelper.ChunkTransactionsWithLinksAndPages(PaginationHelper.AllTransactions, limit, url); + handlerMock.MockMultiPaginatedResponses(linkedPages, GetCompleteUrl(string.Empty), url, limit); + + var paginator = PaginationFactory.CreateHandler(CreateApiCall() + .RequestBuilder(rb => rb + .Setup(HttpMethod.Get, url) + .Parameters(p => p + .Query(q => q.Setup("page", "1")) + .Query(q => q.Setup("limit", limit)))), + (page, strategy) => + BasePagedResponseFactory.Create( + page, + strategy, + tPage => tPage.Data + ), + (func, builder) => + PaginatorFactory.Create( + func, + builder, + pagedResponse => pagedResponse.Items, + new LinkPagination("$response.body#/links/next") + ) + ).Paginate(); + + var collected = new List(); + + // Act + foreach (var item in paginator) + { + collected.Add(item); + } + + // Assert + var firstPageItems = PaginationHelper.AllTransactions.Take(2).ToList(); + Assert.AreEqual(firstPageItems.Count, collected.Count); + CollectionAssert.AreEquivalent(firstPageItems.Select(t => t.Id), collected.Select(t => t.Id)); + } + } +} diff --git a/APIMatic.Core.Test/ParametersTest.cs b/APIMatic.Core.Test/ParametersTest.cs index 717d204a..f52fda40 100644 --- a/APIMatic.Core.Test/ParametersTest.cs +++ b/APIMatic.Core.Test/ParametersTest.cs @@ -27,8 +27,8 @@ public void Parameters_Apply_WithoutValidation() .Apply(requestBuilder); Assert.False(requestBuilder.queryParameters.ContainsKey("query")); Assert.False(requestBuilder.formParameters.Exists(kv => kv.Key == "form")); - Assert.False(requestBuilder.headers.ContainsKey("header")); - Assert.True(requestBuilder.QueryUrl.ToString().Contains("{template}")); + Assert.False(requestBuilder.headersParameters.ContainsKey("header")); + Assert.True(requestBuilder.GetQueryUrl().Contains("{template}")); new Parameter.Builder() @@ -74,7 +74,7 @@ public void TemplateParameter_Null() new Parameter.Builder().Template(p => p.Setup("template", null)).Validate().Apply(requestBuilder); - Assert.AreEqual("http://my/path:3000/v1/path/", requestBuilder.QueryUrl.ToString()); + Assert.AreEqual("http://my/path:3000/v1/path/", requestBuilder.GetQueryUrl()); } [Test] @@ -89,7 +89,7 @@ public void TemplateParameter_Collection() "gamma" })).Validate().Apply(requestBuilder); - Assert.AreEqual("http://my/path:3000/v1/path/asad/alpha/gamma", requestBuilder.QueryUrl.ToString()); + Assert.AreEqual("http://my/path:3000/v1/path/asad/alpha/gamma", requestBuilder.GetQueryUrl()); } [Test] @@ -99,7 +99,7 @@ public void TemplateParameter_DateTime() new Parameter.Builder().Template(p => p.Setup("template", new DateTime(2022, 12, 14))).Validate().Apply(requestBuilder); - Assert.AreEqual("http://my/path:3000/v1/path/2022-12-14T00:00:00", requestBuilder.QueryUrl.ToString()); + Assert.AreEqual("http://my/path:3000/v1/path/2022-12-14T00:00:00", requestBuilder.GetQueryUrl()); } [Test] @@ -111,7 +111,7 @@ public void TemplateParameter_DateTimeOffset() .Validate() .Apply(requestBuilder); - Assert.AreEqual("http://my/path:3000/v1/path/2022-12-14T02:01:04+00:00", requestBuilder.QueryUrl.ToString()); + Assert.AreEqual("http://my/path:3000/v1/path/2022-12-14T02:01:04+00:00", requestBuilder.GetQueryUrl()); } [Test] diff --git a/APIMatic.Core.Test/Request/RequestBuilderExtensionsTest.cs b/APIMatic.Core.Test/Request/RequestBuilderExtensionsTest.cs new file mode 100644 index 00000000..74635a23 --- /dev/null +++ b/APIMatic.Core.Test/Request/RequestBuilderExtensionsTest.cs @@ -0,0 +1,99 @@ +using APIMatic.Core.Request; +using APIMatic.Core.Test.MockTypes.Models; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Request +{ + [TestFixture] + public class RequestBuilderExtensionsTest + { + [Test] + public void UpdateBodyValueByPointer_ValidPointer_UpdatesValue() + { + // Arrange + var person = new Person { Name = "Alice", Age = 30 }; + var pointer = "/age"; + + // Act + var updated = RequestBuilderExtensions.UpdateValueByPointer( + person, + pointer, + old => int.Parse(old.ToString() ?? "") + 5 + ); + + // Assert + Assert.AreEqual(35, updated.Age); + Assert.AreEqual("Alice", updated.Name); + } + + [Test] + public void UpdateBodyValueByPointer_NullValue_ReturnsOriginal() + { + // Act + var result = RequestBuilderExtensions.UpdateValueByPointer( + null, + "/name", + old => "Bob" + ); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void UpdateBodyValueByPointer_NullPointer_ReturnsOriginal() + { + var person = new Person { Name = "Charlie", Age = 40 }; + var result = RequestBuilderExtensions.UpdateValueByPointer( + person, + null, + old => "David" + ); + + Assert.AreEqual("Charlie", result.Name); + } + + [Test] + public void UpdateBodyValueByPointer_NullUpdater_ReturnsOriginal() + { + var person = new Person { Name = "Charlie", Age = 40 }; + var result = RequestBuilderExtensions.UpdateValueByPointer( + person, + "/name", + null + ); + + Assert.AreEqual("Charlie", result.Name); + } + + [Test] + public void UpdateBodyValueByPointer_UpdaterReturnsNull_ReturnsOriginal() + { + var person = new Person { Name = "Eva", Age = 50 }; + var pointer = "/name"; + + var result = RequestBuilderExtensions.UpdateValueByPointer( + person, + pointer, + old => null + ); + + Assert.AreEqual("Eva", result.Name); + } + + [Test] + public void UpdateBodyValueByPointer_InvalidPointer_ReturnsOriginal() + { + var person = new Person { Name = "Frank", Age = 60 }; + var invalidPointer = "/nonexistent"; + + var result = RequestBuilderExtensions.UpdateValueByPointer( + person, + invalidPointer, + old => "ShouldNotApply" + ); + + Assert.AreEqual("Frank", result.Name); + } + } +} diff --git a/APIMatic.Core.Test/Request/RequestBuilderUpdateParameterByJsonPointerTest.cs b/APIMatic.Core.Test/Request/RequestBuilderUpdateParameterByJsonPointerTest.cs new file mode 100644 index 00000000..b11a4757 --- /dev/null +++ b/APIMatic.Core.Test/Request/RequestBuilderUpdateParameterByJsonPointerTest.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using APIMatic.Core.Request; +using APIMatic.Core.Test.MockTypes.Models.Pagination; +using APIMatic.Core.Types.Sdk; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Request +{ + [TestFixture] + public class RequestBuilderUpdateParameterByJsonPointerTest : TestBase + { + private const string ServerUrl = "https://api.example.com/users"; + + readonly Action setupRequest = rb => + rb.Setup(HttpMethod.Get, ServerUrl); + + [Test] + public async Task UpdateParameterByJsonPointer_HeaderParam_SingleValue_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Header(h => h.Setup("id", 0))); + await builder.Build(); + + // Act + builder.UpdateParameterByJsonPointer("$request.headers#/id", old => 999); + + // Assert + Assert.IsTrue(builder.headersParameters.ContainsKey("id")); + Assert.AreEqual("999", builder.headersParameters["id"].ToString()); + } + + [Test] + public async Task UpdateParameterByJsonPointer_HeaderParam_IncorrectJsonPointer_UpdatesPrevious() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Header(h => h.Setup("id", 0))); + await builder.Build(); + + var previousHeaders = builder.headersParameters; + + // Act + builder.UpdateParameterByJsonPointer("$request.headers", old => 999); + + // Assert + Assert.AreEqual(previousHeaders, builder.headersParameters); + } + + [Test] + public async Task UpdateParameterByJsonPointer_HeaderParam_SingleValue_UpdatesWithEmptyPointer() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Header(h => h.Setup("id", 0))); + await builder.Build(); + + var previousHeaders = builder.headersParameters; + + // Act + builder.UpdateParameterByJsonPointer("$request.headers#", old => 999); + + // Assert + Assert.AreEqual(previousHeaders, builder.headersParameters); + } + + [Test] + public async Task UpdateParameterByJsonPointer_HeaderParam_SingleValue_UpdatesWithNonExistentPointer() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Header(h => h.Setup("id", 0))); + await builder.Build(); + + var previousHeaders = builder.headersParameters; + + // Act + builder.UpdateParameterByJsonPointer("$request.headers#/nonexistent", old => 999); + + // Assert + Assert.AreEqual(previousHeaders, builder.headersParameters); + } + + [Test] + public async Task UpdateParameterByJsonPointer_HeaderParam_ObjectValue_UpdatesObjectCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + var initialUser = new User { Id = 1, Name = "User1" }; + builder.Parameters(p => p.Header(h => h.Setup("user", initialUser))); + await builder.Build(); + + // Act + builder.UpdateParameterByJsonPointer("$request.headers#/user/Id", old => 456); + + // Assert + Assert.IsTrue(builder.headersParameters.ContainsKey("user")); + var updatedUser = builder.headersParameters["user"] as User; + Assert.IsNotNull(updatedUser); + Assert.AreEqual(456, updatedUser.Id); + Assert.AreEqual("User1", updatedUser.Name); + } + + [Test] + public async Task UpdateParameterByJsonPointer_QueryParam_ObjectValue_UpdatesObjectCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + var initialUser = new User { Id = 1, Name = "User1" }; + builder.Parameters(p => p.Query(q => q.Setup("user", initialUser))); + await builder.Build(); + + // Act + builder.UpdateParameterByJsonPointer("$request.query#/user/Id", old => 456); + + // Assert + Assert.IsTrue(builder.queryParameters.ContainsKey("user")); + var updatedUser = builder.queryParameters["user"] as User; + Assert.IsNotNull(updatedUser); + Assert.AreEqual(456, updatedUser.Id); + Assert.AreEqual("User1", updatedUser.Name); + } + + [Test] + public async Task UpdateParameterByJsonPointer_TemplateParam_SingleValue_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Template(t => t.Setup("userId", 1))); + await builder.Build(); + + // Act + builder.UpdateParameterByJsonPointer("$request.path#/userId", old => 456); + + // Assert + Assert.IsTrue(builder.templateParameters.ContainsKey("userId")); + Assert.AreEqual(456, builder.templateParameters["userId"]); + } + + [Test] + public async Task UpdateParameterByJsonPointer_TemplateParam_Collection_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Template(t => t.Setup("ids", new List { 1, 2, 3 }))); + await builder.Build(); + + // Act + builder.UpdateParameterByJsonPointer("$request.path#/ids/1", old => 999); + + // Assert + Assert.IsTrue(builder.templateParameters.ContainsKey("ids")); + var updated = builder.templateParameters["ids"] as IList; + Assert.IsNotNull(updated); + CollectionAssert.AreEqual(new List { 1, 999, 3 }, updated); + } + + [Test] + public async Task UpdateParameterByJsonPointer_BodyParameters_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Body(b => b.Setup("age", 25))); + await builder.Build(); + + builder.UpdateParameterByJsonPointer("$request.body#/age", old => 30); + + Assert.IsTrue(builder.bodyParameters.ContainsKey("age")); + Assert.AreEqual(30, builder.bodyParameters["age"]); + } + + [Test] + public async Task UpdateParameterByJsonPointer_FormParameters_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Form(f => + { + f.Setup("username", "john"); + f.Setup("email", "john@example.com"); + })); + + await builder.Build(); + + builder.UpdateParameterByJsonPointer("$request.body#/email", old => "jane@example.com"); + + var updated = builder.formParameters.ToDictionary(p => p.Key, p => p.Value); + Assert.AreEqual("jane@example.com", updated["email"]); + } + + [Test] + public async Task UpdateParameterByJsonPointer_DynamicBody_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Body( b => b.Setup(new { name = "Alice", age = 30 }))); + await builder.Build(); + + builder.UpdateParameterByJsonPointer("$request.body#/age", old => 35); + + Assert.AreEqual(35, builder.body.age); + } + + [Test] + public async Task UpdateParameterByJsonPointer_StringBody_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Body( b => b.Setup("InitialValue"))); + await builder.Build(); + + builder.UpdateParameterByJsonPointer("$request.body#/", old => "UpdatedValue"); + + Assert.AreEqual("UpdatedValue", builder.body); + } + + [Test] + public async Task UpdateParameterByJsonPointer_ObjectBody_UpdatesCorrectly() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + builder.Parameters(p => p.Body( b => b.Setup(new User { Id = 1, Name = "Alice" }))); + await builder.Build(); + + builder.UpdateParameterByJsonPointer("$request.body#/Id", old => 35); + + var updatedBody = builder.body as User; + Assert.IsNotNull(updatedBody); + Assert.AreEqual(35, updatedBody.Id); + Assert.AreEqual("Alice", updatedBody.Name); // Ensure other values remain intact + } + + [Test] + public async Task UpdateParameterByJsonPointer_Body_CoreFileStreamInfo_DoesNotUpdate() + { + var builder = new RequestBuilder(LazyGlobalConfiguration.Value, ServerUrl); + setupRequest(builder); + + var originalBody = new CoreFileStreamInfo(new MemoryStream(), "text/plain", "file.txt"); + builder.Parameters(p => p.Body( b => b.Setup(originalBody))); + + await builder.Build(); + + builder.UpdateParameterByJsonPointer("$request.body#/", _ => "should-not-apply"); + + Assert.AreSame(originalBody, builder.body); // Still the same object + } + } +} diff --git a/APIMatic.Core.Test/Utilities/CoreHelperTest.cs b/APIMatic.Core.Test/Utilities/CoreHelperTest.cs index b2b267a1..98aba361 100644 --- a/APIMatic.Core.Test/Utilities/CoreHelperTest.cs +++ b/APIMatic.Core.Test/Utilities/CoreHelperTest.cs @@ -21,6 +21,7 @@ namespace APIMatic.Core.Test.Utilities public class CoreHelperTest : TestBase { internal const string SERVER_URL = "http://my/path:3000/v1"; + #region CleanUrl [Test] @@ -60,19 +61,20 @@ public void CleanUrl_InvalidUrl() stringBuilder.Append("://localhost:3000//api/body"); Assert.Throws(() => CoreHelper.CleanUrl(stringBuilder)); } + #endregion #region Serialize + [Test] public void JsonSerialize_ServerResponse() { ServerResponse serverResponse = new ServerResponse() { - Message = "Pass", - Passed = true, - Input = "Test is pass" + Message = "Pass", Passed = true, Input = "Test is pass" }; - string expected = "{\"passed\":true,\"Message\":\"Pass\",\"input\":\"Test is pass\",\"AdditionalProperties\":null}"; + string expected = + "{\"passed\":true,\"Message\":\"Pass\",\"input\":\"Test is pass\",\"AdditionalProperties\":null}"; string actual = CoreHelper.JsonSerialize(serverResponse); Assert.AreEqual(expected, actual); } @@ -80,10 +82,7 @@ public void JsonSerialize_ServerResponse() [Test] public void JsonSerialize_CustomAttributeClass() { - TestModelC testModelC = new TestModelC() - { - Name = "Test", - }; + TestModelC testModelC = new TestModelC() { Name = "Test", }; string expected = "{\"$id\":\"1\",\"Name\":\"Test\"}"; string actual = CoreHelper.JsonSerialize(testModelC); Assert.AreEqual(expected, actual); @@ -101,10 +100,7 @@ public void JsonSerialize_Null_Objects() [Test] public void JsonSerialize_DateTime_RFCFormat() { - List list = new List - { - DateTime.Parse("2018-4-12") - }; + List list = new List { DateTime.Parse("2018-4-12") }; string expected = "[\"Thu, 12 Apr 2018 00:00:00 GMT\"]"; string actual = CoreHelper.JsonSerialize(list, new CoreCustomDateTimeConverter("r")); Assert.AreEqual(expected, actual); @@ -131,13 +127,15 @@ public void JsonSerialize_JsonString_NotAString() [Test] public void JsonSerialize_EnumStringAllowUnknownEnumValues() { - Assert.Throws(() => CoreHelper.JsonSerialize(WorkingDaysAllowAdditionalValues._Unknown)); + Assert.Throws(() => + CoreHelper.JsonSerialize(WorkingDaysAllowAdditionalValues._Unknown)); } [Test] public void JsonSerialize_EnumNumberAllowUnknownEnumValues() { - Assert.Throws(() => CoreHelper.JsonSerialize(MonthNumberAllowAdditionalValues._Unknown)); + Assert.Throws(() => + CoreHelper.JsonSerialize(MonthNumberAllowAdditionalValues._Unknown)); } [Test] @@ -157,13 +155,9 @@ public void JsonSerialize_EnumNumber() [Test] public void JsonDeserialize_ServerResponse() { - string serverResponseJson = "{\"passed\":true,\"Message\":\"Pass\",\"input\":\"Test is pass\",\"AdditionalProperties\":null}"; - ServerResponse expected = new ServerResponse() - { - Message = "Pass", - Passed = true, - Input = "Test is pass" - }; + string serverResponseJson = + "{\"passed\":true,\"Message\":\"Pass\",\"input\":\"Test is pass\",\"AdditionalProperties\":null}"; + ServerResponse expected = new ServerResponse() { Message = "Pass", Passed = true, Input = "Test is pass" }; ServerResponse actual = CoreHelper.JsonDeserialize(serverResponseJson); Assert.AreEqual(expected, actual); @@ -174,13 +168,11 @@ public void JsonDeserialize_ServerResponse() public void JsonDeserialize_DateTime() { DateTime dateTime = new DateTime(2022, 12, 12); - List expected = new List - { - dateTime - }; + List expected = new List { dateTime }; string dateTimeString = CoreHelper.JsonSerialize(expected, new CoreCustomDateTimeConverter("r")); - List actual = CoreHelper.JsonDeserialize>(dateTimeString, new CoreCustomDateTimeConverter("r")); + List actual = + CoreHelper.JsonDeserialize>(dateTimeString, new CoreCustomDateTimeConverter("r")); Assert.AreEqual(expected, actual); } @@ -276,12 +268,10 @@ public void AppendQueryParameter_ValidParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var parametersKeys = new List() - { - "service", "api" - }; + var parametersKeys = new List() { "service", "api" }; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, "messagingService", "testApi")); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, + GetParameters(parametersKeys, "messagingService", "testApi")); string expected = $"{SERVER_URL}?service=messagingService&api=testApi"; string actual = queryBuilder.ToString(); @@ -306,13 +296,11 @@ public void AppendQueryParameter_DateTimeParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime=2022-12-14T00:00:00"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, new DateTime(2022, 12, 14))); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, + GetParameters(parametersKeys, new DateTime(2022, 12, 14))); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -322,10 +310,7 @@ public void AppendQueryParameter_DateTimeOffsetParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; var dateTimeOffset = new DateTimeOffset(new DateTime(2022, 12, 14)); string dateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"; @@ -341,10 +326,7 @@ public void AppendQueryParameter_NullParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; object obj = null; string expected = $"{SERVER_URL}"; @@ -358,10 +340,7 @@ public void AppendQueryParameter_NullListParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - List parametersKeys = new List() - { - "list" - }; + List parametersKeys = new List() { "list" }; List obj = null; string expected = $"{SERVER_URL}"; @@ -375,10 +354,7 @@ public void AppendQueryParameter_EmptyListParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - List parametersKeys = new List() - { - "list" - }; + List parametersKeys = new List() { "list" }; List obj = new List(); string expected = $"{SERVER_URL}"; @@ -392,10 +368,7 @@ public void AppendQueryParameter_NullDictionaryParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - List parametersKeys = new List() - { - "dictionary" - }; + List parametersKeys = new List() { "dictionary" }; Dictionary dictionary = null; string expected = $"{SERVER_URL}"; @@ -409,10 +382,7 @@ public void AppendQueryParameter_EmptyDictionaryParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - List parametersKeys = new List() - { - "dictionary" - }; + List parametersKeys = new List() { "dictionary" }; Dictionary dictionary = new Dictionary(); string expected = $"{SERVER_URL}"; @@ -426,15 +396,9 @@ public void AppendQueryParameter_UnIndexedCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - "test", "collection" - }; + var stringCollection = new List() { "test", "collection" }; - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime[]=test&dateTime[]=collection"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection)); @@ -448,18 +412,13 @@ public void AppendQueryParameter_IndexedCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - "test", "collection" - }; + var stringCollection = new List() { "test", "collection" }; - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime[0]=test&dateTime[1]=collection"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), ArraySerialization.Indexed); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), + ArraySerialization.Indexed); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -469,18 +428,13 @@ public void AppendQueryParameter_PlainCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - "test", "collection" - }; + var stringCollection = new List() { "test", "collection" }; - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime=test&dateTime=collection"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), ArraySerialization.Plain); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), + ArraySerialization.Plain); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -490,18 +444,13 @@ public void AppendQueryParameter_TabSeparatedCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - "test", "collection" - }; + var stringCollection = new List() { "test", "collection" }; - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime=test\tcollection"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), ArraySerialization.TSV); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), + ArraySerialization.TSV); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -511,18 +460,13 @@ public void AppendQueryParameter_CommaSeparatedCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - "test", "collection" - }; + var stringCollection = new List() { "test", "collection" }; - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime=test,collection"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), ArraySerialization.CSV); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), + ArraySerialization.CSV); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -534,10 +478,7 @@ public void AppendQueryParameter_CustomParameter() queryBuilder.Append(SERVER_URL); var serverResponse = new ServerResponse(true); - var parametersKeys = new List() - { - "serverResponse" - }; + var parametersKeys = new List() { "serverResponse" }; string expected = $"{SERVER_URL}?serverResponse%5Bpassed%5D=true"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, serverResponse)); @@ -551,19 +492,13 @@ public void AppendQueryParameter_IndexedCustomDictionaryParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var dictionaryData = new Dictionary() - { - {"key1", "value1" }, - {"key2", "value2" } - }; + var dictionaryData = new Dictionary() { { "key1", "value1" }, { "key2", "value2" } }; - var parametersKeys = new List() - { - "dictionary" - }; + var parametersKeys = new List() { "dictionary" }; string expected = $"{SERVER_URL}?dictionary[0]=%5Bkey1%2C%20value1%5D&dictionary[1]=%5Bkey2%2C%20value2%5D"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, dictionaryData), ArraySerialization.Indexed); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, dictionaryData), + ArraySerialization.Indexed); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -574,14 +509,8 @@ public void AppendQueryParameter_CustomDateTimeParameter() StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); DateTime dateTime = new DateTime(2022, 12, 15); - TestModel testModel = new TestModel() - { - TestDateTime = dateTime - }; - var parametersKeys = new List() - { - "dateTime" - }; + TestModel testModel = new TestModel() { TestDateTime = dateTime }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime%5BTestDateTime%5D=2022-12-15T00%3A00%3A00"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, testModel)); @@ -595,14 +524,8 @@ public void AppendQueryParameter_CustomDateTimeConvertorParameter() StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); DateTime dateTime = new DateTime(2022, 12, 15); - TestModelB testModel = new TestModelB() - { - TestDateTime = dateTime - }; - var parametersKeys = new List() - { - "dateTime" - }; + TestModelB testModel = new TestModelB() { TestDateTime = dateTime }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime%5BTestDateTime%5D=2022-12-15"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, testModel)); @@ -618,16 +541,11 @@ public void AppendQueryParameter_CustomStreamParameter() queryBuilder.Append(SERVER_URL); byte[] buffer = Encoding.ASCII.GetBytes("test"); Stream memoryStream = new MemoryStream(buffer); - TestModel testModel = new TestModel() - { - DateStream = memoryStream - }; - var parametersKeys = new List() - { - "stream" - }; + TestModel testModel = new TestModel() { DateStream = memoryStream }; + var parametersKeys = new List() { "stream" }; - string expected = $"{SERVER_URL}?stream%5BDateStream%5D=System.IO.MemoryStream&stream%5BTestDateTime%5D=0001-01-01T00%3A00%3A00"; + string expected = + $"{SERVER_URL}?stream%5BDateStream%5D=System.IO.MemoryStream&stream%5BTestDateTime%5D=0001-01-01T00%3A00%3A00"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, testModel)); memoryStream.Dispose(); string actual = queryBuilder.ToString(); @@ -644,10 +562,7 @@ public void AppendQueryParameter_CustomJObjectParameter() jObject.Album = "alpha"; jObject.Artist = "beta"; - var parametersKeys = new List() - { - "jObject" - }; + var parametersKeys = new List() { "jObject" }; string expected = $"{SERVER_URL}?jObject%5BAlbum%5D=alpha&jObject%5BArtist%5D=beta"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, jObject)); @@ -661,13 +576,11 @@ public void AppendQueryParameter_CustomEnumParameter() StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var parametersKeys = new List() - { - "enum" - }; + var parametersKeys = new List() { "enum" }; string expected = $"{SERVER_URL}?enum=5"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, ArraySerialization.PSV)); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, + GetParameters(parametersKeys, ArraySerialization.PSV)); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -677,18 +590,13 @@ public void AppendQueryParameter_PipeSeparatedCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - "test", "collection" - }; + var stringCollection = new List() { "test", "collection" }; - var parametersKeys = new List() - { - "dateTime" - }; + var parametersKeys = new List() { "dateTime" }; string expected = $"{SERVER_URL}?dateTime=test|collection"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), ArraySerialization.PSV); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), + ArraySerialization.PSV); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } @@ -698,15 +606,9 @@ public void AppendQueryParameter_CustomCollectionParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var stringCollection = new List() - { - new ServerResponse(true) - }; + var stringCollection = new List() { new ServerResponse(true) }; - var parametersKeys = new List() - { - "serverResponse" - }; + var parametersKeys = new List() { "serverResponse" }; string expected = $"{SERVER_URL}?serverResponse%5B0%5D%5Bpassed%5D=true"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection)); @@ -720,10 +622,7 @@ public void AppendQueryParameter_CustomNullCollectionParameter() StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); IList stringCollection = null; - var parametersKeys = new List() - { - "serverResponse" - }; + var parametersKeys = new List() { "serverResponse" }; string expected = $"{SERVER_URL}"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection)); @@ -737,10 +636,7 @@ public void AppendQueryParameter_WithAlreadyAppendedParameter() StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL + "?x=9"); IList stringCollection = null; - var parametersKeys = new List() - { - "serverResponse" - }; + var parametersKeys = new List() { "serverResponse" }; string expected = $"{SERVER_URL}?x=9"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection)); @@ -760,16 +656,11 @@ public void AppendQueryParameter_IntegerArrayParameter() integers[2] = 5; integers[3] = 6; integers[4] = 7; - TestModel testModel = new TestModel - { - Integers = integers - }; - var parametersKeys = new List() - { - "req" - }; + TestModel testModel = new TestModel { Integers = integers }; + var parametersKeys = new List() { "req" }; - string expected = $"{SERVER_URL}?req%5BIntegers%5D[]=9&req%5BIntegers%5D[]=4&req%5BIntegers%5D[]=5&req%5BIntegers%5D[]=6&req%5BIntegers%5D[]=7&req%5BTestDateTime%5D=0001-01-01T00%3A00%3A00"; + string expected = + $"{SERVER_URL}?req%5BIntegers%5D[]=9&req%5BIntegers%5D[]=4&req%5BIntegers%5D[]=5&req%5BIntegers%5D[]=6&req%5BIntegers%5D[]=7&req%5BTestDateTime%5D=0001-01-01T00%3A00%3A00"; CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, testModel)); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); @@ -783,21 +674,21 @@ public void AppendQueryParameter_CustomCollectionOfCollectionParameter() queryBuilder.Append(SERVER_URL); var stringCollection = new List>() { - new List(){ new ServerResponse(true) } + new List() { new ServerResponse(true) } }; - var parametersKeys = new List() - { - "serverResponse" - }; + var parametersKeys = new List() { "serverResponse" }; - string expected = $"{SERVER_URL}?serverResponse=System.Collections.Generic.List%601%5BAPIMatic.Core.Test.MockTypes.Models.ServerResponse%5D"; - CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), ArraySerialization.Plain); + string expected = + $"{SERVER_URL}?serverResponse=System.Collections.Generic.List%601%5BAPIMatic.Core.Test.MockTypes.Models.ServerResponse%5D"; + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, GetParameters(parametersKeys, stringCollection), + ArraySerialization.Plain); string actual = queryBuilder.ToString(); Assert.AreEqual(expected, actual); } - private IEnumerable> GetParameters(List keys = null, params object[] parameters) + private IEnumerable> GetParameters(List keys = null, + params object[] parameters) { int index = 0; foreach (object parameter in parameters) @@ -809,6 +700,67 @@ private IEnumerable> GetParameters(List key #endregion + #region ExtractQueryParameters + + [Test] + public void ExtractQueryParametersForUrl_WithValidUrl_ReturnsCorrectDictionary() + { + // Arrange + var url = "https://api.example.com/items?limit=10&page=2&search=hello%20world"; + + // Act + var result = CoreHelper.ExtractQueryParametersForUrl(url); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.AreEqual("10", result["limit"]); + Assert.AreEqual("2", result["page"]); + Assert.AreEqual("hello world", result["search"]); + } + + [Test] + public void ExtractQueryParametersForUrl_WithNoQuery_ReturnsEmptyDictionary() + { + // Arrange + var url = "https://api.example.com/items"; + + // Act + var result = CoreHelper.ExtractQueryParametersForUrl(url); + + // Assert + Assert.IsEmpty(result); + } + + [Test] + public void ExtractQueryParametersForUrl_WithEmptyValue_ReturnsEmptyString() + { + // Arrange + var url = "https://api.example.com/items?foo="; + + // Act + var result = CoreHelper.ExtractQueryParametersForUrl(url); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual(string.Empty, result["foo"]); + } + + [Test] + public void ExtractQueryParametersForUrl_WithMissingValuePart_IgnoresIt() + { + // Arrange + var url = "https://api.example.com/items?onlykey"; + + // Act + var result = CoreHelper.ExtractQueryParametersForUrl(url); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual(string.Empty, result["onlykey"]); + } + + #endregion + #region PrepareFormFieldsFromObject [Test] @@ -816,19 +768,15 @@ public void PrepareFormFieldsFromObject_IndexesCustomDictionaryParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var dictionaryData = new Dictionary() - { - {"key1", "value1" }, - {"key2", "value2" } - }; + var dictionaryData = new Dictionary() { { "key1", "value1" }, { "key2", "value2" } }; List> expected = new List>() { - new KeyValuePair("dictionary[key1]", "value1"), - new KeyValuePair("dictionary[key2]", "value2") + new("dictionary[key1]", "value1"), new("dictionary[key2]", "value2") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("dictionary", dictionaryData, ArraySerialization.Indexed); + List> actual = + CoreHelper.PrepareFormFieldsFromObject("dictionary", dictionaryData, ArraySerialization.Indexed); Assert.AreEqual(expected, actual); } @@ -837,19 +785,18 @@ public void PrepareFormFieldsFromObject_PlainCustomListParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var arraySerializations = new List() - { - "alpha", "beta", "gamma" - }; + var arraySerializations = new List() { "alpha", "beta", "gamma" }; List> expected = new List>() { - new KeyValuePair("arraySerialization", "alpha"), - new KeyValuePair("arraySerialization", "beta"), - new KeyValuePair("arraySerialization", "gamma") + new("arraySerialization", "alpha"), + new("arraySerialization", "beta"), + new("arraySerialization", "gamma") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("arraySerialization", arraySerializations, ArraySerialization.Plain); + List> actual = + CoreHelper.PrepareFormFieldsFromObject("arraySerialization", arraySerializations, + ArraySerialization.Plain); Assert.AreEqual(expected, actual); } @@ -858,19 +805,17 @@ public void PrepareFormFieldsFromObject_UnIndexedCustomListParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var arraySerializations = new List() - { - "alpha", "beta", "gamma" - }; + var arraySerializations = new List() { "alpha", "beta", "gamma" }; List> expected = new List>() { - new KeyValuePair("arraySerialization[]", "alpha"), - new KeyValuePair("arraySerialization[]", "beta"), - new KeyValuePair("arraySerialization[]", "gamma") + new("arraySerialization[]", "alpha"), + new("arraySerialization[]", "beta"), + new("arraySerialization[]", "gamma") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("arraySerialization", arraySerializations, ArraySerialization.UnIndexed); + List> actual = CoreHelper.PrepareFormFieldsFromObject("arraySerialization", + arraySerializations, ArraySerialization.UnIndexed); Assert.AreEqual(expected, actual); } @@ -879,19 +824,17 @@ public void PrepareFormFieldsFromObject_UnIndexedCustomListNullParameter() { StringBuilder queryBuilder = new StringBuilder(); queryBuilder.Append(SERVER_URL); - var arraySerializations = new List() - { - "alpha", "beta", "gamma", null - }; + var arraySerializations = new List() { "alpha", "beta", "gamma", null }; List> expected = new List>() { - new KeyValuePair("arraySerialization[]", "alpha"), - new KeyValuePair("arraySerialization[]", "beta"), - new KeyValuePair("arraySerialization[]", "gamma") + new("arraySerialization[]", "alpha"), + new("arraySerialization[]", "beta"), + new("arraySerialization[]", "gamma") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("arraySerialization", arraySerializations, ArraySerialization.UnIndexed); + List> actual = CoreHelper.PrepareFormFieldsFromObject("arraySerialization", + arraySerializations, ArraySerialization.UnIndexed); Assert.AreEqual(expected, actual); } @@ -902,19 +845,18 @@ public void PrepareFormFieldsFromObject_PlainCustomNestedListParameter() queryBuilder.Append(SERVER_URL); var arraySerializations = new List>() { - new List() - { - ArraySerialization.Indexed, ArraySerialization.PSV - } + new() { ArraySerialization.Indexed, ArraySerialization.PSV } }; List> expected = new List>() { - new KeyValuePair("arraySerialization[0][0]", "0"), - new KeyValuePair("arraySerialization[0][1]", "5") + new("arraySerialization[0][0]", "0"), + new("arraySerialization[0][1]", "5") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("arraySerialization", arraySerializations, ArraySerialization.Plain); + List> actual = + CoreHelper.PrepareFormFieldsFromObject("arraySerialization", arraySerializations, + ArraySerialization.Plain); Assert.AreEqual(expected, actual); } @@ -928,9 +870,10 @@ public void PrepareFormFieldsFromObject_IndexedCoreJsonObjectParameter() List> expected = new List>() { - new KeyValuePair("jsonObject[message]", "TestCase") + new("jsonObject[message]", "TestCase") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("jsonObject", jsonObject, ArraySerialization.Indexed); + List> actual = + CoreHelper.PrepareFormFieldsFromObject("jsonObject", jsonObject, ArraySerialization.Indexed); Assert.AreEqual(expected, actual); } @@ -941,9 +884,9 @@ public void PrepareFormFieldsFromObject_IndexedCoreJsonObjectNullValuesParameter queryBuilder.Append(SERVER_URL); JsonObject jsonObject = JsonObject.FromJsonString("{\"message\" : {\"prop\" : null}}"); - List> expected = new List>(); - List> actual = CoreHelper.PrepareFormFieldsFromObject("jsonObject", jsonObject, ArraySerialization.Indexed); + List> actual = + CoreHelper.PrepareFormFieldsFromObject("jsonObject", jsonObject, ArraySerialization.Indexed); Assert.AreEqual(expected, actual); } @@ -954,12 +897,12 @@ public void PrepareFormFieldsFromObject_IndexedCoreJsonValueParameter() queryBuilder.Append(SERVER_URL); JsonValue jsonValue = JsonValue.FromString("TestCase"); - List> expected = new List>() { - new KeyValuePair("jsonValue", "TestCase") + new("jsonValue", "TestCase") }; - List> actual = CoreHelper.PrepareFormFieldsFromObject("jsonValue", jsonValue, ArraySerialization.Indexed); + List> actual = + CoreHelper.PrepareFormFieldsFromObject("jsonValue", jsonValue, ArraySerialization.Indexed); Assert.AreEqual(expected, actual); } @@ -1002,7 +945,8 @@ public void PrepareFormFieldsFromObject_WithAdditionalPropertiesBaseModel() List> expected = new List>() { new("simpleModelWithAdditionalPropertiesBaseModel[requiredProperty]", "Required Field"), - new("simpleModelWithAdditionalPropertiesBaseModel[additionalPropertyKey]", "additionalPropertyValue") + new("simpleModelWithAdditionalPropertiesBaseModel[additionalPropertyKey]", + "additionalPropertyValue") }; List> actual = CoreHelper.PrepareFormFieldsFromObject( @@ -1012,12 +956,12 @@ public void PrepareFormFieldsFromObject_WithAdditionalPropertiesBaseModel() // Assert that all items in 'expected' are found within 'actual' bool containsAllExpectedEntries = expected.All(item => actual.Contains(item)); Assert.IsTrue(containsAllExpectedEntries, "Not all expected entries are present in actual."); - } #endregion #region DeepCloneObject + [Test] public void DeepCloneObject_ServerResponse() { @@ -1025,6 +969,7 @@ public void DeepCloneObject_ServerResponse() ServerResponse actual = CoreHelper.DeepCloneObject(expected); Assert.AreEqual(expected, actual); } + #endregion #region Converters @@ -1076,6 +1021,7 @@ public void IsNullableType_AllTypes() Assert.That(CoreHelper.IsNullableType(typeof(List)), Is.True); Assert.That(CoreHelper.IsNullableType(typeof(Dictionary)), Is.True); } + #endregion } } diff --git a/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs b/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs new file mode 100644 index 00000000..9cdfbed0 --- /dev/null +++ b/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs @@ -0,0 +1,85 @@ +using APIMatic.Core.Utilities.Json; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Utilities.Json +{ + [TestFixture] + public class JsonPointerResolverTest + { + [Test] + public void ResolveScopedJsonValue_NullPointer_ReturnsNull() + { + var result = JsonPointerResolver.ResolveScopedJsonValue(null, "{}", "{}"); + Assert.IsNull(result); + } + + [Test] + public void ResolveScopedJsonValue_InvalidFormat_ReturnsNull() + { + var result = JsonPointerResolver.ResolveScopedJsonValue("$response.body", "{}", "{}"); + Assert.IsNull(result); + } + + [Test] + public void ResolveScopedJsonValue_NullJsonPointer_ReturnsNull() + { + var result = JsonPointerResolver.ResolveScopedJsonValue("$response.body#", "{}", "{}"); + Assert.IsNull(result); + } + + [Test] + public void ResolveScopedJsonValue_ValidBodyPointer_ReturnsValue() + { + var json = @"{""name"":""alice"", ""age"":30}"; + var pointer = "$response.body#/name"; + + var result = JsonPointerResolver.ResolveScopedJsonValue(pointer, json, null); + + Assert.AreEqual("alice", result); + } + + [Test] + public void ResolveScopedJsonValue_ValidHeadersPointer_ReturnsValue() + { + var headers = @"{""content-type"":""application/json""}"; + var pointer = "$response.headers#/content-type"; + + var result = JsonPointerResolver.ResolveScopedJsonValue(pointer, null, headers); + + Assert.AreEqual("application/json", result); + } + + [Test] + public void ResolveScopedJsonValue_PointerNotFound_ReturnsNull() + { + var json = @"{""name"":""alice""}"; + var pointer = "$response.body#/nonexistent"; + + var result = JsonPointerResolver.ResolveScopedJsonValue(pointer, json, null); + + Assert.IsNull(result); + } + + [Test] + public void ResolveScopedJsonValue_UnsupportedPrefix_ReturnsNull() + { + var pointer = "$request.body#/name"; + var json = @"{""name"":""alice""}"; + + var result = JsonPointerResolver.ResolveScopedJsonValue(pointer, json, null); + + Assert.IsNull(result); + } + + [Test] + public void ResolveScopedJsonValue_BooleanToken_ReturnsTokenToString() + { + var jsonBody = @"{ ""isActive"": true }"; + var pointerString = "$response.body#/isActive"; + + var result = JsonPointerResolver.ResolveScopedJsonValue(pointerString, jsonBody, null); + + Assert.AreEqual("True", result); + } + } +} diff --git a/APIMatic.Core.Test/Utilities/PaginationHelper.cs b/APIMatic.Core.Test/Utilities/PaginationHelper.cs new file mode 100644 index 00000000..e29316b1 --- /dev/null +++ b/APIMatic.Core.Test/Utilities/PaginationHelper.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using APIMatic.Core.Test.MockTypes.Models.Pagination; +using NUnit.Framework; +using RichardSzalay.MockHttp; + +namespace APIMatic.Core.Test.Utilities +{ + internal static class PaginationHelper + { + internal static readonly IReadOnlyCollection AllTransactions = new List + { + new("id1", 100, DateTime.UtcNow), + new("id2", 200, DateTime.UtcNow), + new("id3", 300, DateTime.UtcNow), + new("id4", 400, DateTime.UtcNow), + new("id5", 500, DateTime.UtcNow) + }; + + public static List ChunkTransactions(IReadOnlyCollection transactions, int limit) + { + var chunks = transactions + .Select((tx, index) => new { tx, index }) + .GroupBy(x => x.index / limit) + .Select(g => new TransactionsOffset(g.Select(x => x.tx).ToList())) + .ToList(); + + chunks.Add(new TransactionsOffset(new List())); + + return chunks; + } + + public static void MockOffsetPaginatedResponses(this MockHttpMessageHandler handlerMock, + List pages, string baseUrl) + { + int offset = 0; + foreach (var page in pages) + { + var pagedContent = JsonContent.Create(page); + + handlerMock.When($"{baseUrl}?offset={offset}") + .With(req => + { + Assert.AreEqual("application/json", req.Headers.Accept.ToString()); + return true; + }) + .Respond(HttpStatusCode.OK, pagedContent); + + offset += page.Data?.Count ?? 0; + } + } + + public static void MockPagePaginatedResponse(this MockHttpMessageHandler handlerMock, + List pages, string baseUrl, int limit) + { + for (int i = 0; i < pages.Count; i++) + { + var content = JsonContent.Create(pages[i]); + var requestUrl = $"{baseUrl}?page={i}&limit={limit}"; + + handlerMock.When(requestUrl) + .With(req => + { + Assert.AreEqual("application/json", req.Headers.Accept.ToString()); + return true; + }) + .Respond(HttpStatusCode.OK, content); + } + } + + public static List ChunkTransactionsWithCursor(IReadOnlyCollection transactions, int limit) + { + var chunks = transactions + .Select((tx, index) => new { tx, index }) + .GroupBy(x => x.index / limit) + .Select((g, i) => + new TransactionsCursored(g.Select(x => x.tx).ToList(), $"cursor{(i * limit) + g.Count()}")) + .ToList(); + + // Final empty page with null cursor + chunks.Add(new TransactionsCursored(new List(), null)); + return chunks; + } + + public static void MockCursorPaginatedResponses(this MockHttpMessageHandler handlerMock, + List pages, string baseUrl) + { + var cursor = "cursor0"; + foreach (var page in pages) + { + var pagedContent = JsonContent.Create(page); + var url = $"{baseUrl}&cursor={cursor}"; + + handlerMock.When(url) + .With(req => + { + Assert.AreEqual("application/json", req.Headers.Accept.ToString()); + return true; + }) + .Respond(HttpStatusCode.OK, pagedContent); + + cursor = page.NextCursor; + } + } + + public static List ChunkTransactionsWithLinks(IReadOnlyCollection transactions, int limit, + string urlPath) + { + var chunks = transactions + .Select((tx, index) => new { tx, index }) + .GroupBy(x => x.index / limit) + .Select((g, i) => + { + var pageNumber = i + 1; + var nextLink = (i + 1) * limit < transactions.Count + ? $"{urlPath}?page={pageNumber + 1}&limit={limit}" + : null; + + var links = new Links(next: nextLink); + return new TransactionsLinked(g.Select(x => x.tx).ToList(), links); + }) + .ToList(); + + return chunks; + } + + public static void MockLinkedPaginatedResponses(this MockHttpMessageHandler handlerMock, + List pages, string baseUrl) + { + var currentLink = "/transactions?page=1&limit=2"; + for (int i = 0; i < pages.Count; i++) + { + var page = pages[i]; + var currentUrl = $"{baseUrl}{currentLink}"; + + handlerMock.When(currentUrl) + .With(req => + { + Assert.AreEqual("application/json", req.Headers.Accept.ToString()); + return true; + }) + .Respond(HttpStatusCode.OK, JsonContent.Create(page)); + currentLink = page.Links.Next; + } + } + + public static List ChunkTransactionsWithLinksAndPages(IReadOnlyCollection transactions, + int limit, string urlPath) + { + var chunks = transactions + .Select((tx, index) => new { tx, index }) + .GroupBy(x => x.index / limit) + .Select((g, i) => + { + var links = new Links(next: null); + return new TransactionsLinked(g.Select(x => x.tx).ToList(), links); + }) + .ToList(); + + chunks.Add(new TransactionsLinked(new List(), null)); + + return chunks; + } + + public static void MockMultiPaginatedResponses(this MockHttpMessageHandler handlerMock, + List pages, string baseUrl, string basePathUrl, int limit) + { + var currentLink = $"{basePathUrl}?page=1&limit={limit}"; + var linkPaginationPages = pages.Take(1).ToList(); + + for (int i = 0; i < linkPaginationPages.Count; i++) + { + var page = linkPaginationPages[i]; + var currentUrl = $"{baseUrl}{currentLink}"; + + handlerMock.When(currentUrl) + .With(req => + { + Assert.AreEqual("application/json", req.Headers.Accept.ToString()); + return true; + }) + .Respond(HttpStatusCode.OK, JsonContent.Create(page)); + currentLink = page.Links?.Next; + } + + for (int i = 1; i < pages.Count; i++) + { + var content = JsonContent.Create(pages[i]); + var requestUrl = $"{baseUrl}{basePathUrl}?page={i + 1}&limit={limit}"; + + handlerMock.When(requestUrl) + .With(req => + { + Assert.AreEqual("application/json", req.Headers.Accept.ToString()); + return true; + }) + .Respond(HttpStatusCode.OK, content); + } + } + } +} diff --git a/APIMatic.Core/APIMatic.Core.csproj b/APIMatic.Core/APIMatic.Core.csproj index e951f270..a7549d72 100644 --- a/APIMatic.Core/APIMatic.Core.csproj +++ b/APIMatic.Core/APIMatic.Core.csproj @@ -20,6 +20,7 @@ APIMatic;dotnet;core;Core Library https://www.apimatic.io icon.png + 8 @@ -29,10 +30,15 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/APIMatic.Core/ApiCall.cs b/APIMatic.Core/ApiCall.cs index 0ef4dc0d..17a86aee 100644 --- a/APIMatic.Core/ApiCall.cs +++ b/APIMatic.Core/ApiCall.cs @@ -82,6 +82,14 @@ public ApiCall RequestBuilder(RequestBuilder _requestBuilder) + { + requestBuilder = _requestBuilder; + return this; + } + + internal RequestBuilder GetRequestBuilder() => requestBuilder; + /// /// Setup the response handler /// @@ -100,16 +108,22 @@ public ApiCall /// public async Task ExecuteAsync(CancellationToken cancellationToken = default) + => (await ExecuteRequestAsync(cancellationToken)).result; + + public async Task<(ReturnType result, CoreResponse response)> ExecuteRequestAsync( + CancellationToken cancellationToken = default) { requestBuilder.AcceptHeader = responseHandler.AcceptHeader; CoreRequest request = await requestBuilder.Build().ConfigureAwait(false); globalConfiguration.ApiCallback?.OnBeforeHttpRequestEventHandler(request); _sdkLogger.LogRequest(request); - CoreResponse response = await globalConfiguration.HttpClient.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + CoreResponse response = await globalConfiguration.HttpClient.ExecuteAsync(request, cancellationToken) + .ConfigureAwait(false); globalConfiguration.ApiCallback?.OnAfterHttpResponseEventHandler(response); _sdkLogger.LogResponse(response); var context = new CoreContext(request, response); - return responseHandler.Result(context, returnTypeCreator); + var result = responseHandler.Result(context, returnTypeCreator); + return (result, response); } } } diff --git a/APIMatic.Core/Authentication/AuthGroupBuilder.cs b/APIMatic.Core/Authentication/AuthGroupBuilder.cs index 9913b4b7..f94cce26 100644 --- a/APIMatic.Core/Authentication/AuthGroupBuilder.cs +++ b/APIMatic.Core/Authentication/AuthGroupBuilder.cs @@ -21,6 +21,14 @@ internal AuthGroupBuilder(Dictionary authManagers) { authManagersMap = authManagers ?? new Dictionary(); } + + internal AuthGroupBuilder(AuthGroupBuilder other) + { + authManagersMap = new Dictionary(other.authManagersMap); + authManagers = new List(other.authManagers); + validatedAuthManagers = new List(other.validatedAuthManagers); + isAndGroup = other.isAndGroup; + } /// /// Add an authorization scheme to this group, using its name. diff --git a/APIMatic.Core/GlobalConfiguration.cs b/APIMatic.Core/GlobalConfiguration.cs index 1636f823..1895e276 100644 --- a/APIMatic.Core/GlobalConfiguration.cs +++ b/APIMatic.Core/GlobalConfiguration.cs @@ -50,7 +50,7 @@ private GlobalConfiguration(ICoreHttpClientConfiguration httpConfiguration, Dict /// /// /// - public string ServerUrl(Enum server = null) => GlobalRequestBuilder(server).QueryUrl.ToString(); + public string ServerUrl(Enum server = null) => GlobalRequestBuilder(server).GetQueryUrl(); /// /// Returns the global request builder after applying the global configurations @@ -59,8 +59,7 @@ private GlobalConfiguration(ICoreHttpClientConfiguration httpConfiguration, Dict /// public RequestBuilder GlobalRequestBuilder(Enum server = null) { - RequestBuilder requestBuilder = new RequestBuilder(this); - requestBuilder.QueryUrl.Append(_serverUrls[server ?? _defaultServer]); + RequestBuilder requestBuilder = new RequestBuilder(this, _serverUrls[server ?? _defaultServer]); _parameters.Validate().Apply(requestBuilder); return requestBuilder; } diff --git a/APIMatic.Core/Pagination/AsyncPaginator.cs b/APIMatic.Core/Pagination/AsyncPaginator.cs new file mode 100644 index 00000000..f6445d93 --- /dev/null +++ b/APIMatic.Core/Pagination/AsyncPaginator.cs @@ -0,0 +1,119 @@ +using APIMatic.Core.Request; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination.Strategies; + +namespace APIMatic.Core.Pagination +{ + /// + /// Provides asynchronous iteration over a paginated data source by fetching items across multiple pages. + /// + /// + /// The type of individual items being enumerated. + /// + /// + /// The type of metadata associated with each page of results (e.g., pagination tokens, total counts). + /// + public class AsyncPaginator : IAsyncEnumerable + { + private readonly + Func>> + _apiCallExecutor; + + private readonly RequestBuilder _requestBuilder; + private readonly IReadOnlyCollection _paginationStrategies; + private readonly Func> _pagedResponseItemConverter; + + public AsyncPaginator( + Func>> + apiCallExecutor, + RequestBuilder requestBuilder, + Func> pagedResponseItemConverter, + IReadOnlyCollection paginationStrategies) + { + _apiCallExecutor = apiCallExecutor; + _requestBuilder = requestBuilder; + _pagedResponseItemConverter = pagedResponseItemConverter; + _paginationStrategies = paginationStrategies?.Count > 0 + ? paginationStrategies + : throw new ArgumentException("At least one pagination strategy must be provided.", + nameof(paginationStrategies)); + } + + /// + /// Returns an asynchronous enumerator that iterates through all items across multiple paged responses. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous iteration. + /// + /// + /// An asynchronous enumerator that yields items of type from all retrieved pages. + /// + /// + /// This method uses to retrieve pages and applies a converter function to extract items from each page. + /// Supports cancellation via the provided . + /// + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + await foreach (var pageMetaData in GetPagesAsync(cancellationToken)) + { + foreach (var item in _pagedResponseItemConverter(pageMetaData)) + { + yield return item; + } + } + } + + /// + /// Asynchronously retrieves a sequence of page metadata objects, one page at a time. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// + /// An asynchronous stream of representing metadata for each page. + /// + /// + /// Use await foreach to iterate over the returned pages asynchronously. + /// + public async IAsyncEnumerable GetPagesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var paginationContext = PaginationContext.CreateDefault(_requestBuilder); + var initialStrategy = _paginationStrategies.First(); + var requestBuilder = initialStrategy.Apply(paginationContext); + + var result = await initialStrategy.ExecuteAndYieldAsync(requestBuilder, + _apiCallExecutor, _pagedResponseItemConverter, cancellationToken).ConfigureAwait(false); + + if (result == null) yield break; + + yield return result.Value.PageMetadata; + paginationContext = result.Value.Context; + + var strategy = _paginationStrategies.SelectFirstApplicableStrategy(paginationContext); + if (strategy == null) yield break; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + requestBuilder = strategy.Apply(paginationContext); + if (requestBuilder == null) yield break; + + result = await strategy.ExecuteAndYieldAsync(requestBuilder, _apiCallExecutor, + _pagedResponseItemConverter, cancellationToken).ConfigureAwait(false); + if (result == null) yield break; + + yield return result.Value.PageMetadata; + paginationContext = result.Value.Context; + } + } + } +} diff --git a/APIMatic.Core/Pagination/PaginatedResult.cs b/APIMatic.Core/Pagination/PaginatedResult.cs new file mode 100644 index 00000000..935f6746 --- /dev/null +++ b/APIMatic.Core/Pagination/PaginatedResult.cs @@ -0,0 +1,16 @@ +using APIMatic.Core.Types.Sdk; + +namespace APIMatic.Core.Pagination +{ + public class PaginatedResult + { + public CoreResponse Response { get; } + public TPageMetadata PageMetadata { get; } + + internal PaginatedResult(CoreResponse response, TPageMetadata pageMetadata) + { + Response = response; + PageMetadata = pageMetadata; + } + } +} diff --git a/APIMatic.Core/Pagination/PaginationContext.cs b/APIMatic.Core/Pagination/PaginationContext.cs new file mode 100644 index 00000000..257f222e --- /dev/null +++ b/APIMatic.Core/Pagination/PaginationContext.cs @@ -0,0 +1,31 @@ +using APIMatic.Core.Request; +using APIMatic.Core.Types.Sdk; +using APIMatic.Core.Utilities; + +namespace APIMatic.Core.Pagination +{ + public class PaginationContext + { + private readonly CoreResponse _response; + + internal int DataSize { get; } + internal RequestBuilder RequestBuilder { get; } + internal bool HasResponse => _response != null; + internal string ResponseBody => _response?.Body; + internal string ResponseHeaders => CoreHelper.JsonSerialize(_response?.Headers); + + private PaginationContext(RequestBuilder requestBuilder, int dataSize = 0, CoreResponse response = null) + { + _response = response; + + DataSize = dataSize; + RequestBuilder = requestBuilder; + } + + internal static PaginationContext CreateDefault(RequestBuilder requestBuilder) => + new PaginationContext(RequestBuilder.RequestBuilderWithParameters(requestBuilder)); + + internal static PaginationContext Create(int dataSize, CoreResponse response, RequestBuilder requestBuilder) => + new PaginationContext(requestBuilder, dataSize, response); + } +} diff --git a/APIMatic.Core/Pagination/Paginator.cs b/APIMatic.Core/Pagination/Paginator.cs new file mode 100644 index 00000000..d586c9bc --- /dev/null +++ b/APIMatic.Core/Pagination/Paginator.cs @@ -0,0 +1,116 @@ +using APIMatic.Core.Request; +using APIMatic.Core.Utilities; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using APIMatic.Core.Pagination.Strategies; +using System.Threading; + +namespace APIMatic.Core.Pagination +{ + /// + /// Provides synchronous iteration over a paginated data source by fetching items across multiple pages. + /// + /// + /// The type of individual items to be iterated over. + /// + /// + /// The type of metadata associated with each page of results, such as pagination state or response headers. + /// + public class Paginator : IEnumerable + { + private readonly + Func>> + _apiCallExecutor; + + private readonly RequestBuilder _requestBuilder; + private readonly IReadOnlyCollection _paginationStrategies; + private readonly Func> _pagedResponseItemConverter; + + public Paginator( + Func>> + apiCallExecutor, RequestBuilder requestBuilder, + Func> pagedResponseItemConverter, + IReadOnlyCollection paginationStrategies) + { + _apiCallExecutor = apiCallExecutor; + _requestBuilder = requestBuilder; + _pagedResponseItemConverter = pagedResponseItemConverter; + _paginationStrategies = paginationStrategies?.Count > 0 + ? paginationStrategies + : throw new ArgumentException("At least one pagination strategy must be provided.", + nameof(paginationStrategies)); + } + + /// + /// Returns an enumerator that iterates through all items retrieved from a paginated data source. + /// + /// + /// An that iterates through individual items across all pages. + /// + /// + /// This method synchronously retrieves pages using and extracts items from each page + /// using the configured response-to-item converter. + /// + public IEnumerator GetEnumerator() + { + foreach (var pageMetaData in GetPages()) + { + foreach (var item in _pagedResponseItemConverter(pageMetaData)) + { + yield return item; + } + } + } + + /// + /// Returns a non-generic enumerator that iterates through the collection. + /// + /// + /// An that iterates through the collection. + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// Retrieves metadata for each page of results from a paginated data source synchronously. + /// + /// + /// An that yields metadata for each page retrieved using the configured pagination strategies. + /// + /// + /// This method applies pagination strategies to generate requests and yields metadata for each page until no further pages are available. + /// It is the synchronous counterpart to GetPagesAsync. + /// + public IEnumerable GetPages() + { + var paginationContext = PaginationContext.CreateDefault(_requestBuilder); + var initialStrategy = _paginationStrategies.First(); + var requestBuilder = initialStrategy.Apply(paginationContext); + + var result = CoreHelper.RunTask(initialStrategy.ExecuteAndYieldAsync(requestBuilder, _apiCallExecutor, + _pagedResponseItemConverter, CancellationToken.None)); + if (result == null) yield break; + + yield return result.Value.PageMetadata; + paginationContext = result.Value.Context; + + var strategy = _paginationStrategies.SelectFirstApplicableStrategy(paginationContext); + if (strategy == null) yield break; + + while (true) + { + requestBuilder = strategy.Apply(paginationContext); + if (requestBuilder == null) yield break; + + result = CoreHelper.RunTask(strategy.ExecuteAndYieldAsync(requestBuilder, _apiCallExecutor, + _pagedResponseItemConverter, CancellationToken.None)); + if (result == null) yield break; + + yield return result.Value.PageMetadata; + paginationContext = result.Value.Context; + } + } + } +} diff --git a/APIMatic.Core/Pagination/PaginatorExtension.cs b/APIMatic.Core/Pagination/PaginatorExtension.cs new file mode 100644 index 00000000..3683215c --- /dev/null +++ b/APIMatic.Core/Pagination/PaginatorExtension.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Request; + +namespace APIMatic.Core.Pagination +{ + internal static class PaginatorExtension + { + public static IPaginationStrategy SelectFirstApplicableStrategy( + this IEnumerable strategies, PaginationContext context) => strategies + .Select(s => new { Strategy = s, Builder = s.Apply(context) }) + .FirstOrDefault(x => x.Builder != null) + ?.Strategy; + + public static async Task<(TPageMetadata PageMetadata, PaginationContext Context)?> ExecuteAndYieldAsync( + this IPaginationStrategy strategy, + RequestBuilder requestBuilder, + Func>> + apiCallExecutor, + Func> pagedResponseItemConverter, + CancellationToken cancellationToken = default) + { + var result = await apiCallExecutor(requestBuilder, strategy, cancellationToken).ConfigureAwait(false); + var items = pagedResponseItemConverter(result.PageMetadata); + if (items == null || !items.Any()) return null; + + var updatedContext = PaginationContext.Create(items.Count(), result.Response, requestBuilder); + return (result.PageMetadata, updatedContext); + } + } +} diff --git a/APIMatic.Core/Pagination/Strategies/CursorPagination.cs b/APIMatic.Core/Pagination/Strategies/CursorPagination.cs new file mode 100644 index 00000000..feed2647 --- /dev/null +++ b/APIMatic.Core/Pagination/Strategies/CursorPagination.cs @@ -0,0 +1,60 @@ +using APIMatic.Core.Request; +using APIMatic.Core.Utilities.Json; + +namespace APIMatic.Core.Pagination.Strategies +{ + public class CursorPagination : IPaginationStrategy + { + private readonly string _output; + private readonly string _input; + + public string CursorValue { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// JsonPointer of a field received in the response, representing the next cursor. + /// JsonPointer of a field in the request, representing the cursor. + public CursorPagination(string output, string input) + { + _output = output; + _input = input; + } + + /// + /// Applies pagination logic by updating the request builder with a new cursor value + /// extracted from the response, if available. + /// + /// The context containing the current request builder, response data, and headers. + /// + /// A new instance with updated cursor parameter if the response was processed; + /// otherwise, null if no update occurred. + /// + public RequestBuilder Apply(PaginationContext paginationContext) + { + var isUpdated = false; + + var updatedBuilder = RequestBuilder + .RequestBuilderWithParameters(paginationContext.RequestBuilder) + .UpdateParameterByJsonPointer(_input, old => + { + var oldValue = old?.ToString(); + if (!paginationContext.HasResponse) + { + CursorValue = oldValue; + isUpdated = true; + return oldValue; + } + + CursorValue = JsonPointerResolver.ResolveScopedJsonValue( + _output, paginationContext.ResponseBody, paginationContext.ResponseHeaders + ) ?? oldValue; + + isUpdated |= CursorValue != oldValue; + return CursorValue; + }); + + return isUpdated ? updatedBuilder : null; + } + } +} diff --git a/APIMatic.Core/Pagination/Strategies/IPaginationStrategy.cs b/APIMatic.Core/Pagination/Strategies/IPaginationStrategy.cs new file mode 100644 index 00000000..e4e49576 --- /dev/null +++ b/APIMatic.Core/Pagination/Strategies/IPaginationStrategy.cs @@ -0,0 +1,9 @@ +using APIMatic.Core.Request; + +namespace APIMatic.Core.Pagination.Strategies +{ + public interface IPaginationStrategy + { + RequestBuilder Apply(PaginationContext paginationContext); + } +} diff --git a/APIMatic.Core/Pagination/Strategies/LinkPagination.cs b/APIMatic.Core/Pagination/Strategies/LinkPagination.cs new file mode 100644 index 00000000..f19ea187 --- /dev/null +++ b/APIMatic.Core/Pagination/Strategies/LinkPagination.cs @@ -0,0 +1,56 @@ +using APIMatic.Core.Utilities; +using APIMatic.Core.Request; +using APIMatic.Core.Utilities.Json; + +namespace APIMatic.Core.Pagination.Strategies +{ + public class LinkPagination : IPaginationStrategy + { + private readonly string _next; + + public string CurrentLinkValue { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// JsonPointer of a field in the response, representing the next request query URL. + public LinkPagination(string next) + { + _next = next; + } + + /// + /// Applies link-based pagination by extracting the next page URL from the response + /// and updating the request builder with query parameters from that URL. + /// + /// + /// The context containing the current request builder and response data (body and headers). + /// + /// + /// A new with updated query parameters if a next link is found; + /// the original request builder if no response is available; otherwise, null if no link is present. + /// + public RequestBuilder Apply(PaginationContext paginationContext) + { + CurrentLinkValue = JsonPointerResolver.ResolveScopedJsonValue( + _next, + paginationContext.ResponseBody, + paginationContext.ResponseHeaders + ); + + if (!paginationContext.HasResponse) + return RequestBuilder.RequestBuilderWithParameters(paginationContext.RequestBuilder); + + if (string.IsNullOrEmpty(CurrentLinkValue)) + return null; + + return new RequestBuilder(paginationContext.RequestBuilder).Parameters(parameters => + { + foreach (var pair in CoreHelper.ExtractQueryParametersForUrl(CurrentLinkValue)) + { + parameters.Query(query => query.Setup(pair.Key, pair.Value)); + } + }); + } + } +} diff --git a/APIMatic.Core/Pagination/Strategies/NumberPagination.cs b/APIMatic.Core/Pagination/Strategies/NumberPagination.cs new file mode 100644 index 00000000..7ea58c3d --- /dev/null +++ b/APIMatic.Core/Pagination/Strategies/NumberPagination.cs @@ -0,0 +1,57 @@ +using APIMatic.Core.Request; + +namespace APIMatic.Core.Pagination.Strategies +{ + public class NumberPagination : IPaginationStrategy + { + private readonly string _input; + + public int CurrentPage { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// JsonPointer of a field in the request, representing the page number. + public NumberPagination(string input) + { + _input = input; + } + + /// + /// Applies page-based pagination by incrementing the current page number + /// and updating the request builder accordingly. + /// + /// + /// The context containing the current request builder and response metadata (body and headers). + /// + /// + /// A new with the page number incremented if pagination is applied; + /// otherwise, null if no changes are made. + /// + public RequestBuilder Apply(PaginationContext paginationContext) + { + var isUpdated = false; + + var currentRequestBuilder = RequestBuilder.RequestBuilderWithParameters(paginationContext.RequestBuilder) + .UpdateParameterByJsonPointer( + _input, old => + { + var oldValue = int.TryParse(old?.ToString(), out var value) ? value : 0; + + if (!paginationContext.HasResponse) + { + CurrentPage = oldValue; + isUpdated = true; + return old; + } + + var newValue = oldValue + 1; + CurrentPage = newValue; + isUpdated = true; + return newValue; + }); + + return isUpdated ? currentRequestBuilder : null; + } + } +} diff --git a/APIMatic.Core/Pagination/Strategies/OffsetPagination.cs b/APIMatic.Core/Pagination/Strategies/OffsetPagination.cs new file mode 100644 index 00000000..36d5e1ac --- /dev/null +++ b/APIMatic.Core/Pagination/Strategies/OffsetPagination.cs @@ -0,0 +1,58 @@ +using APIMatic.Core.Request; + +namespace APIMatic.Core.Pagination.Strategies +{ + public class OffsetPagination : IPaginationStrategy + { + private readonly string _input; + + public int OffsetValue { get; private set; } + + /// + /// Constructor accepting a JSON pointer to the offset field in the request. + /// + /// JsonPointer of a field in request, representing offset. + public OffsetPagination(string input) + { + _input = input; + } + + /// + /// Applies offset-based pagination by updating the request builder with a new offset value. + /// If the response is not available, it uses the existing offset value from the request. + /// Otherwise, it increments the offset by the size of the data returned in the previous response. + /// + /// + /// The context containing the current request builder, response data, and data size for pagination. + /// + /// + /// A new with the updated offset value if pagination was applied; + /// otherwise, null if no update occurred. + /// + public RequestBuilder Apply(PaginationContext paginationContext) + { + var isUpdated = false; + + var currentRequestBuilder = RequestBuilder.RequestBuilderWithParameters(paginationContext.RequestBuilder) + .UpdateParameterByJsonPointer( + _input, old => + { + var oldValue = int.Parse(old?.ToString() ?? "0"); + + if (!paginationContext.HasResponse) + { + OffsetValue = oldValue; + isUpdated = true; + return old; + } + + var newValue = oldValue + paginationContext.DataSize; + OffsetValue = newValue; + isUpdated = true; + return newValue; + }); + + return isUpdated ? currentRequestBuilder : null; + } + } +} diff --git a/APIMatic.Core/PaginationHandler.cs b/APIMatic.Core/PaginationHandler.cs new file mode 100644 index 00000000..2bd2c45c --- /dev/null +++ b/APIMatic.Core/PaginationHandler.cs @@ -0,0 +1,65 @@ +using APIMatic.Core.Types.Sdk; +using System; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Pagination; +using APIMatic.Core.Pagination.Strategies; +using APIMatic.Core.Request; +namespace APIMatic.Core +{ + public class PaginationHandler + { + private readonly Func> + _requestExecutor; + + private readonly RequestBuilder _requestBuilder; + private readonly Func _createPageResponse; + + private readonly Func>>, + RequestBuilder, TPageableType> _createPageable; + + internal PaginationHandler( + Func> requestExecutor, + RequestBuilder requestBuilder, + Func createPageResponse, + Func>>, + RequestBuilder, TPageableType> createPageable) + { + _requestExecutor = requestExecutor; + _requestBuilder = requestBuilder; + _createPageResponse = createPageResponse; + _createPageable = createPageable; + } + + public TPageableType Paginate() + { + return _createPageable(async (reqBuilder, strategy, cancellationToken) => + { + var (result, response) = + await _requestExecutor(reqBuilder, cancellationToken).ConfigureAwait(false); + var pageMeta = _createPageResponse(result, strategy); + return new PaginatedResult(response, pageMeta); + }, + _requestBuilder); + } + } + + public static class PaginationFactory + { + public static PaginationHandler CreateHandler( + ApiCall apiCall, + Func createPageResponse, + Func>>, + RequestBuilder, TPageableType> createPageable) + where Request : CoreRequest + where Response : CoreResponse + where Context : CoreContext + where ApiException : CoreApiException + { + return new PaginationHandler((builder, token) => + apiCall.RequestBuilder(builder).ExecuteRequestAsync(token), + apiCall.GetRequestBuilder(), createPageResponse, createPageable); + } + } +} diff --git a/APIMatic.Core/Request/Parameters/HeaderParam.cs b/APIMatic.Core/Request/Parameters/HeaderParam.cs index c9c1a9f4..4d206bb8 100644 --- a/APIMatic.Core/Request/Parameters/HeaderParam.cs +++ b/APIMatic.Core/Request/Parameters/HeaderParam.cs @@ -14,16 +14,8 @@ public class HeaderParam : Parameter internal override void Apply(RequestBuilder requestBuilder) { - if (!validated) - { - return; - } - var headerValue = CoreHelper.JsonSerialize(value)?.TrimStart('"').TrimEnd('"'); - if (headerValue == null) - { - return; - } - requestBuilder.headers[key] = headerValue; + if (!validated) return; + requestBuilder.headersParameters[key] = value; } } } diff --git a/APIMatic.Core/Request/Parameters/Parameter.cs b/APIMatic.Core/Request/Parameters/Parameter.cs index 90e66fdb..c6aa03e9 100644 --- a/APIMatic.Core/Request/Parameters/Parameter.cs +++ b/APIMatic.Core/Request/Parameters/Parameter.cs @@ -5,6 +5,9 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using APIMatic.Core.Utilities; +using Microsoft.Json.Pointer; +using Newtonsoft.Json.Linq; namespace APIMatic.Core.Request.Parameters { diff --git a/APIMatic.Core/Request/Parameters/QueryParam.cs b/APIMatic.Core/Request/Parameters/QueryParam.cs index 11529b42..0f0231ff 100644 --- a/APIMatic.Core/Request/Parameters/QueryParam.cs +++ b/APIMatic.Core/Request/Parameters/QueryParam.cs @@ -12,10 +12,7 @@ public class QueryParam : Parameter internal override void Apply(RequestBuilder requestBuilder) { - if (!validated) - { - return; - } + if (!validated) return; requestBuilder.queryParameters[key] = value; } } diff --git a/APIMatic.Core/Request/Parameters/TemplateParam.cs b/APIMatic.Core/Request/Parameters/TemplateParam.cs index e7a4d12a..767be531 100644 --- a/APIMatic.Core/Request/Parameters/TemplateParam.cs +++ b/APIMatic.Core/Request/Parameters/TemplateParam.cs @@ -15,31 +15,6 @@ public class TemplateParam : Parameter { internal TemplateParam() => typeName = "template"; - private string GetReplacerValueForCollection(ICollection collection) - { - var replacedValues = new List(); - var enumerator = collection.GetEnumerator(); - while (enumerator.MoveNext()) - { - replacedValues.Add(GetReplacerValue(enumerator.Current)); - } - return string.Join("/", replacedValues); - } - - private string GetReplacerValue(object value) - { - if (value == null) - { - return string.Empty; - } - if (value is ICollection collection) - { - return GetReplacerValueForCollection(collection); - } - - return CoreHelper.JsonSerialize(value).TrimStart('"').TrimEnd('"'); - } - internal override void Apply(RequestBuilder requestBuilder) { if (!validated) @@ -47,8 +22,8 @@ internal override void Apply(RequestBuilder requestBuilder) return; } CoreHelper.TryGetInnerValueForContainer(value, out var innerValue); - string replacerValue = Uri.EscapeUriString(GetReplacerValue(innerValue ?? value)); - requestBuilder.QueryUrl.Replace($"{{{key}}}", replacerValue); + requestBuilder.templateParameters.Add(key, innerValue ?? value); } } } + diff --git a/APIMatic.Core/Request/RequestBuilder.cs b/APIMatic.Core/Request/RequestBuilder.cs index 33803672..bd1b9d6f 100644 --- a/APIMatic.Core/Request/RequestBuilder.cs +++ b/APIMatic.Core/Request/RequestBuilder.cs @@ -28,31 +28,72 @@ public class RequestBuilder private readonly Parameter.Builder parameters = new Parameter.Builder(); private bool contentTypeAllowed = true; private bool xmlRequest = false; + private readonly StringBuilder queryUrl; private Func bodySerializer = PrepareBody; - - internal readonly Dictionary headers = new Dictionary(); + internal dynamic body; internal Type bodyType; + internal readonly Dictionary headersParameters = new Dictionary(); + internal readonly Dictionary templateParameters = new Dictionary(); internal readonly Dictionary bodyParameters = new Dictionary(); internal readonly List> formParameters = new List>(); internal readonly Dictionary queryParameters = new Dictionary(); - internal RequestBuilder(GlobalConfiguration configuration) + internal RequestBuilder(GlobalConfiguration configuration, string serverUrl) { this.configuration = configuration; + queryUrl = new StringBuilder(serverUrl); authGroup = new AuthGroupBuilder(configuration.AuthManagers); } + + internal RequestBuilder(RequestBuilder source) + { + this.configuration = source.configuration; + this.retryOption = source.retryOption; + this.contentTypeAllowed = source.contentTypeAllowed; + this.httpMethod = source.httpMethod; + this.queryUrl = new StringBuilder(source.queryUrl.ToString()); + this.AcceptHeader = source.AcceptHeader; + this.ArraySerialization = source.ArraySerialization; + this.HasBinaryResponse = source.HasBinaryResponse; + this.xmlRequest = source.xmlRequest; + this.bodySerializer = source.bodySerializer; + this.body = source.body; + this.bodyType = source.bodyType; - internal StringBuilder QueryUrl { get; } = new StringBuilder(); - + this.headersParameters = new Dictionary(source.headersParameters); + this.templateParameters = new Dictionary(source.templateParameters); + this.bodyParameters = new Dictionary(source.bodyParameters); + this.formParameters = new List>(source.formParameters); + this.queryParameters = new Dictionary(source.queryParameters); + this.authGroup = new AuthGroupBuilder(source.authGroup); + } + + internal static RequestBuilder RequestBuilderWithParameters(RequestBuilder source) + { + var requestBuilder = new RequestBuilder(source); + source.parameters.Validate().Apply(requestBuilder); + return requestBuilder; + } + internal ContentType AcceptHeader { get; set; } = ContentType.SCALAR; internal ArraySerialization ArraySerialization { get; set; } internal bool HasBinaryResponse { get; set; } + internal string GetQueryUrl() + { + var queryBuilder = new StringBuilder(this.queryUrl.ToString()); + foreach (var kvp in templateParameters) + { + queryBuilder.Replace($"{{{kvp.Key}}}", CoreHelper.GetTemplateReplacerValue(kvp.Value)); + } + return queryBuilder.ToString(); + } + private Type BodyType => bodyParameters.Any() ? typeof(object) : bodyType; - + /// /// Required: Sets the API route and http method /// @@ -62,7 +103,7 @@ internal RequestBuilder(GlobalConfiguration configuration) public RequestBuilder Setup(HttpMethod httpMethod, string path) { this.httpMethod = httpMethod; - QueryUrl.Append(path); + queryUrl.Append(path); return this; } @@ -142,6 +183,65 @@ public RequestBuilder XmlBodySerializer(Func xmlSerializer) return this; } + internal RequestBuilder UpdateParameterByJsonPointer(string pointerString, Func setter) + { + var index = pointerString.IndexOf('#'); + if (index < 0) + return this; + + var prefix = pointerString.Substring(0, index); + var pointer = pointerString.Substring(index + 1); + + switch (prefix) + { + case "$request.headers": + RequestBuilderExtensions.UpdateRequestParametersByPointer(headersParameters, pointer, setter); + break; + case "$request.path": + RequestBuilderExtensions.UpdateRequestParametersByPointer(templateParameters, pointer, setter); + break; + case "$request.query": + RequestBuilderExtensions.UpdateRequestParametersByPointer(queryParameters, pointer, setter); + break; + case "$request.body": + UpdateBodyByPointer(setter, pointer); + break; + } + + return this; + } + + private void UpdateBodyByPointer(Func setter, string pointer) + { + if (bodyParameters.Any()) + { + RequestBuilderExtensions.UpdateRequestParametersByPointer(bodyParameters, pointer, setter); + return; + } + + if (body is null) + { + RequestBuilderExtensions.UpdateFormParameterValueByPointer(formParameters, pointer, setter); + return; + } + + UpdateDynamicBodyContent(setter, pointer); + } + + private void UpdateDynamicBodyContent(Func setter, string pointer) + { + if (body is CoreFileStreamInfo || setter == null) + return; + + if (body is string || string.IsNullOrEmpty(pointer)) + { + body = setter(body); + return; + } + + body = RequestBuilderExtensions.UpdateValueByPointer(body, pointer, setter); + } + /// /// This applies all the configuration and build an instance of CoreRequest /// @@ -151,17 +251,15 @@ public async Task Build() parameters.Validate().Apply(this); configuration.RuntimeParameters.Validate().Apply(this); await authGroup.Apply(this).ConfigureAwait(false); - CoreHelper.AppendUrlWithQueryParameters(QueryUrl, queryParameters, ArraySerialization); + var queryBuilder = new StringBuilder(GetQueryUrl()); + CoreHelper.AppendUrlWithQueryParameters(queryBuilder, queryParameters, ArraySerialization); body = bodyParameters.Any() ? bodyParameters : body; AppendContentTypeHeader(); AppendAcceptHeader(); - return new CoreRequest(httpMethod, CoreHelper.CleanUrl(QueryUrl), headers, bodySerializer(body), formParameters, queryParameters) - { - RetryOption = retryOption, - HasBinaryResponse = HasBinaryResponse - }; + return new CoreRequest(httpMethod, CoreHelper.CleanUrl(queryBuilder), headersParameters.ToCoreRequestHeaders(), bodySerializer(body), + formParameters, queryParameters) { RetryOption = retryOption, HasBinaryResponse = HasBinaryResponse }; } - + private void AppendContentTypeHeader() { if (!ContentHeaderKeyRequired("content-type")) @@ -174,20 +272,20 @@ private void AppendContentTypeHeader() } if (xmlRequest) { - headers.Add("content-type", ContentType.XML.GetValue()); + headersParameters.Add("content-type", ContentType.XML.GetValue()); return; } if (body is Stream || body is byte[]) { - headers.Add("content-type", ContentType.BINARY.GetValue()); + headersParameters.Add("content-type", ContentType.BINARY.GetValue()); return; } if (CoreHelper.IsScalarType(BodyType)) { - headers.Add("content-type", ContentType.SCALAR.GetValue()); + headersParameters.Add("content-type", ContentType.SCALAR.GetValue()); return; } - headers.Add("content-type", ContentType.JSON_UTF8.GetValue()); + headersParameters.Add("content-type", ContentType.JSON_UTF8.GetValue()); } private void AppendAcceptHeader() @@ -200,7 +298,7 @@ private void AppendAcceptHeader() { return; } - headers.Add("accept", AcceptHeader.GetValue()); + headersParameters.Add("accept", AcceptHeader.GetValue()); } private bool ContentHeaderKeyRequired(string key) @@ -209,7 +307,7 @@ private bool ContentHeaderKeyRequired(string key) { return false; } - if (headers.Any(p => p.Key.EqualsIgnoreCase(key))) + if (headersParameters.Any(p => p.Key.EqualsIgnoreCase(key))) { return false; } diff --git a/APIMatic.Core/Request/RequestBuilderExtensions.cs b/APIMatic.Core/Request/RequestBuilderExtensions.cs new file mode 100644 index 00000000..ff654083 --- /dev/null +++ b/APIMatic.Core/Request/RequestBuilderExtensions.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using APIMatic.Core.Utilities; +using Microsoft.Json.Pointer; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace APIMatic.Core.Request +{ + internal static class RequestBuilderExtensions + { + public static Dictionary ToCoreRequestHeaders(this Dictionary headers) + { + var result = new Dictionary(); + + foreach (var kvp in headers) + { + var serialized = CoreHelper.JsonSerialize(kvp.Value)?.TrimStart('"').TrimEnd('"'); + if (serialized != null) + { + result[kvp.Key] = serialized; + } + } + + return result; + } + + public static void UpdateRequestParametersByPointer(Dictionary requestParameters, + string pointer, + Func setter) + { + var updated = + UpdateValueByPointer(requestParameters, pointer, setter); + + foreach (var kvp in updated) + { + if (!requestParameters.TryGetValue(kvp.Key, out var existingValue) || !Equals(existingValue, kvp.Value)) + { + requestParameters[kvp.Key] = kvp.Value; + } + } + } + + public static void UpdateFormParameterValueByPointer(List> formParameters, + string pointer, + Func setter) + { + var formParameterDictionary = formParameters + .GroupBy(p => p.Key) + .ToDictionary(g => g.Key, g => g.Last().Value); // Handles duplicate keys safely + + var updated = + UpdateValueByPointer(formParameterDictionary, pointer, setter); + + formParameters.Clear(); + formParameters.AddRange(updated.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value))); + } + + public static T UpdateValueByPointer(T value, string pointer, Func updater) + { + if (EqualityComparer.Default.Equals(value, default) || string.IsNullOrEmpty(pointer) || updater == null) + return value; + + try + { + var type = value.GetType(); + var rootToken = JToken.Parse(JsonConvert.SerializeObject(value)); + var tokenToUpdate = new JsonPointer(pointer).Evaluate(rootToken); + + var updatedValue = updater(tokenToUpdate.ToObject()); + if (updatedValue == null) return value; + + ReplaceToken(tokenToUpdate, updatedValue); + + return typeof(T) == typeof(Dictionary) + ? (T)(object)ReconstructDictionary((Dictionary)(object)value, (JObject)rootToken) + : (T)JsonConvert.DeserializeObject(rootToken.ToString(), type); + } + catch + { + return value; + } + } + + private static void ReplaceToken(JToken token, object newValue) + { + var newToken = JToken.FromObject(newValue); + switch (token.Parent) + { + case JProperty prop: + prop.Value = newToken; + break; + case JArray array: + array[array.IndexOf(token)] = newToken; + break; + } + } + + private static Dictionary ReconstructDictionary( + Dictionary original, + JObject updatedRoot) + { + var typeMap = original.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.GetType() ?? typeof(object)); + return updatedRoot.Properties().ToDictionary( + prop => prop.Name, + prop => prop.Value.ToObject(typeMap.TryGetValue(prop.Name, out var t) ? t : typeof(object)) + ); + } + } +} + diff --git a/APIMatic.Core/Utilities/CoreHelper.cs b/APIMatic.Core/Utilities/CoreHelper.cs index 64819b0d..0ba74290 100644 --- a/APIMatic.Core/Utilities/CoreHelper.cs +++ b/APIMatic.Core/Utilities/CoreHelper.cs @@ -13,9 +13,11 @@ using APIMatic.Core.Http.Configuration; using APIMatic.Core.Types; using APIMatic.Core.Types.Sdk; +using Microsoft.Json.Pointer; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; +using System.Net; namespace APIMatic.Core.Utilities { @@ -58,14 +60,7 @@ public static string JsonSerialize(object obj, JsonConverter converter = null) settings.PreserveReferencesHandling = PreserveReferencesHandling.Objects; } - if (converter == null) - { - settings.Converters.Add(new IsoDateTimeConverter()); - } - else - { - settings.Converters.Add(converter); - } + settings.Converters.Add(converter ?? new IsoDateTimeConverter()); return JsonConvert.SerializeObject(obj, Formatting.None, settings); } @@ -79,19 +74,25 @@ public static string JsonSerialize(object obj, JsonConverter converter = null) /// The deserialized object. public static T JsonDeserialize(string json, JsonConverter converter = null) { - if (string.IsNullOrWhiteSpace(json)) - { - return default; - } + return string.IsNullOrWhiteSpace(json) + ? default + : JsonConvert.DeserializeObject(json, converter ?? new IsoDateTimeConverter()); + } - if (converter == null) - { - return JsonConvert.DeserializeObject(json, new IsoDateTimeConverter()); - } - else + internal static IDictionary ExtractQueryParametersForUrl(string queryUrl) + { + var queryParams = queryUrl.Split('?'); + + if (queryParams.Length <= 1) { - return JsonConvert.DeserializeObject(json, converter); + return new Dictionary(); } + + return queryParams[1].Split('&').Select(param => param.Split(new []{'='}, 2)) + .ToDictionary( + pair => WebUtility.UrlDecode(pair[0]), + pair => (object)(pair.Length > 1 ? WebUtility.UrlDecode(pair[1]) : string.Empty) + ); } /// @@ -121,6 +122,30 @@ public static void AppendUrlWithQueryParameters(StringBuilder queryBuilder, IEnu } } + private static string GetTemplateReplacerValueForCollection(ICollection collection) + { + var replacedValues = new List(); + var enumerator = collection.GetEnumerator(); + while (enumerator.MoveNext()) + { + replacedValues.Add(GetTemplateReplacerValue(enumerator.Current)); + } + return string.Join("/", replacedValues); + } + + public static string GetTemplateReplacerValue(object value) + { + switch (value) + { + case null: + return string.Empty; + case ICollection collection: + return GetTemplateReplacerValueForCollection(collection); + default: + return CoreHelper.JsonSerialize(value).TrimStart('"').TrimEnd('"'); + } + } + private static void AppendParameters(StringBuilder queryBuilder, ArraySerialization arraySerialization, KeyValuePair pair) { string paramKeyValPair; diff --git a/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs b/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs new file mode 100644 index 00000000..c9ab7d08 --- /dev/null +++ b/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs @@ -0,0 +1,57 @@ +using System.Linq; +using Microsoft.Json.Pointer; +using Newtonsoft.Json.Linq; + +namespace APIMatic.Core.Utilities.Json +{ + internal static class JsonPointerResolver + { + public static string ResolveScopedJsonValue(string pointerString, string jsonBody, string jsonHeaders) + { + if (string.IsNullOrEmpty(pointerString) || !pointerString.Contains('#')) + return null; + + var (scope, path) = (pointerString.Split('#')[0], pointerString.Split('#')[1]); + if (string.IsNullOrEmpty(path)) + return null; + + var jsonPointer = new JsonPointer(path); + + switch (scope) + { + case "$response.body": + return ExtractValueByPointer(jsonPointer, jsonBody); + case "$response.headers": + return ExtractValueByPointer(jsonPointer, jsonHeaders); + default: + return null; + } + } + + private static string ExtractValueByPointer(JsonPointer jsonPointer, string json) + { + if (json == null) return null; + + var jsonObject = JObject.Parse(json); + JToken jsonToken; + try + { + jsonToken = jsonPointer.Evaluate(jsonObject); + } + catch + { + return null; + } + + switch (jsonToken.Type) + { + case JTokenType.Null: + return null; + case JTokenType.String: + return jsonToken.Value(); + default: + return jsonToken.ToString(); + } + } + } +}