Pr.BuildingBlocks.Cms.Infrastructure
7.0.0
dotnet add package Pr.BuildingBlocks.Cms.Infrastructure --version 7.0.0
NuGet\Install-Package Pr.BuildingBlocks.Cms.Infrastructure -Version 7.0.0
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="7.0.0" />
<PackageVersion Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="7.0.0" />
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" />
paket add Pr.BuildingBlocks.Cms.Infrastructure --version 7.0.0
#r "nuget: Pr.BuildingBlocks.Cms.Infrastructure, 7.0.0"
#:package Pr.BuildingBlocks.Cms.Infrastructure@7.0.0
#addin nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=7.0.0
#tool nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=7.0.0
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:
net10.0 - PackageId:
Pr.BuildingBlocks.Cms.Infrastructure - Wersja:
7.0.0
Spis treści
- Instalacja
- Quick start
- Bootstrap (
AddSharedInfrastructure) - Health checks
- Globalna obsługa wyjątków
- Czas i strefa Europe/Warsaw
- Paginacja
- Slugi
- Wrapper odpowiedzi API
- Wolverine — publikacja zdarzeń domenowych
Instalacja
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="7.0.0" />
Pakiet ciągnie tranzytywnie:
Microsoft.EntityFrameworkCore10.0.8Npgsql.EntityFrameworkCore.PostgreSQL10.0.2Microsoft.AspNetCore.App(FrameworkReference)WolverineFx6.39.1,WolverineFx.EntityFrameworkCore6.39.1Slugify.Core5.1.1,NickBuhro.Translit1.4.5Pr.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:
IClock→SystemDateTimeProvider(singleton) — dostawca czasu skonfigurowany dla strefyEurope/Warsaw. WstrzykujIClockw handlerach zamiastDateTime.UtcNow, żeby testy mogły kontrolować czas.ExceptionHandlerMiddleware(singleton) — instancja używana przezUseGlobalExceptions().
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 razem z wyjątkiem i serializowane jako
ErrorResponse { statusCode, title, detail } (camelCase, indented JSON, application/json; charset=utf-8).
Poziom logu zależy od typu wyjątku:
BaseExceptioni pochodne (wyjątki biznesowe, 4xx) oraz konflikty zapisu z EF Core mapowane na 409 (DbUpdateConcurrencyException,DbUpdateExceptionzSqlState == "23505") —Information. To spodziewany wynik, a nie awaria, więc nie tworzy eventu Sentry ani alarmu. Przy domyślnymLogging:LogLevel:Default = Warning(paczkaPr.BuildingBlocks.Cms.Logging) ten log nie trafia na konsolę; włączasz go kategoriąPr.BuildingBlocks.Cms.Infrastructure.Exceptions.ExceptionHandlerMiddleware: Information.- Pozostałe wyjątki (także
InvalidOperationExceptioniArgumentExceptionmapowane na 400) —Error.
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ę DateTimeOffset ↔ Europe/Warsaw:
UtcDateTimeOffsetJsonConverterdodawany doJsonOptions.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:00zima /+02:00lato).
- Wejście bez offsetu (
UtcDateTimeOffsetModelBinderProviderwstawiany na początekMvcOptions.ModelBinderProvidersdla typówDateTimeOffsetiDateTimeOffset?. 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 ISlugifier → Slugifier (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 = trueCollapseDashes = trueTrimWhitespace = trueCustomReplacements: 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 BeforeAsync(MessageContext context, TDbContext dbContext);
public Task AfterAsync(MessageContext context, TDbContext dbContext, IClock clock,
CancellationToken cancellationToken);
}
Middleware Wolverine, który zapisuje zmiany agregatów i koperty ich zdarzeń domenowych w jednej transakcji bazy (od 7.0.0, decyzja w ADR 0001).
Przed handlerem BeforeAsync dołącza TDbContext do outboxa Wolverine (EfCoreEnvelopeTransaction).
Nie otwiera transakcji ani połączenia. Od tej chwili wiadomości wysłane w trakcie obsługi (kaskadowe
i PublishAsync) nie trafiają od razu do magazynu Wolverine, tylko jako koperty do DbContext:
wiadomości do trwałych kolejek lokalnych do inboxa, pozostałe do outboxa.
Po handlerze AfterAsync:
- Zbiera
DomainEventswszystkich śledzonychIAggregateRoot, także oznaczonych do usunięcia, i czyści listy na agregatach (ClearDomainEvents()). - Ustawia każdemu zdarzeniu
OccurredOn = clock.UtcNowi publikuje je, czyli dodaje jego kopertę doDbContext. - Otwiera transakcję bazy i woła
SaveChangesAsync, który zapisuje encje i koperty razem. - Wołając
EfCoreEnvelopeTransaction.CommitAsync, oznacza wiadomość z trwałego inboxa jako obsłużoną w tej samej transakcji, zatwierdza transakcję, a potem wysyła koperty.
Gdy zapis się nie uda, transakcja jest wycofana: nie zostaje ani zmiana encji, ani żadna koperta, a handler kończy się błędem (ponowienie wiadomości albo błąd żądania HTTP). Gdy się uda, koperty są w trwałym magazynie Wolverine, zanim obsługa się skończy, więc awaria poda po zapisie ich nie gubi.
Transakcja obejmuje tylko zapis na końcu obsługi, a nie cały handler, więc długi handler (np. konwersja pliku) nie trzyma połączenia z puli. Kosztem jest krótka transakcja na końcu każdej obsłużonej wiadomości, także wtedy, gdy handler niczego nie zmienił.
Agregaty domenowe nie wiedzą o czasie ani o magistrali wiadomości — tylko gromadzą zdarzenia, a middleware zajmuje się zapisem i publikacją.
Wymagania wobec serwisu
WolverineFxiWolverineFx.EntityFrameworkCoreco najmniej 6.39.1.TDbContextzarejestrowany przezAddDbContextWithWolverineIntegration<TDbContext>(). Ta rejestracja mapuje tabele kopert w modelu, więc koperty zapisuje ten samSaveChangesAsyncco encje. Bez mapowaniaBeforeAsyncrzucaDomainEventsConfigurationException(HTTP 500).- Trwały magazyn wiadomości w bazie
TDbContext, np.opts.PersistMessagesWithPostgresql(connectionString, schema). Bez niegoBeforeAsyncrzucaInvalidOperationExceptionz Wolverine. - Kod Wolverine zregenerowany po aktualizacji paczki (
codegen writeprzyTypeLoadMode.Static). Kod wygenerowany dla 6.x nie wołaBeforeAsync, więcAfterAsyncrzucaDomainEventsConfigurationException(HTTP 500) i niczego nie zapisuje. - Handler nie woła sam
SaveChangesAsyncnaTDbContext. Zmiany zapisane w ten sposób są zatwierdzone od razu, poza transakcją z kopertami zdarzeń. - Bez
EnableRetryOnFailurew konfiguracji Npgsql: strategia ponawiania EF Core nie dopuszcza transakcji otwieranej przezBeginTransactionAsync. - Bez
opts.Policies.AutoApplyTransactions()i[Transactional]na łańcuchach z tym middleware: ramki transakcyjne Wolverine wołająCommitAsyncdrugi raz poAfterAsync. Takie połączenie nie jest obsługiwane ani sprawdzone.
Rejestracja w Wolverine
builder.Host.UseWolverine(opts =>
{
opts.PersistMessagesWithPostgresql(connectionString, "infrastructure");
opts.Services.AddDbContextWithWolverineIntegration<AppDbContext>(db => db.UseNpgsql(connectionString));
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));
}
}
Middleware zapisze kopertę EpisodePublished razem ze zmienionym Episode i wyśle ją po zatwierdzeniu
transakcji — handler zewnętrzny może na nie zareagować bez bezpośredniej zależności w kodzie domenowym.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.8)
- NickBuhro.Translit (>= 1.4.5)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.2)
- Pr.BuildingBlocks.Cms.Core (>= 7.0.0)
- Slugify.Core (>= 5.1.1)
- WolverineFx (>= 6.39.1)
- WolverineFx.EntityFrameworkCore (>= 6.39.1)
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 |
|---|---|---|
| 7.0.0 | 95 | 11/6/2026 |
| 6.1.0 | 140 | 11/4/2026 |
| 6.0.0 | 1,217 | 6/9/2026 |
| 5.0.1 | 389 | 4/28/2026 |
| 5.0.0 | 139 | 4/28/2026 |
| 4.0.1 | 171 | 4/27/2026 |
| 4.0.0 | 161 | 4/27/2026 |
| 3.5.2 | 250 | 3/15/2026 |
| 3.5.0 | 188 | 3/9/2026 |
| 3.4.1 | 369 | 2/10/2026 |
| 3.4.0 | 355 | 2/10/2026 |
| 3.3.3 | 772 | 1/17/2026 |
| 3.3.2 | 484 | 1/17/2026 |
| 3.3.1 | 469 | 1/17/2026 |
| 3.3.0 | 761 | 1/17/2026 |
| 3.2.1 | 768 | 1/17/2026 |
| 3.2.0 | 488 | 1/12/2026 |
| 3.1.8 | 496 | 1/9/2026 |
| 3.1.7 | 772 | 1/9/2026 |
| 3.1.6 | 528 | 12/30/2025 |