Skip to content

Commit fec75f5

Browse files
feat: ✨ Code cleanup and standardization of error handling
1 parent f696c0e commit fec75f5

10 files changed

Lines changed: 344 additions & 53 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This is **Intility.JsonApiToolkit**, a .NET library that implements the JSON:API
1313
- Microsoft.AspNetCore.Mvc
1414
- Microsoft.EntityFrameworkCore
1515
- Microsoft.AspNetCore.JsonPatch
16+
- Microsoft.Extensions.DependencyInjection.Abstractions
1617
- Intility.Logging.AspNetCore
1718
- **Test Framework**: xUnit with Moq for mocking
1819
- **Documentation**: DocFX for API documentation
@@ -83,7 +84,7 @@ Documentation is built using DocFX and deployed to GitHub Pages. The documentati
8384

8485
- **Convention-based mapping**: Properties are automatically mapped from C# PascalCase to JSON camelCase
8586
- **Query parameter parsing**: Standard JSON:API query syntax (`filter[field]=value`, `sort=field,-field2`, `page[number]=1&page[size]=10`, `include=relationship`)
86-
- **Async-first**: Main controller method `JsonApiOkAsync()` is async and works with `IQueryable<T>`
87+
- **Async-first**: Main controller method `JsonApiQueryAsync()` is async and works with `IQueryable<T>`
8788
- **Entity Framework integration**: Uses EF Core's `Include()` and query building capabilities
8889
- **Filter expressions**: Complex filtering with operators (eq, ne, gt, lt, contains, etc.), logical grouping, and enum support
8990
- **JSON column detection**: Collections and complex objects without ID properties are automatically mapped as JSON attributes instead of relationships (useful for EF Core owned entities stored as JSON columns)
@@ -137,7 +138,7 @@ Tests are organized by component:
137138
### Common Patterns
138139

139140
- Controllers should inherit from `JsonApiController`
140-
- Use `JsonApiOkAsync(queryable, "resourceType")` for collections with full query processing
141+
- Use `JsonApiQueryAsync(queryable, "resourceType")` for collections with full query processing
141142
- Use `JsonApiOk(entity, "resourceType")` for already-loaded entities or collections
142143
- Entity types should have an `Id` property (auto-detected by `EntityMapper.GetIdProperty()`)
143144
- Use `QueryParameters queryParams = GetJsonApiQueryParameters()` to access parsed query parameters

JsonApiToolkit/Extensions/Querying/QueryHelpers.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public static class QueryHelpers
4040
if (property != null)
4141
return property;
4242

43-
string pascalCase = StringExtensions.ToPascalCase(jsonPropertyName);
43+
string pascalCase = jsonPropertyName.ToPascalCase();
4444
property = entityType.GetProperty(pascalCase);
4545

4646
return property
@@ -57,7 +57,7 @@ public static class QueryHelpers
5757
/// <param name="value">The string value from the query parameter</param>
5858
/// <param name="targetType">The target property type to convert to</param>
5959
/// <returns>
60-
/// /// The converted value, or trows an exception if conversion fails or is not supported
60+
/// /// The converted value, or throws an exception if conversion fails or is not supported
6161
/// </returns>
6262
/// <remarks>
6363
/// Handles common primitive types (int, long, decimal, bool, DateTime, Guid, Uri, TimeSpan,

