Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 57 additions & 0 deletions docs/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,56 @@ This is cosmetic organization -- all modules share one connection.

Override `ConfigureEndpoints` on the module class for non-standard routes.

### Form Requests

Form Requests bundle parameter binding, authorization, validation, and data normalization into a single class. The handler receives an already-valid request object.

```csharp
[FormRequest]
public sealed class CreateProductRequest : FormRequest<CreateProductRequest>
{
public string Name { get; set; } = "";
public decimal Price { get; set; }

public override bool Authorize(ClaimsPrincipal user)
=> user.HasPermission("Products.Create");

public override void Prepare()
{
Name = Name.Trim();
}

protected override void ConfigureRules(RuleConfigurator<CreateProductRequest> rules)
{
rules.RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
rules.RuleFor(x => x.Price).GreaterThan(0);
}
}
```

**Pipeline:** `Bind → Authorize → Prepare → Validate → Handler`

- `Authorize` returns `false` → **403 Forbidden** (short-circuit)
- `Prepare` normalizes data before validation runs
- Validation fails → **422 Unprocessable Entity** with RFC 7807 problem+json:

```json
{
"title": "Validation Error",
"status": 422,
"detail": "One or more validation errors occurred.",
"errors": { "Name": ["'Name' must not be empty."] }
}
```

**Rules:**
- FormRequest classes **must be sealed** (SM0056)
- FormRequest classes **must extend `FormRequest<TSelf>`** (SM0057)
- `[FormRequest]` types get TypeScript interfaces auto-generated (same as `[Dto]`)
- The filter runs on all module route groups automatically
- Existing endpoints using `IValidator<T>` + manual validation are unaffected (opt-in)
- FluentValidation is used under the hood — `RuleFor()` API is standard FluentValidation

---

