diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Clients/CountryCacheTests.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Clients/CountryCacheTests.cs new file mode 100644 index 0000000..f4184df --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Clients/CountryCacheTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Paymentsense.Coding.Challenge.Api.Clients; +using Paymentsense.Coding.Challenge.Api.Models; +using Xunit; + +namespace Paymentsense.Coding.Challenge.Api.Tests.Clients +{ + public class CountryCacheTests + { + private readonly List _countriesMock; + + public CountryCacheTests() + { + var country = new CountryModel + { + Name = "Test Country", + Flag = "flag", + Population = 123456, + Timezones = new[] {"UTC", "UTC+01:00"}, + Languages = new[] {new Language() {Name = "English"}}, + Currencies = new[] {new CurrencyModel() {Name = "GBP"}}, + Capital = "London", + Borders = new[] {"IRL"} + }; + + _countriesMock = Enumerable.Range(1, 100).Select(x => country).ToList(); + } + + [Fact] + public void GetCountries_ReturnsEmptyList() + { + var cache = new CountryCache(); + var result = cache.GetCountries(); + + result.Should().BeOfType>(); + result.Should().HaveCount(0); + } + + [Fact] + public void PopulateCountries_PopulatesCountries() + { + var cache = new CountryCache(); + cache.PopulateCountries(_countriesMock); + var result = cache.GetCountries(); + + result.Should().BeOfType>(); + result.Should().HaveCount(_countriesMock.Count); + result.Should().BeSameAs(_countriesMock); + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Controllers/CountriesControllerTests.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Controllers/CountriesControllerTests.cs new file mode 100644 index 0000000..9513d96 --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Controllers/CountriesControllerTests.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Paymentsense.Coding.Challenge.Api.Controllers; +using Paymentsense.Coding.Challenge.Api.Models; +using Paymentsense.Coding.Challenge.Api.Services; +using Xunit; + +namespace Paymentsense.Coding.Challenge.Api.Tests.Controllers +{ + public class CountriesControllerTests + { + [Fact] + public async void Get_OnInvoke_ReturnsCountries() + { + var country = new CountryModel + { + Name = "Test Country", + Flag = "flag", + Population = 123456, + Timezones = new[] {"UTC", "UTC+01:00"}, + Languages = new[] {new Language() {Name = "English"}}, + Currencies = new[] {new CurrencyModel() {Name = "GBP"}}, + Capital = "London", + Borders = new[] {"IRL"} + }; + + var countriesMock = new List() {country}; + + var mockCountriesService = new Mock(); + mockCountriesService.Setup(c => c.GetCountries(null, null)).ReturnsAsync(countriesMock); + + var controller = new CountriesController(mockCountriesService.Object); + + var result = (await controller.Get(null, null)).Result as OkObjectResult; + + result.StatusCode.Should().Be(StatusCodes.Status200OK); + result.Value.Should().BeOfType>(); + result.Value.Should().Be(countriesMock); + mockCountriesService.Verify(c => c.GetCountries(null, null), Times.Once); + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Paymentsense.Coding.Challenge.Api.Tests.csproj b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Paymentsense.Coding.Challenge.Api.Tests.csproj index ba38576..8f766be 100644 --- a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Paymentsense.Coding.Challenge.Api.Tests.csproj +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Paymentsense.Coding.Challenge.Api.Tests.csproj @@ -10,6 +10,7 @@ + all @@ -21,10 +22,6 @@ - - - - diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Services/CountriesServiceTests.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Services/CountriesServiceTests.cs new file mode 100644 index 0000000..0004eba --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api.Tests/Services/CountriesServiceTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using Paymentsense.Coding.Challenge.Api.Clients; +using Paymentsense.Coding.Challenge.Api.Models; +using Paymentsense.Coding.Challenge.Api.Services; +using Xunit; + +namespace Paymentsense.Coding.Challenge.Api.Tests.Services +{ + public class CountriesServiceTests + { + private readonly List _countriesMock; + + public CountriesServiceTests() + { + var country = new CountryModel + { + Name = "Test Country", + Flag = "flag", + Population = 123456, + Timezones = new[] {"UTC", "UTC+01:00"}, + Languages = new[] {new Language() {Name = "English"}}, + Currencies = new[] {new CurrencyModel() {Name = "GBP"}}, + Capital = "London", + Borders = new[] {"IRL"} + }; + + _countriesMock = Enumerable.Range(1, 100).Select(x => country).ToList(); + } + + [Fact] + public async void GetCountries_ReturnsCountriesFromApiClientFirstCall_NoPagination() + { + var mockCountriesApiClient = new Mock(); + mockCountriesApiClient.Setup(c => c.GetCountries()).ReturnsAsync(_countriesMock); + + var mockCache = new Mock(); + mockCache.Setup(c => c.GetCountries()).Returns(new List()); + var countriesService = new CountriesService(mockCache.Object, mockCountriesApiClient.Object); + + var result = await countriesService.GetCountries(null, null); + + result.Should().BeOfType>(); + result.Should().BeSameAs(_countriesMock); + mockCountriesApiClient.Verify(c => c.GetCountries(), Times.Once); + mockCache.Verify(c => c.GetCountries(), Times.Once); + } + + [Fact] + public async void GetCountries_PopulatesCacheOnFirstCall_NoPagination() + { + var mockCountriesApiClient = new Mock(); + mockCountriesApiClient.Setup(c => c.GetCountries()).ReturnsAsync(_countriesMock); + + var mockCache = new Mock(); + mockCache.Setup(c => c.GetCountries()).Returns(new List()); + var countriesService = new CountriesService(mockCache.Object, mockCountriesApiClient.Object); + + var result = await countriesService.GetCountries(null, null); + mockCache.Verify(c => c.PopulateCountries(result), Times.Once); + + mockCache.Verify(c => c.GetCountries(), Times.Once); + } + + [Fact] + public async void GetCountries_ReturnsCountriesFromCacheSecondCall_NoPagination() + { + var mockCountriesApiClient = new Mock(); + + var mockCache = new Mock(); + mockCache.Setup(c => c.GetCountries()).Returns(_countriesMock); + var countriesService = new CountriesService(mockCache.Object, mockCountriesApiClient.Object); + + var result = await countriesService.GetCountries(null, null); + + result.Should().BeOfType>(); + result.Should().BeSameAs(_countriesMock); + mockCountriesApiClient.Verify(c => c.GetCountries(), Times.Never); + mockCache.Verify(c => c.GetCountries(), Times.Once); + } + + [Fact] + public async void GetCountries_Returns10Countries_Pagination() + { + var mockCountriesApiClient = new Mock(); + mockCountriesApiClient.Setup(c => c.GetCountries()).ReturnsAsync(_countriesMock); + + var mockCache = new Mock(); + mockCache.Setup(c => c.GetCountries()).Returns(new List()); + var countriesService = new CountriesService(mockCache.Object, mockCountriesApiClient.Object); + + var result = await countriesService.GetCountries(10, 0); + + result.Should().HaveCount(10); + } + + [Fact] + public async void GetCountries_ReturnsDifferent10Countries_Pagination() + { + var mockCountriesApiClient = new Mock(); + mockCountriesApiClient.Setup(c => c.GetCountries()).ReturnsAsync(_countriesMock); + + var mockCache = new Mock(); + mockCache.Setup(c => c.GetCountries()).Returns(new List()); + var countriesService = new CountriesService(mockCache.Object, mockCountriesApiClient.Object); + + var result = await countriesService.GetCountries(10, 0); + var resultNextPage = await countriesService.GetCountries(10, 1); + result.Should().HaveCount(10); + resultNextPage.Should().HaveCount(10); + resultNextPage.Should().NotBeSameAs(result); + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/CountriesApiClient.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/CountriesApiClient.cs new file mode 100644 index 0000000..b4b1ebc --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/CountriesApiClient.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Paymentsense.Coding.Challenge.Api.Models; + +namespace Paymentsense.Coding.Challenge.Api.Clients +{ + internal class CountriesApiClient : ICountriesApiClient + { + private readonly HttpClient _clientFactory; + + public CountriesApiClient(IHttpClientFactory clientFactory) + { + _clientFactory = clientFactory.CreateClient(); + } + + public async Task> GetCountries() + { + using var client = _clientFactory; + var responseStream = client.GetStreamAsync( + "https://restcountries.eu/rest/v2/all?fields=name;flag;population;timezones;currencies;languages;capital;borders"); + + return await JsonSerializer.DeserializeAsync>(await responseStream); + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/CountryCache.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/CountryCache.cs new file mode 100644 index 0000000..242ca59 --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/CountryCache.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Paymentsense.Coding.Challenge.Api.Models; + +namespace Paymentsense.Coding.Challenge.Api.Clients +{ + public class CountryCache : ICountryCache + { + private static List Countries { get; set; } = new List(); + private static readonly object CountriesLock = new object(); + + public List GetCountries() + { + lock (CountriesLock) + { + return Countries; + } + } + + public void PopulateCountries(List countries) + { + lock (CountriesLock) + { + Countries = countries; + } + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/ICountriesApiClient.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/ICountriesApiClient.cs new file mode 100644 index 0000000..c9c793a --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/ICountriesApiClient.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Paymentsense.Coding.Challenge.Api.Models; + +namespace Paymentsense.Coding.Challenge.Api.Clients +{ + public interface ICountriesApiClient + { + public Task> GetCountries(); + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/ICountryCache.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/ICountryCache.cs new file mode 100644 index 0000000..42ea196 --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Clients/ICountryCache.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Paymentsense.Coding.Challenge.Api.Models; + +namespace Paymentsense.Coding.Challenge.Api.Clients +{ + public interface ICountryCache + { + public List GetCountries(); + public void PopulateCountries(List countries); + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/CountriesController.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/CountriesController.cs new file mode 100644 index 0000000..776bb64 --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/CountriesController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using Paymentsense.Coding.Challenge.Api.Models; +using Paymentsense.Coding.Challenge.Api.Services; + +namespace Paymentsense.Coding.Challenge.Api.Controllers +{ + [ApiController] + [Route("[controller]")] + public class CountriesController : ControllerBase + { + private readonly ICountriesService _countriesService; + + public CountriesController(ICountriesService countriesService) + { + _countriesService = countriesService; + } + + + [HttpGet] + public async Task>> Get(int? pageNumber, int? page) + { + var countries = await _countriesService.GetCountries(null, null); + + return Ok(countries); + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/PaymentsenseCodingChallengeController.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/PaymentsenseCodingChallengeController.cs index d7ced3c..35feee9 100644 --- a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/PaymentsenseCodingChallengeController.cs +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Controllers/PaymentsenseCodingChallengeController.cs @@ -12,4 +12,4 @@ public ActionResult Get() return Ok("Paymentsense Coding Challenge!"); } } -} +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/CountryModel.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/CountryModel.cs new file mode 100644 index 0000000..c2ae6bb --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/CountryModel.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace Paymentsense.Coding.Challenge.Api.Models +{ + [BindProperties(SupportsGet = true)] + public class CountryModel + { + [JsonPropertyName("currencies")] public CurrencyModel[] Currencies { get; set; } + [JsonPropertyName("flag")] public string Flag { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("capital")] public string Capital { get; set; } + [JsonPropertyName("population")] public int Population { get; set; } + [JsonPropertyName("timezones")] public string[] Timezones { get; set; } + [JsonPropertyName("borders")] public string[] Borders { get; set; } + [JsonPropertyName("languages")] public Language[] Languages { get; set; } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/CurrencyModel.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/CurrencyModel.cs new file mode 100644 index 0000000..fb11d2f --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/CurrencyModel.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Paymentsense.Coding.Challenge.Api.Models +{ + public class CurrencyModel + { + [JsonPropertyName("code")] public string Code { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("symbol")] public string Symbol { get; set; } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/Language.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/Language.cs new file mode 100644 index 0000000..ca753f2 --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Models/Language.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Paymentsense.Coding.Challenge.Api.Models +{ + public class Language + { + [JsonPropertyName("iso639_1")] public string Iso6391 { get; set; } + [JsonPropertyName("iso639_2")] public string Iso6392 { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("nativeName")] public string NativeName { get; set; } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Paymentsense.Coding.Challenge.Api.csproj b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Paymentsense.Coding.Challenge.Api.csproj index 1d82fb7..e699224 100644 --- a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Paymentsense.Coding.Challenge.Api.csproj +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Paymentsense.Coding.Challenge.Api.csproj @@ -5,9 +5,4 @@ Paymentsense.Coding.Challenge.Api.Program - - - - - diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Services/CountriesService.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Services/CountriesService.cs new file mode 100644 index 0000000..b569acd --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Services/CountriesService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Paymentsense.Coding.Challenge.Api.Clients; +using Paymentsense.Coding.Challenge.Api.Models; + +namespace Paymentsense.Coding.Challenge.Api.Services +{ + public class CountriesService : ICountriesService + { + private readonly ICountriesApiClient _countriesApiClient; + private readonly ICountryCache _countryCache; + + public CountriesService(ICountryCache cache, ICountriesApiClient countriesApiClient) + { + _countriesApiClient = countriesApiClient; + _countryCache = cache; + } + + public async Task> GetCountries(int? pageNumber, int? page) + { + var countries = _countryCache.GetCountries(); + if (countries.Count == 0) + { + countries = await _countriesApiClient.GetCountries(); + _countryCache.PopulateCountries(countries); + } + + if (page == null || pageNumber == null) + { + return countries; + } + + var numToSkip = page.Value * pageNumber.Value; + return countries.Skip(numToSkip).Take(pageNumber.Value).ToList(); + } + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Services/ICountriesService.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Services/ICountriesService.cs new file mode 100644 index 0000000..7394c11 --- /dev/null +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Services/ICountriesService.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Paymentsense.Coding.Challenge.Api.Models; + +namespace Paymentsense.Coding.Challenge.Api.Services +{ + public interface ICountriesService + { + Task> GetCountries(int? pageNumber, int? page); + } +} \ No newline at end of file diff --git a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Startup.cs b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Startup.cs index 623b8b2..dd78b1a 100644 --- a/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Startup.cs +++ b/paymentsense-coding-challenge-api/Paymentsense.Coding.Challenge.Api/Startup.cs @@ -3,6 +3,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Paymentsense.Coding.Challenge.Api.Clients; +using Paymentsense.Coding.Challenge.Api.Services; namespace Paymentsense.Coding.Challenge.Api { @@ -29,6 +31,10 @@ public void ConfigureServices(IServiceCollection services) .AllowAnyHeader(); }); }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHttpClient(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -54,4 +60,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) }); } } -} +} \ No newline at end of file