Skip to content

Commit 2e9f486

Browse files
authored
HttpValidationProblemDetails helper (#77)
1 parent e214835 commit 2e9f486

File tree

8 files changed

+512
-45
lines changed

8 files changed

+512
-45
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using ExampleApi.Infrastructure;
3+
using IeuanWalker.MinimalApi.Endpoints;
4+
using Microsoft.AspNetCore.Http.HttpResults;
5+
6+
namespace ExampleApi.Endpoints.Validation.GetValidationErrors;
7+
8+
public class GetValidationErrorsEndpoint : IEndpointWithoutRequest<ProblemHttpResult>
9+
{
10+
[ExcludeFromCodeCoverage]
11+
public static void Configure(RouteHandlerBuilder builder)
12+
{
13+
builder
14+
.Group<ValidationEndpointGroup>()
15+
.Get("/ValidationErrors")
16+
.Version(1)
17+
.WithSummary("ValidationErrors");
18+
}
19+
20+
public Task<ProblemHttpResult> Handle(CancellationToken ct)
21+
{
22+
ProblemHttpResult result = new ValidationErrors<RequestModel>()
23+
.Add(x => x.Name, "Name is required")
24+
.Add(x => x.Nested.Description, "Description is required")
25+
.ToProblemResponse();
26+
27+
return Task.FromResult(result);
28+
}
29+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace ExampleApi.Endpoints.Validation.GetValidationErrors;
4+
5+
[ExcludeFromCodeCoverage]
6+
public class RequestModel
7+
{
8+
public string Name { get; set; } = string.Empty;
9+
public NestedRequest Nested { get; set; } = new();
10+
}
11+
12+
[ExcludeFromCodeCoverage]
13+
public class NestedRequest
14+
{
15+
public string Description { get; set; } = string.Empty;
16+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using System.Linq.Expressions;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Http.HttpResults;
4+
5+
namespace IeuanWalker.MinimalApi.Endpoints;
6+
7+
/// <summary>
8+
/// Collects validation errors and formats them as HTTP problem details.
9+
/// </summary>
10+
/// <typeparam name="T">The request model type used for property expressions.</typeparam>
11+
public sealed class ValidationErrors<T>
12+
{
13+
readonly Dictionary<string, List<string>> _errors = [];
14+
15+
/// <summary>
16+
/// Returns true when at least one error has been added.
17+
/// </summary>
18+
public bool HasErrors()
19+
{
20+
return _errors.Count != 0;
21+
}
22+
23+
/// <summary>
24+
/// Adds one or more error messages for a property expression.
25+
/// </summary>
26+
/// <param name="property">The property expression to build the error key.</param>
27+
/// <param name="messages">One or more error messages.</param>
28+
/// <returns>The same <see cref="ValidationErrors{T}"/> instance for chaining.</returns>
29+
/// <exception cref="ArgumentException">Thrown when no messages are provided or the expression is invalid.</exception>
30+
public ValidationErrors<T> Add(Expression<Func<T, object>> property, params string[] messages)
31+
{
32+
ValidateMessages(messages);
33+
string name = GetPropertyPath(property.Body);
34+
return Add(name, messages);
35+
}
36+
37+
/// <summary>
38+
/// Adds one or more error messages for a specific key.
39+
/// </summary>
40+
/// <param name="key">The error key to associate with the messages.</param>
41+
/// <param name="messages">One or more error messages.</param>
42+
/// <returns>The same <see cref="ValidationErrors{T}"/> instance for chaining.</returns>
43+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="key"/> is null or whitespace.</exception>
44+
/// <exception cref="ArgumentException">Thrown when no messages are provided.</exception>
45+
public ValidationErrors<T> Add(string key, params string[] messages)
46+
{
47+
ArgumentNullException.ThrowIfNullOrWhiteSpace(key, nameof(key));
48+
ValidateMessages(messages);
49+
if (!_errors.TryGetValue(key, out List<string>? list))
50+
{
51+
list = [];
52+
_errors[key] = list;
53+
}
54+
list.AddRange(messages);
55+
return this;
56+
}
57+
58+
/// <summary>
59+
/// Creates <see cref="HttpValidationProblemDetails"/> from the collected errors.
60+
/// </summary>
61+
/// <returns>A validation problem details object.</returns>
62+
public HttpValidationProblemDetails ToProblemDetails()
63+
{
64+
Dictionary<string, string[]> errorDict = _errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray());
65+
return new HttpValidationProblemDetails(errorDict);
66+
}
67+
68+
/// <summary>
69+
/// Returns a problem response containing the validation errors.
70+
/// </summary>
71+
/// <returns>A <see cref="ProblemHttpResult"/> with validation details.</returns>
72+
public ProblemHttpResult ToProblemResponse()
73+
{
74+
return TypedResults.Problem(ToProblemDetails());
75+
}
76+
77+
static string GetPropertyPath(Expression expression)
78+
{
79+
Stack<string> members = new();
80+
while (expression is MemberExpression memberExpr)
81+
{
82+
members.Push(memberExpr.Member.Name);
83+
expression = memberExpr.Expression!;
84+
}
85+
if (expression is ParameterExpression)
86+
{
87+
return string.Join(".", members);
88+
}
89+
90+
if (expression is UnaryExpression unary && unary.Operand is MemberExpression)
91+
{
92+
return GetPropertyPath(unary.Operand);
93+
}
94+
95+
if (expression is MethodCallExpression call && call.Method.Name == "get_Item")
96+
{
97+
if (call.Object is MemberExpression memberObject)
98+
{
99+
string basePath = GetPropertyPath(memberObject);
100+
string indexedPath = $"{basePath}[{call.Arguments[0]}]";
101+
if (members.Count == 0)
102+
{
103+
return indexedPath;
104+
}
105+
106+
return $"{indexedPath}.{string.Join(".", members)}";
107+
}
108+
109+
throw new ArgumentException("Indexer expressions are only supported on direct member access, e.g. x => x.Items[0]");
110+
}
111+
112+
throw new ArgumentException("Expression must select a (possibly nested) property, e.g. x => x.Prop1.Prop2");
113+
}
114+
115+
static void ValidateMessages(string[] messages)
116+
{
117+
if (messages.Length == 0)
118+
{
119+
throw new ArgumentException("At least one validation message must be provided.", nameof(messages));
120+
}
121+
}
122+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
4+
namespace ExampleApi.IntegrationTests.Endpoints.Validation;
5+
6+
public class ValidationErrorsTests : IClassFixture<ExampleApiWebApplicationFactory>
7+
{
8+
readonly HttpClient _client;
9+
10+
public ValidationErrorsTests(ExampleApiWebApplicationFactory factory)
11+
{
12+
_client = factory.CreateClient();
13+
}
14+
15+
[Fact]
16+
public async Task GetValidationErrors_ReturnsProblemDetailsWithErrors()
17+
{
18+
// Act
19+
HttpResponseMessage response = await _client.GetAsync("/api/v1/validation/ValidationErrors");
20+
21+
// Assert
22+
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
23+
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/problem+json");
24+
25+
string content = await response.Content.ReadAsStringAsync();
26+
using JsonDocument json = JsonDocument.Parse(content);
27+
28+
JsonElement errors = json.RootElement.GetProperty("errors");
29+
errors.TryGetProperty("Name", out JsonElement nameErrors).ShouldBeTrue();
30+
nameErrors[0].GetString().ShouldBe("Name is required");
31+
32+
errors.TryGetProperty("Nested.Description", out JsonElement nestedErrors).ShouldBeTrue();
33+
nestedErrors[0].GetString().ShouldBe("Description is required");
34+
}
35+
}

tests/ExampleApi.IntegrationTests/ExpectedOpenApi.json

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,13 +1723,27 @@
17231723
}
17241724
}
17251725
},
1726+
"/api/v1/validation/ValidationErrors": {
1727+
"get": {
1728+
"tags": [
1729+
"Validation"
1730+
],
1731+
"summary": "ValidationErrors",
1732+
"operationId": "get_ValidationValidationErrors_21",
1733+
"responses": {
1734+
"200": {
1735+
"description": "OK"
1736+
}
1737+
}
1738+
}
1739+
},
17261740
"/api/v1/validation/DataValidation": {
17271741
"post": {
17281742
"tags": [
17291743
"Validation"
17301744
],
17311745
"summary": "DataAnnotationsFromBody",
1732-
"operationId": "post_ValidationDataValidation_21",
1746+
"operationId": "post_ValidationDataValidation_22",
17331747
"requestBody": {
17341748
"content": {
17351749
"application/json": {
@@ -1752,7 +1766,7 @@
17521766
"Validation"
17531767
],
17541768
"summary": "FluentValidationFromBody",
1755-
"operationId": "post_ValidationFluentValidation_22",
1769+
"operationId": "post_ValidationFluentValidation_23",
17561770
"requestBody": {
17571771
"content": {
17581772
"application/json": {
@@ -1782,7 +1796,7 @@
17821796
"Validation"
17831797
],
17841798
"summary": "FluentValidationFromForm",
1785-
"operationId": "post_ValidationFluentValidationFromForm_23",
1799+
"operationId": "post_ValidationFluentValidationFromForm_24",
17861800
"requestBody": {
17871801
"content": {
17881802
"multipart/form-data": {
@@ -1818,7 +1832,7 @@
18181832
"Validation"
18191833
],
18201834
"summary": "FluentValidationEdgeCases",
1821-
"operationId": "post_ValidationFluentValidationEdgeCases_24",
1835+
"operationId": "post_ValidationFluentValidationEdgeCases_25",
18221836
"requestBody": {
18231837
"content": {
18241838
"application/json": {
@@ -1848,7 +1862,7 @@
18481862
"Validation"
18491863
],
18501864
"summary": "ManualWithValidation",
1851-
"operationId": "post_ValidationWithValidation_25",
1865+
"operationId": "post_ValidationWithValidation_26",
18521866
"requestBody": {
18531867
"content": {
18541868
"application/json": {
@@ -1871,7 +1885,7 @@
18711885
"Validation"
18721886
],
18731887
"summary": "WithValidationAlterAndRemove",
1874-
"operationId": "post_ValidationWithValidationAlterAndRemove_26",
1888+
"operationId": "post_ValidationWithValidationAlterAndRemove_27",
18751889
"requestBody": {
18761890
"content": {
18771891
"multipart/form-data": {

tests/ExampleApi.IntegrationTests/Infrastructure/OpenApiTests.Validation.cs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ public async Task OpenApiJson_ValidValidationStructure()
451451
}
452452
}
453453
},
454-
"/api/v1/validation/FluentValidation/FromQuery": {
454+
"/api/v1/validation/FluentValidation/FromQuery": {
455455
"get": {
456456
"tags": [
457457
"Validation"
@@ -748,13 +748,27 @@ public async Task OpenApiJson_ValidValidationStructure()
748748
}
749749
}
750750
},
751-
"/api/v1/validation/DataValidation": {
751+
"/api/v1/validation/ValidationErrors": {
752+
"get": {
753+
"tags": [
754+
"Validation"
755+
],
756+
"summary": "ValidationErrors",
757+
"operationId": "get_ValidationValidationErrors_21",
758+
"responses": {
759+
"200": {
760+
"description": "OK"
761+
}
762+
}
763+
}
764+
},
765+
"/api/v1/validation/DataValidation": {
752766
"post": {
753767
"tags": [
754768
"Validation"
755769
],
756770
"summary": "DataAnnotationsFromBody",
757-
"operationId": "post_ValidationDataValidation_21",
771+
"operationId": "post_ValidationDataValidation_22",
758772
"requestBody": {
759773
"content": {
760774
"application/json": {
@@ -777,7 +791,7 @@ public async Task OpenApiJson_ValidValidationStructure()
777791
"Validation"
778792
],
779793
"summary": "FluentValidationFromBody",
780-
"operationId": "post_ValidationFluentValidation_22",
794+
"operationId": "post_ValidationFluentValidation_23",
781795
"requestBody": {
782796
"content": {
783797
"application/json": {
@@ -807,7 +821,7 @@ public async Task OpenApiJson_ValidValidationStructure()
807821
"Validation"
808822
],
809823
"summary": "FluentValidationFromForm",
810-
"operationId": "post_ValidationFluentValidationFromForm_23",
824+
"operationId": "post_ValidationFluentValidationFromForm_24",
811825
"requestBody": {
812826
"content": {
813827
"multipart/form-data": {
@@ -843,7 +857,7 @@ public async Task OpenApiJson_ValidValidationStructure()
843857
"Validation"
844858
],
845859
"summary": "FluentValidationEdgeCases",
846-
"operationId": "post_ValidationFluentValidationEdgeCases_24",
860+
"operationId": "post_ValidationFluentValidationEdgeCases_25",
847861
"requestBody": {
848862
"content": {
849863
"application/json": {
@@ -873,7 +887,7 @@ public async Task OpenApiJson_ValidValidationStructure()
873887
"Validation"
874888
],
875889
"summary": "ManualWithValidation",
876-
"operationId": "post_ValidationWithValidation_25",
890+
"operationId": "post_ValidationWithValidation_26",
877891
"requestBody": {
878892
"content": {
879893
"application/json": {
@@ -896,7 +910,7 @@ public async Task OpenApiJson_ValidValidationStructure()
896910
"Validation"
897911
],
898912
"summary": "WithValidationAlterAndRemove",
899-
"operationId": "post_ValidationWithValidationAlterAndRemove_26",
913+
"operationId": "post_ValidationWithValidationAlterAndRemove_27",
900914
"requestBody": {
901915
"content": {
902916
"multipart/form-data": {

0 commit comments

Comments
 (0)