diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f216e5..cf4b14bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ Represents the **NuGet** versions. +## v3.17.0 +- *Enhancement*: Additional `CoreEx.Validation` usability improvements: + - `Validator.CreateFor` added to enable the creation of a `CommonValidator` instance for a specified type `T` (more purposeful name); synonym for existing `CommonValidator.Create` (unchanged). + - `Validator.Null` added to enable simplified specification of a `IValidatorEx` of `null` to avoid explicit `null` casting. + - `Collection` extension method has additional overload to pass in the `IValidatorEx` to use for each item in the collection; versus, having to use `CollectionRuleItem.Create`. + - `Dictionary` extension method has additional overload to pass in the `IValidatorEx` and `IValidator` to use for each entry in the dictionary; versus, having to use `DictionaryRuleItem.Create`. + - `MinimumCount` and `MaximumCount` extension methods for `ICollection` added to enable explicit specification of these two basic validations. + - `Validation.CreateCollection` renamed to `Validation.CreateForCollection` and creates a `CommonValidator`. + - Existing `CollectionValidator` deprecated as the `CommonValidator` offers same; removes duplication of capability. + - `Validation.CreateDictionary` renamed to `Validation.CreateForDictionary` and creates a `CommonValidator`. + - Existing `DictionaryValidator` deprecated as the `CommonValidator` offers same; removes duplication of capability. +- *Enhancement*: Added `ServiceBusReceiverHealthCheck` to perform a peek message on the `ServiceBusReceiver` as a means to determine health. Use `IHealthChecksBuilder.AddServiceBusReceiverHealthCheck` to configure. +- *Fixed:* The `FileLockSynchronizer`, `BlobLeaseSynchronizer` and `TableWorkStatePersistence` have had any file/blob/table validations/manipulations moved from the constructor to limit critical failures at startup from a DI perspective; now only performed where required/used. This also allows improved health check opportunities as means to verify. + ## v3.16.0 - *Enhancement*: Added basic [FluentValidator](https://docs.fluentvalidation.net/en/latest/) compatibility to the `CoreEx.Validation` by supporting _key_ (common) named capabilities: - `AbstractValidator` added as a wrapper for `Validator`; with both supporting `RuleFor` method (wrapper for existing `Property`). diff --git a/Common.targets b/Common.targets index 6468f728..242e5fa7 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.16.0 + 3.17.0 preview Avanade Avanade diff --git a/samples/My.Hr/My.Hr.Api/Startup.cs b/samples/My.Hr/My.Hr.Api/Startup.cs index aa60347b..d937170d 100644 --- a/samples/My.Hr/My.Hr.Api/Startup.cs +++ b/samples/My.Hr/My.Hr.Api/Startup.cs @@ -5,6 +5,9 @@ using OpenTelemetry.Trace; using Az = Azure.Messaging.ServiceBus; using CoreEx.Database.HealthChecks; +using CoreEx.Azure.ServiceBus.HealthChecks; +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.DependencyInjection; namespace My.Hr.Api; @@ -51,8 +54,8 @@ public void ConfigureServices(IServiceCollection services) // Register the health checks. services - .AddHealthChecks(); - //.AddTypeActivatedCheck("Verification Queue", HealthStatus.Unhealthy, nameof(HrSettings.ServiceBusConnection), nameof(HrSettings.VerificationQueueName)) + .AddHealthChecks() + .AddServiceBusReceiverHealthCheck(sp => sp.GetRequiredService().CreateReceiver(sp.GetRequiredService().VerificationQueueName), "verification-queue"); services.AddControllers(); diff --git a/samples/My.Hr/My.Hr.Api/appsettings.Development.json b/samples/My.Hr/My.Hr.Api/appsettings.Development.json index 0245e9d0..40fcd139 100644 --- a/samples/My.Hr/My.Hr.Api/appsettings.Development.json +++ b/samples/My.Hr/My.Hr.Api/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "AllowedHosts": "*", - "VerificationQueueName": "pendingVerifications", + "VerificationQueueName": "verification-queue", "ServiceBusConnection": "coreex.servicebus.windows.net", "ConnectionStrings": { "Database": "Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true" diff --git a/samples/My.Hr/My.Hr.Api/appsettings.json b/samples/My.Hr/My.Hr.Api/appsettings.json index e4143fb1..8b858ae5 100644 --- a/samples/My.Hr/My.Hr.Api/appsettings.json +++ b/samples/My.Hr/My.Hr.Api/appsettings.json @@ -14,5 +14,8 @@ "AbsoluteExpirationRelativeToNow": "03:00:00", "SlidingExpiration": "00:45:00" } + }, + "ServiceBusConnection": { + "fullyQualifiedNamespace": "Endpoint=sb://top-secret.servicebus.windows.net/;SharedAccessKeyName=top-secret;SharedAccessKey=top-encrypted-secret" } } \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs b/src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs new file mode 100644 index 00000000..6d682c19 --- /dev/null +++ b/src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs @@ -0,0 +1,37 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace CoreEx.Azure.ServiceBus.HealthChecks +{ + /// + /// Provides a to verify the receiver is accessible by peeking a message. + /// + /// The create factory. + public class ServiceBusReceiverHealthCheck(Func receiverFactory) : IHealthCheck + { + private readonly Func _receiverFactory = receiverFactory.ThrowIfNull(nameof(receiverFactory)); + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + await using var receiver = _receiverFactory() ?? throw new InvalidOperationException("The ServiceBusReceiver factory returned null."); + var msg = await receiver.PeekMessageAsync(null, cancellationToken).ConfigureAwait(false); + return HealthCheckResult.Healthy(null, new Dictionary{ { "message", msg is null ? "none" : new Message { MessageId = msg.MessageId, CorrelationId = msg.CorrelationId, Subject = msg.Subject, SessionId = msg.SessionId, PartitionKey = msg.PartitionKey } } }); + } + + private class Message + { + public string? MessageId { get; set; } + public string? CorrelationId { get; set; } + public string? Subject { get; set; } + public string? SessionId { get; set; } + public string? PartitionKey { get; set; } + } + } +} diff --git a/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs b/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs index 88db64cd..dd43ac30 100644 --- a/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs +++ b/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs @@ -2,11 +2,14 @@ using CoreEx; using CoreEx.Azure.ServiceBus; +using CoreEx.Azure.ServiceBus.HealthChecks; using CoreEx.Configuration; using CoreEx.Events; using CoreEx.Events.Subscribing; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using Asb = Azure.Messaging.ServiceBus; namespace Microsoft.Extensions.DependencyInjection @@ -75,5 +78,24 @@ public static IServiceCollection AddAzureServiceBusPurger(this IServiceCollectio configure?.Invoke(sp, sbp); return sbp; }); + + /// + /// Adds a that will peek a message from the Azure Service Bus receiver to confirm health. + /// + /// The . + /// The health check name. Defaults to 'azure-service-bus-receiver'. + /// The factory. + /// The that should be reported when the health check reports a failure. If the provided value is null, then will be reported. + /// A list of tags that can be used for filtering health checks. + /// An optional representing the timeout of the check. + public static IHealthChecksBuilder AddServiceBusReceiverHealthCheck(this IHealthChecksBuilder builder, Func serviceBusReceiverFactory, string? name = null, HealthStatus? failureStatus = default, IEnumerable? tags = default, TimeSpan? timeout = default) + { + serviceBusReceiverFactory.ThrowIfNull(nameof(serviceBusReceiverFactory)); + + return builder.Add(new HealthCheckRegistration(name ?? "azure-service-bus-receiver", sp => + { + return new ServiceBusReceiverHealthCheck(() => serviceBusReceiverFactory(sp)); + }, failureStatus, tags, timeout)); + } } } \ No newline at end of file diff --git a/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs b/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs index 8ccc7063..77d736bf 100644 --- a/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs +++ b/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs @@ -37,8 +37,6 @@ public class BlobLeaseSynchronizer : IServiceSynchronizer public BlobLeaseSynchronizer(BlobContainerClient client) { _client = client.ThrowIfNull(nameof(client)); - _client.CreateIfNotExists(); - _timer = new Lazy(() => new Timer(_ => { foreach (var kvp in _dict.ToArray()) @@ -74,6 +72,8 @@ public bool Enter(string? name = null) _dict.GetOrAdd(GetName(name), fn => { + _client.CreateIfNotExists(); + var blob = _client.GetBlobClient(GetName(name)); try { diff --git a/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs b/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs index 6f9f9326..21d0c852 100644 --- a/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs +++ b/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs @@ -23,6 +23,8 @@ public class TableWorkStatePersistence : IWorkStatePersistence private readonly TableClient _workStateTableClient; private readonly TableClient _workDataTableClient; private readonly IJsonSerializer _jsonSerializer; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private volatile bool _firstTime = true; /// /// Initializes a new instance of the class. @@ -84,6 +86,7 @@ private class WorkDataEntity() : ITableEntity private const int _maxChunks = 15; private const int _maxSize = _chunkSize * _maxChunks; private readonly BinaryData?[] _data = new BinaryData?[_maxChunks]; + public WorkDataEntity(BinaryData data) : this() { var arr = data.ToArray(); @@ -101,6 +104,7 @@ public WorkDataEntity(BinaryData data) : this() _data[i++] = BinaryData.FromBytes(chunk); } } + public string PartitionKey { get; set; } = GetPartitionKey(); public string RowKey { get; set; } = null!; public DateTimeOffset? Timestamp { get; set; } @@ -149,9 +153,35 @@ public WorkDataEntity(BinaryData data) : this() /// private static string GetPartitionKey() => (ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null) ?? "default"; + /// + /// Creates the tables if they do not already exist. + /// + private async Task CreateIfNotExistsAsync(CancellationToken cancellationToken) + { + if (_firstTime) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_firstTime) + { + await _workDataTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); + await _workStateTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); + _firstTime = false; + } + } + finally + { + _semaphore.Release(); + } + } + } + /// public async Task GetAsync(string id, CancellationToken cancellationToken) { + await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); + var er = await _workStateTableClient.GetEntityIfExistsAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); if (!er.HasValue) return null; @@ -182,12 +212,16 @@ public WorkDataEntity(BinaryData data) : this() /// /// Performs an upsert (create/update). /// - private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken) - => await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false); + private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken) + { + await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); + await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false); + } /// public async Task DeleteAsync(string id, CancellationToken cancellationToken) { + await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); await _workDataTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); await _workStateTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -195,12 +229,17 @@ public async Task DeleteAsync(string id, CancellationToken cancellationToken) /// public async Task GetDataAsync(string id, CancellationToken cancellationToken) { + await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); + var er = await _workDataTableClient.GetEntityIfExistsAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); return er.HasValue ? er.Value!.ToSingleData() : null; } /// - public Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken) - => _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken); + public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken) + { + await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); + await _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken).ConfigureAwait(false); + } } } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs b/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs index d63468fd..d8e12852 100644 --- a/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs +++ b/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs @@ -35,7 +35,7 @@ public static IServiceCollection AddSqlServerEventOutboxHostedService(this IServ var hc = healthCheck ? new TimerHostedServiceHealthCheck() : null; if (hc is not null) { - var sb = new StringBuilder("EventOutbox"); + var sb = new StringBuilder("sql-server-event-outbox"); if (partitionKey is not null) sb.Append($"-PartitionKey-{partitionKey}"); diff --git a/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs b/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs index 95df7e80..14a26b82 100644 --- a/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs +++ b/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs @@ -22,7 +22,20 @@ public static class DatabaseServiceCollectionExtensions public static IServiceCollection AddDatabase(this IServiceCollection services, Func create, bool healthCheck = true) { services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated.")); - return AddHealthCheck(services, healthCheck); + return AddHealthCheck(services, healthCheck, null); + } + + /// + /// Adds an as a scoped service including a corresponding health check. + /// + /// The . + /// The function to create the instance. + /// The health check name; defaults to 'database'. + /// The to support fluent-style method-chaining. + public static IServiceCollection AddDatabase(this IServiceCollection services, Func create, string? healthCheckName) + { + services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated.")); + return AddHealthCheck(services, true, healthCheckName); } /// @@ -35,16 +48,29 @@ public static IServiceCollection AddDatabase(this IServiceCollection services, F public static IServiceCollection AddDatabase(this IServiceCollection services, bool healthCheck = true) where TDb : class, IDatabase { services.AddScoped(); - return AddHealthCheck(services, healthCheck); + return AddHealthCheck(services, healthCheck, null); + } + + /// + /// Adds an as a scoped service including a corresponding health check. + /// + /// The . + /// The . + /// The health check name; defaults to 'database'. + /// The to support fluent-style method-chaining. + public static IServiceCollection AddDatabase(this IServiceCollection services, string? healthCheckName) where TDb : class, IDatabase + { + services.AddScoped(); + return AddHealthCheck(services, true, healthCheckName); } /// /// Adds the where configured to do so. /// - private static IServiceCollection AddHealthCheck(this IServiceCollection services, bool healthCheck) + private static IServiceCollection AddHealthCheck(this IServiceCollection services, bool healthCheck, string? healthCheckName) { if (healthCheck) - services.AddHealthChecks().AddTypeActivatedCheck>("Database", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30)); + services.AddHealthChecks().AddTypeActivatedCheck>(healthCheckName ?? "database", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30)); return services; } diff --git a/src/CoreEx.Validation/CollectionValidator.cs b/src/CoreEx.Validation/CollectionValidator.cs deleted file mode 100644 index 63401dfb..00000000 --- a/src/CoreEx.Validation/CollectionValidator.cs +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Results; -using CoreEx.Validation.Rules; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Provides collection validation. - /// - /// The collection . - /// The item . - public class CollectionValidator : ValidatorBase where TColl : class, IEnumerable - { - private ICollectionRuleItem? _item; - private Func, Task>? _additionalAsync; - - /// - /// Gets or sets the minimum count; - /// - public int MinCount { get; set; } - - /// - /// Gets or sets the maximum count. - /// - public int? MaxCount { get; set; } - - /// - /// Indicates whether the underlying collection items can be null. - /// - public bool AllowNullItems { get; set; } - - /// - /// Gets or sets the collection item validation configuration. - /// - public ICollectionRuleItem? Item - { - get => _item; - - set - { - if (value == null) - { - _item = value; - return; - } - - if (typeof(TItem) != value.ItemType) - throw new ArgumentException($"A CollectionRule TProperty ItemType '{typeof(TItem).Name}' must be the same as the Item {value.ItemType.Name}"); - - _item = value; - } - } - - /// - /// Gets or sets the friendly text name used in validation messages. - /// - /// Defaults to the formatted as sentence case where specified; otherwise, 'Value'. - public LText? Text { get; set; } - - /// - public override Task> ValidateAsync(TColl? value, ValidationArgs? args = null, CancellationToken cancellationToken = default) - { - value.ThrowIfNull(nameof(value)); - - return ValidationInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - args ??= new ValidationArgs(); - if (string.IsNullOrEmpty(args.FullyQualifiedEntityName)) - args.FullyQualifiedEntityName = Validation.ValueNameDefault; - - if (string.IsNullOrEmpty(args.FullyQualifiedEntityName)) - args.FullyQualifiedJsonEntityName = Validation.ValueNameDefault; - - var context = new ValidationContext(value, args); - - var i = 0; - var hasNullItem = false; - var hasItemErrors = false; - foreach (var item in value) - { - if (!AllowNullItems && item == null) - hasNullItem = true; - - // Validate and merge. - if (item != null && Item?.ItemValidator != null) - { - var name = $"[{i}]"; - var ic = new PropertyContext(context, item, name, name); - var ia = ic.CreateValidationArgs(); - var ir = await Item.ItemValidator.ValidateAsync(item, ia, cancellationToken).ConfigureAwait(false); - context.MergeResult(ir); - if (context.FailureResult is not null) - return context; - - if (ir.HasErrors) - hasItemErrors = true; - } - - i++; - } - - var text = new Lazy(() => Text ?? args?.FullyQualifiedEntityName.ToSentenceCase() ?? Validation.ValueTextDefault!); - if (hasNullItem) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.CollectionNullItemFormat, [text.Value, null]); - - // Check the length/count. - if (i < MinCount) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.MinCountFormat, [text.Value, null, MinCount]); - else if (MaxCount.HasValue && i > MaxCount.Value) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.MaxCountFormat, [text.Value, null, MaxCount]); - - // Check for duplicates. - if (!hasItemErrors && Item != null) - { - var pctx = new PropertyContext(text.Value, context, value); - Item.DuplicateValidation(pctx, context.Value); - } - - if (context.FailureResult is not null) - return context; - - var result = await OnValidateAsync(context).ConfigureAwait(false); - if (result.IsSuccess && _additionalAsync != null) - result = await _additionalAsync(context).ConfigureAwait(false); - - context.SetFailureResult(result); - return context; - }, cancellationToken); - } - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added by the inheriting classes. - /// - /// The . - /// The corresponding . - protected virtual Task OnValidateAsync(ValidationContext context) => Task.FromResult(Result.Success); - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added. - /// - /// The asynchronous function to invoke. - /// The . - public CollectionValidator AdditionalAsync(Func, Task> additionalAsync) - { - if (_additionalAsync != null) - throw new InvalidOperationException("Additional can only be defined once for a CollectionValidator."); - - _additionalAsync = additionalAsync.ThrowIfNull(nameof(additionalAsync)); - return this; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/CommonValidator.cs b/src/CoreEx.Validation/CommonValidator.cs index a8198551..8e35c30e 100644 --- a/src/CoreEx.Validation/CommonValidator.cs +++ b/src/CoreEx.Validation/CommonValidator.cs @@ -5,20 +5,16 @@ namespace CoreEx.Validation { /// - /// Provides access to the common validator capabilities. + /// Provides access to the common value validator capabilities. /// public static class CommonValidator { /// - /// Creates a new instance of the . + /// Create a new instance of the . /// - /// An action with the . + /// An action with the to enable further configuration. /// The . - public static CommonValidator Create(Action> validator) - { - var cv = new CommonValidator(); - validator?.Invoke(cv); - return cv; - } + /// This is a synonym for the . + public static CommonValidator Create(Action>? configure = null) => new CommonValidator().Configure(configure); } } \ No newline at end of file diff --git a/src/CoreEx.Validation/CommonValidatorT.cs b/src/CoreEx.Validation/CommonValidatorT.cs index 468e55cf..3c028f66 100644 --- a/src/CoreEx.Validation/CommonValidatorT.cs +++ b/src/CoreEx.Validation/CommonValidatorT.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Abstractions.Reflection; using CoreEx.Localization; using CoreEx.Results; using System; @@ -23,10 +22,22 @@ public class CommonValidator : PropertyRuleBase, T>, IVali /// public CommonValidator() : base(Validation.ValueNameDefault) { } + /// + /// Enables the validator to be further configured. + /// + /// An action with the to enable further configuration. + /// The validator to support fluent-style method-chaining. + public CommonValidator Configure(Action>? validator) + { + validator?.Invoke(this); + return this; + } + /// + /// This method is not supported and as such will throw a . /// public override Task, T>> ValidateAsync(CancellationToken cancellationToken = default) - => throw new NotSupportedException("The ValidateAsync method is not supported for a CommonValueRule."); + => throw new NotSupportedException("The ValidateAsync(CancellationToken) method is not supported for a CommonValidator as no value has been specified; please use other overloads."); /// /// Validates the value. @@ -86,7 +97,7 @@ internal async Task ValidateAsync(PropertyContext context, var vc = new ValidationContext>(vv, new ValidationArgs { Config = context.Parent.Config, - SelectedPropertyName = context.Name, + SelectedPropertyName = context.Parent.SelectedPropertyName, FullyQualifiedEntityName = context.Parent.FullyQualifiedEntityName, FullyQualifiedJsonEntityName = context.Parent.FullyQualifiedJsonEntityName, UseJsonNames = context.UseJsonName diff --git a/src/CoreEx.Validation/DictionaryValidator.cs b/src/CoreEx.Validation/DictionaryValidator.cs deleted file mode 100644 index f4d75d5d..00000000 --- a/src/CoreEx.Validation/DictionaryValidator.cs +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Results; -using CoreEx.Validation.Rules; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Provides dictionary validation. - /// - /// The dictionary . - /// The key . - /// The value . - public class DictionaryValidator : ValidatorBase - where TDict : class, IDictionary - { - private IDictionaryRuleItem? _item; - private Func, CancellationToken, Task>? _additionalAsync; - - /// - /// Indicates whether the underlying dictionary key can be null. - /// - public bool AllowNullKeys { get; set; } - - /// - /// Indicates whether the underlying dictionary value can be null. - /// - public bool AllowNullValues { get; set; } - - /// - /// Gets or sets the minimum count; - /// - public int MinCount { get; set; } - - /// - /// Gets or sets the maximum count. - /// - public int? MaxCount { get; set; } - - /// - /// Gets or sets the collection item validation configuration. - /// - public IDictionaryRuleItem? Item - { - get => _item; - - set - { - if (value == null) - { - _item = value; - return; - } - - if (typeof(TKey) != value.KeyType) - throw new ArgumentException($"A CollectionRule TProperty Key Type '{typeof(TKey).Name}' must be the same as the Key {value.KeyType.Name}."); - - if (typeof(TValue) != value.ValueType) - throw new ArgumentException($"A CollectionRule TProperty Value Type '{typeof(TValue).Name}' must be the same as the Value {value.ValueType.Name}."); - - _item = value; - } - } - - /// - /// Gets or sets the friendly text name used in validation messages. - /// - /// Defaults to the formatted as sentence case where specified; otherwise, 'Value'. - public LText? Text { get; set; } - - /// - public override Task> ValidateAsync(TDict? value, ValidationArgs? args = null, CancellationToken cancellationToken = default) - { - value.ThrowIfNull(nameof(value)); - - return ValidationInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - args ??= new ValidationArgs(); - if (string.IsNullOrEmpty(args.FullyQualifiedEntityName)) - args.FullyQualifiedEntityName = Validation.ValueNameDefault; - - if (string.IsNullOrEmpty(args.FullyQualifiedEntityName)) - args.FullyQualifiedJsonEntityName = Validation.ValueNameDefault; - - var context = new ValidationContext(value, args); - - var i = 0; - var hasNullKey = false; - var hasNullValue = false; - foreach (var item in value) - { - i++; - - if (!AllowNullKeys && item.Key == null) - hasNullKey = true; - - if (!AllowNullValues && item.Value == null) - hasNullValue = true; - - if (Item?.KeyValidator == null && Item?.ValueValidator == null) - continue; - - // Validate and merge. - var name = $"[{item.Key}]"; - - if (item.Key != null && Item?.KeyValidator != null) - { - var kc = new PropertyContext>(context, item, name, name); - var ka = kc.CreateValidationArgs(); - var kr = await Item.KeyValidator.ValidateAsync(item.Key, ka, cancellationToken).ConfigureAwait(false); - context.MergeResult(kr); - if (context.FailureResult is not null) - return context; - } - - if (item.Value != null && Item?.ValueValidator != null) - { - var vc = new PropertyContext>(context, item, name, name); - var va = vc.CreateValidationArgs(); - var vr = await Item.ValueValidator.ValidateAsync(item.Value, va, cancellationToken).ConfigureAwait(false); - context.MergeResult(vr); - if (context.FailureResult is not null) - return context; - } - } - - var text = new Lazy(() => Text ?? args?.FullyQualifiedEntityName.ToSentenceCase() ?? Validation.ValueTextDefault); - if (hasNullKey) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.DictionaryNullKeyFormat, [text.Value, null]); - - if (hasNullValue) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.DictionaryNullValueFormat, [text.Value, null]); - - // Check the length/count. - if (i < MinCount) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.MinCountFormat, [text.Value, null, MinCount]); - else if (MaxCount.HasValue && i > MaxCount.Value) - context.AddMessage(Entities.MessageType.Error, ValidatorStrings.MaxCountFormat, [text.Value, null, MaxCount]); - - if (context.FailureResult is not null) - return context; - - var result = await OnValidateAsync(context, cancellationToken).ConfigureAwait(false); - if (result.IsSuccess && _additionalAsync != null) - result = await _additionalAsync(context, cancellationToken).ConfigureAwait(false); - - context.SetFailureResult(result); - return context; - }, cancellationToken); - } - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added by the inheriting classes. - /// - /// The . - /// The . - /// The corresponding . - protected virtual Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) => Task.FromResult(Result.Success); - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added. - /// - /// The asynchronous function to invoke. - /// The . - public DictionaryValidator AdditionalAsync(Func, CancellationToken, Task> additionalAsync) - { - if (_additionalAsync != null) - throw new InvalidOperationException("Additional can only be defined once for a DictionaryValidator."); - - _additionalAsync = additionalAsync.ThrowIfNull(nameof(additionalAsync)); - return this; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IPropertyRule.cs b/src/CoreEx.Validation/IPropertyRule.cs index 6f8d57a4..38509998 100644 --- a/src/CoreEx.Validation/IPropertyRule.cs +++ b/src/CoreEx.Validation/IPropertyRule.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Localization; +using System; using System.Threading; using System.Threading.Tasks; @@ -36,6 +37,7 @@ public interface IPropertyRule /// /// The . /// A . + /// This may not be supported by all implementations; in which case a may be thrown. Task ValidateAsync(CancellationToken cancellationToken = default); /// @@ -44,6 +46,7 @@ public interface IPropertyRule /// Indicates whether to automatically throw a where . /// >The . /// A . + /// This may not be supported by all implementations; in which case a may be thrown. public async Task ValidateAsync(bool throwOnError, CancellationToken cancellationToken = default) { var ir = await ValidateAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/CoreEx.Validation/PropertyRule.cs b/src/CoreEx.Validation/PropertyRule.cs index 36f73b4d..231b743c 100644 --- a/src/CoreEx.Validation/PropertyRule.cs +++ b/src/CoreEx.Validation/PropertyRule.cs @@ -79,6 +79,8 @@ public async Task ValidateAsync(ValidationContext context, Cancellation Task IValueRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) => throw new NotSupportedException("A property value validation should not occur directly on a PropertyRule."); /// + /// This method is not supported and as such will throw a . + /// public override Task> ValidateAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException("The ValidateAsync method is not supported for a PropertyRule."); } } \ No newline at end of file diff --git a/src/CoreEx.Validation/README.md b/src/CoreEx.Validation/README.md index efe6bf2f..5e1a1cdb 100644 --- a/src/CoreEx.Validation/README.md +++ b/src/CoreEx.Validation/README.md @@ -120,7 +120,9 @@ Extension method | Description | Underlying rule `Mandatory()` | Adds a *mandatory* validation. | `MandatoryRule` `Matches()` | Adds a `Regex` validation. | `StringRule` `MaximumLength()` | Adds a `string` maximum length validation. | `StringRule` +`MaximumCount()` | Adds an `ICollection` maximum count validation. | `CollectionRule` `MinimumLength()` | Adds a `string` minimum length validation. | `StringRule` +`MinimumCount()` | Adds an `ICollection` minimum count validation. | `CollectionRule` `Must()` | Adds a *must* validation. | `MustRule` `NotEmpty()` | Adds a *mandatory* validation. | `MandatoryRule` `NotEqual()` | Adds a *not equal value comparison* validation. | `CompareValueRule` diff --git a/src/CoreEx.Validation/ReferenceDataValidation.cs b/src/CoreEx.Validation/ReferenceDataValidation.cs index 6dd08ea0..164d5387 100644 --- a/src/CoreEx.Validation/ReferenceDataValidation.cs +++ b/src/CoreEx.Validation/ReferenceDataValidation.cs @@ -23,5 +23,10 @@ public static class ReferenceDataValidation /// Gets or sets the maximum length for the . /// public static int MaxDescriptionLength { get; set; } = 1000; + + /// + /// Indicates whether the is supported. + /// + public static bool SupportsDescription { get; set; } = false; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/ReferenceDataValidator.cs b/src/CoreEx.Validation/ReferenceDataValidator.cs index a1eae729..c6dec006 100644 --- a/src/CoreEx.Validation/ReferenceDataValidator.cs +++ b/src/CoreEx.Validation/ReferenceDataValidator.cs @@ -20,7 +20,8 @@ public ReferenceDataValidator() Property(x => x.Id).Mandatory().Custom(ValidateId); Property(x => x.Code).Mandatory().String(ReferenceDataValidation.MaxCodeLength); Property(x => x.Text).Mandatory().String(ReferenceDataValidation.MaxTextLength); - Property(x => x.Description).String(ReferenceDataValidation.MaxDescriptionLength); + Property(x => x.Description).String(ReferenceDataValidation.MaxDescriptionLength).When(() => ReferenceDataValidation.SupportsDescription); + Property(x => x.Description).Empty().When(() => !ReferenceDataValidation.SupportsDescription); Property(x => x.EndDate).When(x => x.StartDate.HasValue && x.EndDate.HasValue).CompareProperty(CompareOperator.GreaterThanEqual, x => x.StartDate); } diff --git a/src/CoreEx.Validation/Rules/CollectionRule.cs b/src/CoreEx.Validation/Rules/CollectionRule.cs index 7f4d799f..1695bb5a 100644 --- a/src/CoreEx.Validation/Rules/CollectionRule.cs +++ b/src/CoreEx.Validation/Rules/CollectionRule.cs @@ -73,6 +73,17 @@ protected override async Task ValidateAsync(PropertyContext if (context.Value == null) return; + // Where only validating count on an icollection do it quickly and exit. + if (AllowNullItems && Item is null && context.Value is ICollection coll) + { + if (coll.Count < MinCount) + context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MinCountFormat, MinCount); + else if (MaxCount.HasValue && coll.Count > MaxCount.Value) + context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxCountFormat, MaxCount); + + return; + } + // Iterate through the collection validating each of the items. var i = 0; var hasNullItem = false; diff --git a/src/CoreEx.Validation/Rules/CommonRule.cs b/src/CoreEx.Validation/Rules/CommonRule.cs index df094595..9009b5f2 100644 --- a/src/CoreEx.Validation/Rules/CommonRule.cs +++ b/src/CoreEx.Validation/Rules/CommonRule.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/CoreEx.Validation/Rules/EntityRule.cs b/src/CoreEx.Validation/Rules/EntityRule.cs index b70fa71d..37863181 100644 --- a/src/CoreEx.Validation/Rules/EntityRule.cs +++ b/src/CoreEx.Validation/Rules/EntityRule.cs @@ -34,6 +34,12 @@ protected override async Task ValidateAsync(PropertyContext if (context.Value == null) return; + if (Validator is CommonValidator vp) // Common validators need the originating context for best results. + { + await vp.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + // Create the context args. var args = context.CreateValidationArgs(); diff --git a/src/CoreEx.Validation/Rules/InteropRule.cs b/src/CoreEx.Validation/Rules/InteropRule.cs index 306f750f..0059deee 100644 --- a/src/CoreEx.Validation/Rules/InteropRule.cs +++ b/src/CoreEx.Validation/Rules/InteropRule.cs @@ -15,7 +15,7 @@ namespace CoreEx.Validation.Rules public class InteropRule : ValueRuleBase where TEntity : class where TProperty : class? where TValidator : IValidator { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The function to return the . public InteropRule(Func validatorFunc) @@ -45,6 +45,12 @@ protected override async Task ValidateAsync(PropertyContext return; var v = ValidatorFunc() ?? throw new InvalidOperationException($"The {nameof(ValidatorFunc)} must return a non-null value."); + if (v is CommonValidator cv) // Common validators need the originating context for best results. + { + await cv.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + if (v is IValidatorEx vex) // Use the "better" validator to enable. { // Create the context args. diff --git a/src/CoreEx.Validation/ValidationExtensions.cs b/src/CoreEx.Validation/ValidationExtensions.cs index 51c2c34f..7b232cb8 100644 --- a/src/CoreEx.Validation/ValidationExtensions.cs +++ b/src/CoreEx.Validation/ValidationExtensions.cs @@ -1298,10 +1298,10 @@ public static ReferenceDataCodeRuleAs RefDataCode(this IProper #region Collection /// - /// Adds a collection () validation (see ). + /// Adds a collection () validation (see ) where the can be specified. /// /// The entity . - /// + /// The property . /// The being extended. /// The minimum count. /// The maximum count. @@ -1314,15 +1314,55 @@ public static IPropertyRule Collection(t return rule.ThrowIfNull(nameof(rule)).AddRule(cr); } + /// + /// Adds a collection () validation (see ) for the specified . + /// + /// The entity . + /// The property . + /// The property item . + /// The being extended. + /// The property item . + /// The minimum count. + /// The maximum count. + /// Indicates whether the underlying collection item must not be null. + /// A . + public static IPropertyRule Collection(this IPropertyRule rule, IValidatorEx itemValidator, int minCount = 0, int? maxCount = null, bool allowNullItems = false) where TEntity : class where TProperty : IEnumerable? + { + var cr = new CollectionRule { MinCount = minCount, MaxCount = maxCount, Item = CollectionRuleItem.Create(itemValidator), AllowNullItems = allowNullItems }; + return rule.ThrowIfNull(nameof(rule)).AddRule(cr); + } + + /// + /// Adds a collection () minimum count validation (see ). + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum count. + /// A . + public static IPropertyRule MinimumCount(this IPropertyRule rule, int minCount) where TEntity : class where TProperty : System.Collections.ICollection? + => Collection(rule, minCount, null, null, true); + + /// + /// Adds a collection () maximum count validation (see ). + /// + /// The entity . + /// The property . + /// The being extended. + /// The maximum count. + /// A . + public static IPropertyRule MaximumCount(this IPropertyRule rule, int maxCount) where TEntity : class where TProperty : System.Collections.ICollection? + => Collection(rule, 0, maxCount, null, true); + #endregion #region Dictionary /// - /// Adds a dictionary () validation (see ). + /// Adds a dictionary () validation (see ) where the can be specified. /// /// The entity . - /// + /// The property . /// The being extended. /// The minimum count. /// The maximum count. @@ -1336,6 +1376,27 @@ public static IPropertyRule Dictionary(t return rule.ThrowIfNull(nameof(rule)).AddRule(cr); } + /// + /// Adds a dictionary () validation (see ) for the specified and . + /// + /// The entity . + /// The property . + /// The key . + /// The value . + /// The being extended. + /// The key . + /// The value . + /// The minimum count. + /// The maximum count. + /// Indicates whether the underlying dictionary keys must not be null. + /// Indicates whether the underlying dictionary values must not be null. + /// A . + public static IPropertyRule Dictionary(this IPropertyRule rule, IValidatorEx? keyValidator, IValidatorEx? valueValidator, int minCount = 0, int? maxCount = null, bool allowNullKeys = false, bool allowNullValues = false) where TEntity : class where TProperty : Dictionary? where TKey : notnull + { + var cr = new DictionaryRule { MinCount = minCount, MaxCount = maxCount, Item = DictionaryRuleItem.Create(keyValidator, valueValidator), AllowNullKeys = allowNullKeys, AllowNullValues = allowNullValues }; + return rule.ThrowIfNull(nameof(rule)).AddRule(cr); + } + #endregion #region Entity @@ -1531,6 +1592,40 @@ public static IPropertyRule Default(this #endregion + #region As + + /// + /// Cast the to the originating . + /// + /// The entity . + /// The property . + /// The being extended. + /// The cast to a ; otherwise, throws an . + public static CommonValidator AsCommonValidator(this IPropertyRule rule) where TEntity : class + => rule is CommonValidator cv ? cv : throw new InvalidCastException("The rule is not an instance of CommonValidator."); + + /// + /// Cast the to the originating . + /// + /// The entity . + /// The property . + /// The being extended. + /// The cast to a ; otherwise, throws an . + public static ValueValidator AsValueValidator(this IPropertyRule rule) where TEntity : class + => rule is ValueValidator vv ? vv : throw new InvalidCastException("The rule is not an instance of ValueValidator."); + + /// + /// Cast the to the originating . + /// + /// The entity . + /// The property . + /// The being extended. + /// The cast to a ; otherwise, throws an . + public static ValidatorBase AsValidator(this IPropertyRule rule) where TEntity : class + => rule is ValidatorBase vb ? vb : throw new InvalidCastException("The rule is not an instance of ValidatorBase."); + + #endregion + #region MultiValidator /// diff --git a/src/CoreEx.Validation/Validator.cs b/src/CoreEx.Validation/Validator.cs index 2569e260..2875ea14 100644 --- a/src/CoreEx.Validation/Validator.cs +++ b/src/CoreEx.Validation/Validator.cs @@ -3,6 +3,7 @@ using CoreEx.Validation.Rules; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections; using System.Collections.Generic; namespace CoreEx.Validation @@ -13,14 +14,14 @@ namespace CoreEx.Validation public static class Validator { /// - /// Creates a . + /// Create a . /// /// The entity . /// A . public static Validator Create() where TEntity : class => new(); /// - /// Creates (or gets) an instance of the validator. + /// Create (or get) an instance of the pre-registered validator. /// /// The validator . /// The ; defaults to where not specified. @@ -30,39 +31,88 @@ public static TValidator Create(IServiceProvider? serviceProvider = ?? throw new InvalidOperationException($"Attempted to get service '{typeof(TValidator).FullName}' but null was returned; this would indicate that the service has not been configured correctly."); /// - /// Creates a . + /// Create value validator (see ). + /// + /// The value . + /// An action with the to enable further configuration. + /// The . + /// This is a synonym for the . + public static CommonValidator CreateFor(Action>? validator = null) => CommonValidator.Create(validator); + + /// + /// Create a collection-based where the can be specified. /// /// The collection . - /// The item . /// The minimum count. /// The maximum count. /// The item configuration. /// Indicates whether the underlying collection item must not be null. - /// The . - public static CollectionValidator CreateCollection(int minCount = 0, int? maxCount = null, ICollectionRuleItem? item = null, bool allowNullItems = false) where TColl : class, IEnumerable => - new() { MinCount = minCount, MaxCount = maxCount, Item = item, AllowNullItems = allowNullItems }; + /// The for the collection. + public static CommonValidator CreateForCollection(int minCount = 0, int? maxCount = null, ICollectionRuleItem? item = null, bool allowNullItems = false) where TColl : class, IEnumerable? + => CreateFor(v => v.Collection(minCount, maxCount, item, allowNullItems)); + + /// + /// Create a collection-based for the specified . + /// + /// The collection . + /// The item . + /// The item . + /// The minimum count. + /// The maximum count. + /// Indicates whether the underlying collection item must not be null. + /// The for the collection. + public static CommonValidator CreateForCollection(IValidatorEx itemValidator, int minCount = 0, int? maxCount = null, bool allowNullItems = false) where TColl : class, IEnumerable? + => CreateFor(v => v.Collection(itemValidator, minCount, maxCount, allowNullItems)); /// - /// Creates a . + /// Create a dictionary-based where the can be specified. + /// + /// The dictionary . + /// The minimum count. + /// The maximum count. + /// The item configuration. + /// Indicates whether the underlying dictionary key can be null. + /// Indicates whether the underlying dictionary value can be null. + /// The for the dictionary. + public static CommonValidator CreateForDictionary(int minCount = 0, int? maxCount = null, IDictionaryRuleItem? item = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : class, IDictionary + => CreateFor(v => v.Dictionary(minCount, maxCount, item, allowNullKeys, allowNullValues)); + + /// + /// Create a dictionary-based for the specified and . /// /// The dictionary . /// The key . /// The value . + /// The key . + /// The value . /// The minimum count. /// The maximum count. - /// The item configuration. /// Indicates whether the underlying dictionary key can be null. /// Indicates whether the underlying dictionary value can be null. - /// The . - public static DictionaryValidator CreateDictionary(int minCount = 0, int? maxCount = null, IDictionaryRuleItem? item = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : class, IDictionary => - new() { MinCount = minCount, MaxCount = maxCount, Item = item, AllowNullKeys = allowNullKeys, AllowNullValues = allowNullValues }; + /// The for the dictionary. + public static CommonValidator CreateForDictionary(IValidatorEx? keyValidator, IValidatorEx? valueValidator, int minCount = 0, int? maxCount = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : Dictionary? where TKey : notnull + => CreateFor(v => v.Dictionary(keyValidator, valueValidator, minCount, maxCount, allowNullKeys, allowNullValues)); /// - /// Creates a . + /// Create a dictionary-based for the specified . /// - /// The value . - /// An action with the . - /// The . - public static CommonValidator CreateCommon(Action> validator) => CommonValidator.Create(validator); + /// The dictionary . + /// The key . + /// The value . + /// The value . + /// The minimum count. + /// The maximum count. + /// Indicates whether the underlying dictionary key can be null. + /// Indicates whether the underlying dictionary value can be null. + /// The for the dictionary. + public static CommonValidator CreateForDictionary(IValidatorEx? valueValidator, int minCount = 0, int? maxCount = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : Dictionary? where TKey : notnull + => CreateFor(v => v.Dictionary((IValidatorEx?)null, valueValidator, minCount, maxCount, allowNullKeys, allowNullValues)); + + /// + /// Creates a null . + /// + /// The . + /// A null ; i.e. simply null. + public static IValidatorEx? Null() => null; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValueValidator.cs b/src/CoreEx.Validation/ValueValidator.cs index 32255c47..736c399b 100644 --- a/src/CoreEx.Validation/ValueValidator.cs +++ b/src/CoreEx.Validation/ValueValidator.cs @@ -11,15 +11,22 @@ namespace CoreEx.Validation /// Enables validation for a value. /// /// The value . - /// The value to validate. - /// The value name (defaults to ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - public class ValueValidator(T value, string? name = null, LText? text = null) : PropertyRuleBase, T>(string.IsNullOrEmpty(name) ? Validation.ValueNameDefault : name, text) + /// This exists to enable . + public class ValueValidator : PropertyRuleBase, T> { + /// + /// Initializes a new instance of the class. + /// + /// The value to validate. + /// The value name (defaults to ). + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + internal ValueValidator(T value, string? name = null, LText? text = null) : base(string.IsNullOrEmpty(name) ? Validation.ValueNameDefault : name, text) + => ValidationValue = new ValidationValue(null, value); + /// /// Gets the . /// - public ValidationValue ValidationValue { get; } = new ValidationValue(null, value); + public ValidationValue ValidationValue { get; } /// /// Gets the value. diff --git a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs index 5f012133..2482fe0e 100644 --- a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs +++ b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs @@ -288,12 +288,13 @@ public static IServiceCollection AddEventDataSerializer(this IServiceCollection /// The . /// The function to create the . /// Indicates whether a corresponding should be configured. + /// The health check name; defaults to 'reference-data-orchestrator'. /// The . - public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, Func createOrchestrator, bool healthCheck = true) + public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, Func createOrchestrator, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") { CheckServices(services).AddSingleton(sp => createOrchestrator(sp)); if (healthCheck) - services.AddHealthChecks().AddTypeActivatedCheck(nameof(ReferenceDataOrchestrator)); + services.AddHealthChecks().AddTypeActivatedCheck(healthCheckName ?? "reference-data-orchestrator"); return services; } @@ -303,9 +304,10 @@ public static IServiceCollection AddReferenceDataOrchestrator(this IServiceColle /// /// The . /// Indicates whether a corresponding should be configured. + /// The health check name; defaults to 'reference-data-orchestrator'. /// The . - public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true) - => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp).Register(), healthCheck); + public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") + => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp).Register(), healthCheck, healthCheckName); /// /// Adds the using a as a singleton service automatically registering the specified (see ). @@ -313,9 +315,10 @@ public static IServiceCollection AddReferenceDataOrchestrator(this IServiceColle /// The to register. /// The . /// Indicates whether a corresponding should be configured. + /// The health check name; defaults to 'reference-data-orchestrator'. /// The . - public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true) where TProvider : IReferenceDataProvider - => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp).Register(), healthCheck); + public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") where TProvider : IReferenceDataProvider + => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp).Register(), healthCheck, healthCheckName); /// /// Adds the as the scoped service. @@ -384,7 +387,7 @@ public static IServiceCollection AddWorkStateOrchestrator(this IServiceCollectio /// Adds an that will publish and send a health-check (message). /// /// The . - /// The health check name. Defaults to 'EventPublisher'. + /// The health check name. Defaults to 'event-publisher'. /// The factory. /// The optional action to configure the . /// The that should be reported when the health check reports a failure. If the provided value is null, then will be reported. @@ -396,7 +399,7 @@ public static IHealthChecksBuilder AddEventPublisherHealthCheck(this IHealthChec { eventPublisherFactory.ThrowIfNull(nameof(eventPublisherFactory)); - return builder.Add(new HealthCheckRegistration(name ?? "EventPublisher",sp => + return builder.Add(new HealthCheckRegistration(name ?? "event-publisher", sp => { var ep = (eventPublisherFactory is null ? sp.GetService() : eventPublisherFactory(sp)) ?? throw new InvalidOperationException("An IEventPublisher was either not registered or the factory returned null."); var hc = new EventPublisherHealthCheck(ep); diff --git a/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs b/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs index 82ca3a24..0639a11a 100644 --- a/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs +++ b/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs @@ -35,8 +35,7 @@ public abstract class SubscriberBase(IValidator? valueValidator protected IValidator? ValueValidator { get; set; } = valueValidator; /// - /// Caution where overridding this method as it contains the underlying functionality to invoke that is the required method to be overridden. - public async override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) + public async sealed override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) { return await Result.Go(@event.ThrowIfNull(nameof(@event))) .When(ed => ValueIsRequired && ed.Value is null, _ => Result.ValidationError(EventSubscriberBase.RequiredErrorText)) diff --git a/src/CoreEx/Hosting/FileLockSynchronizer.cs b/src/CoreEx/Hosting/FileLockSynchronizer.cs index c2025495..6bfef6f9 100644 --- a/src/CoreEx/Hosting/FileLockSynchronizer.cs +++ b/src/CoreEx/Hosting/FileLockSynchronizer.cs @@ -13,35 +13,24 @@ namespace CoreEx.Hosting /// /// A lock file is created per with a name of and extension of '.lock'; e.g. 'Namespace.Class.lock'. For this to function correctly all running /// instances must be referencing the same shared directory as specified by the (see ). - public class FileLockSynchronizer : IServiceSynchronizer + /// The . + public class FileLockSynchronizer(SettingsBase settings) : IServiceSynchronizer { /// /// Gets the configuration key that defines the directory path for the exclusive lock files. /// public const string ConfigKey = "FileLockSynchronizerPath"; - private readonly string _path; + private readonly string _path = settings.ThrowIfNull(nameof(settings)).GetValue(ConfigKey) ?? throw new ArgumentException($"Configuration setting '{ConfigKey}' either does not exist or has no value.", nameof(settings)); private readonly ConcurrentDictionary _dict = new(); private bool _disposed; - /// - /// Initializes a new instance of the class. - /// - /// The . - public FileLockSynchronizer(SettingsBase settings) + /// + public bool Enter(string? name = null) { - _path = settings.ThrowIfNull(nameof(settings)).GetValue(ConfigKey); - - if (string.IsNullOrEmpty(_path)) - throw new ArgumentException($"Configuration setting '{ConfigKey}' either does not exist or has no value.", nameof(settings)); - if (!Directory.Exists(_path)) throw new ArgumentException($"Configuration setting '{ConfigKey}' path does not exist: {_path}"); - } - /// - public bool Enter(string? name = null) - { var fn = Path.Combine(_path, $"{typeof(T).FullName}{(name == null ? "" : $".{name}")}.lock"); try diff --git a/src/CoreEx/Results/ResultT.cs b/src/CoreEx/Results/ResultT.cs index b692ae34..e0adbd92 100644 --- a/src/CoreEx/Results/ResultT.cs +++ b/src/CoreEx/Results/ResultT.cs @@ -155,7 +155,7 @@ public Result Required(string? name = null, LText? text = null) public static explicit operator Result(Result result) => result.Bind(); /// - /// Implicityly converts a to a as . + /// Implicitly converts a to a as . /// /// The underlying value. public static implicit operator Result(T value) => Result.Ok(value); diff --git a/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs index 98b6f123..930f5f03 100644 --- a/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs @@ -13,8 +13,8 @@ public class CommonValidatorTest [OneTimeSetUp] public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - private static readonly CommonValidator _cv = Validator.CreateCommon(v => v.String(5).Must(x => x.Value != "XXXXX")); - private static readonly CommonValidator _cv2 = Validator.CreateCommon(v => v.Mandatory().CompareValue(CompareOperator.NotEqual, 1)); + private static readonly CommonValidator _cv = Validator.CreateFor(v => v.String(5).Must(x => x.Value != "XXXXX")); + private static readonly CommonValidator _cv2 = Validator.CreateFor(v => v.Mandatory().CompareValue(CompareOperator.NotEqual, 1)); [Test] public async Task Validate() @@ -122,7 +122,7 @@ public async Task Common_Nullable() [Test] public async Task Common_FailureResult_ViaAdditional() { - var cv = Validator.CreateCommon(v => v.String(5)).AdditionalAsync((c, _) => Task.FromResult(Result.NotFoundError())); + var cv = Validator.CreateFor(v => v.String(5)).AdditionalAsync((c, _) => Task.FromResult(Result.NotFoundError())); var r = await cv.ValidateAsync("abc"); Assert.That(r, Is.Not.Null); @@ -138,7 +138,7 @@ public async Task Common_FailureResult_ViaAdditional() [Test] public async Task Common_FailureResult_ViaCustom() { - var cv = Validator.CreateCommon(v => v.String(5).Custom(ctx => Result.NotFoundError())); + var cv = CommonValidator.Create(v => v.String(5).Custom(ctx => Result.NotFoundError())); var r = await cv.ValidateAsync("abc"); Assert.That(r, Is.Not.Null); @@ -154,7 +154,7 @@ public async Task Common_FailureResult_ViaCustom() [Test] public async Task Common_FailureResult_WithOwningValidator() { - var cv = Validator.CreateCommon(v => v.String(5).Custom(ctx => Result.NotFoundError())); + var cv = CommonValidator.Create(v => v.String(5).Custom(ctx => Result.NotFoundError())); var pv = Validator.Create().HasProperty(x => x.Name, p => p.Common(cv)); var p = new Person { Name = "abc" }; @@ -169,6 +169,23 @@ public async Task Common_FailureResult_WithOwningValidator() Assert.Throws(() => r.ThrowOnError()); } + [Test] + public async Task CreateFor() + { + var cv = Validator.CreateFor().Configure(v => v.MaximumLength(5)); + var r = await cv.ValidateAsync("abcdef"); + + Assert.That(r, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(r.HasErrors, Is.True); + Assert.That(r.Messages!, Has.Count.EqualTo(1)); + Assert.That(r.Messages![0].Text, Is.EqualTo("Value must not exceed 5 characters in length.")); + Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); + Assert.That(r.Messages[0].Property, Is.EqualTo("value")); + }); + } + public class Person { public string? Name { get; set; } diff --git a/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs index 5b888af4..64a54d4d 100644 --- a/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs @@ -7,7 +7,7 @@ namespace CoreEx.Test.Framework.Validation { - [TestFixture] + [TestFixture, NonParallelizable] public class ReferenceDataValidatorTest { [OneTimeSetUp] @@ -61,5 +61,51 @@ public async Task Validate_Dates() Assert.That(r.Messages[0].Property, Is.EqualTo("EndDate")); }); } + + [Test] + public async Task Validate_SupportsDescription() + { + ReferenceDataValidation.SupportsDescription = true; + var r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", Description = new string('x', 1001) }); + + Assert.That(r, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(r.HasErrors, Is.True); + Assert.That(r.Messages!, Has.Count.EqualTo(1)); + Assert.That(r.Messages![0].Text, Is.EqualTo("Description must not exceed 1000 characters in length.")); + Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); + Assert.That(r.Messages[0].Property, Is.EqualTo("Description")); + }); + + r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", Description = new string('x', 500) }); + Assert.That(r, Is.Not.Null); + Assert.That(r.HasErrors, Is.False); + + r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX" }); + Assert.That(r, Is.Not.Null); + Assert.That(r.HasErrors, Is.False); + } + + [Test] + public async Task Validate_NoSupportsDescription() + { + ReferenceDataValidation.SupportsDescription = false; + var r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", Description = "XXX" }); + + Assert.That(r, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(r.HasErrors, Is.True); + Assert.That(r.Messages!, Has.Count.EqualTo(1)); + Assert.That(r.Messages![0].Text, Is.EqualTo("Description must not be specified.")); + Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); + Assert.That(r.Messages[0].Property, Is.EqualTo("Description")); + }); + + r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX" }); + Assert.That(r, Is.Not.Null); + Assert.That(r.HasErrors, Is.False); + } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs index 18ed6c64..e644284b 100644 --- a/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs @@ -97,7 +97,7 @@ public async Task Validate_Item() [Test] public async Task Validate_ItemInt() { - var iv = Validator.CreateCommon(r => r.Text("Number").CompareValue(CompareOperator.LessThanEqual, 5)); + var iv = Validator.CreateFor(r => r.Text("Number").CompareValue(CompareOperator.LessThanEqual, 5)); var v1 = await new int[] { 1, 2, 3, 4, 5 }.Validate("value").Collection(item: CollectionRuleItem.Create(iv)).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); @@ -113,6 +113,25 @@ public async Task Validate_ItemInt() }); } + [Test] + public async Task Validate_ItemInt2() + { + var iv = Validator.CreateFor(r => r.Text("Number").LessThanOrEqualTo(5)); + + var v1 = await new int[] { 1, 2, 3, 4, 5 }.Validate("value").Collection(iv).ValidateAsync(); + Assert.That(v1.HasErrors, Is.False); + + v1 = await new int[] { 6, 2, 3, 4, 5 }.Validate("value").Collection(iv).ValidateAsync(); + Assert.Multiple(() => + { + Assert.That(v1.HasErrors, Is.True); + Assert.That(v1.Messages!, Has.Count.EqualTo(1)); + Assert.That(v1.Messages![0].Text, Is.EqualTo("Number must be less than or equal to 5.")); + Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); + Assert.That(v1.Messages[0].Property, Is.EqualTo("value[0]")); + }); + } + [Test] public async Task Validate_Item_Null() { @@ -263,12 +282,29 @@ public async Task Validate_Item_Duplicates_IgnoreInitial() } [Test] - public async Task Validate_Ints() + public async Task Validate_Ints_MinCount() + { + var v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").MinimumCount(4).ValidateAsync(); + Assert.That(v1.HasErrors, Is.False); + + v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").MinimumCount(5).ValidateAsync(); + Assert.Multiple(() => + { + Assert.That(v1.HasErrors, Is.True); + Assert.That(v1.Messages!, Has.Count.EqualTo(1)); + Assert.That(v1.Messages![0].Text, Is.EqualTo("Array must have at least 5 item(s).")); + Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); + Assert.That(v1.Messages[0].Property, Is.EqualTo("Array")); + }); + } + + [Test] + public async Task Validate_Ints2_MaxCount() { - var v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").Collection(maxCount: 5).ValidateAsync(); + var v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").MaximumCount(5).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").Collection(maxCount: 3).ValidateAsync(); + v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").MaximumCount(3).ValidateAsync(); Assert.Multiple(() => { Assert.That(v1.HasErrors, Is.True); diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs index b6553680..ab42a726 100644 --- a/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs @@ -43,11 +43,9 @@ public async Task Validate() v1 = await new Dictionary { { "k1", "v1" }, { "k2", "v2" }, { "k3", "v3" } }.Validate("Dict").Dictionary(maxCount: 3).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - //v1 = await ((int[])null).Validate().Collection(1).RunAsync(); v1 = await ((Dictionary?)null).Validate().Collection(1).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - //v1 = await new int[0].Validate().Collection(1).RunAsync(); v1 = await new Dictionary { }.Validate("Dict").Dictionary(1).ValidateAsync(); Assert.Multiple(() => { @@ -118,12 +116,12 @@ public async Task Validate_Ints() [Test] public async Task Validate_Key() { - var kv = Validator.CreateCommon(r => r.Text("Key").Mandatory().String(2)); + var kv = Validator.CreateFor(r => r.Text("Key").Mandatory().String(2)); - var v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 } }.Validate("Dict").Dictionary(item: DictionaryRuleItem.Create(kv)).ValidateAsync(); + var v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 } }.Validate("Dict").Dictionary(item: DictionaryRuleItem.Create(kv)).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - v1 = await new Dictionary { { "k1", 1 }, { "k2x", 2 } }.Validate("Dict").Dictionary(item: DictionaryRuleItem.Create(kv)).ValidateAsync(); + v1 = await new Dictionary { { "k1", 1 }, { "k2x", 2 } }.Validate("Dict").Dictionary(kv, Validator.Null()).ValidateAsync(); Assert.Multiple(() => { Assert.That(v1.HasErrors, Is.True); @@ -137,13 +135,13 @@ public async Task Validate_Key() [Test] public async Task Validate_KeyAndValue() { - var kv = Validator.CreateCommon(r => r.Text("Key").Mandatory().String(2)); - var vv = Validator.CreateCommon(r => r.Mandatory().CompareValue(CompareOperator.LessThanEqual, 10)); + var kv = Validator.CreateFor(r => r.Text("Key").Mandatory().String(2)); + var vv = Validator.CreateFor(r => r.Mandatory().CompareValue(CompareOperator.LessThanEqual, 10)); var v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 } }.Validate("Dict").Dictionary(item: DictionaryRuleItem.Create(kv, vv)).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - v1 = await new Dictionary { { "k1", 11 }, { "k2x", 2 } }.Validate("Dict").Dictionary(item: DictionaryRuleItem.Create(kv, vv)).ValidateAsync(); + v1 = await new Dictionary { { "k1", 11 }, { "k2x", 2 } }.Validate("Dict").Dictionary(kv, vv).ValidateAsync(); Assert.Multiple(() => { Assert.That(v1.HasErrors, Is.True); diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs index 65b81e6c..f35a7abb 100644 --- a/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs @@ -19,7 +19,7 @@ public class EntityRuleTest public async Task Validate() { var te = new TestEntity { Item = new TestItem() }; - var v1 = await te.Validate("value").Entity(_tev).ValidateAsync(); + var v1 = await te.Validate("entity", "EnTiTy").Entity(_tev).ValidateAsync(); Assert.Multiple(() => { @@ -27,7 +27,7 @@ public async Task Validate() Assert.That(v1.Messages!, Has.Count.EqualTo(1)); Assert.That(v1.Messages![0].Text, Is.EqualTo("Identifier is required.")); Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value.Item.Id")); + Assert.That(v1.Messages[0].Property, Is.EqualTo("entity.Item.Id")); }); } } diff --git a/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs index 7a922963..8f6e2d87 100644 --- a/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs @@ -489,7 +489,7 @@ public void Entity_ValueCachePerfAsync() [Test] public async Task Coll_Validator_MaxCount() { - var vxc = Validator.CreateCollection, TestItem>(minCount: 1, maxCount: 2, item: CollectionRuleItem.Create(new TestItemValidator())); + var vxc = Validator.CreateForCollection>(minCount: 1, maxCount: 2, item: CollectionRuleItem.Create(new TestItemValidator())); var tc = new List { new() { Id = "A", Text = "aaa" }, new() { Id = "B", Text = "bbb" }, new() { Id = "C", Text = "ccc" } }; var r = await vxc.ValidateAsync(tc); @@ -513,7 +513,25 @@ public async Task Coll_Validator_MaxCount() [Test] public async Task Coll_Validator_MinCount() { - var vxc = Validator.CreateCollection, TestItem>(minCount: 3, item: CollectionRuleItem.Create(new TestItemValidator())); + var vxc = Validator.CreateForCollection, TestItem>(new TestItemValidator(), minCount: 3); + var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "B", Text = "B" } }; + + var r = await vxc.ValidateAsync(tc); + + Assert.Multiple(() => + { + Assert.That(r.HasErrors, Is.True); + Assert.That(r.Messages!, Has.Count.EqualTo(1)); + Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); + Assert.That(r.Messages[0].Text, Is.EqualTo("Value must have at least 3 item(s).")); + Assert.That(r.Messages[0].Property, Is.EqualTo("value")); + }); + } + + [Test] + public async Task Coll_Validator_MinCount2() + { + var vxc = Validator.CreateFor>().Collection(new TestItemValidator(), minCount: 3).AsCommonValidator(); var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "B", Text = "B" } }; var r = await vxc.ValidateAsync(tc); @@ -531,7 +549,7 @@ public async Task Coll_Validator_MinCount() [Test] public async Task Coll_Validator_Duplicate() { - var vxc = Validator.CreateCollection, TestItem>(item: CollectionRuleItem.Create(new TestItemValidator()).DuplicateCheck(x => x.Id)); + var vxc = Validator.CreateForCollection>(item: CollectionRuleItem.Create(new TestItemValidator()).DuplicateCheck(x => x.Id)); var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "A", Text = "A" } }; var r = await vxc.ValidateAsync(tc); @@ -549,7 +567,7 @@ public async Task Coll_Validator_Duplicate() [Test] public async Task Coll_Validator_OK() { - var vxc = Validator.CreateCollection, TestItem>(minCount: 1, maxCount: 2, item: CollectionRuleItem.Create(new TestItemValidator()).DuplicateCheck(x => x.Id)); + var vxc = Validator.CreateForCollection>(minCount: 1, maxCount: 2, item: CollectionRuleItem.Create(new TestItemValidator()).DuplicateCheck(x => x.Id)); var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "B", Text = "B" } }; var r = await vxc.ValidateAsync(tc); @@ -560,7 +578,7 @@ public async Task Coll_Validator_OK() [Test] public async Task Coll_Validator_Int_OK() { - var vxc = Validator.CreateCollection, int>(minCount: 1, maxCount: 5); + var vxc = Validator.CreateForCollection>(minCount: 1, maxCount: 5); var ic = new List { 1, 2, 3, 4, 5 }; var r = await vxc.ValidateAsync(ic); @@ -571,7 +589,7 @@ public async Task Coll_Validator_Int_OK() [Test] public async Task Coll_Validator_Int_Error() { - var vxc = Validator.CreateCollection, int>(minCount: 1, maxCount: 3); + var vxc = Validator.CreateFor>(v => v.Collection(1, 3)); var ic = new List { 1, 2, 3, 4, 5 }; var r = await vxc.ValidateAsync(ic); @@ -589,7 +607,7 @@ public async Task Coll_Validator_Int_Error() [Test] public async Task Dict_Validator_MaxCount() { - var vxd = Validator.CreateDictionary, string, TestItem>(minCount: 1, maxCount: 2, item: DictionaryRuleItem.Create(value: new TestItemValidator())); + var vxd = Validator.CreateForDictionary>(minCount: 1, maxCount: 2, item: DictionaryRuleItem.Create(value: new TestItemValidator())); var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "aaa" } }, { "k2", new TestItem { Id = "B", Text = "bbb" } }, { "k3", new TestItem { Id = "C", Text = "ccc" } } }; var r = await vxd.ValidateAsync(tc); @@ -613,7 +631,7 @@ public async Task Dict_Validator_MaxCount() [Test] public async Task Dict_Validator_MinCount() { - var vxd = Validator.CreateDictionary, string, TestItem>(minCount: 3, item: DictionaryRuleItem.Create(value: new TestItemValidator())); + var vxd = Validator.CreateForDictionary>(minCount: 3, item: DictionaryRuleItem.Create(value: new TestItemValidator())); var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "A" } }, { "k2", new TestItem { Id = "B", Text = "B" } } }; var r = await vxd.ValidateAsync(tc); @@ -631,7 +649,7 @@ public async Task Dict_Validator_MinCount() [Test] public async Task Dict_Validator_OK() { - var vxd = Validator.CreateDictionary, string, TestItem>(minCount: 2, item: DictionaryRuleItem.Create(value: new TestItemValidator())); + var vxd = Validator.CreateForDictionary, string, TestItem>(new TestItemValidator(), minCount: 2); var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "A" } }, { "k2", new TestItem { Id = "B", Text = "B" } } }; var r = await vxd.ValidateAsync(tc); @@ -642,7 +660,7 @@ public async Task Dict_Validator_OK() [Test] public async Task Dict_Validator_Int_OK() { - var vxd = Validator.CreateDictionary, string, int>(minCount: 1, maxCount: 5); + var vxd = Validator.CreateForDictionary>(minCount: 1, maxCount: 5); var id = new Dictionary { { "k1", 1 }, { "k2", 2 }, { "k3", 3 }, { "k4", 4 }, { "k5", 5 } }; var r = await vxd.ValidateAsync(id); @@ -653,7 +671,7 @@ public async Task Dict_Validator_Int_OK() [Test] public async Task Dict_Validator_Int_Error() { - var vxd = Validator.CreateDictionary, string, int>(minCount: 1, maxCount: 3); + var vxd = Validator.CreateForDictionary>(minCount: 1, maxCount: 3); var id = new Dictionary { { "k1", 1 }, { "k2", 2 }, { "k3", 3 }, { "k4", 4 }, { "k5", 5 } }; var r = await vxd.ValidateAsync(id); @@ -671,9 +689,8 @@ public async Task Dict_Validator_Int_Error() [Test] public async Task Dict_Validator_KeyError() { - var kv = CommonValidator.Create(x => x.Text("Key").Mandatory().String(2)); - - var vxd = Validator.CreateDictionary, string, TestItem>(minCount: 2, item: DictionaryRuleItem.Create(key: kv, value: new TestItemValidator())); + var kv = CommonValidator.Create(x => x.Text("Key").Mandatory().String(2)); + var vxd = Validator.CreateForDictionary, string, TestItem>(kv, new TestItemValidator(), minCount: 2); var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "A" } }, { "k2x", new TestItem { Id = "B", Text = "B" } } }; var r = await vxd.ValidateAsync(tc);