Swagger versioning is not working. It displays all endpoints, despite the selected API version - asp.net-core

all!
I am using Swagger in ASP.NET Core 3.1 application.
I need to create an endpoint for the new version of API and with the same route as a previous version.
My controller is:
namespace Application.Controllers
{
[ApiVersion("1")]
[ApiVersion("2")]
[ApiController]
[Route("api/v{version:apiVersion}")]
public class CustomController: ControllerBase
{
[HttpGet]
[Route("result")]
public IActionResult GetResult()
{
return Ok("v1")
}
[HttpGet]
[MapToApiVersion("2")]
[Route("result")]
public IActionResult GetResult(int number)
{
return Ok("v2")
}
}
}
My configuration:
services.AddApiVersioning(
options =>
{
options.ReportApiVersions = true;
});
services.AddSwaggerGen(c =>
{
c.SwaggerDoc($"v1", new OpenApiInfo { Title = "api1", Version = $"v1" });
c.SwaggerDoc($"v2", new OpenApiInfo { Title = "api2", Version = $"v2" });
c.OperationFilter<RemoveVersionParameterFilter>();
c.DocumentFilter<ReplaceVersionWithExactValueInPathFilter>();
c.EnableAnnotations();
});
app.UseSwagger().UseSwaggerUI(c =>
{
c.SwaggerEndpoint($"/swagger/v1/swagger.json", $"api1 v1");
c.SwaggerEndpoint($"/swagger/v2/swagger.json", $"api2 v2");
});
After loading I get an error: Fetch error undefined /swagger/v1/swagger.json
But If I change the second route to the "resutlTwo", I can observe both endpoints in swagger, ignoring current version (api1 v1 or api2 v2)
How can I see only 1 endpoint per API version?

Thanks Roar S. for help!
I just added
services.AddApiVersioning(apiVersioningOptions =>
{
apiVersioningOptions.ReportApiVersions = true;
apiVersioningOptions.ApiVersionReader = new UrlSegmentApiVersionReader();
});
and
c.DocInclusionPredicate((version, desc) =>
{
var endpointMetadata = desc.ActionDescriptor.EndpointMetadata;
if (!desc.TryGetMethodInfo(out MethodInfo methodInfo))
{
return false;
}
var specificVersion = endpointMetadata
.Where(data => data is MapToApiVersionAttribute)
.SelectMany(data => (data as MapToApiVersionAttribute).Versions)
.Select(apiVersion => apiVersion.ToString())
.SingleOrDefault();
if (!string.IsNullOrEmpty(specificVersion))
{
return $"v{specificVersion}" == version;
}
var versions = endpointMetadata
.Where(data => data is ApiVersionAttribute)
.SelectMany(data => (data as ApiVersionAttribute).Versions)
.Select(apiVersion => apiVersion.ToString());
return versions.Any(v => $"v{v}" == version);
});
And it split endpoints to different files.

