Skip to content

Commit

Permalink
Use fluent validation
Browse files Browse the repository at this point in the history
  • Loading branch information
vova-lantsov-dev committed Jan 28, 2023
1 parent 56cef1b commit f203c08
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 34 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using ElectricityOffNotifier.AppHost.Models;
using ElectricityOffNotifier.AppHost.Helpers;
using ElectricityOffNotifier.AppHost.Models;
using ElectricityOffNotifier.Data;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

Expand All @@ -10,17 +13,24 @@ namespace ElectricityOffNotifier.AppHost.Controllers;
public sealed class AddressController : ControllerBase
{
private readonly ElectricityDbContext _context;
private readonly IValidator<FindAddressesModel> _findAddressesModelValidator;

public AddressController(ElectricityDbContext context)
public AddressController(ElectricityDbContext context, IValidator<FindAddressesModel> findAddressesModelValidator)
{
_context = context;
_findAddressesModelValidator = findAddressesModelValidator;
}

[HttpGet]
public async Task<ActionResult<List<AddressModel>>> FindAddressesInCity(
[FromQuery] FindAddressesModel model,
CancellationToken cancellationToken)
{
ValidationResult validationResult = _findAddressesModelValidator.Validate(model);

if (!validationResult.IsValid)
return this.BadRequestExt(validationResult);

return await _context.Addresses
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Street, $"%{model.Street}%") &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public async Task<ActionResult> Ping()
int producerId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier));
int checkerId = int.Parse(User.FindFirstValue(CustomClaimTypes.CheckerId));

