Pr.BuildingBlocks.Cms.Infrastructure 5.0.1

dotnet add package Pr.BuildingBlocks.Cms.Infrastructure --version 5.0.1
                    
NuGet\Install-Package Pr.BuildingBlocks.Cms.Infrastructure -Version 5.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="5.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="5.0.1" />
                    
Directory.Packages.props
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pr.BuildingBlocks.Cms.Infrastructure --version 5.0.1
                    
#r "nuget: Pr.BuildingBlocks.Cms.Infrastructure, 5.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pr.BuildingBlocks.Cms.Infrastructure@5.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=5.0.1
                    
Install as a Cake Addin
#tool nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=5.0.1
                    
Install as a Cake Tool

Pr.BuildingBlocks.Cms.Infrastructure

Biblioteka building blocków warstwy infrastruktury dla mikroserwisów CMS Polskiego Radia. Zapewnia gotowe implementacje DI dla ASP.NET Core, EF Core (PostgreSQL) i Wolverine — spinając abstrakcje z Pr.BuildingBlocks.Cms.Core z konkretnymi technologiami.

  • Target framework: net8.0
  • PackageId: Pr.BuildingBlocks.Cms.Infrastructure
  • Wersja: 5.0.1

Spis treści


Instalacja

<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="5.0.1" />

Pakiet ciągnie tranzytywnie:

  • Microsoft.EntityFrameworkCore 9.0.9
  • Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4
  • Microsoft.AspNetCore.App (FrameworkReference)
  • WolverineFx 4.12.2
  • Slugify.Core 5.1.1, NickBuhro.Translit 1.4.5
  • Pr.BuildingBlocks.Cms.Core (project reference)

Quick start

Minimalny Program.cs mikroserwisu CMS:

using Pr.BuildingBlocks.Cms.Infrastructure;
using Pr.BuildingBlocks.Cms.Infrastructure.Controllers;
using Pr.BuildingBlocks.Cms.Infrastructure.Exceptions;
using Pr.BuildingBlocks.Cms.Infrastructure.Slugs;
using Pr.BuildingBlocks.Cms.Infrastructure.Time;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddSharedInfrastructure(builder.Configuration)   // IClock + ExceptionHandlerMiddleware
    .AddSlugifier()                                   // ISlugifier
    .AddApiResponseWrapper()                          // { data: ... } wrapper
    .AddUtcDateTimeOffsetSupport()                    // DateTimeOffset ↔ Europe/Warsaw
    .AddCmsHealthController();                        // GET /health → { "data": "Healthy" }

builder.Services.AddControllers();

var app = builder.Build();

app.UseGlobalExceptions();                            // mapuje wyjątki na ErrorResponse
app.MapControllers();

app.Run();

Bootstrap (AddSharedInfrastructure)

public static IServiceCollection AddSharedInfrastructure(
    this IServiceCollection services,
    IConfiguration configuration);

Rejestruje:

  • IClockSystemDateTimeProvider (singleton) — dostawca czasu skonfigurowany dla strefy Europe/Warsaw. Wstrzykuj IClock w handlerach zamiast DateTime.UtcNow, żeby testy mogły kontrolować czas.
  • ExceptionHandlerMiddleware (singleton) — instancja używana przez UseGlobalExceptions().

Wymaga IConfiguration, choć aktualnie nie czyta z niej żadnej wartości — przyjęte na wzrost.


Health checks

public static IServiceCollection AddCmsHealthController(
    this IServiceCollection services,
    Action<HealthControllerOptions>? configure = null);

Rejestruje wbudowany HealthController (MVC) zwracający 200 "Healthy" (liveness) bez zewnętrznych zależności. Trasa jest budowana dynamicznie z HealthControllerOptions.Prefix: domyślnie /health, z prefiksem — /{prefix}/health.

Ponieważ to MVC controller, odpowiedź przechodzi przez ApiResponseWrapperFilter (jeśli zarejestrowany przez AddApiResponseWrapper) i jest opakowana w envelope { data: "Healthy" } — spójnie z resztą endpointów aplikacji.

