From bc4bfc228ef4d2ec72a2eba830df0e450541592e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 13:22:41 +0200 Subject: [PATCH 1/7] [Dto]: enforce [ColumnPermission] automatically in generated CRUD endpoints Endpoints (minimal API + controller) now mask restricted response columns via a generated {Entity}ColumnPermissions class: GET/POST/PATCH/bulk responses, paginated list items, and select= projections. Anonymous users are treated as having no permissions (safe by default). Columns ignored from the response DTO are skipped; list masking respects [DtoIgnore(List)]. --- .gitignore | 2 + README.md | 6 + .../src/content/docs/packages/dto/crud-api.md | 16 +++ docs/src/content/docs/packages/ui.md | 3 +- .../SampleApi.Tests/ColumnPermissionTests.cs | 132 ++++++++++++++++++ .../DtoGenerator.CrudExtraction.cs | 15 +- .../DtoGenerator.CrudGeneration.cs | 131 ++++++++++++++--- .../src/ZibStack.NET.Dto/DtoGenerator.cs | 6 + .../ZibStack.NET.Dto/Models/CrudApiInfo.cs | 3 + 9 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 packages/ZibStack.NET.Dto/sample/SampleApi.Tests/ColumnPermissionTests.cs diff --git a/.gitignore b/.gitignore index a036b99..038b331 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ Thumbs.db ## TypeGen sample outputs — regenerated on every build, never commit. **/sample/**/generated/ + +.claude/settings.local.json diff --git a/README.md b/README.md index aeb3878..3a07ec3 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,12 @@ public class Player [DtoIgnore(DtoTarget.Response)] public DateTime CreatedAt { get; set; } } +// Column-level permissions — endpoints mask restricted columns automatically +// (response masking + select= filtering) unless the caller holds the claim/role: +[CrudApi] +[ColumnPermission("Salary", "finance.read")] +public partial class Employee { /* ... */ } + // Test scaffolding — generates xUnit CRUD integration tests for every [CrudApi] entity: [assembly: GenerateCrudTests] diff --git a/docs/src/content/docs/packages/dto/crud-api.md b/docs/src/content/docs/packages/dto/crud-api.md index 0b9038e..5a84f16 100644 --- a/docs/src/content/docs/packages/dto/crud-api.md +++ b/docs/src/content/docs/packages/dto/crud-api.md @@ -321,6 +321,22 @@ public class PlayerStore : EfCrudStore } ``` +### Column-level permissions (`[ColumnPermission]`) + +`[ColumnPermission("Column", "permission")]` (from ZibStack.NET.UI) is enforced automatically by the generated endpoints — no manual wiring. A caller must hold a `permission` claim with that value **or** be in a role of that name; otherwise the column is masked: + +```csharp +[CrudApi] +[ColumnPermission("Salary", "finance.read")] +public partial class Player { ... } +``` + +- `GET /{id}`, `POST`, `PATCH`, `POST /bulk` — `Salary` is set to `default` in the response unless the caller holds `finance.read`. +- `GET /` (list) — every page item is masked the same way (also when a separate `ListItem` DTO is in play, as long as the column survives `[DtoIgnore(DtoTarget.List)]`). +- `GET /?select=Name,Salary` — restricted fields are silently dropped from the projection. + +Anonymous and unauthenticated callers are treated as having **no** permissions (safe by default). The masking logic lives in a generated `{Entity}ColumnPermissions` class — call `PlayerColumnPermissions.Apply(response, user)` yourself if you build custom endpoints on top of the generated DTOs. + ### Error responses All error responses use the [RFC 9110](https://tools.ietf.org/html/rfc9110) **ProblemDetails** format (`application/problem+json`): diff --git a/docs/src/content/docs/packages/ui.md b/docs/src/content/docs/packages/ui.md index bc9ebe0..6b693c3 100644 --- a/docs/src/content/docs/packages/ui.md +++ b/docs/src/content/docs/packages/ui.md @@ -74,7 +74,8 @@ public partial class PostalCodeView Endpoint = "/api/voivodeships/recalculate", Method = "POST", Confirmation = "Recalculate balances?", Permission = "finance.write")] -// Permission metadata +// Permission metadata. [ColumnPermission] also masks the column server-side +// in [CrudApi]/[ImTiredOfCrud] endpoints — see Dto → CRUD API docs. [Permission("voivodeship.read")] [ColumnPermission("Budget", "finance.read")] [DataFilter("VoivodeshipId")] diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/ColumnPermissionTests.cs b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/ColumnPermissionTests.cs new file mode 100644 index 0000000..7084668 --- /dev/null +++ b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/ColumnPermissionTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ZibStack.NET.Dto.Sample.Tests; + +/// +/// Player is marked [ColumnPermission("Salary", "finance.read")] — the generated +/// endpoints must null Salary for callers without that permission and pass it +/// through for callers that hold it. +/// +public class ColumnPermissionTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + private readonly HttpClient _anonymous; + + public ColumnPermissionTests(WebApplicationFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + private HttpClient CreatePrivilegedClient() => + _factory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + services.AddSingleton(new ClaimInjectionFilter()))) + .CreateClient(); + + private async Task CreatePlayerAsync(HttpClient client, string name) + { + var response = await client.PostAsJsonAsync("/api/players", + new { Name = name, Level = 10, Salary = 1234.56m, Password = "secret-pass" }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("id").GetInt32(); + } + + [Fact] + public async Task GetById_Anonymous_MasksSalary() + { + var id = await CreatePlayerAsync(_anonymous, $"PermGet_{Guid.NewGuid():N}"); + + var body = await _anonymous.GetFromJsonAsync($"/api/players/{id}"); + Assert.Equal(0m, body.GetProperty("salary").GetDecimal()); + } + + [Fact] + public async Task Create_Response_Anonymous_MasksSalary() + { + var response = await _anonymous.PostAsJsonAsync("/api/players", + new { Name = $"PermCreate_{Guid.NewGuid():N}", Level = 10, Salary = 999m, Password = "secret-pass" }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(0m, body.GetProperty("salary").GetDecimal()); + } + + [Fact] + public async Task GetList_Anonymous_MasksSalary() + { + var name = $"PermList_{Guid.NewGuid():N}"; + await CreatePlayerAsync(_anonymous, name); + + var body = await _anonymous.GetFromJsonAsync($"/api/players?filter=Name=*{name}"); + var items = body.GetProperty("items"); + Assert.True(items.GetArrayLength() > 0, "Filter should find the player we created"); + foreach (var item in items.EnumerateArray()) + Assert.Equal(0m, item.GetProperty("salary").GetDecimal()); + } + + [Fact] + public async Task Select_Anonymous_DropsRestrictedField() + { + var name = $"PermSelect_{Guid.NewGuid():N}"; + await CreatePlayerAsync(_anonymous, name); + + var body = await _anonymous.GetFromJsonAsync( + $"/api/players?filter=Name=*{name}&select=Name,Salary"); + var items = body.GetProperty("items"); + Assert.True(items.GetArrayLength() > 0, "Filter should find the player we created"); + foreach (var item in items.EnumerateArray()) + { + Assert.True(item.TryGetProperty("name", out _), "Selected non-restricted field should be present"); + Assert.False(item.TryGetProperty("salary", out _), "Restricted field should be dropped from select projection"); + } + } + + [Fact] + public async Task GetById_WithFinanceRead_SeesSalary() + { + var client = CreatePrivilegedClient(); + var id = await CreatePlayerAsync(client, $"PermPriv_{Guid.NewGuid():N}"); + + var body = await client.GetFromJsonAsync($"/api/players/{id}"); + Assert.Equal(1234.56m, body.GetProperty("salary").GetDecimal()); + } + + [Fact] + public async Task GetList_WithFinanceRead_SeesSalary() + { + var client = CreatePrivilegedClient(); + var name = $"PermPrivList_{Guid.NewGuid():N}"; + await CreatePlayerAsync(client, name); + + var body = await client.GetFromJsonAsync($"/api/players?filter=Name=*{name}"); + var items = body.GetProperty("items"); + Assert.True(items.GetArrayLength() > 0, "Filter should find the player we created"); + foreach (var item in items.EnumerateArray()) + Assert.Equal(1234.56m, item.GetProperty("salary").GetDecimal()); + } + + /// Injects a principal holding the finance.read permission claim ahead of the app pipeline. + private sealed class ClaimInjectionFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use(async (ctx, nextMw) => + { + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaim(new Claim("permission", "finance.read")); + ctx.User = new ClaimsPrincipal(identity); + await nextMw(); + }); + next(app); + }; + } +} diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs index 858597e..2735a67 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs @@ -123,6 +123,7 @@ public partial class DtoGenerator // Bridge: extract [ColumnPermission("Column", "permission")] from class var columnPermissions = new Dictionary(); + var listColumnPermissions = new Dictionary(); foreach (var a in allAttrs) { if (a.AttributeClass?.ToDisplayString() == "ZibStack.NET.UI.ColumnPermissionAttribute" @@ -130,7 +131,19 @@ public partial class DtoGenerator && a.ConstructorArguments[0].Value is string colName && a.ConstructorArguments[1].Value is string colPerm) { + // Only keep columns that actually exist on the response DTO — restricted + // columns ignored from the response can't (and don't need to) be masked. + var colProp = GetAllProperties(symbol).FirstOrDefault(p => p.Name == colName); + if (colProp is null) continue; + var (cig, con) = GetDtoTargetFlags(colProp); + // DtoTarget.Response = 4 + var ignoredFromResponse = cig == 31 || (cig & 4) != 0 || (con != 0 && (con & 4) == 0); + if (ignoredFromResponse) continue; columnPermissions[colName] = colPerm; + // DtoTarget.List = 16 + var ignoredFromList = (cig & 16) != 0 || (con != 0 && (con & 16) == 0); + if (!ignoredFromList) + listColumnPermissions[colName] = colPerm; } } @@ -170,7 +183,7 @@ public partial class DtoGenerator updatePolicy, deletePolicy, listResponseName, - columnPermissions) { SoftDelete = softDelete, SignalR = signalR }; + columnPermissions) { SoftDelete = softDelete, SignalR = signalR, ListColumnPermissions = listColumnPermissions }; } catch { return null; } } // ─── Auto-implied DTOs from [CrudApi] ────────────────────────────── diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs index e9134da..aa2f080 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs @@ -31,6 +31,13 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) var hubContextType = info.SignalR ? $"IHubContext<{entity}Hub, I{entity}HubClient>" : null; var hubParam = info.SignalR ? $", {hubContextType} __hub" : ""; + // [ColumnPermission] enforcement: endpoints mask restricted response columns automatically. + var enforcePerms = info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null; + var permsClass = $"{entity}ColumnPermissions"; + var userParam = enforcePerms ? ", System.Security.Claims.ClaimsPrincipal? __user" : ""; + // The list endpoint only needs masking when a restricted column survives in the list DTO. + var enforceListPerms = enforcePerms && (info.ListResponseName is null || info.ListColumnPermissions.Count > 0); + sb.AppendLine("// "); sb.AppendLine($"// Generated by ZibStack.NET.Dto from [CrudApi] on {entity}"); sb.AppendLine("#nullable enable"); @@ -80,7 +87,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // GET {id} if ((info.Operations & OpGetById) != 0) { - sb.AppendLine($" group.MapGet(\"{{id}}\", async ({keyType} id, {storeType} store, CancellationToken ct) =>"); + sb.AppendLine($" group.MapGet(\"{{id}}\", async ({keyType} id, {storeType} store{userParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); @@ -89,7 +96,9 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return Results.Ok({fqResponse}.FromEntity(entity));"); + sb.AppendLine(enforcePerms + ? $" return Results.Ok({permsClass}.Apply({fqResponse}.FromEntity(entity), __user));" + : $" return Results.Ok({fqResponse}.FromEntity(entity));"); } else { @@ -118,11 +127,14 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) ? (info.Namespace is not null ? $"{info.Namespace}.{info.QueryName}" : info.QueryName) : null; + // ClaimsPrincipal binds from HttpContext.User; it can't be optional, so it + // goes before the defaulted params instead of reusing userParam. + var listUserParam = enforcePerms ? " System.Security.Claims.ClaimsPrincipal? __user," : ""; if (fqQuery is not null) { - var asyncKeyword = info.HasQueryDsl ? "async " : ""; + var asyncKeyword = info.HasQueryDsl || enforceListPerms ? "async " : ""; sb.AppendLine($" group.MapGet(\"\", {asyncKeyword}([Microsoft.AspNetCore.Http.AsParameters] {fqQuery} query,"); - sb.AppendLine($" {storeType} store, int page = 1, int pageSize = 20{dslParams}{softDeleteParam}, CancellationToken ct = default) =>"); + sb.AppendLine($" {storeType} store,{listUserParam} int page = 1, int pageSize = 20{dslParams}{softDeleteParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var q = store.Query();"); if (info.SoftDelete) @@ -133,7 +145,8 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) } else { - sb.AppendLine($" group.MapGet(\"\", ({storeType} store, int page = 1, int pageSize = 20{dslParams}{softDeleteParam}, CancellationToken ct = default) =>"); + var asyncKeyword = enforceListPerms ? "async " : ""; + sb.AppendLine($" group.MapGet(\"\", {asyncKeyword}({storeType} store,{listUserParam} int page = 1, int pageSize = 20{dslParams}{softDeleteParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var q = store.Query();"); if (info.SoftDelete) @@ -152,6 +165,8 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine(" if (select is not null)"); sb.AppendLine(" {"); sb.AppendLine(" var fields = ZibStack.NET.Query.SelectParser.Parse(select);"); + if (enforcePerms) + sb.AppendLine($" {permsClass}.FilterFields(fields, __user);"); sb.AppendLine($" q = {fqQuery}.ApplyIncludes(q, fields);"); sb.AppendLine(" var totalCount = q.Count();"); sb.AppendLine(" var items = q.Skip((page - 1) * pageSize).Take(pageSize).ToList();"); @@ -163,7 +178,9 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) if (fqListResponse is not null) { sb.AppendLine($" var defaultProjected = {fqListResponse}.ProjectFrom(q);"); - if (info.HasQueryDsl) + if (enforceListPerms) + sb.AppendLine($" return Results.Ok((await PaginatedResponse<{fqListResponse}>.CreateAsync(defaultProjected, page, pageSize, ct)).Map(r => {permsClass}.Apply(r, __user)));"); + else if (info.HasQueryDsl) sb.AppendLine($" return Results.Ok(await PaginatedResponse<{fqListResponse}>.CreateAsync(defaultProjected, page, pageSize, ct));"); else sb.AppendLine($" return PaginatedResponse<{fqListResponse}>.CreateAsync(defaultProjected, page, pageSize, ct);"); @@ -184,7 +201,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqCreate = info.Namespace is not null ? $"{info.Namespace}.{info.CreateRequestName}" : info.CreateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForCreate" : "Validate"; - sb.AppendLine($" group.MapPost(\"\", async ({fqCreate} request, {storeType} store{hubParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPost(\"\", async ({fqCreate} request, {storeType} store{hubParam}{userParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary());"); @@ -203,7 +220,10 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return Results.CreatedAtRoute(\"Get{entity}\", new {{ id = entity.{info.KeyPropertyName} }}, {fqResponse}.FromEntity(entity));"); + var createdValue = enforcePerms + ? $"{permsClass}.Apply({fqResponse}.FromEntity(entity), __user)" + : $"{fqResponse}.FromEntity(entity)"; + sb.AppendLine($" return Results.CreatedAtRoute(\"Get{entity}\", new {{ id = entity.{info.KeyPropertyName} }}, {createdValue});"); } else { @@ -221,7 +241,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqUpdate = info.Namespace is not null ? $"{info.Namespace}.{info.UpdateRequestName}" : info.UpdateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForUpdate" : "Validate"; - sb.AppendLine($" group.MapPatch(\"{{id}}\", async ({keyType} id, {fqUpdate} request, {storeType} store{hubParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPatch(\"{{id}}\", async ({keyType} id, {fqUpdate} request, {storeType} store{hubParam}{userParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); @@ -242,7 +262,9 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return Results.Ok({fqResponse}.FromEntity(entity));"); + sb.AppendLine(enforcePerms + ? $" return Results.Ok({permsClass}.Apply({fqResponse}.FromEntity(entity), __user));" + : $" return Results.Ok({fqResponse}.FromEntity(entity));"); } else { @@ -288,7 +310,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqCreate = info.Namespace is not null ? $"{info.Namespace}.{info.CreateRequestName}" : info.CreateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForCreate" : "Validate"; - sb.AppendLine($" group.MapPost(\"bulk\", async (List<{fqCreate}> requests, {storeType} store, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPost(\"bulk\", async (List<{fqCreate}> requests, {storeType} store{userParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine(" var allErrors = new DtoValidationResult();"); sb.AppendLine(" for (var i = 0; i < requests.Count; i++)"); @@ -307,7 +329,9 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return Results.Ok(entities.Select(e => {fqResponse}.FromEntity(e)).ToList());"); + sb.AppendLine(enforcePerms + ? $" return Results.Ok(entities.Select(e => {permsClass}.Apply({fqResponse}.FromEntity(e), __user)).ToList());" + : $" return Results.Ok(entities.Select(e => {fqResponse}.FromEntity(e)).ToList());"); } else { @@ -359,24 +383,71 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine(" return group;"); sb.AppendLine(" }"); - // Generate permission filter helper if [ColumnPermission] attributes exist - if (info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null) + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Emits a standalone {Entity}ColumnPermissions class so both the minimal API + /// endpoints and the generated controller can mask restricted columns. Null/anonymous + /// users are treated as having no permissions (safe by default). + /// + private static string GenerateColumnPermissionsSource(CrudApiInfo info) + { + var sb = new StringBuilder(); + var entity = info.ClassName; + var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName!; + + sb.AppendLine("// "); + sb.AppendLine($"// Generated by ZibStack.NET.Dto from [ColumnPermission] on {entity}"); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine($"namespace {info.Namespace ?? "ZibStack.NET.Dto.Generated"};"); + sb.AppendLine(); + sb.AppendLine($"/// Masks columns the current user lacks permission for. Called automatically by the generated CRUD endpoints."); + sb.AppendLine($"public static class {entity}ColumnPermissions"); + sb.AppendLine("{"); + sb.AppendLine(" private static bool Has(System.Security.Claims.ClaimsPrincipal? user, string permission)"); + sb.AppendLine(" => user is not null && (user.HasClaim(\"permission\", permission) || user.IsInRole(permission));"); + sb.AppendLine(); + sb.AppendLine(" /// Nulls restricted properties unless the user holds the required permission claim or role."); + sb.AppendLine($" public static {fqResponse} Apply({fqResponse} response, System.Security.Claims.ClaimsPrincipal? user)"); + sb.AppendLine(" {"); + foreach (var kv in info.ColumnPermissions) + { + sb.AppendLine($" if (!Has(user, \"{kv.Value}\"))"); + sb.AppendLine($" response = response with {{ {kv.Key} = default }};"); + } + sb.AppendLine(" return response;"); + sb.AppendLine(" }"); + + if (info.ListResponseName is not null && info.ListColumnPermissions.Count > 0) { - var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; + var fqList = info.Namespace is not null ? $"{info.Namespace}.{info.ListResponseName}" : info.ListResponseName; sb.AppendLine(); - sb.AppendLine($" /// Nulls restricted properties on the response based on user claims."); - sb.AppendLine($" public static {fqResponse} ApplyColumnPermissions({fqResponse} response, System.Security.Claims.ClaimsPrincipal? user)"); + sb.AppendLine(" /// List-item overload — covers restricted columns that survive in the list DTO."); + sb.AppendLine($" public static {fqList} Apply({fqList} response, System.Security.Claims.ClaimsPrincipal? user)"); sb.AppendLine(" {"); - sb.AppendLine(" if (user is null) return response;"); - foreach (var kv in info.ColumnPermissions) + foreach (var kv in info.ListColumnPermissions) { - sb.AppendLine($" if (!user.HasClaim(\"permission\", \"{kv.Value}\") && !user.IsInRole(\"{kv.Value}\"))"); + sb.AppendLine($" if (!Has(user, \"{kv.Value}\"))"); sb.AppendLine($" response = response with {{ {kv.Key} = default }};"); } sb.AppendLine(" return response;"); sb.AppendLine(" }"); } + sb.AppendLine(); + sb.AppendLine(" /// Removes restricted field paths from a select= projection for users without the required permission."); + sb.AppendLine(" public static void FilterFields(System.Collections.Generic.HashSet fields, System.Security.Claims.ClaimsPrincipal? user)"); + sb.AppendLine(" {"); + foreach (var kv in info.ColumnPermissions) + { + sb.AppendLine($" if (!Has(user, \"{kv.Value}\"))"); + sb.AppendLine($" fields.RemoveWhere(f => f.Equals(\"{kv.Key}\", System.StringComparison.OrdinalIgnoreCase) || f.StartsWith(\"{kv.Key}.\", System.StringComparison.OrdinalIgnoreCase));"); + } + sb.AppendLine(" }"); sb.AppendLine("}"); return sb.ToString(); @@ -391,6 +462,11 @@ private static string GenerateControllerSource(CrudApiInfo info) var storeType = $"ZibStack.NET.Dto.ICrudStore<{fqEntity}, {keyType}>"; var controllerName = $"{entity}CrudController"; + // [ColumnPermission] enforcement — controllers read the principal from ControllerBase.User. + var enforcePerms = info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null; + var permsClass = $"{entity}ColumnPermissions"; + var enforceListPerms = enforcePerms && (info.ListResponseName is null || info.ListColumnPermissions.Count > 0); + sb.AppendLine("// "); sb.AppendLine($"// Generated by ZibStack.NET.Dto from [CrudApi] on {entity}"); sb.AppendLine("#nullable enable"); @@ -438,7 +514,9 @@ private static string GenerateControllerSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return Ok({fqResponse}.FromEntity(entity));"); + sb.AppendLine(enforcePerms + ? $" return Ok({permsClass}.Apply({fqResponse}.FromEntity(entity), User));" + : $" return Ok({fqResponse}.FromEntity(entity));"); } else { @@ -483,6 +561,8 @@ private static string GenerateControllerSource(CrudApiInfo info) { sb.AppendLine($" var projected = {fqCtrlList}.ProjectFrom(q);"); sb.AppendLine($" var result = await PaginatedResponse<{fqCtrlList}>.CreateAsync(projected, page, pageSize, ct);"); + if (enforceListPerms) + sb.AppendLine($" result = result.Map(r => {permsClass}.Apply(r, User));"); } else { @@ -510,7 +590,10 @@ private static string GenerateControllerSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return CreatedAtAction(nameof(GetById), new {{ id = entity.{info.KeyPropertyName} }}, {fqResponse}.FromEntity(entity));"); + var createdValue = enforcePerms + ? $"{permsClass}.Apply({fqResponse}.FromEntity(entity), User)" + : $"{fqResponse}.FromEntity(entity)"; + sb.AppendLine($" return CreatedAtAction(nameof(GetById), new {{ id = entity.{info.KeyPropertyName} }}, {createdValue});"); } else { @@ -539,7 +622,9 @@ private static string GenerateControllerSource(CrudApiInfo info) if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; - sb.AppendLine($" return Ok({fqResponse}.FromEntity(entity));"); + sb.AppendLine(enforcePerms + ? $" return Ok({permsClass}.Apply({fqResponse}.FromEntity(entity), User));" + : $" return Ok({fqResponse}.FromEntity(entity));"); } else { diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs index 3a210d3..eeb6a49 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs @@ -582,6 +582,10 @@ void VisitType(INamedTypeSymbol type) // Emit code map partial class with summary linking to all generated types spc.AddSource($"{info.FullyQualifiedName}.CodeMap.g.cs", GenerateCodeMap(info)); + // [ColumnPermission] masking helper shared by endpoints and controller + if (info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null) + spc.AddSource($"{info.FullyQualifiedName}.ColumnPermissions.g.cs", GenerateColumnPermissionsSource(info)); + // Soft delete: emit IsDeleted + DeletedAt properties on the entity partial if (info.SoftDelete) spc.AddSource($"{info.FullyQualifiedName}.SoftDelete.g.cs", GenerateSoftDeleteProperties(info)); @@ -615,6 +619,8 @@ void VisitType(INamedTypeSymbol type) if (info.Style == StyleController || info.Style == StyleBoth) spc.AddSource($"{info.FullyQualifiedName}.Controller.Model.g.cs", GenerateControllerSource(info)); spc.AddSource($"{info.FullyQualifiedName}.CodeMap.Model.g.cs", GenerateCodeMap(info)); + if (info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null) + spc.AddSource($"{info.FullyQualifiedName}.ColumnPermissions.Model.g.cs", GenerateColumnPermissionsSource(info)); }); // ── [assembly: GenerateCrudTests] → xUnit integration test stubs ──── diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs index fb8ac87..c7ae452 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs @@ -27,6 +27,9 @@ internal sealed class CrudApiInfo public string? ListResponseName { get; } public string? QueryName { get; } public Dictionary ColumnPermissions { get; } + /// Subset of whose columns survive in the list DTO + /// (i.e. not removed by [DtoIgnore(DtoTarget.List)]). Used by the list endpoint. + public Dictionary ListColumnPermissions { get; set; } = new(); public bool IsCombinedDto { get; } public bool HasResponseDto { get; } public bool HasQueryDto { get; } From 3a4bbd1dd77c790f43967725da8cda59b1466b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 13:25:08 +0200 Subject: [PATCH 2/7] =?UTF-8?q?[Dto]:=20drop=20stale=20Phase=201B/1C/1D=20?= =?UTF-8?q?comments=20=E2=80=94=20fluent=20Response/Query=20already=20ship?= =?UTF-8?q?ped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ZibStack.NET.Dto/sample/SampleApi/DtoConfig.cs | 7 ++++--- .../ZibStack.NET.Dto/DtoGenerator.FluentExtraction.cs | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi/DtoConfig.cs b/packages/ZibStack.NET.Dto/sample/SampleApi/DtoConfig.cs index 51436c7..0a0c330 100644 --- a/packages/ZibStack.NET.Dto/sample/SampleApi/DtoConfig.cs +++ b/packages/ZibStack.NET.Dto/sample/SampleApi/DtoConfig.cs @@ -12,9 +12,10 @@ namespace ZibStack.NET.Dto.Sample; /// and configure everything in one place. /// /// -/// Phase 1: only Create / Update / CreateOrUpdate go through fluent. -/// Response / Query / per-property overrides are still attribute-driven — -/// landing in Phase 1B once their extraction is split into reusable cores. +/// Fluent covers Create / Update / CreateOrUpdate / Response / Query, per-property +/// overrides (.Ignore/.IgnoreIn/.OnlyIn/.RenameTo) and CrudApi option overrides. +/// Only limitation: .RenameTo() doesn't apply to Query DTOs (the query generator +/// uses the property name for both the URL param and the entity expression). /// /// // `internal` matches the generated IDtoConfigurator interface accessibility — diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.FluentExtraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.FluentExtraction.cs index d4c0ba5..5ba8da1 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.FluentExtraction.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.FluentExtraction.cs @@ -6,11 +6,12 @@ namespace ZibStack.NET.Dto; /// -/// Adapters that turn a into a -/// via the shared BuildDtoClassInfoCore. This -/// is Phase 1 — Create/Update/Combined only. ResponseDto / QueryDto fluent -/// support arrives in Phase 1B once their Get*Info bodies are split into -/// reusable cores too. +/// Adapters that turn a into the +/// per-kind info models via the shared Build*InfoCore methods. Covers +/// Create/Update/Combined, ResponseDto, QueryDto, per-property overrides +/// (Ignore/IgnoreIn/OnlyIn/RenameTo) and CrudApi option overrides. Known +/// limitation: .RenameTo() is not applied to Query DTOs (see +/// ). /// public partial class DtoGenerator { From 5a9410b3f01f8ecf28f010a229091af0938b15b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 13:36:11 +0200 Subject: [PATCH 3/7] =?UTF-8?q?[Dto]:=20optimistic=20concurrency=20via=20E?= =?UTF-8?q?Tag/If-Match=20=E2=80=94=20[CrudApi(Concurrency=20=3D=20true)]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generator adds a RowVersion (long) partial to the entity, GET/POST/PATCH respond with a weak ETag, PATCH requires If-Match (428 missing / 412 stale), DELETE validates If-Match when provided. Generated CRUD smoke tests send If-Match: * on concurrency-enabled entities. New Document sample entity + ETag integration test suite. --- README.md | 5 + .../src/content/docs/packages/dto/crud-api.md | 36 +++++- .../SampleApi.Tests/EtagConcurrencyTests.cs | 121 ++++++++++++++++++ .../sample/SampleApi/AppDbContext.cs | 1 + .../sample/SampleApi/Models/Document.cs | 18 +++ .../sample/SampleApi/Program.cs | 1 + .../DtoGenerator.CrudAttributeSources.cs | 45 +++++++ .../DtoGenerator.CrudExtraction.cs | 4 +- .../DtoGenerator.CrudGeneration.cs | 113 ++++++++++++++-- .../src/ZibStack.NET.Dto/DtoGenerator.cs | 7 + .../ZibStack.NET.Dto/Models/CrudApiInfo.cs | 4 + 11 files changed, 341 insertions(+), 14 deletions(-) create mode 100644 packages/ZibStack.NET.Dto/sample/SampleApi.Tests/EtagConcurrencyTests.cs create mode 100644 packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs diff --git a/README.md b/README.md index 3a07ec3..4191cdb 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,11 @@ public class Player [ColumnPermission("Salary", "finance.read")] public partial class Employee { /* ... */ } +// Optimistic concurrency — weak ETags + If-Match preconditions (428/412), +// RowVersion property generated on the entity: +[CrudApi(Concurrency = true)] +public partial class Document { /* ... */ } + // Test scaffolding — generates xUnit CRUD integration tests for every [CrudApi] entity: [assembly: GenerateCrudTests] diff --git a/docs/src/content/docs/packages/dto/crud-api.md b/docs/src/content/docs/packages/dto/crud-api.md index 5a84f16..84bad37 100644 --- a/docs/src/content/docs/packages/dto/crud-api.md +++ b/docs/src/content/docs/packages/dto/crud-api.md @@ -223,7 +223,9 @@ Explicit DTO attributes always take priority when present: UpdatePolicy = "write:players", // override for PATCH only DeletePolicy = "admin", // override for DELETE only GetByIdPolicy = "read:players", // override for GET by ID - GetListPolicy = "read:players" // override for GET list + GetListPolicy = "read:players", // override for GET list + SoftDelete = true, // DELETE flags IsDeleted instead of removing + Concurrency = true // optimistic concurrency via ETag / If-Match )] ``` @@ -433,6 +435,38 @@ public class Player { ... } If you already have `IsDeleted` / `DeletedAt` properties on your entity, the generator reuses them and does not emit duplicates. +## Optimistic concurrency (ETags) + +Set `Concurrency = true` on `[CrudApi]` to protect updates against lost writes with standard HTTP preconditions: + +```csharp +[CrudApi(Concurrency = true)] +public partial class Document +{ + [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] + public int Id { get; set; } + public required string Title { get; set; } +} +``` + +What gets generated: + +- **`RowVersion` (long) property** is added to the entity via a generated partial (skipped if you already declare one). +- **`ETag` response header** — `GET /{id}`, `POST` and `PATCH` respond with a weak ETag: `ETag: W/"3"`. +- **PATCH requires `If-Match`** — missing header → `428 Precondition Required`; stale version → `412 Precondition Failed`. On success the version is incremented and the fresh ETag returned. +- **DELETE honors `If-Match`** when provided (412 on mismatch); without the header it deletes unconditionally. +- **`If-Match: *`** explicitly bypasses the version check (RFC 9110 semantics, comma-separated lists supported). + +Typical client flow: + +```http +GET /api/documents/7 → 200, ETag: W/"2" +PATCH /api/documents/7 +If-Match: W/"2" → 200, ETag: W/"3" (someone else's W/"2" now gets 412) +``` + +The version compare-and-increment happens in the endpoint, which protects API-level workflows. For hard database-level guarantees under concurrent writers, additionally mark `RowVersion` as a concurrency token in your EF model configuration (`builder.Property(x => x.RowVersion).IsConcurrencyToken()`). Bulk endpoints intentionally skip precondition checks. + ### Conditional emission CRUD endpoints are only generated when the consuming project references ASP.NET Core (detected at compile time). This means: diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/EtagConcurrencyTests.cs b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/EtagConcurrencyTests.cs new file mode 100644 index 0000000..6f75b99 --- /dev/null +++ b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/EtagConcurrencyTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace ZibStack.NET.Dto.Sample.Tests; + +/// +/// Document is marked [CrudApi(Concurrency = true)] — endpoints carry weak ETags, +/// PATCH demands If-Match (428 without, 412 on stale) and DELETE honors If-Match. +/// +public class EtagConcurrencyTests : IClassFixture> +{ + private readonly HttpClient _client; + + public EtagConcurrencyTests(WebApplicationFactory factory) + => _client = factory.CreateClient(); + + private async Task<(string location, string etag)> CreateDocumentAsync() + { + var response = await _client.PostAsJsonAsync("/api/documents", + new { Title = $"Doc_{Guid.NewGuid():N}", Content = "v1" }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(response.Headers.ETag); + return (response.Headers.Location!.ToString(), response.Headers.ETag!.ToString()); + } + + private Task PatchAsync(string url, object body, string? ifMatch) + { + var req = new HttpRequestMessage(HttpMethod.Patch, url) { Content = JsonContent.Create(body) }; + if (ifMatch is not null) req.Headers.TryAddWithoutValidation("If-Match", ifMatch); + return _client.SendAsync(req); + } + + [Fact] + public async Task GetById_ReturnsWeakETag() + { + var (location, _) = await CreateDocumentAsync(); + var response = await _client.GetAsync(location); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Headers.ETag); + Assert.True(response.Headers.ETag!.IsWeak, "ETag should be weak (W/ prefix)"); + } + + [Fact] + public async Task Patch_WithoutIfMatch_Returns428() + { + var (location, _) = await CreateDocumentAsync(); + var response = await PatchAsync(location, new { Title = "updated" }, ifMatch: null); + Assert.Equal((HttpStatusCode)428, response.StatusCode); + } + + [Fact] + public async Task Patch_WithWrongETag_Returns412() + { + var (location, _) = await CreateDocumentAsync(); + var response = await PatchAsync(location, new { Title = "updated" }, ifMatch: "W/\"99999\""); + Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode); + } + + [Fact] + public async Task Patch_WithCurrentETag_Succeeds_AndBumpsVersion() + { + var (location, etag) = await CreateDocumentAsync(); + + var response = await PatchAsync(location, new { Title = "updated once" }, ifMatch: etag); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var newEtag = response.Headers.ETag!.ToString(); + Assert.NotEqual(etag, newEtag); + + // The old ETag is now stale — a second writer using it must get 412. + var stale = await PatchAsync(location, new { Title = "lost update" }, ifMatch: etag); + Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode); + + // The fresh ETag works. + var fresh = await PatchAsync(location, new { Title = "updated twice" }, ifMatch: newEtag); + Assert.Equal(HttpStatusCode.OK, fresh.StatusCode); + } + + [Fact] + public async Task Patch_WithStar_BypassesVersionCheck() + { + var (location, _) = await CreateDocumentAsync(); + var response = await PatchAsync(location, new { Title = "force overwrite" }, ifMatch: "*"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Delete_WithStaleETag_Returns412() + { + var (location, etag) = await CreateDocumentAsync(); + // Bump the version so the captured ETag goes stale. + var patch = await PatchAsync(location, new { Title = "bump" }, ifMatch: etag); + Assert.Equal(HttpStatusCode.OK, patch.StatusCode); + + var req = new HttpRequestMessage(HttpMethod.Delete, location); + req.Headers.TryAddWithoutValidation("If-Match", etag); + var response = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode); + } + + [Fact] + public async Task Delete_WithCurrentETag_Succeeds() + { + var (location, etag) = await CreateDocumentAsync(); + var req = new HttpRequestMessage(HttpMethod.Delete, location); + req.Headers.TryAddWithoutValidation("If-Match", etag); + var response = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task Delete_WithoutIfMatch_StillSucceeds() + { + // If-Match is optional on DELETE — validated only when provided. + var (location, _) = await CreateDocumentAsync(); + var response = await _client.DeleteAsync(location); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi/AppDbContext.cs b/packages/ZibStack.NET.Dto/sample/SampleApi/AppDbContext.cs index aad861e..ca2ca92 100644 --- a/packages/ZibStack.NET.Dto/sample/SampleApi/AppDbContext.cs +++ b/packages/ZibStack.NET.Dto/sample/SampleApi/AppDbContext.cs @@ -9,6 +9,7 @@ public class AppDbContext : DbContext { public DbSet Players => Set(); public DbSet Teams => Set(); + public DbSet Documents => Set(); public AppDbContext(DbContextOptions options) : base(options) { } diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs b/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs new file mode 100644 index 0000000..d3d591d --- /dev/null +++ b/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs @@ -0,0 +1,18 @@ +using ZibStack.NET.Dto; +using ZibStack.NET.Validation; + +namespace ZibStack.NET.Dto.Sample.Models; + +// Optimistic concurrency demo: the generator adds a RowVersion property, GET/POST/PATCH +// responses carry an ETag header, PATCH requires If-Match (428/412) and DELETE honors it. +[CrudApi(Concurrency = true)] +public partial class Document +{ + [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] + public int Id { get; set; } + + [ZRequired] [ZMinLength(1)] [ZMaxLength(200)] + public required string Title { get; set; } + + public string? Content { get; set; } +} diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi/Program.cs b/packages/ZibStack.NET.Dto/sample/SampleApi/Program.cs index 4f30abf..9e3fc88 100644 --- a/packages/ZibStack.NET.Dto/sample/SampleApi/Program.cs +++ b/packages/ZibStack.NET.Dto/sample/SampleApi/Program.cs @@ -33,6 +33,7 @@ // Auto-generated CRUD endpoints app.MapPlayerEndpoints(); app.MapTeamEndpoints(); +app.MapDocumentEndpoints(); // ─── [Destructurable] demo ─────────────────────────────────────── // Shape-record approach: a user-declared partial record carries the picked diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs index e6307ce..c2d6468 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs @@ -93,6 +93,51 @@ internal sealed class CrudApiAttribute : System.Attribute /// entity partial class if they don't already exist. /// public bool SoftDelete { get; set; } + + /// + /// When true, enables optimistic concurrency via ETags. The generator adds a + /// RowVersion (long) property to the entity partial class. GET by ID, POST and + /// PATCH responses carry a weak ETag: W/""{version}"" header. PATCH requires + /// an If-Match header (428 Precondition Required when missing, 412 + /// Precondition Failed on version mismatch); DELETE validates If-Match when + /// provided. Use If-Match: * to bypass the version check explicitly. + /// For hard database-level guarantees, additionally mark RowVersion as a + /// concurrency token in your EF model configuration. + /// + public bool Concurrency { get; set; } + } +} +"; + + private const string ETagSource = @"// +#nullable enable + +namespace ZibStack.NET.Dto +{ + /// Weak-ETag helpers used by generated CRUD endpoints with [CrudApi(Concurrency = true)]. + public static class ETag + { + /// Formats a row version as a weak ETag: W/""42"". + public static string Format(long version) => $""W/\""{version}\""""; + + /// + /// Returns true when the If-Match header value matches the current version. + /// Accepts *, W/""n"", ""n"" and bare n; comma-separated lists + /// match when any entry matches (RFC 9110 If-Match semantics). + /// + public static bool Matches(string? ifMatch, long version) + { + if (string.IsNullOrWhiteSpace(ifMatch)) return false; + foreach (var raw in ifMatch!.Split(',')) + { + var tag = raw.Trim(); + if (tag == ""*"") return true; + if (tag.StartsWith(""W/"", System.StringComparison.OrdinalIgnoreCase)) tag = tag.Substring(2); + tag = tag.Trim('""'); + if (long.TryParse(tag, out var v) && v == version) return true; + } + return false; + } } } "; diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs index 2735a67..a817201 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs @@ -30,6 +30,8 @@ public partial class DtoGenerator var deletePolicy = attr.NamedArguments.FirstOrDefault(a => a.Key == "DeletePolicy").Value.Value as string; var softDeleteRaw = attr.NamedArguments.FirstOrDefault(a => a.Key == "SoftDelete").Value.Value; var softDelete = softDeleteRaw is true; + var concurrency = attr.NamedArguments.FirstOrDefault(a => a.Key == "Concurrency").Value.Value is true; + var hasUserRowVersion = concurrency && GetAllProperties(symbol).Any(p => p.Name == "RowVersion"); var signalR = symbol.GetAttributes().Any(a => a.AttributeClass?.Name == "SignalRHubAttribute"); // Resolve key property type @@ -183,7 +185,7 @@ public partial class DtoGenerator updatePolicy, deletePolicy, listResponseName, - columnPermissions) { SoftDelete = softDelete, SignalR = signalR, ListColumnPermissions = listColumnPermissions }; + columnPermissions) { SoftDelete = softDelete, SignalR = signalR, Concurrency = concurrency, HasUserRowVersion = hasUserRowVersion, ListColumnPermissions = listColumnPermissions }; } catch { return null; } } // ─── Auto-implied DTOs from [CrudApi] ────────────────────────────── diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs index aa2f080..a5de836 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs @@ -38,6 +38,11 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // The list endpoint only needs masking when a restricted column survives in the list DTO. var enforceListPerms = enforcePerms && (info.ListResponseName is null || info.ListColumnPermissions.Count > 0); + // Optimistic concurrency: ETag response header + If-Match precondition checks. + var concurrency = info.Concurrency; + var httpParam = concurrency ? ", HttpContext __http" : ""; + var ifMatchParam = concurrency ? ", [Microsoft.AspNetCore.Mvc.FromHeader(Name = \"If-Match\")] string? ifMatch = null" : ""; + sb.AppendLine("// "); sb.AppendLine($"// Generated by ZibStack.NET.Dto from [CrudApi] on {entity}"); sb.AppendLine("#nullable enable"); @@ -87,12 +92,14 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // GET {id} if ((info.Operations & OpGetById) != 0) { - sb.AppendLine($" group.MapGet(\"{{id}}\", async ({keyType} id, {storeType} store{userParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapGet(\"{{id}}\", async ({keyType} id, {storeType} store{userParam}{httpParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); if (info.SoftDelete) sb.AppendLine(" if (entity.IsDeleted) return Results.Problem(statusCode: 404, title: \"Not Found\");"); + if (concurrency) + sb.AppendLine(" __http.Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; @@ -201,12 +208,14 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqCreate = info.Namespace is not null ? $"{info.Namespace}.{info.CreateRequestName}" : info.CreateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForCreate" : "Validate"; - sb.AppendLine($" group.MapPost(\"\", async ({fqCreate} request, {storeType} store{hubParam}{userParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPost(\"\", async ({fqCreate} request, {storeType} store{hubParam}{userParam}{httpParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary());"); sb.AppendLine(" var entity = request.ToEntity();"); sb.AppendLine(" await store.CreateAsync(entity, ct);"); + if (concurrency) + sb.AppendLine(" __http.Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); if (info.SignalR) { var pushType = info.HasResponseDto && info.ResponseName is not null @@ -241,14 +250,23 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqUpdate = info.Namespace is not null ? $"{info.Namespace}.{info.UpdateRequestName}" : info.UpdateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForUpdate" : "Validate"; - sb.AppendLine($" group.MapPatch(\"{{id}}\", async ({keyType} id, {fqUpdate} request, {storeType} store{hubParam}{userParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPatch(\"{{id}}\", async ({keyType} id, {fqUpdate} request, {storeType} store{hubParam}{userParam}{httpParam}{ifMatchParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); + if (concurrency) + { + sb.AppendLine(" if (ifMatch is null) return Results.Problem(statusCode: 428, title: \"Precondition Required\", detail: \"Send If-Match with the ETag from GET (or If-Match: *).\");"); + sb.AppendLine(" if (!ZibStack.NET.Dto.ETag.Matches(ifMatch, entity.RowVersion)) return Results.Problem(statusCode: 412, title: \"Precondition Failed\", detail: \"The resource was modified by someone else. GET it again and retry with the fresh ETag.\");"); + } sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary());"); sb.AppendLine(" request.ApplyTo(entity);"); + if (concurrency) + sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await store.UpdateAsync(entity, ct);"); + if (concurrency) + sb.AppendLine(" __http.Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); if (info.SignalR) { var pushType = info.HasResponseDto && info.ResponseName is not null @@ -280,15 +298,19 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // DELETE if ((info.Operations & OpDelete) != 0) { - sb.AppendLine($" group.MapDelete(\"{{id}}\", async ({keyType} id, {storeType} store{hubParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapDelete(\"{{id}}\", async ({keyType} id, {storeType} store{hubParam}{ifMatchParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); + if (concurrency) + sb.AppendLine(" if (ifMatch is not null && !ZibStack.NET.Dto.ETag.Matches(ifMatch, entity.RowVersion)) return Results.Problem(statusCode: 412, title: \"Precondition Failed\", detail: \"The resource was modified by someone else. GET it again and retry with the fresh ETag.\");"); if (info.SoftDelete) { sb.AppendLine(" if (entity.IsDeleted) return Results.Problem(statusCode: 404, title: \"Not Found\");"); sb.AppendLine(" entity.IsDeleted = true;"); sb.AppendLine(" entity.DeletedAt = System.DateTime.UtcNow;"); + if (concurrency) + sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await store.UpdateAsync(entity, ct);"); } else @@ -467,6 +489,10 @@ private static string GenerateControllerSource(CrudApiInfo info) var permsClass = $"{entity}ColumnPermissions"; var enforceListPerms = enforcePerms && (info.ListResponseName is null || info.ListColumnPermissions.Count > 0); + // Optimistic concurrency — ETag via Response.Headers, If-Match via [FromHeader]. + var concurrency = info.Concurrency; + var ifMatchParam = concurrency ? "[FromHeader(Name = \"If-Match\")] string? ifMatch, " : ""; + sb.AppendLine("// "); sb.AppendLine($"// Generated by ZibStack.NET.Dto from [CrudApi] on {entity}"); sb.AppendLine("#nullable enable"); @@ -511,6 +537,8 @@ private static string GenerateControllerSource(CrudApiInfo info) sb.AppendLine(" if (entity is null) return NotFound();"); if (info.SoftDelete) sb.AppendLine(" if (entity.IsDeleted) return NotFound();"); + if (concurrency) + sb.AppendLine(" Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; @@ -587,6 +615,8 @@ private static string GenerateControllerSource(CrudApiInfo info) sb.AppendLine(" if (!validation.IsValid) { foreach (var kv in validation.Errors) foreach (var msg in kv.Value) ModelState.AddModelError(kv.Key, msg); return ValidationProblem(); }"); sb.AppendLine(" var entity = request.ToEntity();"); sb.AppendLine(" await _store.CreateAsync(entity, ct);"); + if (concurrency) + sb.AppendLine(" Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; @@ -611,14 +641,23 @@ private static string GenerateControllerSource(CrudApiInfo info) if (info.UpdatePolicy is not null) sb.AppendLine($" [Microsoft.AspNetCore.Authorization.Authorize(Policy = \"{info.UpdatePolicy}\")]"); sb.AppendLine(" [HttpPatch(\"{id}\")]"); - sb.AppendLine($" public async Task Update({keyType} id, [FromBody] {fqUpdate} request, CancellationToken ct)"); + sb.AppendLine($" public async Task Update({keyType} id, [FromBody] {fqUpdate} request, {ifMatchParam}CancellationToken ct = default)"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await _store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return NotFound();"); + if (concurrency) + { + sb.AppendLine(" if (ifMatch is null) return Problem(statusCode: 428, title: \"Precondition Required\", detail: \"Send If-Match with the ETag from GET (or If-Match: *).\");"); + sb.AppendLine(" if (!ZibStack.NET.Dto.ETag.Matches(ifMatch, entity.RowVersion)) return Problem(statusCode: 412, title: \"Precondition Failed\", detail: \"The resource was modified by someone else. GET it again and retry with the fresh ETag.\");"); + } sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) { foreach (var kv in validation.Errors) foreach (var msg in kv.Value) ModelState.AddModelError(kv.Key, msg); return ValidationProblem(); }"); sb.AppendLine(" request.ApplyTo(entity);"); + if (concurrency) + sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await _store.UpdateAsync(entity, ct);"); + if (concurrency) + sb.AppendLine(" Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); if (info.HasResponseDto && info.ResponseName is not null) { var fqResponse = info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName; @@ -640,15 +679,19 @@ private static string GenerateControllerSource(CrudApiInfo info) if (info.DeletePolicy is not null) sb.AppendLine($" [Microsoft.AspNetCore.Authorization.Authorize(Policy = \"{info.DeletePolicy}\")]"); sb.AppendLine(" [HttpDelete(\"{id}\")]"); - sb.AppendLine($" public async Task Delete({keyType} id, CancellationToken ct)"); + sb.AppendLine($" public async Task Delete({keyType} id, {ifMatchParam}CancellationToken ct = default)"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await _store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return NotFound();"); + if (concurrency) + sb.AppendLine(" if (ifMatch is not null && !ZibStack.NET.Dto.ETag.Matches(ifMatch, entity.RowVersion)) return Problem(statusCode: 412, title: \"Precondition Failed\", detail: \"The resource was modified by someone else. GET it again and retry with the fresh ETag.\");"); if (info.SoftDelete) { sb.AppendLine(" if (entity.IsDeleted) return NotFound();"); sb.AppendLine(" entity.IsDeleted = true;"); sb.AppendLine(" entity.DeletedAt = System.DateTime.UtcNow;"); + if (concurrency) + sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await _store.UpdateAsync(entity, ct);"); } else @@ -723,6 +766,7 @@ internal sealed class CrudTestInfo public string KeyPropertyName { get; set; } = "Id"; public int Operations { get; set; } = 31; public bool SoftDelete { get; set; } + public bool Concurrency { get; set; } public bool HasQueryDsl { get; set; } public List Properties { get; set; } = new(); } @@ -779,6 +823,7 @@ private static void ScanForCrudApi(INamespaceSymbol ns, List resul case "Route": info.Route = arg.Value.Value as string ?? ""; break; case "Operations": if (arg.Value.Value is int ops) info.Operations = ops; break; case "SoftDelete": if (arg.Value.Value is true) info.SoftDelete = true; break; + case "Concurrency": if (arg.Value.Value is true) info.Concurrency = true; break; case "KeyProperty": var kp = arg.Value.Value as string ?? "Id"; var prop = type.GetMembers(kp).OfType().FirstOrDefault(); @@ -960,6 +1005,15 @@ private static string GenerateCrudTestSource(CrudTestInfo info) var createBody = BuildAnonymousObject(createProps); var updateBody = BuildAnonymousObject(updateProps); + // [CrudApi(Concurrency = true)] endpoints demand If-Match on PATCH (and honor it + // on DELETE) — generated smoke tests bypass the version check with If-Match: *. + string PatchCall(string urlExpr, string contentExpr) => info.Concurrency + ? $"await SendWithIfMatchAsync(HttpMethod.Patch, {urlExpr}, {contentExpr})" + : $"await _client.PatchAsync({urlExpr}, {contentExpr})"; + string DeleteCall(string urlExpr) => info.Concurrency + ? $"await SendWithIfMatchAsync(HttpMethod.Delete, {urlExpr})" + : $"await _client.DeleteAsync({urlExpr})"; + sb.AppendLine("// "); sb.AppendLine("// These tests are fully regenerated on each build. Do NOT edit — write"); sb.AppendLine("// custom tests in a separate file instead."); @@ -1006,6 +1060,18 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine(" /// Override in a partial class to add headers, tokens, etc."); sb.AppendLine(" static partial void ConfigureClient(HttpClient client);"); + if (info.Concurrency) + { + sb.AppendLine(); + sb.AppendLine(" /// Concurrency-enabled endpoints require If-Match; * explicitly bypasses the version check."); + sb.AppendLine(" private Task SendWithIfMatchAsync(HttpMethod method, string url, HttpContent? content = null)"); + sb.AppendLine(" {"); + sb.AppendLine(" var req = new HttpRequestMessage(method, url) { Content = content };"); + sb.AppendLine(" req.Headers.TryAddWithoutValidation(\"If-Match\", \"*\");"); + sb.AppendLine(" return _client.SendAsync(req);"); + sb.AppendLine(" }"); + } + // GET list — verify returns items array if ((info.Operations & OpGetList) != 0) { @@ -1084,7 +1150,7 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine(); sb.AppendLine($" // 3. Update — send new values and verify they stuck"); sb.AppendLine($" var patchContent = JsonContent.Create({updateBody});"); - sb.AppendLine(" var updateResponse = await _client.PatchAsync(location, patchContent);"); + sb.AppendLine($" var updateResponse = {PatchCall("location", "patchContent")};"); sb.AppendLine(" Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);"); if (verifyProp is not null) @@ -1101,7 +1167,7 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine(); sb.AppendLine($" // 4. Delete"); - sb.AppendLine(" var deleteResponse = await _client.DeleteAsync(location);"); + sb.AppendLine($" var deleteResponse = {DeleteCall("location")};"); sb.AppendLine(" Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);"); sb.AppendLine(); sb.AppendLine($" // 5. Verify actually gone — GetById returns 404"); @@ -1130,7 +1196,7 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine(" var before = await _client.GetFromJsonAsync(location);"); sb.AppendLine(); sb.AppendLine($" var patchContent = JsonContent.Create({updateBody});"); - sb.AppendLine(" var updateResponse = await _client.PatchAsync(location, patchContent);"); + sb.AppendLine($" var updateResponse = {PatchCall("location", "patchContent")};"); sb.AppendLine(" Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);"); if (verifyProp is not null) @@ -1158,7 +1224,7 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine($" var body = await createResponse.Content.ReadFromJsonAsync();"); sb.AppendLine($" var id = body.GetProperty(\"{keyJsonName}\").{keyGetter}();"); sb.AppendLine($" var patchContent = JsonContent.Create({updateBody});"); - sb.AppendLine($" var updateResponse = await _client.PatchAsync($\"/{route}/{{id}}\", patchContent);"); + sb.AppendLine($" var updateResponse = {PatchCall($"$\"/{route}/{{id}}\"", "patchContent")};"); sb.AppendLine(" Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);"); sb.AppendLine(" }"); } @@ -1174,7 +1240,7 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine(" Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);"); sb.AppendLine(" var location = createResponse.Headers.Location!.ToString();"); sb.AppendLine(); - sb.AppendLine(" var deleteResponse = await _client.DeleteAsync(location);"); + sb.AppendLine($" var deleteResponse = {DeleteCall("location")};"); sb.AppendLine(" Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);"); sb.AppendLine(); sb.AppendLine(" var afterDelete = await _client.GetAsync(location);"); @@ -1194,7 +1260,7 @@ private static string GenerateCrudTestSource(CrudTestInfo info) sb.AppendLine(" Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);"); sb.AppendLine($" var body = await createResponse.Content.ReadFromJsonAsync();"); sb.AppendLine($" var id = body.GetProperty(\"{keyJsonName}\").{keyGetter}();"); - sb.AppendLine($" var deleteResponse = await _client.DeleteAsync($\"/{route}/{{id}}\");"); + sb.AppendLine($" var deleteResponse = {DeleteCall($"$\"/{route}/{{id}}\"")};"); sb.AppendLine(" Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);"); sb.AppendLine(" }"); } @@ -1724,6 +1790,29 @@ private static string GenerateSignalRHub(CrudApiInfo info) return sb.ToString(); } + private static string GenerateConcurrencyProperties(CrudApiInfo info) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + if (info.Namespace is not null) + { + sb.AppendLine($"namespace {info.Namespace};"); + sb.AppendLine(); + } + + sb.AppendLine($"partial class {info.ClassName}"); + sb.AppendLine("{"); + sb.AppendLine(" /// Optimistic-concurrency version. Incremented by the generated PATCH/DELETE endpoints"); + sb.AppendLine(" /// when [CrudApi(Concurrency = true)]; surfaced to clients as a weak ETag."); + sb.AppendLine(" public long RowVersion { get; set; }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + private static string GenerateSoftDeleteProperties(CrudApiInfo info) { var sb = new StringBuilder(); diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs index eeb6a49..1354dad 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs @@ -50,6 +50,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("IDtoConfigurator.g.cs", ConfiguratorSource); ctx.AddSource("GenerateCrudTestsAttribute.g.cs", GenerateCrudTestsAttributeSource); ctx.AddSource("SignalRHubAttribute.g.cs", SignalRHubAttributeSource); + ctx.AddSource("ETag.g.cs", ETagSource); }); // Detect available serializers and emit PatchField + converters @@ -590,6 +591,10 @@ void VisitType(INamedTypeSymbol type) if (info.SoftDelete) spc.AddSource($"{info.FullyQualifiedName}.SoftDelete.g.cs", GenerateSoftDeleteProperties(info)); + // Concurrency: emit RowVersion property on the entity partial + if (info.Concurrency && !info.HasUserRowVersion) + spc.AddSource($"{info.FullyQualifiedName}.Concurrency.g.cs", GenerateConcurrencyProperties(info)); + // SignalR hub: emit hub class + client interface when [SignalRHub] is on the entity if (info.SignalR) spc.AddSource($"{info.FullyQualifiedName}.Hub.g.cs", GenerateSignalRHub(info)); @@ -621,6 +626,8 @@ void VisitType(INamedTypeSymbol type) spc.AddSource($"{info.FullyQualifiedName}.CodeMap.Model.g.cs", GenerateCodeMap(info)); if (info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null) spc.AddSource($"{info.FullyQualifiedName}.ColumnPermissions.Model.g.cs", GenerateColumnPermissionsSource(info)); + if (info.Concurrency && !info.HasUserRowVersion) + spc.AddSource($"{info.FullyQualifiedName}.Concurrency.Model.g.cs", GenerateConcurrencyProperties(info)); }); // ── [assembly: GenerateCrudTests] → xUnit integration test stubs ──── diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs index c7ae452..aebbedc 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs @@ -36,6 +36,10 @@ internal sealed class CrudApiInfo public bool HasQueryDsl { get; set; } public bool SoftDelete { get; set; } public bool SignalR { get; set; } + /// Optimistic concurrency: RowVersion property + ETag/If-Match handling in endpoints. + public bool Concurrency { get; set; } + /// True when the entity already declares its own RowVersion property — skip the generated partial. + public bool HasUserRowVersion { get; set; } public CrudApiInfo( string className, From b639cca7442b7acd43f5ea5e97e152752ad391b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 13:45:28 +0200 Subject: [PATCH 4/7] =?UTF-8?q?[Dto]:=20audit=20fields=20=E2=80=94=20[Crud?= =?UTF-8?q?Api(Audit=20=3D=20true)]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated endpoints stamp CreatedAt/UpdatedAt (UTC) and CreatedBy/UpdatedBy (caller identity name) on POST/bulk-create, refresh UpdatedAt/UpdatedBy on PATCH and soft DELETE. Missing audit properties are emitted on the entity partial; user-declared ones are reused. Works with EF, Dapper and custom stores since stamping happens in the endpoint layer. --- README.md | 7 +- .../src/content/docs/packages/dto/crud-api.md | 21 +++- .../SampleApi.Tests/AuditFieldsTests.cs | 109 +++++++++++++++++ .../sample/SampleApi/Models/Document.cs | 4 +- .../DtoGenerator.CrudAttributeSources.cs | 9 ++ .../DtoGenerator.CrudExtraction.cs | 10 +- .../DtoGenerator.CrudGeneration.cs | 112 +++++++++++++++++- .../src/ZibStack.NET.Dto/DtoGenerator.cs | 6 + .../ZibStack.NET.Dto/Models/CrudApiInfo.cs | 4 + 9 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 packages/ZibStack.NET.Dto/sample/SampleApi.Tests/AuditFieldsTests.cs diff --git a/README.md b/README.md index 4191cdb..8c419d8 100644 --- a/README.md +++ b/README.md @@ -263,9 +263,10 @@ public class Player [ColumnPermission("Salary", "finance.read")] public partial class Employee { /* ... */ } -// Optimistic concurrency — weak ETags + If-Match preconditions (428/412), -// RowVersion property generated on the entity: -[CrudApi(Concurrency = true)] +// Optimistic concurrency + audit trail — weak ETags + If-Match preconditions +// (428/412), RowVersion + CreatedAt/UpdatedAt/CreatedBy/UpdatedBy generated on +// the entity and stamped automatically by the endpoints: +[CrudApi(Concurrency = true, Audit = true)] public partial class Document { /* ... */ } // Test scaffolding — generates xUnit CRUD integration tests for every [CrudApi] entity: diff --git a/docs/src/content/docs/packages/dto/crud-api.md b/docs/src/content/docs/packages/dto/crud-api.md index 84bad37..915e688 100644 --- a/docs/src/content/docs/packages/dto/crud-api.md +++ b/docs/src/content/docs/packages/dto/crud-api.md @@ -225,7 +225,8 @@ Explicit DTO attributes always take priority when present: GetByIdPolicy = "read:players", // override for GET by ID GetListPolicy = "read:players", // override for GET list SoftDelete = true, // DELETE flags IsDeleted instead of removing - Concurrency = true // optimistic concurrency via ETag / If-Match + Concurrency = true, // optimistic concurrency via ETag / If-Match + Audit = true // auto-stamp CreatedAt/UpdatedAt/CreatedBy/UpdatedBy )] ``` @@ -467,6 +468,24 @@ If-Match: W/"2" → 200, ETag: W/"3" (someone else's W/"2" now The version compare-and-increment happens in the endpoint, which protects API-level workflows. For hard database-level guarantees under concurrent writers, additionally mark `RowVersion` as a concurrency token in your EF model configuration (`builder.Property(x => x.RowVersion).IsConcurrencyToken()`). Bulk endpoints intentionally skip precondition checks. +## Audit fields + +Set `Audit = true` on `[CrudApi]` to have the generated endpoints maintain audit metadata automatically: + +```csharp +[CrudApi(Audit = true)] +public partial class Document { ... } +``` + +What gets generated: + +- **Missing audit properties** are added to the entity partial: `CreatedAt`/`UpdatedAt` (`DateTime`) and `CreatedBy`/`UpdatedBy` (`string?`). Properties you already declare are reused (declare them yourself if you want them in response DTOs — generated partials are invisible to DTO extraction). +- **POST** (and bulk create) stamps `CreatedAt`/`UpdatedAt` with `DateTime.UtcNow` and `CreatedBy`/`UpdatedBy` with the caller's identity name (`null` for anonymous callers). +- **PATCH** refreshes `UpdatedAt`/`UpdatedBy`. +- **Soft DELETE** (when combined with `SoftDelete = true`) also refreshes `UpdatedAt`/`UpdatedBy`, so you can tell who archived the record. + +The stamping happens in the endpoint layer, so it works identically with the EF store, the Dapper store, and any custom `ICrudStore` implementation. (The EF-generated store additionally fills `CreatedAt`/`UpdatedAt` by convention when those properties exist on the entity — the two mechanisms agree, endpoint values simply win.) + ### Conditional emission CRUD endpoints are only generated when the consuming project references ASP.NET Core (detected at compile time). This means: diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/AuditFieldsTests.cs b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/AuditFieldsTests.cs new file mode 100644 index 0000000..a0f585a --- /dev/null +++ b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/AuditFieldsTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using ZibStack.NET.Dto.Sample; +using ZibStack.NET.Dto.Sample.Models; + +namespace ZibStack.NET.Dto.Sample.Tests; + +/// +/// Document is marked [CrudApi(Audit = true)] — generated endpoints stamp +/// CreatedAt/UpdatedAt (UTC) and CreatedBy/UpdatedBy (identity name). Audit fields +/// live on the entity, not in the response DTO, so assertions read the DbContext. +/// +public class AuditFieldsTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public AuditFieldsTests(WebApplicationFactory factory) => _factory = factory; + + private static async Task CreateDocumentAsync(HttpClient client, string title) + { + var response = await client.PostAsJsonAsync("/api/documents", new { Title = title, Content = "v1" }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("id").GetInt32(); + } + + private Document LoadDocument(WebApplicationFactory factory, int id) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.Documents.AsNoTracking().Single(d => d.Id == id); + } + + [Fact] + public async Task Create_StampsCreatedAtAndUpdatedAt() + { + var client = _factory.CreateClient(); + var id = await CreateDocumentAsync(client, $"Audit_{Guid.NewGuid():N}"); + + var doc = LoadDocument(_factory, id); + Assert.NotEqual(default, doc.CreatedAt); + Assert.NotEqual(default, doc.UpdatedAt); + Assert.True((DateTime.UtcNow - doc.CreatedAt).Duration() < TimeSpan.FromMinutes(1)); + Assert.Null(doc.CreatedBy); // anonymous caller + } + + [Fact] + public async Task Patch_RefreshesUpdatedAt_KeepsCreatedAt() + { + var client = _factory.CreateClient(); + var id = await CreateDocumentAsync(client, $"Audit_{Guid.NewGuid():N}"); + var before = LoadDocument(_factory, id); + + await Task.Delay(50); // ensure a measurable clock difference + var req = new HttpRequestMessage(HttpMethod.Patch, $"/api/documents/{id}") + { + Content = JsonContent.Create(new { Title = "updated title" }) + }; + req.Headers.TryAddWithoutValidation("If-Match", "*"); + var response = await client.SendAsync(req); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var after = LoadDocument(_factory, id); + Assert.Equal(before.CreatedAt, after.CreatedAt); + Assert.True(after.UpdatedAt > before.UpdatedAt, "PATCH should refresh UpdatedAt"); + } + + [Fact] + public async Task Create_AsAuthenticatedUser_StampsCreatedBy() + { + var factory = _factory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + services.AddSingleton(new NamedUserFilter("alice@example.com")))); + var client = factory.CreateClient(); + + var id = await CreateDocumentAsync(client, $"Audit_{Guid.NewGuid():N}"); + + var doc = LoadDocument(factory, id); + Assert.Equal("alice@example.com", doc.CreatedBy); + Assert.Equal("alice@example.com", doc.UpdatedBy); + } + + /// Injects an authenticated principal with a name claim ahead of the app pipeline. + private sealed class NamedUserFilter : IStartupFilter + { + private readonly string _name; + public NamedUserFilter(string name) => _name = name; + + public Action Configure(Action next) => app => + { + app.Use(async (ctx, nextMw) => + { + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaim(new Claim(ClaimTypes.Name, _name)); + ctx.User = new ClaimsPrincipal(identity); + await nextMw(); + }); + next(app); + }; + } +} diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs b/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs index d3d591d..ab19fbd 100644 --- a/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs +++ b/packages/ZibStack.NET.Dto/sample/SampleApi/Models/Document.cs @@ -5,7 +5,9 @@ namespace ZibStack.NET.Dto.Sample.Models; // Optimistic concurrency demo: the generator adds a RowVersion property, GET/POST/PATCH // responses carry an ETag header, PATCH requires If-Match (428/412) and DELETE honors it. -[CrudApi(Concurrency = true)] +// Audit = true additionally generates CreatedAt/UpdatedAt/CreatedBy/UpdatedBy and stamps +// them in the write endpoints. +[CrudApi(Concurrency = true, Audit = true)] public partial class Document { [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs index c2d6468..1a9c35c 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs @@ -105,6 +105,15 @@ internal sealed class CrudApiAttribute : System.Attribute /// concurrency token in your EF model configuration. /// public bool Concurrency { get; set; } + + /// + /// When true, audit fields are maintained automatically by the generated endpoints: + /// POST fills CreatedAt/UpdatedAt (UTC) and CreatedBy/UpdatedBy (from the caller's + /// identity name), PATCH and soft DELETE refresh UpdatedAt/UpdatedBy. Missing audit + /// properties are added to the entity partial class (CreatedAt/UpdatedAt as DateTime, + /// CreatedBy/UpdatedBy as string?); properties you already declare are reused. + /// + public bool Audit { get; set; } } } "; diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs index a817201..fb4a520 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudExtraction.cs @@ -32,6 +32,14 @@ public partial class DtoGenerator var softDelete = softDeleteRaw is true; var concurrency = attr.NamedArguments.FirstOrDefault(a => a.Key == "Concurrency").Value.Value is true; var hasUserRowVersion = concurrency && GetAllProperties(symbol).Any(p => p.Name == "RowVersion"); + var audit = attr.NamedArguments.FirstOrDefault(a => a.Key == "Audit").Value.Value is true; + var auditFieldsToGenerate = new List(); + if (audit) + { + var existing = new HashSet(GetAllProperties(symbol).Select(p => p.Name)); + foreach (var f in new[] { "CreatedAt", "UpdatedAt", "CreatedBy", "UpdatedBy" }) + if (!existing.Contains(f)) auditFieldsToGenerate.Add(f); + } var signalR = symbol.GetAttributes().Any(a => a.AttributeClass?.Name == "SignalRHubAttribute"); // Resolve key property type @@ -185,7 +193,7 @@ public partial class DtoGenerator updatePolicy, deletePolicy, listResponseName, - columnPermissions) { SoftDelete = softDelete, SignalR = signalR, Concurrency = concurrency, HasUserRowVersion = hasUserRowVersion, ListColumnPermissions = listColumnPermissions }; + columnPermissions) { SoftDelete = softDelete, SignalR = signalR, Concurrency = concurrency, HasUserRowVersion = hasUserRowVersion, Audit = audit, AuditFieldsToGenerate = auditFieldsToGenerate, ListColumnPermissions = listColumnPermissions }; } catch { return null; } } // ─── Auto-implied DTOs from [CrudApi] ────────────────────────────── diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs index a5de836..1d56761 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs @@ -34,7 +34,8 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // [ColumnPermission] enforcement: endpoints mask restricted response columns automatically. var enforcePerms = info.ColumnPermissions.Count > 0 && info.HasResponseDto && info.ResponseName is not null; var permsClass = $"{entity}ColumnPermissions"; - var userParam = enforcePerms ? ", System.Security.Claims.ClaimsPrincipal? __user" : ""; + const string userParamStr = ", System.Security.Claims.ClaimsPrincipal? __user"; + var userParam = enforcePerms ? userParamStr : ""; // The list endpoint only needs masking when a restricted column survives in the list DTO. var enforceListPerms = enforcePerms && (info.ListResponseName is null || info.ListColumnPermissions.Count > 0); @@ -43,6 +44,11 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) var httpParam = concurrency ? ", HttpContext __http" : ""; var ifMatchParam = concurrency ? ", [Microsoft.AspNetCore.Mvc.FromHeader(Name = \"If-Match\")] string? ifMatch = null" : ""; + // Audit fields: write endpoints stamp CreatedAt/UpdatedAt + CreatedBy/UpdatedBy. + var audit = info.Audit; + var writeUserParam = enforcePerms || audit ? userParamStr : ""; + var deleteUserParam = audit && info.SoftDelete ? userParamStr : ""; + sb.AppendLine("// "); sb.AppendLine($"// Generated by ZibStack.NET.Dto from [CrudApi] on {entity}"); sb.AppendLine("#nullable enable"); @@ -208,11 +214,18 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqCreate = info.Namespace is not null ? $"{info.Namespace}.{info.CreateRequestName}" : info.CreateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForCreate" : "Validate"; - sb.AppendLine($" group.MapPost(\"\", async ({fqCreate} request, {storeType} store{hubParam}{userParam}{httpParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPost(\"\", async ({fqCreate} request, {storeType} store{hubParam}{writeUserParam}{httpParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary());"); sb.AppendLine(" var entity = request.ToEntity();"); + if (audit) + { + sb.AppendLine(" entity.CreatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.CreatedBy = __user?.Identity?.Name;"); + sb.AppendLine(" entity.UpdatedBy = __user?.Identity?.Name;"); + } sb.AppendLine(" await store.CreateAsync(entity, ct);"); if (concurrency) sb.AppendLine(" __http.Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); @@ -250,7 +263,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqUpdate = info.Namespace is not null ? $"{info.Namespace}.{info.UpdateRequestName}" : info.UpdateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForUpdate" : "Validate"; - sb.AppendLine($" group.MapPatch(\"{{id}}\", async ({keyType} id, {fqUpdate} request, {storeType} store{hubParam}{userParam}{httpParam}{ifMatchParam}, CancellationToken ct = default) =>"); + sb.AppendLine($" group.MapPatch(\"{{id}}\", async ({keyType} id, {fqUpdate} request, {storeType} store{hubParam}{writeUserParam}{httpParam}{ifMatchParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); @@ -262,6 +275,11 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary());"); sb.AppendLine(" request.ApplyTo(entity);"); + if (audit) + { + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedBy = __user?.Identity?.Name;"); + } if (concurrency) sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await store.UpdateAsync(entity, ct);"); @@ -298,7 +316,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // DELETE if ((info.Operations & OpDelete) != 0) { - sb.AppendLine($" group.MapDelete(\"{{id}}\", async ({keyType} id, {storeType} store{hubParam}{ifMatchParam}, CancellationToken ct = default) =>"); + sb.AppendLine($" group.MapDelete(\"{{id}}\", async ({keyType} id, {storeType} store{hubParam}{deleteUserParam}{ifMatchParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var entity = await store.GetByIdAsync(id, ct);"); sb.AppendLine(" if (entity is null) return Results.Problem(statusCode: 404, title: \"Not Found\");"); @@ -309,6 +327,11 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine(" if (entity.IsDeleted) return Results.Problem(statusCode: 404, title: \"Not Found\");"); sb.AppendLine(" entity.IsDeleted = true;"); sb.AppendLine(" entity.DeletedAt = System.DateTime.UtcNow;"); + if (audit) + { + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedBy = __user?.Identity?.Name;"); + } if (concurrency) sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await store.UpdateAsync(entity, ct);"); @@ -332,7 +355,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) { var fqCreate = info.Namespace is not null ? $"{info.Namespace}.{info.CreateRequestName}" : info.CreateRequestName; var validateMethod = info.IsCombinedDto ? "ValidateForCreate" : "Validate"; - sb.AppendLine($" group.MapPost(\"bulk\", async (List<{fqCreate}> requests, {storeType} store{userParam}, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPost(\"bulk\", async (List<{fqCreate}> requests, {storeType} store{writeUserParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine(" var allErrors = new DtoValidationResult();"); sb.AppendLine(" for (var i = 0; i < requests.Count; i++)"); @@ -345,6 +368,13 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine(" foreach (var request in requests)"); sb.AppendLine(" {"); sb.AppendLine(" var entity = request.ToEntity();"); + if (audit) + { + sb.AppendLine(" entity.CreatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.CreatedBy = __user?.Identity?.Name;"); + sb.AppendLine(" entity.UpdatedBy = __user?.Identity?.Name;"); + } sb.AppendLine(" await store.CreateAsync(entity, ct);"); sb.AppendLine(" entities.Add(entity);"); sb.AppendLine(" }"); @@ -369,7 +399,7 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) // BULK DELETE if ((info.Operations & OpBulkDelete) != 0) { - sb.AppendLine($" group.MapPost(\"bulk-delete\", async (List<{keyType}> ids, {storeType} store, CancellationToken ct) =>"); + sb.AppendLine($" group.MapPost(\"bulk-delete\", async (List<{keyType}> ids, {storeType} store{deleteUserParam}, CancellationToken ct) =>"); sb.AppendLine(" {"); sb.AppendLine(" var deleted = 0;"); sb.AppendLine(" foreach (var id in ids)"); @@ -381,6 +411,11 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine(" {"); sb.AppendLine(" entity.IsDeleted = true;"); sb.AppendLine(" entity.DeletedAt = System.DateTime.UtcNow;"); + if (audit) + { + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedBy = __user?.Identity?.Name;"); + } sb.AppendLine(" await store.UpdateAsync(entity, ct);"); sb.AppendLine(" deleted++;"); sb.AppendLine(" }"); @@ -493,6 +528,9 @@ private static string GenerateControllerSource(CrudApiInfo info) var concurrency = info.Concurrency; var ifMatchParam = concurrency ? "[FromHeader(Name = \"If-Match\")] string? ifMatch, " : ""; + // Audit fields — stamped from ControllerBase.User. + var audit = info.Audit; + sb.AppendLine("// "); sb.AppendLine($"// Generated by ZibStack.NET.Dto from [CrudApi] on {entity}"); sb.AppendLine("#nullable enable"); @@ -614,6 +652,13 @@ private static string GenerateControllerSource(CrudApiInfo info) sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) { foreach (var kv in validation.Errors) foreach (var msg in kv.Value) ModelState.AddModelError(kv.Key, msg); return ValidationProblem(); }"); sb.AppendLine(" var entity = request.ToEntity();"); + if (audit) + { + sb.AppendLine(" entity.CreatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.CreatedBy = User?.Identity?.Name;"); + sb.AppendLine(" entity.UpdatedBy = User?.Identity?.Name;"); + } sb.AppendLine(" await _store.CreateAsync(entity, ct);"); if (concurrency) sb.AppendLine(" Response.Headers.ETag = ZibStack.NET.Dto.ETag.Format(entity.RowVersion);"); @@ -653,6 +698,11 @@ private static string GenerateControllerSource(CrudApiInfo info) sb.AppendLine($" var validation = request.{validateMethod}();"); sb.AppendLine(" if (!validation.IsValid) { foreach (var kv in validation.Errors) foreach (var msg in kv.Value) ModelState.AddModelError(kv.Key, msg); return ValidationProblem(); }"); sb.AppendLine(" request.ApplyTo(entity);"); + if (audit) + { + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedBy = User?.Identity?.Name;"); + } if (concurrency) sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await _store.UpdateAsync(entity, ct);"); @@ -690,6 +740,11 @@ private static string GenerateControllerSource(CrudApiInfo info) sb.AppendLine(" if (entity.IsDeleted) return NotFound();"); sb.AppendLine(" entity.IsDeleted = true;"); sb.AppendLine(" entity.DeletedAt = System.DateTime.UtcNow;"); + if (audit) + { + sb.AppendLine(" entity.UpdatedAt = System.DateTime.UtcNow;"); + sb.AppendLine(" entity.UpdatedBy = User?.Identity?.Name;"); + } if (concurrency) sb.AppendLine(" entity.RowVersion++;"); sb.AppendLine(" await _store.UpdateAsync(entity, ct);"); @@ -1790,6 +1845,51 @@ private static string GenerateSignalRHub(CrudApiInfo info) return sb.ToString(); } + private static string GenerateAuditProperties(CrudApiInfo info) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + if (info.Namespace is not null) + { + sb.AppendLine($"namespace {info.Namespace};"); + sb.AppendLine(); + } + + sb.AppendLine($"partial class {info.ClassName}"); + sb.AppendLine("{"); + var first = true; + foreach (var field in info.AuditFieldsToGenerate) + { + if (!first) sb.AppendLine(); + first = false; + switch (field) + { + case "CreatedAt": + sb.AppendLine(" /// UTC creation timestamp. Stamped by the generated POST endpoint when [CrudApi(Audit = true)]."); + sb.AppendLine(" public System.DateTime CreatedAt { get; set; }"); + break; + case "UpdatedAt": + sb.AppendLine(" /// UTC last-modified timestamp. Refreshed by the generated POST/PATCH/soft-DELETE endpoints."); + sb.AppendLine(" public System.DateTime UpdatedAt { get; set; }"); + break; + case "CreatedBy": + sb.AppendLine(" /// Identity name of the creator. Null for anonymous callers."); + sb.AppendLine(" public string? CreatedBy { get; set; }"); + break; + case "UpdatedBy": + sb.AppendLine(" /// Identity name of the last modifier. Null for anonymous callers."); + sb.AppendLine(" public string? UpdatedBy { get; set; }"); + break; + } + } + sb.AppendLine("}"); + + return sb.ToString(); + } + private static string GenerateConcurrencyProperties(CrudApiInfo info) { var sb = new StringBuilder(); diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs index 1354dad..e7ac545 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs @@ -595,6 +595,10 @@ void VisitType(INamedTypeSymbol type) if (info.Concurrency && !info.HasUserRowVersion) spc.AddSource($"{info.FullyQualifiedName}.Concurrency.g.cs", GenerateConcurrencyProperties(info)); + // Audit: emit missing CreatedAt/UpdatedAt/CreatedBy/UpdatedBy on the entity partial + if (info.Audit && info.AuditFieldsToGenerate.Count > 0) + spc.AddSource($"{info.FullyQualifiedName}.Audit.g.cs", GenerateAuditProperties(info)); + // SignalR hub: emit hub class + client interface when [SignalRHub] is on the entity if (info.SignalR) spc.AddSource($"{info.FullyQualifiedName}.Hub.g.cs", GenerateSignalRHub(info)); @@ -628,6 +632,8 @@ void VisitType(INamedTypeSymbol type) spc.AddSource($"{info.FullyQualifiedName}.ColumnPermissions.Model.g.cs", GenerateColumnPermissionsSource(info)); if (info.Concurrency && !info.HasUserRowVersion) spc.AddSource($"{info.FullyQualifiedName}.Concurrency.Model.g.cs", GenerateConcurrencyProperties(info)); + if (info.Audit && info.AuditFieldsToGenerate.Count > 0) + spc.AddSource($"{info.FullyQualifiedName}.Audit.Model.g.cs", GenerateAuditProperties(info)); }); // ── [assembly: GenerateCrudTests] → xUnit integration test stubs ──── diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs index aebbedc..c746564 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/CrudApiInfo.cs @@ -40,6 +40,10 @@ internal sealed class CrudApiInfo public bool Concurrency { get; set; } /// True when the entity already declares its own RowVersion property — skip the generated partial. public bool HasUserRowVersion { get; set; } + /// Audit fields: endpoints fill CreatedAt/UpdatedAt/CreatedBy/UpdatedBy automatically. + public bool Audit { get; set; } + /// Audit properties the entity does not declare itself — emitted via a generated partial. + public List AuditFieldsToGenerate { get; set; } = new(); public CrudApiInfo( string className, From fdbe63cbeb576265cba955af7aab1e6af5855d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 14:04:28 +0200 Subject: [PATCH 5/7] [Aop]: convert aspect failures to failed Results on Result-returning methods Methods returning ZibStack.NET.Result.Result / Result now receive [Authorize] failures as Error.Unauthorized and [Validate] failures as Error.Validation instead of exceptions. Handlers throw new dedicated AspectAuthorizationException / AspectValidationException types (derived from the previous base types for compatibility); the generated interceptor wraps its body in a conversion try/catch only when the (unwrapped) return type is the fully-qualified Result type. Method-own exceptions still throw. --- README.md | 1 + .../src/content/docs/packages/aop/built-in.md | 26 ++++- .../AspectExceptions.cs | 25 +++++ .../AuthorizeHandler.cs | 6 +- .../ValidateHandler.cs | 2 +- .../ZibStack.NET.Aop/Generator/AopEmitter.cs | 42 ++++++++ .../BuiltInAspectTests.cs | 14 +-- .../Fixtures/TestServices.cs | 40 ++++++++ .../ResultIntegrationTests.cs | 96 +++++++++++++++++++ .../ZibStack.NET.Aop.Tests.csproj | 3 + 10 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AspectExceptions.cs create mode 100644 packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ResultIntegrationTests.cs diff --git a/README.md b/README.md index 8c419d8..1e97940 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ ZibStack is designed so you can adopt as little or as much as you want. Start at **Tier 3 — Opinionated scaffolding. High buy-in, high payoff.** Full-stack CRUD generation, query DSL, UI metadata. Best for solo projects and small teams where the time savings justify the framework buy-in; be cautious on large enterprise codebases where "magic" can surprise teammates. - **`[CrudApi]` / `[ImTiredOfCrud]`** — one attribute generates DTOs, endpoints, EF/Dapper stores, validation, query DSL, form/table UI schemas. Add `[SignalRHub]` for real-time push — generated endpoints notify connected clients via `OnCreated`/`OnUpdated`/`OnDeleted`. +- **Aspects meet `Result`** — methods returning `Result`/`Result` get `[Authorize]`/`[Validate]` failures as failed Results (`Error.Unauthorized`/`Error.Validation`) instead of exceptions. - **ZibStack.NET.Query** — filter/sort DSL (`filter=Level>25,Team.Name=*ski`) compiled to LINQ/SQL. - **ZibStack.NET.UI** — compile-time form/table metadata, consumed by any frontend. diff --git a/docs/src/content/docs/packages/aop/built-in.md b/docs/src/content/docs/packages/aop/built-in.md index 37fedb4..c0ad908 100644 --- a/docs/src/content/docs/packages/aop/built-in.md +++ b/docs/src/content/docs/packages/aop/built-in.md @@ -231,7 +231,20 @@ public class MyAuthProvider : IAuthorizationProvider } ``` -Throws `UnauthorizedAccessException` on failure. **Async methods only** (`IAsyncAspectHandler`). +Throws `AspectAuthorizationException` (derives from `UnauthorizedAccessException`) on failure. **Async methods only** (`IAsyncAspectHandler`). + +**Result integration** — when the method returns `ZibStack.NET.Result.Result` or `Result`, authorization failures come back as a failed Result with `Error.Unauthorized` instead of throwing: + +```csharp +[Authorize(Roles = "Admin")] +public async Task> DeleteOrderAsync(int id) { ... } + +var result = await svc.DeleteOrderAsync(7); +if (result.IsFailure) // Error.Code == "Unauthorized" — no try/catch needed + return result.ToHttpResult(); +``` + +The method's own exceptions (including plain `UnauthorizedAccessException`) still propagate as exceptions — only aspect precondition failures are converted. ### `[PollyRetry]` — Polly integration (optional) @@ -283,11 +296,20 @@ Setup: `builder.Services.AddHybridCache();` ```csharp [Validate] public Order CreateOrder(CreateOrderRequest request) { ... } -// If request has [Required] Name = null → throws ArgumentException before method runs +// If request has [Required] Name = null → throws AspectValidationException +// (derives from ArgumentException) before method runs ``` Validates complex object parameters using `System.ComponentModel.DataAnnotations.Validator`. Primitives/strings are skipped. Works on sync and async methods. +**Result integration** — when the method returns `ZibStack.NET.Result.Result` or `Result`, validation failures come back as a failed Result with `Error.Validation` instead of throwing: + +```csharp +[Validate] +public Result CreateOrder(CreateOrderRequest request) { ... } +// invalid request → Result.IsFailure with Error.Code == "Validation" +``` + ### `[Transaction]` — TransactionScope ```csharp diff --git a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AspectExceptions.cs b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AspectExceptions.cs new file mode 100644 index 0000000..e5e6db3 --- /dev/null +++ b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AspectExceptions.cs @@ -0,0 +1,25 @@ +using System; + +namespace ZibStack.NET.Aop; + +/// +/// Thrown by when authorization fails. Derives from +/// so existing catch blocks keep working. +/// On methods returning ZibStack.NET.Result.Result/Result<T> the generated +/// interceptor converts this into a failed Result with Error.Unauthorized instead of throwing. +/// +public sealed class AspectAuthorizationException : UnauthorizedAccessException +{ + public AspectAuthorizationException(string message) : base(message) { } +} + +/// +/// Thrown by when parameter validation fails. Derives from +/// so existing catch blocks keep working. +/// On methods returning ZibStack.NET.Result.Result/Result<T> the generated +/// interceptor converts this into a failed Result with Error.Validation instead of throwing. +/// +public sealed class AspectValidationException : ArgumentException +{ + public AspectValidationException(string message, string? paramName) : base(message, paramName) { } +} diff --git a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AuthorizeHandler.cs b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AuthorizeHandler.cs index 3275004..6aa1ab7 100644 --- a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AuthorizeHandler.cs +++ b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/AuthorizeHandler.cs @@ -33,7 +33,7 @@ public async ValueTask OnBeforeAsync(AspectContext context) if (policy is not null) { if (!await _provider.IsAuthorizedAsync(policy).ConfigureAwait(false)) - throw new UnauthorizedAccessException( + throw new AspectAuthorizationException( $"Authorization policy '{policy}' failed for {context.ClassName}.{context.MethodName}."); return; } @@ -48,13 +48,13 @@ public async ValueTask OnBeforeAsync(AspectContext context) return; } - throw new UnauthorizedAccessException( + throw new AspectAuthorizationException( $"None of the required roles '{roles}' matched for {context.ClassName}.{context.MethodName}."); } // No policy or roles specified — [Authorize] without args means "must be authenticated". if (!await _provider.IsAuthorizedAsync("__authenticated").ConfigureAwait(false)) - throw new UnauthorizedAccessException( + throw new AspectAuthorizationException( $"Authentication required for {context.ClassName}.{context.MethodName}."); } diff --git a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/ValidateHandler.cs b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/ValidateHandler.cs index e4fe23e..31a04cb 100644 --- a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/ValidateHandler.cs +++ b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop.Abstractions/ValidateHandler.cs @@ -37,7 +37,7 @@ public void OnBefore(AspectContext context) errors.Add(result.ErrorMessage); } - throw new ArgumentException( + throw new AspectValidationException( $"Validation failed for parameter '{param.Name}' in {context.ClassName}.{context.MethodName}: {string.Join("; ", errors)}", param.Name); } diff --git a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop/Generator/AopEmitter.cs b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop/Generator/AopEmitter.cs index 6f37a3e..72b3990 100644 --- a/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop/Generator/AopEmitter.cs +++ b/packages/ZibStack.NET.Aop/src/ZibStack.NET.Aop/Generator/AopEmitter.cs @@ -164,6 +164,17 @@ private static void EmitInterceptorMethod( var indent = " "; + // Result-returning methods: aspect precondition failures ([Authorize], [Validate]) + // convert to a failed Result instead of escaping as exceptions. The outer try wraps + // the whole interceptor body because OnBefore (where preconditions throw) runs + // before the regular try/catch around the method invocation. + var resultReturnType = method.ReturnsVoid ? null : GetResultReturnType(method); + if (resultReturnType is not null) + { + sb.AppendLine($"{indent}try"); + sb.AppendLine($"{indent}{{"); + } + // If the method has a CancellationToken parameter, create a linked CTS // upfront so handlers can signal cancellation via AspectContext that the // method body actually observes through its own awaits. The linked source @@ -357,9 +368,40 @@ private static void EmitInterceptorMethod( sb.AppendLine($"{indent} throw;"); sb.AppendLine($"{indent}}}"); + + // Close the Result-conversion wrapper. Aspect-specific exception types only — + // the method's own ArgumentException / UnauthorizedAccessException still throw. + if (resultReturnType is not null) + { + sb.AppendLine($"{indent}}}"); + sb.AppendLine($"{indent}catch (global::ZibStack.NET.Aop.AspectAuthorizationException __raex)"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} return {resultReturnType}.Failure(global::ZibStack.NET.Result.Error.Unauthorized(__raex.Message));"); + sb.AppendLine($"{indent}}}"); + sb.AppendLine($"{indent}catch (global::ZibStack.NET.Aop.AspectValidationException __rvex)"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} return {resultReturnType}.Failure(global::ZibStack.NET.Result.Error.Validation(__rvex.Message));"); + sb.AppendLine($"{indent}}}"); + } + sb.AppendLine(" }"); } + /// + /// When the (unwrapped) return type is ZibStack.NET.Result.Result or Result<T>, + /// returns the fully-qualified type expression to call .Failure(...) on; + /// otherwise null. Matched by fully-qualified name so user types named Result + /// never trigger the conversion. + /// + private static string? GetResultReturnType(InterceptedMethodModel method) + { + var rt = GetUnwrappedReturnType(method).Trim().TrimEnd('?'); + if (rt == "global::ZibStack.NET.Result.Result" || + (rt.StartsWith("global::ZibStack.NET.Result.Result<", System.StringComparison.Ordinal) && rt.EndsWith(">", System.StringComparison.Ordinal))) + return rt; + return null; + } + // === Runtime handler emission (for [AspectHandler]-based aspects) === /// diff --git a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/BuiltInAspectTests.cs b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/BuiltInAspectTests.cs index f18995b..fe96c10 100644 --- a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/BuiltInAspectTests.cs +++ b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/BuiltInAspectTests.cs @@ -438,7 +438,9 @@ public async Task Authorize_Roles_Denied_Throws() { _auth.Roles.Add("User"); var svc = new AuthorizeTestService(); - var ex = await Assert.ThrowsAsync(() => svc.AdminOnlyAsync()); + // AspectAuthorizationException derives from UnauthorizedAccessException — + // assert the exact type so a regression to the base type is caught. + var ex = await Assert.ThrowsAsync(() => svc.AdminOnlyAsync()); Assert.Contains("Admin", ex.Message); } @@ -446,7 +448,7 @@ public async Task Authorize_Roles_Denied_Throws() public async Task Authorize_Roles_NoRoles_Throws() { var svc = new AuthorizeTestService(); - await Assert.ThrowsAsync(() => svc.AdminOnlyAsync()); + await Assert.ThrowsAsync(() => svc.AdminOnlyAsync()); } [Fact] @@ -462,7 +464,7 @@ public async Task Authorize_Policy_Allowed_Succeeds() public async Task Authorize_Policy_Denied_Throws() { var svc = new AuthorizeTestService(); - var ex = await Assert.ThrowsAsync(() => svc.PolicyProtectedAsync()); + var ex = await Assert.ThrowsAsync(() => svc.PolicyProtectedAsync()); Assert.Contains("CanEdit", ex.Message); } @@ -480,7 +482,7 @@ public async Task Authorize_NoArgs_NotAuthenticated_Throws() { _auth.IsAuthenticated = false; var svc = new AuthorizeTestService(); - await Assert.ThrowsAsync(() => svc.AuthenticatedOnlyAsync()); + await Assert.ThrowsAsync(() => svc.AuthenticatedOnlyAsync()); } } @@ -503,7 +505,7 @@ public void Validate_ValidRequest_Succeeds() public void Validate_RequiredMissing_Throws() { var svc = new ValidateTestService(); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => svc.Process(new ValidateRequest { Age = 25 })); // Name is null Assert.Contains("Name", ex.Message); } @@ -512,7 +514,7 @@ public void Validate_RequiredMissing_Throws() public void Validate_RangeViolation_Throws() { var svc = new ValidateTestService(); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => svc.Process(new ValidateRequest { Name = "Bob", Age = 200 })); Assert.Contains("Age", ex.Message); } diff --git a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/Fixtures/TestServices.cs b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/Fixtures/TestServices.cs index 0fc5679..4ccd523 100644 --- a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/Fixtures/TestServices.cs +++ b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/Fixtures/TestServices.cs @@ -522,6 +522,46 @@ public class ValidateTestService public string ProcessMulti(ValidateRequest request, int count) => $"ok:{count}"; } +// ── Aspect ↔ Result integration ───────────────────────────────────────────────── +// Methods returning ZibStack.NET.Result.Result / Result: aspect precondition +// failures ([Authorize], [Validate]) come back as failed Results, not exceptions. + +public class ResultAspectService +{ + [Authorize(Roles = "Admin")] + public async Task> AdminNumberAsync() + { + await Task.CompletedTask; + return ZibStack.NET.Result.Result.Success(42); + } + + [Authorize(Roles = "Admin")] + public async Task AdminActionAsync() + { + await Task.CompletedTask; + return ZibStack.NET.Result.Result.Success(); + } + + [Validate] + public ZibStack.NET.Result.Result Register(ValidateRequest request) => + ZibStack.NET.Result.Result.Success($"ok:{request.Name}"); + + [Authorize(Roles = "Admin")] + public async Task> AdminThrowsAsync() + { + await Task.CompletedTask; + throw new InvalidOperationException("body exploded"); + } + + // Plain return type — aspect failures must still throw. + [Authorize(Roles = "Admin")] + public async Task AdminPlainAsync() + { + await Task.CompletedTask; + return 1; + } +} + // ── Built-in [Transaction] ────────────────────────────────────────────────────── public class TransactionTestService diff --git a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ResultIntegrationTests.cs b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ResultIntegrationTests.cs new file mode 100644 index 0000000..33fbaf0 --- /dev/null +++ b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ResultIntegrationTests.cs @@ -0,0 +1,96 @@ +using ZibStack.NET.Aop.Tests.Fixtures; +using Xunit; + +namespace ZibStack.NET.Aop.Tests; + +// ── Aspect ↔ Result integration ───────────────────────────────────────────── +// Methods returning ZibStack.NET.Result.Result / Result receive aspect +// precondition failures as failed Results instead of exceptions. The method's +// own exceptions (and aspect failures on non-Result methods) still throw. + +[Collection("Aop")] +public class ResultIntegrationTests +{ + private readonly AopFixture _fx; + + public ResultIntegrationTests(AopFixture fx) + { + _fx = fx; + _fx.AuthProvider.Reset(); + } + + [Fact] + public async Task Authorize_Failure_OnResultT_ReturnsFailedResult() + { + var svc = new ResultAspectService(); + var result = await svc.AdminNumberAsync(); + + Assert.True(result.IsFailure); + Assert.Equal("Unauthorized", result.Error!.Code); + Assert.Contains("Admin", result.Error.Message); + } + + [Fact] + public async Task Authorize_Success_OnResultT_ReturnsValue() + { + _fx.AuthProvider.Roles.Add("Admin"); + try + { + var svc = new ResultAspectService(); + var result = await svc.AdminNumberAsync(); + + Assert.True(result.IsSuccess); + Assert.Equal(42, result.Value); + } + finally { _fx.AuthProvider.Reset(); } + } + + [Fact] + public async Task Authorize_Failure_OnNonGenericResult_ReturnsFailedResult() + { + var svc = new ResultAspectService(); + var result = await svc.AdminActionAsync(); + + Assert.True(result.IsFailure); + Assert.Equal("Unauthorized", result.Error!.Code); + } + + [Fact] + public void Validate_Failure_OnResultT_ReturnsValidationError() + { + var svc = new ResultAspectService(); + var result = svc.Register(new ValidateRequest { Name = null, Age = 0 }); + + Assert.True(result.IsFailure); + Assert.Equal("Validation", result.Error!.Code); + } + + [Fact] + public void Validate_Success_OnResultT_ReturnsValue() + { + var svc = new ResultAspectService(); + var result = svc.Register(new ValidateRequest { Name = "Zib", Age = 30 }); + + Assert.True(result.IsSuccess); + Assert.Equal("ok:Zib", result.Value); + } + + [Fact] + public async Task MethodOwnException_OnResultT_StillThrows() + { + _fx.AuthProvider.Roles.Add("Admin"); + try + { + var svc = new ResultAspectService(); + await Assert.ThrowsAsync(() => svc.AdminThrowsAsync()); + } + finally { _fx.AuthProvider.Reset(); } + } + + [Fact] + public async Task Authorize_Failure_OnPlainReturnType_StillThrows() + { + var svc = new ResultAspectService(); + await Assert.ThrowsAsync(() => svc.AdminPlainAsync()); + } +} diff --git a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ZibStack.NET.Aop.Tests.csproj b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ZibStack.NET.Aop.Tests.csproj index d4ea39b..1977471 100644 --- a/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ZibStack.NET.Aop.Tests.csproj +++ b/packages/ZibStack.NET.Aop/tests/ZibStack.NET.Aop.Tests/ZibStack.NET.Aop.Tests.csproj @@ -25,6 +25,9 @@ + + From 9db38b8d2b72814e4655d248d04cf060619cc74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 14:09:29 +0200 Subject: [PATCH 6/7] [Dto]: cursor (keyset) pagination on generated GET list endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor= (empty) starts keyset pagination ordered by the entity key; the CursorPage response carries an opaque nextCursor (null on last page). filter= composes, sort= is ignored in cursor mode, [ColumnPermission] masking applies. Supported key types: int/long/Guid/string — others simply don't get the cursor parameter. Offset PaginatedResponse behavior unchanged. Also fix stale soft-delete GetById doc (generated code returns 404). --- .gitignore | 1 + README.md | 3 + .../src/content/docs/packages/dto/crud-api.md | 3 +- .../content/docs/packages/dto/paginated.md | 18 ++++ .../SampleApi.Tests/CursorPaginationTests.cs | 94 +++++++++++++++++++ .../DtoGenerator.CrudAttributeSources.cs | 31 ++++++ .../DtoGenerator.CrudGeneration.cs | 66 +++++++++++-- .../src/ZibStack.NET.Dto/DtoGenerator.cs | 1 + 8 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 packages/ZibStack.NET.Dto/sample/SampleApi.Tests/CursorPaginationTests.cs diff --git a/.gitignore b/.gitignore index 038b331..a298b10 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ Thumbs.db **/sample/**/generated/ .claude/settings.local.json +packages/ZibStack.NET.Dto/sample/SampleApi/generated1/ diff --git a/README.md b/README.md index 1e97940..f5d7c57 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,9 @@ public partial class Employee { /* ... */ } [CrudApi(Concurrency = true, Audit = true)] public partial class Document { /* ... */ } +// Cursor (keyset) pagination on every generated list endpoint: +// GET /api/documents?cursor=&pageSize=20 → { items, nextCursor, pageSize } + // Test scaffolding — generates xUnit CRUD integration tests for every [CrudApi] entity: [assembly: GenerateCrudTests] diff --git a/docs/src/content/docs/packages/dto/crud-api.md b/docs/src/content/docs/packages/dto/crud-api.md index 915e688..e447c40 100644 --- a/docs/src/content/docs/packages/dto/crud-api.md +++ b/docs/src/content/docs/packages/dto/crud-api.md @@ -424,7 +424,8 @@ What gets generated: - **DELETE endpoints** (`DELETE /api/players/{id}` and `POST /api/players/bulk-delete`) set `IsDeleted = true` and `DeletedAt = DateTime.UtcNow` instead of removing the row. - **GET list** (`GET /api/players`) filters out soft-deleted entities by default — a `WHERE IsDeleted = false` clause is appended to the query automatically. - **`?includeDeleted=true`** query parameter on the GET list endpoint bypasses the filter and returns all entities including deleted ones. -- **GET by ID** still returns soft-deleted entities (no silent 404 — the consumer can inspect `IsDeleted` on the response). +- **`?cursor=`** on the GET list endpoint switches to keyset pagination — see [PaginatedResponse → Cursor pagination](/packages/dto/paginated/). +- **GET by ID** returns `404 Not Found` for soft-deleted entities — same as the list view, a deleted record is invisible through the API. Query the store directly (`store.Query()` is unfiltered) when you need to inspect archived rows. Works with all API styles — Minimal API, Controller, and bulk operations. The bulk-delete endpoint also applies the soft-delete logic per entity instead of issuing a hard delete. diff --git a/docs/src/content/docs/packages/dto/paginated.md b/docs/src/content/docs/packages/dto/paginated.md index 56ab756..2cd0bad 100644 --- a/docs/src/content/docs/packages/dto/paginated.md +++ b/docs/src/content/docs/packages/dto/paginated.md @@ -40,3 +40,21 @@ public async Task List([FromQuery] ProductQuery query, int page = } ``` +## Cursor (keyset) pagination (`CursorPage`) + +Offset pagination degrades on deep pages (`OFFSET 100000` scans and discards) and can skip/duplicate rows when data changes between pages. Generated minimal-API GET list endpoints also support **keyset pagination** out of the box — pass `cursor=` (empty) to start: + +```http +GET /api/products?cursor=&pageSize=20 +→ { "items": [...], "nextCursor": "MTIz", "pageSize": 20 } + +GET /api/products?cursor=MTIz&pageSize=20 +→ { "items": [...], "nextCursor": null, "pageSize": 20 } // null = last page +``` + +- Items are ordered by the entity key; the opaque `nextCursor` encodes the last-seen key. The query becomes `WHERE Key > @after ORDER BY Key LIMIT @pageSize` — constant cost at any depth. +- `filter=` composes with cursor mode; `sort=` is ignored (keyset pagination requires the stable key order). +- Supported key types: `int`, `long`, `Guid`, `string`. For other key types the `cursor` parameter is not emitted. +- Without the `cursor` parameter the endpoint behaves exactly as before (offset `PaginatedResponse`). +- Cursor mode returns full `{Entity}Response` items ([ColumnPermission] masking still applies); the separate list-item DTO is an offset-mode feature. + diff --git a/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/CursorPaginationTests.cs b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/CursorPaginationTests.cs new file mode 100644 index 0000000..11080a7 --- /dev/null +++ b/packages/ZibStack.NET.Dto/sample/SampleApi.Tests/CursorPaginationTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace ZibStack.NET.Dto.Sample.Tests; + +/// +/// Generated GET list endpoints support keyset pagination: ?cursor= (empty) +/// starts from the beginning, the response carries an opaque nextCursor which +/// is null on the last page. Items come back in stable key order. +/// +public class CursorPaginationTests : IClassFixture> +{ + private readonly HttpClient _client; + + public CursorPaginationTests(WebApplicationFactory factory) + => _client = factory.CreateClient(); + + private async Task> SeedDocumentsAsync(string prefix, int count) + { + var ids = new List(); + for (var i = 0; i < count; i++) + { + var response = await _client.PostAsJsonAsync("/api/documents", + new { Title = $"{prefix}_{i}", Content = "x" }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + ids.Add(body.GetProperty("id").GetInt32()); + } + return ids; + } + + [Fact] + public async Task Cursor_WalksAllPages_InKeyOrder_WithoutDuplicates() + { + var prefix = $"Cur_{Guid.NewGuid():N}"; + var seeded = await SeedDocumentsAsync(prefix, 5); + + var collected = new List(); + string cursorQuery = "cursor="; + for (var hop = 0; hop < 10; hop++) // bound the loop in case nextCursor never ends + { + var page = await _client.GetFromJsonAsync( + $"/api/documents?filter=Title=*{prefix}&pageSize=2&{cursorQuery}"); + foreach (var item in page.GetProperty("items").EnumerateArray()) + collected.Add(item.GetProperty("id").GetInt32()); + + var next = page.GetProperty("nextCursor"); + if (next.ValueKind == JsonValueKind.Null) break; + cursorQuery = $"cursor={Uri.EscapeDataString(next.GetString()!)}"; + } + + Assert.Equal(seeded.OrderBy(i => i), collected); // all items, key order, no dupes + } + + [Fact] + public async Task Cursor_LastPage_HasNullNextCursor() + { + var prefix = $"CurLast_{Guid.NewGuid():N}"; + await SeedDocumentsAsync(prefix, 2); + + var page = await _client.GetFromJsonAsync( + $"/api/documents?filter=Title=*{prefix}&pageSize=10&cursor="); + Assert.Equal(2, page.GetProperty("items").GetArrayLength()); + Assert.Equal(JsonValueKind.Null, page.GetProperty("nextCursor").ValueKind); + } + + [Fact] + public async Task Cursor_RespectsFilter() + { + var prefix = $"CurFil_{Guid.NewGuid():N}"; + await SeedDocumentsAsync(prefix, 3); + await SeedDocumentsAsync($"Other_{Guid.NewGuid():N}", 2); + + var page = await _client.GetFromJsonAsync( + $"/api/documents?filter=Title=*{prefix}&pageSize=50&cursor="); + Assert.Equal(3, page.GetProperty("items").GetArrayLength()); + } + + [Fact] + public async Task NoCursorParam_KeepsOffsetPagination() + { + var prefix = $"CurOff_{Guid.NewGuid():N}"; + await SeedDocumentsAsync(prefix, 1); + + var page = await _client.GetFromJsonAsync( + $"/api/documents?filter=Title=*{prefix}"); + // offset shape: totalCount/page/pageSize, no nextCursor + Assert.True(page.TryGetProperty("totalCount", out _)); + Assert.False(page.TryGetProperty("nextCursor", out _)); + } +} diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs index 1a9c35c..9720ca9 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudAttributeSources.cs @@ -116,6 +116,37 @@ internal sealed class CrudApiAttribute : System.Attribute public bool Audit { get; set; } } } +"; + + private const string CursorPageSource = @"// +#nullable enable + +namespace ZibStack.NET.Dto +{ + /// + /// Cursor (keyset) pagination wrapper returned by generated GET list endpoints when + /// the cursor query parameter is present. Pass cursor= (empty) for the + /// first page, then follow until it is null. + /// + public record CursorPage + { + public System.Collections.Generic.IReadOnlyList Items { get; init; } = System.Array.Empty(); + /// Opaque cursor for the next page; null when this is the last page. + public string? NextCursor { get; init; } + public int PageSize { get; init; } + } + + /// Opaque-cursor helpers used by generated cursor-pagination endpoints. + public static class Cursor + { + public static string Encode(object key) => + System.Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + System.Convert.ToString(key, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty)); + + public static string Decode(string cursor) => + System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String(cursor)); + } +} "; private const string ETagSource = @"// diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs index 1d56761..13c7daf 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.CrudGeneration.cs @@ -140,14 +140,21 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) ? (info.Namespace is not null ? $"{info.Namespace}.{info.QueryName}" : info.QueryName) : null; + // Cursor (keyset) pagination — supported for int/long/Guid/string keys. + var cursorKeyParse = GetCursorKeyParseExpression(info.KeyTypeName); + var cursorParam = cursorKeyParse is not null ? ", string? cursor = null" : ""; + // Any branch that returns a materialized value forces the async form so all + // lambda return paths agree (IResult vs Task>). + var listAsync = info.HasQueryDsl || enforceListPerms || cursorKeyParse is not null; + // ClaimsPrincipal binds from HttpContext.User; it can't be optional, so it // goes before the defaulted params instead of reusing userParam. var listUserParam = enforcePerms ? " System.Security.Claims.ClaimsPrincipal? __user," : ""; if (fqQuery is not null) { - var asyncKeyword = info.HasQueryDsl || enforceListPerms ? "async " : ""; + var asyncKeyword = listAsync ? "async " : ""; sb.AppendLine($" group.MapGet(\"\", {asyncKeyword}([Microsoft.AspNetCore.Http.AsParameters] {fqQuery} query,"); - sb.AppendLine($" {storeType} store,{listUserParam} int page = 1, int pageSize = 20{dslParams}{softDeleteParam}, CancellationToken ct = default) =>"); + sb.AppendLine($" {storeType} store,{listUserParam} int page = 1, int pageSize = 20{dslParams}{softDeleteParam}{cursorParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var q = store.Query();"); if (info.SoftDelete) @@ -158,8 +165,8 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) } else { - var asyncKeyword = enforceListPerms ? "async " : ""; - sb.AppendLine($" group.MapGet(\"\", {asyncKeyword}({storeType} store,{listUserParam} int page = 1, int pageSize = 20{dslParams}{softDeleteParam}, CancellationToken ct = default) =>"); + var asyncKeyword = listAsync ? "async " : ""; + sb.AppendLine($" group.MapGet(\"\", {asyncKeyword}({storeType} store,{listUserParam} int page = 1, int pageSize = 20{dslParams}{softDeleteParam}{cursorParam}, CancellationToken ct = default) =>"); sb.AppendLine(" {"); sb.AppendLine(" var q = store.Query();"); if (info.SoftDelete) @@ -172,6 +179,39 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine(" if (count) return Results.Ok(new { count = q.Count() });"); } + // cursor= keyset pagination: ordered by key, windowed by pageSize + 1 to + // detect the last page. cursor= (empty) starts from the beginning; the + // response carries the opaque cursor for the next page. sort= is ignored + // in cursor mode — keyset pagination requires the stable key order. + if (cursorKeyParse is not null) + { + var keyProp = info.KeyPropertyName; + var fqCursorResponse = info.HasResponseDto && info.ResponseName is not null + ? (info.Namespace is not null ? $"{info.Namespace}.{info.ResponseName}" : info.ResponseName) + : null; + var cursorItemType = fqCursorResponse ?? fqEntity; + var cursorItemExpr = fqCursorResponse is null + ? "e" + : enforcePerms + ? $"{permsClass}.Apply({fqCursorResponse}.FromEntity(e), __user)" + : $"{fqCursorResponse}.FromEntity(e)"; + + sb.AppendLine(" if (cursor is not null)"); + sb.AppendLine(" {"); + sb.AppendLine($" var __ordered = q.OrderBy(e => e.{keyProp});"); + sb.AppendLine(" if (cursor.Length > 0)"); + sb.AppendLine(" {"); + sb.AppendLine($" var __after = {cursorKeyParse};"); + sb.AppendLine($" __ordered = q.Where(e => e.{keyProp}.CompareTo(__after) > 0).OrderBy(e => e.{keyProp});"); + sb.AppendLine(" }"); + sb.AppendLine(" var __win = __ordered.Take(pageSize + 1).ToList();"); + sb.AppendLine(" var __hasMore = __win.Count > pageSize;"); + sb.AppendLine(" if (__hasMore) __win.RemoveAt(pageSize);"); + sb.AppendLine($" var __next = __hasMore && __win.Count > 0 ? ZibStack.NET.Dto.Cursor.Encode(__win[__win.Count - 1].{keyProp}) : null;"); + sb.AppendLine($" return Results.Ok(new CursorPage<{cursorItemType}> {{ Items = __win.Select(e => {cursorItemExpr}).ToList(), NextCursor = __next, PageSize = pageSize }});"); + sb.AppendLine(" }"); + } + // select= field projection (when ZibStack.NET.Query is referenced) if (info.HasQueryDsl && fqQuery is not null) { @@ -193,14 +233,14 @@ private static string GenerateMinimalApiSource(CrudApiInfo info) sb.AppendLine($" var defaultProjected = {fqListResponse}.ProjectFrom(q);"); if (enforceListPerms) sb.AppendLine($" return Results.Ok((await PaginatedResponse<{fqListResponse}>.CreateAsync(defaultProjected, page, pageSize, ct)).Map(r => {permsClass}.Apply(r, __user)));"); - else if (info.HasQueryDsl) + else if (listAsync) sb.AppendLine($" return Results.Ok(await PaginatedResponse<{fqListResponse}>.CreateAsync(defaultProjected, page, pageSize, ct));"); else sb.AppendLine($" return PaginatedResponse<{fqListResponse}>.CreateAsync(defaultProjected, page, pageSize, ct);"); } else { - if (info.HasQueryDsl) + if (listAsync) sb.AppendLine($" return Results.Ok(await PaginatedResponse<{fqEntity}>.CreateAsync(q, page, pageSize, ct));"); else sb.AppendLine($" return PaginatedResponse<{fqEntity}>.CreateAsync(q, page, pageSize, ct);"); @@ -510,6 +550,20 @@ private static string GenerateColumnPermissionsSource(CrudApiInfo info) return sb.ToString(); } + /// + /// C# expression parsing the decoded cursor back into the entity key type, or null + /// when the key type doesn't support keyset pagination (cursor support is then + /// simply not emitted for that entity). + /// + private static string? GetCursorKeyParseExpression(string keyTypeName) => keyTypeName.TrimEnd('?') switch + { + "int" or "System.Int32" => "int.Parse(ZibStack.NET.Dto.Cursor.Decode(cursor), System.Globalization.CultureInfo.InvariantCulture)", + "long" or "System.Int64" => "long.Parse(ZibStack.NET.Dto.Cursor.Decode(cursor), System.Globalization.CultureInfo.InvariantCulture)", + "System.Guid" or "Guid" => "System.Guid.Parse(ZibStack.NET.Dto.Cursor.Decode(cursor))", + "string" or "System.String" => "ZibStack.NET.Dto.Cursor.Decode(cursor)", + _ => null, + }; + private static string GenerateControllerSource(CrudApiInfo info) { var sb = new StringBuilder(); diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs index e7ac545..38cda64 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs @@ -51,6 +51,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("GenerateCrudTestsAttribute.g.cs", GenerateCrudTestsAttributeSource); ctx.AddSource("SignalRHubAttribute.g.cs", SignalRHubAttributeSource); ctx.AddSource("ETag.g.cs", ETagSource); + ctx.AddSource("CursorPage.g.cs", CursorPageSource); }); // Detect available serializers and emit PatchField + converters From da4c67db2591c9f7cc018261fda80441cf85f007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Thu, 11 Jun 2026 14:13:41 +0200 Subject: [PATCH 7/7] [Docs]: cross-link Aop Result integration from the Result page --- docs/src/content/docs/packages/result.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/src/content/docs/packages/result.md b/docs/src/content/docs/packages/result.md index fa29ec9..51d7966 100644 --- a/docs/src/content/docs/packages/result.md +++ b/docs/src/content/docs/packages/result.md @@ -264,6 +264,20 @@ app.MapPost("/api/orders/{id}/ship", async (int id, ShipmentService svc) => > **Tip:** Place the `ToResponse()` extension in a shared project or `ServiceCollectionExtensions` file so all endpoints can use it consistently. +## AOP integration + +When a method returns `Result` / `Result`, ZibStack.NET.Aop aspects cooperate with the Result pattern: `[Authorize]` failures come back as `Error.Unauthorized` and `[Validate]` failures as `Error.Validation` — no exceptions, no try/catch: + +```csharp +[Authorize(Roles = "Admin")] +public async Task> DeleteOrderAsync(int id) { ... } + +var result = await svc.DeleteOrderAsync(7); // never throws on authorization failure +if (result.IsFailure) return result.ToResponse(); +``` + +See [Aop → Built-in aspects](/packages/aop/built-in/) for details. + ## Requirements - .NET 8.0+ (async extensions)