JsonApiToolkit/Extensions/StringExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public static string ToCamelCase(this string str)
3434
/// Used for converting JSON property names (camelCase) to C# property names (PascalCase).
3535
/// Returns the original string if it's null or empty.
3636
/// </remarks>
37-
public static string ToPascalCase(string str)
37+
public static string ToPascalCase(this string str)
3838
{
3939
if (string.IsNullOrEmpty(str))
4040
return str;

JsonApiToolkit/Filters/JsonApiExceptionFilter.cs

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,45 +28,65 @@ public class JsonApiExceptionFilter(ILogger<JsonApiExceptionFilter> logger) : IE
2828
/// </remarks>
2929
public void OnException(ExceptionContext context)
3030
{
31-
var (status, title) = context.Exception switch
32-
{
33-
JsonApiBadRequestException => (400, "Bad Request"),
34-
JsonApiNotFoundException => (404, "Not Found"),
35-
JsonApiConflictException => (409, "Conflict"),
36-
JsonApiUnauthorizedException => (401, "Unauthorized"),
37-
JsonApiForbiddenException => (403, "Forbidden"),
38-
JsonApiTooManyRequestsException => (429, "Too Many Requests"),
39-
_ => (500, "Internal Server Error"),
40-
};
31+
int status;
32+
string title;
33+
JsonApiError error;
4134

42-
if (status == 500)
43-
{
44-
// Log full stack trace for unexpected errors
45-
_logger.LogError(context.Exception, "An unhandled exception occurred");
46-
}
47-
else
35+
if (context.Exception is JsonApiException jsonApiException)
4836
{
49-
// Log only the message for handled exceptions
37+
// Handle structured JSON:API exceptions
38+
status = jsonApiException.StatusCode;
39+
title = GetTitleForStatusCode(status);
40+
41+
error = new JsonApiError
42+
{
43+
Status = status.ToString(),
44+
Title = title,
45+
Detail = jsonApiException.Message,
46+
Code = jsonApiException.Code,
47+
Source = jsonApiException.ErrorSource,
48+
Meta = jsonApiException.Meta,
49+
};
50+
51+
// Log handled exceptions
5052
_logger.LogInformation(
5153
"Handled JSON:API exception: {Type} - {Message}",
52-
context.Exception.GetType().Name,
53-
context.Exception.Message
54+
jsonApiException.GetType().Name,
55+
jsonApiException.Message
5456
);
5557
}
56-
57-
var error = new JsonApiError
58+
else
5859
{
59-
Status = status.ToString(),
60-
Title = title,
61-
Detail =
62-
status != 500
63-
? context.Exception.Message
64-
: "An error occurred while processing your request.",
65-
};
60+
// Handle unexpected exceptions
61+
status = 500;
62+
title = "Internal Server Error";
63+
64+
error = new JsonApiError
65+
{
66+
Status = status.ToString(),
67+
Title = title,
68+
Detail = "An error occurred while processing your request.",
69+
};
70+
71+
// Log full stack trace for unexpected errors
72+
_logger.LogError(context.Exception, "An unhandled exception occurred");
73+
}
6674

6775
var response = new JsonApiErrorResponse { Errors = [error] };
6876

6977
context.Result = new ObjectResult(response) { StatusCode = status };
7078
context.ExceptionHandled = true;
7179
}
80+
81+
private static string GetTitleForStatusCode(int statusCode) =>
82+
statusCode switch
83+
{
84+
400 => "Bad Request",
85+
401 => "Unauthorized",
86+
403 => "Forbidden",
87+
404 => "Not Found",
88+
409 => "Conflict",
89+
429 => "Too Many Requests",
90+
_ => "Error",
91+
};
7292
}
File renamed without changes.
Lines changed: 211 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,236 @@
1-
// Supress warnings for this file
2-
#pragma warning disable RCS1194
3-
41
namespace JsonApiToolkit.Models.Errors;
52