services.AddCmsHealthController();                                    // GET /health → { "data": "Healthy" }
services.AddCmsHealthController(opt => opt.Prefix = "podcasts");      // GET /podcasts/health

Wewnątrz extension robi services.AddControllers().AddApplicationPart(...) — dzięki temu HealthController jest wykrywany przez ASP.NET Core mimo że żyje w assembly biblioteki. AddControllers() jest idempotentne, więc kolejne wywołanie w Program.cs mikroserwisu nie powoduje konfliktu.

Jeśli z jakiegoś powodu chcesz pominąć wrapper na health-checku (np. external probe oczekuje gołego stringa), dodaj [SkipApiResponseWrapper] przez własny override controllera, lub zarejestruj oddzielny endpoint przez app.MapHealthChecks(...).

Dla bardziej zaawansowanych sprawdzeń (Postgres, RabbitMQ) używaj standardowego API:

builder.Services.AddHealthChecks()
    .AddNpgSql(connectionString)
    .AddRabbitMQ(rabbitConnectionString);

app.MapHealthChecks("/health/ready");

AddCmsHealthController świadomie nie wymusza zależności od Microsoft.Extensions.Diagnostics.HealthChecks, żeby pozostać klockiem czysto liveness.


Globalna obsługa wyjątków

Pipeline wyjątków składa się z dwóch elementów: rejestracji w DI (AddSharedInfrastructure) i montażu w pipeline (UseGlobalExceptions).

app.UseGlobalExceptions();

Wszystkie wyjątki są łapane, logowane (logger.LogError) i serializowane jako ErrorResponse { statusCode, title, detail } (camelCase, indented JSON, application/json; charset=utf-8).

Tabela mapowań

Wyjątek HTTP Tytuł Detail
DomainRuleException 400 „Naruszenie reguły domenowej" exception.Message
InvalidValueException 400 „Nieprawidłowa wartość" exception.Message
NotFoundException 404 „Nie znaleziono zasobu" exception.Message
ConflictException 409 „Konflikt zasobu" exception.Message
OptimisticConcurrencyException 409 „Konflikt optymistycznej współbieżności" exception.Message
UniqueConstraintViolationException 409 „Naruszenie unikalności" exception.Message
DbUpdateConcurrencyException (EF Core) 409 „Konflikt optymistycznej współbieżności" „Zasób został zmodyfikowany przez inną transakcję. Odśwież dane i spróbuj ponownie."
DbUpdateException z PostgresException.SqlState == "23505" 409 „Naruszenie unikalności" „Naruszono unikalność klucza '{constraintName}'."
ForbiddenException 403 „Odmowa dostępu" exception.Message
BaseException (pozostałe) 400 „Błąd aplikacji" exception.Message
InvalidOperationException 400 „Nieprawidłowa operacja" exception.Message
ArgumentNullException 400 „Argument null" exception.Message
ArgumentException (pozostałe) 400 „Nieprawidłowy argument" exception.Message
Pozostałe 500 „Wewnętrzny błąd serwera" W Development: exception.Message. Inaczej: „Wystąpił nieoczekiwany błąd."

DbUpdateException jest rozpoznawana jako naruszenie unikalności tylko gdy InnerException to Npgsql.PostgresException ze SqlState == "23505" — to standardowy kod PostgreSQL dla unique constraint violation. Inne DbUpdateException wpadają do fallback-u Exception → 500.


Czas i strefa Europe/Warsaw

Wszystkie operacje czasowe w mikroserwisach CMS pracują w strefie Europe/Warsaw. Biblioteka zapewnia dwa filary tej spójności.

SystemDateTimeProvider (rejestrowany jako IClock)

Implementacja IClock rejestrowana automatycznie przez AddSharedInfrastructure. Zwraca DateTimeOffset.UtcNow po konwersji do strefy Europe/Warsaw. Wstrzykuj IClock w handlerach i agregatach zamiast statycznych wywołań DateTime.UtcNow.

