diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Controllers/ExchangeRatesControllerTests.cs new file mode 100644 index 000000000..48f210070 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Controllers/ExchangeRatesControllerTests.cs @@ -0,0 +1,85 @@ +using ExchangeRateProviderAPI_PaolaRojas.Controllers; +using ExchangeRateProviderAPI_PaolaRojas.Models; +using ExchangeRateProviderAPI_PaolaRojas.Models.Requests; +using ExchangeRateProviderAPI_PaolaRojas.Models.Responses; +using ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Controllers +{ + public class ExchangeRatesControllerTests + { + [Fact] + public async Task Should_Return_200_With_ExchangeRates() + { + var testRequest = new CurrencyRequest + { + Currencies = new List { new("USD") } + }; + + var response = new ExchangeRateResponse + { + ExchangeRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 21.90m) + } + }; + + var mockService = MockExchangeRateService.WithResult(response); + var controller = new ExchangeRatesController(mockService.Object); + + var result = await controller.GetExchangeRates(testRequest); + + result.Should().BeOfType(); + var okResult = result as OkObjectResult; + okResult!.Value.Should().BeEquivalentTo(response); + } + + [Fact] + public async Task Should_Return_404_When_ExchangeRates_Are_Empty() + { + var testRequest = new CurrencyRequest + { + Currencies = new List { new("XYZ") } + }; + + var emptyResponse = new ExchangeRateResponse { ExchangeRates = [] }; + var mockService = MockExchangeRateService.WithResult(emptyResponse); + var controller = new ExchangeRatesController(mockService.Object); + + var result = await controller.GetExchangeRates(testRequest); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Should_Return_404_When_Response_Is_Null() + { + var testRequest = new CurrencyRequest + { + Currencies = [new("USD")] + }; + + var mockService = MockExchangeRateService.WithResult(null); + var controller = new ExchangeRatesController(mockService.Object); + + var result = await controller.GetExchangeRates(testRequest); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Should_Return_404_For_Empty_Input_List() + { + var testRequest = new CurrencyRequest { Currencies = [] }; + + var mockService = MockExchangeRateService.WithResult(new ExchangeRateResponse { ExchangeRates = [] }); + var controller = new ExchangeRatesController(mockService.Object); + + var result = await controller.GetExchangeRates(testRequest); + + result.Should().BeOfType(); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/ExchangeRateProviderAPI_PaolaRojas.UnitTests.csproj b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/ExchangeRateProviderAPI_PaolaRojas.UnitTests.csproj new file mode 100644 index 000000000..2edc0ce98 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/ExchangeRateProviderAPI_PaolaRojas.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Mocks/MockExchangeRateService.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Mocks/MockExchangeRateService.cs new file mode 100644 index 000000000..bb49b94c7 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Mocks/MockExchangeRateService.cs @@ -0,0 +1,18 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models; +using ExchangeRateProviderAPI_PaolaRojas.Models.Responses; +using ExchangeRateProviderAPI_PaolaRojas.Services; +using Moq; + +namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks +{ + public static class MockExchangeRateService + { + public static Mock WithResult(ExchangeRateResponse? response) + { + var mock = new Mock(); + mock.Setup(s => s.GetExchangeRatesAsync(It.IsAny>())) + .ReturnsAsync(response); + return mock; + } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Mocks/MockHttpMessageHandler.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Mocks/MockHttpMessageHandler.cs new file mode 100644 index 000000000..4493ab45b --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Mocks/MockHttpMessageHandler.cs @@ -0,0 +1,37 @@ +using System.Net; + +namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks +{ + public class MockHttpMessageHandler : HttpMessageHandler + { + private string? _mockContent; + private Exception? _exception; + + public void SetMockResponse(string content) + { + _mockContent = content; + _exception = null; + } + + public void SetException(Exception ex) + { + _exception = ex; + _mockContent = null; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_exception is not null) + { + throw _exception; + } + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_mockContent ?? "") + }; + + return Task.FromResult(response); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Services/ExchangeRateServiceTests.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Services/ExchangeRateServiceTests.cs new file mode 100644 index 000000000..384340ccb --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas.UnitTests/Services/ExchangeRateServiceTests.cs @@ -0,0 +1,120 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models; +using ExchangeRateProviderAPI_PaolaRojas.Models.Options; +using ExchangeRateProviderAPI_PaolaRojas.Services; +using ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Services +{ + public class ExchangeRateServiceTests + { + private readonly MockHttpMessageHandler _httpHandler; + private readonly IMemoryCache _cache; + private readonly IOptions _options; + private readonly ILogger _logger; + + private const string MockCnbText = """ + 29 Apr 2025 #82 + Country|Currency|Amount|Code|Rate + USA|dollar|1|USD|21.909 + EMU|euro|1|EUR|24.920 + India|rupee|100|INR|25.732 + BADLINE + """; + + public ExchangeRateServiceTests() + { + _httpHandler = new MockHttpMessageHandler(); + _cache = new MemoryCache(new MemoryCacheOptions()); + _options = Options.Create(new CnbOptions + { + DailyExchangeBaseUrl = "https://mock-cnb-url.com" + }); + _logger = Mock.Of>(); + } + + private ExchangeRateService CreateService() + { + _httpHandler.SetMockResponse(MockCnbText); + var httpClient = new HttpClient(_httpHandler); + return new ExchangeRateService(_options, _cache, _logger, httpClient); + } + + [Fact] + public async Task Should_Return_Filtered_ExchangeRates() + { + var service = CreateService(); + var result = await service.GetExchangeRatesAsync([new Currency("USD")]); + + result.ExchangeRates.Should().ContainSingle(e => e.SourceCurrency.Code == "USD"); + } + + [Fact] + public async Task Should_Return_Empty_When_Currency_Not_Found() + { + var service = CreateService(); + var result = await service.GetExchangeRatesAsync([new Currency("XYZ")]); + + result.ExchangeRates.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Ignore_Malformed_Lines() + { + var service = CreateService(); + var result = await service.GetExchangeRatesAsync([new Currency("INR")]); + + result.ExchangeRates.Should().ContainSingle(e => e.SourceCurrency.Code == "INR"); + } + + [Fact] + public async Task Should_Handle_Duplicate_Currencies_Gracefully() + { + var service = CreateService(); + var result = await service.GetExchangeRatesAsync([new Currency("USD"), new Currency("usd"), new Currency("USD")]); + + result.ExchangeRates.Should().ContainSingle(e => e.SourceCurrency.Code == "USD"); + } + + [Fact] + public async Task Should_Filter_Multiple_Valid_Currencies() + { + var service = CreateService(); + var result = await service.GetExchangeRatesAsync([new Currency("USD"), new Currency("EUR"), new Currency("XYZ")]); + + result.ExchangeRates.Should().HaveCount(2); + } + + [Fact] + public async Task Should_Return_Cached_Result_On_Second_Call() + { + var service = CreateService(); + var first = await service.GetExchangeRatesAsync([new Currency("USD")]); + var second = await service.GetExchangeRatesAsync([new Currency("USD")]); + + second.Should().BeEquivalentTo(first); + } + + [Fact] + public async Task Should_Return_Empty_Response_When_Exception_Occurs() + { + var throwingHandler = new MockHttpMessageHandler(); + throwingHandler.SetException(new HttpRequestException("CNB is down")); + + var httpClient = new HttpClient(throwingHandler); + var service = new ExchangeRateService(_options, _cache, _logger, httpClient); + + var currencies = new[] { new Currency("USD") }; + + var result = await service.GetExchangeRatesAsync(currencies); + + result.Should().NotBeNull(); + result.ExchangeRates.Should().BeEmpty(); + } + + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Controllers/ExchangeRatesController.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Controllers/ExchangeRatesController.cs new file mode 100644 index 000000000..2fe091e6a --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Controllers/ExchangeRatesController.cs @@ -0,0 +1,25 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models.Handlers; +using ExchangeRateProviderAPI_PaolaRojas.Models.Requests; +using ExchangeRateProviderAPI_PaolaRojas.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateProviderAPI_PaolaRojas.Controllers +{ + [ApiController] + [Route("api/[controller]")] + [Authorize(AuthenticationSchemes = ApiKeyAuthenticationOptions.DefaultScheme)] + public class ExchangeRatesController(IExchangeRateService exchangeRateService) : ControllerBase + { + private readonly IExchangeRateService _exchangeRateService = exchangeRateService; + + [HttpPost("rates")] + public async Task GetExchangeRates([FromBody] CurrencyRequest request) + { + var result = await _exchangeRateService.GetExchangeRatesAsync(request.Currencies); + return result?.ExchangeRates != null && result.ExchangeRates.Any() + ? Ok(result) + : NotFound("No exchange rates found for the provided currencies."); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/ExchangeRateProviderAPI_PaolaRojas.csproj b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/ExchangeRateProviderAPI_PaolaRojas.csproj new file mode 100644 index 000000000..dae217b1d --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/ExchangeRateProviderAPI_PaolaRojas.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/ExchangeRateProviderAPI_PaolaRojas.http b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/ExchangeRateProviderAPI_PaolaRojas.http new file mode 100644 index 000000000..9888fcc52 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/ExchangeRateProviderAPI_PaolaRojas.http @@ -0,0 +1,42 @@ +@ExchangeRateProviderAPI_PaolaRojas_HostAddress = http://localhost:5156 + +### Valid Request: Get exchange rate for USD and EUR +POST https://localhost:5001/api/ExchangeRates/exchange_rates +X-API-KEY: jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh +Content-Type: application/json + +{ + "currencies": ["USD", "EUR"] +} + +### + +### Unauthorized Request: Missing API key +POST https://localhost:5001/api/ExchangeRates/exchange_rates +Content-Type: application/json + +{ + "currencies": ["USD"] +} + +### + +### No Matching Currency +POST https://localhost:5001/api/ExchangeRates/exchange_rates +X-API-KEY: jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh +Content-Type: application/json + +{ + "currencies": ["XYZ"] +} + +### + +### Empty Currency List +POST https://localhost:5001/api/ExchangeRates/exchange_rates +X-API-KEY: jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh +Content-Type: application/json + +{ + "currencies": [] +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Currency.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Currency.cs new file mode 100644 index 000000000..0a100adae --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Currency.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateProviderAPI_PaolaRojas.Models +{ + public class Currency(string code) + { + + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } = code; + + public override string ToString() + { + return Code; + } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/ExchangeRate.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/ExchangeRate.cs new file mode 100644 index 000000000..c041ee2a4 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/ExchangeRate.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateProviderAPI_PaolaRojas.Models +{ + public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + public Currency SourceCurrency { get; } = sourceCurrency; + + public Currency TargetCurrency { get; } = targetCurrency; + + public decimal Value { get; } = value; + + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; + } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Handlers/ApiKeyAuthHandler.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Handlers/ApiKeyAuthHandler.cs new file mode 100644 index 000000000..1b9f5d6d3 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Handlers/ApiKeyAuthHandler.cs @@ -0,0 +1,40 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models.Options; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace ExchangeRateProviderAPI_PaolaRojas.Models.Handlers +{ + public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions + { + public const string DefaultScheme = "ApiKeyScheme"; + public string Scheme => DefaultScheme; + public string ApiKeyHeaderName { get; set; } = "X-API-KEY"; + } + + public class ApiKeyAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IOptions apiKeyOptions) : AuthenticationHandler(options, logger, encoder) + { + private readonly string _configuredApiKey = apiKeyOptions.Value.Key; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(Options.ApiKeyHeaderName, out var extractedApiKey)) + return Task.FromResult(AuthenticateResult.Fail("API Key missing")); + + if (string.IsNullOrWhiteSpace(_configuredApiKey) || !_configuredApiKey.Equals(extractedApiKey)) + return Task.FromResult(AuthenticateResult.Fail("Invalid API Key")); + + var claims = new[] { new Claim(ClaimTypes.Name, "ApiKeyUser") }; + var identity = new ClaimsIdentity(claims, Options.Scheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Options.Scheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Options/ApiKeyOptions.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Options/ApiKeyOptions.cs new file mode 100644 index 000000000..f5ef60278 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Options/ApiKeyOptions.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateProviderAPI_PaolaRojas.Models.Options +{ + public class ApiKeyOptions + { + public const string SectionName = "Authentication:ApiKey"; + public string Key { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Options/CnbOptions.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Options/CnbOptions.cs new file mode 100644 index 000000000..c0d0ae6d4 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Options/CnbOptions.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateProviderAPI_PaolaRojas.Models.Options +{ + public class CnbOptions + { + public const string SectionName = "CNB"; + public string DailyExchangeBaseUrl { get; set; } = string.Empty; + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Requests/CurrencyRequest.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Requests/CurrencyRequest.cs new file mode 100644 index 000000000..24a2c1ceb --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Requests/CurrencyRequest.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateProviderAPI_PaolaRojas.Models.Requests +{ + public class CurrencyRequest + { + public required IEnumerable Currencies { get; set; } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Responses/ExchangeRateResponse.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Responses/ExchangeRateResponse.cs new file mode 100644 index 000000000..968d8cbfe --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Models/Responses/ExchangeRateResponse.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateProviderAPI_PaolaRojas.Models.Responses +{ + public class ExchangeRateResponse + { + public IEnumerable? ExchangeRates { get; set; } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Program.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Program.cs new file mode 100644 index 000000000..d9f972478 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Program.cs @@ -0,0 +1,65 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models.Handlers; +using ExchangeRateProviderAPI_PaolaRojas.Models.Options; +using ExchangeRateProviderAPI_PaolaRojas.Services; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Bind Configuration Options +builder.Services.Configure( + builder.Configuration.GetSection(CnbOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(ApiKeyOptions.SectionName)); + +// Add services +builder.Services.AddHttpClient(); +builder.Services.AddMemoryCache(); +builder.Services.AddControllers(); + +//Add startup service tester +builder.Services.AddHostedService(); + +// Add API Key Authentication +builder.Services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme) + .AddScheme(ApiKeyAuthenticationOptions.DefaultScheme, options => { }); + +// Swagger with API Key +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme + { + Description = "API Key needed to access endpoints.", + Type = SecuritySchemeType.ApiKey, + Name = "X-API-KEY", + In = ParameterLocation.Header, + Scheme = "ApiKeyScheme" + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement() + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "ApiKey" + }, + In = ParameterLocation.Header + }, + Array.Empty() + } + }); +}); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Properties/launchSettings.json b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Properties/launchSettings.json new file mode 100644 index 000000000..4de2adef6 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:64934", + "sslPort": 44385 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5156", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7064;http://localhost:5156", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/ExchangeRateService.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/ExchangeRateService.cs new file mode 100644 index 000000000..4f4c9bd82 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/ExchangeRateService.cs @@ -0,0 +1,85 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models; +using ExchangeRateProviderAPI_PaolaRojas.Models.Options; +using ExchangeRateProviderAPI_PaolaRojas.Models.Responses; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using System.Globalization; + +namespace ExchangeRateProviderAPI_PaolaRojas.Services +{ + public class ExchangeRateService( + IOptions options, + IMemoryCache cache, + ILogger logger, + HttpClient httpClient) : IExchangeRateService + { + private readonly string _cnbBaseUrl = options.Value.DailyExchangeBaseUrl; + private readonly IMemoryCache _cache = cache; + private readonly ILogger _logger = logger; + private readonly HttpClient _httpClient = httpClient; + + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + + public async Task GetExchangeRatesAsync(IEnumerable requestedCurrencies) + { + var requestedCodes = requestedCurrencies + .Select(c => c.Code.ToUpperInvariant()) + .ToHashSet(); + + var today = DateTime.UtcNow.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + var cacheKey = $"CNB_ExchangeRates_{DateTime.UtcNow:yyyyMMdd}"; + + List allRates; + + try + { + allRates = await _cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + + string requestUrl = $"{_cnbBaseUrl}?date={today}"; + + var response = await _httpClient.GetStringAsync(requestUrl); + + var lines = response.Split('\n'); + var rates = new List(); + + foreach (var line in lines.Skip(2)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + var parts = line.Split('|'); + if (parts.Length != 5) continue; + + if (!int.TryParse(parts[2], out int amount)) continue; + string code = parts[3]; + if (!decimal.TryParse(parts[4], NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) continue; + + var normalizedRate = value / amount; + var source = new Currency(code); + var target = new Currency("CZK"); + + rates.Add(new ExchangeRate(source, target, normalizedRate)); + } + + return rates; + }) ?? []; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve or parse CNB exchange rate text data."); + return new ExchangeRateResponse { ExchangeRates = [] }; + } + + var filtered = allRates + .Where(r => + requestedCodes.Contains(r.SourceCurrency.Code)) + .ToList(); + + return new ExchangeRateResponse + { + ExchangeRates = filtered + }; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/IExchangeRateService.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/IExchangeRateService.cs new file mode 100644 index 000000000..668fcb317 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/IExchangeRateService.cs @@ -0,0 +1,10 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models; +using ExchangeRateProviderAPI_PaolaRojas.Models.Responses; + +namespace ExchangeRateProviderAPI_PaolaRojas.Services +{ + public interface IExchangeRateService + { + Task GetExchangeRatesAsync(IEnumerable requestedCurrencies); + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/StartupExchangeRateTester.cs b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/StartupExchangeRateTester.cs new file mode 100644 index 000000000..b810fb183 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/Services/StartupExchangeRateTester.cs @@ -0,0 +1,63 @@ +using ExchangeRateProviderAPI_PaolaRojas.Models; + +namespace ExchangeRateProviderAPI_PaolaRojas.Services +{ + /// + /// A hosted service that runs once at application startup to test the exchange rate service. + /// It simulates a request for a predefined list of currencies and logs the result to verify + /// that exchange rate data is being fetched and parsed correctly. + /// + public class StartupExchangeRateTester : IHostedService + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public StartupExchangeRateTester( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var exchangeRateService = scope.ServiceProvider.GetRequiredService(); + + var testCurrencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + }; + + try + { + _logger.LogInformation("Testing exchange rate service at startup..."); + + var result = await exchangeRateService.GetExchangeRatesAsync(testCurrencies); + + var count = result.ExchangeRates?.Count() ?? 0; + _logger.LogInformation("Retrieved {Count} exchange rates:", count); + + foreach (var rate in result.ExchangeRates ?? []) + { + _logger.LogInformation(rate.ToString()); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve exchange rates at startup."); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/appsettings.Development.json b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/appsettings.json b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/appsettings.json new file mode 100644 index 000000000..e428cf9a4 --- /dev/null +++ b/jobs/Backend/ExchangeRateProviderAPI_PaolaRojas/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "CNB": { + "DailyExchangeBaseUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt" + }, + "Authentication": { + "ApiKey": { + "Key": "jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh" + } + }, + "AllowedHosts": "*" +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..090640037 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,9 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProviderAPI_PaolaRojas", "..\ExchangeRateProviderAPI_PaolaRojas\ExchangeRateProviderAPI_PaolaRojas.csproj", "{0E750F61-408D-42DA-9FFF-9A6750083DF2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProviderAPI_PaolaRojas.UnitTests", "..\ExchangeRateProviderAPI_PaolaRojas.UnitTests\ExchangeRateProviderAPI_PaolaRojas.UnitTests.csproj", "{B6A4231F-FACF-4AFB-9F2A-93AE425B3A30}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,10 +13,14 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {0E750F61-408D-42DA-9FFF-9A6750083DF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E750F61-408D-42DA-9FFF-9A6750083DF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E750F61-408D-42DA-9FFF-9A6750083DF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E750F61-408D-42DA-9FFF-9A6750083DF2}.Release|Any CPU.Build.0 = Release|Any CPU + {B6A4231F-FACF-4AFB-9F2A-93AE425B3A30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6A4231F-FACF-4AFB-9F2A-93AE425B3A30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6A4231F-FACF-4AFB-9F2A-93AE425B3A30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6A4231F-FACF-4AFB-9F2A-93AE425B3A30}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE