diff --git a/CHANGELOG.md b/CHANGELOG.md index 1605c48..8d0ddb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.5] - 2026-06-28 + +### Changed +- Centralized lease input validation and provider-configuration checks in shared `LeaseInputValidation` helpers used by PostgreSQL, Redis, and etcd lease services. +- Centralized lease failure result mapping in `LeaseResultErrors`, using `MaksIT.Core` exception message extraction for acquire/release errors. +- Updated dependencies: `MaksIT.Core` (`1.6.8`), `MaksIT.Results` (`2.0.3`), `Microsoft.Extensions.DependencyInjection.Abstractions` and `Microsoft.Extensions.Logging.Abstractions` (`11.0.0-preview.5.26302.115`), and `StackExchange.Redis` (`3.0.7`). + ## [1.0.4] - 2026-06-20 ### Changed diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index e7bba28..8b8930b 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 43.6% + + Branch Coverage: 49.1% @@ -15,7 +15,7 @@ Branch Coverage - - 43.6% + + 49.1% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index 3f9d668..d9d16ee 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,21 +1,21 @@ - - Line Coverage: 56% + + Line Coverage: 57.1% - + - - + + Line Coverage - - 56% + + 57.1% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index 9c11943..32ded5d 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 76.1% + + Method Coverage: 70.7% @@ -15,7 +15,7 @@ Method Coverage - - 76.1% + + 70.7% diff --git a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj index 4807b00..82250f4 100644 --- a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj +++ b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj @@ -13,9 +13,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MaksIT.HAMode/Abstractions/LeaseInputValidation.cs b/src/MaksIT.HAMode/Abstractions/LeaseInputValidation.cs new file mode 100644 index 0000000..fc562ed --- /dev/null +++ b/src/MaksIT.HAMode/Abstractions/LeaseInputValidation.cs @@ -0,0 +1,112 @@ +using MaksIT.Results; + +namespace MaksIT.HAMode.Abstractions; + +internal static class LeaseInputValidation { + internal static Result? ValidateAcquireInputs(string leaseName, string holderId, TimeSpan ttl) { + if (string.IsNullOrWhiteSpace(leaseName)) + return Result.BadRequest(false, "leaseName is required."); + + if (string.IsNullOrWhiteSpace(holderId)) + return Result.BadRequest(false, "holderId is required."); + + if (ttl <= TimeSpan.Zero) + return Result.BadRequest(false, "ttl must be positive."); + + return null; + } + + internal static Result? ValidateReleaseInputs(string leaseName, string holderId) { + if (string.IsNullOrWhiteSpace(leaseName)) + return Result.BadRequest("leaseName is required."); + + if (string.IsNullOrWhiteSpace(holderId)) + return Result.BadRequest("holderId is required."); + + return null; + } + + internal static Result? ValidatePostgreSqlProvider( + IRuntimeLeaseConnectionStringProvider connectionStringProvider, + bool hasSharedDataSource + ) { + if (!hasSharedDataSource && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString)) + return Result.BadRequest(false, "connection string is required."); + + if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema)) + return Result.BadRequest(false, "schema is required."); + + if (string.IsNullOrWhiteSpace(connectionStringProvider.Table)) + return Result.BadRequest(false, "table is required."); + + return null; + } + + internal static Result? ValidatePostgreSqlProviderForRelease( + IRuntimeLeaseConnectionStringProvider connectionStringProvider, + bool hasSharedDataSource + ) { + if (!hasSharedDataSource && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString)) + return Result.BadRequest("connection string is required."); + + if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema)) + return Result.BadRequest("schema is required."); + + if (string.IsNullOrWhiteSpace(connectionStringProvider.Table)) + return Result.BadRequest("table is required."); + + return null; + } + + internal static Result? ValidateRedisProvider( + IRuntimeLeaseRedisConnectionProvider connectionProvider, + bool hasSharedMultiplexer + ) { + if (!hasSharedMultiplexer && string.IsNullOrWhiteSpace(connectionProvider.Configuration)) + return Result.BadRequest(false, "redis configuration is required."); + + if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) + return Result.BadRequest(false, "redis key prefix is required."); + + return null; + } + + internal static Result? ValidateRedisProviderForRelease( + IRuntimeLeaseRedisConnectionProvider connectionProvider, + bool hasSharedMultiplexer + ) { + if (!hasSharedMultiplexer && string.IsNullOrWhiteSpace(connectionProvider.Configuration)) + return Result.BadRequest("redis configuration is required."); + + if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) + return Result.BadRequest("redis key prefix is required."); + + return null; + } + + internal static Result? ValidateEtcdProvider( + IRuntimeLeaseEtcdConnectionProvider connectionProvider, + bool hasSharedClient + ) { + if (!hasSharedClient && string.IsNullOrWhiteSpace(connectionProvider.Endpoints)) + return Result.BadRequest(false, "etcd endpoints are required."); + + if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) + return Result.BadRequest(false, "etcd key prefix is required."); + + return null; + } + + internal static Result? ValidateEtcdProviderForRelease( + IRuntimeLeaseEtcdConnectionProvider connectionProvider, + bool hasSharedClient + ) { + if (!hasSharedClient && string.IsNullOrWhiteSpace(connectionProvider.Endpoints)) + return Result.BadRequest("etcd endpoints are required."); + + if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) + return Result.BadRequest("etcd key prefix is required."); + + return null; + } +} diff --git a/src/MaksIT.HAMode/Abstractions/LeaseResultErrors.cs b/src/MaksIT.HAMode/Abstractions/LeaseResultErrors.cs new file mode 100644 index 0000000..092d600 --- /dev/null +++ b/src/MaksIT.HAMode/Abstractions/LeaseResultErrors.cs @@ -0,0 +1,24 @@ +using MaksIT.Core.Extensions; +using MaksIT.Results; + +namespace MaksIT.HAMode.Abstractions; + +internal static class LeaseResultErrors { + internal static Result AcquireFailed(Exception exception) => + Result.InternalServerError(false, ["Lease acquire failed.", .. exception.ExtractMessages()]); + + internal static Result ReleaseFailed(Exception exception) => + Result.InternalServerError(["Lease release failed.", .. exception.ExtractMessages()]); + + internal static Result AcquireTableMissing(string qualifiedTableName) => + Result.InternalServerError(false, [ + $"Lease table '{qualifiedTableName}' was not found.", + "Create the table or set Schema/Table in the PostgreSQL connection provider." + ]); + + internal static Result ReleaseTableMissing(string qualifiedTableName) => + Result.InternalServerError([ + $"Lease table '{qualifiedTableName}' was not found.", + "Create the table or set Schema/Table in the PostgreSQL connection provider." + ]); +} diff --git a/src/MaksIT.HAMode/Etcd/RuntimeLeaseServiceEtcd.cs b/src/MaksIT.HAMode/Etcd/RuntimeLeaseServiceEtcd.cs index 3910d5d..5ec0a85 100644 --- a/src/MaksIT.HAMode/Etcd/RuntimeLeaseServiceEtcd.cs +++ b/src/MaksIT.HAMode/Etcd/RuntimeLeaseServiceEtcd.cs @@ -22,20 +22,11 @@ public sealed class RuntimeLeaseServiceEtcd( : new EtcdClient(connectionProvider.Endpoints))); public async Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(leaseName)) + if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation) + return acquireValidation; - return Result.BadRequest(false, "leaseName is required."); - if (string.IsNullOrWhiteSpace(holderId)) - return Result.BadRequest(false, "holderId is required."); - - if (ttl <= TimeSpan.Zero) - return Result.BadRequest(false, "ttl must be positive."); - - if (sharedClient is null && string.IsNullOrWhiteSpace(connectionProvider.Endpoints)) - return Result.BadRequest(false, "etcd endpoints are required."); - - if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) - return Result.BadRequest(false, "etcd key prefix is required."); + if (LeaseInputValidation.ValidateEtcdProvider(connectionProvider, sharedClient is not null) is { } providerValidation) + return providerValidation; try { var key = BuildKey(leaseName); @@ -90,22 +81,16 @@ public sealed class RuntimeLeaseServiceEtcd( } catch (Exception ex) { logger.LogError(ex, "etcd TryAcquire lease failed for {LeaseName}", leaseName); - return Result.InternalServerError(false, ["Lease acquire failed.", ex.Message]); + return LeaseResultErrors.AcquireFailed(ex); } } public async Task ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(leaseName)) - return Result.BadRequest("leaseName is required."); + if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation) + return releaseValidation; - if (string.IsNullOrWhiteSpace(holderId)) - return Result.BadRequest("holderId is required."); - - if (sharedClient is null && string.IsNullOrWhiteSpace(connectionProvider.Endpoints)) - return Result.BadRequest("etcd endpoints are required."); - - if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) - return Result.BadRequest("etcd key prefix is required."); + if (LeaseInputValidation.ValidateEtcdProviderForRelease(connectionProvider, sharedClient is not null) is { } providerValidation) + return providerValidation; try { var keyBytes = ByteString.CopyFromUtf8(BuildKey(leaseName)); @@ -130,7 +115,7 @@ public sealed class RuntimeLeaseServiceEtcd( } catch (Exception ex) { logger.LogWarning(ex, "etcd Release lease failed for {LeaseName} (ignored).", leaseName); - return Result.InternalServerError(["Lease release failed.", ex.Message]); + return LeaseResultErrors.ReleaseFailed(ex); } } diff --git a/src/MaksIT.HAMode/MaksIT.HAMode.csproj b/src/MaksIT.HAMode/MaksIT.HAMode.csproj index 93b7071..f2bccce 100644 --- a/src/MaksIT.HAMode/MaksIT.HAMode.csproj +++ b/src/MaksIT.HAMode/MaksIT.HAMode.csproj @@ -8,7 +8,7 @@ $(NoWarn);CS1591 MaksIT.HAMode - 1.0.4 + 1.0.5 Maksym Sadovnychyy MAKS-IT MaksIT.HAMode @@ -33,11 +33,12 @@ - - - + + + + - + diff --git a/src/MaksIT.HAMode/PostgreSql/RuntimeLeaseServiceNpgsql.cs b/src/MaksIT.HAMode/PostgreSql/RuntimeLeaseServiceNpgsql.cs index 9ca6226..227d5d2 100644 --- a/src/MaksIT.HAMode/PostgreSql/RuntimeLeaseServiceNpgsql.cs +++ b/src/MaksIT.HAMode/PostgreSql/RuntimeLeaseServiceNpgsql.cs @@ -14,22 +14,11 @@ public sealed class RuntimeLeaseServiceNpgsql( NpgsqlDataSource? dataSource = null ) : IRuntimeLeaseService { public async Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(leaseName)) - return Result.BadRequest(false, "leaseName is required."); + if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation) + return acquireValidation; - if (string.IsNullOrWhiteSpace(holderId)) - return Result.BadRequest(false, "holderId is required."); - - if (ttl <= TimeSpan.Zero) - return Result.BadRequest(false, "ttl must be positive."); - if (dataSource is null && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString)) - return Result.BadRequest(false, "connection string is required."); - - if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema)) - return Result.BadRequest(false, "schema is required."); - - if (string.IsNullOrWhiteSpace(connectionStringProvider.Table)) - return Result.BadRequest(false, "table is required."); + if (LeaseInputValidation.ValidatePostgreSqlProvider(connectionStringProvider, dataSource is not null) is { } providerValidation) + return providerValidation; try { await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); @@ -68,32 +57,20 @@ public sealed class RuntimeLeaseServiceNpgsql( catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable) { var qualifiedTableName = $"{connectionStringProvider.Schema}.{connectionStringProvider.Table}"; logger.LogError(ex, "Lease table {TableName} was not found while acquiring lease {LeaseName}", qualifiedTableName, leaseName); - return Result.InternalServerError(false, [ - $"Lease table '{qualifiedTableName}' was not found.", - "Create the table or set Schema/Table in the PostgreSQL connection provider." - ]); + return LeaseResultErrors.AcquireTableMissing(qualifiedTableName); } catch (Exception ex) { logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName); - return Result.InternalServerError(false, ["Lease acquire failed.", ex.Message]); + return LeaseResultErrors.AcquireFailed(ex); } } public async Task ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(leaseName)) - return Result.BadRequest("leaseName is required."); + if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation) + return releaseValidation; - if (string.IsNullOrWhiteSpace(holderId)) - return Result.BadRequest("holderId is required."); - - if (dataSource is null && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString)) - return Result.BadRequest("connection string is required."); - - if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema)) - return Result.BadRequest("schema is required."); - - if (string.IsNullOrWhiteSpace(connectionStringProvider.Table)) - return Result.BadRequest("table is required."); + if (LeaseInputValidation.ValidatePostgreSqlProviderForRelease(connectionStringProvider, dataSource is not null) is { } providerValidation) + return providerValidation; try { await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); @@ -114,14 +91,11 @@ public sealed class RuntimeLeaseServiceNpgsql( catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable) { var qualifiedTableName = $"{connectionStringProvider.Schema}.{connectionStringProvider.Table}"; logger.LogWarning(ex, "Lease table {TableName} was not found while releasing lease {LeaseName}", qualifiedTableName, leaseName); - return Result.InternalServerError([ - $"Lease table '{qualifiedTableName}' was not found.", - "Create the table or set Schema/Table in the PostgreSQL connection provider." - ]); + return LeaseResultErrors.ReleaseTableMissing(qualifiedTableName); } catch (Exception ex) { logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName); - return Result.InternalServerError(["Lease release failed.", ex.Message]); + return LeaseResultErrors.ReleaseFailed(ex); } } diff --git a/src/MaksIT.HAMode/Redis/RuntimeLeaseServiceRedis.cs b/src/MaksIT.HAMode/Redis/RuntimeLeaseServiceRedis.cs index 3a4e417..074ff67 100644 --- a/src/MaksIT.HAMode/Redis/RuntimeLeaseServiceRedis.cs +++ b/src/MaksIT.HAMode/Redis/RuntimeLeaseServiceRedis.cs @@ -37,20 +37,11 @@ public sealed class RuntimeLeaseServiceRedis( private ConnectionMultiplexer? _ownedMultiplexer; public async Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(leaseName)) - return Result.BadRequest(false, "leaseName is required."); + if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation) + return acquireValidation; - if (string.IsNullOrWhiteSpace(holderId)) - return Result.BadRequest(false, "holderId is required."); - - if (ttl <= TimeSpan.Zero) - return Result.BadRequest(false, "ttl must be positive."); - - if (sharedMultiplexer is null && string.IsNullOrWhiteSpace(connectionProvider.Configuration)) - return Result.BadRequest(false, "redis configuration is required."); - - if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) - return Result.BadRequest(false, "redis key prefix is required."); + if (LeaseInputValidation.ValidateRedisProvider(connectionProvider, sharedMultiplexer is not null) is { } providerValidation) + return providerValidation; try { var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database; @@ -64,22 +55,16 @@ public sealed class RuntimeLeaseServiceRedis( } catch (Exception ex) { logger.LogError(ex, "Redis TryAcquire lease failed for {LeaseName}", leaseName); - return Result.InternalServerError(false, ["Lease acquire failed.", ex.Message]); + return LeaseResultErrors.AcquireFailed(ex); } } public async Task ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(leaseName)) - return Result.BadRequest("leaseName is required."); + if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation) + return releaseValidation; - if (string.IsNullOrWhiteSpace(holderId)) - return Result.BadRequest("holderId is required."); - - if (sharedMultiplexer is null && string.IsNullOrWhiteSpace(connectionProvider.Configuration)) - return Result.BadRequest("redis configuration is required."); - - if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) - return Result.BadRequest("redis key prefix is required."); + if (LeaseInputValidation.ValidateRedisProviderForRelease(connectionProvider, sharedMultiplexer is not null) is { } providerValidation) + return providerValidation; try { var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database; @@ -89,7 +74,7 @@ public sealed class RuntimeLeaseServiceRedis( } catch (Exception ex) { logger.LogWarning(ex, "Redis Release lease failed for {LeaseName} (ignored).", leaseName); - return Result.InternalServerError(["Lease release failed.", ex.Message]); + return LeaseResultErrors.ReleaseFailed(ex); } }