From 39186573d83b5b1c533bd1addebfc215170bdb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Demicheli?= Date: Thu, 7 May 2026 00:02:04 +0200 Subject: [PATCH 1/4] FEATURE: Query Results and validations. --- .../Endpoints/Companies.cs | 16 +- .../Endpoints/Countries.cs | 8 +- .../Endpoints/Products.cs | 12 +- .../Features/Company/GetCompanyByIdTests.cs | 12 +- .../Features/Company/GetCompanyPageTests.cs | 53 ++-- .../Features/Country/GetCountryByIdTests.cs | 13 +- .../Features/Country/GetCountryListTests.cs | 31 ++- .../Product/DownloadProductPictureTests.cs | 25 +- .../Features/Product/GetProductByIdTests.cs | 13 +- .../Features/Product/GetProductPageTests.cs | 49 ++-- .../Company/Extensions/CompanyExtensions.cs | 26 +- .../Features/Company/GetCompanyById.cs | 9 +- .../Features/Company/GetCompanyPage.cs | 33 ++- .../Country/Extensions/CountryExtensions.cs | 17 +- .../Features/Country/GetCountryById.cs | 8 +- .../Features/Country/GetCountryList.cs | 19 +- .../Product/DownloadProductPicture.cs | 10 +- .../Product/Extensions/ProductExtensions.cs | 60 ++--- .../Features/Product/GetProductById.cs | 13 +- .../Features/Product/GetProductPage.cs | 32 ++- .../ApplicationTests.cs | 4 +- .../MediatorCommandExtensions.cs | 101 ++++++++ .../MediatorExtensions.cs | 184 -------------- .../MediatorQueryExtensions.cs | 121 +++++++++ .../Behaviors/QueryValidationBehavior.cs | 33 +++ .../Queries/Contracts/IPagedQuery.cs | 27 ++ .../Extensions/QueryBehaviorExtensions.cs | 83 ++++++ .../Queries/Extensions/QueryExtensions.cs | 240 +++++++++--------- .../Queries/QueryBase.cs | 15 +- .../Queries/QueryByIdBase.cs | 2 +- .../Queries/QueryPagedBase.cs | 24 +- .../Queries/QueryResult.cs | 18 ++ .../Queries/Validators/QueryBaseValidator.cs | 37 +++ .../Validators/QueryPagedBaseValidator.cs | 57 +++++ .../Context/Extensions/FilterExtensions.cs | 4 +- 35 files changed, 889 insertions(+), 520 deletions(-) create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorCommandExtensions.cs delete mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorExtensions.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Behaviors/QueryValidationBehavior.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Contracts/IPagedQuery.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryResult.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs create mode 100644 src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs index f87733c..4faf21d 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs @@ -25,11 +25,11 @@ public IEndpointRouteBuilder AddCompanies(ApiVersionSet versionSet) "Companies"); companies.MapGet("", - Task>, NotFound>> ([FromServices] ISender sender, - HttpRequest request, - CancellationToken cancellationToken) => - sender.ExecuteQueryAsync(new GetCompanyPage.Query(request.Query), - cancellationToken), + Task>, NotFound, ValidationProblem>> ([FromServices] ISender sender, + HttpRequest request, + CancellationToken cancellationToken) => + sender.ExecutePagedQueryAsync(new GetCompanyPage.Query(request.Query), + cancellationToken), "GetCompanies", #if (!auth) "Gets a page of companies"); @@ -39,9 +39,9 @@ public IEndpointRouteBuilder AddCompanies(ApiVersionSet versionSet) #endif companies.MapGet("{id:guid}", - Task, NotFound>> ([FromServices] ISender sender, - [FromRoute] Guid id, - CancellationToken cancellationToken) => + Task, NotFound>> ([FromServices] ISender sender, + [FromRoute] Guid id, + CancellationToken cancellationToken) => sender.ExecuteQueryAsync(new GetCompanyById.Query(id), cancellationToken), "GetCompany", diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs index 0901a27..c22d207 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs @@ -18,15 +18,15 @@ public IEndpointRouteBuilder AddCountries(ApiVersionSet versionSet) var countries = builder.CreateApiGroupBuilder(versionSet, "Countries"); countries.MapGet("", - Task>, NotFound>> ([FromServices] ISender sender, - HttpRequest request) => + Task>, NotFound, ValidationProblem>> ([FromServices] ISender sender, + HttpRequest request) => sender.ExecuteQueryAsync(new GetCountryList.Query(request.Query)), "GetCountries", "Gets a list of countries"); countries.MapGet("{id:guid}", - Task, NotFound>> ([FromServices] ISender sender, - [FromRoute] Guid id) => + Task, NotFound>> ([FromServices] ISender sender, + [FromRoute] Guid id) => sender.ExecuteQueryAsync(new GetCountryById.Query(id)), "GetCountry", "Gets a country by Id"); diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs index 9ba8234..ffc16a7 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs @@ -25,9 +25,9 @@ public IEndpointRouteBuilder AddProducts(ApiVersionSet versionSet) "Products"); products.MapGet("", - Task>, NotFound>> ([FromServices] ISender sender, - HttpRequest request, - CancellationToken cancellationToken) => + Task>, NotFound, ValidationProblem>> ([FromServices] ISender sender, + HttpRequest request, + CancellationToken cancellationToken) => sender.ExecuteQueryAsync(new GetProductPage.Query(request.Query), cancellationToken), "GetProducts", @@ -39,9 +39,9 @@ public IEndpointRouteBuilder AddProducts(ApiVersionSet versionSet) #endif products.MapGet("{id:guid}", - Task, NotFound>> ([FromServices] ISender sender, - [FromRoute] Guid id, - CancellationToken cancellationToken) => + Task, NotFound>> ([FromServices] ISender sender, + [FromRoute] Guid id, + CancellationToken cancellationToken) => sender.ExecuteQueryAsync(new GetProductById.Query(id), cancellationToken), "GetProduct", diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyByIdTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyByIdTests.cs index f540f85..9eb7831 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyByIdTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyByIdTests.cs @@ -5,6 +5,8 @@ using Monaco.Template.Backend.Domain.Tests.Factories.Entities; using Moq; using System.Diagnostics.CodeAnalysis; +using Monaco.Template.Backend.Application.Features.Company.DTOs; +using Monaco.Template.Backend.Common.Application.Queries; using Xunit; namespace Monaco.Template.Backend.Application.Tests.Features.Company; @@ -27,9 +29,11 @@ public async Task GetExistingCompanyByIdSucceeds() var sut = new GetCompanyById.Handler(_dbContextMock.Object); var result = await sut.Handle(query, CancellationToken.None); - result.Should() - .NotBeNull(); - result!.Name + var success = result.Should() + .BeOfType>(); + success.Subject + .Result + .Name .Should() .Be(company.Name); } @@ -45,6 +49,6 @@ public async Task GetNonExistingCompanyByIdFails() var result = await sut.Handle(query, CancellationToken.None); result.Should() - .BeNull(); + .BeOfType>(); } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyPageTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyPageTests.cs index 6d9b1c3..c8422e1 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyPageTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyPageTests.cs @@ -3,6 +3,8 @@ using Monaco.Template.Backend.Application.Features.Company; using Monaco.Template.Backend.Application.Features.Company.DTOs; using Monaco.Template.Backend.Application.Persistence; +using Monaco.Template.Backend.Common.Application.Queries; +using Monaco.Template.Backend.Common.Domain.Model; using Monaco.Template.Backend.Common.Tests; using Monaco.Template.Backend.Domain.Tests.Factories; using Moq; @@ -23,22 +25,28 @@ public async Task GetCompanyPageWithoutParamsSucceeds(List>()); + var query = new GetCompanyPage.Query([]); var sut = new GetCompanyPage.Handler(_dbContextMock.Object); var result = await sut.Handle(query, CancellationToken.None); - result.Should() - .NotBeNull(); - result!.Pager + var success = result.Should() + .BeOfType>>(); + + success.Subject + .Result + .Pager .Count .Should() .Be(companies.Count); - result.Items - .Should() - .HaveCount(companies.Count).And - .Contain(x => companies.Any(c => c.Name == x.Name)).And - .BeInAscendingOrder(x => x.Name); + + success.Subject + .Result + .Items + .Should() + .HaveCount(companies.Count).And + .Contain(x => companies.Any(c => c.Name == x.Name)).And + .BeInAscendingOrder(x => x.Name); } [Theory(DisplayName = "Get company page with params succeeds")] @@ -50,8 +58,7 @@ public async Task GetCompanyPageWithParamsSucceeds(List> { new(nameof(CompanyDto.Name), - new(companiesSet.Select(x => x.Name) - .ToArray())), + new([..companiesSet.Select(x => x.Name)])), new("expand", nameof(CompanyDto.Country)), new("sort", $"-{nameof(CompanyDto.Name)}") }; @@ -61,16 +68,22 @@ public async Task GetCompanyPageWithParamsSucceeds(List>>(); + + success.Subject + .Result + .Pager .Count .Should() .Be(companiesSet.Count); - result.Items - .Should() - .HaveCount(companiesSet.Count).And - .Contain(x => companiesSet.Any(c => c.Name == x.Name)).And - .BeInDescendingOrder(x => x.Name); + + success.Subject + .Result + .Items + .Should() + .HaveCount(companiesSet.Count).And + .Contain(x => companiesSet.Any(c => c.Name == x.Name)).And + .BeInDescendingOrder(x => x.Name); } -} +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs index b195741..d1203e6 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs @@ -1,6 +1,8 @@ using AwesomeAssertions; using Monaco.Template.Backend.Application.Features.Country; +using Monaco.Template.Backend.Application.Features.Country.DTOs; using Monaco.Template.Backend.Application.Persistence; +using Monaco.Template.Backend.Common.Application.Queries; using Monaco.Template.Backend.Common.Tests; using Monaco.Template.Backend.Domain.Tests.Factories.Entities; using Moq; @@ -27,9 +29,12 @@ public async Task GetExistingCountryByIdSucceeds() var sut = new GetCountryById.Handler(_dbContextMock.Object); var result = await sut.Handle(query, CancellationToken.None); - result.Should() - .NotBeNull(); - result!.Name + var success = result.Should() + .BeOfType>(); + + success.Subject + .Result + .Name .Should() .Be(country.Name); } @@ -44,6 +49,6 @@ public async Task GetNonExistingCountryByIdFails() var result = await sut.Handle(query, CancellationToken.None); result.Should() - .BeNull(); + .BeOfType>(); } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryListTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryListTests.cs index bc93236..ec42d21 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryListTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryListTests.cs @@ -3,6 +3,7 @@ using Monaco.Template.Backend.Application.Features.Country; using Monaco.Template.Backend.Application.Features.Country.DTOs; using Monaco.Template.Backend.Application.Persistence; +using Monaco.Template.Backend.Common.Application.Queries; using Monaco.Template.Backend.Common.Tests; using Monaco.Template.Backend.Domain.Tests.Factories; using Moq; @@ -23,15 +24,20 @@ public async Task GetCountryListWithoutParamsSucceeds(List>()); + var query = new GetCountryList.Query([]); var sut = new GetCountryList.Handler(_dbContextMock.Object); var result = await sut.Handle(query, CancellationToken.None); - result.Should() - .HaveCount(countries.Count).And - .Contain(x => countries.Any(c => c.Name == x.Name)).And - .BeInAscendingOrder(x => x.Name); + var success = result.Should() + .BeOfType>>(); + + success.Subject + .Result + .Should() + .HaveCount(countries.Count).And + .Contain(x => countries.Any(c => c.Name == x.Name)).And + .BeInAscendingOrder(x => x.Name); } [Theory(DisplayName = "Get country list with params succeeds")] @@ -44,7 +50,7 @@ public async Task GetCountryListWithParamsSucceeds(List> { new(nameof(CountryDto.Name), - new(countriesSet.Select(x => x.Name).ToArray())), + new([..countriesSet.Select(x => x.Name)])), new("sort", $"-{nameof(CountryDto.Name)}") }; @@ -54,9 +60,14 @@ public async Task GetCountryListWithParamsSucceeds(List countriesSet.Any(c => c.Name == x.Name)).And - .BeInDescendingOrder(x => x.Name); + var success = result.Should() + .BeOfType>>(); + + success.Subject + .Result + .Should() + .HaveCount(countriesSet.Count).And + .Contain(x => countriesSet.Any(c => c.Name == x.Name)).And + .BeInDescendingOrder(x => x.Name); } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/DownloadProductPictureTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/DownloadProductPictureTests.cs index 12e5927..0662b3b 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/DownloadProductPictureTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/DownloadProductPictureTests.cs @@ -3,6 +3,7 @@ using Monaco.Template.Backend.Application.Persistence; using Monaco.Template.Backend.Application.Services.Contracts; using Monaco.Template.Backend.Common.Application.DTOs; +using Monaco.Template.Backend.Common.Application.Queries; using Monaco.Template.Backend.Common.Tests; using Monaco.Template.Backend.Domain.Tests.Factories; using Moq; @@ -25,7 +26,7 @@ public async Task GetExistingProductPictureSucceeds(List x.DownloadFileAsync(It.IsAny(), @@ -41,9 +42,12 @@ public async Task GetExistingProductPictureSucceeds(List>(); + + success.Subject + .Result + .FileName .Should() .Be(pictureFileName); } @@ -55,7 +59,7 @@ public async Task GetExistingProductPictureThumbnailSucceeds(Domain.Model.Entiti _dbContextMock.CreateAndSetupDbSetMock(products); var product = products.First(); - var picture = product.Pictures.First(); + var picture = product.Pictures[0]; var pictureFileName = $"{picture.Name}{picture.Extension}"; _fileServiceMock.Setup(x => x.DownloadFileAsync(It.IsAny(), It.IsAny())) @@ -70,9 +74,12 @@ public async Task GetExistingProductPictureThumbnailSucceeds(Domain.Model.Entiti var sut = new DownloadProductPicture.Handler(_dbContextMock.Object, _fileServiceMock.Object); var result = await sut.Handle(query, CancellationToken.None); - result.Should() - .NotBeNull(); - result!.FileName + var success = result.Should() + .BeOfType>(); + + success.Subject + .Result + .FileName .Should() .Be(pictureFileName); } @@ -90,6 +97,6 @@ public async Task GetNonExistingProductByIdFails(List>(); } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductByIdTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductByIdTests.cs index 890933b..66f31ed 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductByIdTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductByIdTests.cs @@ -5,6 +5,8 @@ using Monaco.Template.Backend.Domain.Tests.Factories; using Moq; using System.Diagnostics.CodeAnalysis; +using Monaco.Template.Backend.Application.Features.Product.DTOs; +using Monaco.Template.Backend.Common.Application.Queries; using Xunit; namespace Monaco.Template.Backend.Application.Tests.Features.Product; @@ -26,9 +28,12 @@ public async Task GetExistingProductByIdSucceeds(List>(); + + success.Subject + .Result + .Title .Should() .Be(product.Title); } @@ -44,6 +49,6 @@ public async Task GetNonExistingProductByIdFails(List>(); } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductPageTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductPageTests.cs index c6992c1..8b893bb 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductPageTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductPageTests.cs @@ -3,6 +3,8 @@ using Monaco.Template.Backend.Application.Features.Product; using Monaco.Template.Backend.Application.Features.Product.DTOs; using Monaco.Template.Backend.Application.Persistence; +using Monaco.Template.Backend.Common.Application.Queries; +using Monaco.Template.Backend.Common.Domain.Model; using Monaco.Template.Backend.Common.Tests; using Monaco.Template.Backend.Domain.Tests.Factories; using Moq; @@ -27,17 +29,23 @@ public async Task GetProductPageWithoutParamsSucceeds(List>>(); + + success.Subject + .Result + .Pager .Count .Should() .Be(products.Count); - result.Items - .Should() - .HaveCount(products.Count).And - .Contain(x => products.Any(c => c.Title == x.Title)).And - .BeInAscendingOrder(x => x.Title); + + success.Subject + .Result + .Items + .Should() + .HaveCount(products.Count).And + .Contain(x => products.Any(c => c.Title == x.Title)).And + .BeInAscendingOrder(x => x.Title); } [Theory(DisplayName = "Get product page with params succeeds")] @@ -48,8 +56,7 @@ public async Task GetProductPageWithParamsSucceeds(List x.Title) - .ToArray())), + new([..productsSet.Select(x => x.Title)])), new("expand", new StringValues([ nameof(ProductDto.Company), @@ -62,16 +69,22 @@ public async Task GetProductPageWithParamsSucceeds(List>>(); + + success.Subject + .Result + .Pager .Count .Should() .Be(productsSet.Count); - result.Items - .Should() - .HaveCount(productsSet.Count).And - .Contain(x => productsSet.Any(c => c.Title == x.Title)).And - .BeInDescendingOrder(x => x.Title); + + success.Subject + .Result + .Items + .Should() + .HaveCount(productsSet.Count).And + .Contain(x => productsSet.Any(c => c.Title == x.Title)).And + .BeInDescendingOrder(x => x.Title); } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/Extensions/CompanyExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/Extensions/CompanyExtensions.cs index f9b02cb..5b7d054 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/Extensions/CompanyExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/Extensions/CompanyExtensions.cs @@ -7,21 +7,19 @@ namespace Monaco.Template.Backend.Application.Features.Company.Extensions; internal static class CompanyExtensions { - extension(Domain.Model.Entities.Company? value) + extension(Domain.Model.Entities.Company value) { - public CompanyDto? Map(bool expandCountry = false) => - value is null - ? null - : new(value.Id, - value.Name, - value.Email, - value.WebSiteUrl, - value.Address?.Street, - value.Address?.City, - value.Address?.County, - value.Address?.PostCode, - value.Address?.CountryId, - expandCountry ? value.Address?.Country.Map() : null); + public CompanyDto Map(bool expandCountry = false) => + new(value.Id, + value.Name, + value.Email, + value.WebSiteUrl, + value.Address?.Street, + value.Address?.City, + value.Address?.County, + value.Address?.PostCode, + value.Address?.CountryId, + expandCountry ? value.Address?.Country.Map() : null); } extension(CreateCompany.Command value) diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs index df91785..00dc682 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs @@ -9,9 +9,9 @@ namespace Monaco.Template.Backend.Application.Features.Company; public sealed class GetCompanyById { - public sealed record Query(Guid Id) : QueryByIdBase(Id); + public sealed record Query(Guid Id) : QueryByIdBase>(Id); - internal sealed class Handler : IRequestHandler + internal sealed class Handler : IRequestHandler> { private readonly AppDbContext _dbContext; @@ -20,13 +20,14 @@ public Handler(AppDbContext dbContext) _dbContext = dbContext; } - public async Task Handle(Query request, CancellationToken cancellationToken) + public async Task> Handle(Query request, CancellationToken cancellationToken) { var item = await _dbContext.Set() .AsNoTracking() .Include(x => x.Address!.Country) .SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - return item.Map(true); + + return QueryResult.SuccessOrNotFound(item?.Map()); } } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyPage.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyPage.cs index 89ed355..87c2cc9 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyPage.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyPage.cs @@ -1,8 +1,10 @@ -using MediatR; +using System.Linq.Expressions; +using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; using Monaco.Template.Backend.Application.Features.Company.DTOs; using Monaco.Template.Backend.Application.Features.Company.Extensions; +using Monaco.Template.Backend.Application.Features.Country.DTOs; using Monaco.Template.Backend.Application.Persistence; using Monaco.Template.Backend.Common.Application.Queries; using Monaco.Template.Backend.Common.Domain.Model; @@ -12,12 +14,27 @@ namespace Monaco.Template.Backend.Application.Features.Company; public sealed class GetCompanyPage { - public sealed record Query(IEnumerable> QueryParams) : QueryPagedBase(QueryParams) + public sealed record Query(IEnumerable> QueryParams) : QueryPagedBase(QueryParams) { public bool ExpandCountry => Expand(nameof(CompanyDto.Country)); + + public override Dictionary>> GetFilteringMappedFields() => + new() + { + [nameof(CompanyDto.Id)] = x => x.Id, + [nameof(CompanyDto.Name)] = x => x.Name, + [nameof(CompanyDto.Email)] = x => x.Email, + [nameof(CompanyDto.WebSiteUrl)] = x => x.WebSiteUrl!, + [nameof(CompanyDto.Street)] = x => x.Address!.Street!, + [nameof(CompanyDto.City)] = x => x.Address!.City!, + [nameof(CompanyDto.County)] = x => x.Address!.County!, + [nameof(CompanyDto.PostCode)] = x => x.Address!.PostCode!, + [nameof(CompanyDto.CountryId)] = x => x.Address!.CountryId, + [$"{nameof(CompanyDto.Country)}.{nameof(CountryDto.Name)}"] = x => x.Address!.Country.Name + }; } - internal sealed class Handler : IRequestHandler?> + internal sealed class Handler : IRequestHandler>> { private readonly AppDbContext _dbContext; @@ -26,7 +43,7 @@ public Handler(AppDbContext dbContext) _dbContext = dbContext; } - public async Task?> Handle(Query request, CancellationToken cancellationToken) + public async Task>> Handle(Query request, CancellationToken cancellationToken) { var query = _dbContext.Set() .AsNoTracking(); @@ -34,13 +51,13 @@ public Handler(AppDbContext dbContext) if (request.ExpandCountry) query = query.Include(x => x.Address!.Country); - var page = await query.ApplyFilter(request.QueryParams, CompanyExtensions.GetMappedFields()) - .ApplySort(request.Sort, nameof(CompanyDto.Name), CompanyExtensions.GetMappedFields()) + var page = await query.ApplyFilter(request.QueryParams, request.GetFilteringMappedFields()) + .ApplySort(request.Sort, nameof(CompanyDto.Name), request.GetSortingMappedFields()) .ToPageAsync(request.Offset, request.Limit, x => x.Map(request.ExpandCountry)!, cancellationToken); - return page; + return QueryResult>.Success(page); } } -} +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/Extensions/CountryExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/Extensions/CountryExtensions.cs index b689eb7..358897b 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/Extensions/CountryExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/Extensions/CountryExtensions.cs @@ -1,23 +1,12 @@ using Monaco.Template.Backend.Application.Features.Country.DTOs; -using System.Linq.Expressions; namespace Monaco.Template.Backend.Application.Features.Country.Extensions; public static class CountryExtensions { - extension(Domain.Model.Entities.Country? value) + extension(Domain.Model.Entities.Country value) { - public CountryDto? Map() => - value is null - ? null - : new(value.Id, - value.Name); + public CountryDto Map() => + new(value.Id, value.Name); } - - public static Dictionary>> GetMappedFields() => - new() - { - [nameof(CountryDto.Id)] = x => x.Id, - [nameof(CountryDto.Name)] = x => x.Name - }; } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryById.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryById.cs index ad90d38..a24fc7d 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryById.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryById.cs @@ -9,9 +9,9 @@ namespace Monaco.Template.Backend.Application.Features.Country; public sealed class GetCountryById { - public sealed record Query(Guid Id) : QueryByIdBase(Id); + public sealed record Query(Guid Id) : QueryByIdBase>(Id); - internal sealed class Handler : IRequestHandler + internal sealed class Handler : IRequestHandler> { private readonly AppDbContext _dbContext; @@ -20,9 +20,9 @@ public Handler(AppDbContext dbContext) _dbContext = dbContext; } - public Task Handle(Query request, CancellationToken cancellationToken) => + public Task> Handle(Query request, CancellationToken cancellationToken) => request.ExecuteQueryAsync(_dbContext, - x => x.Map(), + x => x?.Map(), cancellationToken); } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryList.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryList.cs index be000c9..47e0c26 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryList.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryList.cs @@ -5,14 +5,23 @@ using Monaco.Template.Backend.Application.Persistence; using Monaco.Template.Backend.Common.Application.Queries; using Monaco.Template.Backend.Common.Application.Queries.Extensions; +using System.Linq.Expressions; namespace Monaco.Template.Backend.Application.Features.Country; public sealed class GetCountryList { - public sealed record Query(IEnumerable> QueryParams) : QueryBase>(QueryParams); + public sealed record Query(IEnumerable> QueryParams) : QueryBase, Domain.Model.Entities.Country>(QueryParams) + { + public override Dictionary>> GetFilteringMappedFields() => + new() + { + [nameof(CountryDto.Id)] = x => x.Id, + [nameof(CountryDto.Name)] = x => x.Name + }; + } - internal sealed class Handler : IRequestHandler> + internal sealed class Handler : IRequestHandler>> { private readonly AppDbContext _dbContext; @@ -21,11 +30,11 @@ public Handler(AppDbContext dbContext) _dbContext = dbContext; } - public Task> Handle(Query request, CancellationToken cancellationToken) => + public Task>> Handle(Query request, CancellationToken cancellationToken) => request.ExecuteQueryAsync(_dbContext, - x => x.Map()!, + x => x.Map(), nameof(CountryDto.Name), - CountryExtensions.GetMappedFields(), + request.GetFilteringMappedFields(), cancellationToken); } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/DownloadProductPicture.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/DownloadProductPicture.cs index b359685..8c081bd 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/DownloadProductPicture.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/DownloadProductPicture.cs @@ -12,12 +12,12 @@ public sealed class DownloadProductPicture { public sealed record Query(Guid ProductId, Guid PictureId, - IEnumerable> QueryParams) : QueryBase(QueryParams) + IEnumerable> QueryParams) : QueryBase>(QueryParams) { public bool? IsThumbnail => GetValueBool("thumbnail"); }; - internal sealed class Handler : IRequestHandler + internal sealed class Handler : IRequestHandler> { private readonly AppDbContext _dbContext; private readonly IFileService _fileService; @@ -28,7 +28,7 @@ public Handler(AppDbContext dbContext, IFileService fileService) _fileService = fileService; } - public async Task Handle(Query request, CancellationToken cancellationToken) + public async Task> Handle(Query request, CancellationToken cancellationToken) { var query = _dbContext.Set() .AsNoTracking() @@ -42,8 +42,8 @@ public Handler(AppDbContext dbContext, IFileService fileService) var item = await query.SingleOrDefaultAsync(cancellationToken); return item is null - ? null - : await _fileService.DownloadFileAsync(item, cancellationToken); + ? QueryResult.NotFound() + : QueryResult.Success(await _fileService.DownloadFileAsync(item, cancellationToken)); } } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/Extensions/ProductExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/Extensions/ProductExtensions.cs index 9d286bf..a129afb 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/Extensions/ProductExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/Extensions/ProductExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Monaco.Template.Backend.Application.Features.Company.DTOs; using Monaco.Template.Backend.Application.Features.Company.Extensions; using Monaco.Template.Backend.Application.Features.File.Extensions; using Monaco.Template.Backend.Application.Features.Product.DTOs; @@ -8,38 +7,33 @@ #if massTransitIntegration using Monaco.Template.Backend.Messages.V1; #endif -using System.Linq.Expressions; namespace Monaco.Template.Backend.Application.Features.Product.Extensions; public static class ProductExtensions { - extension(Domain.Model.Entities.Product? value) + extension(Domain.Model.Entities.Product value) { - public ProductDto? Map(bool expandCompany = false, - bool expandPictures = false, - bool expandDefaultPicture = false) => - value is null - ? null - : new(value.Id, - value.Title, - value.Description, - value.Price, - value.CompanyId, - expandCompany - ? value.Company - .Map() - : null, - expandPictures - ? value.Pictures - .Select(x => x.Map()!) - .ToArray() - : null, - value.DefaultPictureId, - expandDefaultPicture - ? value.DefaultPicture - .Map() - : null); + public ProductDto Map(bool expandCompany = false, + bool expandPictures = false, + bool expandDefaultPicture = false) => + new(value.Id, + value.Title, + value.Description, + value.Price, + value.CompanyId, + expandCompany + ? value.Company + .Map() + : null, + expandPictures + ? [..value.Pictures.Select(x => x.Map()!)] + : null, + value.DefaultPictureId, + expandDefaultPicture + ? value.DefaultPicture + .Map() + : null); } #if (massTransitIntegration) @@ -69,16 +63,4 @@ internal ProductCreated MapMessage() => return (company, pics); } } - - public static Dictionary>> GetMappedFields() => - new() - { - [nameof(ProductDto.Id)] = x => x.Id, - [nameof(ProductDto.Title)] = x => x.Title, - [nameof(ProductDto.Description)] = x => x.Description, - [nameof(ProductDto.Price)] = x => x.Price, - [nameof(ProductDto.CompanyId)] = x => x.CompanyId, - [$"{nameof(ProductDto.Company)}.{nameof(CompanyDto.Name)}"] = x => x.Company.Name, - [nameof(ProductDto.DefaultPictureId)] = x => x.DefaultPictureId - }; } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductById.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductById.cs index b36c2c2..c465720 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductById.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductById.cs @@ -10,9 +10,9 @@ namespace Monaco.Template.Backend.Application.Features.Product; public sealed class GetProductById { - public sealed record Query(Guid Id) : QueryByIdBase(Id); + public sealed record Query(Guid Id) : QueryByIdBase>(Id); - internal sealed class Handler : IRequestHandler + internal sealed class Handler : IRequestHandler> { private readonly AppDbContext _dbContext; @@ -21,13 +21,16 @@ public Handler(AppDbContext dbContext) _dbContext = dbContext; } - public async Task Handle(Query request, CancellationToken cancellationToken) + public async Task> Handle(Query request, CancellationToken cancellationToken) { var item = await _dbContext.Set() .AsNoTracking() .Include(x => x.Company) + .Include(x => x.Pictures) + .ThenInclude(x => x.Thumbnail) + .Include(x => x.DefaultPicture) .SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - return item.Map(true, true, true); + return QueryResult.SuccessOrNotFound(item?.Map(true, true, true)); } } -} +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductPage.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductPage.cs index 4028e95..38b8f92 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductPage.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductPage.cs @@ -1,6 +1,8 @@ -using MediatR; +using System.Linq.Expressions; +using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; +using Monaco.Template.Backend.Application.Features.Company.DTOs; using Monaco.Template.Backend.Application.Features.Product.DTOs; using Monaco.Template.Backend.Application.Features.Product.Extensions; using Monaco.Template.Backend.Application.Persistence; @@ -12,16 +14,26 @@ namespace Monaco.Template.Backend.Application.Features.Product; public sealed class GetProductPage { - public sealed record Query(IEnumerable> QueryParams) : QueryPagedBase(QueryParams) + public sealed record Query(IEnumerable> QueryParams) : QueryPagedBase(QueryParams) { public bool ExpandCompany => Expand(nameof(ProductDto.Company)); - public bool ExpandPictures => Expand(nameof(ProductDto.Pictures)); - public bool ExpandDefaultPicture => Expand(nameof(ProductDto.DefaultPicture)); + + public override Dictionary>> GetFilteringMappedFields() => + new() + { + [nameof(ProductDto.Id)] = x => x.Id, + [nameof(ProductDto.Title)] = x => x.Title, + [nameof(ProductDto.Description)] = x => x.Description, + [nameof(ProductDto.Price)] = x => x.Price, + [nameof(ProductDto.CompanyId)] = x => x.CompanyId, + [$"{nameof(ProductDto.Company)}.{nameof(CompanyDto.Name)}"] = x => x.Company.Name, + [nameof(ProductDto.DefaultPictureId)] = x => x.DefaultPictureId + }; } - internal sealed class Handler : IRequestHandler?> + internal sealed class Handler : IRequestHandler>> { private readonly AppDbContext _dbContext; @@ -30,7 +42,7 @@ public Handler(AppDbContext dbContext) _dbContext = dbContext; } - public async Task?> Handle(Query request, CancellationToken cancellationToken) + public async Task>> Handle(Query request, CancellationToken cancellationToken) { var query = _dbContext.Set() .AsNoTracking(); @@ -43,15 +55,15 @@ public Handler(AppDbContext dbContext) if (request.ExpandDefaultPicture) query = query.Include(x => x.DefaultPicture); - var page = await query.ApplyFilter(request.QueryParams, ProductExtensions.GetMappedFields()) - .ApplySort(request.Sort, nameof(ProductDto.Title), ProductExtensions.GetMappedFields()) + var page = await query.ApplyFilter(request.QueryParams, request.GetFilteringMappedFields()) + .ApplySort(request.Sort, nameof(ProductDto.Title), request.GetSortingMappedFields()) .ToPageAsync(request.Offset, request.Limit, x => x.Map(request.ExpandCompany, request.ExpandPictures, - request.ExpandDefaultPicture)!, + request.ExpandDefaultPicture), cancellationToken); - return page; + return QueryResult>.Success(page); } } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.ArchitectureTests/ApplicationTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.ArchitectureTests/ApplicationTests.cs index 28909cd..ff2852b 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.ArchitectureTests/ApplicationTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.ArchitectureTests/ApplicationTests.cs @@ -54,7 +54,9 @@ public class ApplicationTests : BaseTest private readonly GivenClassesConjunction _validators = Classes().That() .AreAssignableTo(Validator) .And() - .AreNotAbstract(); + .AreNotAbstract() + .And() + .ResideInAssembly(ApplicationAssembly); private static readonly Class AggregateRoot = Architecture.GetClassOfType(typeof(AggregateRoot)); private static readonly Class Entity = Architecture.GetClassOfType(typeof(Entity)); diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorCommandExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorCommandExtensions.cs new file mode 100644 index 0000000..d356a90 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorCommandExtensions.cs @@ -0,0 +1,101 @@ +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Monaco.Template.Backend.Common.Application.Commands; +using NotFound = Microsoft.AspNetCore.Http.HttpResults.NotFound; + +namespace Monaco.Template.Backend.Common.Api.Application; + +public static class MediatorCommandExtensions +{ + extension(ISender sender) + { + /// + /// Executes the command passed and returns the corresponding response that can be either , a , a , or a depending on the validations and processing. + /// + /// + /// The URI to include in the headers of the Created() response + /// The parameters (if any) to pass for concatenating into the resultUri + /// + /// + public async Task, NotFound, ValidationProblem, Conflict, ForbidHttpResult>> ExecuteCommandCreatedAsync(CommandBase command, + string resultUri, + object[]? uriParams = null, + CancellationToken cancellationToken = default) => + await sender.ExecuteCommandAsync(command, + result => TypedResults.Created(string.Format(resultUri, [.. uriParams ?? [], result]), + new CreatedResponse(result)), + cancellationToken); + + /// + /// Executes the command passed and returns the corresponding response that can be either , a , a , or a depending on the validations and processing. + /// + /// + /// + /// + public async Task> ExecuteCommandNoContentAsync(CommandBase command, + CancellationToken cancellationToken = default) => + await sender.ExecuteCommandAsync(command, TypedResults.NoContent(), cancellationToken); + + /// + /// Executes the command passed and returns the corresponding response that can be either , a , a , or a depending on the validations and processing. + /// + /// + /// + /// + public async Task> ExecuteCommandOkAsync(CommandBase command, + CancellationToken cancellationToken = default) => + await sender.ExecuteCommandAsync(command, TypedResults.Ok(), cancellationToken); + + /// + /// Executes the command passed and returns the corresponding response that can be either a user-defined , a , a , or a depending on the validations and processing. + /// + /// The type of the success response. Must implement . + /// + /// + /// + /// + public async Task> ExecuteCommandAsync(CommandBase command, + TResponse response, + CancellationToken cancellationToken = default) + where TResponse : IResult + { + var result = await sender.Send(command, cancellationToken); + return result switch + { + Common.Application.Commands.NotFound => TypedResults.NotFound(), + ValidationFailure validationFailed => TypedResults.ValidationProblem(validationFailed.ValidationResult.ToDictionary()), + ConcurrencyConflict => TypedResults.Conflict(), + Forbidden => TypedResults.Forbid(), + Success => response, + _ => throw new InvalidOperationException($"Unexpected command result type '{result.GetType().FullName ?? "null"}' returned for command '{command.GetType().FullName}'.") + }; + } + + /// + /// Executes the command passed and returns the corresponding response that can be either an calculated based on a function, a , a , or a depending on the validations and processing. + /// + /// The type of the result returned by the command. + /// The type of the success response. Must implement . + /// + /// A function to convert the result to the desired response type + /// + /// + public async Task> ExecuteCommandAsync(CommandBase command, + Func func, + CancellationToken cancellationToken = default) + where TResponse : IResult + { + var result = await sender.Send(command, cancellationToken); + return result switch + { + Common.Application.Commands.NotFound => TypedResults.NotFound(), + ValidationFailure validationFailed => TypedResults.ValidationProblem(validationFailed.ValidationResult.ToDictionary()), + ConcurrencyConflict => TypedResults.Conflict(), + Forbidden => TypedResults.Forbid(), + Success success => func(success.Result), + _ => throw new InvalidOperationException($"Unexpected command result type '{result.GetType().FullName ?? "null"}' returned for command '{command.GetType().FullName}'.") + }; + } + } +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorExtensions.cs deleted file mode 100644 index c81717d..0000000 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorExtensions.cs +++ /dev/null @@ -1,184 +0,0 @@ -using MediatR; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Monaco.Template.Backend.Common.Application.Commands; -using Monaco.Template.Backend.Common.Application.DTOs; -using Monaco.Template.Backend.Common.Application.Queries; -using Monaco.Template.Backend.Common.Domain.Model; -using NotFound = Microsoft.AspNetCore.Http.HttpResults.NotFound; - -namespace Monaco.Template.Backend.Common.Api.Application; - -public static class MediatorExtensions -{ - extension(ISender sender) - { - /// - /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the retuned result is null or not - /// - /// The type of the records returned by the query - /// - /// - /// - public async Task, NotFound>> ExecuteQueryAsync(QueryBase query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); - - /// - /// Executes the paged query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned result is null or not - /// - /// The type of the records contained in the page returned by the query - /// - /// - /// - public async Task>, NotFound>> ExecuteQueryAsync(QueryPagedBase query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); - - /// - /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not - /// - /// The type of the item returned by the query - /// - /// - /// - public async Task, NotFound>> ExecuteQueryAsync(QueryByIdBase query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); - - /// - /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not - /// - /// The type of the item returned by the query - /// The type of the key to search the item by - /// - /// - /// - public async Task, NotFound>> ExecuteQueryAsync(QueryByKeyBase query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); - - /// - /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not - /// - /// - /// - /// - /// - public async Task> ExecuteFileDownloadAsync(QueryBase query, - CancellationToken cancellationToken = default) where TResult : FileDownloadDto => - GetFileDownload(await sender.Send(query, cancellationToken)); - - /// - /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not - /// - /// - /// - /// - /// - public async Task> ExecuteFileDownloadAsync(QueryByIdBase query, - CancellationToken cancellationToken = default) where TResult : FileDownloadDto => - GetFileDownload(await sender.Send(query, cancellationToken)); - - /// - /// Executes the command passed and returns the corresponding response that can be either , a , a , or a depending on the validations and processing. - /// - /// - /// The URI to include in the headers of the Created() response - /// The parameters (if any) to pass for concatenating into the resultUri - /// - /// - public async Task, NotFound, ValidationProblem, Conflict, ForbidHttpResult>> ExecuteCommandCreatedAsync(CommandBase command, - string resultUri, - object[]? uriParams = null, - CancellationToken cancellationToken = default) => - await sender.ExecuteCommandAsync(command, - result => TypedResults.Created(string.Format(resultUri, [.. uriParams ?? [], result]), - new CreatedResponse(result)), - cancellationToken); - - /// - /// Executes the command passed and returns the corresponding response that can be either , a , a , or a depending on the validations and processing. - /// - /// - /// - /// - public async Task> ExecuteCommandNoContentAsync(CommandBase command, - CancellationToken cancellationToken = default) => - await sender.ExecuteCommandAsync(command, TypedResults.NoContent(), cancellationToken); - - /// - /// Executes the command passed and returns the corresponding response that can be either , a , a , or a depending on the validations and processing. - /// - /// - /// - /// - public async Task> ExecuteCommandOkAsync(CommandBase command, - CancellationToken cancellationToken = default) => - await sender.ExecuteCommandAsync(command, TypedResults.Ok(), cancellationToken); - - /// - /// Executes the command passed and returns the corresponding response that can be either a user-defined , a , a , or a depending on the validations and processing. - /// - /// The type of the success response. Must implement . - /// - /// - /// - /// - public async Task> ExecuteCommandAsync(CommandBase command, - TResponse response, - CancellationToken cancellationToken = default) - where TResponse : IResult - { - var result = await sender.Send(command, cancellationToken); - return result switch - { - Common.Application.Commands.NotFound => TypedResults.NotFound(), - ValidationFailure validationFailed => TypedResults.ValidationProblem(validationFailed.ValidationResult.ToDictionary()), - ConcurrencyConflict => TypedResults.Conflict(), - Forbidden => TypedResults.Forbid(), - Success => response, - _ => throw new InvalidOperationException($"Unexpected command result type '{result.GetType().FullName ?? "null"}' returned for command '{command.GetType().FullName}'.") - }; - } - - /// - /// Executes the command passed and returns the corresponding response that can be either an calculated based on a function, a , a , or a depending on the validations and processing. - /// - /// The type of the result returned by the command. - /// The type of the success response. Must implement . - /// - /// A function to convert the result to the desired response type - /// - /// - public async Task> ExecuteCommandAsync(CommandBase command, - Func func, - CancellationToken cancellationToken = default) - where TResponse : IResult - { - var result = await sender.Send(command, cancellationToken); - return result switch - { - Common.Application.Commands.NotFound => TypedResults.NotFound(), - ValidationFailure validationFailed => TypedResults.ValidationProblem(validationFailed.ValidationResult.ToDictionary()), - ConcurrencyConflict => TypedResults.Conflict(), - Forbidden => TypedResults.Forbid(), - Success success => func(success.Result), - _ => throw new InvalidOperationException($"Unexpected command result type '{result.GetType().FullName ?? "null"}' returned for command '{command.GetType().FullName}'.") - }; - } - } - - private static Results GetFileDownload(TResult? item) where TResult : FileDownloadDto => - item is null - ? TypedResults.NotFound() - : TypedResults.File(item.FileContent, - item.ContentType, - item.FileName); - - - private static Results, NotFound> OkOrNotFound(TResult? result) => - result is null - ? TypedResults.NotFound() - : TypedResults.Ok(result); -} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs new file mode 100644 index 0000000..e962c19 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs @@ -0,0 +1,121 @@ +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Monaco.Template.Backend.Common.Application.DTOs; +using Monaco.Template.Backend.Common.Application.Queries; +using Monaco.Template.Backend.Common.Domain.Model; + +namespace Monaco.Template.Backend.Common.Api.Application; + +public static class MediatorQueryExtensions +{ + extension(ISender sender) + { + /// + /// Executes a query returning and maps it to Ok, NotFound or ValidationProblem. + /// + public async Task, NotFound, ValidationProblem>> ExecuteQueryAsync(QueryBase query, + CancellationToken cancellationToken = default) where TEntity : class + { + var result = await sender.Send(query, cancellationToken); + return result switch + { + Success success => TypedResults.Ok(success.Result), + Common.Application.Queries.NotFound => TypedResults.NotFound(), + ValidationFailure f => TypedResults.ValidationProblem(f.ValidationResult.ToDictionary()), + _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned for query '{query.GetType().FullName}'.") + }; + } + + /// + /// Executes a paged query returning and maps it to Ok, NotFound or ValidationProblem. + /// + public async Task>, NotFound, ValidationProblem>> ExecutePagedQueryAsync(QueryPagedBase query, + CancellationToken cancellationToken = default) where TEntity : class => + await sender.ExecuteQueryAsync(query, cancellationToken); + + /// + /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the retuned result is null or not + /// + /// The type of the records returned by the query + /// + /// + /// + public async Task, NotFound>> ExecuteQueryAsync(QueryBase> query, + CancellationToken cancellationToken = default) => + OkOrNotFound(await sender.Send(query, cancellationToken)); + + /// + /// Executes the paged query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned result is null or not + /// + /// The type of the records contained in the page returned by the query + /// + /// + /// + public async Task>, NotFound>> ExecuteQueryAsync(QueryPagedBase query, + CancellationToken cancellationToken = default) => + OkOrNotFound(await sender.Send(query, cancellationToken)); + + /// + /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not + /// + /// The type of the item returned by the query + /// + /// + /// + public async Task, NotFound>> ExecuteQueryAsync(QueryByIdBase> query, + CancellationToken cancellationToken = default) => + OkOrNotFound(await sender.Send(query, cancellationToken)); + + /// + /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not + /// + /// The type of the item returned by the query + /// The type of the key to search the item by + /// + /// + /// + public async Task, NotFound>> ExecuteQueryAsync(QueryByKeyBase, TKey> query, + CancellationToken cancellationToken = default) => + OkOrNotFound(await sender.Send(query, cancellationToken)); + + /// + /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not + /// + /// + /// + /// + /// + public async Task> ExecuteFileDownloadAsync(QueryBase> query, + CancellationToken cancellationToken = default) where TResult : FileDownloadDto => + FileDownloadOrNotFound(await sender.Send(query, cancellationToken)); + + /// + /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not + /// + /// + /// + /// + /// + public async Task> ExecuteFileDownloadAsync(QueryByIdBase> query, + CancellationToken cancellationToken = default) where TResult : FileDownloadDto => + FileDownloadOrNotFound(await sender.Send(query, cancellationToken)); + } + + private static Results FileDownloadOrNotFound(QueryResult result) where TResult : FileDownloadDto => + ResponseOrNotFound(result, + success => TypedResults.File(success.FileContent, + success.ContentType, + success.FileName)); + + private static Results, NotFound> OkOrNotFound(QueryResult result) => + ResponseOrNotFound(result, TypedResults.Ok); + + private static Results ResponseOrNotFound(QueryResult result, Func func) where TResponse : IResult => + result switch + { + Success success => func(success.Result), + Common.Application.Queries.NotFound => TypedResults.NotFound(), + _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.") + }; +} diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Behaviors/QueryValidationBehavior.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Behaviors/QueryValidationBehavior.cs new file mode 100644 index 0000000..762b1a8 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Behaviors/QueryValidationBehavior.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using MediatR; + +namespace Monaco.Template.Backend.Common.Application.Queries.Behaviors; + +public class QueryValidationBehavior : IPipelineBehavior> + where TQuery : IRequest> +{ + private readonly IEnumerable> _validators; + + public QueryValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task> Handle(TQuery request, + RequestHandlerDelegate> next, + CancellationToken cancellationToken) + { + if (!_validators.Any()) + return await next(cancellationToken); + + var context = new ValidationContext(request); + var failures = (await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)))) + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + return failures.Count > 0 + ? QueryResult.ValidationFailure(new(failures)) + : await next(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Contracts/IPagedQuery.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Contracts/IPagedQuery.cs new file mode 100644 index 0000000..ea79f60 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Contracts/IPagedQuery.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Primitives; +using Monaco.Template.Backend.Common.Domain.Model; + +namespace Monaco.Template.Backend.Common.Application.Queries.Contracts; + +public interface IPagedQuery +{ + const int MaxLimit = 100; + + IEnumerable> QueryParams { get; } + + static int ParseOffset(IEnumerable> queryParams) => + queryParams.FirstOrDefault(x => x.Key.Equals(nameof(Page<>.Pager.Offset), StringComparison.InvariantCultureIgnoreCase)) + .Value + .Select(x => int.TryParse(x, out var y) ? y : 0) + .Where(x => x >= 0) + .DefaultIfEmpty(0) + .FirstOrDefault(); + + static int ParseLimit(IEnumerable> queryParams) => + queryParams.FirstOrDefault(x => x.Key.Equals(nameof(Page<>.Pager.Limit), StringComparison.InvariantCultureIgnoreCase)) + .Value + .Select(x => int.TryParse(x, out var y) ? y : 0) + .Where(x => x is > 0 and <= MaxLimit) + .DefaultIfEmpty(10) + .FirstOrDefault(); +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs new file mode 100644 index 0000000..2c7da91 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs @@ -0,0 +1,83 @@ +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Monaco.Template.Backend.Common.Application.Queries.Behaviors; +using System.Reflection; +using Monaco.Template.Backend.Common.Application.Queries.Validators; + +namespace Monaco.Template.Backend.Common.Application.Queries.Extensions; + +public static class QueryBehaviorExtensions +{ + extension(IServiceCollection services) + { + /// + /// Scans the assembly for query types returning , registers + /// for each, and automatically registers + /// the appropriate default base validators ( or + /// ). + /// Specific validators already registered in DI will also be picked up by the behavior. + /// + public IServiceCollection RegisterQueryValidationBehaviors(Assembly assembly) + { + var queryTypes = assembly.GetTypes() + .Where(t => !t.IsAbstract && + t.GetInterfaces().Any(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IRequest<>) && + i.GenericTypeArguments[0].IsGenericType && + i.GenericTypeArguments[0].GetGenericTypeDefinition() == typeof(QueryResult<>))); + + foreach (var queryType in queryTypes) + { + var resultType = queryType.GetInterfaces() + .First(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IRequest<>)) + .GenericTypeArguments[0] // QueryResult + .GenericTypeArguments[0]; // T + + // Register the behavior + services.AddScoped(typeof(IPipelineBehavior<,>).MakeGenericType(queryType, typeof(QueryResult<>).MakeGenericType(resultType)), + typeof(QueryValidationBehavior<,>).MakeGenericType(queryType, resultType)); + + // Register default base validators + RegisterDefaultValidators(services, queryType); + } + + return services; + } + } + + private static void RegisterDefaultValidators(IServiceCollection services, Type queryType) + { + var validatorInterface = typeof(IValidator<>).MakeGenericType(queryType); + var baseType = queryType.BaseType; + + while (baseType is not null && baseType != typeof(object)) + { + if (baseType.IsGenericType) + { + var found = true; + switch (baseType.GetGenericTypeDefinition()) + { + case var t when t == typeof(QueryPagedBase<,>): + services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); + services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); + break; + case var t when t == typeof(QueryPagedBase<>): + services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<>).MakeGenericType(baseType.GenericTypeArguments)); + break; + case var t when t == typeof(QueryBase<,>): + services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); + break; + default: + found = false; + break; + } + + if (found) break; + } + + baseType = baseType.BaseType; + } + } +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryExtensions.cs index b71c468..0abd645 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryExtensions.cs @@ -8,28 +8,28 @@ namespace Monaco.Template.Backend.Common.Application.Queries.Extensions; public static class QueryExtensions { - extension(QueryBase> request) where T : Entity + extension(QueryBase, TEntity> request) where TEntity : Entity { - public async Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - Dictionary>> mappedFieldsSort, - CancellationToken cancellationToken) + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + Dictionary>> mappedFieldsSort, + CancellationToken cancellationToken) { - var result = await dbContext.Set() + var result = await dbContext.Set() .AsNoTracking() .ApplyFilter(request.QueryParams, mappedFieldsFilter) .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) .ToListAsync(cancellationToken); - return [.. result.Select(selector)]; + return QueryResult>.Success([..result.Select(selector)]); } - - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - CancellationToken cancellationToken) => + + public Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + CancellationToken cancellationToken) => request.ExecuteQueryAsync(dbContext, selector, defaultSortField, @@ -37,29 +37,29 @@ public Task> ExecuteQueryAsync(BaseDbContext dbContext, mappedFieldsFilter, cancellationToken); - public async Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func, IQueryable> queryFunc, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - Dictionary>> mappedFieldsSort, - CancellationToken cancellationToken) + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func, IQueryable> queryFunc, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + Dictionary>> mappedFieldsSort, + CancellationToken cancellationToken) { - var query = dbContext.Set().AsQueryable(); + var query = dbContext.Set().AsQueryable(); query = queryFunc.Invoke(query); var result = await query.ApplyFilter(request.QueryParams, mappedFieldsFilter) .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) .ToListAsync(cancellationToken); - return [.. result.Select(selector)]; + return QueryResult>.Success([.. result.Select(selector)]); } - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func, IQueryable> queryFunc, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - CancellationToken cancellationToken) => + public Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func, IQueryable> queryFunc, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + CancellationToken cancellationToken) => request.ExecuteQueryAsync(dbContext, selector, queryFunc, @@ -68,29 +68,29 @@ public Task> ExecuteQueryAsync(BaseDbContext dbContext, mappedFieldsFilter, cancellationToken); - public async Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func>, Expression>> expression, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - Dictionary>> mappedFieldsSort, - CancellationToken cancellationToken) + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func, TEntity>, Expression>> expression, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + Dictionary>> mappedFieldsSort, + CancellationToken cancellationToken) { - var result = await dbContext.Set() + var result = await dbContext.Set() .AsNoTracking() .Where(expression.Invoke(request)) .ApplyFilter(request.QueryParams, mappedFieldsFilter) .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) .ToListAsync(cancellationToken); - return [.. result.Select(selector)]; + return QueryResult>.Success([.. result.Select(selector)]); } - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func>, Expression>> expression, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - CancellationToken cancellationToken) => + public Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func, TEntity>, Expression>> expression, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + CancellationToken cancellationToken) => request.ExecuteQueryAsync(dbContext, selector, expression, @@ -100,31 +100,31 @@ public Task> ExecuteQueryAsync(BaseDbContext dbContext, cancellationToken); } - extension(TReq request) where TReq : QueryBase> where T : Entity + extension(TReq request) where TReq : QueryBase, TEntity> where TEntity : Entity { - public async Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func>> expression, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - Dictionary>> mappedFieldsSort, - CancellationToken cancellationToken) + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func>> expression, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + Dictionary>> mappedFieldsSort, + CancellationToken cancellationToken) { - var result = await dbContext.Set() + var result = await dbContext.Set() .AsNoTracking() .Where(expression.Invoke(request)) .ApplyFilter(request.QueryParams, mappedFieldsFilter) .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) .ToListAsync(cancellationToken); - return [.. result.Select(selector)]; + return QueryResult>.Success([..result.Select(selector)]); } - - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func>> expression, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - CancellationToken cancellationToken) => + + public Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func>> expression, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + CancellationToken cancellationToken) => request.ExecuteQueryAsync(dbContext, selector, expression, @@ -134,86 +134,86 @@ public Task> ExecuteQueryAsync(BaseDbContext dbContext, cancellationToken); } - extension(QueryPagedBase request) where T : Entity + extension(QueryPagedBase request) where TEntity : Entity { - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - Dictionary>> mappedFieldsSort, - CancellationToken cancellationToken) => - dbContext.Set() - .AsNoTracking() - .ApplyFilter(request.QueryParams, mappedFieldsFilter) - .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) - .ToPageAsync(request.Offset, request.Limit, selector, cancellationToken); - - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - CancellationToken cancellationToken) => - request.ExecuteQueryAsync(dbContext, - selector, - defaultSortField, - mappedFieldsFilter, - mappedFieldsFilter, - cancellationToken); + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + Dictionary>> mappedFieldsSort, + CancellationToken cancellationToken) => + QueryResult>.Success(await dbContext.Set() + .AsNoTracking() + .ApplyFilter(request.QueryParams, mappedFieldsFilter) + .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) + .ToPageAsync(request.Offset, request.Limit, selector, cancellationToken)); - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func, Expression>> expression, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - Dictionary>> mappedFieldsSort, - CancellationToken cancellationToken) => - dbContext.Set() - .AsNoTracking() - .Where(expression.Invoke(request)) - .ApplyFilter(request.QueryParams, mappedFieldsFilter) - .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) - .ToPageAsync(request.Offset, request.Limit, selector, cancellationToken); - - public Task> ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func, Expression>> expression, - string defaultSortField, - Dictionary>> mappedFieldsFilter, - CancellationToken cancellationToken) => + public Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + CancellationToken cancellationToken) => request.ExecuteQueryAsync(dbContext, selector, - expression, defaultSortField, mappedFieldsFilter, mappedFieldsFilter, cancellationToken); + + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func, Expression>> expression, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + Dictionary>> mappedFieldsSort, + CancellationToken cancellationToken) => + QueryResult>.Success(await dbContext.Set() + .AsNoTracking() + .Where(expression.Invoke(request)) + .ApplyFilter(request.QueryParams, mappedFieldsFilter) + .ApplySort(request.Sort, defaultSortField, mappedFieldsSort) + .ToPageAsync(request.Offset, request.Limit, selector, cancellationToken)); + + public async Task>> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func, Expression>> expression, + string defaultSortField, + Dictionary>> mappedFieldsFilter, + CancellationToken cancellationToken) => + await request.ExecuteQueryAsync(dbContext, + selector, + expression, + defaultSortField, + mappedFieldsFilter, + mappedFieldsFilter, + cancellationToken); } - extension(QueryByIdBase request) where T : Entity + extension(QueryByIdBase> request) where TEntity : Entity { - public async Task ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - CancellationToken cancellationToken) + public async Task> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + CancellationToken cancellationToken) { - var item = await dbContext.Set() + var item = await dbContext.Set() .AsNoTracking() .SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - return selector.Invoke(item); + return QueryResult.SuccessOrNotFound(selector.Invoke(item)); } } - extension(TReq request) where TReq : QueryByIdBase where T : Entity + extension(TReq request) where TReq : QueryByIdBase> where TEntity : Entity { - public async Task ExecuteQueryAsync(BaseDbContext dbContext, - Func selector, - Func>> expression, - CancellationToken cancellationToken) + public async Task> ExecuteQueryAsync(BaseDbContext dbContext, + Func selector, + Func>> expression, + CancellationToken cancellationToken) { - var item = await dbContext.Set() + var item = await dbContext.Set() .AsNoTracking() .Where(expression.Invoke(request)) .SingleOrDefaultAsync(cancellationToken); - return selector.Invoke(item); + return QueryResult.SuccessOrNotFound(selector.Invoke(item)); } } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs index cef625f..4286329 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Primitives; +using System.Linq.Expressions; namespace Monaco.Template.Backend.Common.Application.Queries; @@ -10,15 +11,15 @@ namespace Monaco.Template.Backend.Common.Application.Queries; /// This class is designed to be inherited by specific query implementations. It provides access to the /// query parameters as key-value pairs and includes helper methods for retrieving typed values from the query /// parameters. The query parameters are case-insensitive for key matching. -/// The type of the result expected from the query. +/// The type of the result expected from the query. /// Represents the query parameters collection -public abstract record QueryBase(IEnumerable> QueryParams) : IRequest +public abstract record QueryBase(IEnumerable> QueryParams) : IRequest { - private const string ExpandParam = "expand"; + protected const string ExpandParam = "expand"; public virtual IEnumerable> QueryParams { get; } = QueryParams; public virtual string?[] Sort => [.. QueryParams.FirstOrDefault(x => x.Key == "sort").Value]; - + /// /// Determines whether the specified value is included in the "expand" query parameter. /// @@ -82,4 +83,10 @@ protected bool Expand(string value) => QueryParams.Any(x => x.Key.Equals(ExpandP .Value .Select(x => Enum.TryParse(x, true, out var y) ? y : (TEnum?)null) .FirstOrDefault(x => x is not null); +} + +public abstract record QueryBase(IEnumerable> QueryParams) : QueryBase>(QueryParams) where TEntity : class +{ + public virtual Dictionary>> GetFilteringMappedFields() => []; + public virtual Dictionary>> GetSortingMappedFields() => GetFilteringMappedFields(); } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryByIdBase.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryByIdBase.cs index ce8306a..7361e34 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryByIdBase.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryByIdBase.cs @@ -10,4 +10,4 @@ namespace Monaco.Template.Backend.Common.Application.Queries; /// include additional query parameters. /// The type of the entity to be retrieved. /// The unique identifier of the entity to query. -public abstract record QueryByIdBase(Guid Id) : IRequest; \ No newline at end of file +public abstract record QueryByIdBase(Guid Id) : IRequest; \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryPagedBase.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryPagedBase.cs index 9b6bfc2..b2fa4b6 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryPagedBase.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryPagedBase.cs @@ -1,21 +1,19 @@ using Microsoft.Extensions.Primitives; +using Monaco.Template.Backend.Common.Application.Queries.Contracts; using Monaco.Template.Backend.Common.Domain.Model; namespace Monaco.Template.Backend.Common.Application.Queries; -public abstract record QueryPagedBase(IEnumerable> QueryParams) : QueryBase?>(QueryParams) +public abstract record QueryPagedBase(IEnumerable> QueryParams) + : QueryBase>>(QueryParams), IPagedQuery { - public virtual int Offset => QueryParams.FirstOrDefault(x => x.Key.Equals(nameof(Page<>.Pager.Offset), StringComparison.InvariantCultureIgnoreCase)) - .Value - .Select(x => int.TryParse(x, out var y) ? y : 0) - .Where(x => x >= 0) - .DefaultIfEmpty(0) - .FirstOrDefault(); + public int Offset => IPagedQuery.ParseOffset(QueryParams); + public int Limit => IPagedQuery.ParseLimit(QueryParams); +} - public virtual int Limit => QueryParams.FirstOrDefault(x => x.Key.Equals(nameof(Page<>.Pager.Limit), StringComparison.InvariantCultureIgnoreCase)) - .Value - .Select(x => int.TryParse(x, out var y) ? y : 0) - .Where(x => x is > 0 and <= 100) - .DefaultIfEmpty(10) - .FirstOrDefault(); +public abstract record QueryPagedBase(IEnumerable> QueryParams) + : QueryBase, TEntity>(QueryParams), IPagedQuery where TEntity : class +{ + public int Offset => IPagedQuery.ParseOffset(QueryParams); + public int Limit => IPagedQuery.ParseLimit(QueryParams); } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryResult.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryResult.cs new file mode 100644 index 0000000..2a6ee0d --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryResult.cs @@ -0,0 +1,18 @@ +using FluentValidation.Results; + +namespace Monaco.Template.Backend.Common.Application.Queries; + +public abstract record QueryResult +{ + public static Success Success(T result) => new(result); + public static ValidationFailure ValidationFailure(ValidationResult validationResult) => new(validationResult); + public static NotFound NotFound() => new(); + public static QueryResult SuccessOrNotFound(T? result) => + result is not null + ? new Success(result) + : new NotFound(); +} + +public sealed record Success(T Result) : QueryResult; +public sealed record NotFound : QueryResult; +public sealed record ValidationFailure(ValidationResult ValidationResult) : QueryResult; \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs new file mode 100644 index 0000000..a142bc3 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs @@ -0,0 +1,37 @@ +using FluentValidation; +using Monaco.Template.Backend.Common.Infrastructure.Context.Extensions; + +namespace Monaco.Template.Backend.Common.Application.Queries.Validators; + +public sealed class QueryBaseValidator : AbstractValidator> where TEntity : class +{ + public QueryBaseValidator() + { + RuleForEach(x => x.Sort) + .Must((query, sortField) => sortField is null || + query.GetSortingMappedFields() + .ContainsKey(sortField.TrimStart('-'))) + .WithMessage((_, sortField) => $"Sort field '{sortField?.TrimStart('-')}' is not a valid sortable field."); + + RuleFor(x => x.QueryParams) + .Custom((queryParams, context) => + { + if (context.InstanceToValidate.GetFilteringMappedFields().Count == 0) + return; + + var mappedFields = context.InstanceToValidate + .GetFilteringMappedFields() + .ToDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var param in queryParams.Where(p => mappedFields.ContainsKey(p.Key))) + { + var type = FilterExtensions.GetBodyExpression(mappedFields[param.Key]).Type; + + foreach (var value in param.Value + .Where(v => v is not null && + !FilterExtensions.ValidateDataType(v, type))) + context.AddFailure(param.Key, $"Value '{value}' is not valid for filter field '{param.Key}' which expects type '{type.Name}'."); + } + }); + } +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs new file mode 100644 index 0000000..c0bdf41 --- /dev/null +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs @@ -0,0 +1,57 @@ +using FluentValidation; +using Microsoft.Extensions.Primitives; +using Monaco.Template.Backend.Common.Application.Queries.Contracts; +using Monaco.Template.Backend.Common.Domain.Model; + +namespace Monaco.Template.Backend.Common.Application.Queries.Validators; + +public abstract class QueryPagedValidatorBase : AbstractValidator + where TQuery : IPagedQuery +{ + protected QueryPagedValidatorBase() + { + RuleFor(x => x.QueryParams) + .Must(p => GetValues(p, nameof(Pager.Offset)).Count() <= 1) + .WithMessage($"{nameof(Pager.Offset)} must be specified only once."); + + RuleFor(x => x.QueryParams) + .Must(p => GetValues(p, nameof(Pager.Limit)).Count() <= 1) + .WithMessage($"{nameof(Pager.Limit)} must be specified only once."); + + RuleFor(x => x.QueryParams) + .Must(p => !TryGetInt(p, nameof(Pager.Offset), out var v) || + v >= 0) + .WithMessage($"{nameof(Pager.Offset)} must be greater than or equal to 0."); + + RuleFor(x => x.QueryParams) + .Must(p => !TryGetInt(p, nameof(Pager.Limit), out var v) || + v is > 0 and <= IPagedQuery.MaxLimit) + .WithMessage($"{nameof(Pager.Limit)} must be between 1 and {IPagedQuery.MaxLimit}."); + } + + protected static IEnumerable GetValues(IEnumerable> queryParams, + string key) => + queryParams.Where(x => x.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + .SelectMany(x => x.Value); + + protected static bool TryGetInt(IEnumerable> queryParams, + string key, + out int value) => + int.TryParse(GetValues(queryParams, key).FirstOrDefault(), + out value); +} + +public sealed class QueryPagedBaseValidator : QueryPagedValidatorBase>; + +public sealed class QueryPagedBaseValidator : QueryPagedValidatorBase> + where TEntity : class +{ + public QueryPagedBaseValidator() + { + RuleForEach(x => x.Sort) + .Must((query, sortField) => sortField is null || + query.GetSortingMappedFields() + .ContainsKey(sortField.TrimStart('-'))) + .WithMessage((_, sortField) => $"Sort field '{sortField?.TrimStart('-')}' is not a valid sortable field."); + } +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Infrastructure/Context/Extensions/FilterExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Infrastructure/Context/Extensions/FilterExtensions.cs index 9f7dfbe..ff7652e 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Infrastructure/Context/Extensions/FilterExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Infrastructure/Context/Extensions/FilterExtensions.cs @@ -89,7 +89,7 @@ private static (Dictionary>> filterMapLower, return (filterMapLower, filterList, predicate); } - private static Expression GetBodyExpression(Expression> expression) => + public static Expression GetBodyExpression(Expression> expression) => expression.Body.NodeType == ExpressionType.Convert ? ((UnaryExpression)expression.Body).Operand : expression.Body; private static Expression> GetOperationExpression(string fieldKey, Expression> fieldMap, object? value, bool toLowerCase = false) @@ -153,7 +153,7 @@ private static Expression> GetOperationExpression(string fieldK return Expression.Lambda>(expression, fieldMap.Parameters); } - private static bool ValidateDataType(string? data, Type type) + public static bool ValidateDataType(string? data, Type type) { data = data is ['!', ..] ? data[1..] : data; return data switch From 959b686b0a47e615618938f37e60e2a6d38fcbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Demicheli?= Date: Wed, 17 Jun 2026 20:03:24 +0200 Subject: [PATCH 2/4] Fixed PR comments and some cleanup here and there. --- .../Endpoints/Products.cs | 10 +-- .../ServiceCollectionExtensions.cs | 2 + .../Features/Company/GetCompanyById.cs | 2 +- .../MediatorQueryExtensions.cs | 44 ++++++++---- .../Extensions/QueryBehaviorExtensions.cs | 27 ++++---- .../Queries/QueryBase.cs | 2 +- .../Queries/Validators/QueryBaseValidator.cs | 4 +- .../Validators/QueryPagedBaseValidator.cs | 40 ++++++++++- .../Apis/ICompaniesApi.cs | 11 ++- .../Apis/ICountriesApi.cs | 2 +- .../Apis/IProductsApi.cs | 11 ++- .../Tests/CompaniesTests.cs | 62 +++++++++++++++++ .../Tests/ProductsTests.cs | 68 ++++++++++++++++++- 13 files changed, 242 insertions(+), 43 deletions(-) diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs index ffc16a7..fb06839 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs @@ -99,11 +99,11 @@ public IEndpointRouteBuilder AddProducts(ApiVersionSet versionSet) #endif products.MapGet("{productId:guid}/Pictures/{pictureId:guid}", - Task> ([FromServices] ISender sender, - [FromRoute] Guid productId, - [FromRoute] Guid pictureId, - HttpRequest request, - CancellationToken cancellationToken) => + Task> ([FromServices] ISender sender, + [FromRoute] Guid productId, + [FromRoute] Guid pictureId, + HttpRequest request, + CancellationToken cancellationToken) => sender.ExecuteFileDownloadAsync(new DownloadProductPicture.Query(productId, pictureId, request.Query), diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/DependencyInjection/ServiceCollectionExtensions.cs index 8bb3670..315a361 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Monaco.Template.Backend.Application.Services.Contracts; #endif using Monaco.Template.Backend.Common.Application.Commands.Behaviors; +using Monaco.Template.Backend.Common.Application.Queries.Extensions; using Monaco.Template.Backend.Common.Application.Validators.Contracts; using System.Reflection; using Monaco.Template.Backend.Application.Persistence; @@ -34,6 +35,7 @@ public IServiceCollection ConfigureApplication(Action option .AddMediatR(config => config.RegisterServicesFromAssemblies(GetApplicationAssembly())) .RegisterCommandConcurrencyExceptionBehaviors(GetApplicationAssembly()) .RegisterCommandValidationBehaviors(GetApplicationAssembly()) + .RegisterQueryValidationBehaviors(GetApplicationAssembly()) .AddValidatorsFromAssembly(GetApplicationAssembly(), filter: filter => !filter.ValidatorType .GetInterfaces() diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs index 00dc682..2a4a2ac 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs @@ -27,7 +27,7 @@ public async Task> Handle(Query request, CancellationTok .Include(x => x.Address!.Country) .SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - return QueryResult.SuccessOrNotFound(item?.Map()); + return QueryResult.SuccessOrNotFound(item?.Map(true)); } } } diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs index e962c19..6ab5378 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs @@ -35,26 +35,26 @@ public async Task>, NotFound, ValidationProblem>> Execu await sender.ExecuteQueryAsync(query, cancellationToken); /// - /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the retuned result is null or not + /// Executes the query passed and returns the corresponding response that can be either Ok(result), NotFound or ValidationProblem. /// /// The type of the records returned by the query /// /// /// - public async Task, NotFound>> ExecuteQueryAsync(QueryBase> query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); + public async Task, NotFound, ValidationProblem>> ExecuteQueryAsync(QueryBase> query, + CancellationToken cancellationToken = default) => + OkOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// - /// Executes the paged query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned result is null or not + /// Executes the paged query passed and returns the corresponding response that can be either Ok(result), NotFound or ValidationProblem. /// /// The type of the records contained in the page returned by the query /// /// /// - public async Task>, NotFound>> ExecuteQueryAsync(QueryPagedBase query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); + public async Task>, NotFound, ValidationProblem>> ExecuteQueryAsync(QueryPagedBase query, + CancellationToken cancellationToken = default) => + OkOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not @@ -80,15 +80,15 @@ public async Task, NotFound>> ExecuteQueryAsync - /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not + /// Executes the query passed and returns a FileStreamResult, NotFound or ValidationProblem. /// /// /// /// /// - public async Task> ExecuteFileDownloadAsync(QueryBase> query, - CancellationToken cancellationToken = default) where TResult : FileDownloadDto => - FileDownloadOrNotFound(await sender.Send(query, cancellationToken)); + public async Task> ExecuteFileDownloadAsync(QueryBase> query, + CancellationToken cancellationToken = default) where TResult : FileDownloadDto => + FileDownloadOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not @@ -102,6 +102,15 @@ public async Task> ExecuteFileDownloadAs FileDownloadOrNotFound(await sender.Send(query, cancellationToken)); } + private static Results FileDownloadOrNotFoundOrValidationProblem(QueryResult result) where TResult : FileDownloadDto => + ResponseOrNotFoundOrValidationProblem(result, + success => TypedResults.File(success.FileContent, + success.ContentType, + success.FileName)); + + private static Results, NotFound, ValidationProblem> OkOrNotFoundOrValidationProblem(QueryResult result) => + ResponseOrNotFoundOrValidationProblem(result, TypedResults.Ok); + private static Results FileDownloadOrNotFound(QueryResult result) where TResult : FileDownloadDto => ResponseOrNotFound(result, success => TypedResults.File(success.FileContent, @@ -111,6 +120,15 @@ private static Results FileDownloadOrNotFound, NotFound> OkOrNotFound(QueryResult result) => ResponseOrNotFound(result, TypedResults.Ok); + private static Results ResponseOrNotFoundOrValidationProblem(QueryResult result, Func func) where TResponse : IResult => + result switch + { + Success success => func(success.Result), + Common.Application.Queries.NotFound => TypedResults.NotFound(), + ValidationFailure validationFailed => TypedResults.ValidationProblem(validationFailed.ValidationResult.ToDictionary()), + _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.") + }; + private static Results ResponseOrNotFound(QueryResult result, Func func) where TResponse : IResult => result switch { @@ -118,4 +136,4 @@ private static Results ResponseOrNotFound => TypedResults.NotFound(), _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.") }; -} +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs index 2c7da91..7e94030 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs @@ -14,8 +14,10 @@ public static class QueryBehaviorExtensions /// /// Scans the assembly for query types returning , registers /// for each, and automatically registers - /// the appropriate default base validators ( or - /// ). + /// the appropriate default base validators closed over the concrete query type + /// (, + /// or + /// ). /// Specific validators already registered in DI will also be picked up by the behavior. /// public IServiceCollection RegisterQueryValidationBehaviors(Assembly assembly) @@ -58,17 +60,16 @@ private static void RegisterDefaultValidators(IServiceCollection services, Type { var found = true; switch (baseType.GetGenericTypeDefinition()) - { - case var t when t == typeof(QueryPagedBase<,>): - services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); - services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); - break; - case var t when t == typeof(QueryPagedBase<>): - services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<>).MakeGenericType(baseType.GenericTypeArguments)); - break; - case var t when t == typeof(QueryBase<,>): - services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); - break; + { + case var t when t == typeof(QueryPagedBase<,>): + services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<,,>).MakeGenericType([queryType, .. baseType.GenericTypeArguments])); + break; + case var t when t == typeof(QueryPagedBase<>): + services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<,>).MakeGenericType([queryType, .. baseType.GenericTypeArguments])); + break; + case var t when t == typeof(QueryBase<,>): + services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,,>).MakeGenericType([queryType, .. baseType.GenericTypeArguments])); + break; default: found = false; break; diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs index 4286329..b5b53a3 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs @@ -18,7 +18,7 @@ public abstract record QueryBase(IEnumerable> QueryParams { get; } = QueryParams; - public virtual string?[] Sort => [.. QueryParams.FirstOrDefault(x => x.Key == "sort").Value]; + public virtual string?[] Sort => [.. QueryParams.FirstOrDefault(x => x.Key.Equals("sort", StringComparison.InvariantCultureIgnoreCase)).Value]; /// /// Determines whether the specified value is included in the "expand" query parameter. diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs index a142bc3..23bac0e 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs @@ -3,7 +3,9 @@ namespace Monaco.Template.Backend.Common.Application.Queries.Validators; -public sealed class QueryBaseValidator : AbstractValidator> where TEntity : class +public sealed class QueryBaseValidator : AbstractValidator + where TQuery : QueryBase + where TEntity : class { public QueryBaseValidator() { diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs index c0bdf41..165d12c 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Primitives; using Monaco.Template.Backend.Common.Application.Queries.Contracts; using Monaco.Template.Backend.Common.Domain.Model; +using Monaco.Template.Backend.Common.Infrastructure.Context.Extensions; namespace Monaco.Template.Backend.Common.Application.Queries.Validators; @@ -18,11 +19,19 @@ protected QueryPagedValidatorBase() .Must(p => GetValues(p, nameof(Pager.Limit)).Count() <= 1) .WithMessage($"{nameof(Pager.Limit)} must be specified only once."); + RuleFor(x => x.QueryParams) + .Must(p => HasValidIntValues(p, nameof(Pager.Offset))) + .WithMessage($"{nameof(Pager.Offset)} must be an integer."); + RuleFor(x => x.QueryParams) .Must(p => !TryGetInt(p, nameof(Pager.Offset), out var v) || v >= 0) .WithMessage($"{nameof(Pager.Offset)} must be greater than or equal to 0."); + RuleFor(x => x.QueryParams) + .Must(p => HasValidIntValues(p, nameof(Pager.Limit))) + .WithMessage($"{nameof(Pager.Limit)} must be an integer."); + RuleFor(x => x.QueryParams) .Must(p => !TryGetInt(p, nameof(Pager.Limit), out var v) || v is > 0 and <= IPagedQuery.MaxLimit) @@ -39,11 +48,17 @@ protected static bool TryGetInt(IEnumerable> out int value) => int.TryParse(GetValues(queryParams, key).FirstOrDefault(), out value); + + private static bool HasValidIntValues(IEnumerable> queryParams, + string key) => + GetValues(queryParams, key).All(value => int.TryParse(value, out _)); } -public sealed class QueryPagedBaseValidator : QueryPagedValidatorBase>; +public sealed class QueryPagedBaseValidator : QueryPagedValidatorBase + where TQuery : QueryPagedBase; -public sealed class QueryPagedBaseValidator : QueryPagedValidatorBase> +public sealed class QueryPagedBaseValidator : QueryPagedValidatorBase + where TQuery : QueryPagedBase where TEntity : class { public QueryPagedBaseValidator() @@ -53,5 +68,26 @@ public QueryPagedBaseValidator() query.GetSortingMappedFields() .ContainsKey(sortField.TrimStart('-'))) .WithMessage((_, sortField) => $"Sort field '{sortField?.TrimStart('-')}' is not a valid sortable field."); + + RuleFor(x => x.QueryParams) + .Custom((queryParams, context) => + { + if (context.InstanceToValidate.GetFilteringMappedFields().Count == 0) + return; + + var mappedFields = context.InstanceToValidate + .GetFilteringMappedFields() + .ToDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var param in queryParams.Where(p => mappedFields.ContainsKey(p.Key))) + { + var type = FilterExtensions.GetBodyExpression(mappedFields[param.Key]).Type; + + foreach (var value in param.Value + .Where(v => v is not null && + !FilterExtensions.ValidateDataType(v, type))) + context.AddFailure(param.Key, $"Value '{value}' is not valid for filter field '{param.Key}' which expects type '{type.Name}'."); + } + }); } } \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICompaniesApi.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICompaniesApi.cs index b5a19f4..8260cbe 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICompaniesApi.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICompaniesApi.cs @@ -11,9 +11,16 @@ internal interface ICompaniesApi [Get("/api/v1/Companies")] Task>> Query([Query(CollectionFormat.Multi)] string[]? expand = null, int? offset = null, - int? limit = null); + int? limit = null, + [Query(CollectionFormat.Multi)] string[]? sort = null); - [Get("/api/v1/Companies/{id}")] + [Get("/api/v1/Companies")] + Task>> QueryRaw([Query(CollectionFormat.Multi)] string[]? expand = null, + string? offset = null, + string? limit = null, + [Query(CollectionFormat.Multi)] string[]? sort = null); + + [Get("/api/v1/Companies/{id}")] Task> Get(Guid id); [Post("/api/v1/Companies")] diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICountriesApi.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICountriesApi.cs index 4fbddad..080dcbd 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICountriesApi.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/ICountriesApi.cs @@ -6,7 +6,7 @@ namespace Monaco.Template.Backend.IntegrationTests.Apis; internal interface ICountriesApi { [Get("/api/v1/Countries")] - Task> Query(); + Task> Query([Query(CollectionFormat.Multi)] string[]? sort = null); [Get("/api/v1/Countries/{id}")] Task> Get(Guid id); diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/IProductsApi.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/IProductsApi.cs index 74699bf..66b323d 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/IProductsApi.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Apis/IProductsApi.cs @@ -11,9 +11,16 @@ internal interface IProductsApi [Get("/api/v1/Products")] Task>> Query([Query(CollectionFormat.Multi)] string[]? expand = null, int? offset = null, - int? limit = null); + int? limit = null, + [Query(CollectionFormat.Multi)] string[]? sort = null); - [Get("/api/v1/Products/{id}")] + [Get("/api/v1/Products")] + Task>> QueryRaw([Query(CollectionFormat.Multi)] string[]? expand = null, + string? offset = null, + string? limit = null, + [Query(CollectionFormat.Multi)] string[]? sort = null); + + [Get("/api/v1/Products/{id}")] Task> Get(Guid id); [Get("/api/v1/Products/{productId}/Pictures/{pictureId}")] diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs index 2b35171..32f3fd3 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs @@ -35,6 +35,68 @@ public override async Task InitializeAsync() #endif } + [Theory(DisplayName = "Get Companies page with invalid offset returns validation error")] + [InlineData(-1)] + [InlineData(-100)] + public async Task GetCompaniesPageWithInvalidOffsetReturnsValidationError(int offset) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(offset: offset); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Theory(DisplayName = "Get Companies page with non-integer offset returns validation error")] + [InlineData("abc")] + public async Task GetCompaniesPageWithNonIntegerOffsetReturnsValidationError(string offset) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.QueryRaw(offset: offset); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Theory(DisplayName = "Get Companies page with invalid limit returns validation error")] + [InlineData(0)] + [InlineData(-1)] + [InlineData(101)] + public async Task GetCompaniesPageWithInvalidLimitReturnsValidationError(int limit) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(limit: limit); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Theory(DisplayName = "Get Companies page with non-integer limit returns validation error")] + [InlineData("abc")] + public async Task GetCompaniesPageWithNonIntegerLimitReturnsValidationError(string limit) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.QueryRaw(limit: limit); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Fact(DisplayName = "Get Companies page with invalid sort field returns validation error")] + public async Task GetCompaniesPageWithInvalidSortFieldReturnsValidationError() + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(sort: ["nonExistentField"]); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + [Theory(DisplayName = "Get Companies page succeeds")] [InlineData(false, null, null, 3)] [InlineData(true, 1, 5, 2)] diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs index a7077c7..031a2e3 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs @@ -62,6 +62,68 @@ private Task SetupAccessToken() => private BlobContainerClient GetBlobContainerClient() => new(Fixture.StorageConnectionString, AppFixture.StorageContainer); + [Theory(DisplayName = "Get Products page with invalid offset returns validation error")] + [InlineData(-1)] + [InlineData(-100)] + public async Task GetProductsPageWithInvalidOffsetReturnsValidationError(int offset) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(offset: offset); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Theory(DisplayName = "Get Products page with non-integer offset returns validation error")] + [InlineData("abc")] + public async Task GetProductsPageWithNonIntegerOffsetReturnsValidationError(string offset) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.QueryRaw(offset: offset); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Theory(DisplayName = "Get Products page with invalid limit returns validation error")] + [InlineData(0)] + [InlineData(-1)] + [InlineData(101)] + public async Task GetProductsPageWithInvalidLimitReturnsValidationError(int limit) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(limit: limit); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Theory(DisplayName = "Get Products page with non-integer limit returns validation error")] + [InlineData("abc")] + public async Task GetProductsPageWithNonIntegerLimitReturnsValidationError(string limit) + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.QueryRaw(limit: limit); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + + [Fact(DisplayName = "Get Products page with invalid sort field returns validation error")] + public async Task GetProductsPageWithInvalidSortFieldReturnsValidationError() + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(sort: ["nonExistentField"]); + + response.StatusCode + .Should() + .Be(HttpStatusCode.BadRequest); + } + [Theory(DisplayName = "Get Products page succeeds")] [InlineData(false, false, false, null, null, 3)] [InlineData(true, true, true, 1, 5, 2)] @@ -134,7 +196,9 @@ public async Task GetProductsPageSucceeds(bool expandCompany, p.DefaultPicture .Should() .BeNull(); - }); + }) + .And + .BeInAscendingOrder(p => p.Title); result.Pager .Should() .BeEquivalentTo(new Pager(offset ?? 0, @@ -538,4 +602,4 @@ await Parallel.ForEachAsync(container.GetBlobs(), await base.DisposeAsync(); } -} \ No newline at end of file +} From 1c34c3b792341b535d9de3ee89901e02d1d06479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Demicheli?= Date: Thu, 18 Jun 2026 20:54:01 +0200 Subject: [PATCH 3/4] Some fixes and improvements. --- .../Features/Country/GetCountryByIdTests.cs | 2 +- .../Queries/Validators/QueryBaseValidator.cs | 13 ++++++------ .../Validators/QueryPagedBaseValidator.cs | 1 + .../Tests/CompaniesTests.cs | 19 ++++++++++++++++++ .../Tests/CountriesTests.cs | 15 ++++++++++++++ .../Tests/ProductsTests.cs | 20 ++++++++++++++++++- 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs index d1203e6..1b6b8f4 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs @@ -51,4 +51,4 @@ public async Task GetNonExistingCountryByIdFails() result.Should() .BeOfType>(); } -} +} \ No newline at end of file diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs index 23bac0e..def1769 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs @@ -10,9 +10,9 @@ public sealed class QueryBaseValidator : AbstractValidator x.Sort) - .Must((query, sortField) => sortField is null || - query.GetSortingMappedFields() - .ContainsKey(sortField.TrimStart('-'))) + .Must((query, sortField) => sortField is null || query.GetSortingMappedFields() + .ToDictionary(StringComparer.OrdinalIgnoreCase) + .ContainsKey(sortField.TrimStart('-'))) .WithMessage((_, sortField) => $"Sort field '{sortField?.TrimStart('-')}' is not a valid sortable field."); RuleFor(x => x.QueryParams) @@ -20,7 +20,7 @@ public QueryBaseValidator() { if (context.InstanceToValidate.GetFilteringMappedFields().Count == 0) return; - + var mappedFields = context.InstanceToValidate .GetFilteringMappedFields() .ToDictionary(StringComparer.OrdinalIgnoreCase); @@ -29,9 +29,8 @@ public QueryBaseValidator() { var type = FilterExtensions.GetBodyExpression(mappedFields[param.Key]).Type; - foreach (var value in param.Value - .Where(v => v is not null && - !FilterExtensions.ValidateDataType(v, type))) + foreach (var value in param.Value.Where(v => v is not null && + !FilterExtensions.ValidateDataType(v, type))) context.AddFailure(param.Key, $"Value '{value}' is not valid for filter field '{param.Key}' which expects type '{type.Name}'."); } }); diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs index 165d12c..66c7421 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs @@ -66,6 +66,7 @@ public QueryPagedBaseValidator() RuleForEach(x => x.Sort) .Must((query, sortField) => sortField is null || query.GetSortingMappedFields() + .ToDictionary(StringComparer.OrdinalIgnoreCase) .ContainsKey(sortField.TrimStart('-'))) .WithMessage((_, sortField) => $"Sort field '{sortField?.TrimStart('-')}' is not a valid sortable field."); diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs index 32f3fd3..a1c8d09 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CompaniesTests.cs @@ -97,6 +97,25 @@ public async Task GetCompaniesPageWithInvalidSortFieldReturnsValidationError() .Be(HttpStatusCode.BadRequest); } + [Fact(DisplayName = "Get Companies page with lowercase sort field succeeds")] + public async Task GetCompaniesPageWithLowercaseSortFieldSucceeds() + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(sort: ["name"]); + + response.StatusCode + .Should() + .Be(HttpStatusCode.OK); + + response.Content + .Should() + .NotBeNull(); + response.Content! + .Items + .Should() + .BeInAscendingOrder(x => x.Name); + } + [Theory(DisplayName = "Get Companies page succeeds")] [InlineData(false, null, null, 3)] [InlineData(true, 1, 5, 2)] diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CountriesTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CountriesTests.cs index 049533f..bb94e90 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CountriesTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/CountriesTests.cs @@ -51,6 +51,21 @@ public async Task GetCountriesSucceeds() .HaveCount(countriesCount); } + [Fact(DisplayName = "Get Countries with lowercase sort field succeeds")] + public async Task GetCountriesWithLowercaseSortFieldSucceeds() + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(["name"]); + + response.StatusCode + .Should() + .Be(HttpStatusCode.OK); + + response.Content + .Should() + .NotBeNullOrEmpty(); + } + [Fact(DisplayName = "Get Country succeeds")] public async Task GetCountrySucceeds() { diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs index 031a2e3..d05819d 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.IntegrationTests/Tests/ProductsTests.cs @@ -124,6 +124,24 @@ public async Task GetProductsPageWithInvalidSortFieldReturnsValidationError() .Be(HttpStatusCode.BadRequest); } + [Fact(DisplayName = "Get Products page with lowercase sort field succeeds")] + public async Task GetProductsPageWithLowercaseSortFieldSucceeds() + { + var api = GetApi(Fixture.WebAppFactory); + var response = await api.Query(sort: ["price"]); + + response.StatusCode + .Should() + .Be(HttpStatusCode.OK); + + response.Content + .Should() + .NotBeNull(); + response.Content!.Items + .Should() + .BeInAscendingOrder(x => x.Price); + } + [Theory(DisplayName = "Get Products page succeeds")] [InlineData(false, false, false, null, null, 3)] [InlineData(true, true, true, 1, 5, 2)] @@ -602,4 +620,4 @@ await Parallel.ForEachAsync(container.GetBlobs(), await base.DisposeAsync(); } -} +} \ No newline at end of file From 2a1b3af1c3b41878ae53191e534045639aa9ac3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Demicheli?= Date: Fri, 19 Jun 2026 20:22:43 +0200 Subject: [PATCH 4/4] Some more improvements and cleanup. --- .../Endpoints/Companies.cs | 6 +-- .../Endpoints/Countries.cs | 4 +- .../Endpoints/Products.cs | 6 +-- .../MediatorQueryExtensions.cs | 40 ++++++------------- 4 files changed, 20 insertions(+), 36 deletions(-) diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs index 4faf21d..f5d748e 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs @@ -39,9 +39,9 @@ public IEndpointRouteBuilder AddCompanies(ApiVersionSet versionSet) #endif companies.MapGet("{id:guid}", - Task, NotFound>> ([FromServices] ISender sender, - [FromRoute] Guid id, - CancellationToken cancellationToken) => + Task, NotFound, ValidationProblem>> ([FromServices] ISender sender, + [FromRoute] Guid id, + CancellationToken cancellationToken) => sender.ExecuteQueryAsync(new GetCompanyById.Query(id), cancellationToken), "GetCompany", diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs index c22d207..9c26a4c 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs @@ -25,8 +25,8 @@ public IEndpointRouteBuilder AddCountries(ApiVersionSet versionSet) "Gets a list of countries"); countries.MapGet("{id:guid}", - Task, NotFound>> ([FromServices] ISender sender, - [FromRoute] Guid id) => + Task, NotFound, ValidationProblem>> ([FromServices] ISender sender, + [FromRoute] Guid id) => sender.ExecuteQueryAsync(new GetCountryById.Query(id)), "GetCountry", "Gets a country by Id"); diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs index fb06839..7c415ba 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs @@ -39,9 +39,9 @@ public IEndpointRouteBuilder AddProducts(ApiVersionSet versionSet) #endif products.MapGet("{id:guid}", - Task, NotFound>> ([FromServices] ISender sender, - [FromRoute] Guid id, - CancellationToken cancellationToken) => + Task, NotFound, ValidationProblem>> ([FromServices] ISender sender, + [FromRoute] Guid id, + CancellationToken cancellationToken) => sender.ExecuteQueryAsync(new GetProductById.Query(id), cancellationToken), "GetProduct", diff --git a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs index 6ab5378..de1744c 100644 --- a/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs +++ b/src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs @@ -57,27 +57,27 @@ public async Task>, NotFound, ValidationProblem>> Execu OkOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// - /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not + /// Executes the query passed and returns the corresponding response that can be either Ok(result), NotFound or ValidationProblem. /// /// The type of the item returned by the query /// /// /// - public async Task, NotFound>> ExecuteQueryAsync(QueryByIdBase> query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); + public async Task, NotFound, ValidationProblem>> ExecuteQueryAsync(QueryByIdBase> query, + CancellationToken cancellationToken = default) => + OkOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// - /// Executes the query passed and returns the corresponding response that can be either Ok(result) or a NotFound() result depending on whether the returned item is null or not + /// Executes the query passed and returns the corresponding response that can be either Ok(result), NotFound or ValidationProblem. /// /// The type of the item returned by the query /// The type of the key to search the item by /// /// /// - public async Task, NotFound>> ExecuteQueryAsync(QueryByKeyBase, TKey> query, - CancellationToken cancellationToken = default) => - OkOrNotFound(await sender.Send(query, cancellationToken)); + public async Task, NotFound, ValidationProblem>> ExecuteQueryAsync(QueryByKeyBase, TKey> query, + CancellationToken cancellationToken = default) => + OkOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// /// Executes the query passed and returns a FileStreamResult, NotFound or ValidationProblem. @@ -91,15 +91,15 @@ public async Task> Ex FileDownloadOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); /// - /// Executes the query passed and returns a FileStreamResult for allowing download of a file or a NotFound() result depending on whether the returned item is null or not + /// Executes the query passed and returns a FileStreamResult, NotFound or ValidationProblem. /// /// /// /// /// - public async Task> ExecuteFileDownloadAsync(QueryByIdBase> query, - CancellationToken cancellationToken = default) where TResult : FileDownloadDto => - FileDownloadOrNotFound(await sender.Send(query, cancellationToken)); + public async Task> ExecuteFileDownloadAsync(QueryByIdBase> query, + CancellationToken cancellationToken = default) where TResult : FileDownloadDto => + FileDownloadOrNotFoundOrValidationProblem(await sender.Send(query, cancellationToken)); } private static Results FileDownloadOrNotFoundOrValidationProblem(QueryResult result) where TResult : FileDownloadDto => @@ -111,15 +111,6 @@ private static Results FileDo private static Results, NotFound, ValidationProblem> OkOrNotFoundOrValidationProblem(QueryResult result) => ResponseOrNotFoundOrValidationProblem(result, TypedResults.Ok); - private static Results FileDownloadOrNotFound(QueryResult result) where TResult : FileDownloadDto => - ResponseOrNotFound(result, - success => TypedResults.File(success.FileContent, - success.ContentType, - success.FileName)); - - private static Results, NotFound> OkOrNotFound(QueryResult result) => - ResponseOrNotFound(result, TypedResults.Ok); - private static Results ResponseOrNotFoundOrValidationProblem(QueryResult result, Func func) where TResponse : IResult => result switch { @@ -129,11 +120,4 @@ private static Results ResponseOrNotFoun _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.") }; - private static Results ResponseOrNotFound(QueryResult result, Func func) where TResponse : IResult => - result switch - { - Success success => func(success.Result), - Common.Application.Queries.NotFound => TypedResults.NotFound(), - _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.") - }; } \ No newline at end of file