From 1a0f4f87f9c8ad7de3f83667a1e1fa26a7046f17 Mon Sep 17 00:00:00 2001 From: Katelyn Kunzmann Date: Mon, 3 Nov 2025 18:35:01 -0500 Subject: [PATCH] Add InvestmentWebAPI project --- InvestmentWebAPI/.gitignore | 11 ++ .../InvestmentControllerUnitTests.cs | 106 +++++++++++++ .../InvestmentPerformanceWebAPI.Tests.csproj | 27 ++++ .../InvestmentPerformanceWebAPI.sln | 48 ++++++ .../Controllers/InvestmentController.cs | 96 ++++++++++++ .../Data/InvestmentDbContext.cs | 65 ++++++++ .../InvestmentPerformanceWebAPI.csproj | 21 +++ .../20251103024509_InitialCreate.Designer.cs | 139 ++++++++++++++++++ .../20251103024509_InitialCreate.cs | 89 +++++++++++ .../InvestmentDbContextModelSnapshot.cs | 136 +++++++++++++++++ .../Models/Investment.cs | 20 +++ .../Models/User.cs | 10 ++ .../InvestmentPerformanceWebAPI/Program.cs | 44 ++++++ .../Properties/launchSettings.json | 41 ++++++ .../appsettings.Development.json | 8 + .../appsettings.json | 12 ++ InvestmentWebAPI/README.md | 101 +++++++++++++ 17 files changed, 974 insertions(+) create mode 100644 InvestmentWebAPI/.gitignore create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentControllerUnitTests.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentPerformanceWebAPI.Tests.csproj create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI.sln create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Controllers/InvestmentController.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Data/InvestmentDbContext.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.Designer.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/InvestmentDbContextModelSnapshot.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/Investment.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/User.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Program.cs create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/Properties/launchSettings.json create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.Development.json create mode 100644 InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.json create mode 100644 InvestmentWebAPI/README.md diff --git a/InvestmentWebAPI/.gitignore b/InvestmentWebAPI/.gitignore new file mode 100644 index 00000000..d7bde01a --- /dev/null +++ b/InvestmentWebAPI/.gitignore @@ -0,0 +1,11 @@ +InvestmentPerformanceWebAPI/bin/ +InvestmentPerformanceWebAPI/obj/ +InvestmentPerformanceWebAPI/Migrations/obj/ +InvestmentPerformanceWebAPI/Migrations/bin/ +InvestmentPerformanceWebAPI.Tests/bin/ +InvestmentPerformanceWebAPI.Tests/obj/ + +InvestmentPerformanceWebAPI/*.db* +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentControllerUnitTests.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentControllerUnitTests.cs new file mode 100644 index 00000000..976a1d06 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentControllerUnitTests.cs @@ -0,0 +1,106 @@ +using InvestmentPerformanceWebAPI.Controllers; +using InvestmentPerformanceWebAPI.Data; +using InvestmentPerformanceWebAPI.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace InvestmentPerformanceWebAPI.Tests.Controllers +{ + public class InvestmentControllerTests + { + private InvestmentDbContext GetInMemoryDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + var context = new InvestmentDbContext(options); + + var user = new User + { + UserId = 1, + Name = "Alice", + Investments = new List + { + new Investment + { + InvestmentId = 1, + UserId = 1, + Name = "Apple Inc.", + Ticker = "AAPL", + NumberOfShares = 10, + CostBasisPerShare = 120, + CurrentPrice = 175, + TimeOfPurchase = new DateTime(2023, 6, 10) + } + } + }; + + context.Users.Add(user); + context.SaveChanges(); + + return context; + } + + [Fact] + public async Task GetInvestmentsForUser_ReturnsInvestments_WhenUserExists() + { + var context = GetInMemoryDbContext(); + var logger = Mock.Of>(); + var controller = new InvestmentController(context, logger); + + var result = await controller.GetInvestmentsForUser(1); + + var okResult = Assert.IsType(result.Result); + var investments = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(investments); + } + + [Fact] + public async Task GetInvestmentDetails_ReturnsCorrectDetails_WhenInvestmentExists() + { + var context = GetInMemoryDbContext(); + var logger = Mock.Of>(); + var controller = new InvestmentController(context, logger); + + var result = await controller.GetInvestmentDetails(1, 1); + + var okResult = Assert.IsType(result.Result); + var investment = okResult.Value; + Assert.NotNull(investment); + + var name = investment.GetType().GetProperty("Name")?.GetValue(investment); + Assert.Equal("Apple Inc.", name); + } + + [Fact] + public async Task GetInvestmentsForUser_ReturnsNotFound_WhenUserDoesNotExist() + { + var context = GetInMemoryDbContext(); + var logger = Mock.Of>(); + var controller = new InvestmentController(context, logger); + + var result = await controller.GetInvestmentsForUser(13); + + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal("User 13 not found", notFoundResult.Value); + } + + [Fact] + public async Task GetInvestmentDetails_ReturnsNotFound_WhenInvestmentDoesNotExist() + { + var context = GetInMemoryDbContext(); + var logger = Mock.Of>(); + var controller = new InvestmentController(context, logger); + + var result = await controller.GetInvestmentDetails(1, 50); + + var notFoundResult = Assert.IsType(result.Result); + Assert.Equal("Investment 50 not found for user 1", notFoundResult.Value); + } + + } +} diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentPerformanceWebAPI.Tests.csproj b/InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentPerformanceWebAPI.Tests.csproj new file mode 100644 index 00000000..96201ae2 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI.Tests/InvestmentPerformanceWebAPI.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI.sln b/InvestmentWebAPI/InvestmentPerformanceWebAPI.sln new file mode 100644 index 00000000..72c8d666 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebAPI", "InvestmentPerformanceWebAPI\InvestmentPerformanceWebAPI.csproj", "{CC862C88-821D-4AEF-A507-B3A49D42F158}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebAPI.Tests", "InvestmentPerformanceWebAPI.Tests\InvestmentPerformanceWebAPI.Tests.csproj", "{AABDE67A-1EB9-4B52-B8E3-48D483E1F026}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Debug|x64.ActiveCfg = Debug|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Debug|x64.Build.0 = Debug|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Debug|x86.Build.0 = Debug|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Release|Any CPU.Build.0 = Release|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Release|x64.ActiveCfg = Release|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Release|x64.Build.0 = Release|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Release|x86.ActiveCfg = Release|Any CPU + {CC862C88-821D-4AEF-A507-B3A49D42F158}.Release|x86.Build.0 = Release|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Debug|x64.ActiveCfg = Debug|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Debug|x64.Build.0 = Debug|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Debug|x86.ActiveCfg = Debug|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Debug|x86.Build.0 = Debug|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Release|Any CPU.Build.0 = Release|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Release|x64.ActiveCfg = Release|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Release|x64.Build.0 = Release|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Release|x86.ActiveCfg = Release|Any CPU + {AABDE67A-1EB9-4B52-B8E3-48D483E1F026}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Controllers/InvestmentController.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Controllers/InvestmentController.cs new file mode 100644 index 00000000..035fe3ed --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Controllers/InvestmentController.cs @@ -0,0 +1,96 @@ +using InvestmentPerformanceWebAPI.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformanceWebAPI.Controllers +{ + [ApiController] + [Route("api/v1/users/{userId}/[controller]")] + [ApiVersion("1.0")] + public class InvestmentController : ControllerBase + { + private readonly InvestmentDbContext _context; + private readonly ILogger _logger; + + public InvestmentController(InvestmentDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + // GET: api/v1/users/{userId}/investments + [HttpGet] + public async Task>> GetInvestmentsForUser(int userId) + { + try + { + _logger.LogInformation("Fetching investments for user {UserId}", userId); + + var user = await _context.Users + .Include(u => u.Investments) + .FirstOrDefaultAsync(u => u.UserId == userId); + + if (user == null) + { + _logger.LogWarning("User {UserId} not found", userId); + return NotFound($"User {userId} not found"); + } + + var investments = user.Investments.Select(i => new + { + i.InvestmentId, + i.Name + }); + + return Ok(investments); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error occurred when fetching investments for user {userId}", userId); + return StatusCode(500, "An unepected error occurred, please try again later."); + } + + } + + // GET: api/v1/users/{userId}/investments/{investmentId} + [HttpGet("{investmentId}")] + public async Task> GetInvestmentDetails(int userId, int investmentId) + { + try + { + _logger.LogInformation("Fetching details for investment {investmentId} for user {userId}", investmentId, userId); + + var investment = await _context.Investments + .Where(i => i.UserId == userId && i.InvestmentId == investmentId) + .FirstOrDefaultAsync(); + + if (investment == null) + { + _logger.LogWarning("Investment {investmentId} not found for user {userId}", investmentId, userId); + return NotFound($"Investment {investmentId} not found for user {userId}"); + } + + var result = new + { + investment.InvestmentId, + investment.Name, + investment.Ticker, + investment.NumberOfShares, + investment.CostBasisPerShare, + investment.CurrentPrice, + investment.CurrentValue, + investment.Term, + investment.TotalGainLoss + }; + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error occurred when fetching investment {investmentId} details for user {userId}", investmentId, userId); + return StatusCode(500, "An unepected error occurred, please try again later."); + } + + } + } +} \ No newline at end of file diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Data/InvestmentDbContext.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Data/InvestmentDbContext.cs new file mode 100644 index 00000000..a55efb9e --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Data/InvestmentDbContext.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using InvestmentPerformanceWebAPI.Models; + +namespace InvestmentPerformanceWebAPI.Data +{ + public class InvestmentDbContext : DbContext + { + public InvestmentDbContext(DbContextOptions options) : base(options) + { + + } + + public DbSet Users => Set(); + public DbSet Investments => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(u => u.Investments) + .WithOne(i => i.User!) + .HasForeignKey(i => i.UserId); + + modelBuilder.Entity().HasData( + new User { UserId = 1, Name = "Alice" }, + new User { UserId = 2, Name = "Bob" } + ); + + modelBuilder.Entity().HasData( + new Investment + { + InvestmentId = 1, + UserId = 1, + Name = "Apple Inc.", + Ticker = "AAPL", + NumberOfShares = 10, + CostBasisPerShare = 120.00m, + CurrentPrice = 175.00m, + TimeOfPurchase = new DateTime(2023, 6, 10) + }, + new Investment + { + InvestmentId = 2, + UserId = 1, + Name = "Tesla Motors", + Ticker = "TSLA", + NumberOfShares = 5, + CostBasisPerShare = 220.00m, + CurrentPrice = 200.00m, + TimeOfPurchase = new DateTime(2025, 9, 15) + }, + new Investment + { + InvestmentId = 3, + UserId = 2, + Name = "Amazon.com", + Ticker = "AMZN", + NumberOfShares = 8, + CostBasisPerShare = 95.00m, + CurrentPrice = 130.00m, + TimeOfPurchase = new DateTime(2024, 2, 1) + } + ); + } + } +} \ No newline at end of file diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj b/InvestmentWebAPI/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj new file mode 100644 index 00000000..7b0b9132 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/InvestmentPerformanceWebAPI.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.Designer.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.Designer.cs new file mode 100644 index 00000000..bc05f348 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.Designer.cs @@ -0,0 +1,139 @@ +// +using System; +using InvestmentPerformanceWebAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPI.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + [Migration("20251103024509_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.10"); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CostBasisPerShare") + .HasColumnType("TEXT"); + + b.Property("CurrentPrice") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumberOfShares") + .HasColumnType("TEXT"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeOfPurchase") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("InvestmentId"); + + b.HasIndex("UserId"); + + b.ToTable("Investments"); + + b.HasData( + new + { + InvestmentId = 1, + CostBasisPerShare = 120.00m, + CurrentPrice = 175.00m, + Name = "Apple Inc.", + NumberOfShares = 10m, + Ticker = "AAPL", + TimeOfPurchase = new DateTime(2023, 6, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), + UserId = 1 + }, + new + { + InvestmentId = 2, + CostBasisPerShare = 220.00m, + CurrentPrice = 200.00m, + Name = "Tesla Motors", + NumberOfShares = 5m, + Ticker = "TSLA", + TimeOfPurchase = new DateTime(2025, 9, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), + UserId = 1 + }, + new + { + InvestmentId = 3, + CostBasisPerShare = 95.00m, + CurrentPrice = 130.00m, + Name = "Amazon.com", + NumberOfShares = 8m, + Ticker = "AMZN", + TimeOfPurchase = new DateTime(2024, 2, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + UserId = 2 + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + + b.HasData( + new + { + UserId = 1, + Name = "Alice" + }, + new + { + UserId = 2, + Name = "Bob" + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Investment", b => + { + b.HasOne("InvestmentPerformanceWebAPI.Models.User", "User") + .WithMany("Investments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Navigation("Investments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.cs new file mode 100644 index 00000000..a66ae9d0 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/20251103024509_InitialCreate.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace InvestmentPerformanceWebAPI.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + UserId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.UserId); + }); + + migrationBuilder.CreateTable( + name: "Investments", + columns: table => new + { + InvestmentId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Ticker = table.Column(type: "TEXT", nullable: false), + NumberOfShares = table.Column(type: "TEXT", nullable: false), + CostBasisPerShare = table.Column(type: "TEXT", nullable: false), + CurrentPrice = table.Column(type: "TEXT", nullable: false), + TimeOfPurchase = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Investments", x => x.InvestmentId); + table.ForeignKey( + name: "FK_Investments_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "UserId", "Name" }, + values: new object[,] + { + { 1, "Alice" }, + { 2, "Bob" } + }); + + migrationBuilder.InsertData( + table: "Investments", + columns: new[] { "InvestmentId", "CostBasisPerShare", "CurrentPrice", "Name", "NumberOfShares", "Ticker", "TimeOfPurchase", "UserId" }, + values: new object[,] + { + { 1, 120.00m, 175.00m, "Apple Inc.", 10m, "AAPL", new DateTime(2023, 6, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), 1 }, + { 2, 220.00m, 200.00m, "Tesla Motors", 5m, "TSLA", new DateTime(2025, 9, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), 1 }, + { 3, 95.00m, 130.00m, "Amazon.com", 8m, "AMZN", new DateTime(2024, 2, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), 2 } + }); + + migrationBuilder.CreateIndex( + name: "IX_Investments_UserId", + table: "Investments", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Investments"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/InvestmentDbContextModelSnapshot.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/InvestmentDbContextModelSnapshot.cs new file mode 100644 index 00000000..747ae58d --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Migrations/InvestmentDbContextModelSnapshot.cs @@ -0,0 +1,136 @@ +// +using System; +using InvestmentPerformanceWebAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPI.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + partial class InvestmentDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.10"); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CostBasisPerShare") + .HasColumnType("TEXT"); + + b.Property("CurrentPrice") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumberOfShares") + .HasColumnType("TEXT"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeOfPurchase") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("InvestmentId"); + + b.HasIndex("UserId"); + + b.ToTable("Investments"); + + b.HasData( + new + { + InvestmentId = 1, + CostBasisPerShare = 120.00m, + CurrentPrice = 175.00m, + Name = "Apple Inc.", + NumberOfShares = 10m, + Ticker = "AAPL", + TimeOfPurchase = new DateTime(2023, 6, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), + UserId = 1 + }, + new + { + InvestmentId = 2, + CostBasisPerShare = 220.00m, + CurrentPrice = 200.00m, + Name = "Tesla Motors", + NumberOfShares = 5m, + Ticker = "TSLA", + TimeOfPurchase = new DateTime(2025, 9, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), + UserId = 1 + }, + new + { + InvestmentId = 3, + CostBasisPerShare = 95.00m, + CurrentPrice = 130.00m, + Name = "Amazon.com", + NumberOfShares = 8m, + Ticker = "AMZN", + TimeOfPurchase = new DateTime(2024, 2, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + UserId = 2 + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + + b.HasData( + new + { + UserId = 1, + Name = "Alice" + }, + new + { + UserId = 2, + Name = "Bob" + }); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.Investment", b => + { + b.HasOne("InvestmentPerformanceWebAPI.Models.User", "User") + .WithMany("Investments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPI.Models.User", b => + { + b.Navigation("Investments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/Investment.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/Investment.cs new file mode 100644 index 00000000..ebeb8270 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/Investment.cs @@ -0,0 +1,20 @@ + +namespace InvestmentPerformanceWebAPI.Models +{ + public class Investment + { + public int InvestmentId { get; set; } + public int UserId { get; set; } + public string Name { get; set; } = string.Empty; + public string Ticker { get; set; } = string.Empty; + public decimal NumberOfShares { get; set; } + public decimal CostBasisPerShare { get; set; } + public decimal CurrentPrice { get; set; } + public DateTime TimeOfPurchase { get; set; } + public User? User { get; set; } + + public decimal CurrentValue => NumberOfShares * CurrentPrice; + public string Term => (DateTime.UtcNow - TimeOfPurchase).TotalDays <= 365 ? "Short Term" : "Long Term"; + public decimal TotalGainLoss => (CurrentPrice - CostBasisPerShare) * NumberOfShares; + } +} \ No newline at end of file diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/User.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/User.cs new file mode 100644 index 00000000..abb0c28f --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Models/User.cs @@ -0,0 +1,10 @@ + +namespace InvestmentPerformanceWebAPI.Models +{ + public class User + { + public int UserId { get; set; } + public string Name { get; set; } = string.Empty; + public ICollection Investments { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Program.cs b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Program.cs new file mode 100644 index 00000000..ee75d8ed --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Program.cs @@ -0,0 +1,44 @@ +using InvestmentPerformanceWebAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +string connString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException( + "Database connection string not found"); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContext(options => + options.UseSqlite(connString)); + +builder.Services.AddApiVersioning(options => +{ + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; +}); + + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/Properties/launchSettings.json b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Properties/launchSettings.json new file mode 100644 index 00000000..384ff4c4 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:58219", + "sslPort": 44332 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5048", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7058;http://localhost:5048", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.Development.json b/InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.json b/InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.json new file mode 100644 index 00000000..e96e3618 --- /dev/null +++ b/InvestmentWebAPI/InvestmentPerformanceWebAPI/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=investment.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/InvestmentWebAPI/README.md b/InvestmentWebAPI/README.md new file mode 100644 index 00000000..9dd0062e --- /dev/null +++ b/InvestmentWebAPI/README.md @@ -0,0 +1,101 @@ +# Investment Performance Web API + +## Setup + +1. Ensure you have .NET 9 installed + `dotnet --version` + +2. Navigate to the project dir + `cd InvestmentPerformanceWebAPI` + +3. Check if Migrations folder exists and is populated + If empty, recreate with + `dotnet ef migrations add InitialCreate` + +4. Create/update the SQLite database + `dotnet ef database update` + +5. Restore packages and build + `dotnet restore` + `dotnet build` + +6. Run the API + `dotnet run` + or if in solution root directory: + `dotnet run --project InvestmentPerformanceWebAPI` + + - Can also run with https: + `dotnet run --launch-profile https` + or if in solution root directory: + `dotnet run --project InvestmentPerformanceWebAPI --launch-profile https` + +7. Once server is running, navigate to swagger path for API UI + + For https: + + - Trust the dev certificate + +8. A new db file called investment.db should exist and contain dummy seeded data that you can enter into Swagger UI to see responses: + User 1: 2 investments (InvestmentIds 1, 2) + User 2: 1 investment (InvestmentId 3) + +## Example API Responses + +GET /api/v1/users/1/investments + +```json +[ + { "investmentId": 1, "name": "Apple Inc." }, + { "investmentId": 2, "name": "Tesla Motors" } +] +``` + +GET /api/v1/users/1/investments/1 + +```json +{ + "investmentId": 1, + "name": "Apple Inc.", + "ticker": "AAPL", + "numberOfShares": 10, + "costBasisPerShare": 120, + "currentPrice": 175, + "currentValue": 1750, + "term": "Long Term", + "totalGainLoss": 550 +} +``` + +## Running tests + +1. From the solution root directory, run + `dotnet test` + +2. Expected output: + `Passed! - 4 passed, 0 failed, 0 skipped` + +3. Test Coverage: + - GetInvestmentsForUser_ReturnsInvestments_WhenUserExists -> Returns the seeded investments for a valid user + - GetInvestmentDetails_ReturnsCorrectDetails_WhenInvestmentExists -> Returns details for specific investment + - GetInvestmentsForUser_ReturnsNotFound_WhenUserDoesNotExist -> Returns 404 for a not found user + - GetInvestmentDetails_ReturnsNotFound_WhenInvestmentDoesNotExist -> Returns 404 for a not found investment + +## Notes + +- Tech Stack: + - C# and .NET 9 (.NET 9.0.306 -> my version) + - ASP.NET Core Web API - controller based + - Database: SQLite with EF Core ORM + - Testing: xUnit and some Moq + - Swagger and OpenAPI +- Project was made to be prod-ready but uses dummy seeded data for ease of use and an in memory database for testing +- Since this will be connected to a trading platform, decided to add Ticker field as well + +## Assumptions/Tradeoffs + +- Given the goal is to deliver a production-ready API within a few hours, I prioritized clean architecture (separating data, models, controllers), unit tests, basic security such as https, versioning in api path, and server-side and client-side logging was kept distinct from each other. If this was a longer term devloped/maintained project then some areas that could be implemented on are: + - Global exception middleware for more fitting error handling and centralized logging + - Data annotations for input validation and database constraints + - Rate limiting + - Response caching + - Along with many other enhancements