From 55b250379ea50d7ef0216c86338a2f3700a99974 Mon Sep 17 00:00:00 2001 From: Kasper Christiansen Date: Sun, 14 Jul 2024 21:37:31 +0200 Subject: [PATCH] Additional offer and catalog endpoint support --- README.md | 11 ++- .../ITjekClient.cs | 23 +++-- .../Models/Catalog.cs | 69 ++++++++++++++ .../Models/Dimensions.cs | 12 +++ src/Kwtc.Tjek.Client/TjekClient.cs | 94 ++++++++++++++++--- .../{UnitTests => }/TjekClientTests.cs | 64 ++++++------- 6 files changed, 216 insertions(+), 57 deletions(-) create mode 100644 src/Kwtc.Tjek.Client.Abstractions/Models/Catalog.cs create mode 100644 src/Kwtc.Tjek.Client.Abstractions/Models/Dimensions.cs rename test/Kwtc.Tjek.Client.Tests/{UnitTests => }/TjekClientTests.cs (78%) diff --git a/README.md b/README.md index f3a7c7d..c09d388 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,17 @@ As the heading states this is a .NET API client for tjek.com > **Warning** > This is work in progress > -> Minor and patch version bumps may break your code until version 1.0.0 is released +> Minor and patch version bumps may have breaking changes until version 1.0.0 is released ## Supported endpoint status - [x] /v2/offers/search -- [ ] /v2/offers -- [ ] /v2/offers/{offerId} +- [x] /v2/offers +- [x] /v2/offers/{offerId} +- [x] /v2/catalogs +- [ ] /v2/catalogs/{catalogId} +- [ ] /v2/catalogs/{catalogId}/pages +- [ ] /v2/catalogs/{catalogId}/hotspots +- [ ] /v2/catalogs/{catalogId}/page_decorations ## How to use Add configuration to appsettings.json diff --git a/src/Kwtc.Tjek.Client.Abstractions/ITjekClient.cs b/src/Kwtc.Tjek.Client.Abstractions/ITjekClient.cs index 4cf619c..3ffc2a0 100644 --- a/src/Kwtc.Tjek.Client.Abstractions/ITjekClient.cs +++ b/src/Kwtc.Tjek.Client.Abstractions/ITjekClient.cs @@ -7,7 +7,7 @@ public interface ITjekClient /// /// Search for offers /// - public Task> Search( + public Task> SearchOffers( string query, string? dealerId = null, string? catalogId = null, @@ -16,11 +16,9 @@ public Task> Search( CancellationToken cancellationToken = default); /// - /// NOT IMPLEMENTED - /// - /// Get list current offers + /// Get list of current offers /// - public Task> List( + public Task> ListOffers( string? dealerId = null, string? catalogId = null, string? publicationType = null, @@ -30,9 +28,18 @@ public Task> List( CancellationToken cancellationToken = default); /// - /// NOT IMPLEMENTED - /// /// Get offer by id /// - public Task Offer(string id, CancellationToken cancellationToken = default); + public Task GetOffer(string id, CancellationToken cancellationToken = default); + + /// + /// Get list of catalogs + /// + public Task> ListCatalogs( + string? dealerId = null, + string? publicationType = null, + int? limit = null, + int? offset = null, + string? orderBy = null, + CancellationToken cancellationToken = default); } diff --git a/src/Kwtc.Tjek.Client.Abstractions/Models/Catalog.cs b/src/Kwtc.Tjek.Client.Abstractions/Models/Catalog.cs new file mode 100644 index 0000000..0f648af --- /dev/null +++ b/src/Kwtc.Tjek.Client.Abstractions/Models/Catalog.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace Kwtc.Tjek.Client.Abstractions.Models; + +public class Catalog +{ + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + [JsonPropertyName("ern")] + public string Ern { get; set; } = default!; + + [JsonPropertyName("run_from")] + public string FromDate { get; set; } = default!; + + [JsonPropertyName("store_id")] + public string? StoreId { get; set; } + + [JsonPropertyName("store_url")] + public string StoreUrl { get; set; } = default!; + + [JsonPropertyName("images")] + public Images Images { get; set; } = default!; + + [JsonPropertyName("types")] + public string[] Types { get; set; } = default!; + + [JsonPropertyName("incito_publication_id")] + public string? IncitoPublicationId { get; set; } + + [JsonPropertyName("all_stores")] + public bool AllStores { get; set; } + + [JsonPropertyName("dealer_url")] + public string DealerUrl { get; set; } = default!; + + [JsonPropertyName("branding")] + public Branding Branding { get; set; } = default!; + + [JsonPropertyName("pdf_url")] + public string PdfUrl { get; set; } = default!; + + [JsonPropertyName("label")] + public string Label { get; set; } = default!; + + [JsonPropertyName("run_till")] + public string ToDate { get; set; } = default!; + + [JsonPropertyName("background")] + public string Background { get; set; } = default!; + + [JsonPropertyName("category_ids")] + public string[] CategoryIds { get; set; } = default!; + + [JsonPropertyName("offer_count")] + public long OfferCount { get; set; } + + [JsonPropertyName("page_count")] + public long PageCount { get; set; } + + [JsonPropertyName("dealer_id")] + public string DealerId { get; set; } = default!; + + [JsonPropertyName("dealer")] + public Dealer Dealer { get; set; } = default!; + + [JsonPropertyName("dimensions")] + public Dimensions Dimensions { get; set; } = default!; +} diff --git a/src/Kwtc.Tjek.Client.Abstractions/Models/Dimensions.cs b/src/Kwtc.Tjek.Client.Abstractions/Models/Dimensions.cs new file mode 100644 index 0000000..2c9d353 --- /dev/null +++ b/src/Kwtc.Tjek.Client.Abstractions/Models/Dimensions.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Kwtc.Tjek.Client.Abstractions.Models; + +public class Dimensions +{ + [JsonPropertyName("width")] + public decimal Width { get; set; } + + [JsonPropertyName("height")] + public decimal Height { get; set; } +} diff --git a/src/Kwtc.Tjek.Client/TjekClient.cs b/src/Kwtc.Tjek.Client/TjekClient.cs index e75539d..df53238 100644 --- a/src/Kwtc.Tjek.Client/TjekClient.cs +++ b/src/Kwtc.Tjek.Client/TjekClient.cs @@ -15,7 +15,7 @@ public TjekClient(IHttpClientFactory httpClientFactory) this.httpClientFactory = httpClientFactory; } - public async Task> Search( + public async Task> SearchOffers( string query, string? dealerId = null, string? catalogId = null, @@ -27,10 +27,9 @@ public async Task> Search( // Build query string var builder = new StringBuilder(); - builder.Append($"?query={query.ToValidUri()}"); - var queryString = BuildQueryString(new Dictionary { + { "query", $"{query}" }, { "dealer_id", $"{dealerId}" }, { "catalog_id", $"{catalogId}" }, { "types", $"{publicationType}" }, @@ -53,24 +52,88 @@ public async Task> Search( return result ?? []; } - /// - /// NOT IMPLEMENTED - /// - public Task> List(string? dealerId = null, string? catalogId = null, string? publicationType = null, int? limit = null, int? offset = null, string? orderBy = null, CancellationToken cancellationToken = default) + public async Task> ListOffers(string? dealerId = null, string? catalogId = null, string? publicationType = null, int? limit = null, int? offset = null, string? orderBy = null, CancellationToken cancellationToken = default) + { + // Build query string + var builder = new StringBuilder(); + var queryString = BuildQueryString(new Dictionary + { + { "dealer_id", $"{dealerId}" }, + { "catalog_id", $"{catalogId}" }, + { "types", $"{publicationType}" }, + { "order_by", $"{orderBy ?? "page"}" }, + { "offset", $"{offset}" }, + { "limit", $"{limit}" }, + }, builder); + + var client = this.httpClientFactory.CreateClient(Constants.HttpClientName); + var response = await client.GetAsync($"v2/offers{queryString}", cancellationToken); + + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + if (contentStream.Length == 0) + { + throw new HttpRequestException(HttpRequestError.InvalidResponse, "Response content is empty"); + } + + var result = await JsonSerializer.DeserializeAsync>(contentStream, cancellationToken: cancellationToken); + + return result ?? []; + } + + public async Task GetOffer(string id, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + Guard.IsNotNullOrEmpty(id, nameof(id)); + + var client = this.httpClientFactory.CreateClient(Constants.HttpClientName); + var response = await client.GetAsync($"v2/offers/{id}", cancellationToken); + + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + if (contentStream.Length == 0) + { + throw new HttpRequestException(HttpRequestError.InvalidResponse, "Response content is empty"); + } + + return await JsonSerializer.DeserializeAsync(contentStream, cancellationToken: cancellationToken); } - /// - /// NOT IMPLEMENTED - /// - public Task Offer(string id, CancellationToken cancellationToken = default) + public async Task> ListCatalogs(string? dealerId = null, string? publicationType = null, int? limit = null, int? offset = null, string? orderBy = null, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + // Build query string + var builder = new StringBuilder(); + var queryString = BuildQueryString(new Dictionary + { + { "dealer_id", $"{dealerId}" }, + { "types", $"{publicationType}" }, + { "order_by", $"{orderBy}" }, + { "offset", $"{offset}" }, + { "limit", $"{limit}" }, + }, builder); + + var client = this.httpClientFactory.CreateClient(Constants.HttpClientName); + var response = await client.GetAsync($"v2/catalogs{queryString}", cancellationToken); + + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + if (contentStream.Length == 0) + { + throw new HttpRequestException(HttpRequestError.InvalidResponse, "Response content is empty"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var result = await JsonSerializer.DeserializeAsync>(contentStream, cancellationToken: cancellationToken); + + return result ?? []; } private static string BuildQueryString(IDictionary parameters, StringBuilder builder) { + var i = 0; foreach (var parameter in parameters) { if (string.IsNullOrEmpty(parameter.Value)) @@ -78,7 +141,10 @@ private static string BuildQueryString(IDictionary parameters, S continue; } - builder.Append($"&{parameter.Key}={parameter.Value.ToValidUri()}"); + builder.Append(i == 0 ? "?" : "&"); + builder.Append($"{parameter.Key}={parameter.Value.ToValidUri()}"); + + i++; } return builder.ToString(); diff --git a/test/Kwtc.Tjek.Client.Tests/UnitTests/TjekClientTests.cs b/test/Kwtc.Tjek.Client.Tests/TjekClientTests.cs similarity index 78% rename from test/Kwtc.Tjek.Client.Tests/UnitTests/TjekClientTests.cs rename to test/Kwtc.Tjek.Client.Tests/TjekClientTests.cs index 8ea700b..90c5ebc 100644 --- a/test/Kwtc.Tjek.Client.Tests/UnitTests/TjekClientTests.cs +++ b/test/Kwtc.Tjek.Client.Tests/TjekClientTests.cs @@ -5,7 +5,7 @@ using Moq; using Moq.Protected; -namespace Kwtc.Tjek.Client.Tests.UnitTests; +namespace Kwtc.Tjek.Client.Tests; public class TjekClientTests { @@ -14,13 +14,13 @@ public class TjekClientTests [Theory] [InlineData(null)] [InlineData("")] - public Task Search_InvalidSearchTerm_ShouldThrow(string searchTerm) + public Task SearchOffers_InvalidSearchTerm_ShouldThrow(string searchTerm) { // Arrange - var sut = GetSut(); + var sut = this.GetSut(); // Act - var act = async () => await sut.Search(searchTerm); + var act = async () => await sut.SearchOffers(searchTerm); // Assert return act.Should().ThrowAsync(); @@ -34,21 +34,21 @@ public Task Search_InvalidSearchTerm_ShouldThrow(string searchTerm) [InlineData("Valid.Search.Term")] [InlineData("Valid\\Search\\Term")] [InlineData("Vælid.Seårch.Tørm")] - public async Task Search_ValidSearchTermExpectedResponse_ShouldSendRequest(string searchTerm) + public async Task SearchOffers_ValidSearchTermExpectedResponse_ShouldSendRequest(string searchTerm) { // Arrange var httpClient = GetMockedClient( uri: $"search?query={searchTerm.ToValidUri()}", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(searchTerm); + await sut.SearchOffers(searchTerm); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); @@ -61,7 +61,7 @@ public async Task Search_ValidSearchTermExpectedResponse_ShouldSendRequest(strin [InlineData(HttpStatusCode.NotFound)] [InlineData(HttpStatusCode.InternalServerError)] [InlineData(HttpStatusCode.MethodNotAllowed)] - public async Task Search_ValidSearchTermUnexpectedResponseCode_ShouldThrow(HttpStatusCode statusCode) + public async Task SearchOffers_ValidSearchTermUnexpectedResponseCode_ShouldThrow(HttpStatusCode statusCode) { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -69,14 +69,14 @@ public async Task Search_ValidSearchTermUnexpectedResponseCode_ShouldThrow(HttpS uri: $"search?query={searchTerm.ToValidUri()}", statusCode: statusCode ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - var act = () => sut.Search(searchTerm); + var act = () => sut.SearchOffers(searchTerm); // Assert await act.Should().ThrowAsync(); @@ -85,7 +85,7 @@ public async Task Search_ValidSearchTermUnexpectedResponseCode_ShouldThrow(HttpS } [Fact] - public async Task Search_ValidSearchTermUnexpectedResponseContent_ShouldThrow() + public async Task SearchOffers_ValidSearchTermUnexpectedResponseContent_ShouldThrow() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -93,14 +93,14 @@ public async Task Search_ValidSearchTermUnexpectedResponseContent_ShouldThrow() uri: $"search?query={searchTerm.ToValidUri()}", content: string.Empty ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - var act = () => sut.Search(searchTerm); + var act = () => sut.SearchOffers(searchTerm); // Assert await act.Should().ThrowAsync().WithMessage("*Response content is empty*"); @@ -109,7 +109,7 @@ public async Task Search_ValidSearchTermUnexpectedResponseContent_ShouldThrow() } [Fact] - public async Task Search_ValidSearchTermAndDealerIdExpectedResponse_ShouldSendRequest() + public async Task SearchOffers_ValidSearchTermAndDealerIdExpectedResponse_ShouldSendRequest() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -117,21 +117,21 @@ public async Task Search_ValidSearchTermAndDealerIdExpectedResponse_ShouldSendRe uri: $"search?query={searchTerm.ToValidUri()}&dealer_id=123", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(query: searchTerm, dealerId: "123"); + await sut.SearchOffers(query: searchTerm, dealerId: "123"); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); } [Fact] - public async Task Search_ValidSearchTermAndDealerIdLimitExpectedResponse_ShouldSendRequest() + public async Task SearchOffers_ValidSearchTermAndDealerIdLimitExpectedResponse_ShouldSendRequest() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -139,21 +139,21 @@ public async Task Search_ValidSearchTermAndDealerIdLimitExpectedResponse_ShouldS uri: $"search?query={searchTerm.ToValidUri()}&dealer_id=123&limit=10", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(query: searchTerm, dealerId: "123", limit: 10); + await sut.SearchOffers(query: searchTerm, dealerId: "123", limit: 10); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); } [Fact] - public async Task Search_ValidSearchTermAndDealerIdNullExpectedResponse_RequestShouldNotContainDealerId() + public async Task SearchOffers_ValidSearchTermAndDealerIdNullExpectedResponse_RequestShouldNotContainDealerId() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -161,21 +161,21 @@ public async Task Search_ValidSearchTermAndDealerIdNullExpectedResponse_RequestS uri: $"search?query={searchTerm.ToValidUri()}&limit=10", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(query: searchTerm, dealerId: null, limit: 10); + await sut.SearchOffers(query: searchTerm, dealerId: null, limit: 10); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); } [Fact] - public async Task Search_ValidSearchTermAndCatalogIdExpectedResponse_ShouldSendRequest() + public async Task SearchOffers_ValidSearchTermAndCatalogIdExpectedResponse_ShouldSendRequest() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -183,21 +183,21 @@ public async Task Search_ValidSearchTermAndCatalogIdExpectedResponse_ShouldSendR uri: $"search?query={searchTerm.ToValidUri()}&catalog_id=123", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(query: searchTerm, catalogId: "123"); + await sut.SearchOffers(query: searchTerm, catalogId: "123"); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); } [Fact] - public async Task Search_ValidSearchTermAndPublicationTypeExpectedResponse_ShouldSendRequest() + public async Task SearchOffers_ValidSearchTermAndPublicationTypeExpectedResponse_ShouldSendRequest() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -205,21 +205,21 @@ public async Task Search_ValidSearchTermAndPublicationTypeExpectedResponse_Shoul uri: $"search?query={searchTerm.ToValidUri()}&types=123", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(query: searchTerm, publicationType: "123"); + await sut.SearchOffers(query: searchTerm, publicationType: "123"); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); } [Fact] - public async Task Search_ValidSearchTermAndLimitExpectedResponse_ShouldSendRequest() + public async Task SearchOffers_ValidSearchTermAndLimitExpectedResponse_ShouldSendRequest() { // Arrange const string searchTerm = "ValidSearchTerm"; @@ -227,19 +227,19 @@ public async Task Search_ValidSearchTermAndLimitExpectedResponse_ShouldSendReque uri: $"search?query={searchTerm.ToValidUri()}&limit=10", content: JsonSerializer.Serialize(new List { new() }) ); - var sut = GetSut(); + var sut = this.GetSut(); this.httpClientFactoryMock .Setup(x => x.CreateClient(Constants.HttpClientName)) .Returns(httpClient); // Act - await sut.Search(query: searchTerm, limit: 10); + await sut.SearchOffers(query: searchTerm, limit: 10); // Assert this.httpClientFactoryMock.Verify(x => x.CreateClient(Constants.HttpClientName), Times.Once); } - + private static HttpClient GetMockedClient(string uri, HttpStatusCode statusCode = HttpStatusCode.OK, string content = "") { var httpMessageHandlerMock = new Mock();