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
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="5.0.1" />
<PackageVersion Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="5.0.1" />
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" />
paket add Pr.BuildingBlocks.Cms.Infrastructure --version 5.0.1
#r "nuget: Pr.BuildingBlocks.Cms.Infrastructure, 5.0.1"
#:package Pr.BuildingBlocks.Cms.Infrastructure@5.0.1
#addin nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=5.0.1
#tool nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=5.0.1
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
- 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="5.0.1" />
Pakiet ciągnie tranzytywnie:
Microsoft.EntityFrameworkCore9.0.9Npgsql.EntityFrameworkCore.PostgreSQL9.0.4Microsoft.AspNetCore.App(FrameworkReference)WolverineFx4.12.2Slugify.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 (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ę 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 AfterAsync(IMessageContext context, TDbContext dbContext, IClock clock,
CancellationToken cancellationToken);
}
Middleware Wolverine, który po obsłużeniu wiadomości:
- Wyciąga z
dbContext.ChangeTrackerwszystkieIAggregateRoot. - Zbiera ich
DomainEventsi czyści listę na agregatach (ClearDomainEvents()). - Woła
dbContext.SaveChangesAsync(cancellationToken). - Dla każdego zdarzenia ustawia
OccurredOn = clock.UtcNowi publikuje przezcontext.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 | Versions 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. |
-
net8.0
- Microsoft.EntityFrameworkCore (>= 9.0.9)
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.9)
- NickBuhro.Translit (>= 1.4.5)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 9.0.4)
- Pr.BuildingBlocks.Cms.Core (>= 5.0.1)
- Slugify.Core (>= 5.1.1)
- WolverineFx (>= 4.12.2)
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 |