var producer = await _context.Producers
Producer producer = await _context.Producers
.Select(c => new Producer {Id = c.Id, IsEnabled = c.IsEnabled, Mode = c.Mode})
.FirstAsync(c => c.Id == producerId);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using ElectricityOffNotifier.AppHost.Auth;
using ElectricityOffNotifier.AppHost.Helpers;
using ElectricityOffNotifier.AppHost.Models;
using ElectricityOffNotifier.Data;
using ElectricityOffNotifier.Data.Models;
using ElectricityOffNotifier.Data.Models.Enums;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PasswordGenerator;
Expand All @@ -15,6 +17,7 @@ public sealed class ProducerController : ControllerBase
{
private readonly ElectricityDbContext _context;
private readonly IConfiguration _configuration;
private readonly IValidator<ProducerRegisterModel> _producerRegisterModelValidator;

private readonly Password _accessTokenGenerator = new(
includeLowercase: true,
Expand All @@ -23,32 +26,21 @@ public sealed class ProducerController : ControllerBase
includeSpecial: false,
passwordLength: 20);

public ProducerController(ElectricityDbContext context, IConfiguration configuration)
public ProducerController(ElectricityDbContext context, IConfiguration configuration,
IValidator<ProducerRegisterModel> producerRegisterModelValidator)
{
_context = context;
_configuration = configuration;
_producerRegisterModelValidator = producerRegisterModelValidator;
}

[HttpPost]
public async Task<ActionResult> Register([FromBody] ProducerRegisterModel model, CancellationToken cancellationToken)
{
if (!await _context.Addresses.AnyAsync(a => a.Id == model.AddressId, cancellationToken))
{
ModelState.AddModelError(nameof(model.AddressId), "Address with specified id was not found.");
return BadRequest(ModelState);
}
ValidationResult validationResult = await _producerRegisterModelValidator.ValidateAsync(model, cancellationToken);

switch (model)
{
case { Mode: ProducerMode.Webhook, WebhookUrl: null }:
ModelState.AddModelError(nameof(model.WebhookUrl),
$"Webhook URL must be set when '{nameof(ProducerMode.Webhook)}' mode is specified.");
return BadRequest(ModelState);
case { Mode: not ProducerMode.Webhook, WebhookUrl: not null }:
ModelState.AddModelError(nameof(model.WebhookUrl),
$"Webhook URL is allowed only when '{nameof(ProducerMode.Webhook)}' mode is specified.");
return BadRequest(ModelState);
}
if (!validationResult.IsValid)
return this.BadRequestExt(validationResult);

string accessToken = _accessTokenGenerator.Next();
byte[] accessTokenSha256Hash = accessToken.ToHmacSha256ByteArray(_configuration["Auth:SecretKey"]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using System.Globalization;
using System.Security.Claims;
using ElectricityOffNotifier.AppHost.Auth;
using ElectricityOffNotifier.AppHost.Helpers;
using ElectricityOffNotifier.AppHost.Models;
using ElectricityOffNotifier.Data;
using ElectricityOffNotifier.Data.Models;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
Expand All @@ -21,19 +24,26 @@ public sealed class SubscriberController : ControllerBase
private readonly ElectricityDbContext _context;
private readonly ITelegramBotClient _botClient;
private readonly ILogger<SubscriberController> _logger;
private readonly IValidator<SubscriberRegisterModel> _subscriberRegisterModelValidator;

public SubscriberController(ElectricityDbContext context, ITelegramBotClient botClient,
ILogger<SubscriberController> logger)
ILogger<SubscriberController> logger, IValidator<SubscriberRegisterModel> subscriberRegisterModelValidator)
{
_context = context;
_botClient = botClient;
_logger = logger;
_subscriberRegisterModelValidator = subscriberRegisterModelValidator;
}

[HttpPost]
public async Task<ActionResult<SubscriberModel>> Register([FromBody] SubscriberRegisterModel model,
CancellationToken cancellationToken)
{
ValidationResult validationResult = _subscriberRegisterModelValidator.Validate(model);

if (!validationResult.IsValid)
return this.BadRequestExt(validationResult);

int checkerId = int.Parse(User.FindFirstValue(CustomClaimTypes.CheckerId));
int producerId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<ItemGroup>
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="6.0.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.4.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.32" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.9.9" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
Expand Down
17 changes: 17 additions & 0 deletions src/ElectricityOffNotifier.AppHost/Helpers/ValidationHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;

namespace ElectricityOffNotifier.AppHost.Helpers;

public static class ValidationHelper
{
public static ActionResult BadRequestExt(this ControllerBase controller, ValidationResult validationResult)
{
foreach (ValidationFailure validationFailure in validationResult.Errors)
{
controller.ModelState.TryAddModelError(validationFailure.PropertyName, validationFailure.ErrorMessage);
}

return controller.BadRequest(controller.ModelState);
}
}
20 changes: 15 additions & 5 deletions src/ElectricityOffNotifier.AppHost/Models/FindAddressesModel.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
using System.ComponentModel.DataAnnotations;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;

namespace ElectricityOffNotifier.AppHost.Models;

public sealed class FindAddressesModel
{
[Range(1, int.MaxValue), FromQuery(Name = "cityId")]
[FromQuery(Name = "cityId")]
public int CityId { get; set; }
[Required, RegularExpression("^[а-яА-Яa-zA-ZїЇєЄґҐ0-9- .']+$"), FromQuery(Name = "street")]
[FromQuery(Name = "street")]
public string Street { get; set; }
[Required, RegularExpression("^[0-9a-zA-Zа-яА-Я- /]+$"), FromQuery(Name = "buildingNo")]
public string BuildingNo { get; set; }
[FromQuery(Name = "buildingNo")]
public string? BuildingNo { get; set; }
}

public sealed class FindAddressesModelValidator : AbstractValidator<FindAddressesModel>
{
public FindAddressesModelValidator()
{
RuleFor(m => m.CityId).GreaterThanOrEqualTo(1);
RuleFor(m => m.Street).NotNull().Matches("^[а-яА-Яa-zA-ZїЇєЄґҐ0-9- .']+$");
RuleFor(m => m.BuildingNo).Matches("^[0-9a-zA-Zа-яА-Я-\\/]+$");
}
}
46 changes: 40 additions & 6 deletions src/ElectricityOffNotifier.AppHost/Models/ProducerRegisterModel.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using ElectricityOffNotifier.Data;
using ElectricityOffNotifier.Data.Models.Enums;
using FluentValidation;
using Microsoft.EntityFrameworkCore;

namespace ElectricityOffNotifier.AppHost.Models;

public sealed record ProducerRegisterModel(
[Range(1, int.MaxValue)]
int AddressId,
[property: JsonConverter(typeof(JsonStringEnumConverter))]
[Required]
ProducerMode Mode,
[Url]
string? WebhookUrl);
string? WebhookUrl);

public sealed class ProducerRegisterModelValidator : AbstractValidator<ProducerRegisterModel>
{
public ProducerRegisterModelValidator(ElectricityDbContext context)
{
// Validate address identifier
RuleFor(m => m.AddressId)
.Cascade(CascadeMode.Stop)
.GreaterThanOrEqualTo(1)
.MustAsync(async (addressId, cancellationToken) =>
await context.Addresses.AnyAsync(a => a.Id == addressId, cancellationToken))
.WithMessage("Address with specified id was not found");

// Validate mode
RuleFor(m => m.Mode).IsInEnum();

// Validate webhook URL
When(m => m.Mode == ProducerMode.Webhook,
() =>
{
RuleFor(m => m.WebhookUrl)
.NotNull()
.WithMessage($"'{{PropertyName}}' must be set when '{nameof(ProducerMode.Webhook)}' mode is specified")
.Must(webhookUrl =>
Uri.TryCreate(webhookUrl, UriKind.Absolute, out Uri? uri) && uri.Scheme is "http" or "https")
.WithMessage("'{PropertyName}' must be a valid URL");
})
.Otherwise(() =>
{
RuleFor(m => m.WebhookUrl)
.Null()
.WithMessage($"'{{PropertyName}}' must be set ONLY when '{nameof(ProducerMode.Webhook)}' mode is specified");
});
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,53 @@
namespace ElectricityOffNotifier.AppHost.Models;
using System.Globalization;
using FluentValidation;
using TimeZoneConverter;

namespace ElectricityOffNotifier.AppHost.Models;

public sealed class SubscriberRegisterModel
{
public long TelegramId { get; set; }
public int? TelegramThreadId { get; set; }
public string? TimeZone { get; set; }
public string? Culture { get; set; }
}

public sealed class SubscriberRegisterModelValidator : AbstractValidator<SubscriberRegisterModel>
{
public SubscriberRegisterModelValidator()
{
// Validate telegram chat identifier
RuleFor(m => m.TelegramId).NotEqual(0L)
.WithMessage("'{PropertyName}' must be a valid Telegram chat identifier");

// Validate telegram chat thread's identifier if set
RuleFor(m => m.TelegramThreadId)
.GreaterThanOrEqualTo(1)
.When(m => m.TelegramThreadId.HasValue)
.WithMessage("'{PropertyName}' must be a valid Telegram chat thread's identifier");

// Validate RFC 4646 culture name
RuleFor(m => m.Culture)
.Must(culture =>
{
try
{
_ = new CultureInfo(culture!);
}
catch (CultureNotFoundException)
{
return false;
}

return true;
})
.When(m => m.Culture != null)
.WithMessage("'{PropertyName}' must be a valid RFC 4646 culture name");

// Validate time zone identifier
RuleFor(m => m.TimeZone)
.Must(timeZone => TZConvert.TryGetTimeZoneInfo(timeZone!, out _))
.When(m => m.TimeZone != null)
.WithMessage("'{PropertyName}' must be a valid Windows or IANA time zone identifier");
}
}
2 changes: 2 additions & 0 deletions src/ElectricityOffNotifier.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using ElectricityOffNotifier.AppHost.Options;
using ElectricityOffNotifier.AppHost.Services;
using ElectricityOffNotifier.Data;
using FluentValidation;
using Hangfire;
using Hangfire.Dashboard;
using Hangfire.PostgreSql;
Expand All @@ -22,6 +23,7 @@

// Configure services
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

string hangfireConnStr = builder.Configuration.GetConnectionString("HangfireConnectionString");
builder.Services.AddHangfire(configuration => configuration
Expand Down

0 comments on commit f203c08

Please sign in to comment.