I just tested your case with this setup.You are missing UrlSegmentApiVersionReader.
public class SwaggerOptions
{
public string Title { get; set; }
public string JsonRoute { get; set; }
public string Description { get; set; }
public List<Version> Versions { get; set; }
public class Version
{
public string Name { get; set; }
public string UiEndpoint { get; set; }
}
}
In Startup#ConfigureServices
// Configure versions
services.AddApiVersioning(apiVersioningOptions =>
{
apiVersioningOptions.ReportApiVersions = true;
apiVersioningOptions.ApiVersionReader = new UrlSegmentApiVersionReader();
});
// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(swaggerGenOptions =>
{
var swaggerOptions = new SwaggerOptions();
Configuration.GetSection("Swagger").Bind(swaggerOptions);
foreach (var currentVersion in swaggerOptions.Versions)
{
swaggerGenOptions.SwaggerDoc(currentVersion.Name, new OpenApiInfo
{
Title = swaggerOptions.Title,
Version = currentVersion.Name,
Description = swaggerOptions.Description
});
}
swaggerGenOptions.DocInclusionPredicate((version, desc) =>
{
if (!desc.TryGetMethodInfo(out MethodInfo methodInfo))
{
return false;
}
var versions = methodInfo.DeclaringType.GetConstructors()
.SelectMany(constructorInfo => constructorInfo.DeclaringType.CustomAttributes
.Where(attributeData => attributeData.AttributeType == typeof(ApiVersionAttribute))
.SelectMany(attributeData => attributeData.ConstructorArguments
.Select(attributeTypedArgument => attributeTypedArgument.Value)));
return versions.Any(v => $"{v}" == version);
});
swaggerGenOptions.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"));
... some filter settings here
});
In Startup#Configure
var swaggerOptions = new SwaggerOptions();
Configuration.GetSection("Swagger").Bind(swaggerOptions);
app.UseSwagger(option => option.RouteTemplate = swaggerOptions.JsonRoute);
app.UseSwaggerUI(option =>
{
foreach (var currentVersion in swaggerOptions.Versions)
{
option.SwaggerEndpoint(currentVersion.UiEndpoint, $"{swaggerOptions.Title} {currentVersion.Name}");
}
});
appsettings.json
{
"Swagger": {
"Title": "App title",
"JsonRoute": "swagger/{documentName}/swagger.json",
"Description": "Some text",
"Versions": [
{
"Name": "2.0",
"UiEndpoint": "/swagger/2.0/swagger.json"
},
{
"Name": "1.0",
"UiEndpoint": "/swagger/1.0/swagger.json"
}
]
}
}
This code is very similar to a related issue I'm working on here on SO.

Related

Certificate Authentication events not triggered in ASP .NET Core 3.1 API

