Add validation to RealmCreate

This commit is contained in:
eelke 2026-02-08 18:00:24 +01:00
parent 09480eb1e4
commit ddbb1f42d7
16 changed files with 326 additions and 23 deletions

View file

@ -1,3 +1,5 @@
using FluentResults;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Services;
@ -6,23 +8,36 @@ using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api;
public static class RealmController
public static class RealmApi
{
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
{
var realm = app.MapGroup("/realms/{slug}");
var realmsGroup = app.MapGroup("/realms");
realmsGroup.MapPost("", RealmCreate)
.Validate<RealmCreateRequest>()
.WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
realm.MapGet("", GetRoot);
realm.MapPost("", (RealmCreateRequest request, [FromServices] RealmService service) =>
service.Create(request))
.WithName("Create Realm");
realm.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var realmSlugGroup = app.MapGroup("{slug}");
realmSlugGroup.MapGet("", GetRealmInfo);
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var openidConnect = realm.MapGroup("openid-connect");
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
openidConnect.MapPost("auth", OpenIdConnectAuth);
openidConnect.MapPost("token", OpenIdConnectToken);
openidConnect.MapGet("jwks", OpenIdConnectJwks);
}
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
{
var response = await service.Create(request);
if (response.IsSuccess)
return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value);
// TODO make helper to convert failure response to a proper HTTP result.
return TypedResults.InternalServerError();
}
private static Task OpenIdConnectJwks(HttpContext context)
{
@ -57,7 +72,7 @@ public static class RealmController
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
}
private static string GetRoot()
private static string GetRealmInfo()
{
return "Hello World!";

View file

@ -10,7 +10,12 @@
<UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId>
</PropertyGroup>
<PropertyGroup>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Serilog" Version="4.3.0" />

View file

@ -1,10 +1,13 @@
using FluentValidation;
using IdentityShroud.Api;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using Serilog;
using Serilog.Formatting.Json;
// Initial logging until we can set it up from Configuration
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
@ -34,6 +37,8 @@ void ConfigureBuilder(WebApplicationBuilder builder)
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext()
//.Enrich.With<UserEnricher>()
@ -51,3 +56,5 @@ void ConfigureApplication(WebApplication app)
// app.UseRouting();
// app.MapControllers();
}
public partial class Program { }

View file

@ -0,0 +1,7 @@
namespace IdentityShroud.Api.Validation;
public static class EndpointRouteBuilderExtensions
{
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
}

View file

@ -0,0 +1,19 @@
using FluentValidation;
using IdentityShroud.Core.Messages.Realm;
namespace IdentityShroud.Api.Validation;
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
{
private const string SlugPattern = @"^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$";
public RealmCreateRequestValidator()
{
RuleFor(x => x.Id)
.NotEqual(Guid.Empty).When(x => x.Id.HasValue);
RuleFor(x => x.Slug)
.Matches(SlugPattern).Unless(x => x.Slug is null);
RuleFor(x => x.Name)
.NotNull().Length(1, 255);
}
}

View file

@ -0,0 +1,33 @@
using FluentValidation;
namespace IdentityShroud.Api.Validation;
public class ValidateFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Grab the deserialized argument (the DTO) from the context.
// The order of arguments matches the order of parameters in the endpoint delegate.
var dto = context.Arguments
.FirstOrDefault(arg => arg is T) as T;
if (dto == null)
return Results.BadRequest("Unable to read request body.");
// Resolve the matching validator from DI.
var validator = context.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator != null)
{
var validationResult = await validator.ValidateAsync(dto);
if (!validationResult.IsValid)
return Results.ValidationProblem(validationResult.ToDictionary());
}
// Validation passed continue to the actual handler.
return await next(context);
}
}