Skip to content
This repository was archived by the owner on Aug 30, 2025. It is now read-only.

Commit 089cee3

Browse files
committed
feat: Add human-readable description in error list
BREAKING CHANGE: Change the structure of error list in `ErrorProblemDetails`
1 parent 27b1ad5 commit 089cee3

24 files changed

+303
-149
lines changed

README.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,15 @@ public class Startup
108108
{
109109
services.AddErrorHandler(options =>
110110
{
111-
options.ExceptionMapper<ModelStatesException>(exception => (exception.Status, exception.Errors));
112-
options.ExceptionMapper<TimeoutException>(exception => StatusCodes.Status504GatewayTimeout);
111+
options.ExceptionMapper<DuplicatedException>(exception => StatusCodes.Status409Conflict);
112+
113+
options.ExceptionMapper<ModelStatesException>(exception => (
114+
exception.Status,
115+
exception.Errors.ToDictionary(
116+
k => k.Key,
117+
v => new ErrorDetails(v.Value, exception.Message)
118+
)
119+
));
113120
});
114121
}
115122
}
@@ -141,9 +148,9 @@ public class HomeController : ControllerBase
141148
type: "type",
142149
errors: new Dictionary<string, string>
143150
{
144-
["Property1"] = "Error1",
145-
["Property2"] = "Error2",
146-
["Property3"] = "Error3",
151+
["Property1"] = new("Error1", "Message1"),
152+
["Property2"] = new("Error2", "Message2"),
153+
["Property3"] = new("Error3", "Message3"),
147154
}
148155
);
149156

@@ -157,9 +164,9 @@ public class HomeController : ControllerBase
157164
type: "type",
158165
errors: new Dictionary<string, string>
159166
{
160-
["Property1"] = "Error1",
161-
["Property2"] = "Error2",
162-
["Property3"] = "Error3",
167+
["Property1"] = new("Error1", "Message1"),
168+
["Property2"] = new("Error2", "Message2"),
169+
["Property3"] = new("Error3", "Message3"),
163170
}
164171
));
165172
}

samples/PowerUtils.AspNetCore.ErrorHandler.Samples/Controllers/ExceptionsController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public IActionResult AggregateExceptionWithTwoInnerExceptions()
3131

3232
[HttpGet("not-found")]
3333
public IActionResult NotFoundException()
34-
=> throw new NotFoundException();
34+
=> throw new NotFoundException("The entity does not exist");
3535

3636
[HttpGet("duplicated")]
3737
public IActionResult DuplicatedException()

samples/PowerUtils.AspNetCore.ErrorHandler.Samples/Controllers/ProblemDetailsController.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public IActionResult GetResultProblem()
4848
statusCode: 409,
4949
extensions: new Dictionary<string, object>
5050
{
51-
["Errors"] = "fake struct"
51+
["Prop"] = new ErrorDetails("struct", "disc fake")
5252
}
5353
)
5454
);
@@ -60,9 +60,9 @@ public IActionResult GetErrorProblem()
6060
new ErrorProblemDetails
6161
{
6262
Status = 409,
63-
Errors = new Dictionary<string, string>()
63+
Errors = new Dictionary<string, ErrorDetails>()
6464
{
65-
["Errors"] = "fake struct"
65+
["Prop"] = new("fake struct", "disc")
6666
}
6767
}
6868
);

samples/PowerUtils.AspNetCore.ErrorHandler.Samples/Controllers/ProblemFactoryController.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ public IActionResult CreateProblemResult()
2222
statusCode: (int)HttpStatusCode.Forbidden,
2323
title: "some title",
2424
type: "some type",
25-
errors: new Dictionary<string, string>
25+
errors: new Dictionary<string, ErrorDetails>()
2626
{
27-
["Key4"] = "Error4",
28-
["Key14"] = "Error124",
27+
["Key4"] = new("Error4", "description 111"),
28+
["Key14"] = new("Error124", "description 423423")
2929
}
3030
);
3131

@@ -37,11 +37,11 @@ public IActionResult CreateProblem()
3737
statusCode: (int)HttpStatusCode.TooManyRequests,
3838
title: "fake title",
3939
type: "fake type",
40-
errors: new Dictionary<string, string>
40+
errors: new Dictionary<string, ErrorDetails>()
4141
{
42-
["Key100"] = "Error114",
43-
["Key114"] = "Error11124",
44-
["me"] = "ti"
42+
["Key100"] = new("Error114", "description fake"),
43+
["Key114"] = new("Error11124", "description 1444"),
44+
["me"] = new("ti", "111"),
4545
}
4646
));
4747
}

samples/PowerUtils.AspNetCore.ErrorHandler.Samples/Exceptions/CustomException.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@
22

33
namespace PowerUtils.AspNetCore.ErrorHandler.Samples.Exceptions
44
{
5-
public class CustomException : Exception { }
5+
public class CustomException : Exception
6+
{
7+
public CustomException()
8+
: base("custom exception")
9+
{ }
10+
}
611
}