I want to implement certificate authentication on my .NET Core 3.1 API. I followed the steps outlined by Microsoft here:https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-5.0
But, I don't think the events "OnCertificateValidated" and "OnAuthenticationFailed" even fire. Breakpoints are never hit, and I even tried adding code that should break in there just to test, but it never fails (most likely because it never reaches there)
I'm trying to validate the Client Certificate CN and I want to only allow certain CNs to be able to call my API.
You can find my code below. What am I doing wrong?
Startup
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(
CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.All;
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
var validationService =
context.HttpContext.RequestServices.GetRequiredService<ICertificateValidationService>();
if (validationService.ValidateCertificate(context.ClientCertificate))
{
context.Success();
}
else
{
context.Fail($"Unrecognized client certificate: {context.ClientCertificate.GetNameInfo(X509NameType.SimpleName, false)}");
}
int test = Convert.ToInt32("test");
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
int test = Convert.ToInt32("test");
context.Fail($"Invalid certificate");
return Task.CompletedTask;
}
};
});
services.AddHealthChecks();
services.AddControllers(setupAction =>
{
setupAction.Filters.Add(new ProducesResponseTypeAttribute(StatusCodes.Status406NotAcceptable));
setupAction.Filters.Add(new ProducesResponseTypeAttribute(StatusCodes.Status500InternalServerError));
setupAction.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters();
services.AddScoped<IJobServiceRepository, JobServiceRepository>();
services.AddScoped<ICaptureServiceRepository, CaptureServiceRepository>();
services.Configure<KTAEndpointConfig>(Configuration.GetSection("KTAEndpointConfig"));
services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
services.AddVersionedApiExplorer(setupAction =>
{
setupAction.GroupNameFormat = "'v'VV";
});
services.AddApiVersioning(setupAction =>
{
setupAction.AssumeDefaultVersionWhenUnspecified = true;
setupAction.DefaultApiVersion = new ApiVersion(1, 0);
setupAction.ReportApiVersions = true;
});
var apiVersionDecriptionProvider = services.BuildServiceProvider().GetService<IApiVersionDescriptionProvider>();
services.AddSwaggerGen(setupAction =>
{
foreach (var description in apiVersionDecriptionProvider.ApiVersionDescriptions)
{
setupAction.SwaggerDoc($"TotalAgilityOpenAPISpecification{description.GroupName}", new Microsoft.OpenApi.Models.OpenApiInfo()
{
Title = "TotalAgility API",
Version = description.ApiVersion.ToString(),
Description = "Kofax TotalAgility wrapper API to allow creating KTA jobs and uploading documents",
Contact = new Microsoft.OpenApi.Models.OpenApiContact()
{
Email = "shivam.sharma#rbc.com",
Name = "Shivam Sharma"
}
});
setupAction.OperationFilter<AddRequiredHeaderParameter>();
var xmlCommentsFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlCommentsFullPath = Path.Combine(AppContext.BaseDirectory, xmlCommentsFile);
setupAction.IncludeXmlComments(xmlCommentsFullPath);
}
setupAction.DocInclusionPredicate((documentName, apiDescription) =>
{
var actionApiVersionModel = apiDescription.ActionDescriptor
.GetApiVersionModel(ApiVersionMapping.Explicit | ApiVersionMapping.Implicit);
if (actionApiVersionModel == null)
{
return true;
}
if (actionApiVersionModel.DeclaredApiVersions.Any())
{
return actionApiVersionModel.DeclaredApiVersions.Any(v =>
$"TotalAgilityOpenAPISpecificationv{v.ToString()}" == documentName);
}
return actionApiVersionModel.ImplementedApiVersions.Any(v =>
$"TotalAgilityOpenAPISpecificationv{v.ToString()}" == documentName);
});
});
services.AddHttpsRedirection((httpsOpts) =>
{
//httpsOpts.HttpsPort = 443;
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVersionDescriptionProvider apiVersionDescriptionProvider)
{
app.UseApiExceptionHandler();
app.UseHeaderLogContextMiddleware();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseSwagger(setupAction =>
{
setupAction.SerializeAsV2 = true;
});
app.UseSwaggerUI(setupAction =>
{
foreach (var decription in apiVersionDescriptionProvider.ApiVersionDescriptions)
{
setupAction.SwaggerEndpoint($"/swagger/TotalAgilityOpenAPISpecification{decription.GroupName}/swagger.json",
decription.GroupName.ToUpperInvariant());
}
//setupAction.SwaggerEndpoint("/swagger/TotalAgilityOpenAPISpecification/swagger.json",
//"TotalAgility API");
setupAction.RoutePrefix = "";
});
app.UseSerilogRequestLogging();
app.UseHeaderValidation();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers().RequireAuthorization();
endpoints.MapHealthChecks("/health");
});
}
}
CertificateValidationService
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
namespace TotalAgility_API.Services
{
public class CertificateValidationService : ICertificateValidationService
{
private readonly IConfiguration _configuration;
private readonly ILogger<CertificateValidationService> _logger;
public CertificateValidationService(IConfiguration configuration, ILogger<CertificateValidationService> logger)
{
_logger = logger;
_configuration = configuration;
}
public bool ValidateCertificate(X509Certificate2 clientCert)
{
List<string> allowedCNs = new List<string>();
_configuration.GetSection("AllowedClientCertCNList").Bind(allowedCNs);
string cn = clientCert.GetNameInfo(X509NameType.SimpleName, false);
if (allowedCNs.Contains(cn))
{
return true;
}
else
{
_logger.LogWarning("Invalid Cert CN: {CN}", cn);
return false;
}
}
}
}
appsettings.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
}
},
"AllowedHosts": "*",
"AllowedClientCertCNList": [
"1",
"2",
"3",
"4"
]
}
Any help would be appreciated here. I'm new to this and I'm don't know how to proceed.
I had a similar issue on IIS Express where only "OnChallenge" fired but not "OnCertificateValidated" or "OnAuthenticationFailed", and kept getting 403 status responses.
This answer from a different question solved it for my case (IIS Express). Set the access sslFlags in .vs\config\applicationhost.config to use client certificate:
<access sslFlags="Ssl, SslNegotiateCert, SslRequireCert" />

FluentValidation failure not returning BadRequest

