Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ src/AppName.Maui/Resources/Raw/web/
.DS_Store

.playwright-cli
.serena
# `just dev` PID and log files.
.just-dev/
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
Expand Down
78 changes: 78 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,84 @@ web-build:
web-dev:
cd src/web && npm run dev

# ---- Dev servers -------------------------------------------------------------

# PID and log files for `just dev`. Git-ignored; nothing else reads it.
run_dir := ".just-dev"

# Where the API listens. src/web/.env.development points VITE_API_BASE_URL at
# this exact URL, and the API's Cors:Origins allows Vite's own :5173 back.
# Passed explicitly because the API project has no launchSettings.json to
# default from.
api_url := "http://localhost:5000"

# Start the API and Vite in the background (stop: `just dev-stop`, output: `just dev-logs`).
dev: dev-stop
#!/usr/bin/env bash
set -euo pipefail

mkdir -p "{{run_dir}}"

# Each server is started under `setsid`, in its own process group, and
# records its OWN pid rather than the one the shell hands back.
#
# Both wrappers fork the thing that actually holds the port -- `dotnet run`
# execs the built binary, `npm run dev` spawns vite -- so killing the pid
# `$!` gives us would reap the wrapper and leave the server behind, still
# holding :5000 or :5173. Writing `$$` from inside the new session records
# the group leader instead, and `exec` keeps that same pid for the server.
# `dev-stop` then signals the whole group.
# ASPNETCORE_ENVIRONMENT is set by hand because the API project has no
# launchSettings.json to carry it, and `dotnet run` then defaults to
# Production. That default is not cosmetic: WebApplication.CreateBuilder
# loads user-secrets ONLY in Development, so a Production run cannot see
# ConnectionStrings:AppDb and dies in AddInfrastructure before it listens.
# appsettings.Development.json -- the dev JWT key, and the Cors:Origins
# entry for Vite's :5173 -- is skipped for the same reason.
setsid bash -c "echo \$\$ > '{{run_dir}}/api.pid'; ASPNETCORE_ENVIRONMENT=Development exec dotnet run --project src/AppName.Api --urls '{{api_url}}'" \
> "{{run_dir}}/api.log" 2>&1 &

setsid bash -c "echo \$\$ > '{{run_dir}}/web.pid'; exec npm --prefix src/web run dev" \
> "{{run_dir}}/web.log" 2>&1 &

echo "api {{api_url}} -> {{run_dir}}/api.log"
echo "web http://localhost:5173 -> {{run_dir}}/web.log"
echo
echo "just dev-logs follow both"
echo "just dev-stop stop both"

# Stop whatever `just dev` started. Safe to run when nothing is up.
dev-stop:
#!/usr/bin/env bash
# No `-e`: a stale pid file whose process is already gone is the normal
# case, not a failure.
set -uo pipefail

for name in api web; do
pid_file="{{run_dir}}/${name}.pid"

[[ -f "$pid_file" ]] || continue

pid="$(cat "$pid_file")"

# A NEGATIVE pid signals the whole process group -- see the note in
# `dev` for why the group and not the single process.
if [[ -n "$pid" ]] && kill -TERM -- "-${pid}" 2>/dev/null; then
echo "stopped ${name}"
fi

rm -f "$pid_file"
done

# Follow both dev-server logs. Ctrl+C stops following; the servers keep running.
dev-logs:
#!/usr/bin/env bash
set -euo pipefail

# -F rather than -f so a log that has not been created yet is waited for
# instead of aborting the whole tail.
tail -n 40 -F "{{run_dir}}/api.log" "{{run_dir}}/web.log"

# ---- MAUI app ----------------------------------------------------------------

# Build the MAUI head (builds the React bundle first via the BuildReactApp MSBuild target).
Expand Down
1 change: 1 addition & 0 deletions src/AppName.Api/AppName.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<UserSecretsId>408b55d2-b12e-4515-8cac-fa34dbc48f54</UserSecretsId>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.IdentityModel.Tokens;

namespace AppName.Api.Endpoints;
namespace AppName.Api.Endpoints.Auth;

