![]() |
VOOZH | about |
dotnet add package Pr.BuildingBlocks.Cms.Infrastructure --version 6.0.0
NuGet\Install-Package Pr.BuildingBlocks.Cms.Infrastructure -Version 6.0.0
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="6.0.0" />
<PackageVersion Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="6.0.0" />Directory.Packages.props
<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" />Project file
paket add Pr.BuildingBlocks.Cms.Infrastructure --version 6.0.0
#r "nuget: Pr.BuildingBlocks.Cms.Infrastructure, 6.0.0"
#:package Pr.BuildingBlocks.Cms.Infrastructure@6.0.0
#addin nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=6.0.0Install as a Cake Addin
#tool nuget:?package=Pr.BuildingBlocks.Cms.Infrastructure&version=6.0.0Install as a Cake Tool
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 z konkretnymi technologiami.
net10.0Pr.BuildingBlocks.Cms.Infrastructure6.0.0AddSharedInfrastructure)<PackageReference Include="Pr.BuildingBlocks.Cms.Infrastructure" Version="6.0.0" />
Pakiet ciągnie tranzytywnie:
Microsoft.EntityFrameworkCore 10.0.8Npgsql.EntityFrameworkCore.PostgreSQL 10.0.2Microsoft.AspNetCore.App (FrameworkReference)WolverineFx 6.5.1Slugify.Core 5.1.1, NickBuhro.Translit 1.4.5Pr.BuildingBlocks.Cms.Core (project reference)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();
AddSharedInfrastructure)public static IServiceCollection AddSharedInfrastructure(
this IServiceCollection services,
IConfiguration configuration);
Rejestruje:
IClock → SystemDateTimeProvider (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.
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.
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).
| 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.
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 bindingpublic static IServiceCollection AddUtcDateTimeOffsetSupport(this IServiceCollection services);
Rejestruje globalną konwersję DateTimeOffset ↔ Europe/Warsaw:
UtcDateTimeOffsetJsonConverter dodawany do JsonOptions.Converters.
"2026-04-10T14:00:00") → traktowane jako czas polski → konwersja na UTC."2026-04-10T14:00:00+02:00", "...Z") → konwersja na UTC.+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"
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);
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"
// ...
}
}
Globalny filtr MVC opakowujący ObjectResult.Value w { data: ... }.
services.AddApiResponseWrapper();
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).[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.
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:
dbContext.ChangeTracker wszystkie IAggregateRoot.DomainEvents i czyści listę na agregatach (ClearDomainEvents()).dbContext.SaveChangesAsync(cancellationToken).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ą.
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).
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 | net10.0 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
This package is not used by any NuGet packages.
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 6.0.0 | 160 | 6/9/2026 |
| 5.0.1 | 354 | 4/28/2026 |
| 5.0.0 | 106 | 4/28/2026 |
| 4.0.1 | 124 | 4/27/2026 |
| 4.0.0 | 120 | 4/27/2026 |
| 3.5.2 | 222 | 3/15/2026 |
| 3.5.0 | 155 | 3/9/2026 |
| 3.4.1 | 333 | 2/10/2026 |
| 3.4.0 | 320 | 2/10/2026 |
| 3.3.3 | 735 | 1/17/2026 |
| 3.3.2 | 446 | 1/17/2026 |
| 3.3.1 | 436 | 1/17/2026 |
| 3.3.0 | 721 | 1/17/2026 |
| 3.2.1 | 730 | 1/17/2026 |
| 3.2.0 | 454 | 1/12/2026 |
| 3.1.8 | 462 | 1/9/2026 |
| 3.1.7 | 734 | 1/9/2026 |
| 3.1.6 | 493 | 12/30/2025 |
| 3.1.5 | 1,137 | 10/20/2025 |
| 3.1.4 | 683 | 10/20/2025 |