Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ Thumbs.db

## TypeGen sample outputs — regenerated on every build, never commit.
**/sample/**/generated/

.claude/settings.local.json
packages/ZibStack.NET.Dto/sample/SampleApi/generated1/
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`** — methods returning `Result`/`Result<T>` 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.

Expand Down Expand Up @@ -257,6 +258,21 @@ 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 { /* ... */ }

// 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 { /* ... */ }

// 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]

Expand Down
26 changes: 24 additions & 2 deletions docs/src/content/docs/packages/aop/built-in.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, authorization failures come back as a failed Result with `Error.Unauthorized` instead of throwing:

```csharp
[Authorize(Roles = "Admin")]
public async Task<Result<Order>> 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)

Expand Down Expand Up @@ -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<T>`, validation failures come back as a failed Result with `Error.Validation` instead of throwing:

```csharp
[Validate]
public Result<Order> CreateOrder(CreateOrderRequest request) { ... }
// invalid request → Result.IsFailure with Error.Code == "Validation"
```

### `[Transaction]` — TransactionScope

```csharp
Expand Down
74 changes: 72 additions & 2 deletions docs/src/content/docs/packages/dto/crud-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ 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
Audit = true // auto-stamp CreatedAt/UpdatedAt/CreatedBy/UpdatedBy
)]
```

Expand Down Expand Up @@ -321,6 +324,22 @@ public class PlayerStore : EfCrudStore<Player, int, AppDbContext>
}
```

### 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`):
Expand Down Expand Up @@ -405,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.

Expand All @@ -417,6 +437,56 @@ 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.

## 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:
Expand Down
18 changes: 18 additions & 0 deletions docs/src/content/docs/packages/dto/paginated.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,21 @@ public async Task<IActionResult> List([FromQuery] ProductQuery query, int page =
}
```

## Cursor (keyset) pagination (`CursorPage<T>`)

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<T>`).
- Cursor mode returns full `{Entity}Response` items ([ColumnPermission] masking still applies); the separate list-item DTO is an offset-mode feature.

14 changes: 14 additions & 0 deletions docs/src/content/docs/packages/result.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, 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<Result<Order>> 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)
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/packages/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace ZibStack.NET.Aop;

/// <summary>
/// Thrown by <see cref="AuthorizeHandler"/> when authorization fails. Derives from
/// <see cref="UnauthorizedAccessException"/> so existing catch blocks keep working.
/// On methods returning <c>ZibStack.NET.Result.Result</c>/<c>Result&lt;T&gt;</c> the generated
/// interceptor converts this into a failed Result with <c>Error.Unauthorized</c> instead of throwing.
/// </summary>
public sealed class AspectAuthorizationException : UnauthorizedAccessException
{
public AspectAuthorizationException(string message) : base(message) { }
}

/// <summary>
/// Thrown by <see cref="ValidateHandler"/> when parameter validation fails. Derives from
/// <see cref="ArgumentException"/> so existing catch blocks keep working.
/// On methods returning <c>ZibStack.NET.Result.Result</c>/<c>Result&lt;T&gt;</c> the generated
/// interceptor converts this into a failed Result with <c>Error.Validation</c> instead of throwing.
/// </summary>
public sealed class AspectValidationException : ArgumentException
{
public AspectValidationException(string message, string? paramName) : base(message, paramName) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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}.");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(" }");
}

/// <summary>
/// When the (unwrapped) return type is ZibStack.NET.Result.Result or Result&lt;T&gt;,
/// returns the fully-qualified type expression to call <c>.Failure(...)</c> on;
/// otherwise null. Matched by fully-qualified name so user types named Result
/// never trigger the conversion.
/// </summary>
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) ===

/// <summary>
Expand Down
Loading
Loading