Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
98d9988
Add OpenSpec change: minimal-api-mediator-openapi-migration
niemyjski May 26, 2026
36cd1bc
refactor: consolidate Startup.cs into minimal hosting Program.cs
niemyjski May 26, 2026
c22ccaa
feat: add Api infrastructure (endpoints, groups, results, filters)
niemyjski May 26, 2026
9312c10
feat: migrate StatusEndpoints and UtilityEndpoints to Minimal API
niemyjski May 26, 2026
5e2b597
feat: migrate Token, WebHook, and Stripe endpoints to Minimal API
niemyjski May 26, 2026
364841b
Migrate SavedViewController and UserController to Minimal API endpoin…
niemyjski May 26, 2026
17c5167
feat: migrate Project and Organization endpoints to Minimal API
niemyjski May 27, 2026
539e61d
feat: migrate Auth endpoints to Minimal API
niemyjski May 27, 2026
cbe2ac5
Migrate Stack, Admin, Event controllers to Minimal API endpoints
niemyjski May 27, 2026
31643d3
Fix minimal API parity regressions
niemyjski May 27, 2026
4f00649
refactor: remove MVC controllers infrastructure
niemyjski May 27, 2026
b1dc7b6
test: add route manifest and OpenAPI snapshot tests
niemyjski May 27, 2026
a68fbe7
fix: address audit findings — route constraints, validation filter, w…
niemyjski May 27, 2026
a2ae9a2
fix: regenerate endpoint-manifest.json with corrected routes
niemyjski May 27, 2026
730e050
refactor: modernize Job runner to minimal hosting pattern
niemyjski May 27, 2026
1356c67
docs: restore API documentation metadata on all endpoints
niemyjski May 27, 2026
5be3aaf
docs: add missing OpenAPI response codes, parameters, and schema types
niemyjski May 27, 2026
69255d5
fix: restore original OpenAPI tags to match controller-derived tag names
niemyjski May 27, 2026
7b403d5
fix: address PR feedback and CI build failure
niemyjski May 27, 2026
30aed31
fix: disable ValidateOnBuild in test factory for CI
niemyjski May 27, 2026
8c5fcdf
fix: resolve Program type ambiguity and address CodeQL feedback
niemyjski May 27, 2026
6bb1dd4
fix: correct ConfigurationResponseEndpointFilter status code check an…
niemyjski May 27, 2026
07ce2c9
fix: resolve validation parity, security bug, and paging issues
niemyjski May 27, 2026
859addf
fix: resolve flaky CI tests and restore OpenAPI schema parity
niemyjski May 27, 2026
5cf1cac
fix: correct stack endpoint response code metadata
niemyjski May 27, 2026
0d370c3
fix: serialize queue-dependent tests to prevent parallel interference
niemyjski May 27, 2026
06a9a9a
fix: restore OpenAPI schema parity with snake_case naming and documen…
niemyjski May 27, 2026
07838fb
fix(security): add organization access check to OrganizationHandler
niemyjski May 27, 2026
55c268d
feat: migrate TokenHandler to Result<T> return types
niemyjski May 28, 2026
025db45
feat: migrate Stack, Auth, Event, Stripe handlers to Result<T>
niemyjski May 28, 2026
3c88785
chore: remove accidental audit-output files
niemyjski May 28, 2026
57e339e
feat: migrate WebHook, Admin, User, SavedView, Project, Organization …
niemyjski May 28, 2026
2a49058
fix: correct status code mappings to preserve original behavior
niemyjski May 28, 2026
701f9ba
chore: gitignore audit-output directory
niemyjski May 28, 2026
aa9a681
merge: resolve conflict in SavedViewHandler after main merge
niemyjski May 28, 2026
61e141a
fix: address PR code quality feedback
niemyjski May 28, 2026
ae90a8f
fix: correct ModelActionResults status code and result mapper parity
niemyjski May 28, 2026
e69d7ee
fix: preserve per-ID failure details in bulk-delete responses
niemyjski May 28, 2026
79ecb52
docs: update backend-architecture skill with Minimal API + Mediator p…
niemyjski May 28, 2026
7a03dcf
fix: address code quality review findings
niemyjski May 28, 2026
43cceec
fix: address adversarial review findings
niemyjski May 28, 2026
9b70370
merge: port origin/main changes to minimal API handlers
niemyjski May 30, 2026
6d9d3f9
fix: add missing publish parameter to RemoveSystemNotification
niemyjski May 30, 2026
4e57a6b
fix: restore correct 426 status for suspended org reads and structure…
niemyjski May 31, 2026
2b58a7d
feat: replace Delta<T> with RFC 6902 JSON Patch support
niemyjski May 31, 2026
11bef8d
fix: set application/json-patch+json content-type in both frontends a…
niemyjski May 31, 2026
a3efdc4
style: fix ESLint double-quote and Prettier formatting in frontend PA…
niemyjski May 31, 2026
fc58321
fix: correct import ordering for eslint perfectionist/sort-imports
niemyjski May 31, 2026
944a219
style: fix remaining single-quote ESLint errors in Angular toJsonPatch
niemyjski May 31, 2026
38b4d7a
fix: add DefaultJsonTypeInfoResolver to test options for Release mode…
niemyjski May 31, 2026
5d912bd
Merge remote-tracking branch 'origin/main' into niemyjski/minimal-api…
niemyjski May 31, 2026
22cd049
fix: reject malformed json patch paths and restore event openapi bodies
niemyjski May 31, 2026
694fe45
fix: isolate test file storage by app scope
niemyjski May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 104 additions & 19 deletions .agents/skills/backend-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ aspire run --project src/Exceptionless.AppHost
```text
Exceptionless.Core → Domain logic, services, repositories, validation
Exceptionless.Insulation → Infrastructure implementations (Redis, GeoIP, Mail, HealthChecks)
Exceptionless.Web → ASP.NET Core host, controllers, WebSocket hubs
Exceptionless.Web → ASP.NET Core host, Minimal API endpoints, Mediator handlers, WebSocket hubs
Exceptionless.Job → Background job workers
```

