Update to latest packages and show examples

This commit is contained in:
2026-07-05 22:05:53 +02:00
parent 3a36978997
commit c7ddc282ed
52 changed files with 2156 additions and 21 deletions
+15 -14
View File
@@ -7,28 +7,29 @@
</ItemGroup>
<ItemGroup>
<PackageVersion Include="AutoMapper" Version="16.1.1" />
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="GerstITS.Authentication.ApiKey" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Authentication.ApiKey" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Authentication.OpenId" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Common" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Common" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Data" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Data.EntityFramework" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Data.EntityFramework" Version="2026.7.8" />
<PackageVersion Include="GerstITS.Data.EntityFramework.PostgreSql" Version="2026.6.8" />
<PackageVersion Include="GerstITS.Data.EntityFramework.SqlServer" Version="2026.6.3" />
<PackageVersion Include="GerstITS.IoC" Version="2026.7.1" />
<PackageVersion Include="GerstITS.IoC.DotNetCore" Version="2026.7.1" />
<PackageVersion Include="GerstITS.IoC" Version="2026.7.7" />
<PackageVersion Include="GerstITS.IoC.DotNetCore" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Job" Version="2026.6.2" />
<PackageVersion Include="GerstITS.Job.Scheduling" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Mail" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Job.Scheduling" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Mail" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Mapping.AutoMapper" Version="2026.6.2" />
<PackageVersion Include="GerstITS.Search" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Search" Version="2026.7.10" />
<PackageVersion Include="GerstITS.System" Version="2026.6.8" />
<PackageVersion Include="GerstITS.TestFramework" Version="2026.7.2" />
<PackageVersion Include="GerstITS.Validation" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Web" Version="2026.7.1" />
<PackageVersion Include="GerstITS.Web.Api" Version="2026.6.27" />
<PackageVersion Include="GerstITS.Web.Api.Swagger" Version="2026.7.3" />
<PackageVersion Include="GerstITS.Web.Rest" Version="2026.6.25" />
<PackageVersion Include="GerstITS.Validation" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Web" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Web.Api" Version="2026.7.12" />
<PackageVersion Include="GerstITS.Web.Api.Swagger" Version="2026.7.7" />
<PackageVersion Include="GerstITS.Web.Rest" Version="2026.7.7" />
<PackageVersion Include="Hocon.Extensions.Configuration" Version="2.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
@@ -44,4 +45,4 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
</Project>
@@ -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<string>($"{prefix}:{nameof(Header)}");
Key = configuration.GetValue<string>($"{prefix}:{nameof(Key)}");
}
#endregion
#region IApiKeyConfiguration
public string Header { get; }
public string Key { get; }
#endregion
}
@@ -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<IExceptionHandlerFeature>();
if (exceptionFeature is null) return;
var exception = exceptionFeature.Error;
var ticketId = Guid.NewGuid().ToString();
context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("GlobalExceptionHandler")
.LogError($"Global exception occurs (TicketId {ticketId}).{Environment.NewLine}{exception}");
var transformation = context.RequestServices
.GetServices<IExceptionTransformation>()
.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<IHttpResponseFeature>().ReasonPhrase = info.ReasonPhrase;
await context.Response.WriteAsync(info.Details ?? string.Empty);
});
});
return app;
}
}
@@ -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;
/// <summary>
/// Is responsible to demonstrate the encrypted session based cache.
/// </summary>
[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
/// <summary>
/// Gets a cached value by specified key.
/// </summary>
/// <param name="key">Key of the cached value.</param>
/// <returns>Returns the cached value.</returns>
[HttpGet,
Route("{key}")]
public IActionResult Get(string key)
{
return Api().Use(key)
.Get(_cache.Get<string>);
}
/// <summary>
/// Checks whether a value is cached for the specified key.
/// </summary>
/// <param name="key">Key of the cached value.</param>
/// <returns>Returns true if a value is cached.</returns>
[HttpGet,
Route("{key}/Exists")]
public IActionResult Exists(string key)
{
return Api().Use(key)
.Get(_cache.ContainsKey);
}
/// <summary>
/// Caches a value for the specified key.
/// </summary>
/// <param name="key">Key of the value to cache.</param>
/// <param name="value">Value to cache.</param>
/// <returns>Returns the cached value.</returns>
[HttpPost,
Route("{key}")]
public IActionResult Set(string key, [FromBody] string value)
{
return Api().Use(key)
.Create(x => {
_cache.Set(x, value);
return value;
});
}
/// <summary>
/// Removes a cached value by specified key.
/// </summary>
/// <param name="key">Key of the cached value.</param>
/// <returns>Returns true if the value was removed.</returns>
[HttpDelete,
Route("{key}")]
public IActionResult Remove(string key)
{
return Api().Use(key)
.Delete(x => {
_cache.Remove(x);
return true;
});
}
#endregion
}
@@ -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;
/// <summary>
/// Is responsible to send contact requests via the configured smtp mail provider.
/// </summary>
[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
/// <summary>
/// Sends a contact request as mail to the support address.
/// </summary>
/// <param name="contactRequest">Contact request to send.</param>
/// <returns>Returns true if the mail was sent.</returns>
[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
}
@@ -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;
/// <summary>
/// Is responsible to manage customers backed by the repository based data access.
/// </summary>
[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
/// <summary>
/// Gets a customer by specified id. Deactivated customers are excluded by default.
/// </summary>
/// <param name="id">Id of the customer.</param>
/// <param name="includeInactive">Includes deactivated (soft deleted) customers if set to true.</param>
/// <returns>Returns a customer.</returns>
[HttpGet,
Route("{id}")]
public IActionResult Get(int id, [FromQuery] bool includeInactive = false)
{
return Api().Use(id)
.Get(x => _provider.GetById(x, includeInactive));
}
/// <summary>
/// Searches customers by specified filter using the search engine (paging, sorting, filtering).
/// </summary>
/// <param name="filter">Filter criteria of the search.</param>
/// <returns>Returns the matching customers including the total count.</returns>
[HttpPost,
Route("Search")]
public IActionResult Search([FromBody] CustomerSearchFilter filter)
{
return Api().Use(filter)
.Get(_provider.Search);
}
/// <summary>
/// Creates a new customer.
/// </summary>
/// <param name="customer">Customer to create.</param>
/// <returns>Returns the created customer.</returns>
[HttpPost]
public IActionResult Create([FromBody] Customer customer)
{
return Api().Use(customer)
.Create(_provider.Create);
}
/// <summary>
/// Updates an existing customer.
/// </summary>
/// <param name="customer">Customer to update.</param>
/// <returns>Returns the updated customer.</returns>
[HttpPut]
public IActionResult Update([FromBody] Customer customer)
{
return Api().Use(customer)
.Update(_provider.Update);
}
/// <summary>
/// Deletes a customer. Performs a soft delete by default, a permanent delete if force is set to true.
/// </summary>
/// <param name="id">Id of the customer.</param>
/// <param name="force">Deletes the customer permanently if set to true.</param>
/// <returns>Returns true if the customer was deleted.</returns>
[HttpDelete,
Route("{id}")]
public IActionResult Delete(int id, [FromQuery] bool force = false)
{
return Api().Use(id)
.Delete(x => _provider.Delete(x, force));
}
/// <summary>
/// Imports customers using bulk operations.
/// </summary>
/// <param name="customers">Customers to import.</param>
/// <returns>Returns the bulk operation result.</returns>
[HttpPost,
Route("Import")]
public IActionResult Import([FromBody] IEnumerable<Customer> customers)
{
return Api().Use(customers)
.Create(_provider.Import);
}
/// <summary>
/// Gets all notes of a customer ordered by their validity.
/// </summary>
/// <param name="id">Id of the customer.</param>
/// <returns>Returns the notes of the customer.</returns>
[HttpGet,
Route("{id}/Notes")]
public IActionResult GetNotes(int id)
{
return Api().Use(id)
.Get(_provider.GetNotes);
}
/// <summary>
/// Adds a historicized note to a customer.
/// </summary>
/// <param name="id">Id of the customer.</param>
/// <param name="note">Note to add.</param>
/// <returns>Returns the created note.</returns>
[HttpPost,
Route("{id}/Notes")]
public IActionResult AddNote(int id, [FromBody] CustomerNote note)
{
return Api().Use(id)
.Create(x => _provider.AddNote(x, note.Text));
}
#endregion
}
@@ -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;
/// <summary>
/// Is responsible to provide maintenance functions protected by an api key header.
/// </summary>
[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
/// <summary>
/// Gets the current server time. Requires a valid api key header.
/// </summary>
/// <returns>Returns the current server time.</returns>
[HttpGet,
Route("Ping")]
public IActionResult Ping()
{
return Api().Get(() => _systemClock.UtcNow);
}
#endregion
}
@@ -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;
/// <summary>
/// Is responsible to demonstrate open id (jwt bearer) protected endpoints.
/// </summary>
[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
/// <summary>
/// Gets the name of the authenticated user. Requires a valid bearer token.
/// </summary>
/// <returns>Returns the name of the authenticated user.</returns>
[HttpGet,
Route("Me")]
public IActionResult Me()
{
return Api().Get(() => User.Identity.Name);
}
#endregion
}
@@ -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)));
}
}
}
@@ -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<Customer> customers, ICustomerProvider customerProvider) =>
Results.Ok(customerProvider.Import(customers)));
}
}
}
@@ -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<T>(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()}");
}
}
}
@@ -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();
}
}
+16
View File
@@ -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
}
+8 -1
View File
@@ -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()
@@ -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<CustomerEntity> Customers => Set<CustomerEntity>();
public DbSet<CustomerNoteEntity> CustomerNotes => Set<CustomerNoteEntity>();
protected override string ConnectionStringName => "CustomerDbContext";
#endregion
#region Constructors
public CustomerDbContext(IDatabaseConfigurator databaseConfigurator,
DbContextOptions<CustomerDbContext> dbContextOptions)
: base(databaseConfigurator, dbContextOptions)
{
}
#endregion
}
@@ -0,0 +1,28 @@
using GerstITS.Data;
namespace GerstITS.Examples.Data.Entities;
public sealed class CustomerEntity : EntityBase<int>, 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<CustomerNoteEntity> Notes { get; set; }
#endregion
}
@@ -0,0 +1,19 @@
using GerstITS.Data;
namespace GerstITS.Examples.Data.Entities;
public sealed class CustomerNoteEntity : EntityBase<int>, 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
}
@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Company>Gerst ITS</Company>
<Authors>Gerst ITS</Authors>
<Copyright>© 2025 Gerst ITS</Copyright>
<Product>Gerst ITS Examples data access</Product>
<Description>Example data access.</Description>
<Version>0.0.0.0</Version>
<AssemblyVersion>0.0.0.0</AssemblyVersion>
<AssemblyInformationalVersion>0.0.0.0</AssemblyInformationalVersion>
<FileVersion>0.0.0.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<WarningsAsErrors />
<NoWarn />
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<WarningsAsErrors />
<NoWarn />
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GerstITS.Data" />
<PackageReference Include="GerstITS.Data.EntityFramework" />
<PackageReference Include="GerstITS.Data.EntityFramework.PostgreSql" />
<PackageReference Include="GerstITS.IoC" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectCapability Include="ConfigurableFileNesting" />
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
</ItemGroup>
</Project>
@@ -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<CustomerEntity>
{
#region Properties
protected override string TableName => "Customers";
#endregion
#region Methods
protected override void AddKeyMapping(EntityTypeBuilder<CustomerEntity> entity)
{
entity.HasKey(i => i.Id);
}
protected override void AddPropertyMappings(EntityTypeBuilder<CustomerEntity> 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<CustomerEntity> entity)
{
entity.HasMany(i => i.Notes)
.WithOne()
.HasForeignKey(i => i.CustomerId);
}
#endregion
}
@@ -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<CustomerNoteEntity>
{
#region Properties
protected override string TableName => "CustomerNotes";
#endregion
#region Methods
protected override void AddKeyMapping(EntityTypeBuilder<CustomerNoteEntity> entity)
{
entity.HasKey(i => i.Id);
}
protected override void AddPropertyMappings(EntityTypeBuilder<CustomerNoteEntity> entity)
{
entity.Property(i => i.Text).HasMaxLength(2000).IsRequired();
entity.Property(i => i.CreatedBy).HasMaxLength(100);
entity.Property(i => i.HistorySet).HasMaxLength(50);
}
#endregion
}
@@ -0,0 +1,138 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<int>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("Deactivated")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeactivatedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("EMail")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("character varying(250)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("Modified")
.HasColumnType("timestamp with time zone");
b.Property<string>("ModifiedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Customers", "examples");
});
modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerNoteEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<int>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("HistorySet")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GerstITS.Examples.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "examples");
migrationBuilder.CreateTable(
name: "Customers",
schema: "examples",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EMail = table.Column<string>(type: "character varying(250)", maxLength: 250, nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Deactivated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DeactivatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Modified = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ModifiedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Version = table.Column<int>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
CustomerId = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
Created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
HistorySet = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ValidFrom = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerNotes",
schema: "examples");
migrationBuilder.DropTable(
name: "Customers",
schema: "examples");
}
}
}
@@ -0,0 +1,16 @@
using GerstITS.Data.EntityFramework.PostgreSql.Migrations;
using Microsoft.Extensions.Configuration;
namespace GerstITS.Examples.Data.Migrations;
public sealed class CustomerDatabaseConfigurator : PostgreSqlDatabaseConfiguratorBase<CustomerDbContext>
{
#region Constructors
public CustomerDatabaseConfigurator(IConfiguration configuration)
: base("examples", configuration)
{
}
#endregion
}
@@ -0,0 +1,135 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<int>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("Deactivated")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeactivatedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("EMail")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("character varying(250)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("Modified")
.HasColumnType("timestamp with time zone");
b.Property<string>("ModifiedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Customers", "examples");
});
modelBuilder.Entity("GerstITS.Examples.Data.Entities.CustomerNoteEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<int>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("HistorySet")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,7 @@
using GerstITS.Data.EntityFramework;
namespace GerstITS.Examples.Data.Migrations.DesignTime;
public sealed class CustomerDbContextDesignTimeFactory : DesignTimeDbContextFactoryBase<CustomerDbContext>
{
}
+19
View File
@@ -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<IServiceCollection>
{
#region IIoCModule
public void RegisterComponents(IServiceCollection container)
{
container.AddMigration<CustomerDbContext, CustomerDatabaseConfigurator>();
container.AddEntityFrameworkSession<CustomerDbContext>();
}
#endregion
}
+5
View File
@@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"CustomerDbContext": "Host=localhost;Port=5432;Database=gerstits_examples;Username=postgres;Password=postgres"
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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<Customer> Search(CustomerSearchFilter filter);
Customer Create(Customer customer);
Customer Update(Customer customer);
bool Delete(int id);
bool Delete(int id, bool forceDelete);
BulkResult Import(IEnumerable<Customer> customers);
IEnumerable<CustomerNote> GetNotes(int customerId);
CustomerNote AddNote(int customerId, string text);
}
@@ -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<CustomerEntity>(includeInactive)
.FirstOrDefault(i => i.Id == id)
.ThrowsAnExceptionIfNotExisting();
return ToCustomizedModel(entity);
}
public SearchResult<Customer> Search(CustomerSearchFilter filter)
{
var searchResult = _searchEngine.Search<Customer, CustomerSearchFilter>(filter);
searchResult.Result = searchResult.Result
.Select(i => i.ApplyCustomizations())
.ToList();
return searchResult;
}
public Customer Create(Customer customer)
{
var entity = _mapper.Map<CustomerEntity>(customer);
_repository.Save(entity);
return ToCustomizedModel(entity);
}
public Customer Update(Customer customer)
{
var entity = _repository.Query<CustomerEntity>()
.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<CustomerEntity>(forceDelete)
.FirstOrDefault(i => i.Id == id)
.ThrowsAnExceptionIfNotExisting();
_repository.Delete(entity, forceDelete);
return true;
}
public BulkResult Import(IEnumerable<Customer> customers)
{
var importableCustomers = customers.Is(x => x.IsNullOrEmpty())
.ThenThrows<ArgumentException>()
.WithMessage("At least one customer is required for an import.");
var entities = importableCustomers.Select(_mapper.Map<CustomerEntity>)
.ToList();
return _repository.BulkSave(entities);
}
public IEnumerable<CustomerNote> GetNotes(int customerId)
{
return _repository.Query<CustomerNoteEntity>()
.Where(i => i.CustomerId == customerId)
.OrderBy(i => i.ValidFrom)
.ToList()
.Select(_mapper.Map<CustomerNote>)
.ToList();
}
public CustomerNote AddNote(int customerId, string text)
{
_repository.Query<CustomerEntity>()
.FirstOrDefault(i => i.Id == customerId)
.ThrowsAnExceptionIfNotExisting();
var entity = new CustomerNoteEntity
{
CustomerId = customerId,
Text = text
};
_repository.Save(entity);
return _mapper.Map<CustomerNote>(entity);
}
#endregion
#region Methods
private Customer ToCustomizedModel(CustomerEntity entity)
{
return _mapper.Map<Customer>(entity)
.ApplyCustomizations();
}
#endregion
}
@@ -0,0 +1,23 @@
using GerstITS.Common;
using GerstITS.System.Customizations;
namespace GerstITS.Examples.Logic.Customers.Customizations;
internal sealed class CustomerDisplayNameCustomization : CustomizationBase<Customer>
{
#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
}
@@ -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<CustomerEntity, Customer>()
.ForMember(x => x.DisplayName, m => m.Ignore());
CreateMap<Customer, CustomerEntity>()
.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<CustomerNoteEntity, CustomerNote>();
}
#endregion
}
@@ -0,0 +1,28 @@
using GerstITS.Common;
using GerstITS.Search;
namespace GerstITS.Examples.Logic.Customers.Search;
internal sealed class CustomerSearchFilterInitializationRule : InitializationRuleBase<CustomerSearchFilter>
{
#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
}
@@ -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<Customer, CustomerSearchFilter>
{
#region Fields
private readonly IReadOnlyRepository _repository;
#endregion
#region Constructors
public CustomerSearchQueryBuilderRule(IReadOnlyRepository repository)
{
_repository = repository;
}
#endregion
#region Methods
protected override IQueryable<Customer> CreateQuery(CustomerSearchFilter filter)
{
var query = _repository.Query<CustomerEntity>();
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
}
@@ -0,0 +1,7 @@
using GerstITS.Search;
namespace GerstITS.Examples.Logic.Customers.Validation;
internal sealed class CustomerSearchFilterValidationRule : PagingValidationRuleBase<CustomerSearchFilter>
{
}
@@ -0,0 +1,24 @@
using FluentValidation;
using GerstITS.Validation;
namespace GerstITS.Examples.Logic.Customers.Validation;
internal sealed class CustomerValidationRule : ValidationRuleBase<Customer>
{
#region Constructors
public CustomerValidationRule()
{
RuleFor(x => x.FirstName)
.NotEmpty();
RuleFor(x => x.LastName)
.NotEmpty();
RuleFor(x => x.EMail)
.NotEmpty()
.IsEMail();
}
#endregion
}
@@ -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<ICustomerProvider>();
container.RegisterValidationRulesOf<CustomerValidationRule>();
container.RegisterSearchQueryBuilderRulesOf<CustomerSearchQueryBuilderRule>();
container.RegisterInitializationRulesOf<CustomerSearchFilterInitializationRule>();
container.RegisterCustomizationsOf<CustomerDisplayNameCustomization>();
container.AddScoped<ICustomerProvider, CustomerProvider>();
}
#endregion
}
@@ -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<Customer>(null, null).Should().BeFalse();
}
#endregion
}
@@ -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<string>().Should().BeFalse();
}
#endregion
}
@@ -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
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GerstITS.TestFramework" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerstITS.Examples.Logic\GerstITS.Examples.Logic.csproj" />
</ItemGroup>
</Project>
@@ -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<Customer> CreateCustomers()
{
return new[]
{
new Customer { FirstName = "Anna", LastName = "Albrecht" },
new Customer { FirstName = "Zoe", LastName = "Zimmermann" }
};
}
#endregion
}
@@ -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
}
@@ -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<int>: {"42".ToValue<int>()}");
Print($"---> ToValue<TimeSpan> 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<SortingDirections>()}");
}
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<DateTime>(new DateTime(2026, 1, 1), new DateTime(2026, 1, 31));
var midJanuaryToFebruary = new Range<DateTime>(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<ArgumentOutOfRangeException>()
.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<Range<int>>(0, 10).End}");
}
private static void Print(string message)
{
global::System.Console.WriteLine(message);
}
#endregion
}
@@ -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<Customer> 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<CustomerNote> GetNotes(int id);
[WebMethod(WebMethods.Post),
ResourceUrl("Customer/{id}/Notes")]
CustomerNote AddNote(int id, CustomerNote note);
}
@@ -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
}
@@ -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
}
@@ -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
}
+1 -5
View File
@@ -7,7 +7,6 @@
<packageSources>
<clear />
<add key="Gerst ITS" value="https://packages.gerst-it.com/nuget/public/v3/index.json" />
<add key="nuget.org - V2" value="https://www.nuget.org/api/v2/" />
<add key="nuget.org - V3" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<disabledPackageSources>
@@ -15,10 +14,7 @@
<activePackageSource>
</activePackageSource>
<packageSourceMapping>
<packageSource key="nuget.org - V2">
<package pattern="*" />
</packageSource>
<packageSource key="nuget.org - V3">
<packageSource key="nuget.org - V3">
<package pattern="*" />
</packageSource>
<packageSource key="Gerst ITS">
+33 -1
View File
@@ -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<T>` (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 <Name>` 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:<port>/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`).