chore: add hrynco common library solution
- add the standalone HrynCo.Common solution and projects - include the shared common library source and tests - add package metadata and a repo gitignore
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
artifacts/
|
||||||
|
TestResults/
|
||||||
|
.idea/
|
||||||
|
*.DotSettings.user
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using HrynCo.Common.HealthChecks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class BaseConfigurationCheckTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckConfigurationAsync_ShouldReturnHealthyWhenOptionsAreValid()
|
||||||
|
{
|
||||||
|
var check = new FakeConfigurationCheck(new SampleOptions
|
||||||
|
{
|
||||||
|
Code = "ok"
|
||||||
|
}, "Sample");
|
||||||
|
|
||||||
|
HealthCheckResult result = await check.CheckConfigurationAsync(default);
|
||||||
|
|
||||||
|
result.Status.Should().Be(HealthStatus.Healthy);
|
||||||
|
result.Description.Should().Be("Sample configuration is valid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckConfigurationAsync_ShouldReturnUnhealthyWhenOptionsAreInvalid()
|
||||||
|
{
|
||||||
|
var check = new FakeConfigurationCheck(new SampleOptions(), "Sample");
|
||||||
|
|
||||||
|
HealthCheckResult result = await check.CheckConfigurationAsync(default);
|
||||||
|
|
||||||
|
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||||
|
result.Description.Should().Contain("Sample configuration invalid:");
|
||||||
|
result.Description.Should().Contain("The Code field is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeConfigurationCheck : BaseConfigurationCheck<SampleOptions>
|
||||||
|
{
|
||||||
|
public FakeConfigurationCheck(SampleOptions options, string name)
|
||||||
|
: base(options, name)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SampleOptions
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string? Code { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using HrynCo.Common.Caching;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class CachingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionCredentialStore_ShouldSaveGetAndRemoveValues()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var store = new InMemorySessionCredentialStore(cache);
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await store.SaveAsync(userId, "github", "secret", TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
(await store.GetAsync(userId, "github")).Should().Be("secret");
|
||||||
|
|
||||||
|
await store.RemoveAsync(userId, "github");
|
||||||
|
|
||||||
|
(await store.GetAsync(userId, "github")).Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionPromptStore_ShouldSaveGetAndRemoveValues()
|
||||||
|
{
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var store = new InMemorySessionPromptStore(cache);
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await store.SaveAsync(userId, "openai", "prompt", TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
(await store.GetAsync(userId, "openai")).Should().Be("prompt");
|
||||||
|
|
||||||
|
await store.RemoveAsync(userId, "openai");
|
||||||
|
|
||||||
|
(await store.GetAsync(userId, "openai")).Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using HrynCo.Common;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class ClockTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Clock_ShouldExposeUtcAndLocalTimeCloseToConstructionTime()
|
||||||
|
{
|
||||||
|
DateTimeOffset initialUtcNow = new(2026, 04, 30, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var clock = new Clock(initialUtcNow);
|
||||||
|
|
||||||
|
clock.UtcNow.Should().BeCloseTo(initialUtcNow, TimeSpan.FromSeconds(1));
|
||||||
|
clock.Now.Should().BeCloseTo(initialUtcNow.ToLocalTime(), TimeSpan.FromSeconds(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetNextDayOfWeek_ShouldReturnNextMatchingDay()
|
||||||
|
{
|
||||||
|
var clock = new Clock(new DateTimeOffset(2026, 04, 30, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
clock.GetNextDayOfWeek(DayOfWeek.Friday).Should().Be(new DateOnly(2026, 5, 1));
|
||||||
|
clock.GetNextDayOfWeek(DayOfWeek.Thursday).Should().Be(new DateOnly(2026, 5, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLastDayOfMonth_ShouldReturnMonthEnd()
|
||||||
|
{
|
||||||
|
var clock = new Clock(new DateTimeOffset(2026, 02, 10, 8, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
clock.GetLastDayOfMonth().Should().Be(new DateOnly(2026, 2, 28));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetNextMonthStart_ShouldReturnFirstDayOfNextMonth()
|
||||||
|
{
|
||||||
|
var clock = new Clock(new DateTimeOffset(2026, 12, 15, 8, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
clock.GetNextMonthStart().Should().Be(new DateOnly(2027, 1, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using HrynCo.Common;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class ConfigurationFactoryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateConfiguration_ShouldLoadJsonAndEnvironmentValues()
|
||||||
|
{
|
||||||
|
string basePath = CreateTempDirectory();
|
||||||
|
string originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(basePath, "appsettings.json"), """
|
||||||
|
{
|
||||||
|
"Title": "base",
|
||||||
|
"Nested": {
|
||||||
|
"Value": "base"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(basePath, "appsettings.Test.json"), """
|
||||||
|
{
|
||||||
|
"Nested": {
|
||||||
|
"Value": "override"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(basePath, "local.settings.json"), """
|
||||||
|
{
|
||||||
|
"Local": "enabled"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Test");
|
||||||
|
Environment.SetEnvironmentVariable("TITLE", "from-env");
|
||||||
|
|
||||||
|
IConfigurationRoot configuration = ConfigurationFactory.CreateConfiguration(basePath);
|
||||||
|
|
||||||
|
configuration["Title"].Should().Be("from-env");
|
||||||
|
configuration["Nested:Value"].Should().Be("override");
|
||||||
|
configuration["Local"].Should().Be("enabled");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment);
|
||||||
|
Environment.SetEnvironmentVariable("TITLE", null);
|
||||||
|
Directory.Delete(basePath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateConfiguration_ShouldThrowWhenBasePathIsMissing()
|
||||||
|
{
|
||||||
|
string missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
Action act = () => ConfigurationFactory.CreateConfiguration(missingPath, "Production");
|
||||||
|
|
||||||
|
act.Should().Throw<DirectoryNotFoundException>()
|
||||||
|
.WithMessage($"Directory {missingPath} does not exist. Wrong value of the basePath parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateTempDirectory()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using System.Diagnostics.CodeAnalysis;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using HrynCo.Common.HealthChecks;
|
||||||
|
using HrynCo.Common.HealthChecks.Defaults;
|
||||||
|
using HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class HealthChecksTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CompositeHealthCheck_ShouldUseConfigurationCheckWhenConfiguredForConfigOnly()
|
||||||
|
{
|
||||||
|
var configCheck = new FakeConfigurationCheck();
|
||||||
|
var serviceCheck = new FakeServiceHealthCheck();
|
||||||
|
var healthCheck = new CompositeHealthCheck(configCheck, serviceCheck, configOnly: true);
|
||||||
|
|
||||||
|
HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext(), default);
|
||||||
|
|
||||||
|
result.Status.Should().Be(HealthStatus.Healthy);
|
||||||
|
configCheck.Called.Should().BeTrue();
|
||||||
|
serviceCheck.Called.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CompositeHealthCheck_ShouldUseServiceCheckWhenNotConfigOnly()
|
||||||
|
{
|
||||||
|
var configCheck = new FakeConfigurationCheck();
|
||||||
|
var serviceCheck = new FakeServiceHealthCheck();
|
||||||
|
var healthCheck = new CompositeHealthCheck(configCheck, serviceCheck, configOnly: false);
|
||||||
|
|
||||||
|
HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext(), default);
|
||||||
|
|
||||||
|
result.Status.Should().Be(HealthStatus.Healthy);
|
||||||
|
configCheck.Called.Should().BeFalse();
|
||||||
|
serviceCheck.Called.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DbServiceHealthCheck_ShouldReturnHealthyWhenConnectionSucceeds()
|
||||||
|
{
|
||||||
|
var check = new DbServiceHealthCheck(new FakeDatabaseConnectionChecker(true));
|
||||||
|
|
||||||
|
HealthCheckResult result = await check.CheckHealthAsync(default);
|
||||||
|
|
||||||
|
result.Status.Should().Be(HealthStatus.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DbServiceHealthCheck_ShouldReturnUnhealthyWhenConnectionFails()
|
||||||
|
{
|
||||||
|
var check = new DbServiceHealthCheck(new FakeDatabaseConnectionChecker(false));
|
||||||
|
|
||||||
|
HealthCheckResult result = await check.CheckHealthAsync(default);
|
||||||
|
|
||||||
|
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeConfigurationCheck : IConfigurationCheck
|
||||||
|
{
|
||||||
|
public bool Called { get; private set; }
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> CheckConfigurationAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
Called = true;
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy("config"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeServiceHealthCheck : IServiceHealthCheck
|
||||||
|
{
|
||||||
|
public bool Called { get; private set; }
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
Called = true;
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy("service"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeDatabaseConnectionChecker : IDatabaseConnectionChecker
|
||||||
|
{
|
||||||
|
private readonly bool _canConnect;
|
||||||
|
|
||||||
|
public FakeDatabaseConnectionChecker(bool canConnect)
|
||||||
|
{
|
||||||
|
_canConnect = canConnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> CanConnectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
return Task.FromResult(_canConnect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.2.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HrynCo.Common\HrynCo.Common.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using HrynCo.Common;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class ProfilerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task MeasureExecutionAsync_ShouldReturnResultAndWriteStartAndEndEvents()
|
||||||
|
{
|
||||||
|
var sink = new CollectingSink();
|
||||||
|
ILogger logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(sink)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
var profiler = new Profiler(logger);
|
||||||
|
|
||||||
|
int result = await profiler.MeasureExecutionAsync(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(1);
|
||||||
|
return 42;
|
||||||
|
}, "LoadItems");
|
||||||
|
|
||||||
|
result.Should().Be(42);
|
||||||
|
sink.Events.Count.Should().BeGreaterThan(1);
|
||||||
|
sink.Events.Should().ContainSingle(e =>
|
||||||
|
e.Level == LogEventLevel.Information
|
||||||
|
&& e.MessageTemplate.Text.Contains("Start", StringComparison.Ordinal)
|
||||||
|
&& e.Properties["BlockName"].ToString().Contains("LoadItems", StringComparison.Ordinal));
|
||||||
|
sink.Events.Should().ContainSingle(e =>
|
||||||
|
e.Level == LogEventLevel.Information
|
||||||
|
&& e.MessageTemplate.Text.Contains("End", StringComparison.Ordinal)
|
||||||
|
&& e.Properties["Measurements"].ToString().Contains("True", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MeasureExecutionAsync_ActionOverload_ShouldInvokeAction()
|
||||||
|
{
|
||||||
|
var sink = new CollectingSink();
|
||||||
|
ILogger logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(sink)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
var profiler = new Profiler(logger);
|
||||||
|
bool invoked = false;
|
||||||
|
|
||||||
|
await profiler.MeasureExecutionAsync(async () =>
|
||||||
|
{
|
||||||
|
invoked = true;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}, "ActionBlock");
|
||||||
|
|
||||||
|
invoked.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MeasureExecutionAsync_ShouldLogErrorAndRethrow()
|
||||||
|
{
|
||||||
|
var sink = new CollectingSink();
|
||||||
|
ILogger logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(sink)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
var profiler = new Profiler(logger);
|
||||||
|
|
||||||
|
Func<Task> act = async () =>
|
||||||
|
{
|
||||||
|
await profiler.MeasureExecutionAsync<int>(() => throw new InvalidOperationException("boom"), "FailBlock");
|
||||||
|
};
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
sink.Events.Should().Contain(e =>
|
||||||
|
e.Level == LogEventLevel.Error
|
||||||
|
&& e.MessageTemplate.Text.Contains("An error occurred", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CollectingSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public ConcurrentQueue<LogEvent> Events { get; } = new();
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
Events.Enqueue(logEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using HrynCo.Common.Security;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class SecretProtectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ShouldRejectMissingKey()
|
||||||
|
{
|
||||||
|
Action act = () => _ = new SecretProtector(string.Empty);
|
||||||
|
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("Secret encryption key is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ShouldRejectInvalidKeyLength()
|
||||||
|
{
|
||||||
|
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(31));
|
||||||
|
|
||||||
|
Action act = () => _ = new SecretProtector(key);
|
||||||
|
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("Secret encryption key must be 32 bytes encoded as Base64.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProtectAndUnprotect_ShouldRoundTripPlaintext()
|
||||||
|
{
|
||||||
|
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var protector = new SecretProtector(key);
|
||||||
|
|
||||||
|
string protectedValue = protector.Protect("hello world");
|
||||||
|
string plaintext = protector.Unprotect(protectedValue);
|
||||||
|
|
||||||
|
protectedValue.Should().StartWith("v1:");
|
||||||
|
plaintext.Should().Be("hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unprotect_ShouldRejectUnsupportedFormat()
|
||||||
|
{
|
||||||
|
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var protector = new SecretProtector(key);
|
||||||
|
|
||||||
|
Action act = () => protector.Unprotect("v2:payload");
|
||||||
|
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("Unsupported protected value format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unprotect_ShouldFailWhenProtectedValueIsTamperedWith()
|
||||||
|
{
|
||||||
|
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var protector = new SecretProtector(key);
|
||||||
|
string protectedValue = protector.Protect("hello world");
|
||||||
|
string tamperedValue = protectedValue[..^1] + (protectedValue.EndsWith('A') ? 'B' : 'A');
|
||||||
|
|
||||||
|
Action act = () => protector.Unprotect(tamperedValue);
|
||||||
|
|
||||||
|
act.Should().Throw<CryptographicException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unprotect_ShouldFailWhenUsingTheWrongKey()
|
||||||
|
{
|
||||||
|
string originalKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
string otherKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var protector = new SecretProtector(originalKey);
|
||||||
|
var wrongProtector = new SecretProtector(otherKey);
|
||||||
|
string protectedValue = protector.Protect("hello world");
|
||||||
|
|
||||||
|
Action act = () => wrongProtector.Unprotect(protectedValue);
|
||||||
|
|
||||||
|
act.Should().Throw<CryptographicException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class TimeApiUsageGuardTests
|
||||||
|
{
|
||||||
|
private static readonly string[] ForbiddenPatterns =
|
||||||
|
[
|
||||||
|
"DateTime.Now",
|
||||||
|
"DateTimeOffset.Now"
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionCode_ShouldNotUseLocalNowApis()
|
||||||
|
{
|
||||||
|
string solutionRoot = GetSolutionRoot();
|
||||||
|
|
||||||
|
var violations = Directory
|
||||||
|
.EnumerateFiles(solutionRoot, "*.cs", SearchOption.AllDirectories)
|
||||||
|
.Where(IsProductionSourceFile)
|
||||||
|
.SelectMany(file => FindViolations(solutionRoot, file))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
violations.Should().BeEmpty(
|
||||||
|
"production code should use UTC-based APIs for persisted and serialized timestamps.{0}{1}",
|
||||||
|
Environment.NewLine,
|
||||||
|
string.Join(Environment.NewLine, violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSolutionRoot()
|
||||||
|
{
|
||||||
|
string? current = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current, "HrynCo.Common.sln")))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = Directory.GetParent(current)?.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate the solution root from the test assembly output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsProductionSourceFile(string filePath)
|
||||||
|
{
|
||||||
|
string normalizedPath = filePath.Replace('\\', '/');
|
||||||
|
|
||||||
|
return !normalizedPath.Contains("/bin/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !normalizedPath.Contains("/obj/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !normalizedPath.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !normalizedPath.Contains(".Tests/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !normalizedPath.Contains(".IntegrationTests/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> FindViolations(string solutionRoot, string filePath)
|
||||||
|
{
|
||||||
|
string relativePath = Path.GetRelativePath(solutionRoot, filePath).Replace('\\', '/');
|
||||||
|
string[] lines = File.ReadAllLines(filePath);
|
||||||
|
|
||||||
|
for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++)
|
||||||
|
{
|
||||||
|
string line = lines[lineIndex];
|
||||||
|
|
||||||
|
foreach (string forbiddenPattern in ForbiddenPatterns)
|
||||||
|
{
|
||||||
|
if (line.Contains(forbiddenPattern, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
yield return $"{relativePath}:{lineIndex + 1} contains forbidden API `{forbiddenPattern}`";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
namespace HrynCo.Common.Tests;
|
||||||
|
|
||||||
|
using HrynCo.Common.Tree;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public sealed class FakeNode : ITreeNode<Guid>
|
||||||
|
{
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
public Guid? ParentId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FakeNamedNode : ITreeNode<Guid>, INameNode
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
public Guid? ParentId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TreeUtilsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CollectDescendants_ShouldReturnSelfAndAllChildren()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var child1 = Guid.NewGuid();
|
||||||
|
var child2 = Guid.NewGuid();
|
||||||
|
var nodes = new List<FakeNode>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = root,
|
||||||
|
ParentId = null
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = child1,
|
||||||
|
ParentId = root
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = child2,
|
||||||
|
ParentId = child1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TreeUtils.CollectDescendants(root, nodes, Guid.Empty);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeEquivalentTo(new[]
|
||||||
|
{
|
||||||
|
root, child1, child2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildBreadcrumb_ShouldReturnPathFromRootToNode()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var child = Guid.NewGuid();
|
||||||
|
var grandchild = Guid.NewGuid();
|
||||||
|
|
||||||
|
var map = new Dictionary<Guid, FakeNamedNode>
|
||||||
|
{
|
||||||
|
[root] = new()
|
||||||
|
{
|
||||||
|
Id = root,
|
||||||
|
ParentId = null,
|
||||||
|
Name = "Root"
|
||||||
|
},
|
||||||
|
[child] = new()
|
||||||
|
{
|
||||||
|
Id = child,
|
||||||
|
ParentId = root,
|
||||||
|
Name = "Child"
|
||||||
|
},
|
||||||
|
[grandchild] = new()
|
||||||
|
{
|
||||||
|
Id = grandchild,
|
||||||
|
ParentId = child,
|
||||||
|
Name = "Grandchild"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TreeUtils.BuildBreadcrumb(grandchild, map);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeEquivalentTo(new[]
|
||||||
|
{
|
||||||
|
new BreadcrumbNode<Guid>(root, "Root"), new BreadcrumbNode<Guid>(child, "Child"),
|
||||||
|
new BreadcrumbNode<Guid>(grandchild, "Grandchild")
|
||||||
|
}, options => options.WithStrictOrdering());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CollectDescendants_ShouldReturnOnlyRootWhenThereAreNoChildren()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TreeUtils.CollectDescendants(root, Array.Empty<FakeNode>(), Guid.Empty);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeEquivalentTo(new[] { root });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CollectDescendants_ShouldHandleCyclesWithoutRepeatingNodes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var child = Guid.NewGuid();
|
||||||
|
var nodes = new List<FakeNode>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = root,
|
||||||
|
ParentId = child
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = child,
|
||||||
|
ParentId = root
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TreeUtils.CollectDescendants(root, nodes, Guid.Empty);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeEquivalentTo(new[] { root, child });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildBreadcrumb_ShouldReturnEmptyListWhenNodeIsMissing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var map = new Dictionary<Guid, FakeNamedNode>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TreeUtils.BuildBreadcrumb(Guid.NewGuid(), map);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HrynCo.Common", "HrynCo.Common\HrynCo.Common.csproj", "{5732ABF0-92F3-4123-865E-5B40F0E86F57}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HrynCo.Common.Tests", "HrynCo.Common.Tests\HrynCo.Common.Tests.csproj", "{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace HrynCo.Common.Caching;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
public interface ISessionCredentialStore
|
||||||
|
{
|
||||||
|
Task<string?> GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SaveAsync(
|
||||||
|
Guid userId,
|
||||||
|
string provider,
|
||||||
|
string apiKey,
|
||||||
|
TimeSpan ttl,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace HrynCo.Common.Caching;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
public interface ISessionPromptStore
|
||||||
|
{
|
||||||
|
Task<string?> GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SaveAsync(
|
||||||
|
Guid userId,
|
||||||
|
string provider,
|
||||||
|
string prompt,
|
||||||
|
TimeSpan ttl,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace HrynCo.Common.Caching;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
public sealed class InMemorySessionCredentialStore : ISessionCredentialStore
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
|
public InMemorySessionCredentialStore(IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
_cache.TryGetValue(BuildKey(userId, provider), out string? apiKey);
|
||||||
|
return Task.FromResult(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
_cache.Remove(BuildKey(userId, provider));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveAsync(
|
||||||
|
Guid userId,
|
||||||
|
string provider,
|
||||||
|
string apiKey,
|
||||||
|
TimeSpan ttl,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
_cache.Set(
|
||||||
|
BuildKey(userId, provider),
|
||||||
|
apiKey,
|
||||||
|
new MemoryCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = ttl,
|
||||||
|
SlidingExpiration = TimeSpan.FromMinutes(30)
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildKey(Guid userId, string provider)
|
||||||
|
{
|
||||||
|
return $"ai-session-key:{provider}:{userId:N}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace HrynCo.Common.Caching;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
public sealed class InMemorySessionPromptStore : ISessionPromptStore
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
|
public InMemorySessionPromptStore(IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
_cache.TryGetValue(BuildKey(userId, provider), out string? prompt);
|
||||||
|
return Task.FromResult(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
_cache.Remove(BuildKey(userId, provider));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveAsync(
|
||||||
|
Guid userId,
|
||||||
|
string provider,
|
||||||
|
string prompt,
|
||||||
|
TimeSpan ttl,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
_cache.Set(
|
||||||
|
BuildKey(userId, provider),
|
||||||
|
prompt,
|
||||||
|
new MemoryCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = ttl,
|
||||||
|
SlidingExpiration = TimeSpan.FromMinutes(30)
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildKey(Guid userId, string provider)
|
||||||
|
{
|
||||||
|
return $"ai-session-prompt:{provider}:{userId:N}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace HrynCo.Common;
|
||||||
|
|
||||||
|
public sealed class Clock : IClock
|
||||||
|
{
|
||||||
|
private readonly DateTimeOffset _createdAt;
|
||||||
|
private readonly DateTimeOffset _initialNow;
|
||||||
|
private readonly DateTimeOffset _initialUtcNow;
|
||||||
|
|
||||||
|
public Clock()
|
||||||
|
: this(DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Clock(DateTimeOffset initialUtcNow)
|
||||||
|
{
|
||||||
|
_initialUtcNow = initialUtcNow;
|
||||||
|
_initialNow = initialUtcNow.ToLocalTime();
|
||||||
|
_createdAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset Now => _initialNow + (DateTimeOffset.UtcNow - _createdAt);
|
||||||
|
public DateTimeOffset UtcNow => _initialUtcNow + (DateTimeOffset.UtcNow - _createdAt);
|
||||||
|
public DateOnly Today => DateOnly.FromDateTime(Now.DateTime);
|
||||||
|
|
||||||
|
public DateOnly GetNextDayOfWeek(DayOfWeek dayOfWeek)
|
||||||
|
{
|
||||||
|
int daysToAdd = ((int)dayOfWeek - (int)Today.DayOfWeek + 7) % 7;
|
||||||
|
return Today.AddDays(daysToAdd == 0 ? 7 : daysToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateOnly GetLastDayOfMonth()
|
||||||
|
{
|
||||||
|
return new DateOnly(Now.Year, Now.Month, 1).AddMonths(1).AddDays(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateOnly GetNextMonthStart()
|
||||||
|
{
|
||||||
|
return new DateOnly(Now.Year, Now.Month, 1).AddMonths(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace HrynCo.Common;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
public static class ConfigurationFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Use for .NET Core Console applications.
|
||||||
|
/// </summary>
|
||||||
|
public static IConfigurationRoot CreateConfiguration()
|
||||||
|
{
|
||||||
|
return CreateConfiguration(AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IConfigurationRoot CreateConfiguration(string basePath)
|
||||||
|
{
|
||||||
|
string environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
|
||||||
|
return CreateConfiguration(basePath, environmentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IConfigurationRoot CreateConfiguration(string basePath, string environmentName)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(basePath))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException(
|
||||||
|
$"Directory {basePath} does not exist. Wrong value of the {nameof(basePath)} parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConfigurationBuilder()
|
||||||
|
.SetBasePath(basePath)
|
||||||
|
.AddJsonFile("appsettings.json", false, true)
|
||||||
|
.AddJsonFile($"appsettings.{environmentName}.json", true, true)
|
||||||
|
.AddJsonFile("local.settings.json", true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace HrynCo.Common;
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public static class EnvironmentNames
|
||||||
|
{
|
||||||
|
public const string Development = "Development";
|
||||||
|
public const string DockerDev = "DockerDev";
|
||||||
|
public const string IntegrationTests = "IntegrationTests";
|
||||||
|
public const string Production = "Production";
|
||||||
|
public const string Staging = "Staging";
|
||||||
|
|
||||||
|
public static readonly string[] DevelopmentLike =
|
||||||
|
[
|
||||||
|
Development,
|
||||||
|
DockerDev,
|
||||||
|
IntegrationTests
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
public abstract class BaseConfigurationCheck<TOptions> : IConfigurationCheck
|
||||||
|
where TOptions : class
|
||||||
|
{
|
||||||
|
private readonly string _name;
|
||||||
|
private readonly TOptions _options;
|
||||||
|
|
||||||
|
protected BaseConfigurationCheck(TOptions options, string name)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> CheckConfigurationAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = new ValidationContext(_options);
|
||||||
|
var results = new List<ValidationResult>();
|
||||||
|
|
||||||
|
bool isValid = Validator.TryValidateObject(
|
||||||
|
_options,
|
||||||
|
context,
|
||||||
|
results,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
string errors = string.Join("; ", results.Select(r => r.ErrorMessage));
|
||||||
|
return Task.FromResult(HealthCheckResult.Unhealthy($"{_name} configuration invalid: {errors}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy($"{_name} configuration is valid."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks;
|
||||||
|
|
||||||
|
using HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
public class CompositeHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IConfigurationCheck _configCheck;
|
||||||
|
private readonly bool _configOnly;
|
||||||
|
private readonly IServiceHealthCheck _serviceCheck;
|
||||||
|
|
||||||
|
public CompositeHealthCheck(
|
||||||
|
IConfigurationCheck configCheck,
|
||||||
|
IServiceHealthCheck serviceCheck,
|
||||||
|
bool configOnly)
|
||||||
|
{
|
||||||
|
_configCheck = configCheck;
|
||||||
|
_serviceCheck = serviceCheck;
|
||||||
|
_configOnly = configOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _configOnly
|
||||||
|
? _configCheck.CheckConfigurationAsync(cancellationToken)
|
||||||
|
: _serviceCheck.CheckHealthAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks.Defaults;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
public class DbOptions
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string? ConnectionString { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks.Defaults;
|
||||||
|
|
||||||
|
using HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
public sealed class DbServiceHealthCheck : IServiceHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IDatabaseConnectionChecker _checker;
|
||||||
|
|
||||||
|
public DbServiceHealthCheck(IDatabaseConnectionChecker checker)
|
||||||
|
{
|
||||||
|
_checker = checker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _checker.CanConnectAsync(cancellationToken)
|
||||||
|
? HealthCheckResult.Healthy("Database reachable")
|
||||||
|
: HealthCheckResult.Unhealthy("Database unreachable");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks.Defaults;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
public class SeqOptions
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string? ServerUrl { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
public interface IConfigurationCheck
|
||||||
|
{
|
||||||
|
Task<HealthCheckResult> CheckConfigurationAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
|
||||||
|
public interface IDatabaseConnectionChecker
|
||||||
|
{
|
||||||
|
Task<bool> CanConnectAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace HrynCo.Common.HealthChecks.Interfaces;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
public interface IServiceHealthCheck
|
||||||
|
{
|
||||||
|
Task<HealthCheckResult> CheckHealthAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>HrynCo.Common</RootNamespace>
|
||||||
|
<PackageId>HrynCo.Common</PackageId>
|
||||||
|
<Authors>HrynCo</Authors>
|
||||||
|
<Description>Shared common utilities for HrynCo applications.</Description>
|
||||||
|
<PackageTags>hrynco common utilities caching configuration healthchecks security</PackageTags>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<RepositoryUrl>https://gitea.grynco.com.ua/hrynco/hrynco-common.git</RepositoryUrl>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.6"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.6"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.6"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.6"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.5"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath=""/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace HrynCo.Common;
|
||||||
|
|
||||||
|
public interface IClock
|
||||||
|
{
|
||||||
|
DateTimeOffset Now { get; }
|
||||||
|
DateOnly Today { get; }
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
DateOnly GetNextDayOfWeek(DayOfWeek dayOfWeek);
|
||||||
|
DateOnly GetLastDayOfMonth();
|
||||||
|
DateOnly GetNextMonthStart();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace HrynCo.Common;
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
public interface IProfiler
|
||||||
|
{
|
||||||
|
Task<T> MeasureExecutionAsync<T>(Func<Task<T>> function, string blockName = "");
|
||||||
|
Task MeasureExecutionAsync(Func<Task> action, string blockName = "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Profiler(ILogger logger) : IProfiler
|
||||||
|
{
|
||||||
|
public async Task<T> MeasureExecutionAsync<T>(Func<Task<T>> function, string blockName = "")
|
||||||
|
{
|
||||||
|
logger.ForContext("PerformanceLog", true).Information("{BlockName} - Start", blockName);
|
||||||
|
|
||||||
|
var stopwatch = new Stopwatch();
|
||||||
|
var process = Process.GetCurrentProcess();
|
||||||
|
|
||||||
|
long memoryBefore = process.PrivateMemorySize64;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stopwatch.Start();
|
||||||
|
|
||||||
|
T result = await function().ConfigureAwait(false);
|
||||||
|
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
long memoryAfter = process.PrivateMemorySize64;
|
||||||
|
long memoryUsed = memoryAfter - memoryBefore;
|
||||||
|
|
||||||
|
long stopwatchElapsedMilliseconds = stopwatch.ElapsedMilliseconds;
|
||||||
|
|
||||||
|
logger
|
||||||
|
.ForContext("PerformanceLog", true)
|
||||||
|
.ForContext("Measurements", true)
|
||||||
|
.Information(
|
||||||
|
"{BlockName} - End. Duration: {Duration} ms. Memory used: {MemoryUsed} bytes",
|
||||||
|
blockName, stopwatchElapsedMilliseconds, memoryUsed);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex) // NOSONAR
|
||||||
|
{
|
||||||
|
logger
|
||||||
|
.ForContext("PerformanceLog", true)
|
||||||
|
.Error(ex, "{BlockName} - An error occurred", blockName);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MeasureExecutionAsync(Func<Task> action, string blockName = "")
|
||||||
|
{
|
||||||
|
await MeasureExecutionAsync<object?>(async () =>
|
||||||
|
{
|
||||||
|
await action();
|
||||||
|
return null;
|
||||||
|
}, blockName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# HrynCo.Common
|
||||||
|
|
||||||
|
Shared common utilities for HrynCo applications.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Configuration helpers
|
||||||
|
- Health checks
|
||||||
|
- Caching helpers
|
||||||
|
- Security helpers
|
||||||
|
- Tree traversal helpers
|
||||||
|
- Time and profiling helpers
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
This package is intended for reuse through NuGet. The test project is excluded from packing.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace HrynCo.Common.Security;
|
||||||
|
|
||||||
|
public interface ISecretProtector
|
||||||
|
{
|
||||||
|
string Protect(string plaintext);
|
||||||
|
|
||||||
|
string Unprotect(string protectedValue);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace HrynCo.Common.Security;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
public sealed class SecretProtector : ISecretProtector
|
||||||
|
{
|
||||||
|
private readonly byte[] _key;
|
||||||
|
|
||||||
|
public SecretProtector(string encryptionKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(encryptionKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Secret encryption key is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_key = Convert.FromBase64String(encryptionKey);
|
||||||
|
if (_key.Length != 32)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Secret encryption key must be 32 bytes encoded as Base64.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Protect(string plaintext)
|
||||||
|
{
|
||||||
|
byte[] nonce = RandomNumberGenerator.GetBytes(12);
|
||||||
|
byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
byte[] ciphertext = new byte[plaintextBytes.Length];
|
||||||
|
byte[] tag = new byte[16];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, tagSizeInBytes: 16);
|
||||||
|
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
|
||||||
|
|
||||||
|
byte[] payload = new byte[nonce.Length + tag.Length + ciphertext.Length];
|
||||||
|
Buffer.BlockCopy(nonce, 0, payload, 0, nonce.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, payload, nonce.Length, tag.Length);
|
||||||
|
Buffer.BlockCopy(ciphertext, 0, payload, nonce.Length + tag.Length, ciphertext.Length);
|
||||||
|
|
||||||
|
return $"v1:{Convert.ToBase64String(payload)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Unprotect(string protectedValue)
|
||||||
|
{
|
||||||
|
if (!protectedValue.StartsWith("v1:", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unsupported protected value format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload = Convert.FromBase64String(protectedValue[3..]);
|
||||||
|
byte[] nonce = payload[..12];
|
||||||
|
byte[] tag = payload[12..28];
|
||||||
|
byte[] ciphertext = payload[28..];
|
||||||
|
byte[] plaintext = new byte[ciphertext.Length];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, tagSizeInBytes: 16);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace HrynCo.Common.Tree;
|
||||||
|
|
||||||
|
public sealed record BreadcrumbNode<TKey>
|
||||||
|
{
|
||||||
|
public BreadcrumbNode(TKey Id, string Name)
|
||||||
|
{
|
||||||
|
this.Id = Id;
|
||||||
|
this.Name = Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TKey Id { get; init; }
|
||||||
|
public string Name { get; init; }
|
||||||
|
|
||||||
|
public void Deconstruct(out TKey Id, out string Name)
|
||||||
|
{
|
||||||
|
Id = this.Id;
|
||||||
|
Name = this.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace HrynCo.Common.Tree;
|
||||||
|
|
||||||
|
public interface INameNode
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace HrynCo.Common.Tree;
|
||||||
|
|
||||||
|
public interface ITreeNode<TKey>
|
||||||
|
where TKey : struct
|
||||||
|
{
|
||||||
|
TKey Id { get; }
|
||||||
|
TKey? ParentId { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace HrynCo.Common.Tree;
|
||||||
|
|
||||||
|
public static class TreeUtils
|
||||||
|
{
|
||||||
|
public static HashSet<TKey> CollectDescendants<TNode, TKey>(
|
||||||
|
TKey rootId,
|
||||||
|
IEnumerable<TNode> nodes,
|
||||||
|
TKey rootMarker)
|
||||||
|
where TNode : ITreeNode<TKey>
|
||||||
|
where TKey : struct
|
||||||
|
{
|
||||||
|
var map = nodes
|
||||||
|
.GroupBy(x => x.ParentId ?? rootMarker)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var result = new HashSet<TKey>
|
||||||
|
{
|
||||||
|
rootId
|
||||||
|
};
|
||||||
|
var queue = new Queue<TKey>();
|
||||||
|
queue.Enqueue(rootId);
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
TKey current = queue.Dequeue();
|
||||||
|
if (!map.TryGetValue(current, out var children))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (TNode child in children.Where(child => result.Add(child.Id)))
|
||||||
|
{
|
||||||
|
queue.Enqueue(child.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<BreadcrumbNode<TKey>> BuildBreadcrumb<TNode, TKey>(
|
||||||
|
TKey currentId,
|
||||||
|
IReadOnlyDictionary<TKey, TNode> map)
|
||||||
|
where TNode : class, ITreeNode<TKey>, INameNode
|
||||||
|
where TKey : struct
|
||||||
|
{
|
||||||
|
var path = new List<BreadcrumbNode<TKey>>();
|
||||||
|
TNode? current = map.GetValueOrDefault(currentId);
|
||||||
|
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
path.Add(new BreadcrumbNode<TKey>(current.Id, current.Name));
|
||||||
|
current = current.ParentId is { } parentId
|
||||||
|
? map.GetValueOrDefault(parentId)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.Reverse();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user