Expand Down Expand Up @@ -89,49 +89,134 @@ public static class AuthorizationRoles
public const string GlobalAdmin = "global";
}

// Usage
[Authorize(Policy = AuthorizationRoles.UserPolicy)]
public class OrganizationController : RepositoryApiController<...> { }
// Minimal API endpoint groups (NEW pattern)
var group = endpoints.MapGroup("api/v2")
.RequireAuthorization(AuthorizationRoles.UserPolicy) // Group default
.WithTags("Tokens");

[Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)]
public class AdminController : ExceptionlessApiController { }
// Override on specific endpoints
group.MapGet("tokens/me", ...).RequireAuthorization(AuthorizationRoles.ClientPolicy);
group.MapPost("auth/login", ...).AllowAnonymous();
```

## Controller Patterns

Most controllers extend `RepositoryApiController<TRepository, TModel, TViewModel, TNewModel, TUpdateModel>`. Auth/special-case controllers extend `ExceptionlessApiController` directly.
> **DEPRECATED**: Controllers are being migrated to Minimal API endpoints + Mediator handlers (see below).
> Do NOT add new controllers. Use the Endpoint + Handler pattern instead.

Legacy controllers extend `RepositoryApiController<TRepository, TModel, TViewModel, TNewModel, TUpdateModel>`. Auth/special-case controllers extend `ExceptionlessApiController` directly.

## Minimal API + Mediator Architecture (NEW)

All new API work uses Minimal API endpoints with Foundatio.Mediator for command/query dispatch.

### Structure

```text
src/Exceptionless.Web/Api/
├── Endpoints/ ← Thin HTTP adapters (routing, auth, response mapping)
├── Messages/ ← Command/query records (mediator messages)
├── Handlers/ ← Use-case logic (transport-agnostic, return Result<T>)
├── Middleware/ ← Mediator pipeline middleware (validation, logging)
├── Filters/ ← Endpoint filters (HTTP-specific cross-cutting)
├── Results/ ← Result→IResult mapping, pagination, response types
├── Infrastructure/ ← Shared utilities (validation, pagination, links)
└── OpenApi/ ← OpenAPI conventions and transformers
```

### Endpoint Pattern

```csharp
[Route(API_PREFIX + "/organizations")]
[Authorize(Policy = AuthorizationRoles.UserPolicy)]
public class OrganizationController : RepositoryApiController<IOrganizationRepository, Organization, ViewOrganization, NewOrganization, NewOrganization>
public static class TokenEndpoints
{
[HttpGet]
public async Task<ActionResult<IReadOnlyCollection<ViewOrganization>>> GetAllAsync(string? mode = null)
public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder endpoints)
{
var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray());
return Ok(await MapCollectionAsync<ViewOrganization>(organizations, true));
var group = endpoints.MapGroup("api/v2")
.RequireAuthorization(AuthorizationRoles.UserPolicy)
.WithTags("Tokens");

