diff --git a/src/Api/Controllers/SampleController.cs b/src/Api/Controllers/SampleController.cs index 5f7665d..26cae65 100644 --- a/src/Api/Controllers/SampleController.cs +++ b/src/Api/Controllers/SampleController.cs @@ -30,9 +30,10 @@ public SampleController(ISampleService sampleService) /// /// The sample response model. [HttpGet] - public async Task Get() + public IActionResult Get() { - var result = await this.sampleService.GetSamplesAsync(new GetSamplesRequestModel(), CancellationToken.None); + var result = this.sampleService.GetSamples(new GetSamplesRequestModel()); + return this.Ok(result); } @@ -45,10 +46,6 @@ public async Task Get() public async Task Get(Guid id) { var result = await this.sampleService.GetSampleAsync(id, CancellationToken.None); - if (result == null) - { - return this.NotFound(); - } return this.Ok(result); } @@ -61,12 +58,13 @@ public async Task Get(Guid id) [HttpPost] public async Task Create([FromBody] CreateSampleRequestModel request) { - if (request == null) + if (request is null) { return this.BadRequest("Request cannot be null."); } var result = await this.sampleService.CreateSampleAsync(request, CancellationToken.None); + return this.CreatedAtAction(nameof(this.Get), new { id = result.Id }, result); } @@ -78,13 +76,14 @@ public async Task Create([FromBody] CreateSampleRequestModel requ [HttpPut] public async Task Update([FromBody] UpdateSampleRequestModel request) { - if (request == null || request.Id == Guid.Empty) + if (request is null || request.Id == Guid.Empty) { return this.BadRequest("Request cannot be null and ID must be provided."); } var result = await this.sampleService.UpdateSampleAsync(request, CancellationToken.None); - if (result == null) + + if (result is null) { return this.NotFound(); } @@ -106,6 +105,7 @@ public async Task Delete(Guid id) } var result = await this.sampleService.DeleteSampleAsync(id, CancellationToken.None); + if (!result) { return this.NotFound(); diff --git a/src/Api/GlobalSuppressions.cs b/src/Api/GlobalSuppressions.cs index 6cc98eb..2fef874 100644 --- a/src/Api/GlobalSuppressions.cs +++ b/src/Api/GlobalSuppressions.cs @@ -5,3 +5,4 @@ */ [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:FileHeaderFileNameDocumentationMustMatchTypeName", Justification = "C# 9.0 top-level statements do not require a namespace or a class.")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:FileMustHaveHeader", Justification = "Reviewed.")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:FileMayOnlyContainASingleType", Justification = "Reviewed.")] \ No newline at end of file diff --git a/src/Api/Middlewares/ExceptionMiddleware.cs b/src/Api/Middlewares/ExceptionMiddleware.cs new file mode 100644 index 0000000..aca7d59 --- /dev/null +++ b/src/Api/Middlewares/ExceptionMiddleware.cs @@ -0,0 +1,84 @@ +namespace Api.Middlewares +{ + using System; + using System.Net; + using System.Text.Json; + using System.Threading.Tasks; + using Domain.Exceptions; + using Microsoft.AspNetCore.Http; + + /// + /// Extension methods for configuring the exception handling middleware. + /// + public static class ExceptionHandlingExtensions + { + /// + /// Adds the global exception handler middleware to the application pipeline. + /// + /// The application builder. + /// The application builder with the middleware added. + public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder app) + => app.UseMiddleware(); + } + + /// + /// Middleware to handle exceptions globally in the application. + /// + public class ExceptionHandlingMiddleware + { + private readonly RequestDelegate next; + + /// + /// Initializes a new instance of the class. + /// + /// The next middleware in the pipeline. + public ExceptionHandlingMiddleware(RequestDelegate next) + => this.next = next; + + /// + /// Invokes the middleware to handle exceptions. + /// + /// The HTTP context. + /// A task representing the asynchronous operation. + public async Task InvokeAsync(HttpContext ctx) + { + try + { + await this.next(ctx); + } + catch (Exception ex) + { + await HandleExceptionAsync(ctx, ex); + } + } + + private static Task HandleExceptionAsync(HttpContext ctx, Exception exception) + { + HttpStatusCode status; + string errorCode; + string message = exception.Message; + + if (exception is DomainException de) + { + status = de.StatusCode; + errorCode = de.ErrorCode; + } + else + { + status = HttpStatusCode.InternalServerError; + errorCode = "UNHANDLED_ERROR"; + } + + ctx.Response.ContentType = "application/json"; + ctx.Response.StatusCode = (int)status; + + var payload = JsonSerializer.Serialize(new + { + error = message, + code = errorCode, + }); + + return ctx.Response.WriteAsync(payload); + } + } +} diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 71d9d65..ab2535b 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -90,6 +90,8 @@ .WithApiVersionSet(new ApiVersionSet(new ApiVersionSetBuilder(string.Empty), "weatherForecast")) .HasApiVersion(new ApiVersion(2, 0)); +app.UseGlobalExceptionHandler(); + app.MapControllers(); app.MapMetrics(); diff --git a/src/Domain/Enums/SortDirection.cs b/src/Domain/Enums/SortDirection.cs new file mode 100644 index 0000000..6f8e747 --- /dev/null +++ b/src/Domain/Enums/SortDirection.cs @@ -0,0 +1,6 @@ +namespace Domain.Enums; +public enum SortDirection +{ + Asc, + Desc +} \ No newline at end of file diff --git a/src/Domain/Exceptions/DomainException.cs b/src/Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..94c2f0c --- /dev/null +++ b/src/Domain/Exceptions/DomainException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace Domain.Exceptions +{ + public class DomainException : Exception + { + public string ErrorCode { get; } + public HttpStatusCode StatusCode { get; } + public DomainException(string message, + string errorCode = "DOMAIN_ERROR", + HttpStatusCode statusCode = HttpStatusCode.BadRequest) + : base(message) + { + ErrorCode = errorCode; + StatusCode = statusCode; + } + } +} diff --git a/src/Domain/Interfaces/IRepository.cs b/src/Domain/Interfaces/IRepository.cs index eedf958..6c8a49b 100644 --- a/src/Domain/Interfaces/IRepository.cs +++ b/src/Domain/Interfaces/IRepository.cs @@ -54,4 +54,6 @@ Task ExecuteUpdateAsync( CancellationToken cancellationToken = default); Task SaveChangesAsync(CancellationToken cancellationToken = default); + + object Property(TEntity entity, string propertyName); } \ No newline at end of file diff --git a/src/Domain/Interfaces/ISampleService.cs b/src/Domain/Interfaces/ISampleService.cs index 5a731dd..5b7012e 100644 --- a/src/Domain/Interfaces/ISampleService.cs +++ b/src/Domain/Interfaces/ISampleService.cs @@ -5,9 +5,9 @@ namespace Domain.Interfaces; public interface ISampleService { + GetSamplesResponseModel GetSamples(GetSamplesRequestModel request); + Task GetSampleAsync(Guid id, CancellationToken cancellationToken = default); Task CreateSampleAsync(CreateSampleRequestModel request, CancellationToken cancellationToken = default); - Task GetSamplesAsync(GetSamplesRequestModel request, CancellationToken cancellationToken = default); Task UpdateSampleAsync(UpdateSampleRequestModel request, CancellationToken cancellationToken = default); - Task GetSampleAsync(Guid id, CancellationToken cancellationToken = default); Task DeleteSampleAsync(Guid id, CancellationToken cancellationToken = default); } diff --git a/src/Domain/Models/Request/FilterRequestModel.cs b/src/Domain/Models/Request/FilterRequestModel.cs new file mode 100644 index 0000000..eea8d1b --- /dev/null +++ b/src/Domain/Models/Request/FilterRequestModel.cs @@ -0,0 +1,11 @@ +using Domain.Entities; +using Domain.Enums; + +namespace Domain.Models.Request; +public class FilterRequestModel +{ + public int Page { get; set; } = 1; + public int Limit { get; set; } = 10; + public string OrderBy { get; set; } = nameof(BaseEntity.Id); + public SortDirection Direction { get; set; } = SortDirection.Asc; +} \ No newline at end of file diff --git a/src/Domain/Models/Request/Sample/GetSamplesRequestModel.cs b/src/Domain/Models/Request/Sample/GetSamplesRequestModel.cs index b8d9965..5d4e453 100644 --- a/src/Domain/Models/Request/Sample/GetSamplesRequestModel.cs +++ b/src/Domain/Models/Request/Sample/GetSamplesRequestModel.cs @@ -1,6 +1,6 @@ namespace Domain.Models.Request.Sample; -public class GetSamplesRequestModel +public class GetSamplesRequestModel: FilterRequestModel { public string? Name { get; set; } } diff --git a/src/Domain/Models/Response/Sample/GetSamplesResponseModel.cs b/src/Domain/Models/Response/Sample/GetSamplesResponseModel.cs index 47e747a..7937a06 100644 --- a/src/Domain/Models/Response/Sample/GetSamplesResponseModel.cs +++ b/src/Domain/Models/Response/Sample/GetSamplesResponseModel.cs @@ -2,5 +2,17 @@ namespace Domain.Models.Response.Sample; public class GetSamplesResponseModel { - public IList Samples { get; set; } = new List(); + public IReadOnlyList Items { get; } + public int TotalCount { get; } + public int TotalPages { get; } + + public GetSamplesResponseModel( + IReadOnlyList items, + int totalCount, + int totalPages) + { + Items = items; + TotalCount = totalCount; + TotalPages = totalPages; + } } diff --git a/src/Domain/Services/SampleService.cs b/src/Domain/Services/SampleService.cs index ca07584..ca90113 100644 --- a/src/Domain/Services/SampleService.cs +++ b/src/Domain/Services/SampleService.cs @@ -1,7 +1,7 @@ -using System.Linq.Expressions; -using System.Threading.Tasks; using AutoMapper; using Domain.Entities; +using Domain.Enums; +using Domain.Exceptions; using Domain.Interfaces; using Domain.Models.Request.Sample; using Domain.Models.Response.Sample; @@ -22,24 +22,44 @@ public SampleService(IRepository repository, public async Task GetSampleAsync(Guid id, CancellationToken cancellationToken = default) { - var entity = await _repository.GetByIdAsync(id, cancellationToken); + var entity = await _repository.GetByIdAsync(id, cancellationToken) ?? throw new DomainException($"Sample with ID {id} not found."); + return _mapper.Map(entity); } - public async Task GetSamplesAsync(GetSamplesRequestModel request, CancellationToken cancellationToken = default) + public GetSamplesResponseModel GetSamples(GetSamplesRequestModel request) { - Expression> predicate = e => true; - if (!string.IsNullOrEmpty(request.Name)) - { - predicate = e => e.Name == request.Name; + var query = _repository.Query(); + if (!string.IsNullOrWhiteSpace(request.Name)) + query = query.Where(u => u.Name == request.Name); + + if (!string.IsNullOrWhiteSpace(request.OrderBy)) + { + query = request.Direction == SortDirection.Desc + ? query.OrderByDescending(u => _repository.Property(u, request.OrderBy)) + : query.OrderBy(u => _repository.Property(u, request.OrderBy)); } - var samples = await _repository.ListAsync(predicate, true, cancellationToken); - var response = new GetSamplesResponseModel + else { - Samples = _mapper.Map>(samples) - }; - return response; + query = query.OrderByDescending(u => _repository.Property(u, nameof(SampleEntity.CreatedAt))); + } + + var totalCount = query.Count(); + + var skip = (request.Page - 1) * request.Limit; + var samples = query + .Skip(skip) + .Take(request.Limit) + .ToList(); + + var items = samples + .Select(u => _mapper.Map(u)) + .ToList(); + + var totalPages = (int)Math.Ceiling(totalCount / (double)request.Limit); + + return new GetSamplesResponseModel(items, totalCount, totalPages); } public async Task CreateSampleAsync(CreateSampleRequestModel request, CancellationToken cancellationToken = default) diff --git a/src/Infra/Repositories/Repository.cs b/src/Infra/Repositories/Repository.cs index 4f82710..fba35c9 100644 --- a/src/Infra/Repositories/Repository.cs +++ b/src/Infra/Repositories/Repository.cs @@ -109,6 +109,11 @@ public Task ExecuteUpdateAsync( public Task SaveChangesAsync(CancellationToken cancellationToken = default) => _dataContext.SaveChangesAsync(cancellationToken); + public object Property(TEntity entity, string propertyName) + { + return EF.Property(entity, propertyName); + } + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) == 0)