AddUtcDateTimeOffsetSupport — JSON + model binding

public static IServiceCollection AddUtcDateTimeOffsetSupport(this IServiceCollection services);

Rejestruje globalną konwersję DateTimeOffsetEurope/Warsaw:

  • UtcDateTimeOffsetJsonConverter dodawany do JsonOptions.Converters.
    • Wejście bez offsetu ("2026-04-10T14:00:00") → traktowane jako czas polski → konwersja na UTC.
    • Wejście z offsetem ("2026-04-10T14:00:00+02:00", "...Z") → konwersja na UTC.
    • Wyjście zawsze w czasie polskim z odpowiednim offsetem (+01:00 zima / +02:00 lato).
  • UtcDateTimeOffsetModelBinderProvider wstawiany na początek MvcOptions.ModelBinderProviders dla typów DateTimeOffset i DateTimeOffset?. Identyczna semantyka jak konwerter JSON, ale dla query string i route values.

W obu przypadkach: w pamięci pracujesz na UTC, a klient (API consumer) widzi czas polski.

// Request:  GET /episodes?from=2026-06-15T14:00:00
// Bound to: from = 2026-06-15T12:00:00+00:00 (UTC, lato → -2h)
// Response JSON: "publishedAt": "2026-06-15T14:00:00+02:00"

Paginacja

public static Task<(IReadOnlyList<T> Items, int TotalCount)> ToPagedListAsync<T>(
    this IQueryable<T> query,
    int pageNumber,
    int pageSize,
    CancellationToken cancellationToken);

Wykonuje paginację na IQueryable<T>: CountAsync, potem Skip/Take/ToListAsync. Waliduje pageNumber >= 1 i pageSize >= 1 (rzuca ArgumentOutOfRangeException). Mapowanie tuple do PagedResultDto<T> należy do warstwy aplikacyjnej (query handler).

var (items, total) = await dbContext.Episodes
    .Where(e => e.Status == EpisodeStatus.Published)
    .OrderByDescending(e => e.PublishedAt)
    .ToPagedListAsync(pageNumber, pageSize, cancellationToken);

return new PagedResultDto<EpisodeDto>(
    items.Select(EpisodeDto.From).ToList(),
    total,
    pageNumber,
    pageSize);

Slugi

public static IServiceCollection AddSlugifier(
    this IServiceCollection services,
    Action<SlugSettings>? configure = null);

Rejestruje ISlugifierSlugifier (scoped). Implementacja używa Slugify.SlugHelper i wstępnie transliteruje cyrylicę na łacinkę (NickBuhro.Translit).

Domyślne ustawienia (stosowane out-of-the-box przy AddSlugifier() bez argumentów):

  • ForceLowerCase = true
  • CollapseDashes = true
  • TrimWhitespace = true
  • CustomReplacements: polskie znaki diakrytyczne (ą→a, ć→c, …, ż→z) i kropka (.→).

Każde z ustawień można nadpisać przekazując Action<SlugSettings>:

services.AddSlugifier(opt =>
{
    opt.ForceLowerCase = false;
    opt.CustomReplacements["ż"] = "zh";    // dodaj/nadpisz pojedynczą regułę
    opt.CustomReplacements.Remove(".");    // usuń regułę domyślną
});

Można też podmienić cały słownik replacements (jeśli nie chcemy polskich domyślnych):

services.AddSlugifier(opt =>
{
    opt.CustomReplacements = new Dictionary<string, string>
    {
        { "&", "and" }, { "@", "at" }
    };
});
public sealed class CreateEpisodeHandler(ISlugifier slugifier, AppDbContext dbContext)
{
    public async Task<EpisodeId> Handle(CreateEpisodeCommand cmd, CancellationToken ct)
    {
        var slug = slugifier.GenerateSlug(cmd.Title); // "Ślepy zaułek" → "slepy-zaulek"
        // ...
    }
}

Wrapper odpowiedzi API

Globalny filtr MVC opakowujący ObjectResult.Value w { data: ... }.

