diff --git a/Directory.Packages.props b/Directory.Packages.props index 569a221..bca20f4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,28 +7,29 @@ + - + - + - + - - + + - - + + - + - - - - - + + + + + @@ -44,4 +45,4 @@ - \ No newline at end of file + diff --git a/GerstITS.Examples.Api/Common/Configurations/WebApiConfiguration.ApiKey.cs b/GerstITS.Examples.Api/Common/Configurations/WebApiConfiguration.ApiKey.cs new file mode 100644 index 0000000..d10d0ac --- /dev/null +++ b/GerstITS.Examples.Api/Common/Configurations/WebApiConfiguration.ApiKey.cs @@ -0,0 +1,27 @@ +using GerstITS.Authentication.ApiKey; +using GerstITS.System.Configurations; + +namespace GerstITS.Examples.Api.Configurations; + +internal class ApiKeyConfiguration : IApiKeyConfiguration +{ + #region Constructors + + public ApiKeyConfiguration(string parentPrefix, + Microsoft.Extensions.Configuration.IConfiguration configuration) + { + var prefix = $"{parentPrefix}:{this.ToConfigurationPrefix()}"; + + Header = configuration.GetValue($"{prefix}:{nameof(Header)}"); + Key = configuration.GetValue($"{prefix}:{nameof(Key)}"); + } + + #endregion + + #region IApiKeyConfiguration + + public string Header { get; } + public string Key { get; } + + #endregion +} diff --git a/GerstITS.Examples.Api/Common/ExceptionHandling/Extensions/WebApplicationExtensions.cs b/GerstITS.Examples.Api/Common/ExceptionHandling/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..0c1eb40 --- /dev/null +++ b/GerstITS.Examples.Api/Common/ExceptionHandling/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,46 @@ +using System.Net; +using GerstITS.Web.Api.ExceptionHandling; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http.Features; + +namespace GerstITS.Examples.Api.ExceptionHandling; + +internal static class WebApplicationExtensions +{ + internal static WebApplication UseGlobalExceptionHandling(this WebApplication app) + { + app.UseExceptionHandler(exceptionHandlerApp => + { + exceptionHandlerApp.Run(async context => + { + var exceptionFeature = context.Features.Get(); + if (exceptionFeature is null) return; + + var exception = exceptionFeature.Error; + var ticketId = Guid.NewGuid().ToString(); + + context.RequestServices + .GetRequiredService() + .CreateLogger("GlobalExceptionHandler") + .LogError($"Global exception occurs (TicketId {ticketId}).{Environment.NewLine}{exception}"); + + var transformation = context.RequestServices + .GetServices() + .FirstOrDefault(x => x.CanTransform(exception)); + + var info = transformation?.Transform(exception, ticketId) ?? new ExceptionTransformationInfo + { + StatusCode = HttpStatusCode.InternalServerError, + ReasonPhrase = "Internal Server Error.", + Details = $"An internal error occurs. Please contact support with ticket id '{ticketId}'." + }; + + context.Response.StatusCode = (int)info.StatusCode; + context.Features.Get().ReasonPhrase = info.ReasonPhrase; + await context.Response.WriteAsync(info.Details ?? string.Empty); + }); + }); + + return app; + } +} diff --git a/GerstITS.Examples.Api/Controllers/1.1/CacheController.cs b/GerstITS.Examples.Api/Controllers/1.1/CacheController.cs new file mode 100644 index 0000000..fc15d4b --- /dev/null +++ b/GerstITS.Examples.Api/Controllers/1.1/CacheController.cs @@ -0,0 +1,100 @@ +using Asp.Versioning; +using GerstITS.Examples.Api.Versioning; +using GerstITS.System.Caching; +using GerstITS.Validation; +using GerstITS.Web.Api; +using GerstITS.Web.Api.FluentApi; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Controllers._1._1; + +/// +/// Is responsible to demonstrate the encrypted session based cache. +/// +[ApiController, + ApiVersion(Versions._1_1), + Route(ApplicationEnvironment.WebApi.ControllerRouteTemplate)] +public class CacheController : FluentApiControllerBase +{ + #region Fields + + private readonly ICache _cache; + + #endregion + + #region Constructors + + public CacheController(ICache cache, + IValidator validator) + : base(validator) + { + _cache = cache; + } + + #endregion + + #region Methods + + /// + /// Gets a cached value by specified key. + /// + /// Key of the cached value. + /// Returns the cached value. + [HttpGet, + Route("{key}")] + public IActionResult Get(string key) + { + return Api().Use(key) + .Get(_cache.Get); + } + + /// + /// Checks whether a value is cached for the specified key. + /// + /// Key of the cached value. + /// Returns true if a value is cached. + [HttpGet, + Route("{key}/Exists")] + public IActionResult Exists(string key) + { + return Api().Use(key) + .Get(_cache.ContainsKey); + } + + /// + /// Caches a value for the specified key. + /// + /// Key of the value to cache. + /// Value to cache. + /// Returns the cached value. + [HttpPost, + Route("{key}")] + public IActionResult Set(string key, [FromBody] string value) + { + return Api().Use(key) + .Create(x => { + _cache.Set(x, value); + + return value; + }); + } + + /// + /// Removes a cached value by specified key. + /// + /// Key of the cached value. + /// Returns true if the value was removed. + [HttpDelete, + Route("{key}")] + public IActionResult Remove(string key) + { + return Api().Use(key) + .Delete(x => { + _cache.Remove(x); + + return true; + }); + } + + #endregion +} diff --git a/GerstITS.Examples.Api/Controllers/1.1/ContactController.cs b/GerstITS.Examples.Api/Controllers/1.1/ContactController.cs new file mode 100644 index 0000000..4ca270c --- /dev/null +++ b/GerstITS.Examples.Api/Controllers/1.1/ContactController.cs @@ -0,0 +1,64 @@ +using Asp.Versioning; +using GerstITS.Examples.Api.Versioning; +using GerstITS.Mail; +using GerstITS.Validation; +using GerstITS.Web.Api; +using GerstITS.Web.Api.FluentApi; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Controllers._1._1; + +/// +/// Is responsible to send contact requests via the configured smtp mail provider. +/// +[ApiController, + ApiVersion(Versions._1_1), + Route(ApplicationEnvironment.WebApi.ControllerRouteTemplate)] +public class ContactController : FluentApiControllerBase +{ + #region Fields + + private const string _supportAddress = "support@example.com"; + + private readonly IMailProvider _mailProvider; + + #endregion + + #region Constructors + + public ContactController(IMailProvider mailProvider, + IValidator validator) + : base(validator) + { + _mailProvider = mailProvider; + } + + #endregion + + #region Methods + + /// + /// Sends a contact request as mail to the support address. + /// + /// Contact request to send. + /// Returns true if the mail was sent. + [HttpPost] + public IActionResult Send([FromBody] ContactRequest contactRequest) + { + return Api().Use(contactRequest) + .Create(x => _mailProvider.SendMail(ToMailNotification(x))); + } + + private static MailNotification ToMailNotification(ContactRequest contactRequest) + { + return new MailNotification + { + Recipients = new[] { _supportAddress }, + Subject = $"{contactRequest.Subject} ({contactRequest.SenderName}, {contactRequest.SenderEmail})", + Body = contactRequest.Body, + IsHtmlMail = false + }; + } + + #endregion +} diff --git a/GerstITS.Examples.Api/Controllers/1.1/CustomerController.cs b/GerstITS.Examples.Api/Controllers/1.1/CustomerController.cs new file mode 100644 index 0000000..1c9e545 --- /dev/null +++ b/GerstITS.Examples.Api/Controllers/1.1/CustomerController.cs @@ -0,0 +1,146 @@ +using Asp.Versioning; +using GerstITS.Data; +using GerstITS.Examples.Api.Versioning; +using GerstITS.Examples.Logic.Customers; +using GerstITS.Search; +using GerstITS.Validation; +using GerstITS.Web.Api; +using GerstITS.Web.Api.FluentApi; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Controllers._1._1; + +/// +/// Is responsible to manage customers backed by the repository based data access. +/// +[ApiController, + ApiVersion(Versions._1_1), + Route(ApplicationEnvironment.WebApi.ControllerRouteTemplate)] +public class CustomerController : FluentApiControllerBase +{ + #region Fields + + private readonly ICustomerProvider _provider; + + #endregion + + #region Constructors + + public CustomerController(ICustomerProvider provider, + IValidator validator) + : base(validator) + { + _provider = provider; + } + + #endregion + + #region Methods + + /// + /// Gets a customer by specified id. Deactivated customers are excluded by default. + /// + /// Id of the customer. + /// Includes deactivated (soft deleted) customers if set to true. + /// Returns a customer. + [HttpGet, + Route("{id}")] + public IActionResult Get(int id, [FromQuery] bool includeInactive = false) + { + return Api().Use(id) + .Get(x => _provider.GetById(x, includeInactive)); + } + + /// + /// Searches customers by specified filter using the search engine (paging, sorting, filtering). + /// + /// Filter criteria of the search. + /// Returns the matching customers including the total count. + [HttpPost, + Route("Search")] + public IActionResult Search([FromBody] CustomerSearchFilter filter) + { + return Api().Use(filter) + .Get(_provider.Search); + } + + /// + /// Creates a new customer. + /// + /// Customer to create. + /// Returns the created customer. + [HttpPost] + public IActionResult Create([FromBody] Customer customer) + { + return Api().Use(customer) + .Create(_provider.Create); + } + + /// + /// Updates an existing customer. + /// + /// Customer to update. + /// Returns the updated customer. + [HttpPut] + public IActionResult Update([FromBody] Customer customer) + { + return Api().Use(customer) + .Update(_provider.Update); + } + + /// + /// Deletes a customer. Performs a soft delete by default, a permanent delete if force is set to true. + /// + /// Id of the customer. + /// Deletes the customer permanently if set to true. + /// Returns true if the customer was deleted. + [HttpDelete, + Route("{id}")] + public IActionResult Delete(int id, [FromQuery] bool force = false) + { + return Api().Use(id) + .Delete(x => _provider.Delete(x, force)); + } + + /// + /// Imports customers using bulk operations. + /// + /// Customers to import. + /// Returns the bulk operation result. + [HttpPost, + Route("Import")] + public IActionResult Import([FromBody] IEnumerable customers) + { + return Api().Use(customers) + .Create(_provider.Import); + } + + /// + /// Gets all notes of a customer ordered by their validity. + /// + /// Id of the customer. + /// Returns the notes of the customer. + [HttpGet, + Route("{id}/Notes")] + public IActionResult GetNotes(int id) + { + return Api().Use(id) + .Get(_provider.GetNotes); + } + + /// + /// Adds a historicized note to a customer. + /// + /// Id of the customer. + /// Note to add. + /// Returns the created note. + [HttpPost, + Route("{id}/Notes")] + public IActionResult AddNote(int id, [FromBody] CustomerNote note) + { + return Api().Use(id) + .Create(x => _provider.AddNote(x, note.Text)); + } + + #endregion +} diff --git a/GerstITS.Examples.Api/Controllers/1.1/MaintenanceController.cs b/GerstITS.Examples.Api/Controllers/1.1/MaintenanceController.cs new file mode 100644 index 0000000..31dc254 --- /dev/null +++ b/GerstITS.Examples.Api/Controllers/1.1/MaintenanceController.cs @@ -0,0 +1,52 @@ +using Asp.Versioning; +using GerstITS.Authentication.ApiKey; +using GerstITS.Examples.Api.Versioning; +using GerstITS.System.Environment; +using GerstITS.Validation; +using GerstITS.Web.Api; +using GerstITS.Web.Api.FluentApi; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Controllers._1._1; + +/// +/// Is responsible to provide maintenance functions protected by an api key header. +/// +[ApiController, + ApiVersion(Versions._1_1), + ApiKey, + Route(ApplicationEnvironment.WebApi.ControllerRouteTemplate)] +public class MaintenanceController : FluentApiControllerBase +{ + #region Fields + + private readonly ISystemClock _systemClock; + + #endregion + + #region Constructors + + public MaintenanceController(ISystemClock systemClock, + IValidator validator) + : base(validator) + { + _systemClock = systemClock; + } + + #endregion + + #region Methods + + /// + /// Gets the current server time. Requires a valid api key header. + /// + /// Returns the current server time. + [HttpGet, + Route("Ping")] + public IActionResult Ping() + { + return Api().Get(() => _systemClock.UtcNow); + } + + #endregion +} diff --git a/GerstITS.Examples.Api/Controllers/1.1/SecuredController.cs b/GerstITS.Examples.Api/Controllers/1.1/SecuredController.cs new file mode 100644 index 0000000..f63b302 --- /dev/null +++ b/GerstITS.Examples.Api/Controllers/1.1/SecuredController.cs @@ -0,0 +1,43 @@ +using Asp.Versioning; +using GerstITS.Examples.Api.Versioning; +using GerstITS.Validation; +using GerstITS.Web.Api; +using GerstITS.Web.Api.FluentApi; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Controllers._1._1; + +/// +/// Is responsible to demonstrate open id (jwt bearer) protected endpoints. +/// +[ApiController, + ApiVersion(Versions._1_1), + Authorize, + Route(ApplicationEnvironment.WebApi.ControllerRouteTemplate)] +public class SecuredController : FluentApiControllerBase +{ + #region Constructors + + public SecuredController(IValidator validator) + : base(validator) + { + } + + #endregion + + #region Methods + + /// + /// Gets the name of the authenticated user. Requires a valid bearer token. + /// + /// Returns the name of the authenticated user. + [HttpGet, + Route("Me")] + public IActionResult Me() + { + return Api().Get(() => User.Identity.Name); + } + + #endregion +} diff --git a/GerstITS.Examples.Api/Endpoints/Endpoints.CustomerNotes.cs b/GerstITS.Examples.Api/Endpoints/Endpoints.CustomerNotes.cs new file mode 100644 index 0000000..feffefe --- /dev/null +++ b/GerstITS.Examples.Api/Endpoints/Endpoints.CustomerNotes.cs @@ -0,0 +1,30 @@ +using GerstITS.Examples.Logic.Customers; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Endpoints; + +internal static partial class Endpoints +{ + internal static RouteGroupBuilder MapCustomerNoteEndpoints(this RouteGroupBuilder group) + { + CustomerNotes.MapGetByCustomer(group); + CustomerNotes.MapAdd(group); + + return group; + } + + internal static class CustomerNotes + { + internal static void MapGetByCustomer(RouteGroupBuilder group) + { + group.MapGet("customers/{id:int}/notes", (int id, ICustomerProvider customerProvider) => + Results.Ok(customerProvider.GetNotes(id))); + } + + internal static void MapAdd(RouteGroupBuilder group) + { + group.MapPost("customers/{id:int}/notes", (int id, [FromBody] CustomerNote note, ICustomerProvider customerProvider) => + Results.Ok(customerProvider.AddNote(id, note.Text))); + } + } +} diff --git a/GerstITS.Examples.Api/Endpoints/Endpoints.Customers.cs b/GerstITS.Examples.Api/Endpoints/Endpoints.Customers.cs new file mode 100644 index 0000000..52076bb --- /dev/null +++ b/GerstITS.Examples.Api/Endpoints/Endpoints.Customers.cs @@ -0,0 +1,75 @@ +using GerstITS.Examples.Logic.Customers; +using GerstITS.Validation; +using Microsoft.AspNetCore.Mvc; + +namespace GerstITS.Examples.Api.Endpoints; + +internal static partial class Endpoints +{ + internal static RouteGroupBuilder MapCustomerEndpoints(this IEndpointRouteBuilder root) + { + var group = root.MapGroup("/api/v2.0") + .WithTags("Customers"); + + Customers.MapGetById(group); + Customers.MapSearch(group); + Customers.MapCreate(group); + Customers.MapUpdate(group); + Customers.MapDelete(group); + Customers.MapImport(group); + + return group; + } + + internal static class Customers + { + internal static void MapGetById(RouteGroupBuilder group) + { + group.MapGet("customers/{id:int}", (int id, ICustomerProvider customerProvider, bool includeInactive = false) => + Results.Ok(customerProvider.GetById(id, includeInactive))); + } + + internal static void MapSearch(RouteGroupBuilder group) + { + group.MapGet("customers", ([AsParameters] CustomerSearchFilter filter, ICustomerProvider customerProvider, IValidator validator) => + { + Validator.Validate(validator, filter); + + return Results.Ok(customerProvider.Search(filter)); + }); + } + + internal static void MapCreate(RouteGroupBuilder group) + { + group.MapPost("customers", ([FromBody] Customer customer, ICustomerProvider customerProvider, IValidator validator) => + { + Validator.Validate(validator, customer); + + return Results.Ok(customerProvider.Create(customer)); + }); + } + + internal static void MapUpdate(RouteGroupBuilder group) + { + group.MapPut("customers/{id:int}", (int id, [FromBody] Customer customer, ICustomerProvider customerProvider, IValidator validator) => + { + customer.Id = id; + Validator.Validate(validator, customer); + + return Results.Ok(customerProvider.Update(customer)); + }); + } + + internal static void MapDelete(RouteGroupBuilder group) + { + group.MapDelete("customers/{id:int}", (int id, ICustomerProvider customerProvider, bool force = false) => + Results.Ok(customerProvider.Delete(id, force))); + } + + internal static void MapImport(RouteGroupBuilder group) + { + group.MapPost("customers/import", ([FromBody] IEnumerable customers, ICustomerProvider customerProvider) => + Results.Ok(customerProvider.Import(customers))); + } + } +} diff --git a/GerstITS.Examples.Api/Endpoints/Endpoints.Validator.cs b/GerstITS.Examples.Api/Endpoints/Endpoints.Validator.cs new file mode 100644 index 0000000..0b65948 --- /dev/null +++ b/GerstITS.Examples.Api/Endpoints/Endpoints.Validator.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using GerstITS.Validation; + +namespace GerstITS.Examples.Api.Endpoints; + +internal static partial class Endpoints +{ + internal static class Validator + { + internal static void Validate(IValidator validator, T model) + { + var result = validator.Validate(model); + + if (!result.IsValid) + throw new ValidationException($"{typeof(T).Name} is invalid.{Environment.NewLine}Details: {result.ToErrorMessage()}"); + } + } +} diff --git a/GerstITS.Examples.Api/Endpoints/Endpoints.cs b/GerstITS.Examples.Api/Endpoints/Endpoints.cs new file mode 100644 index 0000000..db2e839 --- /dev/null +++ b/GerstITS.Examples.Api/Endpoints/Endpoints.cs @@ -0,0 +1,10 @@ +namespace GerstITS.Examples.Api.Endpoints; + +internal static partial class Endpoints +{ + internal static RouteGroupBuilder MapEndpoints(this IEndpointRouteBuilder root) + { + return root.MapCustomerEndpoints() + .MapCustomerNoteEndpoints(); + } +} diff --git a/GerstITS.Examples.Api/Module.Session.cs b/GerstITS.Examples.Api/Module.Session.cs new file mode 100644 index 0000000..50434d2 --- /dev/null +++ b/GerstITS.Examples.Api/Module.Session.cs @@ -0,0 +1,16 @@ +using GerstITS.Web.Http; + +namespace GerstITS.Examples.Api; + +public sealed partial class Module +{ + #region Methods + + private static void RegisterSession(IServiceCollection container) + { + container.AddDistributedMemoryCache(); + container.AddHttpSession(); + } + + #endregion +} diff --git a/GerstITS.Examples.Api/Program.cs b/GerstITS.Examples.Api/Program.cs index 2816d83..d0fb9de 100644 --- a/GerstITS.Examples.Api/Program.cs +++ b/GerstITS.Examples.Api/Program.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using GerstITS.Examples.Api.Endpoints; +using GerstITS.Examples.Api.ExceptionHandling; using GerstITS.Web.Api; using GerstITS.Web.Api.Hosting; using GerstITS.Web.Api.Swagger; @@ -23,6 +25,7 @@ public class Program .Services() .Build() .IfProduction(app => app.UsePreconfiguredHsts()) + .UseGlobalExceptionHandling() .IfDevelopment(app => app.UseDeveloperExceptionPage() .UsePreconfiguredSwagger()) .UsePreconfiguredCors() @@ -32,7 +35,11 @@ public class Program .UseAuthorization() .UseRouting() .UseSession() - .UseEndpoints(endpoints => endpoints.MapControllers()) + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapEndpoints(); + }) .UseRewriteUnknownPathsToIndexSite(ApplicationEnvironment.WebApi.BaseUrl) .UseSystemIndependentStaticFiles() .UseSerilogRequestLogging() diff --git a/GerstITS.Examples.Data/CustomerDbContext.cs b/GerstITS.Examples.Data/CustomerDbContext.cs new file mode 100644 index 0000000..6c6d605 --- /dev/null +++ b/GerstITS.Examples.Data/CustomerDbContext.cs @@ -0,0 +1,27 @@ +using GerstITS.Data.EntityFramework; +using GerstITS.Examples.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace GerstITS.Examples.Data; + +public sealed class CustomerDbContext : EntityFrameworkDbContextBase, IDbContext +{ + #region Properties + + public DbSet Customers => Set(); + public DbSet CustomerNotes => Set(); + + protected override string ConnectionStringName => "CustomerDbContext"; + + #endregion + + #region Constructors + + public CustomerDbContext(IDatabaseConfigurator databaseConfigurator, + DbContextOptions dbContextOptions) + : base(databaseConfigurator, dbContextOptions) + { + } + + #endregion +} diff --git a/GerstITS.Examples.Data/Entities/CustomerEntity.cs b/GerstITS.Examples.Data/Entities/CustomerEntity.cs new file mode 100644 index 0000000..76c01f2 --- /dev/null +++ b/GerstITS.Examples.Data/Entities/CustomerEntity.cs @@ -0,0 +1,28 @@ +using GerstITS.Data; + +namespace GerstITS.Examples.Data.Entities; + +public sealed class CustomerEntity : EntityBase, IActivatable, IReadOnlyActivatable, ICreatable, IReadOnlyCreatable, IModifiable, IReadOnlyModifiable, IVersionable, IReadOnlyVersionable +{ + #region Properties + + public string FirstName { get; set; } + public string LastName { get; set; } + public string EMail { get; set; } + + public bool IsActive { get; set; } + public DateTime? Deactivated { get; set; } + public string DeactivatedBy { get; set; } + + public DateTime Created { get; set; } + public string CreatedBy { get; set; } + + public DateTime? Modified { get; set; } + public string ModifiedBy { get; set; } + + public int Version { get; set; } + + public ICollection Notes { get; set; } + + #endregion +} diff --git a/GerstITS.Examples.Data/Entities/CustomerNoteEntity.cs b/GerstITS.Examples.Data/Entities/CustomerNoteEntity.cs new file mode 100644 index 0000000..2933e22 --- /dev/null +++ b/GerstITS.Examples.Data/Entities/CustomerNoteEntity.cs @@ -0,0 +1,19 @@ +using GerstITS.Data; + +namespace GerstITS.Examples.Data.Entities; + +public sealed class CustomerNoteEntity : EntityBase, ICreatable, IReadOnlyCreatable, IHistoricizable, IReadOnlyHistoricizable +{ + #region Properties + + public int CustomerId { get; set; } + public string Text { get; set; } + + public DateTime Created { get; set; } + public string CreatedBy { get; set; } + + public string HistorySet { get; set; } + public DateTime ValidFrom { get; set; } + + #endregion +} diff --git a/GerstITS.Examples.Data/GerstITS.Examples.Data.csproj b/GerstITS.Examples.Data/GerstITS.Examples.Data.csproj new file mode 100644 index 0000000..58fa6a4 --- /dev/null +++ b/GerstITS.Examples.Data/GerstITS.Examples.Data.csproj @@ -0,0 +1,56 @@ + + + + Gerst ITS + Gerst ITS + © 2025 Gerst ITS + Gerst ITS Examples data access + Example data access. + 0.0.0.0 + 0.0.0.0 + 0.0.0.0 + 0.0.0.0 + + + + true + true + + + + + + true + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + Always + + + + + + + + + diff --git a/GerstITS.Examples.Data/Mappings/CustomerEntityMapping.cs b/GerstITS.Examples.Data/Mappings/CustomerEntityMapping.cs new file mode 100644 index 0000000..ae907e7 --- /dev/null +++ b/GerstITS.Examples.Data/Mappings/CustomerEntityMapping.cs @@ -0,0 +1,43 @@ +using GerstITS.Data.EntityFramework; +using GerstITS.Examples.Data.Entities; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GerstITS.Examples.Data.Mappings; + +internal sealed class CustomerEntityMapping : EntityMappingBase +{ + #region Properties + + protected override string TableName => "Customers"; + + #endregion + + #region Methods + + protected override void AddKeyMapping(EntityTypeBuilder entity) + { + entity.HasKey(i => i.Id); + } + + protected override void AddPropertyMappings(EntityTypeBuilder entity) + { + entity.Property(i => i.FirstName).HasMaxLength(100).IsRequired(); + entity.Property(i => i.LastName).HasMaxLength(100).IsRequired(); + entity.Property(i => i.EMail).HasMaxLength(250).IsRequired(); + + entity.Property(i => i.CreatedBy).HasMaxLength(100); + entity.Property(i => i.ModifiedBy).HasMaxLength(100); + entity.Property(i => i.DeactivatedBy).HasMaxLength(100); + + entity.Property(i => i.Version).IsConcurrencyToken(); + } + + protected override void AddNavigationProperties(EntityTypeBuilder entity) + { + entity.HasMany(i => i.Notes) + .WithOne() + .HasForeignKey(i => i.CustomerId); + } + + #endregion +} diff --git a/GerstITS.Examples.Data/Mappings/CustomerNoteEntityMapping.cs b/GerstITS.Examples.Data/Mappings/CustomerNoteEntityMapping.cs new file mode 100644 index 0000000..86a049f --- /dev/null +++ b/GerstITS.Examples.Data/Mappings/CustomerNoteEntityMapping.cs @@ -0,0 +1,31 @@ +using GerstITS.Data.EntityFramework; +using GerstITS.Examples.Data.Entities; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GerstITS.Examples.Data.Mappings; + +internal sealed class CustomerNoteEntityMapping : EntityMappingBase +{ + #region Properties + + protected override string TableName => "CustomerNotes"; + + #endregion + + #region Methods + + protected override void AddKeyMapping(EntityTypeBuilder entity) + { + entity.HasKey(i => i.Id); + } + + protected override void AddPropertyMappings(EntityTypeBuilder entity) + { + entity.Property(i => i.Text).HasMaxLength(2000).IsRequired(); + + entity.Property(i => i.CreatedBy).HasMaxLength(100); + entity.Property(i => i.HistorySet).HasMaxLength(50); + } + + #endregion +} diff --git a/GerstITS.Examples.Data/Migrations/20260703100412_InitialCreate.Designer.cs b/GerstITS.Examples.Data/Migrations/20260703100412_InitialCreate.Designer.cs new file mode 100644 index 0000000..8fa42c9 --- /dev/null +++ b/GerstITS.Examples.Data/Migrations/20260703100412_InitialCreate.Designer.cs @@ -0,0 +1,138 @@ +// +using System; +using GerstITS.Examples.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerstITS.Examples.Data.Migrations +{ + [DbContext(typeof(CustomerDbContext))] + [Migration("20260703100412_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("examples") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityAlwaysColumns(modelBuilder); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Deactivated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeactivatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EMail") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Customers", "examples"); + }); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerNoteEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("HistorySet") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerNotes", "examples"); + }); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerNoteEntity", b => + { + b.HasOne("GerstITS.Examples.Data.Entities.CustomerEntity", null) + .WithMany("Notes") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerEntity", b => + { + b.Navigation("Notes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerstITS.Examples.Data/Migrations/20260703100412_InitialCreate.cs b/GerstITS.Examples.Data/Migrations/20260703100412_InitialCreate.cs new file mode 100644 index 0000000..ca23f95 --- /dev/null +++ b/GerstITS.Examples.Data/Migrations/20260703100412_InitialCreate.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerstITS.Examples.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "examples"); + + migrationBuilder.CreateTable( + name: "Customers", + schema: "examples", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn), + FirstName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + LastName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EMail = table.Column(type: "character varying(250)", maxLength: 250, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + Deactivated = table.Column(type: "timestamp with time zone", nullable: true), + DeactivatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Created = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Modified = table.Column(type: "timestamp with time zone", nullable: true), + ModifiedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Version = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CustomerNotes", + schema: "examples", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn), + CustomerId = table.Column(type: "integer", nullable: false), + Text = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + HistorySet = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ValidFrom = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomerNotes", x => x.Id); + table.ForeignKey( + name: "FK_CustomerNotes_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "examples", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CustomerNotes_CustomerId", + schema: "examples", + table: "CustomerNotes", + column: "CustomerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CustomerNotes", + schema: "examples"); + + migrationBuilder.DropTable( + name: "Customers", + schema: "examples"); + } + } +} diff --git a/GerstITS.Examples.Data/Migrations/CustomerDatabaseConfigurator.cs b/GerstITS.Examples.Data/Migrations/CustomerDatabaseConfigurator.cs new file mode 100644 index 0000000..6f29016 --- /dev/null +++ b/GerstITS.Examples.Data/Migrations/CustomerDatabaseConfigurator.cs @@ -0,0 +1,16 @@ +using GerstITS.Data.EntityFramework.PostgreSql.Migrations; +using Microsoft.Extensions.Configuration; + +namespace GerstITS.Examples.Data.Migrations; + +public sealed class CustomerDatabaseConfigurator : PostgreSqlDatabaseConfiguratorBase +{ + #region Constructors + + public CustomerDatabaseConfigurator(IConfiguration configuration) + : base("examples", configuration) + { + } + + #endregion +} diff --git a/GerstITS.Examples.Data/Migrations/CustomerDbContextModelSnapshot.cs b/GerstITS.Examples.Data/Migrations/CustomerDbContextModelSnapshot.cs new file mode 100644 index 0000000..eebc26b --- /dev/null +++ b/GerstITS.Examples.Data/Migrations/CustomerDbContextModelSnapshot.cs @@ -0,0 +1,135 @@ +// +using System; +using GerstITS.Examples.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerstITS.Examples.Data.Migrations +{ + [DbContext(typeof(CustomerDbContext))] + partial class CustomerDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("examples") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityAlwaysColumns(modelBuilder); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Deactivated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeactivatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EMail") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Customers", "examples"); + }); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerNoteEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("HistorySet") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerNotes", "examples"); + }); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerNoteEntity", b => + { + b.HasOne("GerstITS.Examples.Data.Entities.CustomerEntity", null) + .WithMany("Notes") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerEntity", b => + { + b.Navigation("Notes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerstITS.Examples.Data/Migrations/DesignTime/CustomerDbContextDesignTimeFactory.cs b/GerstITS.Examples.Data/Migrations/DesignTime/CustomerDbContextDesignTimeFactory.cs new file mode 100644 index 0000000..ebba79c --- /dev/null +++ b/GerstITS.Examples.Data/Migrations/DesignTime/CustomerDbContextDesignTimeFactory.cs @@ -0,0 +1,7 @@ +using GerstITS.Data.EntityFramework; + +namespace GerstITS.Examples.Data.Migrations.DesignTime; + +public sealed class CustomerDbContextDesignTimeFactory : DesignTimeDbContextFactoryBase +{ +} diff --git a/GerstITS.Examples.Data/Module.cs b/GerstITS.Examples.Data/Module.cs new file mode 100644 index 0000000..4752138 --- /dev/null +++ b/GerstITS.Examples.Data/Module.cs @@ -0,0 +1,19 @@ +using GerstITS.Data.EntityFramework; +using GerstITS.Examples.Data.Migrations; +using GerstITS.IoC; +using Microsoft.Extensions.DependencyInjection; + +namespace GerstITS.Examples.Data; + +public sealed class Module : IIoCModule +{ + #region IIoCModule + + public void RegisterComponents(IServiceCollection container) + { + container.AddMigration(); + container.AddEntityFrameworkSession(); + } + + #endregion +} diff --git a/GerstITS.Examples.Data/appsettings.json b/GerstITS.Examples.Data/appsettings.json new file mode 100644 index 0000000..0882726 --- /dev/null +++ b/GerstITS.Examples.Data/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "CustomerDbContext": "Host=localhost;Port=5432;Database=gerstits_examples;Username=postgres;Password=postgres" + } +} diff --git a/GerstITS.Examples.Logic/Customers/Contracts/Customer.cs b/GerstITS.Examples.Logic/Customers/Contracts/Customer.cs new file mode 100644 index 0000000..a7f83c6 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Contracts/Customer.cs @@ -0,0 +1,17 @@ +using GerstITS.Examples.Logic.Shared.Search.Sorting; + +namespace GerstITS.Examples.Logic.Customers; + +public class Customer : ISortable +{ + #region Properties + + public int Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string EMail { get; set; } + public string DisplayName { get; set; } + public bool IsActive { get; set; } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Contracts/CustomerNote.cs b/GerstITS.Examples.Logic/Customers/Contracts/CustomerNote.cs new file mode 100644 index 0000000..d810b52 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Contracts/CustomerNote.cs @@ -0,0 +1,12 @@ +namespace GerstITS.Examples.Logic.Customers; + +public class CustomerNote +{ + #region Properties + + public int Id { get; set; } + public string Text { get; set; } + public DateTime ValidFrom { get; set; } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Contracts/CustomerSearchFilter.cs b/GerstITS.Examples.Logic/Customers/Contracts/CustomerSearchFilter.cs new file mode 100644 index 0000000..333cf73 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Contracts/CustomerSearchFilter.cs @@ -0,0 +1,13 @@ +using GerstITS.Search; + +namespace GerstITS.Examples.Logic.Customers; + +public class CustomerSearchFilter : SearchFilterBase +{ + #region Properties + + public string Name { get; set; } + public string EMail { get; set; } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Contracts/ICustomerProvider.cs b/GerstITS.Examples.Logic/Customers/Contracts/ICustomerProvider.cs new file mode 100644 index 0000000..aa11682 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Contracts/ICustomerProvider.cs @@ -0,0 +1,23 @@ +using GerstITS.Data; +using GerstITS.Search; + +namespace GerstITS.Examples.Logic.Customers; + +public interface ICustomerProvider +{ + Customer GetById(int id); + Customer GetById(int id, bool includeInactive); + + SearchResult Search(CustomerSearchFilter filter); + + Customer Create(Customer customer); + Customer Update(Customer customer); + + bool Delete(int id); + bool Delete(int id, bool forceDelete); + + BulkResult Import(IEnumerable customers); + + IEnumerable GetNotes(int customerId); + CustomerNote AddNote(int customerId, string text); +} diff --git a/GerstITS.Examples.Logic/Customers/CustomerProvider.cs b/GerstITS.Examples.Logic/Customers/CustomerProvider.cs new file mode 100644 index 0000000..c57924c --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/CustomerProvider.cs @@ -0,0 +1,148 @@ +using AutoMapper; +using GerstITS.Common; +using GerstITS.Data; +using GerstITS.Examples.Data.Entities; +using GerstITS.Search; +using GerstITS.System.Customizations; + +namespace GerstITS.Examples.Logic.Customers; + +internal sealed class CustomerProvider : ICustomerProvider +{ + #region Fields + + private readonly IRepository _repository; + private readonly IMapper _mapper; + private readonly ISearchEngine _searchEngine; + + #endregion + + #region Constructors + + public CustomerProvider(IRepository repository, + IMapper mapper, + ISearchEngine searchEngine) + { + _repository = repository; + _mapper = mapper; + _searchEngine = searchEngine; + } + + #endregion + + #region ICustomerProvider + + public Customer GetById(int id) + { + return GetById(id, false); + } + + public Customer GetById(int id, bool includeInactive) + { + var entity = _repository.Query(includeInactive) + .FirstOrDefault(i => i.Id == id) + .ThrowsAnExceptionIfNotExisting(); + + return ToCustomizedModel(entity); + } + + public SearchResult Search(CustomerSearchFilter filter) + { + var searchResult = _searchEngine.Search(filter); + + searchResult.Result = searchResult.Result + .Select(i => i.ApplyCustomizations()) + .ToList(); + + return searchResult; + } + + public Customer Create(Customer customer) + { + var entity = _mapper.Map(customer); + + _repository.Save(entity); + + return ToCustomizedModel(entity); + } + + public Customer Update(Customer customer) + { + var entity = _repository.Query() + .FirstOrDefault(i => i.Id == customer.Id) + .ThrowsAnExceptionIfNotExisting(); + + _mapper.Map(customer, entity); + + _repository.Save(entity); + + return ToCustomizedModel(entity); + } + + public bool Delete(int id) + { + return Delete(id, false); + } + + public bool Delete(int id, bool forceDelete) + { + var entity = _repository.Query(forceDelete) + .FirstOrDefault(i => i.Id == id) + .ThrowsAnExceptionIfNotExisting(); + + _repository.Delete(entity, forceDelete); + + return true; + } + + public BulkResult Import(IEnumerable customers) + { + var importableCustomers = customers.Is(x => x.IsNullOrEmpty()) + .ThenThrows() + .WithMessage("At least one customer is required for an import."); + + var entities = importableCustomers.Select(_mapper.Map) + .ToList(); + + return _repository.BulkSave(entities); + } + + public IEnumerable GetNotes(int customerId) + { + return _repository.Query() + .Where(i => i.CustomerId == customerId) + .OrderBy(i => i.ValidFrom) + .ToList() + .Select(_mapper.Map) + .ToList(); + } + + public CustomerNote AddNote(int customerId, string text) + { + _repository.Query() + .FirstOrDefault(i => i.Id == customerId) + .ThrowsAnExceptionIfNotExisting(); + + var entity = new CustomerNoteEntity + { + CustomerId = customerId, + Text = text + }; + + _repository.Save(entity); + + return _mapper.Map(entity); + } + + #endregion + + #region Methods + + private Customer ToCustomizedModel(CustomerEntity entity) + { + return _mapper.Map(entity) + .ApplyCustomizations(); + } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Customizations/CustomerDisplayNameCustomization.cs b/GerstITS.Examples.Logic/Customers/Customizations/CustomerDisplayNameCustomization.cs new file mode 100644 index 0000000..7373c50 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Customizations/CustomerDisplayNameCustomization.cs @@ -0,0 +1,23 @@ +using GerstITS.Common; +using GerstITS.System.Customizations; + +namespace GerstITS.Examples.Logic.Customers.Customizations; + +internal sealed class CustomerDisplayNameCustomization : CustomizationBase +{ + #region Methods + + protected override bool CanCustomize(Customer item) + { + return item.IsNotNull(); + } + + protected override Customer Customize(Customer item) + { + item.DisplayName = $"{item.LastName}, {item.FirstName}"; + + return item; + } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Mappings/CustomerMapping.cs b/GerstITS.Examples.Logic/Customers/Mappings/CustomerMapping.cs new file mode 100644 index 0000000..2942b23 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Mappings/CustomerMapping.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using GerstITS.Examples.Data.Entities; + +namespace GerstITS.Examples.Logic.Customers; + +internal sealed class CustomerMapping : Profile +{ + #region Constructors + + public CustomerMapping() + { + CreateMap() + .ForMember(x => x.DisplayName, m => m.Ignore()); + + CreateMap() + .ForMember(x => x.Created, m => m.Ignore()) + .ForMember(x => x.CreatedBy, m => m.Ignore()) + .ForMember(x => x.Modified, m => m.Ignore()) + .ForMember(x => x.ModifiedBy, m => m.Ignore()) + .ForMember(x => x.Deactivated, m => m.Ignore()) + .ForMember(x => x.DeactivatedBy, m => m.Ignore()) + .ForMember(x => x.Version, m => m.Ignore()) + .ForMember(x => x.Notes, m => m.Ignore()); + + CreateMap(); + } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Search/CustomerSearchFilterInitializationRule.cs b/GerstITS.Examples.Logic/Customers/Search/CustomerSearchFilterInitializationRule.cs new file mode 100644 index 0000000..b860c51 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Search/CustomerSearchFilterInitializationRule.cs @@ -0,0 +1,28 @@ +using GerstITS.Common; +using GerstITS.Search; + +namespace GerstITS.Examples.Logic.Customers.Search; + +internal sealed class CustomerSearchFilterInitializationRule : InitializationRuleBase +{ + #region Fields + + private const int _defaultTake = 50; + + #endregion + + #region Methods + + protected override CustomerSearchFilter InitializeInstance(CustomerSearchFilter instance) + { + instance.Skip ??= 0; + instance.Take ??= _defaultTake; + + if (instance.SortBy.IsNullOrWhiteSpace()) + instance.SortBy = nameof(Customer.LastName); + + return instance; + } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Search/CustomerSearchQueryBuilderRule.cs b/GerstITS.Examples.Logic/Customers/Search/CustomerSearchQueryBuilderRule.cs new file mode 100644 index 0000000..7946593 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Search/CustomerSearchQueryBuilderRule.cs @@ -0,0 +1,49 @@ +using GerstITS.Common; +using GerstITS.Data; +using GerstITS.Examples.Data.Entities; +using GerstITS.Search; + +namespace GerstITS.Examples.Logic.Customers.Search; + +internal sealed class CustomerSearchQueryBuilderRule : SearchQueryBuilderRuleBase +{ + #region Fields + + private readonly IReadOnlyRepository _repository; + + #endregion + + #region Constructors + + public CustomerSearchQueryBuilderRule(IReadOnlyRepository repository) + { + _repository = repository; + } + + #endregion + + #region Methods + + protected override IQueryable CreateQuery(CustomerSearchFilter filter) + { + var query = _repository.Query(); + + if (filter.Name.IsNotNullOrWhiteSpace()) + query = query.Where(i => i.FirstName.Contains(filter.Name) + || i.LastName.Contains(filter.Name)); + + if (filter.EMail.IsNotNullOrWhiteSpace()) + query = query.Where(i => i.EMail == filter.EMail); + + return query.Project(x => new Customer + { + Id = x.Id, + FirstName = x.FirstName, + LastName = x.LastName, + EMail = x.EMail, + IsActive = x.IsActive + }); + } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Customers/Validation/CustomerSearchFilterValidationRule.cs b/GerstITS.Examples.Logic/Customers/Validation/CustomerSearchFilterValidationRule.cs new file mode 100644 index 0000000..b444956 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Validation/CustomerSearchFilterValidationRule.cs @@ -0,0 +1,7 @@ +using GerstITS.Search; + +namespace GerstITS.Examples.Logic.Customers.Validation; + +internal sealed class CustomerSearchFilterValidationRule : PagingValidationRuleBase +{ +} diff --git a/GerstITS.Examples.Logic/Customers/Validation/CustomerValidationRule.cs b/GerstITS.Examples.Logic/Customers/Validation/CustomerValidationRule.cs new file mode 100644 index 0000000..9f23271 --- /dev/null +++ b/GerstITS.Examples.Logic/Customers/Validation/CustomerValidationRule.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using GerstITS.Validation; + +namespace GerstITS.Examples.Logic.Customers.Validation; + +internal sealed class CustomerValidationRule : ValidationRuleBase +{ + #region Constructors + + public CustomerValidationRule() + { + RuleFor(x => x.FirstName) + .NotEmpty(); + + RuleFor(x => x.LastName) + .NotEmpty(); + + RuleFor(x => x.EMail) + .NotEmpty() + .IsEMail(); + } + + #endregion +} diff --git a/GerstITS.Examples.Logic/Module.Customers.cs b/GerstITS.Examples.Logic/Module.Customers.cs new file mode 100644 index 0000000..c2893fc --- /dev/null +++ b/GerstITS.Examples.Logic/Module.Customers.cs @@ -0,0 +1,29 @@ +using GerstITS.Examples.Logic.Customers; +using GerstITS.Examples.Logic.Customers.Customizations; +using GerstITS.Examples.Logic.Customers.Search; +using GerstITS.Examples.Logic.Customers.Validation; +using GerstITS.Mapping.AutoMapper; +using GerstITS.Search; +using GerstITS.System.Customizations; +using GerstITS.Validation; +using Microsoft.Extensions.DependencyInjection; + +namespace GerstITS.Examples.Logic; + +public sealed partial class Module +{ + #region Methods + + private static void RegisterCustomers(IServiceCollection container) + { + container.RegisterMappingsOf(); + container.RegisterValidationRulesOf(); + container.RegisterSearchQueryBuilderRulesOf(); + container.RegisterInitializationRulesOf(); + container.RegisterCustomizationsOf(); + + container.AddScoped(); + } + + #endregion +} diff --git a/GerstITS.Examples.Tests/Customers/Customizations/CustomerDisplayNameCustomizationTest.cs b/GerstITS.Examples.Tests/Customers/Customizations/CustomerDisplayNameCustomizationTest.cs new file mode 100644 index 0000000..010c98d --- /dev/null +++ b/GerstITS.Examples.Tests/Customers/Customizations/CustomerDisplayNameCustomizationTest.cs @@ -0,0 +1,35 @@ +using AwesomeAssertions; +using GerstITS.Examples.Logic.Customers; +using GerstITS.Examples.Logic.Customers.Customizations; +using GerstITS.System.Customizations; +using GerstITS.TestFramework; +using Xunit; + +namespace GerstITS.Examples.Tests.Customers.Customizations; + +[TestCategory(TestCategories.Unit)] +public sealed class CustomerDisplayNameCustomizationTest +{ + #region Tests + + [Fact] + public void If_a_customer_is_customized___The_display_name_is_composed() + { + ICustomization customization = new CustomerDisplayNameCustomization(); + var customer = new Customer { FirstName = "Erika", LastName = "Mustermann" }; + + var customized = customization.Customize(customer, null); + + customized.DisplayName.Should().Be("Mustermann, Erika"); + } + + [Fact] + public void If_the_customer_is_null___It_cannot_be_customized() + { + ICustomization customization = new CustomerDisplayNameCustomization(); + + customization.CanCustomize(null, null).Should().BeFalse(); + } + + #endregion +} diff --git a/GerstITS.Examples.Tests/Customers/Search/CustomerSearchFilterInitializationRuleTest.cs b/GerstITS.Examples.Tests/Customers/Search/CustomerSearchFilterInitializationRuleTest.cs new file mode 100644 index 0000000..7e225d1 --- /dev/null +++ b/GerstITS.Examples.Tests/Customers/Search/CustomerSearchFilterInitializationRuleTest.cs @@ -0,0 +1,50 @@ +using AwesomeAssertions; +using GerstITS.Examples.Logic.Customers; +using GerstITS.Examples.Logic.Customers.Search; +using GerstITS.TestFramework; +using Xunit; + +namespace GerstITS.Examples.Tests.Customers.Search; + +[TestCategory(TestCategories.Unit)] +public sealed class CustomerSearchFilterInitializationRuleTest +{ + #region Tests + + [Fact] + public void If_the_filter_is_empty___Defaults_are_applied() + { + var filter = new CustomerSearchFilter(); + + var initialized = new CustomerSearchFilterInitializationRule().Initialize(filter); + + initialized.Skip.Should().Be(0); + initialized.Take.Should().Be(50); + initialized.SortBy.Should().Be(nameof(Customer.LastName)); + } + + [Fact] + public void If_the_filter_is_already_configured___Values_are_kept() + { + var filter = new CustomerSearchFilter + { + Skip = 10, + Take = 5, + SortBy = nameof(Customer.EMail) + }; + + var initialized = new CustomerSearchFilterInitializationRule().Initialize(filter); + + initialized.Skip.Should().Be(10); + initialized.Take.Should().Be(5); + initialized.SortBy.Should().Be(nameof(Customer.EMail)); + } + + [Fact] + public void If_another_filter_type_is_checked___It_cannot_be_initialized() + { + new CustomerSearchFilterInitializationRule().CanInitialize().Should().BeFalse(); + } + + #endregion +} diff --git a/GerstITS.Examples.Tests/Customers/Validation/CustomerValidationRuleTest.cs b/GerstITS.Examples.Tests/Customers/Validation/CustomerValidationRuleTest.cs new file mode 100644 index 0000000..b34f5e9 --- /dev/null +++ b/GerstITS.Examples.Tests/Customers/Validation/CustomerValidationRuleTest.cs @@ -0,0 +1,67 @@ +using AwesomeAssertions; +using GerstITS.Examples.Logic.Customers; +using GerstITS.Examples.Logic.Customers.Validation; +using GerstITS.TestFramework; +using Xunit; + +namespace GerstITS.Examples.Tests.Customers.Validation; + +[TestCategory(TestCategories.Unit)] +public sealed class CustomerValidationRuleTest +{ + #region Tests + + [Fact] + public void If_a_customer_is_complete___It_is_valid() + { + var customer = CreateCustomer(); + + var result = new CustomerValidationRule().Validate((object)customer); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void If_the_first_name_is_missing___It_is_invalid() + { + var customer = CreateCustomer(); + customer.FirstName = string.Empty; + + var result = new CustomerValidationRule().Validate((object)customer); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void If_the_email_has_no_valid_format___It_is_invalid() + { + var customer = CreateCustomer(); + customer.EMail = "not-an-email"; + + var result = new CustomerValidationRule().Validate((object)customer); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void If_a_customer_type_is_checked___It_can_be_validated() + { + new CustomerValidationRule().CanValidate(typeof(Customer)).Should().BeTrue(); + } + + #endregion + + #region Methods + + private static Customer CreateCustomer() + { + return new Customer + { + FirstName = "Erika", + LastName = "Mustermann", + EMail = "erika.mustermann@example.com" + }; + } + + #endregion +} diff --git a/GerstITS.Examples.Tests/GerstITS.Examples.Tests.csproj b/GerstITS.Examples.Tests/GerstITS.Examples.Tests.csproj new file mode 100644 index 0000000..5627123 --- /dev/null +++ b/GerstITS.Examples.Tests/GerstITS.Examples.Tests.csproj @@ -0,0 +1,20 @@ + + + + false + true + + + + + + + + + + + + + + + diff --git a/GerstITS.Examples.Tests/Shared/Search/Sorting/SortingProviderTest.cs b/GerstITS.Examples.Tests/Shared/Search/Sorting/SortingProviderTest.cs new file mode 100644 index 0000000..7a806c8 --- /dev/null +++ b/GerstITS.Examples.Tests/Shared/Search/Sorting/SortingProviderTest.cs @@ -0,0 +1,57 @@ +using AwesomeAssertions; +using GerstITS.Examples.Logic.Customers; +using GerstITS.Examples.Logic.Shared.Search.Sorting; +using GerstITS.Search; +using GerstITS.TestFramework; +using Xunit; + +namespace GerstITS.Examples.Tests.Shared.Search.Sorting; + +[TestCategory(TestCategories.Unit)] +public sealed class SortingProviderTest +{ + #region Tests + + [Fact] + public void If_customers_are_sorted_descending___The_order_is_reversed() + { + var query = CreateCustomers().AsQueryable(); + var sorting = new CustomerSearchFilter { SortBy = nameof(Customer.LastName), SortDirection = SortingDirections.Descending }; + + var sorted = new SortingProvider().Sort(query, sorting); + + sorted.First().LastName.Should().Be("Zimmermann"); + } + + [Fact] + public void If_the_sort_property_is_unknown___The_query_is_left_unchanged() + { + var query = CreateCustomers().AsQueryable(); + var sorting = new CustomerSearchFilter { SortBy = "Unknown" }; + + var sorted = new SortingProvider().Sort(query, sorting); + + sorted.Last().LastName.Should().Be("Zimmermann"); + } + + [Fact] + public void If_the_item_type_is_not_sortable___It_cannot_be_sorted() + { + new SortingProvider().CanSort(new[] { "a" }.AsQueryable()).Should().BeFalse(); + } + + #endregion + + #region Methods + + private static IEnumerable CreateCustomers() + { + return new[] + { + new Customer { FirstName = "Anna", LastName = "Albrecht" }, + new Customer { FirstName = "Zoe", LastName = "Zimmermann" } + }; + } + + #endregion +} diff --git a/GerstITS.Examples.Tests/Shared/Validation/IdValidationRuleTest.cs b/GerstITS.Examples.Tests/Shared/Validation/IdValidationRuleTest.cs new file mode 100644 index 0000000..9077f34 --- /dev/null +++ b/GerstITS.Examples.Tests/Shared/Validation/IdValidationRuleTest.cs @@ -0,0 +1,30 @@ +using AwesomeAssertions; +using GerstITS.Examples.Logic.Shared.Validation; +using GerstITS.TestFramework; +using Xunit; + +namespace GerstITS.Examples.Tests.Shared.Validation; + +[TestCategory(TestCategories.Unit)] +public sealed class IdValidationRuleTest +{ + #region Tests + + [Fact] + public void If_the_id_is_positive___It_is_valid() + { + var result = new IdValidationRule().Validate((object)1); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void If_the_id_is_zero___It_is_invalid() + { + var result = new IdValidationRule().Validate((object)0); + + result.IsValid.Should().BeFalse(); + } + + #endregion +} diff --git a/GerstITS.Examples.WebClient.Console/Tests/CommonFeaturesRunner.cs b/GerstITS.Examples.WebClient.Console/Tests/CommonFeaturesRunner.cs new file mode 100644 index 0000000..db0eb73 --- /dev/null +++ b/GerstITS.Examples.WebClient.Console/Tests/CommonFeaturesRunner.cs @@ -0,0 +1,121 @@ +using GerstITS.Common; +using GerstITS.Examples.WebClients.Examples.Api; +using GerstITS.IoC.DotNetCore; +using GerstITS.System.Environment; +using GerstITS.System.Json; +using GerstITS.System.Reflection; + +namespace GerstITS.Examples.WebClient.Console.Tests; + +internal sealed class CommonFeaturesRunner : IApplicationStart +{ + #region Fields + + private readonly ISystemClock _systemClock; + private readonly IJsonConvert _jsonConvert; + private readonly IActivator _activator; + + #endregion + + #region Constructors + + public CommonFeaturesRunner(ISystemClock systemClock, + IJsonConvert jsonConvert, + IActivator activator) + { + _systemClock = systemClock; + _jsonConvert = jsonConvert; + _activator = activator; + } + + #endregion + + #region IApplicationStart + + public void Run() + { + RunStringExtensions(); + RunObjectExtensions(); + RunEnumerableExtensions(); + RunRanges(); + RunFluentExceptionHandling(); + RunCryptography(); + RunSystemAbstractions(); + } + + #endregion + + #region Methods + + private static void RunStringExtensions() + { + Print($"---> ToCamelCase: {"HelloWorld".ToCamelCase()}"); + Print($"---> ToValue: {"42".ToValue()}"); + Print($"---> ToValue with default: {"invalid".ToValue(TimeSpan.FromMinutes(5))}"); + Print($"---> IsNullOrWhiteSpace: {" ".IsNullOrWhiteSpace()}"); + Print($"---> ReplaceFirstOccurrence: {"a-a-a".ReplaceFirstOccurrence("a", "b")}"); + Print($"---> GetDeterministicHashCode: {"stable".GetDeterministicHashCode()}"); + } + + private static void RunObjectExtensions() + { + object nothing = null; + + Print($"---> IsNull: {nothing.IsNull()}, IsNotNull: {"value".IsNotNull()}"); + Print($"---> Enum Parse: {"descending".Parse()}"); + } + + private static void RunEnumerableExtensions() + { + var numbers = new[] { 1, 2, 2, 3 }; + + Print($"---> IsNotNullOrEmpty: {numbers.IsNotNullOrEmpty()}"); + Print($"---> AsEnumerable: {1.AsEnumerable().Count()}"); + + numbers.Distinct() + .ForEach(i => Print($"---> ForEach: {i}")); + } + + private static void RunRanges() + { + var january = new Range(new DateTime(2026, 1, 1), new DateTime(2026, 1, 31)); + var midJanuaryToFebruary = new Range(new DateTime(2026, 1, 15), new DateTime(2026, 2, 15)); + + Print($"---> Range intersects: {january.Intersects(midJanuaryToFebruary)}"); + } + + private static void RunFluentExceptionHandling() + { + try + { + (-1).Is(x => x < 0) + .ThenThrows() + .WithMessage(x => $"The value '{x}' must not be negative."); + } + catch (ArgumentOutOfRangeException exception) + { + Print($"---> Fluent exception handling: {exception.Message}"); + } + } + + private static void RunCryptography() + { + var encrypted = "A secret value".Encrypt(); + + Print($"---> Encrypt/Decrypt roundtrip: {encrypted.Decrypt()}"); + } + + private void RunSystemAbstractions() + { + Print($"---> ISystemClock.UtcNow: {_systemClock.UtcNow}"); + Print($"---> IJsonConvert.Serialize: {_jsonConvert.Serialize(new { Name = "Example" })}"); + Print($"---> IActivator.CreateInstance: {_activator.CreateInstance>(0, 10).End}"); + } + + private static void Print(string message) + { + global::System.Console.WriteLine(message); + } + + #endregion +} diff --git a/GerstITS.WebClients.Example.Api/Contracts/ICustomer.cs b/GerstITS.WebClients.Example.Api/Contracts/ICustomer.cs new file mode 100644 index 0000000..56d4220 --- /dev/null +++ b/GerstITS.WebClients.Example.Api/Contracts/ICustomer.cs @@ -0,0 +1,35 @@ +using GerstITS.Web.WebClients; + +namespace GerstITS.Examples.WebClients.Examples.Api; + +public interface ICustomer : IWebService +{ + [WebMethod(WebMethods.Get), + ResourceUrl("Customer/{id}"), + HttpHeader(Name = "X-Client", Value = "GerstITS.Examples.WebClient")] + Customer Get(int id); + + [WebMethod(WebMethods.Post), + ResourceUrl("Customer/Search")] + SearchResult Search(CustomerSearchCriteria criteria); + + [WebMethod(WebMethods.Post), + ResourceUrl("Customer")] + Customer Create(Customer model); + + [WebMethod(WebMethods.Put), + ResourceUrl("Customer")] + Customer Update(Customer model); + + [WebMethod(WebMethods.Delete), + ResourceUrl("Customer/{id}")] + bool Delete(int id); + + [WebMethod(WebMethods.Get), + ResourceUrl("Customer/{id}/Notes")] + IEnumerable GetNotes(int id); + + [WebMethod(WebMethods.Post), + ResourceUrl("Customer/{id}/Notes")] + CustomerNote AddNote(int id, CustomerNote note); +} diff --git a/GerstITS.WebClients.Example.Api/Contracts/Serialization/Customer.cs b/GerstITS.WebClients.Example.Api/Contracts/Serialization/Customer.cs new file mode 100644 index 0000000..2e2b5d0 --- /dev/null +++ b/GerstITS.WebClients.Example.Api/Contracts/Serialization/Customer.cs @@ -0,0 +1,15 @@ +namespace GerstITS.Examples.WebClients.Examples.Api; + +public class Customer +{ + #region Properties + + public int Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string EMail { get; set; } + public string DisplayName { get; set; } + public bool IsActive { get; set; } + + #endregion +} diff --git a/GerstITS.WebClients.Example.Api/Contracts/Serialization/CustomerNote.cs b/GerstITS.WebClients.Example.Api/Contracts/Serialization/CustomerNote.cs new file mode 100644 index 0000000..f64d350 --- /dev/null +++ b/GerstITS.WebClients.Example.Api/Contracts/Serialization/CustomerNote.cs @@ -0,0 +1,12 @@ +namespace GerstITS.Examples.WebClients.Examples.Api; + +public class CustomerNote +{ + #region Properties + + public int Id { get; set; } + public string Text { get; set; } + public DateTime ValidFrom { get; set; } + + #endregion +} diff --git a/GerstITS.WebClients.Example.Api/Contracts/Serialization/CustomerSearchCriteria.cs b/GerstITS.WebClients.Example.Api/Contracts/Serialization/CustomerSearchCriteria.cs new file mode 100644 index 0000000..5206c89 --- /dev/null +++ b/GerstITS.WebClients.Example.Api/Contracts/Serialization/CustomerSearchCriteria.cs @@ -0,0 +1,17 @@ +namespace GerstITS.Examples.WebClients.Examples.Api; + +public class CustomerSearchCriteria +{ + #region Properties + + public string Name { get; set; } + public string EMail { get; set; } + + public int? Skip { get; set; } + public int? Take { get; set; } + + public string SortBy { get; set; } + public SortingDirections SortDirection { get; set; } + + #endregion +} diff --git a/NuGet.Config b/NuGet.Config index 31be91c..cd0b812 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -7,7 +7,6 @@ - @@ -15,10 +14,7 @@ - - - - + diff --git a/README.md b/README.md index 5340c88..93b18cb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This solution demonstrates all features of the GerstITS framework packages in a | Project | Purpose | |---------|---------| -| `GerstITS.Examples.Api` | ASP.NET Core Web API host: fluent controllers, versioning, exception handling, Swagger, authentication, session cache, jobs and auto migration. | +| `GerstITS.Examples.Api` | ASP.NET Core Web API host: fluent controllers, minimal API endpoints, versioning, exception handling, Swagger, authentication, session cache, jobs and auto migration. | | `GerstITS.Examples.Logic` | Business logic: providers, search engine integration, validation rules, customizations and AutoMapper profiles. | | `GerstITS.Examples.Data` | Data access: entities with framework aspects, EF Core DbContext, entity mappings, database configurator and migrations. | | `GerstITS.Examples.Jobs.SayHelloWorld` | Scheduled Quartz jobs with cron configuration. | @@ -80,6 +80,7 @@ This solution demonstrates all features of the GerstITS framework packages in a | `ValidationRuleBase` (FluentValidation) | `Logic/Customers/Validation/CustomerValidationRule.cs`, `Logic/Shared/Validation/IdValidationRule.cs` | | `IsEMail()` rule builder extension | `CustomerValidationRule.cs` | | Automatic validation in fluent controllers (`IValidator`) | All controllers via `FluentApiControllerBase` | +| Explicit validation in minimal API endpoints (`IValidator` + `ToErrorMessage`) | `Api/Endpoints/Endpoints.Validator.cs` | ### GerstITS.Mapping.AutoMapper | Feature | Example | @@ -92,7 +93,12 @@ This solution demonstrates all features of the GerstITS framework packages in a | `HostingStartup` fluent pipeline | `Api/Program.cs` | | `FluentApiControllerBase` (`Api().Use(...).Get/Create/Update/Delete`) | `Api/Controllers/1.1/*.cs` | | API versioning (side by side controllers) | `Api/Controllers/1.0` vs `Api/Controllers/1.1` | +| Minimal API endpoints (partial `Endpoints` class per feature, nested static endpoint classes) | `Api/Endpoints/Endpoints.cs`, `Endpoints.Customers.cs`, `Endpoints.CustomerNotes.cs` | +| Endpoint grouping + Swagger tags (`MapGroup`, `WithTags`) | `Api/Endpoints/Endpoints.Customers.cs` | +| Query parameter binding to search filters (`[AsParameters]`) | `Endpoints.Customers.MapSearch` | | Global exception transformations | `Api/Common/ExceptionHandling/Transformations/*.cs` | +| Global exception handling middleware for minimal APIs (`UseGlobalExceptionHandling`) | `Api/Common/ExceptionHandling/Extensions/WebApplicationExtensions.cs`, `Api/Program.cs` | +| Automatic response compression (zstd, brotli, gzip — also for HTTPS) | built in, no app code required — see "Response compression" below | | Server configuration (CORS, HSTS, forwarded headers) | `Api/Common/Configurations/WebApiConfiguration.Server.*.cs` | | Swagger configuration incl. security schemes | `Api/Common/Configurations/WebApiConfiguration.Swagger*.cs`, `WebApiConfiguration.OpenApiSecurityScheme.cs` | | Session based cache storage (`AddHttpSession`) | `Api/Module.Session.cs`, `CacheController.cs` | @@ -133,6 +139,32 @@ This solution demonstrates all features of the GerstITS framework packages in a 4. **Tests**: `dotnet test` (optionally `--filter "Category=Unit"`). 5. **Migrations**: `dotnet ef migrations add ` inside `GerstITS.Examples.Data` (uses the design time factory and `appsettings.json` of that project). +### Fluent controllers vs. minimal API endpoints + +The customer domain is exposed twice to compare both styles: + +- `POST/GET/PUT/DELETE /api/Customer` — fluent controllers (`FluentApiControllerBase`, validation and exception transformations applied by the fluent pipeline). +- `GET/POST/PUT/DELETE /api/v2.0/customers` — minimal API endpoints (partial `Endpoints` class, explicit `Endpoints.Validator`, exception transformations applied by `UseGlobalExceptionHandling`). + +Search differs on purpose: the controller uses `POST /api/Customer/Search` with a body filter, the minimal API uses +`GET /api/v2.0/customers` binding the same `CustomerSearchFilter` from query parameters via `[AsParameters]`. + +### Response compression + +`GerstITS.Web.Api` enables response compression automatically — there is nothing to register or configure in the +application: the framework module registers the zstd (`ZstdSharp`), brotli and gzip providers with +`EnableForHttps = true` and `HostingStartup...Build()` adds the `UseResponseCompression()` middleware. The provider is +picked by the client's `Accept-Encoding` header (zstd preferred, then brotli, then gzip): + +``` +curl -s -D - -o NUL -H "Accept-Encoding: zstd, br, gzip" https://localhost:/api/v2.0/customers +``` + +The response then carries the matching `Content-Encoding` header (e.g. `Content-Encoding: zstd`). + +> Requires the first `GerstITS.Web.Api` release after 2026.7.7 — older packages do not contain the compression +> feature yet, requests are then answered uncompressed. + ### Api key protected endpoints `GET /api/Maintenance/Ping` requires the header configured under `WebApi:ApiKey` (default: `X-Api-Key: example-api-key`).