From 676b90834e3552c991c65edbe63d253a815b438c Mon Sep 17 00:00:00 2001 From: Matt Wherry Date: Tue, 3 Feb 2026 17:07:33 -0500 Subject: [PATCH 1/6] nfc: init projs --- .gitignore | 10 ++++++ Returns.Net/.dockerignore | 25 ++++++++++++++ Returns.Net/Return.Web.Api/Dockerfile | 23 +++++++++++++ Returns.Net/Return.Web.Api/Notes.md | 1 + Returns.Net/Return.Web.Api/Program.cs | 34 +++++++++++++++++++ .../Properties/launchSettings.json | 23 +++++++++++++ .../Return.Web.Api/Return.Web.Api.csproj | 24 +++++++++++++ Returns.Net/Return.Web.Api/appsettings.json | 9 +++++ Returns.Net/Returns.Models/Class1.cs | 3 ++ .../Returns.Models/Returns.Models.csproj | 9 +++++ Returns.Net/Returns.Net.sln | 34 +++++++++++++++++++ .../Returns.Web.Api.Tests.csproj | 24 +++++++++++++ .../Returns.Web.Api.Tests/UnitTest1.cs | 10 ++++++ 13 files changed, 229 insertions(+) create mode 100644 .gitignore create mode 100644 Returns.Net/.dockerignore create mode 100644 Returns.Net/Return.Web.Api/Dockerfile create mode 100644 Returns.Net/Return.Web.Api/Notes.md create mode 100644 Returns.Net/Return.Web.Api/Program.cs create mode 100644 Returns.Net/Return.Web.Api/Properties/launchSettings.json create mode 100644 Returns.Net/Return.Web.Api/Return.Web.Api.csproj create mode 100644 Returns.Net/Return.Web.Api/appsettings.json create mode 100644 Returns.Net/Returns.Models/Class1.cs create mode 100644 Returns.Net/Returns.Models/Returns.Models.csproj create mode 100644 Returns.Net/Returns.Net.sln create mode 100644 Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj create mode 100644 Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..edcddb98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Env +**/.idea +*.DotSettings.user + +# Build +**/bin +**/obj + +appsettings.Development.json +appsettings.Production.json \ No newline at end of file diff --git a/Returns.Net/.dockerignore b/Returns.Net/.dockerignore new file mode 100644 index 00000000..cd967fc3 --- /dev/null +++ b/Returns.Net/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Dockerfile b/Returns.Net/Return.Web.Api/Dockerfile new file mode 100644 index 00000000..4aab30a6 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Return.Web.Api/Return.Web.Api.csproj", "Return.Web.Api/"] +RUN dotnet restore "Return.Web.Api/Return.Web.Api.csproj" +COPY . . +WORKDIR "/src/Return.Web.Api" +RUN dotnet build "./Return.Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Return.Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Return.Web.Api.dll"] diff --git a/Returns.Net/Return.Web.Api/Notes.md b/Returns.Net/Return.Web.Api/Notes.md new file mode 100644 index 00000000..17e0f0de --- /dev/null +++ b/Returns.Net/Return.Web.Api/Notes.md @@ -0,0 +1 @@ +# Notes diff --git a/Returns.Net/Return.Web.Api/Program.cs b/Returns.Net/Return.Web.Api/Program.cs new file mode 100644 index 00000000..4de5d977 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Program.cs @@ -0,0 +1,34 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) { + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); +var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; +app.MapGet( + "/weatherforecast", + () => { + var forecast = Enumerable.Range(1, 5) + .Select(index => new WeatherForecast( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)])) + .ToArray(); + + return forecast; + }) + .WithName("GetWeatherForecast"); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Properties/launchSettings.json b/Returns.Net/Return.Web.Api/Properties/launchSettings.json new file mode 100644 index 00000000..42ac81c3 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5298", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7061;http://localhost:5298", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj new file mode 100644 index 00000000..b8516ab9 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + Linux + + + + + + + + + + .dockerignore + + + appsettings.json + + + + diff --git a/Returns.Net/Return.Web.Api/appsettings.json b/Returns.Net/Return.Web.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Returns.Net/Return.Web.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Returns.Net/Returns.Models/Class1.cs b/Returns.Net/Returns.Models/Class1.cs new file mode 100644 index 00000000..9a5fdcd6 --- /dev/null +++ b/Returns.Net/Returns.Models/Class1.cs @@ -0,0 +1,3 @@ +namespace Returns.Models; + +public class Class1 { } \ No newline at end of file diff --git a/Returns.Net/Returns.Models/Returns.Models.csproj b/Returns.Net/Returns.Models/Returns.Models.csproj new file mode 100644 index 00000000..237d6616 --- /dev/null +++ b/Returns.Net/Returns.Models/Returns.Models.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Returns.Net/Returns.Net.sln b/Returns.Net/Returns.Net.sln new file mode 100644 index 00000000..9a82c9e5 --- /dev/null +++ b/Returns.Net/Returns.Net.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".SolutionItems", ".SolutionItems", "{11A5F59E-43B5-4273-A73E-04B392467E44}" + ProjectSection(SolutionItems) = preProject + ..\InvestmentPerformanceWebAPI.md = ..\InvestmentPerformanceWebAPI.md + ..\.gitignore = ..\.gitignore + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Return.Web.Api", "Return.Web.Api\Return.Web.Api.csproj", "{44714CFB-D275-43B7-BB83-9C06477C284D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Returns.Web.Api.Tests", "Returns.Web.Api.Tests\Returns.Web.Api.Tests.csproj", "{19F947EE-9404-416F-8DC9-8D1B73327643}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Returns.Models", "Returns.Models\Returns.Models.csproj", "{3D4A069B-6529-4613-9B1A-B95B0011E17F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {44714CFB-D275-43B7-BB83-9C06477C284D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44714CFB-D275-43B7-BB83-9C06477C284D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44714CFB-D275-43B7-BB83-9C06477C284D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44714CFB-D275-43B7-BB83-9C06477C284D}.Release|Any CPU.Build.0 = Release|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19F947EE-9404-416F-8DC9-8D1B73327643}.Release|Any CPU.Build.0 = Release|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D4A069B-6529-4613-9B1A-B95B0011E17F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj new file mode 100644 index 00000000..ac33a15c --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + \ No newline at end of file diff --git a/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs b/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs new file mode 100644 index 00000000..6135f0f9 --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace Returns.Web.Api.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file From 0a4306b5de7d4170a5e7de1389d2ed07eb73b56c Mon Sep 17 00:00:00 2001 From: Matt Wherry Date: Tue, 3 Feb 2026 17:13:05 -0500 Subject: [PATCH 2/6] nfc: update proj files, added EF and sqlite --- Returns.Net/Return.Web.Api/Notes.md | 2 ++ Returns.Net/Return.Web.Api/Program.cs | 27 +++++-------------- .../Return.Web.Api/Return.Web.Api.csproj | 10 +++++-- Returns.Net/Returns.Models/ApiRoutes.cs | 6 +++++ Returns.Net/Returns.Models/Class1.cs | 3 --- .../Returns.Models/Returns.Models.csproj | 6 +++-- .../Returns.Web.Api.Tests.csproj | 8 +++++- .../Returns.Web.Api.Tests/UnitTest1.cs | 7 ++++- 8 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 Returns.Net/Returns.Models/ApiRoutes.cs delete mode 100644 Returns.Net/Returns.Models/Class1.cs diff --git a/Returns.Net/Return.Web.Api/Notes.md b/Returns.Net/Return.Web.Api/Notes.md index 17e0f0de..13b42629 100644 --- a/Returns.Net/Return.Web.Api/Notes.md +++ b/Returns.Net/Return.Web.Api/Notes.md @@ -1 +1,3 @@ # Notes +Ideally, i would have time to create a web api client that could be shared/tested alongside the api, but +for times sake, I will be using built in HttpClient for testing. diff --git a/Returns.Net/Return.Web.Api/Program.cs b/Returns.Net/Return.Web.Api/Program.cs index 4de5d977..31e4f220 100644 --- a/Returns.Net/Return.Web.Api/Program.cs +++ b/Returns.Net/Return.Web.Api/Program.cs @@ -1,34 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseHttpsRedirection(); -var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; -app.MapGet( - "/weatherforecast", - () => { - var forecast = Enumerable.Range(1, 5) - .Select(index => new WeatherForecast( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)])) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); +//map endpoints via extension -app.Run(); -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file +app.Run(); \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj index b8516ab9..a27cdf3f 100644 --- a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj +++ b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj @@ -2,14 +2,16 @@ net10.0 + disable enable - enable - Linux + latest + true + @@ -21,4 +23,8 @@ + + + + diff --git a/Returns.Net/Returns.Models/ApiRoutes.cs b/Returns.Net/Returns.Models/ApiRoutes.cs new file mode 100644 index 00000000..1aef08f0 --- /dev/null +++ b/Returns.Net/Returns.Models/ApiRoutes.cs @@ -0,0 +1,6 @@ +namespace Returns.Models; + +public static class ApiRoutes +{ + +} \ No newline at end of file diff --git a/Returns.Net/Returns.Models/Class1.cs b/Returns.Net/Returns.Models/Class1.cs deleted file mode 100644 index 9a5fdcd6..00000000 --- a/Returns.Net/Returns.Models/Class1.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Returns.Models; - -public class Class1 { } \ No newline at end of file diff --git a/Returns.Net/Returns.Models/Returns.Models.csproj b/Returns.Net/Returns.Models/Returns.Models.csproj index 237d6616..34c97153 100644 --- a/Returns.Net/Returns.Models/Returns.Models.csproj +++ b/Returns.Net/Returns.Models/Returns.Models.csproj @@ -1,9 +1,11 @@  - net10.0 - enable + netstandard2.0 + disable enable + latest + true diff --git a/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj index ac33a15c..f5424a29 100644 --- a/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj +++ b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj @@ -2,8 +2,10 @@ net10.0 - enable + disable enable + latest + true false @@ -21,4 +23,8 @@ + + + + \ No newline at end of file diff --git a/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs b/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs index 6135f0f9..3679ae57 100644 --- a/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs +++ b/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs @@ -1,10 +1,15 @@ -namespace Returns.Web.Api.Tests; +using System.Net.Http; + +namespace Returns.Web.Api.Tests; public class UnitTest1 { [Fact] public void Test1() { + using var client = new HttpClient(); + + // } } \ No newline at end of file From 593ced0db9eede8788d30bfd9d0bf9165e091e6e Mon Sep 17 00:00:00 2001 From: Matt Wherry Date: Tue, 3 Feb 2026 18:15:22 -0500 Subject: [PATCH 3/6] Update services, notes, models --- .../Controllers/InvestmentController.cs | 43 +++++++++++++++++++ .../Database/DatabaseOptions.cs | 21 +++++++++ .../Return.Web.Api/Database/Investment.cs | 30 +++++++++++++ Returns.Net/Return.Web.Api/Extensions.cs | 41 ++++++++++++++++++ Returns.Net/Return.Web.Api/Notes.md | 12 +++++- Returns.Net/Return.Web.Api/Program.cs | 19 ++++++-- .../Return.Web.Api/Return.Web.Api.csproj | 2 +- .../Services/DumbyShareService.cs | 23 ++++++++++ .../Services/IInvestmentService.cs | 20 +++++++++ .../Return.Web.Api/Services/IShareService.cs | 14 ++++++ .../Services/InvestmentService.cs | 35 +++++++++++++++ Returns.Net/Returns.Models/ApiRoutes.cs | 24 ++++++++++- Returns.Net/Returns.Models/InvestmentQuery.cs | 12 ++++++ Returns.Net/Returns.Models/InvestmentRes.cs | 17 ++++++++ .../Returns.Models/IsExternalInitShim.cs | 6 +++ Returns.Net/Returns.Web.Api.Tests/Notes.md | 2 + 16 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs create mode 100644 Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs create mode 100644 Returns.Net/Return.Web.Api/Database/Investment.cs create mode 100644 Returns.Net/Return.Web.Api/Extensions.cs create mode 100644 Returns.Net/Return.Web.Api/Services/DumbyShareService.cs create mode 100644 Returns.Net/Return.Web.Api/Services/IInvestmentService.cs create mode 100644 Returns.Net/Return.Web.Api/Services/IShareService.cs create mode 100644 Returns.Net/Return.Web.Api/Services/InvestmentService.cs create mode 100644 Returns.Net/Returns.Models/InvestmentQuery.cs create mode 100644 Returns.Net/Returns.Models/InvestmentRes.cs create mode 100644 Returns.Net/Returns.Models/IsExternalInitShim.cs create mode 100644 Returns.Net/Returns.Web.Api.Tests/Notes.md diff --git a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs new file mode 100644 index 00000000..2e9e3ce3 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Return.Web.Api.Services; +using Returns.Models; + +namespace Return.Web.Api.Controllers; + +[ApiController] +public class InvestmentController(IInvestmentService InvestmentService): ControllerBase +{ + [HttpGet(ApiRoutes.User.Base + "/{userId:guid}/Investments")] + public async Task>> GetUserInvestments( + Guid userId, + CancellationToken cts = default) + { + try { + var results = await InvestmentService.GetByUserId(userId, cts); + if (results is null) + return NotFound(userId); + return Ok(results); + } + catch (Exception ex) { + return Problem(ex.Message); + } + } + + [HttpPost(ApiRoutes.User.Base + "/{userId:guid}/Investments/Query")] + public async Task>> QueryInvestments( + [FromBody]InvestmentQuery query, + CancellationToken cts = default) + { + try { + var results = await InvestmentService.Query(query, cts); + return Ok(results); + } + catch (Exception ex) { + return Problem(ex.Message); + } + } +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs b/Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs new file mode 100644 index 00000000..3e0bd9ce --- /dev/null +++ b/Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace Return.Web.Api.Database; + +[DebuggerDisplay("{ToString(),nq}")] +public class DatabaseOptions +{ + public const string SectionName = "DatabaseOptions"; + + public string Host { get; set; } + + public int Port { get; set; } + + public string? Username { get; set; } + + public string? Password { get; set; } + + public override string ToString() => $"DatabaseOptions(Host={Host}, Port={Port}, Username={Username})"; + + //options validator +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Database/Investment.cs b/Returns.Net/Return.Web.Api/Database/Investment.cs new file mode 100644 index 00000000..e19c791e --- /dev/null +++ b/Returns.Net/Return.Web.Api/Database/Investment.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; + +namespace Return.Web.Api.Database; + +//Represents what would be in your dbcontext +[DebuggerDisplay("{ToString(),nq}")] +public class Investment +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string ShareName { get; set; } + public decimal ShareCount { get; set; } + public decimal PurchaseCost { get; set; } + public DateTime PurchaseDateTime { get; set; } + + public Investment(){ } + + public Investment(Guid id, Guid userId, string shareName, decimal shareCount, decimal purchaseCost, DateTime purchaseDateTime) + { + Id = id; + UserId = userId; + ShareName = shareName; + ShareCount = shareCount; + PurchaseCost = purchaseCost; + PurchaseDateTime = purchaseDateTime; + } + + public override string ToString() => $"{ShareName} ({ShareCount} shares at {PurchaseCost:C} purchased on {PurchaseDateTime:d})"; +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Extensions.cs b/Returns.Net/Return.Web.Api/Extensions.cs new file mode 100644 index 00000000..4947a951 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Extensions.cs @@ -0,0 +1,41 @@ +using System; +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Return.Web.Api.Services; +using Returns.Models; + +namespace Return.Web.Api; + +public static class Extensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddReturnsDbContextFactory(Func? factory = null) + { + //Would setup ef based on Func or existing database options, but will keep simplistic for now and + // use hardcoded data in the service + return services; + } + + public IServiceCollection AddReturnsService() + { + //if the service required a more complex setup, we could add options and validation here. + // Say you need to retrieve keys from vault for the + services.AddSingleton(); + return services; + } + + public IServiceCollection AddMapsterProfiles() + { + var config = new TypeAdapterConfig(); + //usually, mapping from db models to response models, use to using mapster or manual mapping + config.NewConfig(); + //.Map(...); for some custom mapping + + config.Compile(); //precompile + services.AddSingleton(config); + return services; + } + } +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Notes.md b/Returns.Net/Return.Web.Api/Notes.md index 13b42629..6f4e51c4 100644 --- a/Returns.Net/Return.Web.Api/Notes.md +++ b/Returns.Net/Return.Web.Api/Notes.md @@ -1,3 +1,11 @@ # Notes -Ideally, i would have time to create a web api client that could be shared/tested alongside the api, but -for times sake, I will be using built in HttpClient for testing. + +Record over class - The test is only for getting/querying. Plus, + +Endpoint post over get - querying usually you want to pass in a body for some complex query system. For this, it needs +to be post as get does not accept a body. + +Logging - using built in ILogger but so can use serilog or nlog if wanting to. For now, will log to console +but would prefer to log to some type of database + +Exception handling - \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Program.cs b/Returns.Net/Return.Web.Api/Program.cs index 31e4f220..d6e6e0d2 100644 --- a/Returns.Net/Return.Web.Api/Program.cs +++ b/Returns.Net/Return.Web.Api/Program.cs @@ -1,9 +1,25 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Return.Web.Api; +using Return.Web.Api.Database; var builder = WebApplication.CreateBuilder(args); +builder.Services + .AddOptions(DatabaseOptions.SectionName) + .ValidateOnStart(); //add options validation + +builder.Services + .AddLogging(logging => + logging.ClearProviders().AddSimpleConsole()); + +builder.Services + .AddReturnsDbContextFactory() + .AddMapsterProfiles() + .AddReturnsService(); + builder.Services.AddOpenApi(); var app = builder.Build(); @@ -13,7 +29,4 @@ app.UseHttpsRedirection(); -//map endpoints via extension - - app.Run(); \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj index a27cdf3f..ccab7df8 100644 --- a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj +++ b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj @@ -9,9 +9,9 @@ + - diff --git a/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs b/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs new file mode 100644 index 00000000..716a49a4 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs @@ -0,0 +1,23 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Return.Web.Api.Services; + +public class DumbyShareService: IShareService +{ + public async Task GetShareCostAsync(string name, DateTime asOfDate, CancellationToken ct = default) + { + return name switch { + "Stock1" => 100.00m, + "Stock2" => 50.00m, + "Stock3" => 25.00m, + _ => throw new ArgumentOutOfRangeException(nameof(name), $"No share cost data for share '{name}'") + }; + } + + + public async Task GetCurrentShareCostAsync(string shareId, CancellationToken ct = default) + => await GetShareCostAsync(shareId, DateTime.UtcNow, ct); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs b/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs new file mode 100644 index 00000000..d2ff4535 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Returns.Models; + +namespace Return.Web.Api.Services; + +public interface IInvestmentService +{ + /// + /// UserId to get investments for + /// Returns null if no user found + Task?> GetByUserId(Guid userId, CancellationToken cts = default); + + /// + /// + /// + Task> Query(InvestmentQuery query, CancellationToken cts = default); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Services/IShareService.cs b/Returns.Net/Return.Web.Api/Services/IShareService.cs new file mode 100644 index 00000000..78212454 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/IShareService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Return.Web.Api.Services; + +public interface IShareService +{ + //assumption is that shares change, should be able to get a cost for a certain point in time + current cost. Implementation should + // be some api either public or pay to use + Task GetShareCostAsync(string shareId, DateTime asOfDate, CancellationToken ct = default); + + Task GetCurrentShareCostAsync(string shareId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Services/InvestmentService.cs b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs new file mode 100644 index 00000000..b96c4bc1 --- /dev/null +++ b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Return.Web.Api.Database; +using Returns.Models; + +namespace Return.Web.Api.Services; + +public class InvestmentService: IInvestmentService +{ + public static string Stock1Name = "Stock1"; + public static string Stock2Name = "Stock2"; + public static string Stock3Name = "Stock3"; + public static Guid Stock1Id = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static Guid Stock2Id = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static Guid Stock3Id = Guid.Parse("33333333-3333-3333-3333-333333333333"); + public static string User1Id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + public static string User2Id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + public static string User3Id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + + public static IReadOnlyList DatabaseData = [ + + ]; + + public Task?> GetByUserId(Guid userId, CancellationToken cts = default) + { + throw new NotImplementedException(); + } + + public Task> Query(InvestmentQuery query, CancellationToken cts = default) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Returns.Net/Returns.Models/ApiRoutes.cs b/Returns.Net/Returns.Models/ApiRoutes.cs index 1aef08f0..3fdb9d63 100644 --- a/Returns.Net/Returns.Models/ApiRoutes.cs +++ b/Returns.Net/Returns.Models/ApiRoutes.cs @@ -2,5 +2,25 @@ namespace Returns.Models; public static class ApiRoutes { - -} \ No newline at end of file + public static class User + { + public const string Base = "user"; + } +} + +/* + + user - has a share + share - has a price, term + + current value and total gain/loss can be calculated props + +Cost basis per share: this is the price of 1 share of stock at the time it was purchased +Current value: this is the number of shares multiplied by the current price per share +Current price: this is the current price of 1 share of the stock + +Term: this is how long the stock has been owned. <=1 year is short term, >1 year is long term + +Total gain or loss: this is the difference between the current value, and the amount paid for all shares when they were purchased + +*/ \ No newline at end of file diff --git a/Returns.Net/Returns.Models/InvestmentQuery.cs b/Returns.Net/Returns.Models/InvestmentQuery.cs new file mode 100644 index 00000000..7676ee3c --- /dev/null +++ b/Returns.Net/Returns.Models/InvestmentQuery.cs @@ -0,0 +1,12 @@ +using System; + +namespace Returns.Models; + +public class InvestmentQuery +{ + public Guid? UserId { get; set;} + + public int? Start { get; set; } = 0; + + public int Amount { get; set; } = 0; +} \ No newline at end of file diff --git a/Returns.Net/Returns.Models/InvestmentRes.cs b/Returns.Net/Returns.Models/InvestmentRes.cs new file mode 100644 index 00000000..47192cce --- /dev/null +++ b/Returns.Net/Returns.Models/InvestmentRes.cs @@ -0,0 +1,17 @@ +using System; +using System.Diagnostics; + +namespace Returns.Models; + +[DebuggerDisplay("{ToString(),nq}")] +public sealed record InvestmentRes( + Guid Id, //prefer Guids as easier for scaling + security + Guid UserId, + string Name, + decimal ShareCount, + decimal PurchaseCostPerShare, + DateTime PurchaseDate) +{ + public override string ToString() => $"{Name} ({ShareCount} shares at {PurchaseCostPerShare:C} purchased on {PurchaseDate:d})"; +} + diff --git a/Returns.Net/Returns.Models/IsExternalInitShim.cs b/Returns.Net/Returns.Models/IsExternalInitShim.cs new file mode 100644 index 00000000..a48c8bfb --- /dev/null +++ b/Returns.Net/Returns.Models/IsExternalInitShim.cs @@ -0,0 +1,6 @@ +#if !NET5_0_OR_GREATER && !NETCOREAPP3_1_OR_GREATER +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; +// This is needed to support 'init' properties in .NET Standard 2.0. Records use init +public static class IsExternalInit; +#endif \ No newline at end of file diff --git a/Returns.Net/Returns.Web.Api.Tests/Notes.md b/Returns.Net/Returns.Web.Api.Tests/Notes.md new file mode 100644 index 00000000..008775b7 --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/Notes.md @@ -0,0 +1,2 @@ +Ideally, i would have time to create a web api client that could be shared/tested alongside the api, but +for times sake, I will be using built in HttpClient for testing. \ No newline at end of file From f0d0b66610605813c44c1b9d82c0582631f4eadc Mon Sep 17 00:00:00 2001 From: Matt Wherry Date: Tue, 3 Feb 2026 18:28:37 -0500 Subject: [PATCH 4/6] Updated services and routes --- .../Controllers/InvestmentController.cs | 2 +- .../Services/InvestmentService.cs | 41 +++++++++++++++---- Returns.Net/Returns.Models/ApiRoutes.cs | 4 ++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs index 2e9e3ce3..30144685 100644 --- a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs +++ b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs @@ -27,7 +27,7 @@ public async Task>> GetUserInvestments } } - [HttpPost(ApiRoutes.User.Base + "/{userId:guid}/Investments/Query")] + [HttpPost(ApiRoutes.Investment.Base + "/Query")] public async Task>> QueryInvestments( [FromBody]InvestmentQuery query, CancellationToken cts = default) diff --git a/Returns.Net/Return.Web.Api/Services/InvestmentService.cs b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs index b96c4bc1..cea8377e 100644 --- a/Returns.Net/Return.Web.Api/Services/InvestmentService.cs +++ b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs @@ -1,35 +1,58 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Mapster; +using MapsterMapper; +using Microsoft.EntityFrameworkCore; using Return.Web.Api.Database; using Returns.Models; namespace Return.Web.Api.Services; -public class InvestmentService: IInvestmentService +public class InvestmentService(IMapper mapper): IInvestmentService { + public static Guid Investment1Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + public static Guid Investment2Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + public static Guid Investment3Id = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + public static string Stock1Name = "Stock1"; public static string Stock2Name = "Stock2"; public static string Stock3Name = "Stock3"; + public static Guid Stock1Id = Guid.Parse("11111111-1111-1111-1111-111111111111"); public static Guid Stock2Id = Guid.Parse("22222222-2222-2222-2222-222222222222"); public static Guid Stock3Id = Guid.Parse("33333333-3333-3333-3333-333333333333"); - public static string User1Id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; - public static string User2Id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; - public static string User3Id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + + public static Guid User1Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + public static Guid User2Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + public static Guid User3Id = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); public static IReadOnlyList DatabaseData = [ + new(Guid.NewGuid(), User1Id, Stock1Name, 5, 10, new()), + new(Guid.NewGuid(), User1Id, Stock2Name, 10, 20, new()), + new(Guid.NewGuid(), User2Id, Stock2Name, 15, 30, new()), + new(Guid.NewGuid(), User2Id, Stock3Name, 20, 40, new()), + ]; - ]; - public Task?> GetByUserId(Guid userId, CancellationToken cts = default) { - throw new NotImplementedException(); + return Task.FromResult?>(DatabaseData.Select(i => i.UserId == userId).ToList()); } - public Task> Query(InvestmentQuery query, CancellationToken cts = default) + public async Task> Query(InvestmentQuery query, CancellationToken cts = default) { - throw new NotImplementedException(); + var queryable = DatabaseData.AsQueryable(); + //usually would use expression builder or generic, but this also works as a quick example + if(query.UserId.HasValue) + queryable = queryable.Where(i => i.UserId == query.UserId.Value); + + if (query.Start.HasValue) + queryable = queryable.Skip(query.Start.Value); + + //materialize + var items = await queryable.ToListAsync(cts); + return mapper.Map>(items); } } \ No newline at end of file diff --git a/Returns.Net/Returns.Models/ApiRoutes.cs b/Returns.Net/Returns.Models/ApiRoutes.cs index 3fdb9d63..f972ba1c 100644 --- a/Returns.Net/Returns.Models/ApiRoutes.cs +++ b/Returns.Net/Returns.Models/ApiRoutes.cs @@ -6,6 +6,10 @@ public static class User { public const string Base = "user"; } + public static class Investment + { + public const string Base = "investment"; + } } /* From 0d7ae83d7d01832d8d3739ff9606bc8e2194239c Mon Sep 17 00:00:00 2001 From: Matt Wherry Date: Tue, 3 Feb 2026 19:11:55 -0500 Subject: [PATCH 5/6] Added enum, added test examples, added minimal logging --- .../Controllers/InvestmentController.cs | 11 ++- .../Return.Web.Api/Database/Investment.cs | 8 +-- Returns.Net/Return.Web.Api/Extensions.cs | 21 +++--- Returns.Net/Return.Web.Api/Notes.md | 5 +- Returns.Net/Return.Web.Api/Program.cs | 7 +- .../Properties/launchSettings.json | 4 +- .../Return.Web.Api/Return.Web.Api.csproj | 2 +- .../Services/DumbyShareService.cs | 1 + .../Services/IInvestmentService.cs | 2 +- .../Services/InvestmentService.cs | 70 +++++++++++-------- Returns.Net/Returns.Models/ApiRoutes.cs | 1 + Returns.Net/Returns.Models/Enum/TermEnum.cs | 10 +++ Returns.Net/Returns.Models/InvestmentQuery.cs | 8 ++- Returns.Net/Returns.Models/InvestmentRes.cs | 18 ++++- .../Returns.Models/Returns.Models.csproj | 4 ++ Returns.Net/Returns.Models/TestData.cs | 33 +++++++++ .../InvestmentControllerTests.cs | 53 ++++++++++++++ .../Returns.Web.Api.Tests.csproj | 1 + .../Returns.Web.Api.Tests/UnitTest1.cs | 15 ---- 19 files changed, 198 insertions(+), 76 deletions(-) create mode 100644 Returns.Net/Returns.Models/Enum/TermEnum.cs create mode 100644 Returns.Net/Returns.Models/TestData.cs create mode 100644 Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs delete mode 100644 Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs diff --git a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs index 30144685..83a3b178 100644 --- a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs +++ b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs @@ -9,7 +9,7 @@ namespace Return.Web.Api.Controllers; [ApiController] -public class InvestmentController(IInvestmentService InvestmentService): ControllerBase +public class InvestmentController(IInvestmentService investmentService): ControllerBase { [HttpGet(ApiRoutes.User.Base + "/{userId:guid}/Investments")] public async Task>> GetUserInvestments( @@ -17,9 +17,8 @@ public async Task>> GetUserInvestments CancellationToken cts = default) { try { - var results = await InvestmentService.GetByUserId(userId, cts); - if (results is null) - return NotFound(userId); + //usually should check for existing user + var results = await investmentService.GetByUserId(userId, cts); return Ok(results); } catch (Exception ex) { @@ -27,13 +26,13 @@ public async Task>> GetUserInvestments } } - [HttpPost(ApiRoutes.Investment.Base + "/Query")] + [HttpPost(ApiRoutes.Investment.Query)] public async Task>> QueryInvestments( [FromBody]InvestmentQuery query, CancellationToken cts = default) { try { - var results = await InvestmentService.Query(query, cts); + var results = await investmentService.Query(query, cts); return Ok(results); } catch (Exception ex) { diff --git a/Returns.Net/Return.Web.Api/Database/Investment.cs b/Returns.Net/Return.Web.Api/Database/Investment.cs index e19c791e..05f3445b 100644 --- a/Returns.Net/Return.Web.Api/Database/Investment.cs +++ b/Returns.Net/Return.Web.Api/Database/Investment.cs @@ -11,20 +11,20 @@ public class Investment public Guid UserId { get; set; } public string ShareName { get; set; } public decimal ShareCount { get; set; } - public decimal PurchaseCost { get; set; } + public decimal CostBasisPerShare { get; set; } public DateTime PurchaseDateTime { get; set; } public Investment(){ } - public Investment(Guid id, Guid userId, string shareName, decimal shareCount, decimal purchaseCost, DateTime purchaseDateTime) + public Investment(Guid id, Guid userId, string shareName, decimal shareCount, decimal costBasisPerShare, DateTime purchaseDateTime) { Id = id; UserId = userId; ShareName = shareName; ShareCount = shareCount; - PurchaseCost = purchaseCost; + CostBasisPerShare = costBasisPerShare; PurchaseDateTime = purchaseDateTime; } - public override string ToString() => $"{ShareName} ({ShareCount} shares at {PurchaseCost:C} purchased on {PurchaseDateTime:d})"; + public override string ToString() => $"{ShareName} ({ShareCount} shares at {CostBasisPerShare:C} per share purchased on {PurchaseDateTime:d})"; } \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Extensions.cs b/Returns.Net/Return.Web.Api/Extensions.cs index 4947a951..3c63d5ab 100644 --- a/Returns.Net/Return.Web.Api/Extensions.cs +++ b/Returns.Net/Return.Web.Api/Extensions.cs @@ -1,6 +1,6 @@ using System; using Mapster; -using Microsoft.EntityFrameworkCore; +using MapsterMapper; using Microsoft.Extensions.DependencyInjection; using Return.Web.Api.Services; using Returns.Models; @@ -11,17 +11,18 @@ public static class Extensions { extension(IServiceCollection services) { - public IServiceCollection AddReturnsDbContextFactory(Func? factory = null) - { - //Would setup ef based on Func or existing database options, but will keep simplistic for now and - // use hardcoded data in the service - return services; - } + //public IServiceCollection AddReturnsDbContextFactory(Func? factory = null) + //{ + // //Would setup ef based on Func or existing database options, but will keep simplistic for now and + // // use hardcoded data in the service + // return services; + //} public IServiceCollection AddReturnsService() { //if the service required a more complex setup, we could add options and validation here. // Say you need to retrieve keys from vault for the + services.AddSingleton(); services.AddSingleton(); return services; } @@ -30,11 +31,13 @@ public IServiceCollection AddMapsterProfiles() { var config = new TypeAdapterConfig(); //usually, mapping from db models to response models, use to using mapster or manual mapping - config.NewConfig(); - //.Map(...); for some custom mapping + config.NewConfig() + .ConstructUsing(src => new(src.Id, src.UserId, src.ShareName, src.ShareCount, src.CostBasisPerShare, src.PurchaseDateTime, 0m)); + // CurrentPrice will be updated via injected share service config.Compile(); //precompile services.AddSingleton(config); + services.AddSingleton(sp => new Mapper(config)); return services; } } diff --git a/Returns.Net/Return.Web.Api/Notes.md b/Returns.Net/Return.Web.Api/Notes.md index 6f4e51c4..9f5a1711 100644 --- a/Returns.Net/Return.Web.Api/Notes.md +++ b/Returns.Net/Return.Web.Api/Notes.md @@ -6,6 +6,7 @@ Endpoint post over get - querying usually you want to pass in a body for some co to be post as get does not accept a body. Logging - using built in ILogger but so can use serilog or nlog if wanting to. For now, will log to console -but would prefer to log to some type of database +but would prefer to log to some type of database. should use a generic interceptor for all endpoints and log there -Exception handling - \ No newline at end of file +Exception handling - should wrap results to include api status codes and exceptions. Theres a difference between +breaking and no responses, so if valid always return a result, if not it should return bad status code. \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Program.cs b/Returns.Net/Return.Web.Api/Program.cs index d6e6e0d2..d0f1bba2 100644 --- a/Returns.Net/Return.Web.Api/Program.cs +++ b/Returns.Net/Return.Web.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Return.Web.Api; using Return.Web.Api.Database; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -15,8 +16,9 @@ .AddLogging(logging => logging.ClearProviders().AddSimpleConsole()); +builder.Services.AddControllers(); + builder.Services - .AddReturnsDbContextFactory() .AddMapsterProfiles() .AddReturnsService(); @@ -25,8 +27,11 @@ if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.MapScalarApiReference(); } +app.MapControllers(); + app.UseHttpsRedirection(); app.Run(); \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Properties/launchSettings.json b/Returns.Net/Return.Web.Api/Properties/launchSettings.json index 42ac81c3..6e8213b8 100644 --- a/Returns.Net/Return.Web.Api/Properties/launchSettings.json +++ b/Returns.Net/Return.Web.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5298", + "applicationUrl": "http://localhost:5299", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7061;http://localhost:5298", + "applicationUrl": "https://localhost:7061;http://localhost:5299", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj index ccab7df8..3141664f 100644 --- a/Returns.Net/Return.Web.Api/Return.Web.Api.csproj +++ b/Returns.Net/Return.Web.Api/Return.Web.Api.csproj @@ -11,7 +11,7 @@ - + diff --git a/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs b/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs index 716a49a4..70394d9c 100644 --- a/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs +++ b/Returns.Net/Return.Web.Api/Services/DumbyShareService.cs @@ -5,6 +5,7 @@ namespace Return.Web.Api.Services; +//represents some api service. should include caching to not request current price based every call public class DumbyShareService: IShareService { public async Task GetShareCostAsync(string name, DateTime asOfDate, CancellationToken ct = default) diff --git a/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs b/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs index d2ff4535..c8a675ae 100644 --- a/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs +++ b/Returns.Net/Return.Web.Api/Services/IInvestmentService.cs @@ -11,7 +11,7 @@ public interface IInvestmentService /// /// UserId to get investments for /// Returns null if no user found - Task?> GetByUserId(Guid userId, CancellationToken cts = default); + Task> GetByUserId(Guid userId, CancellationToken cts = default); /// /// diff --git a/Returns.Net/Return.Web.Api/Services/InvestmentService.cs b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs index cea8377e..86f0827b 100644 --- a/Returns.Net/Return.Web.Api/Services/InvestmentService.cs +++ b/Returns.Net/Return.Web.Api/Services/InvestmentService.cs @@ -3,46 +3,29 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Mapster; using MapsterMapper; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Return.Web.Api.Database; using Returns.Models; namespace Return.Web.Api.Services; -public class InvestmentService(IMapper mapper): IInvestmentService +public class InvestmentService(ILogger logger, IMapper mapper, IShareService shareService): IInvestmentService { - public static Guid Investment1Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - public static Guid Investment2Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - public static Guid Investment3Id = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); - - public static string Stock1Name = "Stock1"; - public static string Stock2Name = "Stock2"; - public static string Stock3Name = "Stock3"; - - public static Guid Stock1Id = Guid.Parse("11111111-1111-1111-1111-111111111111"); - public static Guid Stock2Id = Guid.Parse("22222222-2222-2222-2222-222222222222"); - public static Guid Stock3Id = Guid.Parse("33333333-3333-3333-3333-333333333333"); - - public static Guid User1Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - public static Guid User2Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - public static Guid User3Id = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); - - public static IReadOnlyList DatabaseData = [ - new(Guid.NewGuid(), User1Id, Stock1Name, 5, 10, new()), - new(Guid.NewGuid(), User1Id, Stock2Name, 10, 20, new()), - new(Guid.NewGuid(), User2Id, Stock2Name, 15, 30, new()), - new(Guid.NewGuid(), User2Id, Stock3Name, 20, 40, new()), - ]; + private static IReadOnlyList DatabaseData => + TestData.DatabaseData + .Select(d => new Investment(d.Id, d.UserId, d.ShareName, d.ShareCount, d.CostBasisPerShare, d.PurchaseDateTime)) + .ToList(); - public Task?> GetByUserId(Guid userId, CancellationToken cts = default) + public async Task> GetByUserId(Guid userId, CancellationToken cts = default) { - return Task.FromResult?>(DatabaseData.Select(i => i.UserId == userId).ToList()); + var investments = DatabaseData.Where(i => i.UserId == userId).ToList(); + return await MapToInvestmentRes(investments, cts); } public async Task> Query(InvestmentQuery query, CancellationToken cts = default) { + logger.LogInformation("{InvestmentQuery}", query); var queryable = DatabaseData.AsQueryable(); //usually would use expression builder or generic, but this also works as a quick example if(query.UserId.HasValue) @@ -51,8 +34,35 @@ public async Task> Query(InvestmentQuery query, Can if (query.Start.HasValue) queryable = queryable.Skip(query.Start.Value); - //materialize - var items = await queryable.ToListAsync(cts); - return mapper.Map>(items); + if(query.Amount.HasValue) + queryable = queryable.Take(query.Amount.Value); + + //materialize, dbcontext has tolistasync where cts would be passed + var items = queryable.ToList(); + return await MapToInvestmentRes(items, cts); + } + + + /// + /// Helper to simulate getting actual current share price from a sahre service + /// + /// + /// + /// + private async Task> MapToInvestmentRes(IReadOnlyList investments, CancellationToken cts) + { + var results = new List(); + //usually would involce some type of caching here to avoid multiple calls for same share + foreach (var investment in investments) + { + var investmentRes = mapper.Map(investment); + var currentPrice = await new DumbyShareService().GetCurrentShareCostAsync(investment.ShareName, cts); + var updatedInvestmentRes = investmentRes with { + CurrentPrice = currentPrice + }; + results.Add(updatedInvestmentRes); + } + + return results; } } \ No newline at end of file diff --git a/Returns.Net/Returns.Models/ApiRoutes.cs b/Returns.Net/Returns.Models/ApiRoutes.cs index f972ba1c..e37b5cf3 100644 --- a/Returns.Net/Returns.Models/ApiRoutes.cs +++ b/Returns.Net/Returns.Models/ApiRoutes.cs @@ -9,6 +9,7 @@ public static class User public static class Investment { public const string Base = "investment"; + public const string Query = Base + "/Query"; } } diff --git a/Returns.Net/Returns.Models/Enum/TermEnum.cs b/Returns.Net/Returns.Models/Enum/TermEnum.cs new file mode 100644 index 00000000..908bbae4 --- /dev/null +++ b/Returns.Net/Returns.Models/Enum/TermEnum.cs @@ -0,0 +1,10 @@ +using System; + +namespace Returns.Models.Enum; + +public enum TermEnum +{ + Unknown, + Short, + Long +} \ No newline at end of file diff --git a/Returns.Net/Returns.Models/InvestmentQuery.cs b/Returns.Net/Returns.Models/InvestmentQuery.cs index 7676ee3c..231a22f0 100644 --- a/Returns.Net/Returns.Models/InvestmentQuery.cs +++ b/Returns.Net/Returns.Models/InvestmentQuery.cs @@ -1,12 +1,16 @@ using System; +using System.Diagnostics; namespace Returns.Models; +[DebuggerDisplay("{ToString(),nq}")] public class InvestmentQuery { - public Guid? UserId { get; set;} + public Guid? UserId { get; set; } public int? Start { get; set; } = 0; - public int Amount { get; set; } = 0; + public int? Amount { get; set; } = 10; + + public override string ToString() => $"InvestmentQuery: UserId={UserId}, Start={Start}, Amount={Amount}"; } \ No newline at end of file diff --git a/Returns.Net/Returns.Models/InvestmentRes.cs b/Returns.Net/Returns.Models/InvestmentRes.cs index 47192cce..46a11a67 100644 --- a/Returns.Net/Returns.Models/InvestmentRes.cs +++ b/Returns.Net/Returns.Models/InvestmentRes.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics; +using System.Text.Json.Serialization; +using Returns.Models.Enum; namespace Returns.Models; @@ -9,9 +11,19 @@ public sealed record InvestmentRes( Guid UserId, string Name, decimal ShareCount, - decimal PurchaseCostPerShare, - DateTime PurchaseDate) + decimal CostBasisPerShare, + DateTime PurchaseDate, + decimal CurrentPrice) { - public override string ToString() => $"{Name} ({ShareCount} shares at {PurchaseCostPerShare:C} purchased on {PurchaseDate:d})"; + //usually i would ignore this data on serialization unless its needed since they are all computed + + [JsonConverter(typeof(JsonStringEnumConverter))] + public TermEnum Term => DateTime.UtcNow.Subtract(PurchaseDate).TotalDays > 365 ? TermEnum.Long : TermEnum.Short; + + public decimal TotalGainOrLoss => CurrentValue - (ShareCount * CostBasisPerShare); + + public decimal CurrentValue => ShareCount * CurrentPrice; + + public override string ToString() => $"{Name} ({ShareCount} shares at {CostBasisPerShare:C} per share purchased on {PurchaseDate:d})"; } diff --git a/Returns.Net/Returns.Models/Returns.Models.csproj b/Returns.Net/Returns.Models/Returns.Models.csproj index 34c97153..b74d97a5 100644 --- a/Returns.Net/Returns.Models/Returns.Models.csproj +++ b/Returns.Net/Returns.Models/Returns.Models.csproj @@ -8,4 +8,8 @@ true + + + + diff --git a/Returns.Net/Returns.Models/TestData.cs b/Returns.Net/Returns.Models/TestData.cs new file mode 100644 index 00000000..87bda9c9 --- /dev/null +++ b/Returns.Net/Returns.Models/TestData.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Returns.Models; + +public static class TestData +{ + //temp, only needed due to test data here, but also being used in api. + public static string Stock1Name = "Stock1"; + public static string Stock2Name = "Stock2"; + public static string Stock3Name = "Stock3"; + + public static Guid User1Id = Guid.Parse("a8b9c0d1-e2f3-4345-a678-b9c0d1e2f345"); + public static Guid User2Id = Guid.Parse("b9c0d1e2-f3a4-4456-b789-c0d1e2f3a456"); + public static Guid User3Id = Guid.Parse("c0d1e2f3-a4b5-4567-c890-d1e2f3a4b567"); + + + public record InvestmentData( + Guid Id, + Guid UserId, + string ShareName, + decimal ShareCount, + decimal CostBasisPerShare, + DateTime PurchaseDateTime); + + public static IReadOnlyList DatabaseData = [ + new(Guid.NewGuid(), User1Id, Stock1Name, 5, 10, new DateTime(2023, 1, 1)), + new(Guid.NewGuid(), User1Id, Stock2Name, 10, 20, new DateTime(2023, 6, 1)), + new(Guid.NewGuid(), User2Id, Stock2Name, 15, 30, new DateTime(2024, 1, 1)), + new(Guid.NewGuid(), User2Id, Stock3Name, 20, 40, new DateTime(2022, 1, 1)), + ]; +} + diff --git a/Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs b/Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs new file mode 100644 index 00000000..8fe5c264 --- /dev/null +++ b/Returns.Net/Returns.Web.Api.Tests/InvestmentControllerTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Returns.Models; + +namespace Returns.Web.Api.Tests; + +public class InvestmentControllerTests +{ + private readonly HttpClient _client; + private readonly JsonSerializerOptions _jsonOptions; + + public InvestmentControllerTests() + { + _client = new(); + _client.BaseAddress = new Uri("http://localhost:5299"); + _jsonOptions = new() { + PropertyNameCaseInsensitive = true + }; + } + + [Fact] + public async Task GetUserInvestments_ReturnsInvestmentsForUser() + { + var userId = TestData.User1Id; + var url = $"/user/{userId}/Investments"; + var response = await _client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var investments = await response.Content.ReadFromJsonAsync>(_jsonOptions); + Assert.NotNull(investments); + Assert.NotEmpty(investments); + Assert.All(investments!, investment => Assert.Equal(userId, investment.UserId)); + } + + [Fact] + public async Task QueryInvestments_WithStartAndAmount_PaginatesCorrectly() + { + var query = new InvestmentQuery { Start = 0, Amount = 2 }; + var url = ApiRoutes.Investment.Query; + var response = await _client.PostAsJsonAsync(url, query); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var investments = await response.Content.ReadFromJsonAsync>(_jsonOptions); + Assert.NotNull(investments); + Assert.True(investments!.Count <= 2); + } + + //can add more tests ad needed, would suggest more complex setup/teardown, logging, etc +} + diff --git a/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj index f5424a29..8aca55c3 100644 --- a/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj +++ b/Returns.Net/Returns.Web.Api.Tests/Returns.Web.Api.Tests.csproj @@ -25,6 +25,7 @@ + \ No newline at end of file diff --git a/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs b/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs deleted file mode 100644 index 3679ae57..00000000 --- a/Returns.Net/Returns.Web.Api.Tests/UnitTest1.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Net.Http; - -namespace Returns.Web.Api.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - using var client = new HttpClient(); - - - // - } -} \ No newline at end of file From ee433f42f0cd1c11e4cca9090e78703931ae1922 Mon Sep 17 00:00:00 2001 From: Matt Wherry Date: Tue, 3 Feb 2026 19:20:59 -0500 Subject: [PATCH 6/6] final touches --- .../Controllers/InvestmentController.cs | 11 ++++++++--- Returns.Net/Return.Web.Api/Notes.md | 11 +++++++++-- Returns.Net/Returns.Models/TestData.cs | 8 ++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs index 83a3b178..67f3132f 100644 --- a/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs +++ b/Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,9 @@ namespace Return.Web.Api.Controllers; public class InvestmentController(IInvestmentService investmentService): ControllerBase { [HttpGet(ApiRoutes.User.Base + "/{userId:guid}/Investments")] - public async Task>> GetUserInvestments( + [ProducesResponseType>((int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task?>> GetUserInvestments( Guid userId, CancellationToken cts = default) { @@ -22,11 +25,13 @@ public async Task>> GetUserInvestments return Ok(results); } catch (Exception ex) { - return Problem(ex.Message); + return BadRequest(ex.Message); } } [HttpPost(ApiRoutes.Investment.Query)] + [ProducesResponseType>((int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] public async Task>> QueryInvestments( [FromBody]InvestmentQuery query, CancellationToken cts = default) @@ -36,7 +41,7 @@ public async Task>> QueryInvestments( return Ok(results); } catch (Exception ex) { - return Problem(ex.Message); + return BadRequest(ex.Message); } } } \ No newline at end of file diff --git a/Returns.Net/Return.Web.Api/Notes.md b/Returns.Net/Return.Web.Api/Notes.md index 9f5a1711..db4821e7 100644 --- a/Returns.Net/Return.Web.Api/Notes.md +++ b/Returns.Net/Return.Web.Api/Notes.md @@ -1,6 +1,6 @@ # Notes -Record over class - The test is only for getting/querying. Plus, +-Took about 130m to complete since the prompt specified a couple of hours Endpoint post over get - querying usually you want to pass in a body for some complex query system. For this, it needs to be post as get does not accept a body. @@ -9,4 +9,11 @@ Logging - using built in ILogger but so can use serilog or nlog if wanting to. F but would prefer to log to some type of database. should use a generic interceptor for all endpoints and log there Exception handling - should wrap results to include api status codes and exceptions. Theres a difference between -breaking and no responses, so if valid always return a result, if not it should return bad status code. \ No newline at end of file +breaking and no responses, so if valid always return a result, if not it should return bad status code. + +Other +-Interfaced out share service so other public apis can be used, say one is cheaper, or even start tracking internally +-tend to use IReadOnlyX where possible for read only operations (and records for responses for immutability). +-Models is netstandard 2.0 so any older project that consumes the api can use the model structures. just needs a helper class for the lang version diff +-wouldve used minimal api setup, but controller works fine also +-wouldve liked to add more proper responses and wrap the return in a result object IE Result which would also contain optional error obj' diff --git a/Returns.Net/Returns.Models/TestData.cs b/Returns.Net/Returns.Models/TestData.cs index 87bda9c9..d2e7f896 100644 --- a/Returns.Net/Returns.Models/TestData.cs +++ b/Returns.Net/Returns.Models/TestData.cs @@ -24,10 +24,10 @@ public record InvestmentData( DateTime PurchaseDateTime); public static IReadOnlyList DatabaseData = [ - new(Guid.NewGuid(), User1Id, Stock1Name, 5, 10, new DateTime(2023, 1, 1)), - new(Guid.NewGuid(), User1Id, Stock2Name, 10, 20, new DateTime(2023, 6, 1)), - new(Guid.NewGuid(), User2Id, Stock2Name, 15, 30, new DateTime(2024, 1, 1)), - new(Guid.NewGuid(), User2Id, Stock3Name, 20, 40, new DateTime(2022, 1, 1)), + new(Guid.NewGuid(), User1Id, Stock1Name, 5, 10, new(2023, 1, 1)), + new(Guid.NewGuid(), User1Id, Stock2Name, 10, 20, new(2023, 6, 1)), + new(Guid.NewGuid(), User2Id, Stock2Name, 15, 30, new(2024, 1, 1)), + new(Guid.NewGuid(), User2Id, Stock3Name, 20, 40, new(2022, 1, 1)), ]; }