I have the following model:
using System.Collections.Generic;
using Enpal.SalesforceCache.Utils.Swagger;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Newtonsoft.Json;
namespace Enpal.SalesforceCache.Model.Salesforce.Generic
{
public class SalesforceDataRequestParameters
{
[SwaggerIgnore]
[BsonId]
public ObjectId Id { get; set; }
[JsonProperty(PropertyName = "consumingApplication")]
public string ConsumingApplication { get; set; }
[JsonProperty(PropertyName = "role")]
public string Role { get; set; }
[JsonProperty(PropertyName = "sObjectName")]
public string SObjectName { get; set; }
[JsonProperty(PropertyName = "readableFieldNames")]
public HashSet<string> ReadableFieldNames { get; set; } = new HashSet<string>();
[JsonProperty(PropertyName = "writableFieldNames")]
public HashSet<string> WritableFieldNames { get; set; } = new HashSet<string>();
[JsonProperty(PropertyName = "indexedFieldNames")]
public HashSet<string> IndexedFieldNames { get; set; } = new HashSet<string>();
[JsonProperty(PropertyName = "canDeleteRecords")]
public bool CanDeleteRecords { get; set; } = false;
[JsonProperty(PropertyName = "incrementalRefreshIntervalInMinutes")]
public int? IncrementalRefreshIntervalInMinutes { get; set; }
[JsonProperty(PropertyName = "fullRefreshIntervalInDays")]
public int? FullRefreshIntervalInDays { get; set; }
[JsonProperty(PropertyName = "retentionAfterLastSyncInDays")]
public int? RetentionAfterLastSyncInDays { get; set; }
[JsonProperty(PropertyName = "writeIntervalInMinutes")]
public int? WriteIntervalInMinutes { get; set; }
[JsonProperty(PropertyName = "deleteIntervalInMinutes")]
public int? DeleteIntervalInMinutes { get; set; }
}
}
and the following validation class:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Enpal.Salesforce.Client;
using Enpal.SalesforceCache.Configuration;
using Enpal.SalesforceCache.Model.Salesforce.Generic;
using Enpal.SalesforceCache.Services;
using FluentValidation;
using Microsoft.Extensions.Options;
namespace Enpal.SalesforceCache.Validators
{
public class SalesforceDataRequestValidator : AbstractValidator<SalesforceDataRequestParameters>
{
private readonly SObjectDescriptionCache _sObjectFieldCache;
public SalesforceDataRequestValidator(IOptions<RecurringJobsConfig> recurringJobsConfig, SObjectDescriptionCache sObjectFieldCache)
{
if (recurringJobsConfig is null)
{
throw new ArgumentNullException(nameof(recurringJobsConfig));
}
_sObjectFieldCache = sObjectFieldCache ?? throw new ArgumentNullException(nameof(sObjectFieldCache));
RecurringJobsConfig recurringJobsValues = recurringJobsConfig.Value;
RuleFor(request => request.IncrementalRefreshIntervalInMinutes)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumIncrementalRefreshIntervalInMinutes)
.When(request => request.IncrementalRefreshIntervalInMinutes != null);
RuleFor(request => request.FullRefreshIntervalInDays)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumFullRefreshIntervalInDays)
.When(request => request.FullRefreshIntervalInDays != null);
RuleFor(request => request.RetentionAfterLastSyncInDays)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumRetentionAfterLastSyncInDays)
.When(request => request.RetentionAfterLastSyncInDays != null);
RuleFor(request => request.WriteIntervalInMinutes)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumWriteIntervalInMinutes)
.When(request => request.WriteIntervalInMinutes != null);
RuleFor(request => request.DeleteIntervalInMinutes)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumDeleteIntervalInMinutes)
.When(request => request.DeleteIntervalInMinutes != null);
RuleFor(request => request.SObjectName)
.MustAsync(SObjectMustExist)
.WithMessage("Invalid sObject name.");
RuleFor(request => request)
.MustAsync(AllReadableFieldsMustExist)
.WithMessage("Invalid readable field names.");
RuleFor(request => request)
.MustAsync(AllWritableFieldsMustExist)
.WithMessage("Invalid writable field names.");
RuleFor(request => request)
.MustAsync(AllIndexedFieldsMustExist)
.WithMessage("Invalid indexed field names.");
}
private async Task<bool> SObjectMustExist(string sObjectName, CancellationToken cancellationToken)
{
(bool exists, _) = await GetFieldNames(sObjectName, cancellationToken);
return exists;
}
private Task<bool> AllReadableFieldsMustExist(SalesforceDataRequestParameters parameters, CancellationToken cancellationToken) =>
AllMustExist(parameters.SObjectName, parameters.ReadableFieldNames, cancellationToken);
private Task<bool> AllWritableFieldsMustExist(SalesforceDataRequestParameters parameters, CancellationToken cancellationToken) =>
AllMustExist(parameters.SObjectName, parameters.WritableFieldNames, cancellationToken);
private Task<bool> AllIndexedFieldsMustExist(SalesforceDataRequestParameters parameters, CancellationToken cancellationToken)
{
HashSet<string> indexFieldNames = parameters.IndexedFieldNames
.SelectMany(x => x.Split(";"))
.Select(x => x.Trim('-', '_'))
.ToHashSet();
return AllMustExist(parameters.SObjectName, indexFieldNames, cancellationToken);
}
private async Task<bool> AllMustExist(string sObjectName, HashSet<string> inputFieldNames, CancellationToken cancellationToken)
{
(bool exists, IList<string> fieldNames) = await GetFieldNames(sObjectName, cancellationToken);
return exists && inputFieldNames.All(x => fieldNames.Contains(x));
}
private async Task<(bool exists, IList<string> fieldNames)> GetFieldNames(string sObjectName, CancellationToken cancellationToken)
{
IList<string> fieldNames;
try
{
fieldNames = await _sObjectFieldCache.GetFieldNames(sObjectName, cancellationToken);
}
catch (SalesforceClientException)
{
return (false, new List<string>());
}
return (fieldNames != null, fieldNames);
}
}
}
While this works, it is far too vague about why the values for ReadableFieldNames, WritableFieldNames, and IndexedFieldNames may cause the entire instance to fail validation.
I'd like to modify the lines like .WithMessage("Invalid readable field names."); to tell me which field names are causing it to fail.
I've attempted to fix this like:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Enpal.Salesforce.Client;
using Enpal.SalesforceCache.Configuration;
using Enpal.SalesforceCache.Model.Salesforce.Generic;
using Enpal.SalesforceCache.Services;
using FluentValidation;
using Microsoft.Extensions.Options;
namespace Enpal.SalesforceCache.Validators
{
public class SalesforceDataRequestValidator : AbstractValidator<SalesforceDataRequestParameters>
{
private readonly SObjectDescriptionCache _sObjectFieldCache;
private const string ReadableFields = "Readable Fields";
private const string WritableFields = "Writable Fields";
private const string IndexedFields = "Indexed Fields";
private readonly Dictionary<string, HashSet<string>> BadFieldsByPurposes = new();
public SalesforceDataRequestValidator(IOptions<RecurringJobsConfig> recurringJobsConfig, SObjectDescriptionCache sObjectFieldCache)
{
if (recurringJobsConfig is null)
{
throw new ArgumentNullException(nameof(recurringJobsConfig));
}
_sObjectFieldCache = sObjectFieldCache ?? throw new ArgumentNullException(nameof(sObjectFieldCache));
RecurringJobsConfig recurringJobsValues = recurringJobsConfig.Value;
RuleFor(request => request.IncrementalRefreshIntervalInMinutes)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumIncrementalRefreshIntervalInMinutes)
.When(request => request.IncrementalRefreshIntervalInMinutes != null);
RuleFor(request => request.FullRefreshIntervalInDays)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumFullRefreshIntervalInDays)
.When(request => request.FullRefreshIntervalInDays != null);
RuleFor(request => request.RetentionAfterLastSyncInDays)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumRetentionAfterLastSyncInDays)
.When(request => request.RetentionAfterLastSyncInDays != null);
RuleFor(request => request.WriteIntervalInMinutes)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumWriteIntervalInMinutes)
.When(request => request.WriteIntervalInMinutes != null);
RuleFor(request => request.DeleteIntervalInMinutes)
.GreaterThanOrEqualTo(recurringJobsValues.MinimumDeleteIntervalInMinutes)
.When(request => request.DeleteIntervalInMinutes != null);
RuleFor(request => request.SObjectName)
.MustAsync(SObjectMustExist)
.WithMessage("Invalid sObject name.");
RuleFor(request => request)
.MustAsync(AllReadableFieldsMustExist)
.WithMessage(CreateFieldNameErrorMessage(ReadableFields));
RuleFor(request => request)
.MustAsync(AllWritableFieldsMustExist)
.WithMessage(CreateFieldNameErrorMessage(WritableFields));
RuleFor(request => request)
.MustAsync(AllIndexedFieldsMustExist)
.WithMessage(CreateFieldNameErrorMessage(IndexedFields));
}
private async Task<bool> SObjectMustExist(string sObjectName, CancellationToken cancellationToken)
{
(bool sObjectExists, _) = await GetFieldNames(sObjectName, cancellationToken);
return sObjectExists;
}
private Task<bool> AllReadableFieldsMustExist(SalesforceDataRequestParameters parameters, CancellationToken cancellationToken) =>
AllMustExist(parameters.SObjectName, parameters.ReadableFieldNames, ReadableFields, cancellationToken);
private Task<bool> AllWritableFieldsMustExist(SalesforceDataRequestParameters parameters, CancellationToken cancellationToken) =>
AllMustExist(parameters.SObjectName, parameters.WritableFieldNames, WritableFields, cancellationToken);
private Task<bool> AllIndexedFieldsMustExist(SalesforceDataRequestParameters parameters, CancellationToken cancellationToken)
{
HashSet<string> indexFieldNames = parameters.IndexedFieldNames
.SelectMany(x => x.Split(";"))
.Select(x => x.Trim('-', '_'))
.ToHashSet();
return AllMustExist(parameters.SObjectName, indexFieldNames, IndexedFields, cancellationToken);
}
private async Task<bool> AllMustExist(string sObjectName, HashSet<string> inputFieldNames, string purpose, CancellationToken cancellationToken)
{
(bool sObjectExists, IList<string> fieldNames) = await GetFieldNames(sObjectName, cancellationToken);
bool allExist = sObjectExists && inputFieldNames.All(x => fieldNames.Contains(x));
if (!allExist)
{
BadFieldsByPurposes[purpose] = await GetBadFieldNames(sObjectName, inputFieldNames, cancellationToken);
}
return allExist;
}
private async Task<(bool sObjectExists, IList<string> fieldNames)> GetFieldNames(string sObjectName, CancellationToken cancellationToken)
{
IList<string> fieldNames;
try
{
fieldNames = await _sObjectFieldCache.GetFieldNames(sObjectName, cancellationToken);
}
catch (SalesforceClientException)
{
return (false, new List<string>());
}
return (fieldNames != null, fieldNames);
}
private async Task<HashSet<string>> GetBadFieldNames(string sObjectName, HashSet<string> inputFieldNames, CancellationToken cancellationToken = default)
{
IList<string> fieldNames;
try
{
fieldNames = await _sObjectFieldCache.GetFieldNames(sObjectName, cancellationToken);
}
catch (SalesforceClientException)
{
return inputFieldNames;
}
return inputFieldNames.Where(x => !fieldNames.Contains(x))
.ToHashSet();
}
private string CreateFieldNameErrorMessage(string purpose) =>
$"Invalid {purpose} names: {string.Join(",", BadFieldsByPurposes[purpose])}";
}
}
However, this does not work because the system will attempt to resolve .WithMessage(CreateFieldNameErrorMessage(ReadableFields)) before .MustAsync(AllReadableFieldsMustExist) has populated private readonly Dictionary<string, HashSet<string>> BadFieldsByPurposes, and so BadFieldsByPurposes[purpose] will "blow up".
While WithMessage has some overloads, none seem to allow me to do anything async.
Is there any way I can make this work without ditching Fluent Validation?
(Or if I need to ditch the Fluent Validation, what might be the best way to do this?)