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`).