samples/PowerUtils.AspNetCore.ErrorHandler.Samples/Exceptions/ModelStatesException.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ public abstract class ModelStatesException : Exception
88
{
99
public int Status { get; set; }
1010
public IDictionary<string, string> Errors { get; set; }
11+
12+
public ModelStatesException() { }
13+
14+
public ModelStatesException(string message)
15+
: base(message) { }
1116
}
1217

1318
public class NotFoundException : ModelStatesException
1419
{
15-
public NotFoundException()
20+
public NotFoundException() { }
21+
22+
public NotFoundException(string message)
23+
: base(message)
1624
{
1725
Status = StatusCodes.Status404NotFound;
1826
Errors = new Dictionary<string, string>()
@@ -24,7 +32,7 @@ public NotFoundException()
2432

2533
public class DuplicatedException : ModelStatesException
2634
{
27-
public DuplicatedException()
35+
public DuplicatedException() : base("double")
2836
{
2937
Status = StatusCodes.Status409Conflict;
3038
Errors = new Dictionary<string, string>()
@@ -33,4 +41,11 @@ public DuplicatedException()
3341
};
3442
}
3543
}
44+
45+
46+
public abstract class PropertyException : Exception
47+
{ // TODO: to finish
48+
public string Property { get; set; }
49+
public string Code { get; set; }
50+
}
3651
}

