Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Env
**/.idea
*.DotSettings.user

# Build
**/bin
**/obj

appsettings.Development.json
appsettings.Production.json
25 changes: 25 additions & 0 deletions Returns.Net/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions Returns.Net/Return.Web.Api/Controllers/InvestmentController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Net;
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")]
[ProducesResponseType<IReadOnlyList<InvestmentRes>>((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<ActionResult<IReadOnlyList<InvestmentRes>?>> GetUserInvestments(
Guid userId,
CancellationToken cts = default)
{
try {
//usually should check for existing user
var results = await investmentService.GetByUserId(userId, cts);
return Ok(results);
}
catch (Exception ex) {
return BadRequest(ex.Message);
}
}

[HttpPost(ApiRoutes.Investment.Query)]
[ProducesResponseType<IReadOnlyList<InvestmentRes>>((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<ActionResult<IReadOnlyList<InvestmentRes>>> QueryInvestments(
[FromBody]InvestmentQuery query,
CancellationToken cts = default)
{
try {
var results = await investmentService.Query(query, cts);
return Ok(results);
}
catch (Exception ex) {
return BadRequest(ex.Message);
}
}
}
21 changes: 21 additions & 0 deletions Returns.Net/Return.Web.Api/Database/DatabaseOptions.cs
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions Returns.Net/Return.Web.Api/Database/Investment.cs
Original file line number Diff line number Diff line change
@@ -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 CostBasisPerShare { get; set; }
public DateTime PurchaseDateTime { get; set; }

public Investment(){ }

public Investment(Guid id, Guid userId, string shareName, decimal shareCount, decimal costBasisPerShare, DateTime purchaseDateTime)
{
Id = id;
UserId = userId;
ShareName = shareName;
ShareCount = shareCount;
CostBasisPerShare = costBasisPerShare;
PurchaseDateTime = purchaseDateTime;
}

public override string ToString() => $"{ShareName} ({ShareCount} shares at {CostBasisPerShare:C} per share purchased on {PurchaseDateTime:d})";
}
23 changes: 23 additions & 0 deletions Returns.Net/Return.Web.Api/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
44 changes: 44 additions & 0 deletions Returns.Net/Return.Web.Api/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using Mapster;
using MapsterMapper;
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<IServiceProvider, DbContextOptions>? 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<IShareService, DumbyShareService>();
services.AddSingleton<IInvestmentService, InvestmentService>();
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<Database.Investment, InvestmentRes>()
.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<IMapper>(sp => new Mapper(config));
return services;
}
}
}
19 changes: 19 additions & 0 deletions Returns.Net/Return.Web.Api/Notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Notes

-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.

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. 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.

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<T> which would also contain optional error obj'
37 changes: 37 additions & 0 deletions Returns.Net/Return.Web.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services
.AddOptions<DatabaseOptions>(DatabaseOptions.SectionName)
.ValidateOnStart(); //add options validation

builder.Services
.AddLogging(logging =>
logging.ClearProviders().AddSimpleConsole());

builder.Services.AddControllers();

builder.Services
.AddMapsterProfiles()
.AddReturnsService();

builder.Services.AddOpenApi();
var app = builder.Build();

if (app.Environment.IsDevelopment()) {
app.MapOpenApi();
app.MapScalarApiReference();
}

app.MapControllers();

app.UseHttpsRedirection();

app.Run();
23 changes: 23 additions & 0 deletions Returns.Net/Return.Web.Api/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5299",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7061;http://localhost:5299",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
30 changes: 30 additions & 0 deletions Returns.Net/Return.Web.Api/Return.Web.Api.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<Deterministic>true</Deterministic>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Scalar.AspNetCore" Version="2.12.32" />
</ItemGroup>

<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Update="appsettings.Development.json">
<DependentUpon>appsettings.json</DependentUpon>
</Content>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Returns.Models\Returns.Models.csproj" />
</ItemGroup>

</Project>
24 changes: 24 additions & 0 deletions Returns.Net/Return.Web.Api/Services/DumbyShareService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

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<decimal> 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<decimal> GetCurrentShareCostAsync(string shareId, CancellationToken ct = default)
=> await GetShareCostAsync(shareId, DateTime.UtcNow, ct);
}
20 changes: 20 additions & 0 deletions Returns.Net/Return.Web.Api/Services/IInvestmentService.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary></summary>
/// <param name="userId">UserId to get investments for</param>
/// <returns>Returns null if no user found</returns>
Task<IReadOnlyList<InvestmentRes>> GetByUserId(Guid userId, CancellationToken cts = default);

/// <summary></summary>
/// <param name="query"></param>
/// <returns></returns>
Task<IReadOnlyList<InvestmentRes>> Query(InvestmentQuery query, CancellationToken cts = default);
}
14 changes: 14 additions & 0 deletions Returns.Net/Return.Web.Api/Services/IShareService.cs
Original file line number Diff line number Diff line change
@@ -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<decimal> GetShareCostAsync(string shareId, DateTime asOfDate, CancellationToken ct = default);

Task<decimal> GetCurrentShareCostAsync(string shareId, CancellationToken ct = default);
}
Loading