3+
/// <summary>
4+
/// Base class for all JSON:API exceptions that provides comprehensive error information.
5+
/// </summary>
6+
/// <remarks>
7+
/// This base class supports the full JSON:API error object specification, allowing
8+
/// for detailed error reporting with structured metadata, source information, and error codes.
9+
/// </remarks>
10+
public abstract class JsonApiException : Exception
11+
{
12+
/// <summary>
13+
/// HTTP status code for this error.
14+
/// </summary>
15+
public int StatusCode { get; }
16+
17+
/// <summary>
18+
/// Application-specific error code for categorizing the error.
19+
/// </summary>
20+
public string? Code { get; }
21+
22+
/// <summary>
23+
/// Source information indicating where the error occurred.
24+
/// </summary>
25+
public ErrorSource? ErrorSource { get; }
26+
27+
/// <summary>
28+
/// Additional metadata about the error.
29+
/// </summary>
30+
public Dictionary<string, object>? Meta { get; }
31+
32+
/// <summary>
33+
/// Initializes a new instance of the JsonApiException class.
34+
/// </summary>
35+
/// <param name="statusCode">HTTP status code</param>
36+
/// <param name="message">Error message</param>
37+
/// <param name="code">Application-specific error code</param>
38+
/// <param name="errorSource">Source information</param>
39+
/// <param name="meta">Additional metadata</param>
40+
/// <param name="innerException">Inner exception</param>
41+
protected JsonApiException(
42+
int statusCode,
43+
string message,
44+
string? code = null,
45+
ErrorSource? errorSource = null,
46+
Dictionary<string, object>? meta = null,
47+
Exception? innerException = null
48+
)
49+
: base(message, innerException)
50+
{
51+
StatusCode = statusCode;
52+
Code = code;
53+
ErrorSource = errorSource;
54+
Meta = meta;
55+
}
56+
}
57+
658
/// <summary>
759
/// Exception representing a 400 Bad Request error.
860
/// </summary>
9-
public class JsonApiBadRequestException(string message) : Exception(message) { }
61+
public class JsonApiBadRequestException : JsonApiException
62+
{
63+
/// <summary>
64+
/// Initializes a new instance of the JsonApiBadRequestException class.
65+
/// </summary>
66+
/// <param name="message">Error message</param>
67+
public JsonApiBadRequestException(string message)
68+
: base(400, message) { }
69+
70+
/// <summary>
71+
/// Initializes a new instance of the JsonApiBadRequestException class with detailed error information.
72+
/// </summary>
73+
/// <param name="message">Error message</param>
74+
/// <param name="code">Application-specific error code</param>
75+
/// <param name="errorSource">Source information</param>
76+
/// <param name="meta">Additional metadata</param>
77+
/// <param name="innerException">Inner exception</param>
78+
public JsonApiBadRequestException(
79+
string message,
80+
string? code = null,
81+
ErrorSource? errorSource = null,
82+
Dictionary<string, object>? meta = null,
83+
Exception? innerException = null
84+
)
85+
: base(400, message, code, errorSource, meta, innerException) { }
86+
}
1087

1188
/// <summary>
1289
/// Exception representing a 404 Not Found error.
1390
/// </summary>
14-
public class JsonApiNotFoundException(string message) : Exception(message) { }
91+
public class JsonApiNotFoundException : JsonApiException
92+
{
93+
/// <summary>
94+
/// Initializes a new instance of the JsonApiNotFoundException class.
95+
/// </summary>
96+
/// <param name="message">Error message</param>
97+
public JsonApiNotFoundException(string message)
98+
: base(404, message) { }
99+
100+
/// <summary>
101+
/// Initializes a new instance of the JsonApiNotFoundException class with detailed error information.
102+
/// </summary>
103+
/// <param name="message">Error message</param>
104+
/// <param name="code">Application-specific error code</param>
105+
/// <param name="errorSource">Source information</param>
106+
/// <param name="meta">Additional metadata</param>
107+
/// <param name="innerException">Inner exception</param>
108+
public JsonApiNotFoundException(
109+
string message,
110+
string? code = null,
111+
ErrorSource? errorSource = null,
112+
Dictionary<string, object>? meta = null,
113+
Exception? innerException = null
114+
)
115+
: base(404, message, code, errorSource, meta, innerException) { }
116+
}
15117

16118
/// <summary>
17119
/// Exception representing a 409 Conflict error.
18120
/// </summary>
19-
public class JsonApiConflictException(string message) : Exception(message) { }
121+
public class JsonApiConflictException : JsonApiException
122+
{
123+
/// <summary>
124+
/// Initializes a new instance of the JsonApiConflictException class.
125+
/// </summary>
126+
/// <param name="message">Error message</param>
127+
public JsonApiConflictException(string message)
128+
: base(409, message) { }
129+
130+
/// <summary>
131+
/// Initializes a new instance of the JsonApiConflictException class with detailed error information.
132+
/// </summary>
133+
/// <param name="message">Error message</param>
134+
/// <param name="code">Application-specific error code</param>
135+
/// <param name="errorSource">Source information</param>
136+
/// <param name="meta">Additional metadata</param>
137+
/// <param name="innerException">Inner exception</param>
138+
public JsonApiConflictException(
139+
string message,
140+
string? code = null,
141+
ErrorSource? errorSource = null,
142+
Dictionary<string, object>? meta = null,
143+
Exception? innerException = null
144+
)
145+
: base(409, message, code, errorSource, meta, innerException) { }
146+
}
20147

