Skip to content

Commit 9cdcda5

Browse files
authored
Merge pull request #12 from sheepla/devin/1777622660-fix-discovered-issues
Fix discovered design issues: AsParameters defaults, CsvBindingExceptionFilter, dead code
2 parents e8767a8 + 274f519 commit 9cdcda5

8 files changed

Lines changed: 107 additions & 84 deletions

File tree

src/AxisEndpoints.Extensions.CsvHelper/CsvBindingExceptionFilter.cs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
namespace AxisEndpoints.Extensions.CsvHelper;
44

55
/// <summary>
6-
/// An endpoint filter that catches <see cref="CsvBindingException"/> thrown during CSV
7-
/// request binding and converts it to an RFC 9457 <c>ValidationProblem</c> response,
8-
/// consistent with the error shape produced by AxisEndpoints' built-in
6+
/// An endpoint filter that inspects <see cref="CsvRequest{TRow}.BindingErrors"/> collected
7+
/// during CSV request binding and converts them to an RFC 9457 <c>ValidationProblem</c>
8+
/// response, consistent with the error shape produced by AxisEndpoints' built-in
99
/// DataAnnotations validation filter.
1010
///
11+
/// Because <c>BindAsync</c> executes before the endpoint filter pipeline, the previous
12+
/// approach of catching <see cref="CsvBindingException"/> in a try/catch could never work.
13+
/// This filter instead checks the bound request argument for deferred errors.
14+
///
1115
/// Register this filter on endpoints that accept a <see cref="CsvRequest{TRow}"/> parameter:
1216
/// <code>
1317
/// config.Post("/import").AddFilter&lt;CsvBindingExceptionFilter&gt;();
@@ -20,13 +24,14 @@ public sealed class CsvBindingExceptionFilter : IEndpointFilter
2024
EndpointFilterDelegate next
2125
)
2226
{
23-
try
24-
{
25-
return await next(context);
26-
}
27-
catch (CsvBindingException ex)
27+
foreach (var argument in context.Arguments)
2828
{
29-
return TypedResults.ValidationProblem(ex.Errors);
29+
if (argument is ICsvBindingErrors { BindingErrors: { Count: > 0 } errors })
30+
{
31+
return TypedResults.ValidationProblem(errors);
32+
}
3033
}
34+
35+
return await next(context);
3136
}
3237
}

