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