I have wired up FluentValidation as per instructions, and when debuging test I can see that model is invalid based on the test setup, but exception is not thrown, but rather method on the controller is being executed. This is on 3.1 with EndPoint routing enabled. Is there anything else one needs to do to get this to work and throw. What happens is that validation obviously runs; it shows as ModelState invalid and correct InstallmentId is invalid, but it keeps processing in Controller instead of throwing exception.
services.AddMvc(
options =>
{
options.EnableEndpointRouting = true;
//// options.Filters.Add<ExceptionFilter>();
//// options.Filters.Add<CustomerRequestFilter>();
})
.AddFluentValidation(
config =>
{
config.RegisterValidatorsFromAssemblyContaining<Startup>();
})
Command and Validator
public class ProcessManualPayment
{
public class Command
: CustomerRequest<Result?>
{
public Guid PaymentPlanId { get; set; }
public Guid InstallmentId { get; set; }
public Guid PaymentCardId { get; set; }
}
public class Validator : AbstractValidator<Command>
{
public Validator()
{
this.RuleFor(x => x.CustomerId)
.IsValidGuid();
this.RuleFor(x => x.PaymentPlanId)
.IsValidGuid();
this.RuleFor(x => x.InstallmentId)
.IsValidGuid();
this.RuleFor(x => x.PaymentCardId)
.IsValidGuid();
}
}
Controller
[Authorize]
[HttpPost]
[Route("payments")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ProcessManualPayment(
[FromBody]
ProcessManualPayment.Command command)
{
Test
[Fact]
public async Task When_Command_Has_Invalid_Payload_Should_Fail()
{
var client = this.factory.CreateClient();
// Arrange
var validCmd = new ProcessManualPayment.Command()
{
CustomerId = Guid.NewGuid(),
PaymentPlanId = Guid.NewGuid(),
InstallmentId = Guid.NewGuid(),
PaymentCardId = Guid.NewGuid(),
};
var validCmdJson = JsonConvert.SerializeObject(validCmd, Formatting.None);
var jObject = JObject.Parse(validCmdJson);
jObject["installmentId"] = "asdf";
var payload = jObject.ToString(Formatting.None);
// Act
var content = new StringContent(payload, Encoding.UTF8, MediaTypeNames.Application.Json);
var response = await client.PostAsync(MakePaymentUrl, content);
var returned = await response.Content.ReadAsStringAsync();
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
[Fact]
public async Task When_Payload_Is_Null_Should_Fail()
{
// Arrange
var client = this.factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(MakePaymentUrl, null);
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
GuidValidator
public class GuidValidator : PropertyValidator
{
public GuidValidator()
: base("'{PropertyName}' value {AttemptedValue} is not a valid Guid.")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
context.MessageFormatter.AppendArgument("AttemptedValue", context.PropertyValue ?? "'null'");
if (context.PropertyValue == null)
{
return false;
}
Guid.TryParse(context.PropertyValue.ToString(), out var value);
return IsValid(value);
}
private static bool IsValid(Guid? value) =>
value.HasValue
&& !value.Equals(Guid.Empty);
}
Mystery solved, I was missing [ApiController] attribute on the controller.

.net core 2.2 webapi and handling multypart request

I have .net core 2.2 webapi as backend.
My backend have to handling the requests (photo and text) from mobile application as multypart-form request.
Plz hint me how it to prpvide on backend?
this problem has solved
public class SwaggerFileOperationFilter : IOperationFilter
{
public void Apply(Operation operation, OperationFilterContext context)
{
if (operation.OperationId == "MailWithPhotos")
{
operation.Parameters = new List<IParameter>
{
new NonBodyParameter
{
Name = "awr_file",
Required = false,
Type = "file",
In = "formData"
},
new NonBodyParameter
{
Name = "awr_message",
Required = true,
Type = "string",
In = "formData"
},
new NonBodyParameter
{
Type = "string",
In = "header",
Name = "Authorization",
Description = "token",
Required = true
}
};
}
}
}
[HttpPost]
[ProducesResponseType(typeof(ResponseMail), 200)]
public async Task<PipeResponse> MailWithPhotos([FromForm] MailwithPhoto fIleUploadAPI)
{
var file = fIleUploadAPI.awr_file; // OK!!!
var message = fIleUploadAPI.awr_message; // OK!!!
var tokenA = Request.Headers["Authorization"]; // OK!!!
var fileContentStream11 = new MemoryStream();
await fIleUploadAPI.awr_file.CopyToAsync(fileContentStream11);
await System.IO.File.WriteAllBytesAsync(Path.Combine(folderPath,
fIleUploadAPI.awr_file.FileName),
fileContentStream11.ToArray());
}
public class MailwithPhoto
{
public string awr_message { get; set; }
public string Authorization { get; set; }
public IFormFile awr_file { get; set; }
}

Asp.Net Core 3.1 Model Binding Doesn't Work

Model binding doesn't work on asp.net core 3.1 version. I have no problem with previous dotnet core versions or .net framework version. Somebody help me please.
Best regards
JavaScript code:
var postData = { "createdFrom": "2020-07-18", "createdTo": "2020-07-19", "message": "test", "logLevel": "Error" };
fetch('/log/list', {
method: "post",
body: JSON.stringify(postData),
headers: {
"Content-Type": "application/json"
}
})
.then(res => res.json())
.then(res => {
console.log(res);
}).catch(err => {
console.error(err);
});
C# code:
[HttpPost]
public IActionResult List([FromBody] LogSearchModel searchModel) // searchModel is null
{
string rawBodyStr = "";
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
{
rawBodyStr = reader.ReadToEndAsync().Result;
}
// rawBodyStr is null
}
public class LogSearchModel
{
[DisplayName("Başlangıç tarihi")]
[UIHint("DateNullable")]
public DateTime? CreatedFrom { get; set; }
[DisplayName("Bitiş tarihi")]
[UIHint("DateNullable")]
public DateTime? CreatedTo { get; set; }
[DisplayName("Açıklama")]
public string Message { get; set; }
[DisplayName("Olay türü")]
public int LogLevel { get; set; }
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<LogManager>();
services.AddControllersWithViews();
}
Your LogLevel is type of int,but you pass the string data.
Change from:
var postData = { "createdFrom": "2020-07-18", "createdTo": "2020-07-19",
"message": "test", "logLevel": "Error" };
To:
var postData = { "createdFrom": "2020-07-18", "createdTo": "2020-07-19",
"message": "test", "logLevel": 1 };

FluentValidation not validating Email Address List(s) correctly?

{Version 8.0.0}
Why would this test be passing?
Test:
[Test]
public void Validation_NullTo_ShouldThrowModelValidationException()
{
var config = new EmailMessage
{
Subject = "My Subject",
Body = "My Body",
To = null
};
EmailMessageValidator validator = new EmailMessageValidator();
ValidationResult results = validator.Validate(config);
if (!results.IsValid)
{
foreach (var failure in results.Errors)
{
Console.WriteLine("Property " + failure.PropertyName + " failed validation. Error was: " + failure.ErrorMessage);
}
}
// results.Errors is Empty and resuts.IsValid is true here. Should be false with at least one Error message.
Assert.True(results.IsValid);
}
If I change the construction of the message like this, validation fails as normal.
var config = new EmailMessage
{
Subject = "My Subject",
Body = "My Body"
};
config.To = new EmailAddressList();
Validators:
public class EmailAddressListAtLeastOneRequiredValidator : AbstractValidator<EmailAddressList>
{
public EmailAddressListAtLeastOneRequiredValidator()
{
RuleFor(model => model)
.NotNull()
.WithMessage(AppMessages.Validation.AtLeastOneShouldBeDefined.ParseIn(nameof(EmailAddressList.Value)));
RuleFor(model => model.Value)
.NotNull()
.WithMessage(AppMessages.Validation.AtLeastOneShouldBeDefined.ParseIn(nameof(EmailAddressList.Value)));
RuleFor(model => model.Value.Count)
.GreaterThan(0)
.WithMessage(AppMessages.Validation.AtLeastOneShouldBeDefined.ParseIn(nameof(EmailAddressList.Value)));
When(model => model.Value?.Count > 0, () =>
{
RuleFor(model => model.Value)
.NotEmpty()
.WithMessage(AppMessages.Validation.AtLeastOneShouldBeDefined.ParseIn(nameof(EmailAddressList.Value)));
});
}
}
public class EmailAddressListValidator : AbstractValidator<EmailAddressList>
{
public EmailAddressListValidator()
{
RuleFor(model => model.Value).SetCollectionValidator(new EmailAddressValidator());
}
}
public class EmailAddressValidator : AbstractValidator<EmailAddress>
{
public EmailAddressValidator()
{
When(model => model.Value != null, () =>
{
RuleFor(model => model.Value)
.EmailAddress()
.WithMessage(AppMessages.Validation.ValueCannotBeNullOrEmpty.ParseIn(nameof(EmailAddress)));
});
}
}
public class EmailMessageValidator : AbstractValidator<EmailMessage>
{
public EmailMessageValidator()
{
RuleFor(model => model.To).SetValidator(new EmailAddressListAtLeastOneRequiredValidator());
When(model => model.Cc?.Value?.Count > 0, () =>
{
RuleFor(model => model.Cc).SetValidator(new EmailAddressListValidator());
});
When(model => model.Bcc?.Value?.Count > 0, () =>
{
RuleFor(model => model.Bcc).SetValidator(new EmailAddressListValidator());
});
RuleFor(model => model.Subject)
.NotEmpty().WithMessage(AppMessages.Validation.ValueCannotBeNullOrEmpty.ParseIn(nameof(EmailMessage.Subject)))
.MaximumLength(100).WithMessage(AppMessages.Validation.ValueLengthCannotBeGreaterThan.ParseIn(nameof(EmailMessage.Subject), 100));
RuleFor(model => model.Body)
.NotEmpty().WithMessage(AppMessages.Validation.ValueCannotBeNullOrEmpty.ParseIn(nameof(EmailMessage.Body)));
}
}
EmailMessage and EmailAddressList Classes:
public class EmailMessage : IEmailMessage
{
public EmailAddressList To { get; set; } = new EmailAddressList();
public EmailAddressList Cc { get; set; } = new EmailAddressList();
public EmailAddressList Bcc { get; set; } = new EmailAddressList();
public string Subject { get; set; }
public string Body { get; set; }
}
public class EmailAddressList : ModelValidation, IEnumerable<EmailAddress>
{
public List<EmailAddress> Value { get; set; } = new List<EmailAddress>();
public EmailAddressList()
: base(new EmailAddressListValidator())
{
}
public EmailAddressList(string emailAddressList)
: base(new EmailAddressListValidator())
{
Value = Split(emailAddressList);
}
public EmailAddressList(IValidator validator)
: base(validator ?? new EmailAddressListValidator())
{
}
public List<EmailAddress> Split(string emailAddressList, char splitChar = ';')
{
return emailAddressList.Contains(splitChar)
? emailAddressList.Split(splitChar).Select(email => new EmailAddress(email)).ToList()
: new List<EmailAddress> { new EmailAddress(emailAddressList) };
}
public string ToString(char splitChar = ';')
{
if (Value == null)
return "";
var value = new StringBuilder();
foreach (var item in Value)
value.Append($"{item.Value};");
return value.ToString().TrimEnd(';');
}
public void Add(string emailAddress, string displayName = "")
{
Value.Add(new EmailAddress(emailAddress, displayName));
}
public void Add(EmailAddress emailAddress)
{
Value.Add(emailAddress);
}
public IEnumerator<EmailAddress> GetEnumerator()
{
return Value.GetEnumerator();
}
[ExcludeFromCodeCoverage]
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
Here is answer from Jeremy Skinner...
This behaviour is normal and correct. Complex child validators set
with SetValidator can only be invoked if the target property is not
null. As the To property is null, the child validator won't be
invoked. What you should be doing is combining the SetValidator with a
NotNull check:
RuleFor(model => model.To) .NotNull().WithMessage("...");
.SetValidator(new EmailAddressListAtLeastOneRequiredValidator());
...and then remove the RuleFor(model => model).NotNull() from the
EmailAddressListAtLeastOneRequiredValidator as this will never be
executed. Overriding PreValidate is not relevant here. Using
PreValidate like this is only relevant when trying to pass a null to
the root validator. When working with child validators, they can never
be invoked with a null instance (which is by design), and the null
should be handled by the parent validator.