samples/PowerUtils.AspNetCore.ErrorHandler.Samples/Startup.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using Microsoft.AspNetCore.Authentication;
34
using Microsoft.AspNetCore.Authentication.JwtBearer;
45
using Microsoft.AspNetCore.Builder;
@@ -43,8 +44,16 @@ public void ConfigureServices(IServiceCollection services)
4344
{
4445
options.PropertyNamingPolicy = PropertyNamingPolicy.SnakeCase;
4546

46-
options.ExceptionMapper<ModelStatesException>(exception => 599); // Only to test the override a mapping
47-
options.ExceptionMapper<ModelStatesException>(exception => (exception.Status, exception.Errors));
47+
options.ExceptionMapper<PropertyException>(exception => 599); // Only to test the override a mapping
48+
//options.ExceptionMapper<PropertyException>(exception => (exception.Status, new(exception.Property, new()))); // TODO: to finish
49+
50+
options.ExceptionMapper<ModelStatesException>(exception => (
51+
exception.Status,
52+
exception.Errors.ToDictionary(
53+
k => k.Key,
54+
v => new ErrorDetails(v.Value, exception.Message)
55+
)
56+
));
4857

4958
options.ExceptionMapper<CustomException>(exception => 582);
5059

src/ApiProblemDetailsFactory.cs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using Microsoft.AspNetCore.Http;
45
using Microsoft.AspNetCore.Mvc;
56
using Microsoft.AspNetCore.Mvc.Infrastructure;
@@ -34,7 +35,7 @@ public ObjectResult CreateProblemResult(
3435
int? statusCode = null,
3536
string title = null,
3637
string type = null,
37-
IDictionary<string, string> errors = null
38+
IDictionary<string, ErrorDetails> errors = null
3839
) => new ObjectResult(CreateProblem(
3940
detail,
4041
instance,
@@ -50,7 +51,7 @@ public ErrorProblemDetails CreateProblem(
5051
int? statusCode = null,
5152
string title = null,
5253
string type = null,
53-
IDictionary<string, string> errors = null
54+
IDictionary<string, ErrorDetails> errors = null
5455
)
5556
{
5657
var problemDetails = new ErrorProblemDetails
@@ -63,6 +64,11 @@ public ErrorProblemDetails CreateProblem(
6364
Errors = errors
6465
};
6566

67+
if(string.IsNullOrWhiteSpace(problemDetails.Detail))
68+
{
69+
problemDetails.Detail = errors?.FirstOrDefault().Value?.Description;
70+
}
71+
6672
_applyDefaults(_httpContextAccessor.HttpContext, problemDetails);
6773

6874
return problemDetails;
@@ -77,20 +83,24 @@ internal ErrorProblemDetails Create(HttpContext httpContext)
7783
return problemDetails;
7884
}
7985

80-
public ErrorProblemDetails Create(HttpContext httpContext, IEnumerable<KeyValuePair<string, string>> errors)
86+
public ErrorProblemDetails Create(HttpContext httpContext, IEnumerable<KeyValuePair<string, ErrorDetails>> errors)
8187
{
82-
var result = Create(httpContext);
88+
var problemDetails = new ErrorProblemDetails();
8389

8490
foreach(var error in errors)
8591
{
86-
result.Errors
92+
problemDetails.Errors
8793
.Add(
8894
_formatPropertyName(error.Key),
8995
error.Value
9096
);
9197
}
9298

93-
return result;
99+
problemDetails.Detail = errors?.FirstOrDefault().Value?.Description;
100+
101+
_applyDefaults(httpContext, problemDetails);
102+
103+
return problemDetails;
94104
}
95105

96106
internal ErrorProblemDetails Create(ActionContext actionContext)
@@ -208,6 +218,8 @@ private static void _applyDetail(ProblemDetails problemDetails)
208218
404 => "The entity was not found.",
209219
409 => "The entity already exists.",
210220

221+
501 => "The feature has not been implemented.",
222+
211223
_ when problemDetails.Status >= 400 && problemDetails.Status < 500 => "One or more validation errors occurred.",
212224

213225
_ => "An unexpected error has occurred."

src/ErrorHandlerOptions.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@ namespace PowerUtils.AspNetCore.ErrorHandler
66
{
77
public interface IExceptionMapper
88
{
9-
(int Status, IEnumerable<KeyValuePair<string, string>> Errors) Handle(Exception exception);
9+
(int Status, IEnumerable<KeyValuePair<string, ErrorDetails>> Errors) Handle(Exception exception);
1010
}
1111

1212

1313
public class ExceptionMapper<TException> : IExceptionMapper
1414
where TException : Exception
1515
{
16-
public Func<TException, (int Status, IEnumerable<KeyValuePair<string, string>>)> Handler { get; set; }
16+
public Func<TException, (int Status, IEnumerable<KeyValuePair<string, ErrorDetails>>)> Handler { get; set; }
1717

1818

19-
public (int Status, IEnumerable<KeyValuePair<string, string>> Errors) Handle(Exception exception)
19+
public (int Status, IEnumerable<KeyValuePair<string, ErrorDetails>> Errors) Handle(Exception exception)
2020
=> Handler(exception as TException);
2121
}
2222

@@ -36,23 +36,23 @@ public ErrorHandlerOptions()
3636
typeof(NotImplementedException),
3737
new ExceptionMapper<NotImplementedException>()
3838
{
39-
Handler = (_) => (StatusCodes.Status501NotImplemented, new Dictionary<string, string>())
39+
Handler = (_) => (StatusCodes.Status501NotImplemented, new Dictionary<string, ErrorDetails>())
4040
}
4141
);
4242

4343
ExceptionMappers.Add(
4444
typeof(TimeoutException),
4545
new ExceptionMapper<TimeoutException>()
4646
{
47-
Handler = (_) => (StatusCodes.Status504GatewayTimeout, new Dictionary<string, string>())
47+
Handler = (_) => (StatusCodes.Status504GatewayTimeout, new Dictionary<string, ErrorDetails>())
4848
}
4949
);
5050
}
5151
}
5252

5353
public static class ErrorHandlerOptionsExtensions
5454
{
55-
public static void ExceptionMapper<TException>(this ErrorHandlerOptions options, Func<TException, (int Status, IEnumerable<KeyValuePair<string, string>> Errors)> configureMapper)
55+
public static void ExceptionMapper<TException>(this ErrorHandlerOptions options, Func<TException, (int Status, IEnumerable<KeyValuePair<string, ErrorDetails>> Errors)> configureMapper)
5656
where TException : Exception
5757
{
5858
var exceptionType = typeof(TException);
@@ -75,6 +75,6 @@ public static void ExceptionMapper<TException>(this ErrorHandlerOptions options,
7575

7676
public static void ExceptionMapper<TException>(this ErrorHandlerOptions options, Func<TException, int> configureMapper)
7777
where TException : Exception
78-
=> options.ExceptionMapper<TException>(e => (configureMapper(e), new Dictionary<string, string>()));
78+
=> options.ExceptionMapper<TException>(e => (configureMapper(e), new Dictionary<string, ErrorDetails>()));
7979
}
8080
}

src/ErrorProblemDetails.cs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ public class ErrorProblemDetails : ProblemDetails
1717
public string TraceId { get; set; }
1818

1919
/// <summary>
20-
/// Error property list
20+
/// List of errors
2121
/// </summary>
2222
/// <example>
23-
/// { "Property": "Error" }
23+
/// { "source": { "code: "", "description": "" } }
2424
/// </example>
25-
public IDictionary<string, string> Errors { get; set; } = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase);
25+
public IDictionary<string, ErrorDetails> Errors { get; set; } = new Dictionary<string, ErrorDetails>(StringComparer.InvariantCultureIgnoreCase);
2626

2727
/// <summary>
2828
/// Initializes a new instance of <see cref="ErrorProblemDetails"/>.
@@ -38,4 +38,37 @@ public override string ToString()
3838
public static implicit operator string(ErrorProblemDetails problemDetailsResponse)
3939
=> problemDetailsResponse.ToString();
4040
}
41+
42+
43+
public class ErrorDetails
44+
{
45+
/// <summary>
46+
/// Error code
47+
/// </summary>
48+
[JsonPropertyName("code")]
49+
public string Code { get; set; }
50+
51+
52+
/// <summary>
53+
/// A human-readable explanation specific to this occurrence of the error.
54+
/// </summary>
55+
[JsonPropertyName("description")]
56+
public string Description { get; set; }
57+
58+
public ErrorDetails() { }
59+
public ErrorDetails(
60+
string code,
61+
string description
62+
)
63+
{
64+
Code = code;
65+
Description = description;
66+
}
67+
68+
public override string ToString()
69+
=> JsonSerializer.Serialize(this);
70+
71+
public static implicit operator string(ErrorDetails errorDetails)
72+
=> errorDetails.ToString();
73+
}
4174
}

0 commit comments

Comments
 (0)