WebApi + ClientApp, GraphQL, Reflection

This commit is contained in:
Boris Milašinović
2026-05-06 20:55:05 +02:00
parent 8f7c704a90
commit 4fb3de19f6
196 changed files with 10395 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace Events.WebAPI.Contract.Command;
public class AddCommand<TDto, TPK>(TDto dto) : IRequest<TPK>
{
public TDto Dto { get; set; } = dto;
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace MobilityOne.Common.Commands;
public class DeleteCommand<TDto, TPK>(TPK id) : IRequest
{
public TPK Id { get; set; } = id;
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace Events.WebAPI.Contract.Command;
public class UpdateCommand<TDto>(TDto dto) : IRequest
{
public TDto Dto { get; set; } = dto;
}

View File

@@ -0,0 +1,18 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class EventDTO : IHasIdAsPK<int>
{
[Sieve(CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string Name { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public DateOnly EventDate { get; set; }
[Sieve(CanSort = true)]
public int RegistrationsCount { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Events.WebAPI.Contract.DTOs;
public interface IHasIdAsPK<T> where T : IEquatable<T>
{
T Id { get; }
}

View File

@@ -0,0 +1,10 @@
namespace Events.WebAPI.Contract.DTOs;
public class IdName<T>
{
public T Id { get; set; } = default!;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace Events.WebAPI.Contract.DTOs;
/// <summary>
/// Contains requested items (based on filter, paging and sorting criteria),
/// and the number of total items satisfying the filter
/// (or count of all items if no filter is present)
/// </summary>
/// <typeparam name="T"></typeparam>
public class Items<T>
{
public List<T>? Data { get; set; }
public int Count { get; set; }
}

View File

@@ -0,0 +1,52 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class PersonDTO : IHasIdAsPK<int>
{
[Sieve(CanFilter = true, CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string FirstName { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string LastName { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string FirstNameTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string LastNameTranscription { get; set; } = string.Empty;
public string AddressLine { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string AddressCountry { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string Email { get; set; } = string.Empty;
public string ContactPhone { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public DateOnly BirthDate { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string DocumentNumber { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string CountryCode { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string CountryName { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public string FullNameTranscription { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public int RegistrationsCount { get; set; }
}

View File

@@ -0,0 +1,42 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class RegistrationDTO : IHasIdAsPK<int>
{
[Sieve(CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public int EventId { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public int PersonId { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public int SportId { get; set; }
[Sieve(CanSort = true)]
public DateTime RegisteredAt { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string PersonName { get; set; } = string.Empty;
[Sieve(CanFilter = true)]
public string PersonTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string PersonFirstNameTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string PersonLastNameTranscription { get; set; } = string.Empty;
[Sieve(CanFilter = true)]
public string CountryCode { get; set; } = string.Empty;
[Sieve(CanSort = true)]
public string CountryName { get; set; } = string.Empty;
[Sieve(CanFilter = true, CanSort = true)]
public string SportName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
using Sieve.Attributes;
namespace Events.WebAPI.Contract.DTOs;
public class SportDTO : IHasIdAsPK<int>
{
[Sieve(CanSort = true)]
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string Name { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" />
<PackageReference Include="Sieve" Version="2.5.5" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.LookupQueries;
public class LookupCountryQuery : IRequest<List<IdName<string>>>
{
public string? Text { get; set; }
}

View File

@@ -0,0 +1,11 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.LookupQueries;
public class LookupPeopleQuery : IRequest<List<IdName<int>>>
{
public string? Text { get; set; }
public string? CountryCode { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Events.WebAPI.Contract.Messages;
public record RegistrationCreated
{
public int RegistrationId { get; init; }
public int PersonId { get; init; }
public int EventId { get; init; }
public int SportId { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace Events.WebAPI.Contract.Messages;
public record RegistrationDeleted
{
public int RegistrationId { get; init; }
public int PersonId { get; init; }
public int EventId { get; init; }
public int SportId { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Events.WebAPI.Contract.Messages;
public record RegistrationUpdated
{
public int RegistrationId { get; init; }
public int PersonId { get; init; }
public int EventId { get; init; }
public int SportId { get; init; }
public int PreviousPersonId { get; init; }
public int PreviousEventId { get; init; }
public int PreviousSportId { get; init; }
}

View File

@@ -0,0 +1,10 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public class DoesItemExistsQuery<TDto, TPK> (TPK id) : IRequest<bool>, IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
public TPK Id { get; set; } = id;
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public abstract class GetCountQuery : IRequest<int>
{
public string? Filters { get; set; }
}
public class GetCountQuery<TDto> : GetCountQuery
{
public static GetCountQuery<TDto> CreateForPK<TPK>(TPK id) where TPK : IEquatable<TPK> {
var query = new GetCountQuery<TDto>()
{
Filters = $"id=={id}"
};
return query;
}
}

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public class GetItemsQuery<TDto> : IRequest<List<TDto>>
{
public string? Filters { get; set; }
public string? Sort { get; set; }
public bool Ascending { get; set; }
public int? PageSize { get; set; }
public int? Page { get; set; }
}

View File

@@ -0,0 +1,10 @@
using Events.WebAPI.Contract.DTOs;
using MediatR;
namespace Events.WebAPI.Contract.Queries.Generic;
public class GetSingleItemQuery<TDto, TPK>(TPK id) : IRequest<TDto>, IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
public TPK Id { get; set; } = id;
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
namespace Events.WebAPI.Contract.Validation.Event;
public class AddEventValidator : AbstractValidator<AddCommand<EventDTO, int>>
{
public AddEventValidator()
{
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
RuleFor(a => a.Dto.EventDate).NotEmpty();
}
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Contract.Validation.Event;
public class DeleteEventValidator : AbstractValidator<DeleteCommand<EventDTO, int>>
{
public DeleteEventValidator(IMediator mediator)
{
RuleFor(a => a.Id).NoChildRecords<DeleteCommand<EventDTO, int>, RegistrationDTO, int>(nameof(RegistrationDTO.EventId), mediator);
}
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
namespace Events.WebAPI.Contract.Validation.Event;
public class UpdateEventValidator : AbstractValidator<UpdateCommand<EventDTO>>
{
public UpdateEventValidator()
{
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(150);
RuleFor(a => a.Dto.EventDate).NotEmpty();
}
}

View File

@@ -0,0 +1,49 @@
using FluentValidation;
using MediatR;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Queries.Generic;
namespace Events.WebAPI.Contract.Validation;
public static class ForeignKeyValueValidatorExtension
{
public static IRuleBuilderOptions<TCommand, TPK> ForeignKeyExists<TCommand, TDto, TPK>(
this IRuleBuilder<TCommand, TPK> ruleBuilder,
IMediator mediator,
IValidationMessageProvider validationMessageProvider,
ValidationMessage? validationMessage = null)
where TDto : IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
ValidationMessage message = validationMessage ?? validationMessageProvider.ForeignKeyNotFound("{PropertyName}");
return ruleBuilder.MustAsync(new ForeignKeyValueValidator<TCommand, TDto, TPK>(mediator).Validate)
.WithMessage(message.Message)
.WithErrorCode(message.Code);
}
private class ForeignKeyValueValidator<TCommand, TDto, TPK> where TDto : IHasIdAsPK<TPK> where TPK : IEquatable<TPK>
{
private readonly IMediator mediator;
public ForeignKeyValueValidator(IMediator mediator)
{
this.mediator = mediator;
}
public async Task<bool> Validate(TCommand command, TPK value, ValidationContext<TCommand> validationContext, CancellationToken cancellationToken)
{
var query = new DoesItemExistsQuery<TDto, TPK>(value);
try
{
bool itemExists = await mediator.Send(query, cancellationToken);
return itemExists;
}
catch (Exception exc)
{
validationContext.AddFailure(exc.Message);
return false;
}
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Events.WebAPI.Contract.Validation;
public interface IValidationMessageProvider
{
ValidationMessage UniqueSportName(string sportName);
ValidationMessage UniquePersonDocumentAndCountry();
ValidationMessage PersonEmailOrContactPhoneRequired();
ValidationMessage UniqueRegistration();
ValidationMessage EventNotFound();
ValidationMessage PersonNotFound();
ValidationMessage SportNotFound();
ValidationMessage ForeignKeyNotFound(string propertyName);
}

View File

@@ -0,0 +1,36 @@
using Events.WebAPI.Contract.Queries.Generic;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation;
public static class NoChildRecordsValidatorExtension
{
public static IRuleBuilderOptions<TCommand, TPK> NoChildRecords<TCommand, TDto, TPK>(this IRuleBuilder<TCommand, TPK> ruleBuilder, string columnName, IMediator mediator)
{
return ruleBuilder.MustAsync(new NoChildRecordsValidator<TDto, TPK>(columnName, mediator).Validate)
.WithMessage("Cannot delete entity {PropertyValue} because there are child records in table related to " + typeof(TDto).Name.ToString());
}
private class NoChildRecordsValidator<TDto, TPK>
{
private readonly string columnName;
private readonly IMediator mediator;
public NoChildRecordsValidator(string columnName, IMediator mediator)
{
this.columnName = columnName;
this.mediator = mediator;
}
public async Task<bool> Validate(TPK value, CancellationToken cancellationToken)
{
var query = new GetCountQuery<TDto>()
{
Filters = $"{columnName}=={value}"
};
int count = await mediator.Send(query, cancellationToken);
return count == 0;
}
}
}

View File

@@ -0,0 +1,43 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Validation;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Person;
public class AddPersonValidator : AbstractValidator<AddCommand<PersonDTO, int>>
{
public AddPersonValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<PersonDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniquePersonDocumentAndCountry(),
t => t.DocumentNumber,
t => t.CountryCode);
RuleFor(a => a.Dto.FirstName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.FirstNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressLine).NotEmpty().MaximumLength(200);
RuleFor(a => a.Dto.PostalCode).NotEmpty().MaximumLength(20);
RuleFor(a => a.Dto.City).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressCountry).NotEmpty().MaximumLength(100);
When(a => !string.IsNullOrWhiteSpace(a.Dto.Email), () =>
{
RuleFor(a => a.Dto.Email).MaximumLength(255).EmailAddress();
});
RuleFor(a => a.Dto.ContactPhone).MaximumLength(50);
RuleFor(a => a.Dto.BirthDate).NotEmpty();
RuleFor(a => a.Dto.DocumentNumber).NotEmpty().MaximumLength(50);
RuleFor(a => a.Dto.CountryCode).NotEmpty().MaximumLength(3);
ValidationMessage emailOrContactPhoneRequired = validationMessageProvider.PersonEmailOrContactPhoneRequired();
RuleFor(a => a.Dto)
.Must(dto => !string.IsNullOrWhiteSpace(dto.Email) || !string.IsNullOrWhiteSpace(dto.ContactPhone))
.WithMessage(emailOrContactPhoneRequired.Message)
.WithErrorCode(emailOrContactPhoneRequired.Code);
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.Validate);
}
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Contract.Validation.Person;
public class DeletePersonValidator : AbstractValidator<DeleteCommand<PersonDTO, int>>
{
public DeletePersonValidator(IMediator mediator)
{
RuleFor(a => a.Id).NoChildRecords<DeleteCommand<PersonDTO, int>, RegistrationDTO, int>(nameof(RegistrationDTO.PersonId), mediator);
}
}

View File

@@ -0,0 +1,43 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Validation;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Person;
public class UpdatePersonValidator : AbstractValidator<UpdateCommand<PersonDTO>>
{
public UpdatePersonValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<PersonDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniquePersonDocumentAndCountry(),
t => t.DocumentNumber,
t => t.CountryCode);
RuleFor(a => a.Dto.FirstName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastName).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.FirstNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.LastNameTranscription).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressLine).NotEmpty().MaximumLength(200);
RuleFor(a => a.Dto.PostalCode).NotEmpty().MaximumLength(20);
RuleFor(a => a.Dto.City).NotEmpty().MaximumLength(100);
RuleFor(a => a.Dto.AddressCountry).NotEmpty().MaximumLength(100);
When(a => !string.IsNullOrWhiteSpace(a.Dto.Email), () =>
{
RuleFor(a => a.Dto.Email).MaximumLength(255).EmailAddress();
});
RuleFor(a => a.Dto.ContactPhone).MaximumLength(50);
RuleFor(a => a.Dto.BirthDate).NotEmpty();
RuleFor(a => a.Dto.DocumentNumber).NotEmpty().MaximumLength(50);
RuleFor(a => a.Dto.CountryCode).NotEmpty().MaximumLength(3);
ValidationMessage emailOrContactPhoneRequired = validationMessageProvider.PersonEmailOrContactPhoneRequired();
RuleFor(a => a.Dto)
.Must(dto => !string.IsNullOrWhiteSpace(dto.Email) || !string.IsNullOrWhiteSpace(dto.ContactPhone))
.WithMessage(emailOrContactPhoneRequired.Message)
.WithErrorCode(emailOrContactPhoneRequired.Code);
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.ValidateExisting);
}
}

View File

@@ -0,0 +1,24 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Registration;
public class AddRegistrationValidator : AbstractValidator<AddCommand<RegistrationDTO, int>>
{
public AddRegistrationValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueValidator = new UniqueIndexValidator<RegistrationDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniqueRegistration(),
t => t.EventId,
t => t.PersonId,
t => t.SportId);
RuleFor(a => a.Dto.EventId).GreaterThan(0).ForeignKeyExists<AddCommand<RegistrationDTO, int>, EventDTO, int>(mediator, validationMessageProvider, validationMessageProvider.EventNotFound());
RuleFor(a => a.Dto.PersonId).GreaterThan(0).ForeignKeyExists<AddCommand<RegistrationDTO, int>, PersonDTO, int>(mediator, validationMessageProvider, validationMessageProvider.PersonNotFound());
RuleFor(a => a.Dto.SportId).GreaterThan(0).ForeignKeyExists<AddCommand<RegistrationDTO, int>, SportDTO, int>(mediator, validationMessageProvider, validationMessageProvider.SportNotFound());
RuleFor(a => a.Dto).CustomAsync(uniqueValidator.Validate);
}
}

View File

@@ -0,0 +1,13 @@
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Contract.Validation.Registration;
public class DeleteRegistrationValidator : AbstractValidator<DeleteCommand<RegistrationDTO, int>>
{
public DeleteRegistrationValidator(IMediator mediator)
{
}
}

View File

@@ -0,0 +1,24 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Registration;
public class UpdateRegistrationValidator : AbstractValidator<UpdateCommand<RegistrationDTO>>
{
public UpdateRegistrationValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueValidator = new UniqueIndexValidator<RegistrationDTO, int>(
mediator,
(_, _) => validationMessageProvider.UniqueRegistration(),
t => t.EventId,
t => t.PersonId,
t => t.SportId);
RuleFor(a => a.Dto.EventId).GreaterThan(0).ForeignKeyExists<UpdateCommand<RegistrationDTO>, EventDTO, int>(mediator, validationMessageProvider, validationMessageProvider.EventNotFound());
RuleFor(a => a.Dto.PersonId).GreaterThan(0).ForeignKeyExists<UpdateCommand<RegistrationDTO>, PersonDTO, int>(mediator, validationMessageProvider, validationMessageProvider.PersonNotFound());
RuleFor(a => a.Dto.SportId).GreaterThan(0).ForeignKeyExists<UpdateCommand<RegistrationDTO>, SportDTO, int>(mediator, validationMessageProvider, validationMessageProvider.SportNotFound());
RuleFor(a => a.Dto).CustomAsync(uniqueValidator.ValidateExisting);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using MediatR;
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
namespace Events.WebAPI.Contract.Validation.Sport;
public class AddSportValidator : AbstractValidator<AddCommand<SportDTO, int>>
{
public AddSportValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<SportDTO, int>(
mediator,
(_, values) => validationMessageProvider.UniqueSportName(values[0]),
t => t.Name);
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(100).DependentRules(() =>
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.Validate));
}
}

View File

@@ -0,0 +1,14 @@
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
using MobilityOne.Common.Commands;
namespace Events.WebAPI.Contract.Validation.Sport;
public class DeleteSportValidator : AbstractValidator<DeleteCommand<SportDTO, int>>
{
public DeleteSportValidator(IMediator mediator)
{
RuleFor(a => a.Id).NoChildRecords<DeleteCommand<SportDTO, int>, RegistrationDTO, int>(nameof(RegistrationDTO.SportId), mediator);
}
}

View File

@@ -0,0 +1,20 @@
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation.Sport;
public class UpdateSportValidator : AbstractValidator<UpdateCommand<SportDTO>>
{
public UpdateSportValidator(IMediator mediator, IValidationMessageProvider validationMessageProvider)
{
var uniqueIndexValidator = new UniqueIndexValidator<SportDTO, int>(
mediator,
(_, values) => validationMessageProvider.UniqueSportName(values[0]),
t => t.Name);
RuleFor(a => a.Dto.Name).NotEmpty().MaximumLength(100).DependentRules(() =>
RuleFor(a => a.Dto).CustomAsync(uniqueIndexValidator.ValidateExisting));
}
}

View File

@@ -0,0 +1,206 @@
using System.Linq.Expressions;
using Events.WebAPI.Contract.Command;
using Events.WebAPI.Contract.DTOs;
using Events.WebAPI.Contract.Queries.Generic;
using FluentValidation;
using FluentValidation.Results;
using MediatR;
namespace Events.WebAPI.Contract.Validation;
public class UniqueIndexValidator<TDto, TPK> where TDto : IHasIdAsPK<TPK>
where TPK : IEquatable<TPK>
{
private readonly IMediator mediator;
private readonly Expression<Func<TDto, object?>>[] selectors;
private readonly Func<IReadOnlyList<string>, IReadOnlyList<string>, ValidationMessage>? errorMessageFactory;
public UniqueIndexValidator(
IMediator mediator,
Func<IReadOnlyList<string>, IReadOnlyList<string>, ValidationMessage>? errorMessageFactory = null,
params Expression<Func<TDto, object?>>[] selectors)
{
this.mediator = mediator;
this.errorMessageFactory = errorMessageFactory;
this.selectors = selectors;
}
public async Task Validate(string value, ValidationContext<AddCommand<TDto, TPK>> context, CancellationToken cancellationToken)
{
string columnName = GetColumnName(context);
var query = new GetCountQuery<TDto>
{
Filters = $"{columnName}==*{EscapeFilterValue(value)}"
};
int count = await mediator.Send(query, cancellationToken);
if (count > 0)
{
ValidationMessage validationMessage = BuildErrorMessage([columnName], [value]);
context.AddFailure(new ValidationFailure(columnName, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
public async Task Validate(TDto dto, ValidationContext<AddCommand<TDto, TPK>> context, CancellationToken cancellationToken)
{
var query = new GetCountQuery<TDto>();
List<string> columnNames = [];
List<string> values = [];
List<string> filters = [];
foreach (var selector in selectors)
{
string columnName = GetColumnName(selector);
columnNames.Add(columnName);
object? rawValue = selector.Compile().Invoke(dto);
string value = FormatValue(rawValue);
values.Add(value);
filters.Add(BuildEqualsFilter(columnName, rawValue));
}
query.Filters = string.Join(",", filters);
int count = await mediator.Send(query, cancellationToken);
if (count > 0)
{
ValidationMessage validationMessage = BuildErrorMessage(columnNames, values);
context.AddFailure(new ValidationFailure(context.PropertyPath, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
public async Task ValidateExisting(string value, ValidationContext<UpdateCommand<TDto>> context, CancellationToken cancellationToken)
{
string columnName = GetColumnName(context);
UpdateCommand<TDto> validatingObject = context.InstanceToValidate;
var query = new GetItemsQuery<TDto>
{
Filters = $"{columnName}==*{EscapeFilterValue(value)}",
Page = 1,
PageSize = 2
};
List<TDto> items = await mediator.Send(query, cancellationToken);
if (items.Count > 0)
{
bool valueBelongsToValidatingItem = items.Any(item => item.Id.Equals(validatingObject.Dto.Id));
if (!valueBelongsToValidatingItem)
{
ValidationMessage validationMessage = BuildErrorMessage([columnName], [value]);
context.AddFailure(new ValidationFailure(columnName, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
}
public async Task ValidateExisting(TDto dto, ValidationContext<UpdateCommand<TDto>> context, CancellationToken cancellationToken)
{
var query = new GetItemsQuery<TDto>();
List<string> columnNames = [];
List<string> values = [];
List<string> filters = [];
foreach (var selector in selectors)
{
string columnName = GetColumnName(selector);
columnNames.Add(columnName);
object? rawValue = selector.Compile().Invoke(dto);
string value = FormatValue(rawValue);
values.Add(value);
filters.Add(BuildEqualsFilter(columnName, rawValue));
}
query.Filters = string.Join(",", filters);
query.Page = 1;
query.PageSize = 2;
List<TDto> items = await mediator.Send(query, cancellationToken);
if (items.Count > 0)
{
bool valueBelongsToValidatingItem = items.Any(item => item.Id.Equals(dto.Id));
if (!valueBelongsToValidatingItem)
{
ValidationMessage validationMessage = BuildErrorMessage(columnNames, values);
context.AddFailure(new ValidationFailure(context.PropertyPath, validationMessage.Message) { ErrorCode = validationMessage.Code });
}
}
}
private ValidationMessage BuildErrorMessage(IReadOnlyList<string> columnNames, IReadOnlyList<string> values)
{
if (errorMessageFactory != null)
return errorMessageFactory(columnNames, values);
return columnNames.Count == 1
? new ValidationMessage(ValidationErrorCodes.UniqueConstraintViolation, $"{columnNames[0]} must be unique. Value {values[0]} has been already used!")
: new ValidationMessage(ValidationErrorCodes.UniqueConstraintViolation, $"n-tuple ({string.Join(", ", columnNames)}) = ({string.Join(", ", values)}) must be unique.");
}
private string GetColumnName(Expression<Func<TDto, object?>> expression)
{
Expression body = expression.Body;
if (body is UnaryExpression unaryExpression && unaryExpression.NodeType == ExpressionType.Convert)
body = unaryExpression.Operand;
if (body is MemberExpression memberExpression)
return memberExpression.Member.Name;
if (body is MethodCallExpression methodCallExpression && methodCallExpression.Object is MemberExpression objectMemberExpression)
return objectMemberExpression.Member.Name;
throw new Exception($"Invalid nodetype ({body.NodeType}) in expression");
}
private string GetColumnName<T>(ValidationContext<T> context)
{
if (selectors.Length != 1)
throw new Exception($"Unique index contains several columns, and must not be called on a single property {context.PropertyPath}");
Expression body = selectors[0].Body;
if (body is UnaryExpression unaryExpression && unaryExpression.NodeType == ExpressionType.Convert)
body = unaryExpression.Operand;
if (body is not MemberExpression memberExpression)
throw new Exception($"Invalid nodetype ({body.NodeType}) in expression");
string columnName = memberExpression.Member.Name;
if (columnName != context.PropertyPath.Replace(nameof(UpdateCommand<TDto>.Dto) + ".", ""))
throw new Exception($"Unique index is defined on {columnName} but called on {context.PropertyPath}");
return columnName;
}
private static string EscapeFilterValue(string value)
{
string escaped = value
.Replace("\\", "\\\\")
.Replace(",", "\\,")
.Replace("|", "\\|");
return string.Equals(escaped, "null", StringComparison.Ordinal)
? "\\null"
: escaped;
}
private static string BuildEqualsFilter(string columnName, object? value)
{
string formattedValue = FormatValue(value);
return value is string
? $"{columnName}==*{EscapeFilterValue(formattedValue)}"
: $"{columnName}=={formattedValue}";
}
private static string FormatValue(object? value)
{
return value switch
{
null => "null",
string stringValue => stringValue,
DateOnly dateOnlyValue => dateOnlyValue.ToString("yyyy-MM-dd"),
DateTime dateTimeValue => dateTimeValue.ToString("O"),
bool boolValue => boolValue ? "true" : "false",
IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty
};
}
}

View File

@@ -0,0 +1,27 @@
using FluentValidation;
using MediatR;
namespace Events.WebAPI.Contract.Validation;
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IBaseRequest
{
private readonly IEnumerable<IValidator<TRequest>> validators;
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
{
this.validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
}
return await next();
}
}

View File

@@ -0,0 +1,14 @@
namespace Events.WebAPI.Contract.Validation;
public static class ValidationErrorCodes
{
public const string UniqueConstraintViolation = "unique_constraint_violation";
public const string SportNameNotUnique = "sport_name_not_unique";
public const string PersonDocumentCountryNotUnique = "person_document_country_not_unique";
public const string PersonEmailOrContactPhoneRequired = "person_email_or_contact_phone_required";
public const string RegistrationNotUnique = "registration_not_unique";
public const string ForeignKeyNotFound = "foreign_key_not_found";
public const string EventNotFound = "event_not_found";
public const string PersonNotFound = "person_not_found";
public const string SportNotFound = "sport_not_found";
}

View File

@@ -0,0 +1,3 @@
namespace Events.WebAPI.Contract.Validation;
public sealed record ValidationMessage(string Code, string Message);