diff --git a/src/web-api.web/Data/ToDoContext.cs b/src/web-api.web/Data/ToDoContext.cs new file mode 100644 index 0000000..c40bfb1 --- /dev/null +++ b/src/web-api.web/Data/ToDoContext.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using web_api.web.Models; + +namespace web_api.web.Data +{ + public class ToDoContext : DbContext + { + public ToDoContext(DbContextOptions options) : base(options) + { + } + + public DbSet ToDos { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure ToDo entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Title).HasMaxLength(200); + entity.Property(e => e.Description).HasMaxLength(1000); + entity.Property(e => e.IsCompleted).IsRequired(); + }); + } + } +} diff --git a/src/web-api.web/Migrations/20250604210641_InitialCreate.Designer.cs b/src/web-api.web/Migrations/20250604210641_InitialCreate.Designer.cs new file mode 100644 index 0000000..1e904db --- /dev/null +++ b/src/web-api.web/Migrations/20250604210641_InitialCreate.Designer.cs @@ -0,0 +1,53 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using web_api.web.Data; + +#nullable disable + +namespace web_api.web.Migrations +{ + [DbContext(typeof(ToDoContext))] + [Migration("20250604210641_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("web_api.web.Models.ToDo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ToDos"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/web-api.web/Migrations/20250604210641_InitialCreate.cs b/src/web-api.web/Migrations/20250604210641_InitialCreate.cs new file mode 100644 index 0000000..e85cd63 --- /dev/null +++ b/src/web-api.web/Migrations/20250604210641_InitialCreate.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace web_api.web.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ToDos", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + IsCompleted = table.Column(type: "bit", nullable: false), + Description = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ToDos", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ToDos"); + } + } +} diff --git a/src/web-api.web/Migrations/ToDoContextModelSnapshot.cs b/src/web-api.web/Migrations/ToDoContextModelSnapshot.cs new file mode 100644 index 0000000..11e99b5 --- /dev/null +++ b/src/web-api.web/Migrations/ToDoContextModelSnapshot.cs @@ -0,0 +1,50 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using web_api.web.Data; + +#nullable disable + +namespace web_api.web.Migrations +{ + [DbContext(typeof(ToDoContext))] + partial class ToDoContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("web_api.web.Models.ToDo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ToDos"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/web-api.web/Models/CoinbaseExchangeRateResponse.cs b/src/web-api.web/Models/CoinbaseExchangeRateResponse.cs new file mode 100644 index 0000000..30fb860 --- /dev/null +++ b/src/web-api.web/Models/CoinbaseExchangeRateResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace web_api.web.Models; + +/// +/// Represents the response from Coinbase API v2/exchange-rates endpoint +/// +public record CoinbaseExchangeRateResponse( + [property: JsonPropertyName("data")] + ExchangeRateData Data +); + +/// +/// Contains the exchange rate data including base currency and rates +/// +public record ExchangeRateData( + [property: JsonPropertyName("currency")] + string Currency, + + [property: JsonPropertyName("rates")] + Dictionary Rates +); diff --git a/src/web-api.web/Program.cs b/src/web-api.web/Program.cs index adeb26f..4011ad7 100644 --- a/src/web-api.web/Program.cs +++ b/src/web-api.web/Program.cs @@ -1,9 +1,23 @@ -using System.Reflection.Metadata.Ecma335; -using web_api.web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using web_api.web.Data; using web_api.web.Models; +using web_api.web.Repositories; +using web_api.web.Services; var builder = WebApplication.CreateBuilder(args); +// Add Entity Framework +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Add repositories +builder.Services.AddScoped(); + +// Add CacheService and IMemoryCache to DI +builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); + // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); @@ -15,14 +29,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); - var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); - + // Configure Swagger UI app.UseSwagger(); app.UseSwaggerUI(c => @@ -34,45 +47,239 @@ app.UseHttpsRedirection(); -app.MapGet("/weatherforecast", (WeatherForecastSvc weatherSvc) => weatherSvc.GetWeatherForecast()) -.WithName("GetWeatherForecast") -.WithOpenApi(); - -app.MapGet("/todo", async (ToDoSvc svc) => await svc.GetAllAsync()) - .WithName("GetAllToDos") - .WithOpenApi(); +//Padlet endpoints -app.MapGet("/todo/{id:int}", async (int id, ToDoSvc svc) => +app.MapGet("/rates", async ([FromQuery(Name = "base")] string baseValue) => { - var todo = await svc.GetByIdAsync(id); - return todo is not null ? Results.Ok(todo) : Results.NotFound(); -}) -.WithName("GetToDoById") -.WithOpenApi(); + try + { + if (baseValue == "fiat") + { + var fiatCurrencies = new[] { "USD", "SGD", "EUR" }; + using var httpClient = new HttpClient(); + var results = new Dictionary>(); -app.MapPost("/todo", async (ToDo toDo, ToDoSvc svc) => -{ - var created = await svc.CreateAsync(toDo); - return Results.Created($"/todo/{created.Id}", created); -}) -.WithName("CreateToDo") + // Create tasks for parallel execution + var tasks = fiatCurrencies.Select(async fiat => + { + var fiatUrl = $"https://api.coinbase.com/v2/exchange-rates?currency={Uri.EscapeDataString(fiat)}"; + var response = await httpClient.GetAsync(fiatUrl); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Coinbase API returned status code {response.StatusCode} for currency {fiat}"); + } + var responseBody = await response.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(responseBody); + var rates = doc.RootElement + .GetProperty("data") + .GetProperty("rates"); + + var selectedRates = new Dictionary(); + foreach (var symbol in new[] { "BTC", "DOGE", "ETH" }) + { + if (rates.TryGetProperty(symbol, out var rateProp)) + { + selectedRates[symbol] = rateProp.GetString(); + } + } + return new { Currency = fiat, Rates = selectedRates }; + }).ToArray(); + + // Wait for all tasks to complete + var taskResults = await Task.WhenAll(tasks); + + // Build the results dictionary + foreach (var result in taskResults) + { + results[result.Currency] = result.Rates; + } + + return Results.Ok(results); + } + if (baseValue == "tokens") + { + var tokenSymbols = new[] { "BTC", "DOGE", "ETH" }; + using var httpClient = new HttpClient(); + var results = new Dictionary>(); + + // Create tasks for parallel execution + var tasks = tokenSymbols.Select(async token => + { + var tokenUrl = $"https://api.coinbase.com/v2/exchange-rates?currency={Uri.EscapeDataString(token)}"; + var response = await httpClient.GetAsync(tokenUrl); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Coinbase API returned status code {response.StatusCode} for currency {token}"); + } + var responseBody = await response.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(responseBody); + var rates = doc.RootElement + .GetProperty("data") + .GetProperty("rates"); + + // Select only fiat currencies + var selectedRates = new Dictionary(); + foreach (var fiat in new[] { "USD", "SGD", "EUR" }) + { + if (rates.TryGetProperty(fiat, out var rateProp)) + { + selectedRates[fiat] = rateProp.GetString(); + } + } + return new { Currency = token, Rates = selectedRates }; + }).ToArray(); + + // Wait for all tasks to complete + var taskResults = await Task.WhenAll(tasks); + + // Build the results dictionary + foreach (var result in taskResults) + { + results[result.Currency] = result.Rates; + } + + return Results.Ok(results); + } + + return Results.NotFound(); + } + catch (HttpRequestException ex) + { + return Results.Problem( + detail: $"Network error while calling Coinbase API: {ex.Message}", + statusCode: 500, + title: "Network Error" + ); + } + catch (System.Text.Json.JsonException ex) + { + return Results.Problem( + detail: $"Failed to parse Coinbase API response: {ex.Message}", + statusCode: 500, + title: "JSON Parse Error" + ); + } + catch (Exception ex) + { + return Results.Problem( + detail: $"Unexpected error: {ex.Message}", + statusCode: 500, + title: "Internal Server Error" + ); + } +}).WithName("GetRates") .WithOpenApi(); -app.MapPut("/todo/{id:int}", async (int id, ToDo toDo, ToDoSvc svc) => + +app.MapPost("/webhook", ([FromBody] WebhookPayload payload) => { - toDo.Id = id; - var updated = await svc.UpdateAsync(toDo); - return updated ? Results.NoContent() : Results.NotFound(); + // Example: log or process the payload as needed + // For now, just return the received payload for demonstration + return Results.Ok(payload); }) -.WithName("UpdateToDo") +.WithName("webhook") .WithOpenApi(); -app.MapDelete("/todo/{id:int}", async (int id, ToDoSvc svc) => +app.MapGet("/rates1", ( + [FromQuery(Name = "base_currency")] string baseCurrency, + [FromQuery(Name = "target_currency")] string targetCurrency, + [FromQuery(Name = "start")] long? start, + [FromQuery(Name = "end")] long? end) => { - var deleted = await svc.DeleteAsync(id); - return deleted ? Results.NoContent() : Results.NotFound(); + // Example: Validate input + if (string.IsNullOrWhiteSpace(baseCurrency) || string.IsNullOrWhiteSpace(targetCurrency) || start is null || end is null) + { + return Results.BadRequest("Missing required query parameters."); + } + + // TODO: Replace with actual rate lookup logic + var result = new + { + BaseCurrency = baseCurrency, + TargetCurrency = targetCurrency, + Start = start, + End = end, + Rates = new[] + { + new { Timestamp = start, Rate = "12345.67" }, + new { Timestamp = end, Rate = "12500.00" } + } + }; + + return Results.Ok(result); }) -.WithName("DeleteToDo") +.WithName("GetRatesByCurrencyAndRange") .WithOpenApi(); -app.Run(); \ No newline at end of file +app.Run(); + +public record WebhookPayload( + string Type, + WebhookData Data +); + +public record WebhookData( + [property: System.Text.Json.Serialization.JsonPropertyName("base_currency")] + string BaseCurrency, + [property: System.Text.Json.Serialization.JsonPropertyName("published_at")] + long PublishedAt, + [property: System.Text.Json.Serialization.JsonPropertyName("rates")] + Dictionary Rates +); + + + + + + +//app.MapGet("/weatherforecast", (WeatherForecastSvc weatherSvc) => weatherSvc.GetWeatherForecast()) +//.WithName("GetWeatherForecast") +//.WithOpenApi(); + + +////Endpoints +//app.MapGet("/weatherforecast", (WeatherForecastSvc weatherSvc) => weatherSvc.GetWeatherForecast()) +//.WithName("GetWeatherForecast") +//.WithOpenApi(); + +//// ToDo API endpoints +//app.MapGet("/todos", async (ToDoSvc toDoSvc) => +//{ +// return await toDoSvc.GetAllAsync(); +//}) +//.WithName("GetToDos") +//.WithOpenApi(); + +//app.MapGet("/todos/{id}", async (int id, ToDoSvc toDoSvc) => +//{ +// var todo = await toDoSvc.GetByIdAsync(id); +// return todo is not null ? Results.Ok(todo) : Results.NotFound(); +//}) +//.WithName("GetToDo") +//.WithOpenApi(); + +//app.MapPost("/todos", async (ToDo todo, ToDoSvc toDoSvc) => +//{ +// var createdTodo = await toDoSvc.CreateAsync(todo); +// return Results.Created($"/todos/{createdTodo.Id}", createdTodo); +//}) +//.WithName("CreateToDo") +//.WithOpenApi(); + +//app.MapPut("/todos/{id}", async (int id, ToDo inputTodo, ToDoSvc toDoSvc) => +//{ +// inputTodo.Id = id; // Ensure the ID matches the route parameter +// var success = await toDoSvc.UpdateAsync(inputTodo); +// return success ? Results.Ok(await toDoSvc.GetByIdAsync(id)) : Results.NotFound(); +//}) +//.WithName("UpdateToDo") +//.WithOpenApi(); + +//app.MapDelete("/todos/{id}", async (int id, ToDoSvc toDoSvc) => +//{ +// var success = await toDoSvc.DeleteAsync(id); +// return success ? Results.NoContent() : Results.NotFound(); +//}) +//.WithName("DeleteToDo") +//.WithOpenApi(); + diff --git a/src/web-api.web/Repositories/IToDoRepository.cs b/src/web-api.web/Repositories/IToDoRepository.cs new file mode 100644 index 0000000..a8619e9 --- /dev/null +++ b/src/web-api.web/Repositories/IToDoRepository.cs @@ -0,0 +1,13 @@ +using web_api.web.Models; + +namespace web_api.web.Repositories +{ + public interface IToDoRepository + { + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(ToDo toDo); + Task UpdateAsync(ToDo toDo); + Task DeleteAsync(int id); + } +} diff --git a/src/web-api.web/Repositories/ToDoRepository.cs b/src/web-api.web/Repositories/ToDoRepository.cs new file mode 100644 index 0000000..a894930 --- /dev/null +++ b/src/web-api.web/Repositories/ToDoRepository.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using web_api.web.Data; +using web_api.web.Models; + +namespace web_api.web.Repositories +{ + public class ToDoRepository : IToDoRepository + { + private readonly ToDoContext _context; + private readonly ILogger _logger; + + public ToDoRepository(ToDoContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task> GetAllAsync() + { + _logger.LogDebug("Fetching all ToDo items from database."); + var toDos = await _context.ToDos.ToListAsync(); + return toDos.AsReadOnly(); + } + + public async Task GetByIdAsync(int id) + { + _logger.LogDebug("Fetching ToDo item with Id {Id} from database.", id); + return await _context.ToDos.FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task CreateAsync(ToDo toDo) + { + _logger.LogDebug("Adding new ToDo item to database."); + _context.ToDos.Add(toDo); + await _context.SaveChangesAsync(); + _logger.LogDebug("Successfully added ToDo item with Id {Id} to database.", toDo.Id); + return toDo; + } + + public async Task UpdateAsync(ToDo toDo) + { + _logger.LogDebug("Updating ToDo item with Id {Id} in database.", toDo.Id); + + var existingToDo = await _context.ToDos.FirstOrDefaultAsync(t => t.Id == toDo.Id); + if (existingToDo == null) + { + _logger.LogDebug("ToDo item with Id {Id} not found in database.", toDo.Id); + return false; + } + + // Update properties + existingToDo.Title = toDo.Title; + existingToDo.IsCompleted = toDo.IsCompleted; + existingToDo.Description = toDo.Description; + + await _context.SaveChangesAsync(); + _logger.LogDebug("Successfully updated ToDo item with Id {Id} in database.", toDo.Id); + return true; + } + + public async Task DeleteAsync(int id) + { + _logger.LogDebug("Deleting ToDo item with Id {Id} from database.", id); + + var toDo = await _context.ToDos.FirstOrDefaultAsync(t => t.Id == id); + if (toDo == null) + { + _logger.LogDebug("ToDo item with Id {Id} not found in database for deletion.", id); + return false; + } + + _context.ToDos.Remove(toDo); + await _context.SaveChangesAsync(); + _logger.LogDebug("Successfully deleted ToDo item with Id {Id} from database.", id); + return true; + } + } +} diff --git a/src/web-api.web/Services/CacheService.cs b/src/web-api.web/Services/CacheService.cs new file mode 100644 index 0000000..a21e46c --- /dev/null +++ b/src/web-api.web/Services/CacheService.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace web_api.web.Services +{ + public class CacheService + { + private readonly IMemoryCache _memoryCache; + + public CacheService(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + public T? Get(object key) + { + return _memoryCache.TryGetValue(key, out T? value) ? value : default; + } + + public void Set(object key, T value, MemoryCacheEntryOptions? options = null) + { + if (options != null) + _memoryCache.Set(key, value, options); + else + _memoryCache.Set(key, value); + } + + public void Remove(object key) + { + _memoryCache.Remove(key); + } + } +} diff --git a/src/web-api.web/Services/ToDoSvc.cs b/src/web-api.web/Services/ToDoSvc.cs index b183990..215acf8 100644 --- a/src/web-api.web/Services/ToDoSvc.cs +++ b/src/web-api.web/Services/ToDoSvc.cs @@ -1,62 +1,63 @@ using web_api.web.Models; +using web_api.web.Repositories; namespace web_api.web.Services { public class ToDoSvc { - private readonly List _toDos = new(); + private readonly IToDoRepository _repository; private readonly ILogger _logger; - public ToDoSvc(ILogger logger) + public ToDoSvc(IToDoRepository repository, ILogger logger) { + _repository = repository; _logger = logger; } - - public Task> GetAllAsync() + public async Task> GetAllAsync() { _logger.LogInformation("Retrieving all ToDo items."); - return Task.FromResult((IReadOnlyList)_toDos.AsReadOnly()); + return await _repository.GetAllAsync(); } - public Task GetByIdAsync(int id) + public async Task GetByIdAsync(int id) { _logger.LogInformation("Retrieving ToDo item with Id {Id}.", id); - var todo = _toDos.Find(t => t.Id == id); - return Task.FromResult(todo); + return await _repository.GetByIdAsync(id); } - public Task CreateAsync(ToDo toDo) + public async Task CreateAsync(ToDo toDo) { - toDo.Id = _toDos.Count > 0 ? _toDos[^1].Id + 1 : 1; - _toDos.Add(toDo); - _logger.LogInformation("Created new ToDo item with Id {Id}.", toDo.Id); - return Task.FromResult(toDo); + var createdToDo = await _repository.CreateAsync(toDo); + _logger.LogInformation("Created new ToDo item with Id {Id}.", createdToDo.Id); + return createdToDo; } - public Task UpdateAsync(ToDo toDo) + public async Task UpdateAsync(ToDo toDo) { - var index = _toDos.FindIndex(t => t.Id == toDo.Id); - if (index == -1) + var success = await _repository.UpdateAsync(toDo); + if (success) + { + _logger.LogInformation("Updated ToDo item with Id {Id}.", toDo.Id); + } + else { _logger.LogWarning("ToDo item with Id {Id} not found for update.", toDo.Id); - return Task.FromResult(false); } - _toDos[index] = toDo; - _logger.LogInformation("Updated ToDo item with Id {Id}.", toDo.Id); - return Task.FromResult(true); + return success; } - public Task DeleteAsync(int id) + public async Task DeleteAsync(int id) { - var toDo = _toDos.Find(t => t.Id == id); - if (toDo == null) + var success = await _repository.DeleteAsync(id); + if (success) + { + _logger.LogInformation("Deleted ToDo item with Id {Id}.", id); + } + else { _logger.LogWarning("ToDo item with Id {Id} not found for deletion.", id); - return Task.FromResult(false); } - _toDos.Remove(toDo); - _logger.LogInformation("Deleted ToDo item with Id {Id}.", id); - return Task.FromResult(true); + return success; } } } diff --git a/src/web-api.web/appsettings.json b/src/web-api.web/appsettings.json index 10f68b8..5b3961a 100644 --- a/src/web-api.web/appsettings.json +++ b/src/web-api.web/appsettings.json @@ -1,4 +1,7 @@ { + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=ToDoDb;Trusted_Connection=true;MultipleActiveResultSets=true" + }, "Logging": { "LogLevel": { "Default": "Information", @@ -6,4 +9,4 @@ } }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/src/web-api.web/web-api.web.csproj b/src/web-api.web/web-api.web.csproj index a74b03e..8f00a91 100644 --- a/src/web-api.web/web-api.web.csproj +++ b/src/web-api.web/web-api.web.csproj @@ -9,6 +9,11 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all +