services.AddApiResponseWrapper();

Działanie

Dla każdego ObjectResult z niepustą wartością wrapper zamienia ciało odpowiedzi na { "data": <oryginalna wartość> }. Wyjątki:

  • PagedResultDto<T> — pomijany (ma własną strukturę data/total/pageNumber/pageSize).
  • Akcje lub kontrolery z [SkipApiResponseWrapper] — pomijane.
[ApiController]
[Route("episodes")]
public sealed class EpisodesController : ControllerBase
{
    [HttpGet("{id:guid}")]
    public ActionResult<EpisodeDto> Get(Guid id) => Ok(...);
    // → 200 { "data": { "id": "...", "title": "..." } }

    [HttpGet]
    public ActionResult<PagedResultDto<EpisodeDto>> List([FromQuery] int pageNumber, [FromQuery] int pageSize)
        => Ok(...);
    // → 200 { "data": [...], "total": 25, "pageNumber": 1, "pageSize": 10 }   (bez podwójnego owijania)

    [HttpGet("raw")]
    [SkipApiResponseWrapper]
    public IActionResult Raw() => Ok(new byte[] { ... });
    // → 200 [...]   (bez wrappera)
}

SkipApiResponseWrapperAttribute jest dostępny zarówno na metodzie, jak i na klasie kontrolera.


Wolverine — publikacja zdarzeń domenowych

public sealed class DomainEventsMiddleware<TDbContext> where TDbContext : DbContext
{
    public Task AfterAsync(IMessageContext context, TDbContext dbContext, IClock clock,
        CancellationToken cancellationToken);
}

Middleware Wolverine, który po obsłużeniu wiadomości:

  1. Wyciąga z dbContext.ChangeTracker wszystkie IAggregateRoot.
  2. Zbiera ich DomainEvents i czyści listę na agregatach (ClearDomainEvents()).
  3. Woła dbContext.SaveChangesAsync(cancellationToken).
  4. Dla każdego zdarzenia ustawia OccurredOn = clock.UtcNow i publikuje przez context.PublishAsync(domainEvent).

Dzięki temu agregaty domenowe nie wiedzą o czasie ani o magistrali wiadomości — tylko gromadzą zdarzenia, a middleware zajmuje się persystencją i publikacją.

Rejestracja w Wolverine

builder.Host.UseWolverine(opts =>
{
    opts.Policies.AddMiddleware(typeof(DomainEventsMiddleware<AppDbContext>));
});

TDbContext to konkretny typ DbContext w danym mikroserwisie (Wolverine generuje kod średnio kosztujący po jednym middleware na DbContext).

Przykład agregatu

public sealed class Episode : AggregateRoot<EpisodeId>
{
    public void Publish()
    {
        if (Status == EpisodeStatus.Published)
            throw new DomainRuleException("Odcinek jest już opublikowany.");

        Status = EpisodeStatus.Published;
        IncrementVersion();
        AddDomainEvent(new EpisodePublished(Id, Title));
    }
}

Po commitcie EF Core middleware automatycznie opublikuje EpisodePublished przez Wolverine — handler zewnętrzny może na nie zareagować bez bezpośredniej zależności w kodzie domenowym.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
5.0.1 241 4/28/2026
5.0.0 89 4/28/2026
4.0.1 106 4/27/2026
4.0.0 100 4/27/2026
3.5.2 198 3/15/2026
3.5.0 132 3/9/2026
3.4.1 312 2/10/2026
3.4.0 297 2/10/2026
3.3.3 711 1/17/2026
3.3.2 425 1/17/2026
3.3.1 415 1/17/2026
3.3.0 701 1/17/2026
3.2.1 699 1/17/2026
3.2.0 434 1/12/2026
3.1.8 442 1/9/2026
3.1.7 713 1/9/2026
3.1.6 471 12/30/2025
3.1.5 1,117 10/20/2025
3.1.4 663 10/20/2025
3.1.3 827 10/7/2025
Loading failed