21148
/// <summary>
22149
/// Exception representing a 401 Unauthorized error.
23150
/// </summary>
24-
public class JsonApiUnauthorizedException(string message) : Exception(message) { }
151+
public class JsonApiUnauthorizedException : JsonApiException
152+
{
153+
/// <summary>
154+
/// Initializes a new instance of the JsonApiUnauthorizedException class.
155+
/// </summary>
156+
/// <param name="message">Error message</param>
157+
public JsonApiUnauthorizedException(string message)
158+
: base(401, message) { }
159+
160+
/// <summary>
161+
/// Initializes a new instance of the JsonApiUnauthorizedException class with detailed error information.
162+
/// </summary>
163+
/// <param name="message">Error message</param>
164+
/// <param name="code">Application-specific error code</param>
165+
/// <param name="errorSource">Source information</param>
166+
/// <param name="meta">Additional metadata</param>
167+
/// <param name="innerException">Inner exception</param>
168+
public JsonApiUnauthorizedException(
169+
string message,
170+
string? code = null,
171+
ErrorSource? errorSource = null,
172+
Dictionary<string, object>? meta = null,
173+
Exception? innerException = null
174+
)
175+
: base(401, message, code, errorSource, meta, innerException) { }
176+
}
25177

26178
/// <summary>
27179
/// Exception representing a 403 Forbidden error.
28180
/// </summary>
29-
public class JsonApiForbiddenException(string message) : Exception(message) { }
181+
public class JsonApiForbiddenException : JsonApiException
182+
{
183+
/// <summary>
184+
/// Initializes a new instance of the JsonApiForbiddenException class.
185+
/// </summary>
186+
/// <param name="message">Error message</param>
187+
public JsonApiForbiddenException(string message)
188+
: base(403, message) { }
189+
190+
/// <summary>
191+
/// Initializes a new instance of the JsonApiForbiddenException class with detailed error information.
192+
/// </summary>
193+
/// <param name="message">Error message</param>
194+
/// <param name="code">Application-specific error code</param>
195+
/// <param name="errorSource">Source information</param>
196+
/// <param name="meta">Additional metadata</param>
197+
/// <param name="innerException">Inner exception</param>
198+
public JsonApiForbiddenException(
199+
string message,
200+
string? code = null,
201+
ErrorSource? errorSource = null,
202+
Dictionary<string, object>? meta = null,
203+
Exception? innerException = null
204+
)
205+
: base(403, message, code, errorSource, meta, innerException) { }
206+
}
30207

31208
/// <summary>
32209
/// Exception representing a 429 Too Many Requests error.
33210
/// </summary>
34-
public class JsonApiTooManyRequestsException(string message) : Exception(message) { }
211+
public class JsonApiTooManyRequestsException : JsonApiException
212+
{
213+
/// <summary>
214+
/// Initializes a new instance of the JsonApiTooManyRequestsException class.
215+
/// </summary>
216+
/// <param name="message">Error message</param>
217+
public JsonApiTooManyRequestsException(string message)
218+
: base(429, message) { }
219+
220+
/// <summary>
221+
/// Initializes a new instance of the JsonApiTooManyRequestsException class with detailed error information.
222+
/// </summary>
223+
/// <param name="message">Error message</param>
224+
/// <param name="code">Application-specific error code</param>
225+
/// <param name="errorSource">Source information</param>
226+
/// <param name="meta">Additional metadata</param>
227+
/// <param name="innerException">Inner exception</param>
228+
public JsonApiTooManyRequestsException(
229+
string message,
230+
string? code = null,
231+
ErrorSource? errorSource = null,
232+
Dictionary<string, object>? meta = null,
233+
Exception? innerException = null
234+
)
235+
: base(429, message, code, errorSource, meta, innerException) { }
236+
}

0 commit comments

Comments
 (0)