group.MapGet("tokens/{id}", async (string id, IMediator mediator)
=> (await mediator.InvokeAsync<Result<ViewToken>>(new GetTokenById(id))).ToHttpResult())
.WithName("GetTokenById")
.Produces<ViewToken>()
.ProducesProblem(StatusCodes.Status404NotFound);

return endpoints;
}
}
```

### Handler Pattern (CRITICAL: Transport-Agnostic)

Handlers MUST return `Result<T>` or `Result` — NEVER `IResult` or HTTP types.

```csharp
public class TokenHandler(ITokenRepository repository, ...) : HandlerBase
{
public async Task<Result<ViewToken>> Handle(GetTokenById message)
{
var model = await repository.GetByIdAsync(message.Id);
if (model is null)
return Result.NotFound("Token not found.");
return mapper.MapToViewToken(model);
}
}
```

### Result→HTTP Status Code Mapping

| Result Type | HTTP Status | Notes |
|---|---|---|
| `Result<T>` success | 200 OK | Default success |
| `Result<T>.Created(val, loc)` | 201 Created | With Location header |
| `Result.NotFound(msg)` | 404 | Message in `title` field |
| `Result.Forbidden(msg)` | 403 | Message in `title` field |
| `Result.BadRequest(msg)` | 400 | Message in `title` field |
| `Result.Invalid(ValidationError)` | 422 | Errors in `errors` dict |
| `Result.Invalid("plan_limit", msg)` | 426 | Upgrade Required |
| `Result.Invalid("not_implemented", msg)` | 501 | Not Implemented |
| `Result.Invalid("rate_limit", msg)` | 429 | Too Many Requests |
| `WorkInProgressResult` | 202 Accepted | Bulk operations |
| `ModelActionResults` (has failures) | 400 | Per-ID failure details |
| `PagedResult<T>` | 200 + Link headers | Auto-pagination |
| `NotModifiedResponse` | 304 | No body |

### Key Rules

- Handlers MUST NOT import `Microsoft.AspNetCore.Http`
- Handlers CAN accept `HttpContext` as a method parameter (auto-resolved by mediator) for auth
- Pagination link URLs MUST be built in the endpoint/mapper layer
- `ProblemDetails` shape MUST be preserved: `instance`, `reference-id`, `errors`, `lower_underscore` keys
- Messages go in `title` field (NOT `detail`) — matches original controller behavior
- Use `ApiValidation.ValidateAsync(model, serviceProvider)` at endpoint level (returns 422 by default)
- Keep v1 legacy route aliases in the same endpoint file as canonical v2 routes

## ProblemDetails and Error Handling

Return helpers from `ExceptionlessApiController`: `Ok()`, `Created()`, `NoContent()`, `Unauthorized()`, `Forbidden()`, `NotFound()`, `ValidationProblem(ModelState)`.
### Endpoint-level validation (Minimal API)

Use `ApiValidation.ValidateAsync(model, serviceProvider)` — returns `ValidationProblemDetails` at 422 for DataAnnotation failures. For MVC-model-binding-compatible 400 responses, pass explicit status code or add endpoint-level checks.

### Handler-level errors

Exceptions auto-convert via `ExceptionToProblemDetailsHandler`: `MiniValidatorException`/`ValidationException` → 422, others → 500.
Return `Result.NotFound()`, `Result.Forbidden()`, `Result.BadRequest()`, or `Result.Invalid(ValidationError)`. The `ResultExtensions.ToHttpResult()` method converts these to proper `IResult` with ProblemDetails shape.

### Exception handling

Exceptions auto-convert via `ExceptionToProblemDetailsHandler`: `MiniValidatorException`/`ValidationException` → 422, `UnauthorizedAccessException` → 401, `VersionConflictDocumentException` → 409, others → 500.

## OpenAPI Baseline

After any API change (new endpoint, changed status codes, modified request/response models), **always regenerate the OpenAPI baseline**:

```bash
# Requires the API to be running (aspire run --project src/Exceptionless.AppHost)
Invoke-WebRequest -Uri "http://localhost:7110/docs/v2/openapi.json" \
-OutFile "tests/Exceptionless.Tests/Controllers/Data/openapi.json"
curl -s http://localhost:7110/docs/v2/openapi.json | jq . > tests/Exceptionless.Tests/Controllers/Data/openapi.json
```

Then include the updated `openapi.json` in the same commit as the API change (or amend). The `OpenApiControllerTests.GetOpenApiJson_Default_ReturnsExpectedBaseline` test will fail if the baseline is stale.
Then include the updated `openapi.json` in the same commit as the API change. The `OpenApiSnapshotTests.GetOpenApiJson_Default_MatchesSnapshot` test will fail if the baseline is stale.

The endpoint manifest test (`EndpointManifestTests`) verifies all registered routes haven't changed — update `tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.txt` when adding/removing routes.

## WebSocket Hubs (NOT SignalR)

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,4 @@ debug-storybook.log
.devcontainer/devcontainer-lock.json

*.lscache
audit-output/
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Acceptance Criteria: Minimal API + Mediator + OpenAPI Migration

## Route Preservation

- **SHALL** preserve all existing v2 routes with identical HTTP methods, paths, and parameter binding.
- **SHALL** preserve existing v1 compatibility aliases with identical behavior.
- **SHALL** produce identical response status codes for all success and error cases.
- **SHALL** preserve response body shapes (JSON property names, nesting, types).
- **SHALL** preserve response headers (pagination, configuration version, rate-limit headers).
- **SHALL** preserve query parameter behavior (filtering, sorting, paging, time ranges).

## Authentication and Authorization

- **SHALL** preserve auth/authorization behavior for all endpoints.
- **SHALL** preserve `ApiKeyAuthenticationHandler` behavior (API key via header, query string, and bearer token).
- **SHALL** preserve role-based policies (UserPolicy, GlobalAdminPolicy) on all endpoints.
- **SHALL** preserve anonymous access on endpoints currently marked `[AllowAnonymous]`.

## Middleware

- **SHALL** preserve `ThrottlingMiddleware` behavior (rate limiting, response codes, headers).
- **SHALL** preserve `OverageMiddleware` behavior (plan enforcement).
- **SHALL NOT** replace existing middleware implementations.
- **SHALL NOT** change middleware pipeline ordering for existing middleware.

## Validation and Error Handling

- **SHALL** preserve ProblemDetails shape: `instance` field, `reference-id` extension, `errors` map.
- **SHALL** preserve `lower_underscore` error keys in validation error responses.
- **SHALL** produce 422 for validation failures with errors map.
- **SHALL** produce 401 for unauthenticated requests.
- **SHALL** produce 403 for unauthorized requests.
- **SHALL** produce 404 for not-found resources.

## Patching

- **SHALL** preserve `Delta<T>` patch behavior (partial update semantics, unchanged fields not modified).
- **SHALL NOT** introduce JSON Patch in this change.

## Event Ingestion

- **SHALL** preserve raw event ingestion behavior (multipart, compressed, raw body).
- **SHALL** preserve event submission via API key authentication.
- **SHALL** preserve batch event submission.

## Mediator Pattern

- **SHALL NOT** use generated mediator endpoints (MapMediatorEndpoints) for existing public API routes.
- **SHALL** use Foundatio.Mediator for command/query dispatch from endpoint lambdas.
- **SHALL** register all handlers via DI auto-discovery.

## OpenAPI

- **SHALL** preserve `/docs/v2/openapi.json` serving Scalar docs.
- **SHALL** generate build-time OpenAPI artifact during `dotnet build`.
- **SHALL** add route manifest snapshot tests that fail on route addition/removal/change.
- **SHALL** add OpenAPI snapshot tests that fail on schema drift.

## Architecture

- **SHALL** place all new endpoint code under `src/Exceptionless.Web/Api/`.
- **SHALL** keep v1 legacy aliases in the same endpoint file as the canonical v2 route.
- **SHALL** remove `AddControllers()` and `MapControllers()` after all controllers are migrated.
- **SHALL** delete `Controllers/` folder after all controllers are migrated.

## Testing

- **SHALL** pass all existing integration tests without modification (unless test infrastructure needs updating for host changes).
- **SHALL** update `tests/http/*.http` files if endpoint paths or parameters change (they should not).
Loading