src/AxisEndpoints.Extensions.CsvHelper/CsvRequest.cs

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,23 @@ namespace AxisEndpoints.Extensions.CsvHelper;
2626
/// derived class to customise CsvHelper behaviour without touching the binding logic.
2727
///
2828
/// DataAnnotations placed on <typeparamref name="TRow"/> are validated per-row during binding.
29-
/// Errors are collected across all rows and surfaced as a <see cref="CsvBindingException"/>
30-
/// before the endpoint handler is invoked.
29+
/// Errors are collected across all rows and stored in <see cref="BindingErrors"/> so that
30+
/// <see cref="CsvBindingExceptionFilter"/> can inspect them inside the endpoint filter pipeline.
3131
/// </summary>
3232
/// <typeparam name="TRow">The strongly-typed row model.</typeparam>
33-
public abstract class CsvRequest<TRow>
33+
public abstract class CsvRequest<TRow> : ICsvBindingErrors
3434
{
3535
/// <summary>The rows parsed from the CSV body. Empty when the body contained no data rows.</summary>
3636
public IReadOnlyList<TRow> Rows { get; private set; } = [];
3737

38+
/// <summary>
39+
/// Validation errors collected during binding, keyed by "row {n}: {memberName}".
40+
/// Empty when all rows pass validation. Inspected by <see cref="CsvBindingExceptionFilter"/>
41+
/// to return a <c>ValidationProblem</c> response before the handler is invoked.
42+
/// </summary>
43+
public IReadOnlyDictionary<string, string[]> BindingErrors { get; private set; } =
44+
new Dictionary<string, string[]>();
45+
3846
// -------------------------------------------------------------------------
3947
// Binding helper — call this from the derived class's BindAsync
4048
// -------------------------------------------------------------------------
@@ -73,7 +81,7 @@ protected static async ValueTask<TDerived> BindCsvAsync<TDerived>(HttpContext co
7381

7482
if (errors.Count > 0)
7583
{
76-
throw new CsvBindingException(errors);
84+
instance.BindingErrors = errors;
7785
}
7886

7987
instance.Rows = rows;
@@ -113,57 +121,61 @@ private static async Task<Stream> ResolveBodyStreamAsync(HttpContext context)
113121
{
114122
var contentType = context.Request.ContentType ?? string.Empty;
115123

116-
if (contentType.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase))
124+
if (contentType.Contains("multipart/form-data", StringComparison.OrdinalIgnoreCase))
117125
{
118126
var form = await context.Request.ReadFormAsync();
119127
var file =
120128
form.Files.FirstOrDefault()
121-
?? throw new InvalidOperationException(
122-
"No file was found in the multipart/form-data request."
129+
?? throw new BadHttpRequestException(
130+
"Expected a CSV file in the multipart/form-data request."
123131
);
124132
return file.OpenReadStream();
125133
}
126134

127-
// Fall through for text/csv and application/octet-stream.
128-
return context.Request.Body;
135+
// Assume the body itself is the CSV content (text/csv or similar).
136+
// Return the body stream wrapped in a MemoryStream so callers can dispose it
137+
// without closing the original request body.
138+
var ms = new MemoryStream();
139+
await context.Request.Body.CopyToAsync(ms);
140+
ms.Position = 0;
141+
return ms;
129142
}
130143

131144
/// <summary>
132-
/// Runs DataAnnotations validation against a single parsed row.
133-
/// Errors are keyed as "row {rowNumber}: {memberName}" to surface the source line.
145+
/// Validates a single row using DataAnnotations and appends any errors to the
146+
/// <paramref name="errors"/> dictionary keyed by <c>"row {rowNumber}: {memberName}"</c>.
134147
/// </summary>
135-
private static void ValidateRow(TRow row, int rowNumber, Dictionary<string, string[]> errors)
148+
private static void ValidateRow(
149+
TRow row,
150+
int rowNumber,
151+
Dictionary<string, string[]> errors
152+
)
136153
{
137154
if (row is null)
138155
{
139156
return;
140157
}
141158

142-
var validationContext = new ValidationContext(row);
143-
var validationResults = new List<ValidationResult>();
144-
145-
if (
146-
Validator.TryValidateObject(
147-
row,
148-
validationContext,
149-
validationResults,
150-
validateAllProperties: true
151-
)
152-
)
159+
var context = new ValidationContext(row);
160+
var results = new List<ValidationResult>();
161+
162+
if (Validator.TryValidateObject(row, context, results, validateAllProperties: true))
153163
{
154164
return;
155165
}
156166

157-
foreach (var result in validationResults)
167+
foreach (var result in results)
158168
{
159-
foreach (var member in result.MemberNames.DefaultIfEmpty(string.Empty))
169+
var memberName = result.MemberNames.FirstOrDefault() ?? "(unknown)";
170+
var key = $"row {rowNumber}: {memberName}";
171+
var message = result.ErrorMessage ?? "Validation failed.";
172+
if (errors.TryGetValue(key, out var existing))
160173
{
161-
var key = $"row {rowNumber}: {member}";
162-
var message = result.ErrorMessage ?? "Validation failed.";
163-
164-
errors[key] = errors.TryGetValue(key, out var existing)
165-
? [.. existing, message]
166-
: [message];
174+
errors[key] = [..existing, message];
175+
}
176+
else
177+
{
178+
errors[key] = [message];
167179
}
168180
}
169181
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace AxisEndpoints.Extensions.CsvHelper;
2+
3+
/// <summary>
4+
/// Non-generic interface exposing CSV binding validation errors. Implemented by
5+
/// <see cref="CsvRequest{TRow}"/> so that <see cref="CsvBindingExceptionFilter"/>
6+
/// can inspect binding errors without knowing the concrete generic type.
7+
/// </summary>
8+
public interface ICsvBindingErrors
9+
{
10+
/// <summary>
11+
/// Validation errors collected during binding, keyed by "row {n}: {memberName}".
12+
/// Empty when all rows pass validation.
13+
/// </summary>
14+
IReadOnlyDictionary<string, string[]> BindingErrors { get; }
15+
}

tests/AxisEndpoints.Example.Tests/CsvEndpointTests.cs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.Net;
22
using System.Text;
3-
using AxisEndpoints.Extensions.CsvHelper;
43
using FluentAssertions;
54

65
namespace AxisEndpoints.Example.Tests;
@@ -49,22 +48,14 @@ public async Task ImportCsv_ValidCsv_Returns204()
4948
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
5049
}
5150

52-
/// <summary>
53-
/// CsvBindingException is thrown during BindAsync (parameter binding phase),
54-
/// which executes before the endpoint filter pipeline. As a result,
55-
/// CsvBindingExceptionFilter cannot catch it and the exception propagates
56-
/// as an unhandled server error.
57-
/// </summary>
5851
[Fact]
59-
public async Task ImportCsv_InvalidRow_ThrowsCsvBindingException()
52+
public async Task ImportCsv_InvalidRow_Returns400ValidationProblem()
6053
{
6154
var csvContent = "name,email,role\n,invalid-email,";
6255
var content = new StringContent(csvContent, Encoding.UTF8, "text/csv");
6356

64-
var act = () => _client.PostAsync("/api/users/users/import", content);
57+
var response = await _client.PostAsync("/api/users/users/import", content);
6558

66-
await act.Should()
67-
.ThrowAsync<Exception>()
68-
.Where(ex => ex.ToString().Contains(nameof(CsvBindingException)));
59+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
6960
}
7061
}

tests/AxisEndpoints.Example.Tests/ListUsersEndpointTests.cs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using AxisEndpoints.Example.Features.Users;
44
using AxisEndpoints.Example.Features.Users.List;
55
using FluentAssertions;
6+
using Microsoft.AspNetCore.Mvc;
67

78
namespace AxisEndpoints.Example.Tests;
89

@@ -18,7 +19,7 @@ public ListUsersEndpointTests(ExampleWebApplicationFactory factory)
1819
[Fact]
1920
public async Task ListUsers_DefaultPagination_ReturnsAllUsers()
2021
{
21-
var response = await _client.GetAsync("/api/users?page=1&pageSize=20");
22+
var response = await _client.GetAsync("/api/users");
2223

2324
response.StatusCode.Should().Be(HttpStatusCode.OK);
2425
var body = await response.Content.ReadFromJsonAsync<ListUsersResponse>();
@@ -30,7 +31,7 @@ public async Task ListUsers_DefaultPagination_ReturnsAllUsers()
3031
}
3132

3233
[Fact]
33-
public async Task ListUsers_WithPagination_ReturnsPagedResults()
34+
public async Task ListUsers_WithExplicitPagination_ReturnsPagedResults()
3435
{
3536
var response = await _client.GetAsync("/api/users?page=1&pageSize=2");
3637

@@ -46,7 +47,7 @@ public async Task ListUsers_WithPagination_ReturnsPagedResults()
4647
[Fact]
4748
public async Task ListUsers_WithRoleFilter_ReturnsFilteredResults()
4849
{
49-
var response = await _client.GetAsync("/api/users?role=Admin&page=1&pageSize=20");
50+
var response = await _client.GetAsync("/api/users?role=Admin");
5051

5152
response.StatusCode.Should().Be(HttpStatusCode.OK);
5253
var body = await response.Content.ReadFromJsonAsync<ListUsersResponse>();
@@ -70,17 +71,25 @@ public async Task ListUsers_SecondPage_ReturnsCorrectItems()
7071
body.Items[1].Name.Should().Be("Diana");
7172
}
7273

73-
/// <summary>
74-
/// When page/pageSize query parameters are omitted, [AsParameters] binding
75-
/// uses the CLR default (0) instead of the property initializer values (1, 20).
76-
/// DataAnnotations [Range(1, ...)] validation then rejects 0, returning 400.
77-
/// This is a known limitation of [AsParameters] with value-type defaults.
78-
/// </summary>
7974
[Fact]
80-
public async Task ListUsers_OmittedPaginationParams_Returns400DueToDefaultValueLimitation()
75+
public async Task ListUsers_InvalidPage_Returns400()
8176
{
82-
var response = await _client.GetAsync("/api/users");
77+
var response = await _client.GetAsync("/api/users?page=0");
78+
79+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
80+
var body = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
81+
body.Should().NotBeNull();
82+
body!.Errors.Should().ContainKey("Page");
83+
}
84+
85+
[Fact]
86+
public async Task ListUsers_InvalidPageSize_Returns400()
87+
{
88+
var response = await _client.GetAsync("/api/users?pageSize=0");
8389

8490
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
91+
var body = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
92+
body.Should().NotBeNull();
93+
body!.Errors.Should().ContainKey("PageSize");
8594
}
8695
}

tests/AxisEndpoints.Example/Features/Users/FindById/FindUserByIdRequest.cs

Lines changed: 0 additions & 15 deletions
This file was deleted.

tests/AxisEndpoints.Example/Features/Users/List/ListUsersEndpoint.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public Task<Response<ListUsersResponse>> HandleAsync(
3232
CancellationToken cancel
3333
)
3434
{
35+
var page = request.Page ?? 1;
36+
var pageSize = request.PageSize ?? 20;
37+
3538
// Dummy data — a real implementation would query a repository.
3639
var allUsers = new List<UserResponse>
3740
{
@@ -70,8 +73,8 @@ CancellationToken cancel
7073
: allUsers;
7174

7275
var items = filtered
73-
.Skip((request.Page - 1) * request.PageSize)
74-
.Take(request.PageSize)
76+
.Skip((page - 1) * pageSize)
77+
.Take(pageSize)
7578
.ToList();
7679

7780
return Task.FromResult(
@@ -80,8 +83,8 @@ CancellationToken cancel
8083
Body = new ListUsersResponse
8184
{
8285
Items = items,
83-
Page = request.Page,
84-
PageSize = request.PageSize,
86+
Page = page,
87+
PageSize = pageSize,
8588
TotalCount = filtered.Count,
8689
},
8790
}

tests/AxisEndpoints.Example/Features/Users/List/ListUsersRequest.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ namespace AxisEndpoints.Example.Features.Users.List;
55

66
/// <summary>
77
/// Demonstrates [FromQuery] binding with multiple parameters and DataAnnotations on query values.
8+
/// Page and PageSize are nullable so that [AsParameters] binding correctly distinguishes
9+
/// "not provided" (null) from an explicit value — property initializers on value types
10+
/// are ignored by [AsParameters], which would otherwise default to 0.
811
/// </summary>
912
public class ListUsersRequest
1013
{
1114
[FromQuery]
1215
[Range(1, int.MaxValue, ErrorMessage = "Page must be 1 or greater.")]
13-
public int Page { get; init; } = 1;
16+
public int? Page { get; init; }
1417

1518
[FromQuery]
1619
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
17-
public int PageSize { get; init; } = 20;
20+
public int? PageSize { get; init; }
1821

1922
/// <summary>Optional role filter. Returns all roles when omitted.</summary>
2023
[FromQuery]

0 commit comments

Comments
 (0)