/// <summary>
/// Local development authentication endpoints.
Expand Down
52 changes: 0 additions & 52 deletions src/AppName.Api/Endpoints/BondEndpoints.cs

This file was deleted.

101 changes: 101 additions & 0 deletions src/AppName.Api/Endpoints/Bonds/BondEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using AppName.Application.Dtos.Bonds;
using AppName.Application.UseCases.Bonds;
using Microsoft.AspNetCore.Http.HttpResults;

namespace AppName.Api.Endpoints.Bonds;

/// <summary>
/// Convertible-bond valuation and refresh endpoints.
/// </summary>
public static class BondEndpoints
{
/// <summary>
/// Maps the valuation/refresh endpoints under the given route group. All
/// endpoints here require an authenticated caller.
/// </summary>
public static RouteGroupBuilder MapBondEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/valuations", async Task<Results<Ok<IReadOnlyList<ConversionValuationDto>>, ValidationProblem>> (
[AsParameters] ValuationQueryRequest request, GetValuationsUseCase useCase, CancellationToken ct) =>
{
if (!request.TryToQuery(out var query, out var errors))
{
return TypedResults.ValidationProblem(errors);
}

var valuations = await useCase.ExecuteAsync(query, ct);

return TypedResults.Ok(valuations);
})
.RequireAuthorization()
.WithName("GetValuations");

// The count is a separate endpoint, not a field on the response above, because the
// grid keeps its total in its own cached query keyed on the view rather than the
// window. An envelope on the GET would run one COUNT per window scroll and discard
// every result.
//
// This route and "/valuations/{symbol}" share a shape. ASP.NET Core ranks a literal
// segment above a parameter segment, so "count" never binds to {symbol}.
group.MapGet("/valuations/count", async Task<Results<Ok<CountDto>, ValidationProblem>> (
[AsParameters] ValuationFilterRequest request, GetValuationCountUseCase useCase, CancellationToken ct) =>
{
if (!request.TryToFilter(out var filter, out var errors))
{
return TypedResults.ValidationProblem(errors);
}

var count = await useCase.ExecuteAsync(filter, ct);

return TypedResults.Ok(new CountDto(count));
})
.RequireAuthorization()
.WithName("GetValuationCount");

// This endpoint returns an envelope while the GET above returns a bare array. The
// two callers differ. Splitting this one would force the caller to send the same
// filter TREE twice, which invites two requests that disagree, and its slice and
// total come from one filter on one context, so they cannot tear. The report caller
// wants the total with the page; the grid does not.
group.MapPost("/valuations/query", async Task<Results<Ok<ValuationPageDto>, ValidationProblem>> (
ValuationQueryBody body, GetValuationPageUseCase useCase, CancellationToken ct) =>
{
if (!body.TryToQuery(out var query, out var errors))
{
return TypedResults.ValidationProblem(errors);
}

var page = await useCase.ExecuteAsync(query, ct);

return TypedResults.Ok(new ValuationPageDto(page.Items, page.Total));
})
.RequireAuthorization()
.WithName("QueryValuations");

group.MapGet("/valuations/{symbol}", async Task<Results<Ok<ConversionValuationDto>, NotFound>> (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) =>
{
var valuation = await useCase.ExecuteAsync(symbol, ct);
return valuation is null ? TypedResults.NotFound() : TypedResults.Ok(valuation);
})
.RequireAuthorization()
.WithName("GetValuationBySymbol");

group.MapPost("/valuations/refresh", async Task<Accepted> (IRefreshAllBondsUseCase useCase, CancellationToken ct) =>
{
await useCase.ExecuteAsync(ct);
return TypedResults.Accepted((string?)null);
})
.RequireAuthorization()
.WithName("RefreshAllValuations");

group.MapPost("/bonds/{symbol}/refresh", async Task<Accepted> (string symbol, RefreshBondDataUseCase useCase, CancellationToken ct) =>
{
await useCase.ExecuteAsync(symbol, ct);
return TypedResults.Accepted((string?)null);
})
.RequireAuthorization()
.WithName("RefreshBondData");

return group;
}
}
Loading
Loading