diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1ff0c423 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9491a2fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,363 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/InvestmentPerformaceApiTest/InvestmentPerformaceApiTest.csproj b/InvestmentPerformaceApiTest/InvestmentPerformaceApiTest.csproj new file mode 100644 index 00000000..f96eef53 --- /dev/null +++ b/InvestmentPerformaceApiTest/InvestmentPerformaceApiTest.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentPerformaceApiTest/InvestmentsControllerTests.cs b/InvestmentPerformaceApiTest/InvestmentsControllerTests.cs new file mode 100644 index 00000000..e7a979b5 --- /dev/null +++ b/InvestmentPerformaceApiTest/InvestmentsControllerTests.cs @@ -0,0 +1,89 @@ +using InvestmentPerformanceWebAPIExercise.Data; +using InvestmentPerformanceWebAPIExercise.Models; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestPlatform.TestHost; +using System.Net.Http.Json; + +namespace InvestmentPerformanceWebAPIExercise +{ + public class InvestmentsControllerTests : IClassFixture> + { + private readonly HttpClient _client; + + private Guid _investmentId = Guid.Empty; + + public InvestmentsControllerTests(WebApplicationFactory factory) + { + _client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Replace DbContext with in-memory version + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + services.Remove(descriptor); + + services.AddDbContext(options => + options.UseInMemoryDatabase("NuixTestDb")); + + // Seed test data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + _investmentId = Guid.NewGuid(); + + db.Investments.Add(new Investment + { + InvestmentId = _investmentId, + UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Name = "Test Investment", + Shares = 100, + CostBasisPerShare = 50.00m, + CurrentPrice = 75.00m, + PurchaseDate = DateTime.UtcNow.AddMonths(-14) + }); + db.SaveChanges(); + }); + }).CreateClient(); + } + + [Fact] + public async Task GetUserInvestments_ReturnsInvestmentList() + { + var userId = "11111111-1111-1111-1111-111111111111"; + + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/investments/user?userId={userId}"); + request.Headers.Add("x-api-key", "NuiX123!"); + + var response = await _client.SendAsync(request); + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains("Test Investment", content); + + } + + [Fact] + public async Task GetInvestmentDetails_ReturnsCorrectPerformance() + { + var userId = "11111111-1111-1111-1111-111111111111"; + var investmentId = _investmentId; + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/investments/user/{userId}/investment/{investmentId}"); + request.Headers.Add("x-api-key", "NuiX123!"); + + var response = await _client.SendAsync(request); + + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(100, json!.Shares); + Assert.Equal("Long Term", json.Term); + Assert.Equal(2500.00m, json.TotalGainLoss); + } + } +} \ No newline at end of file diff --git a/InvestmentPerformanceWebAPIExercise.sln b/InvestmentPerformanceWebAPIExercise.sln new file mode 100644 index 00000000..12ee4237 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36518.9 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebAPIExercise", "InvestmentPerformanceWebAPIExercise\InvestmentPerformanceWebAPIExercise.csproj", "{AD432653-2FFF-4356-830B-5A06E8EBC2DA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformaceApiTest", "InvestmentPerformaceApiTest\InvestmentPerformaceApiTest.csproj", "{55D85C9F-5421-447B-85F2-E4931BD6448A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AD432653-2FFF-4356-830B-5A06E8EBC2DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD432653-2FFF-4356-830B-5A06E8EBC2DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD432653-2FFF-4356-830B-5A06E8EBC2DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD432653-2FFF-4356-830B-5A06E8EBC2DA}.Release|Any CPU.Build.0 = Release|Any CPU + {55D85C9F-5421-447B-85F2-E4931BD6448A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55D85C9F-5421-447B-85F2-E4931BD6448A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55D85C9F-5421-447B-85F2-E4931BD6448A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55D85C9F-5421-447B-85F2-E4931BD6448A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {509A2E16-DA18-4C36-BA04-5DA66CDF1F01} + EndGlobalSection +EndGlobal diff --git a/InvestmentPerformanceWebAPIExercise/Common/Attributes.cs b/InvestmentPerformanceWebAPIExercise/Common/Attributes.cs new file mode 100644 index 00000000..c6f0dedf --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Common/Attributes.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace InvestmentPerformanceWebAPIExercise.Common +{ + public class Attributes + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class ApiKeyAttribute : ServiceFilterAttribute + { + public ApiKeyAttribute() : base(typeof(ApiKeyAuthorizationFilter)) { } + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Common/Encryption.cs b/InvestmentPerformanceWebAPIExercise/Common/Encryption.cs new file mode 100644 index 00000000..2d0d0e27 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Common/Encryption.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; +using System.Text; + +namespace InvestmentPerformanceWebAPIExercise.Common +{ + public class Encryption + { + private static readonly IConfiguration _config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + private static readonly int _iterations = 10000; + private static readonly int _keySize = 256; + + private static readonly string _salt = _config["AppSettings:Salt"] ?? string.Empty; + private static readonly string _vector = _config["AppSettings:Vector"] ?? string.Empty; + private static readonly string _password = _config["AppSettings:EncKey"] ?? string.Empty; + + public static string ErrorMessage = string.Empty; + + public static string Encrypt(string value) => Encrypt(value, _password); + public static string Encrypt(string value, string password) => EncryptInternal(value, password); + public static string Decrypt(string value) => Decrypt(value, _password); + public static string Decrypt(string value, string password) => DecryptInternal(value, password); + + private static string EncryptInternal(string value, string password) + { + byte[] vectorBytes = Encoding.UTF8.GetBytes(_vector); + byte[] saltBytes = Encoding.UTF8.GetBytes(_salt); + byte[] valueBytes = Encoding.UTF8.GetBytes(value); + + using var aes = Aes.Create(); + using var keyDerivation = new Rfc2898DeriveBytes(password, saltBytes, _iterations, HashAlgorithmName.SHA256); + byte[] keyBytes = keyDerivation.GetBytes(_keySize / 8); + + aes.Mode = CipherMode.CBC; + aes.Key = keyBytes; + aes.IV = vectorBytes; + + using var encryptor = aes.CreateEncryptor(); + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write); + cs.Write(valueBytes, 0, valueBytes.Length); + cs.FlushFinalBlock(); + + return Convert.ToBase64String(ms.ToArray()); + } + + private static string DecryptInternal(string value, string password) + { + byte[] vectorBytes = Encoding.UTF8.GetBytes(_vector); + byte[] saltBytes = Encoding.UTF8.GetBytes(_salt); + byte[] valueBytes = Convert.FromBase64String(value); + + using var aes = Aes.Create(); + using var keyDerivation = new Rfc2898DeriveBytes(password, saltBytes, _iterations, HashAlgorithmName.SHA256); + byte[] keyBytes = keyDerivation.GetBytes(_keySize / 8); + + aes.Mode = CipherMode.CBC; + aes.Key = keyBytes; + aes.IV = vectorBytes; + + try + { + using var decryptor = aes.CreateDecryptor(); + using var ms = new MemoryStream(valueBytes); + using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read); + using var reader = new StreamReader(cs, Encoding.UTF8); + return reader.ReadToEnd(); + } + catch (CryptographicException) + { + ErrorMessage = "Invalid key for encrypted text"; + return string.Empty; + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + return string.Empty; + } + } + } + + public static class Hashing + { + public static string GenerateSHA256String(string inputString) + { + using var sha256 = SHA256.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(inputString); + byte[] hash = sha256.ComputeHash(bytes); + return ConvertToHex(hash); + } + + public static string GenerateSHA512String(string inputString) + { + using var sha512 = SHA512.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(inputString); + byte[] hash = sha512.ComputeHash(bytes); + return ConvertToHex(hash); + } + + private static string ConvertToHex(byte[] hash) + { + StringBuilder result = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + result.Append(b.ToString("X2")); + } + return result.ToString(); + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Common/Filters.cs b/InvestmentPerformanceWebAPIExercise/Common/Filters.cs new file mode 100644 index 00000000..61415d43 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Common/Filters.cs @@ -0,0 +1,27 @@ +using InvestmentPerformanceWebAPIExercise.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace InvestmentPerformanceWebAPIExercise.Common +{ + public class ApiKeyAuthorizationFilter : IAuthorizationFilter + { + private const string ApiKeyHeaderName = "x-api-key"; + private readonly IApiKeyValidator _apiKeyValidator; + + public ApiKeyAuthorizationFilter(IApiKeyValidator apiKeyValidator) + { + _apiKeyValidator = apiKeyValidator; + } + + public void OnAuthorization(AuthorizationFilterContext context) + { + var apiKey = context.HttpContext.Request.Headers[ApiKeyHeaderName].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(apiKey) || !_apiKeyValidator.IsValid(apiKey)) + { + context.Result = new UnauthorizedResult(); + } + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Controllers/InvestmentsController.cs b/InvestmentPerformanceWebAPIExercise/Controllers/InvestmentsController.cs new file mode 100644 index 00000000..e5971a4b --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Controllers/InvestmentsController.cs @@ -0,0 +1,83 @@ +using InvestmentPerformanceWebAPIExercise.Data; +using Microsoft.AspNetCore.Mvc; +using static InvestmentPerformanceWebAPIExercise.Common.Attributes; + +namespace InvestmentPerformanceWebAPIExercise.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [ApiKey] + public class InvestmentsController : ControllerBase + { + private readonly InvestmentDbContext _context; + private readonly ILogger _logger; + + public InvestmentsController(InvestmentDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// Get all investments for a user. + /// + [HttpGet("user")] + public IActionResult GetUserInvestments([FromQuery] Guid userId) + { + _logger.LogInformation($"*** {ControllerContext.ActionDescriptor.ControllerName} - {ControllerContext.ActionDescriptor.ActionName} called ***"); + + try + { + var investments = _context.Investments + .Where(i => i.UserId == userId) + .Select(i => new { i.InvestmentId, i.Name }) + .ToList(); + + if (!investments.Any()) + { + } + + return Ok(investments); + } + catch (Exception ex) + { + _logger.LogError(ex, $"{ControllerContext.ActionDescriptor.ControllerName} - {ControllerContext.ActionDescriptor.ActionName}: {ex.Message}"); + return BadRequest(ex.Message); + } + } + + /// + /// Get detailed performance of a user's investment. + /// + [HttpGet("user/{userId}/investment/{investmentId}")] + public IActionResult GetInvestmentDetails(Guid userId, Guid investmentId) + { + _logger.LogInformation($"*** {ControllerContext.ActionDescriptor.ControllerName} - {ControllerContext.ActionDescriptor.ActionName} called ***"); + + try + { + var investment = _context.Investments + .FirstOrDefault(i => i.UserId == userId && i.InvestmentId == investmentId); + + if (investment == null) + return NotFound(); + + var dto = new InvestmentDetailDto + { + Shares = investment.Shares, + CostBasisPerShare = investment.CostBasisPerShare, + CurrentPrice = investment.CurrentPrice, + Term = (DateTime.UtcNow - investment.PurchaseDate).TotalDays <= 365 ? "Short Term" : "Long Term", + TotalGainLoss = (investment.Shares * investment.CurrentPrice) - (investment.Shares * investment.CostBasisPerShare) + }; + + return Ok(dto); + } + catch (Exception ex) + { + _logger.LogError(ex, $"{ControllerContext.ActionDescriptor.ControllerName} - {ControllerContext.ActionDescriptor.ActionName}: {ex.Message}"); + return BadRequest(ex.Message); + } + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Data/InvestmentDbContext.cs b/InvestmentPerformanceWebAPIExercise/Data/InvestmentDbContext.cs new file mode 100644 index 00000000..56620712 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Data/InvestmentDbContext.cs @@ -0,0 +1,13 @@ +using InvestmentPerformanceWebAPIExercise.Models; +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformanceWebAPIExercise.Data +{ + public class InvestmentDbContext : DbContext + { + public DbSet Investments { get; set; } + + public InvestmentDbContext(DbContextOptions options) + : base(options) { } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Data/InvestmentDetailDto.cs b/InvestmentPerformanceWebAPIExercise/Data/InvestmentDetailDto.cs new file mode 100644 index 00000000..7f270515 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Data/InvestmentDetailDto.cs @@ -0,0 +1,12 @@ +namespace InvestmentPerformanceWebAPIExercise.Data +{ + public class InvestmentDetailDto + { + public int Shares { get; set; } + public decimal CostBasisPerShare { get; set; } + public decimal CurrentPrice { get; set; } + public decimal CurrentValue => Shares * CurrentPrice; + public string Term { get; set; } = string.Empty; + public decimal TotalGainLoss { get; set; } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Data/InvestmentDto.cs b/InvestmentPerformanceWebAPIExercise/Data/InvestmentDto.cs new file mode 100644 index 00000000..c57cba11 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Data/InvestmentDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace InvestmentPerformanceWebAPIExercise.Data +{ + public class InvestmentDto + { + public Guid InvestmentId { get; set; } + + [Required] + [StringLength(100)] + public string Name { get; set; } + + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Interfaces/IApiKeyValidator.cs b/InvestmentPerformanceWebAPIExercise/Interfaces/IApiKeyValidator.cs new file mode 100644 index 00000000..2c3406bb --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Interfaces/IApiKeyValidator.cs @@ -0,0 +1,8 @@ +namespace InvestmentPerformanceWebAPIExercise.Interfaces +{ + public interface IApiKeyValidator + { + bool IsValid(string apiKey); + } + +} diff --git a/InvestmentPerformanceWebAPIExercise/InvestmentPerformanceWebAPIExercise.csproj b/InvestmentPerformanceWebAPIExercise/InvestmentPerformanceWebAPIExercise.csproj new file mode 100644 index 00000000..e453e9b7 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/InvestmentPerformanceWebAPIExercise.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/InvestmentPerformanceWebAPIExercise/InvestmentPerformanceWebAPIExercise.http b/InvestmentPerformanceWebAPIExercise/InvestmentPerformanceWebAPIExercise.http new file mode 100644 index 00000000..62f107f6 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/InvestmentPerformanceWebAPIExercise.http @@ -0,0 +1,6 @@ +@InvestmentPerformanceWebAPIExercise_HostAddress = http://localhost:5081 + +GET {{InvestmentPerformanceWebAPIExercise_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028133554_InitialCreate.Designer.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028133554_InitialCreate.Designer.cs new file mode 100644 index 00000000..6a732e8b --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028133554_InitialCreate.Designer.cs @@ -0,0 +1,87 @@ +// +using System; +using InvestmentPerformanceWebAPIExercise.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + [Migration("20251028133554_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("InvestmentId"); + + b.ToTable("Investments"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.UserInvestment", b => + { + b.Property("UserInvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasColumnType("decimal(18,2)"); + + b.Property("InvestmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserInvestmentId"); + + b.HasIndex("InvestmentId"); + + b.ToTable("UserInvestments"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.UserInvestment", b => + { + b.HasOne("InvestmentPerformanceWebAPIExercise.Models.Investment", "Investment") + .WithMany() + .HasForeignKey("InvestmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Investment"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028133554_InitialCreate.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028133554_InitialCreate.cs new file mode 100644 index 00000000..a033e682 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028133554_InitialCreate.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Investments", + columns: table => new + { + InvestmentId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false), + CurrentPrice = table.Column(type: "decimal(18,2)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Investments", x => x.InvestmentId); + }); + + migrationBuilder.CreateTable( + name: "UserInvestments", + columns: table => new + { + UserInvestmentId = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + InvestmentId = table.Column(type: "uniqueidentifier", nullable: false), + Shares = table.Column(type: "int", nullable: false), + CostBasisPerShare = table.Column(type: "decimal(18,2)", nullable: false), + PurchaseDate = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserInvestments", x => x.UserInvestmentId); + table.ForeignKey( + name: "FK_UserInvestments_Investments_InvestmentId", + column: x => x.InvestmentId, + principalTable: "Investments", + principalColumn: "InvestmentId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserInvestments_InvestmentId", + table: "UserInvestments", + column: "InvestmentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserInvestments"); + + migrationBuilder.DropTable( + name: "Investments"); + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028154827_ModifiedObjects-10-28-25-1146.Designer.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028154827_ModifiedObjects-10-28-25-1146.Designer.cs new file mode 100644 index 00000000..df4e6789 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028154827_ModifiedObjects-10-28-25-1146.Designer.cs @@ -0,0 +1,100 @@ +// +using System; +using InvestmentPerformanceWebAPIExercise.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + [Migration("20251028154827_ModifiedObjects-10-28-25-1146")] + partial class ModifiedObjects1028251146 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasColumnType("float"); + + b.Property("CurrentPrice") + .HasColumnType("float"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("InvestmentId"); + + b.ToTable("Investments"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.UserInvestment", b => + { + b.Property("UserInvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("InvestmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserInvestmentId"); + + b.HasIndex("InvestmentId"); + + b.ToTable("UserInvestments"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.UserInvestment", b => + { + b.HasOne("InvestmentPerformanceWebAPIExercise.Models.Investment", "Investment") + .WithMany() + .HasForeignKey("InvestmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Investment"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028154827_ModifiedObjects-10-28-25-1146.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028154827_ModifiedObjects-10-28-25-1146.cs new file mode 100644 index 00000000..2e4f7c24 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028154827_ModifiedObjects-10-28-25-1146.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + /// + public partial class ModifiedObjects1028251146 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CurrentPrice", + table: "Investments", + type: "float", + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)"); + + migrationBuilder.AddColumn( + name: "CostBasisPerShare", + table: "Investments", + type: "float", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "PurchaseDate", + table: "Investments", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "Shares", + table: "Investments", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Investments", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CostBasisPerShare", + table: "Investments"); + + migrationBuilder.DropColumn( + name: "PurchaseDate", + table: "Investments"); + + migrationBuilder.DropColumn( + name: "Shares", + table: "Investments"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Investments"); + + migrationBuilder.AlterColumn( + name: "CurrentPrice", + table: "Investments", + type: "decimal(18,2)", + nullable: false, + oldClrType: typeof(double), + oldType: "float"); + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028161907_ModifiedObjects-10-28-25-1219.Designer.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028161907_ModifiedObjects-10-28-25-1219.Designer.cs new file mode 100644 index 00000000..2baba464 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028161907_ModifiedObjects-10-28-25-1219.Designer.cs @@ -0,0 +1,100 @@ +// +using System; +using InvestmentPerformanceWebAPIExercise.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + [Migration("20251028161907_ModifiedObjects-10-28-25-1219")] + partial class ModifiedObjects1028251219 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasColumnType("decimal(18,2)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("InvestmentId"); + + b.ToTable("Investments"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.UserInvestment", b => + { + b.Property("UserInvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("InvestmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserInvestmentId"); + + b.HasIndex("InvestmentId"); + + b.ToTable("UserInvestments"); + }); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.UserInvestment", b => + { + b.HasOne("InvestmentPerformanceWebAPIExercise.Models.Investment", "Investment") + .WithMany() + .HasForeignKey("InvestmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Investment"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028161907_ModifiedObjects-10-28-25-1219.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028161907_ModifiedObjects-10-28-25-1219.cs new file mode 100644 index 00000000..e7b9ad61 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028161907_ModifiedObjects-10-28-25-1219.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + /// + public partial class ModifiedObjects1028251219 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CurrentPrice", + table: "Investments", + type: "decimal(18,2)", + nullable: false, + oldClrType: typeof(double), + oldType: "float"); + + migrationBuilder.AlterColumn( + name: "CostBasisPerShare", + table: "Investments", + type: "decimal(18,2)", + nullable: false, + oldClrType: typeof(double), + oldType: "float"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CurrentPrice", + table: "Investments", + type: "float", + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)"); + + migrationBuilder.AlterColumn( + name: "CostBasisPerShare", + table: "Investments", + type: "float", + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)"); + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028174118_CleanupDatabase.Designer.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028174118_CleanupDatabase.Designer.cs new file mode 100644 index 00000000..e9f3dce7 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028174118_CleanupDatabase.Designer.cs @@ -0,0 +1,60 @@ +// +using System; +using InvestmentPerformanceWebAPIExercise.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + [Migration("20251028174118_CleanupDatabase")] + partial class CleanupDatabase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasColumnType("decimal(18,2)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("InvestmentId"); + + b.ToTable("Investments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/20251028174118_CleanupDatabase.cs b/InvestmentPerformanceWebAPIExercise/Migrations/20251028174118_CleanupDatabase.cs new file mode 100644 index 00000000..bc7129a1 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/20251028174118_CleanupDatabase.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + /// + public partial class CleanupDatabase : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserInvestments"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserInvestments", + columns: table => new + { + UserInvestmentId = table.Column(type: "uniqueidentifier", nullable: false), + InvestmentId = table.Column(type: "uniqueidentifier", nullable: false), + CostBasisPerShare = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + PurchaseDate = table.Column(type: "datetime2", nullable: false), + Shares = table.Column(type: "int", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserInvestments", x => x.UserInvestmentId); + table.ForeignKey( + name: "FK_UserInvestments_Investments_InvestmentId", + column: x => x.InvestmentId, + principalTable: "Investments", + principalColumn: "InvestmentId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserInvestments_InvestmentId", + table: "UserInvestments", + column: "InvestmentId"); + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Migrations/InvestmentDbContextModelSnapshot.cs b/InvestmentPerformanceWebAPIExercise/Migrations/InvestmentDbContextModelSnapshot.cs new file mode 100644 index 00000000..18aa0a72 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Migrations/InvestmentDbContextModelSnapshot.cs @@ -0,0 +1,57 @@ +// +using System; +using InvestmentPerformanceWebAPIExercise.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InvestmentPerformanceWebAPIExercise.Migrations +{ + [DbContext(typeof(InvestmentDbContext))] + partial class InvestmentDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InvestmentPerformanceWebAPIExercise.Models.Investment", b => + { + b.Property("InvestmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CostBasisPerShare") + .HasColumnType("decimal(18,2)"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("Shares") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("InvestmentId"); + + b.ToTable("Investments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Models/Investment.cs b/InvestmentPerformanceWebAPIExercise/Models/Investment.cs new file mode 100644 index 00000000..c0f91fa9 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Models/Investment.cs @@ -0,0 +1,13 @@ +namespace InvestmentPerformanceWebAPIExercise.Models +{ + public class Investment + { + public Guid InvestmentId { get; set; } + public Guid UserId { get; set; } + public string Name { get; set; } = string.Empty; + public int Shares { get; set; } + public decimal CostBasisPerShare { get; set; } + public decimal CurrentPrice { get; set; } + public DateTime PurchaseDate { get; set; } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Program.cs b/InvestmentPerformanceWebAPIExercise/Program.cs new file mode 100644 index 00000000..13420290 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Program.cs @@ -0,0 +1,204 @@ +using InvestmentPerformanceWebAPIExercise.Common; +using InvestmentPerformanceWebAPIExercise.Data; +using InvestmentPerformanceWebAPIExercise.Interfaces; +using InvestmentPerformanceWebAPIExercise.Models; +using InvestmentPerformanceWebAPIExercise.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Sinks.MSSqlServer; +using System.Reflection; + +namespace InvestmentPerformanceWebAPIExercise +{ + public class Program + { + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddControllers(); + + //******************************************************************** + // Register services + //******************************************************************** + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + + //******************************************************************** + // Swagger configuration + //******************************************************************** + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Investment Performance API", + Version = "v1", + Description = "API for querying investment performance data" + }); + + + // Add API Key security definition + c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme + { + Description = "API Key needed to access the endpoints. Use 'x-api-key: YOUR_KEY'", + Name = "x-api-key", // Header name + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "ApiKeyScheme" + }); + + // Add global security requirement + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "ApiKey" + } + }, + Array.Empty() + } + }); + + // Set the comments path for the Swagger JSON and UI. + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + c.IncludeXmlComments(xmlPath); + }); + + + + //******************************************************************** + // Database dependency injection + //******************************************************************** + var connString = string.Empty; + var env = builder.Configuration.GetSection("AppSettings")["AppConfig"]; + switch (env.ToLower()) + { + case "local": + connString = Encryption.Decrypt(builder.Configuration.GetConnectionString("SQLLocalConnection")); + break; + case "dev": + connString = Encryption.Decrypt(builder.Configuration.GetConnectionString("SQLDevConnection")); + break; + case "stage": + connString = Encryption.Decrypt(builder.Configuration.GetConnectionString("SQLStageConnection")); + break; + default: + connString = Encryption.Decrypt(builder.Configuration.GetConnectionString("SQLProdConnection")); + break; + } + builder.Services.AddDbContext(opt => opt.UseSqlServer(connString, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)).LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Error)); + + //****************************************** + // Serilog configuration + //****************************************** + var logLevelSwitch = new LoggingLevelSwitch(); + + switch (builder.Configuration.GetSection("AppSettings")["LoggingLevel"]?.ToLower()) + { + case "information": + logLevelSwitch.MinimumLevel = LogEventLevel.Information; + break; + case "warning": + logLevelSwitch.MinimumLevel = LogEventLevel.Warning; + break; + case "debug": + logLevelSwitch.MinimumLevel = LogEventLevel.Debug; + break; + case "error": + logLevelSwitch.MinimumLevel = LogEventLevel.Error; + break; + default: + logLevelSwitch.MinimumLevel = LogEventLevel.Verbose; + break; + } + + var sinkOpts = new MSSqlServerSinkOptions + { + TableName = "Logs", + AutoCreateSqlDatabase = true, + AutoCreateSqlTable = true, + BatchPeriod = TimeSpan.FromMinutes(1), + LevelSwitch = logLevelSwitch + }; + var columnOpts = new ColumnOptions(); + columnOpts.Store.Remove(StandardColumn.Properties); + columnOpts.Store.Add(StandardColumn.LogEvent); + columnOpts.PrimaryKey = columnOpts.Id; + columnOpts.Id.NonClusteredIndex = true; + + Log.Logger = new LoggerConfiguration() + .AuditTo.MSSqlServer( + connectionString: connString, + sinkOptions: sinkOpts, + columnOptions: columnOpts + ) + .Enrich.FromLogContext() + .CreateLogger(); + + + builder.Logging.ClearProviders(); + builder.Logging.AddSerilog(Log.Logger); + + Log.Information("***************** Logging Initiated *************"); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + //Add 25 seed investments + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + if (!db.Investments.Any()) + { + int count = 25; + int shares = new Random().Next(100, 501); + decimal cost = Math.Round((decimal)(new Random().NextDouble() * (500 - 100) + 100), 2); + decimal price = Math.Round((decimal)(new Random().NextDouble() * (500 - 100) + 100), 2); + int months = new Random().Next(1, 24); + for (int i = 0; i < count; i++) + { + db.Investments.Add(new Investment + { + InvestmentId = Guid.NewGuid(), + UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Name = $"Tech Fund {i + 1}", + Shares = shares, + CostBasisPerShare = cost, + CurrentPrice = price, + PurchaseDate = DateTime.UtcNow.AddMonths(-months) + }); + + //Generate random values for the next round of data + shares = new Random().Next(100, 501); + cost = Math.Round((decimal)(new Random().NextDouble() * (500 - 100) + 100), 2); + price = Math.Round((decimal)(new Random().NextDouble() * (500 - 100) + 100), 2); + months = new Random().Next(1, 24); + } + db.SaveChanges(); + } + } + + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.MapControllers(); + + app.Run(); + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Properties/launchSettings.json b/InvestmentPerformanceWebAPIExercise/Properties/launchSettings.json new file mode 100644 index 00000000..ddd91d91 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:64845", + "sslPort": 44368 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7246;http://localhost:5081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InvestmentPerformanceWebAPIExercise/Services/ApiKeyValidationService.cs b/InvestmentPerformanceWebAPIExercise/Services/ApiKeyValidationService.cs new file mode 100644 index 00000000..026f8a71 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/Services/ApiKeyValidationService.cs @@ -0,0 +1,21 @@ +using InvestmentPerformanceWebAPIExercise.Interfaces; + +namespace InvestmentPerformanceWebAPIExercise.Services +{ + public class ApiKeyValidationService : IApiKeyValidator + { + private readonly IConfiguration _configuration; + + public ApiKeyValidationService(IConfiguration configuration) + { + _configuration = configuration; + } + + public bool IsValid(string apiKey) + { + var validKey = _configuration["AppSettings:ApiKey"]; + return apiKey == validKey; + } + } + +} diff --git a/InvestmentPerformanceWebAPIExercise/appsettings.Development.json b/InvestmentPerformanceWebAPIExercise/appsettings.Development.json new file mode 100644 index 00000000..4b814ca2 --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AppSettings": { + //General settings + "LoggingLevel": "debug" + } +} diff --git a/InvestmentPerformanceWebAPIExercise/appsettings.json b/InvestmentPerformanceWebAPIExercise/appsettings.json new file mode 100644 index 00000000..c55b430d --- /dev/null +++ b/InvestmentPerformanceWebAPIExercise/appsettings.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "SQLDevConnection": "Y1yDjv7mRJ6i/IabyoH5vfayJMJQAzXjC+XWKHm3+N5N3n1f5Mgx7WynVs6yUGRcBJOedd8W7TnenvfK27/P/pL6r9dEAnEqu8QI/5A9SOcACIHM880SK+QStYIzzgPPd05FjwKhfNG3FAJPqR4slezn1QdflA92K7WRzvSNu40PnDxcMgWd58omPdUbjPW6BHKJWvhnTaXKfAhReYMDWOcfNqGf7NsoiwQEFesk/Yo/KaYUDS0Sp9xDN06a+FDWMQ4qLmtl1fMzdpPHTgLIGsxHg3cTBBApBe/SL4R7Fy5oYCmOro6y+G3Efj9mSIPA", + "SQLStageConnection": "Y1yDjv7mRJ6i/IabyoH5vfayJMJQAzXjC+XWKHm3+N5N3n1f5Mgx7WynVs6yUGRcBJOedd8W7TnenvfK27/P/lpKnsIXsAKqBYP0Lewz8Iun9YYpD9wSd1wyjKop5R6zpUXxwfG8svBW+pBdk8StYc/Ek0Twqc7JymHOrdPtlvRe6xDQWmAf1/3qTZjNzFoXHLX+SV6Y9olL02gA1IF4ACz/eSaPPVDtQl/t+vTFl1HFWtBsRx5JRJjG1FQ5k/k8wstaiZQT/SSwSKiUdYggrzCjKTns9yTtTPNOGPm6+RexrV1TXtLnq2JFtryK525e", + "SQLProdConnection": "Y1yDjv7mRJ6i/IabyoH5vfayJMJQAzXjC+XWKHm3+N5N3n1f5Mgx7WynVs6yUGRcBJOedd8W7TnenvfK27/P/pBUoSxWRLBCdC+r7uDVdn4Eg1ZRHnY8jmzv2EZ9CJWV976b1kakuqzTDDu26OVHtk+BIiR8wR36BCnVpYqo7kb8l50ruZXYHBm9MA0+NASR7A1keMXy01cyT+wrfhdFtD15z5S9zapjzuxbb1Pg3/KYBMQz7K261Nhu5GaxzfBNKTEs9VP9N0duIbVDPd2aVRYwArlV1VDNzyx0/uTnlx0e2DqGT4ldn4H7/LIjMnLq" + }, + "AppSettings": { + //General settings + "AppConfig": "prod", + "LoggingLevel": "warning", + + //Encryption settings + "EncKey": "@nUix123!", + "Salt": "m3d8pt60e4bu28d7", + "Vector": "e8fh46s0k35wx6ve", + + // API key settings + "ApiKey": "NuiX123!" + } +} diff --git a/ReadMe.md b/ReadMe.md index ad2afefb..4950639a 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,28 +1,83 @@ -# Coding Exercise -> This repository holds coding exercises for candidates going through the hiring process. +# My understanding +I was tasked to create a simple API which allows a user to perform the following: -You should have been assigned one of the coding exercises in this list. More details can be found in the specific md file for that assignment. +1. Retrieve a list of investments for a user +2. Retrieve the details of a specific investment for a user -Instructions: Fork this repository, do the assigned work, and submit a pull request for review. +# Assumptions +- Each user has a unique identifier. +- Each user can have multiple investments. +- Each investment has a unique identifier. +- The API user has been authenticated and authorized to access a list of user investments. +- The API user has been authenticated and authorized to access the details of a specific investment. +- For testing purposes the database should be seeded with sample data. +- The API should handle errors gracefully and return appropriate HTTP status codes. +- Logging should be implemented to track API usage and errors. +- The API should be documented using OpenAPI/Swagger for easy consumption by clients. +- The API should follow RESTful principles for resource management. +- Basic tests using a testing framework like xUnit or NUnit should be included to verify the functionality of the API endpoints. -[Investment Performance Web API](InvestmentPerformanceWebAPI.md#investment-performance-web-api) +# Implementation +The API is implemented using ASP.NET Core Web API framework. It includes the following components: +- The project targets .NET 8.0 and uses C# 12.0 features. +- The project uses Entity Framework Core with a SQL Server database to store user and investment data. -[Online Ordering SQL](OnlineOrderingSQL.md#online-ordering) +Folder Structure: +- Models: Define the data structures for an Investment. +- Controllers: Handle HTTP requests and responses for the API endpoints. +- Services: Contains the logic for validating api keys. +- Data: Contains the database context for Entity Framework Core and associated DTOs. +- Common: Contains common utilities such as encryption helper classes. +- Migrations: Contains the Entity Framework Core migrations for database schema management. -# License +Additionally, the project includes the following features: +- Serilog: Handles error logging. +- Swagger: Provides API documentation. +- Unit Tests: Verify the functionality of the API endpoints. +- Seed Data: Initializes the database with sample data for testing purposes. +- API Key Authentication: Secures the API endpoints. +- Encryption: Used for encryption connection strings. +- Configuration (AppSettings): Manages application settings and configurations. +- Dependency Injection: Manages service lifetimes and dependencies. -``` -Copyright 2021 Nuix +# Usage -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +The application can be run locally using the .NET CLI or Visual Studio. Once running, the API endpoints can be accessed via HTTP requests sent to https://localhost:7246. The Swagger UI can be used to explore and test the API endpoints. The 2 main endpoints for this exercise can be found at the following URLs: - http://www.apache.org/licenses/LICENSE-2.0 + https://localhost:7246/api/Investments/user?userId={userId} -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` \ No newline at end of file + https://localhost:7246/api/Investments/user/{investmentId} + +The UserId for the single test user for this exercise is: 11111111-1111-1111-1111-111111111111 + +The API key for accessing the endpoints is through Swagger, Postman or any other development tool is as follows: + + header: x-api-key + value: NuiX123! + +The appSettings.json file contains an "AppConfig" entry for testing against different environments (i.e. "dev", "stage" and "prod"). You can change the value of the "AppConfig" entry to test against different environments. Each environment has its own appsettings.{environment}.json file which contains the appropriate settings for that environment. + +The appSettings.json file also contains an encrypted connection string for the database. The encryption key is stored in the "Encryption settings" section, along with the salt and vector values. These values should be stored in a secure location outside of the application (i.e. Azure Key Vault, AWS Secrets Manager, etc.) for production use. However, for the purposes of this exercise I have included them in the appSettings.json file. + +The database(s) are stored in my personal Azure subscription. I will provide public access to the database(s) throughout the interview process. Once this process concludes I will remove the database, access and any associated resources. + +Logging level is configured using the "LoggingLevel" entry of the appSettings.json file. The current configuration is set to log "debug" level messages in a development environment and "warning" level messages in a production environment. Environments are configured using the appropriate appsettings.json file for the desired environment. + +# xUnit test + +Basic tests are included in the InvestmentPerformaceApiTest.csproj project. The test performs the following: + +1. Uses an in-memory database for testing purposes. +2. Adds an investment for a user. +3. Retrieves the list of investments for the user and verifies that the added investment is present in the list. +4. Retrieves the investment details for the added investment and verifies that the details match. + +These tests can be run using the .NET CLI or Visual Studio Test Explorer. The tests cover the main functionality of the API endpoints. The specific commands that I used during testing are as follows: + + dotnet restore + dotnet build + dotnet test + +The results of running the tests are as follows: + + Test summary: total: 2, failed: 0, succeeded: 2, skipped: 0, duration: 27.8s \ No newline at end of file