## 7. Frontend
Expand Down Expand Up @@ -478,6 +528,13 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. `
| SM0049 | Error | Each endpoint must be in its own file |
| SM0054 | Info | Endpoint should declare a `public const string Route` field |

### Form Requests

| Diagnostic | Severity | Rule |
|------------|----------|------|
| SM0056 | Error | FormRequest class must be sealed |
| SM0057 | Error | FormRequest class must extend `FormRequest<TSelf>` |

### Module Metadata

| Diagnostic | Severity | Rule |
Expand Down
41 changes: 3 additions & 38 deletions framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -11,11 +10,6 @@ namespace SimpleModule.Core.Exceptions;
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
private static readonly JsonSerializerOptions InertiaJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
Expand Down Expand Up @@ -70,7 +64,9 @@ CancellationToken cancellationToken

if (httpContext.Request.IsInertia())
{
return await WriteInertiaErrorAsync(httpContext, statusCode, title, detail);
var inertiaResult = new InertiaErrorResult(statusCode, title, detail);
await inertiaResult.ExecuteAsync(httpContext);
return true;
}

var problemDetails = new ProblemDetails
Expand All @@ -88,35 +84,4 @@ CancellationToken cancellationToken
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}

private static async ValueTask<bool> WriteInertiaErrorAsync(
HttpContext httpContext,
int statusCode,
string title,
string message
)
{
var component = $"Error/{statusCode}";
var props = new
{
status = statusCode,
title,
message,
};

var pageData = new
{
component,
props,
url = httpContext.Request.Path + httpContext.Request.QueryString,
version = InertiaMiddleware.Version,
};

httpContext.Response.Headers[InertiaHttpExtensions.InertiaHeader] = "true";
httpContext.Response.Headers["Vary"] = InertiaHttpExtensions.InertiaHeader;
httpContext.Response.ContentType = "application/json";
var json = JsonSerializer.Serialize(pageData, InertiaJsonOptions);
await httpContext.Response.WriteAsync(json);
return true;
}
}
21 changes: 21 additions & 0 deletions framework/SimpleModule.Core/FormRequests/FormRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Security.Claims;
using FluentValidation.Results;

namespace SimpleModule.Core.FormRequests;

public abstract class FormRequest
{
public virtual bool Authorize(ClaimsPrincipal user) => true;

public virtual void Prepare() { }

public async Task<ValidationResult> ValidateRulesAsync(
CancellationToken cancellationToken = default
)
{
Prepare();
return await ValidateAsync(cancellationToken);
}

internal abstract Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using System;

namespace SimpleModule.Core.FormRequests;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class FormRequestAttribute : Attribute { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Http;
using SimpleModule.Core.Constants;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.Validation;

namespace SimpleModule.Core.FormRequests;

public sealed class FormRequestEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next
)
{
for (var i = 0; i < context.Arguments.Count; i++)
{
if (context.Arguments[i] is not FormRequest formRequest)
continue;

if (!formRequest.Authorize(context.HttpContext.User))
{
if (context.HttpContext.Request.IsInertia())
{
return new InertiaErrorResult(
StatusCodes.Status403Forbidden,
ErrorMessages.ForbiddenTitle,
ErrorMessages.DefaultForbiddenMessage
);
}

return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: ErrorMessages.ForbiddenTitle,
detail: ErrorMessages.DefaultForbiddenMessage
);
}

formRequest.Prepare();

var result = await formRequest.ValidateAsync(context.HttpContext.RequestAborted);
if (!result.IsValid)
{
var errors = result.ToValidationErrors();

if (context.HttpContext.Request.IsInertia())
{
return new InertiaErrorResult(
StatusCodes.Status422UnprocessableEntity,
ErrorMessages.ValidationErrorTitle,
ErrorMessages.DefaultValidationMessage,
errors
);
}

return Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
title: ErrorMessages.ValidationErrorTitle,
detail: ErrorMessages.DefaultValidationMessage,
extensions: new Dictionary<string, object?> { ["errors"] = errors }
);
}
}

return await next(context);
}
}
14 changes: 14 additions & 0 deletions framework/SimpleModule.Core/FormRequests/FormRequestExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace SimpleModule.Core.FormRequests;

public static class FormRequestExtensions
{
public static RouteGroupBuilder AddFormRequestFilter(this RouteGroupBuilder group)
{
EndpointFilterExtensions.AddEndpointFilter<FormRequestEndpointFilter>(group);
return group;
}
}
29 changes: 29 additions & 0 deletions framework/SimpleModule.Core/FormRequests/FormRequest{T}.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using FluentValidation;
using FluentValidation.Results;

namespace SimpleModule.Core.FormRequests;

public abstract class FormRequest<TSelf> : FormRequest
where TSelf : FormRequest<TSelf>
{
private static volatile InlineValidator<TSelf>? _cachedValidator;

protected abstract void ConfigureRules(RuleConfigurator<TSelf> rules);

internal sealed override async Task<ValidationResult> ValidateAsync(
CancellationToken cancellationToken
)
{
var validator = _cachedValidator;
if (validator is null)
{
var configurator = new RuleConfigurator<TSelf>();
ConfigureRules(configurator);
validator = configurator.Build();
Interlocked.CompareExchange(ref _cachedValidator, validator, null);
validator = _cachedValidator;
}

return await validator.ValidateAsync((TSelf)this, cancellationToken);
}
}
25 changes: 25 additions & 0 deletions framework/SimpleModule.Core/FormRequests/RuleConfigurator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Linq.Expressions;
using FluentValidation;

namespace SimpleModule.Core.FormRequests;

public sealed class RuleConfigurator<T>
where T : class
{
private readonly InlineValidator<T> _validator = new();

public IRuleBuilderInitial<T, TProperty> RuleFor<TProperty>(
Expression<Func<T, TProperty>> expression
) => _validator.RuleFor(expression);

public IRuleBuilderInitialCollection<T, TProperty> RuleForEach<TProperty>(
Expression<Func<T, IEnumerable<TProperty>>> expression
) => _validator.RuleForEach(expression);

public void When(Func<T, bool> condition, Action action) => _validator.When(condition, action);

public void Unless(Func<T, bool> condition, Action action) =>
_validator.Unless(condition, action);

internal InlineValidator<T> Build() => _validator;
}
51 changes: 51 additions & 0 deletions framework/SimpleModule.Core/Inertia/InertiaErrorResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;

namespace SimpleModule.Core.Inertia;

public sealed class InertiaErrorResult(
int statusCode,
string title,
string message,
object? errors = null
) : IResult
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public async Task ExecuteAsync(HttpContext httpContext)
{
var component = $"Error/{statusCode}";
var props = errors is not null
? (object)
new
{
status = statusCode,
title,
message,
errors,
}
: new
{
status = statusCode,
title,
message,
};

var pageData = new
{
component,
props,
url = httpContext.Request.Path + httpContext.Request.QueryString,
version = InertiaMiddleware.Version,
};

httpContext.Response.StatusCode = statusCode;
httpContext.Response.Headers[InertiaHttpExtensions.InertiaHeader] = "true";
httpContext.Response.Headers["Vary"] = InertiaHttpExtensions.InertiaHeader;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsync(JsonSerializer.Serialize(pageData, JsonOptions));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ SM0052 | SimpleModule.Generator | Error | Module assembly name does not follow n
SM0053 | SimpleModule.Generator | Error | Module has no matching Contracts assembly
SM0054 | SimpleModule.Generator | Info | Endpoint missing Route const field
SM0055 | SimpleModule.Generator | Error | Entity class must live in a Contracts assembly
SM0056 | SimpleModule.Generator | Error | FormRequest class must be sealed
SM0057 | SimpleModule.Generator | Error | FormRequest class must extend FormRequest<TSelf>
8 changes: 8 additions & 0 deletions framework/SimpleModule.Generator/Discovery/CoreSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ internal readonly record struct CoreSymbols(
INamedTypeSymbol? ModuleFeatures,
INamedTypeSymbol? SaveChangesInterceptor,
INamedTypeSymbol? ModuleOptions,
INamedTypeSymbol? FormRequestAttribute,
INamedTypeSymbol? FormRequestBase,
bool HasAgentsAssembly,
bool HasRagAssembly
)
Expand Down Expand Up @@ -80,6 +82,12 @@ bool HasRagAssembly
"Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor"
),
ModuleOptions: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleOptions"),
FormRequestAttribute: compilation.GetTypeByMetadataName(
"SimpleModule.Core.FormRequests.FormRequestAttribute"
),
FormRequestBase: compilation.GetTypeByMetadataName(
"SimpleModule.Core.FormRequests.FormRequest`1"
),
HasAgentsAssembly: compilation.GetTypeByMetadataName(
"SimpleModule.Agents.SimpleModuleAgentExtensions"
)
Expand Down
Loading
Loading