From b10ad686778049c00f46d5a8144c3401d8a2f4f4 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 25 Mar 2020 14:40:39 +0100 Subject: [PATCH 01/60] Made some types which are not intended to be instantiated abstract --- .../Controllers/JsonApiCommandController.cs | 8 ++++---- .../Controllers/JsonApiQueryController.cs | 8 ++++---- .../Models/JsonApiDocuments/Identifiable.cs | 4 ++-- .../Serialization/Common/DocumentBuilderTests.cs | 8 ++++++-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 088dc31a..86835506 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCore.Controllers { - public class JsonApiCommandController : JsonApiCommandController where T : class, IIdentifiable + public abstract class JsonApiCommandController : JsonApiCommandController where T : class, IIdentifiable { - public JsonApiCommandController( + protected JsonApiCommandController( IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, IResourceCommandService commandService) @@ -17,9 +17,9 @@ public JsonApiCommandController( { } } - public class JsonApiCommandController : BaseJsonApiController where T : class, IIdentifiable + public abstract class JsonApiCommandController : BaseJsonApiController where T : class, IIdentifiable { - public JsonApiCommandController( + protected JsonApiCommandController( IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, IResourceCommandService commandService) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index d3120268..e7a357ca 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCore.Controllers { - public class JsonApiQueryController : JsonApiQueryController where T : class, IIdentifiable + public abstract class JsonApiQueryController : JsonApiQueryController where T : class, IIdentifiable { - public JsonApiQueryController( + protected JsonApiQueryController( IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, IResourceQueryService queryService) @@ -17,9 +17,9 @@ public JsonApiQueryController( { } } - public class JsonApiQueryController : BaseJsonApiController where T : class, IIdentifiable + public abstract class JsonApiQueryController : BaseJsonApiController where T : class, IIdentifiable { - public JsonApiQueryController( + protected JsonApiQueryController( IJsonApiOptions jsonApiContext, ILoggerFactory loggerFactory, IResourceQueryService queryService) diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs index b8512844..f559cf9a 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs @@ -4,10 +4,10 @@ namespace JsonApiDotNetCore.Models { - public class Identifiable : Identifiable + public abstract class Identifiable : Identifiable { } - public class Identifiable : IIdentifiable + public abstract class Identifiable : IIdentifiable { /// /// The resource identifier diff --git a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs index 69b7f17d..6c498a79 100644 --- a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs @@ -50,7 +50,7 @@ public void EntityToDocument_EmptyList_CanBuild() public void EntityToDocument_SingleEntity_CanBuild() { // Arrange - IIdentifiable dummy = new Identifiable(); + IIdentifiable dummy = new DummyResource(); // Act var document = _builder.Build(dummy); @@ -64,7 +64,7 @@ public void EntityToDocument_SingleEntity_CanBuild() public void EntityToDocument_EntityList_CanBuild() { // Arrange - var entities = new List { new Identifiable(), new Identifiable() }; + var entities = new List { new DummyResource(), new DummyResource() }; // Act var document = _builder.Build(entities); @@ -73,5 +73,9 @@ public void EntityToDocument_EntityList_CanBuild() // Assert Assert.Equal(2, data.Count); } + + public sealed class DummyResource : Identifiable + { + } } } From 3eeb2cd0a9eed8c54bc8d1437badf8d8c411ca4a Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 25 Mar 2020 15:13:15 +0100 Subject: [PATCH 02/60] Added members to Error as described at https://jsonapi.org/format/#errors --- .../Extensions/ModelStateExtensions.cs | 4 +- .../Internal/Exceptions/Error.cs | 45 ++++++++++++++----- .../Internal/Exceptions/ErrorCollection.cs | 2 +- .../Internal/Exceptions/JsonApiException.cs | 4 +- .../Server/ResponseSerializerTests.cs | 11 +++-- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index ba5219be..3ad7c805 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -34,9 +34,9 @@ public static ErrorCollection ConvertToErrorCollection(this ModelStateDiction title: entry.Key, detail: modelError.ErrorMessage, meta: modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null, - source: attrName == null ? null : new + source: attrName == null ? null : new ErrorSource { - pointer = $"/data/attributes/{attrName}" + Pointer = $"/data/attributes/{attrName}" })); } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs index 058fdf6f..acf07509 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs @@ -11,7 +11,7 @@ public class Error { public Error() { } - public Error(int status, string title, ErrorMeta meta = null, object source = null) + public Error(int status, string title, ErrorMeta meta = null, ErrorSource source = null) { Status = status.ToString(); Title = title; @@ -19,7 +19,7 @@ public Error(int status, string title, ErrorMeta meta = null, object source = nu Source = source; } - public Error(int status, string title, string detail, ErrorMeta meta = null, object source = null) + public Error(int status, string title, string detail, ErrorMeta meta = null, ErrorSource source = null) { Status = status.ToString(); Title = title; @@ -28,26 +28,32 @@ public Error(int status, string title, string detail, ErrorMeta meta = null, obj Source = source; } - [JsonProperty("title")] - public string Title { get; set; } + [JsonProperty("id")] + public string Id { get; set; } = Guid.NewGuid().ToString(); - [JsonProperty("detail")] - public string Detail { get; set; } + [JsonProperty("links")] + public ErrorLinks Links { get; set; } [JsonProperty("status")] public string Status { get; set; } - [JsonIgnore] - public int StatusCode => int.Parse(Status); + [JsonProperty("code")] + public string Code { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("detail")] + public string Detail { get; set; } [JsonProperty("source")] - public object Source { get; set; } + public ErrorSource Source { get; set; } [JsonProperty("meta")] public ErrorMeta Meta { get; set; } - public bool ShouldSerializeMeta() => (JsonApiOptions.DisableErrorStackTraces == false); - public bool ShouldSerializeSource() => (JsonApiOptions.DisableErrorSource == false); + public bool ShouldSerializeMeta() => JsonApiOptions.DisableErrorStackTraces == false; + public bool ShouldSerializeSource() => JsonApiOptions.DisableErrorSource == false; public IActionResult AsActionResult() { @@ -60,14 +66,29 @@ public IActionResult AsActionResult() } } + public class ErrorLinks + { + [JsonProperty("about")] + public string About { get; set; } + } + public class ErrorMeta { [JsonProperty("stackTrace")] - public string[] StackTrace { get; set; } + public ICollection StackTrace { get; set; } public static ErrorMeta FromException(Exception e) => new ErrorMeta { StackTrace = e.Demystify().ToString().Split(new[] { "\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries) }; } + + public class ErrorSource + { + [JsonProperty("pointer")] + public string Pointer { get; set; } + + [JsonProperty("parameter")] + public string Parameter { get; set; } + } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs index 91e6d962..b8d41d88 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs @@ -31,7 +31,7 @@ public string GetJson() public int GetErrorStatusCode() { var statusCodes = Errors - .Select(e => e.StatusCode) + .Select(e => int.Parse(e.Status)) .Distinct() .ToList(); diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index 9f94800a..a79f3be7 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -14,11 +14,11 @@ public JsonApiException(ErrorCollection errorCollection) public JsonApiException(Error error) : base(error.Title) => _errors.Add(error); - public JsonApiException(int statusCode, string message, string source = null) + public JsonApiException(int statusCode, string message, ErrorSource source = null) : base(message) => _errors.Add(new Error(statusCode, message, null, GetMeta(), source)); - public JsonApiException(int statusCode, string message, string detail, string source = null) + public JsonApiException(int statusCode, string message, string detail, ErrorSource source = null) : base(message) => _errors.Add(new Error(statusCode, message, detail, GetMeta(), source)); diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index a93e2a24..45140557 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -458,12 +458,15 @@ public void SerializeError_CustomError_CanSerialize() var expectedJson = JsonConvert.SerializeObject(new { - errors = new dynamic[] { - new { + errors = new[] + { + new + { myCustomProperty = "custom", + id = error.Id, + status = "507", title = "title", - detail = "detail", - status = "507" + detail = "detail" } } }); From e1a4c3da12a468732394c6f5865c69f86b30a99e Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 12:15:50 +0200 Subject: [PATCH 03/60] Replaced numeric HTTP status codes with HttpStatusCode enum --- .../Controllers/TodoItemsCustomController.cs | 3 ++- .../Resources/ArticleResource.cs | 3 ++- .../Resources/LockableResource.cs | 3 ++- .../Resources/PassportResource.cs | 5 ++-- .../Resources/TodoResource.cs | 3 ++- .../Services/CustomArticleService.cs | 3 ++- .../HttpMethodRestrictionFilter.cs | 3 ++- .../Controllers/JsonApiControllerMixin.cs | 3 ++- .../Extensions/IQueryableExtensions.cs | 7 ++--- .../Extensions/ModelStateExtensions.cs | 5 ++-- .../Extensions/TypeExtensions.cs | 3 ++- .../Formatters/JsonApiReader.cs | 3 ++- .../Formatters/JsonApiWriter.cs | 27 ++++++++++++++++--- .../Internal/Exceptions/Error.cs | 9 ++++--- .../Internal/Exceptions/ErrorCollection.cs | 11 +++++--- .../Internal/Exceptions/Exceptions.cs | 4 ++- .../Internal/Exceptions/JsonApiException.cs | 17 ++++++------ .../Exceptions/JsonApiExceptionFactory.cs | 3 ++- .../Middleware/CurrentRequestMiddleware.cs | 11 ++++---- .../Middleware/DefaultExceptionFilter.cs | 6 ++--- .../Middleware/DefaultTypeMatchFilter.cs | 3 ++- .../Common/QueryParameterParser.cs | 3 ++- .../Common/QueryParameterService.cs | 9 ++++--- .../QueryParameterServices/FilterService.cs | 3 ++- .../QueryParameterServices/IncludeService.cs | 9 ++++--- .../QueryParameterServices/PageService.cs | 3 ++- .../QueryParameterServices/SortService.cs | 5 ++-- .../SparseFieldsService.cs | 11 ++++---- .../Common/BaseDocumentParser.cs | 3 ++- .../Services/DefaultResourceService.cs | 7 ++--- .../Services/ScopedServiceProvider.cs | 3 ++- .../Acceptance/Spec/UpdatingDataTests.cs | 7 +++-- .../BaseJsonApiController_Tests.cs | 15 ++++++----- .../JsonApiControllerMixin_Tests.cs | 27 ++++++++++--------- .../Internal/JsonApiException_Test.cs | 17 ++++++------ .../CurrentRequestMiddlewareTests.cs | 3 ++- .../QueryParameters/PageServiceTests.cs | 5 ++-- .../SparseFieldsServiceTests.cs | 5 +++- .../Server/ResponseSerializerTests.cs | 5 ++-- 39 files changed, 167 insertions(+), 108 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 476ae486..8ee6f5e4 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; @@ -41,7 +42,7 @@ public class CustomJsonApiController private IActionResult Forbidden() { - return new StatusCodeResult(403); + return new StatusCodeResult((int)HttpStatusCode.Forbidden); } public CustomJsonApiController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs index 9a36eb27..67379628 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; @@ -17,7 +18,7 @@ public override IEnumerable
OnReturn(HashSet
entities, Resourc { if (pipeline == ResourcePipeline.GetSingle && entities.Single().Name == "Classified") { - throw new JsonApiException(403, "You are not allowed to see this article!", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "You are not allowed to see this article!", new UnauthorizedAccessException()); } return entities.Where(t => t.Name != "This should be not be included"); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs index d0743968..0dcf39bb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; @@ -18,7 +19,7 @@ protected void DisallowLocked(IEnumerable entities) { if (e.IsLocked) { - throw new JsonApiException(403, "Not allowed to update fields or relations of locked todo item", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked todo item", new UnauthorizedAccessException()); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs index 25cc4afb..33ec6a49 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; @@ -19,7 +20,7 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal { if (pipeline == ResourcePipeline.GetSingle && isIncluded) { - throw new JsonApiException(403, "Not allowed to include passports on individual people", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to include passports on individual people", new UnauthorizedAccessException()); } } @@ -34,7 +35,7 @@ private void DoesNotTouchLockedPassports(IEnumerable entities) { if (entity.IsLocked) { - throw new JsonApiException(403, "Not allowed to update fields or relations of locked persons", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked persons", new UnauthorizedAccessException()); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs index 26f6c69c..93cea03c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; @@ -16,7 +17,7 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal { if (stringId == "1337") { - throw new JsonApiException(403, "Not allowed to update author of any TodoItem", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update author of any TodoItem", new UnauthorizedAccessException()); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 8b7d07a1..fb500be7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; using System.Collections.Generic; +using System.Net; using System.Threading.Tasks; namespace JsonApiDotNetCoreExample.Services @@ -29,7 +30,7 @@ public override async Task
GetAsync(int id) var newEntity = await base.GetAsync(id); if(newEntity == null) { - throw new JsonApiException(404, "The entity could not be found"); + throw new JsonApiException(HttpStatusCode.NotFound, "The entity could not be found"); } newEntity.Name = "None for you Glen Coco"; return newEntity; diff --git a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs index 35341781..46fc57c2 100644 --- a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs +++ b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc.Filters; @@ -16,7 +17,7 @@ public override async Task OnActionExecutionAsync( var method = context.HttpContext.Request.Method; if (CanExecuteAction(method) == false) - throw new JsonApiException(405, $"This resource does not support {method} requests."); + throw new JsonApiException(HttpStatusCode.MethodNotAllowed, $"This resource does not support {method} requests."); await next(); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index 0fa06cab..326dda8e 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -1,3 +1,4 @@ +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Mvc; @@ -9,7 +10,7 @@ public abstract class JsonApiControllerMixin : ControllerBase { protected IActionResult Forbidden() { - return new StatusCodeResult(403); + return new StatusCodeResult((int)HttpStatusCode.Forbidden); } protected IActionResult Error(Error error) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 2132374a..09fa107e 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Net; using System.Reflection; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; @@ -181,7 +182,7 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression } break; default: - throw new JsonApiException(500, $"Unknown filter operation {operation}"); + throw new JsonApiException(HttpStatusCode.InternalServerError, $"Unknown filter operation {operation}"); } return body; @@ -227,7 +228,7 @@ private static IQueryable CallGenericWhereContainsMethod(IQuer } catch (FormatException) { - throw new JsonApiException(400, $"Could not cast {filter.Value} to {property.PropertyType.Name}"); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Could not cast {filter.Value} to {property.PropertyType.Name}"); } } @@ -296,7 +297,7 @@ private static IQueryable CallGenericWhereMethod(IQueryable(this ModelStateDiction { if (modelError.Exception is JsonApiException jex) { - collection.Errors.AddRange(jex.GetError().Errors); + collection.Errors.AddRange(jex.GetErrors().Errors); } else { collection.Errors.Add(new Error( - status: 422, + status: HttpStatusCode.UnprocessableEntity, title: entry.Key, detail: modelError.ErrorMessage, meta: modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null, diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 305e7745..d48255e5 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Net; namespace JsonApiDotNetCore.Extensions { @@ -100,7 +101,7 @@ private static object CreateNewInstance(Type type) } catch (Exception e) { - throw new JsonApiException(500, $"Type '{type}' cannot be instantiated using the default constructor.", e); + throw new JsonApiException(HttpStatusCode.InternalServerError, $"Type '{type}' cannot be instantiated using the default constructor.", e); } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 309705e0..af29def7 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.IO; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -58,7 +59,7 @@ public async Task ReadAsync(InputFormatterContext context if (idMissing) { _logger.LogError("Payload must include id attribute"); - throw new JsonApiException(400, "Payload must include id attribute"); + throw new JsonApiException(HttpStatusCode.BadRequest, "Payload must include id attribute"); } } return await InputFormatterResult.SuccessAsync(model); diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 2620fcdd..957f4452 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; @@ -47,10 +48,10 @@ public async Task WriteAsync(OutputFormatterWriteContext context) response.ContentType = Constants.ContentType; try { - if (context.Object is ProblemDetails pd) + if (context.Object is ProblemDetails problemDetails) { var errors = new ErrorCollection(); - errors.Add(new Error(pd.Status.Value, pd.Title, pd.Detail)); + errors.Add(ConvertProblemDetailsToError(problemDetails)); responseContent = _serializer.Serialize(errors); } else { @@ -61,13 +62,31 @@ public async Task WriteAsync(OutputFormatterWriteContext context) { _logger.LogError(new EventId(), e, "An error occurred while formatting the response"); var errors = new ErrorCollection(); - errors.Add(new Error(500, e.Message, ErrorMeta.FromException(e))); + errors.Add(new Error(HttpStatusCode.InternalServerError, e.Message, ErrorMeta.FromException(e))); responseContent = _serializer.Serialize(errors); - response.StatusCode = 500; + response.StatusCode = (int)HttpStatusCode.InternalServerError; } } await writer.WriteAsync(responseContent); await writer.FlushAsync(); } + + private static Error ConvertProblemDetailsToError(ProblemDetails problemDetails) + { + return new Error + { + Id = !string.IsNullOrWhiteSpace(problemDetails.Instance) + ? problemDetails.Instance + : Guid.NewGuid().ToString(), + Links = !string.IsNullOrWhiteSpace(problemDetails.Type) + ? new ErrorLinks {About = problemDetails.Type} + : null, + Status = problemDetails.Status != null + ? problemDetails.Status.Value.ToString() + : HttpStatusCode.InternalServerError.ToString("d"), + Title = problemDetails.Title, + Detail = problemDetails.Detail + }; + } } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs index acf07509..9adc01c4 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; using Newtonsoft.Json; using Microsoft.AspNetCore.Mvc; @@ -11,17 +12,17 @@ public class Error { public Error() { } - public Error(int status, string title, ErrorMeta meta = null, ErrorSource source = null) + public Error(HttpStatusCode status, string title, ErrorMeta meta = null, ErrorSource source = null) { - Status = status.ToString(); + Status = status.ToString("d"); Title = title; Meta = meta; Source = source; } - public Error(int status, string title, string detail, ErrorMeta meta = null, ErrorSource source = null) + public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta = null, ErrorSource source = null) { - Status = status.ToString(); + Status = status.ToString("d"); Title = title; Detail = detail; Meta = meta; diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs index b8d41d88..08e2665c 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Net; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Microsoft.AspNetCore.Mvc; @@ -28,7 +30,7 @@ public string GetJson() }); } - public int GetErrorStatusCode() + public HttpStatusCode GetErrorStatusCode() { var statusCodes = Errors .Select(e => int.Parse(e.Status)) @@ -36,16 +38,17 @@ public int GetErrorStatusCode() .ToList(); if (statusCodes.Count == 1) - return statusCodes[0]; + return (HttpStatusCode)statusCodes[0]; - return int.Parse(statusCodes.Max().ToString()[0] + "00"); + var statusCode = int.Parse(statusCodes.Max().ToString()[0] + "00"); + return (HttpStatusCode)statusCode; } public IActionResult AsActionResult() { return new ObjectResult(this) { - StatusCode = GetErrorStatusCode() + StatusCode = (int)GetErrorStatusCode() }; } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs index 6c510e56..c01bf0b5 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs @@ -1,3 +1,5 @@ +using System.Net; + namespace JsonApiDotNetCore.Internal { internal static class Exceptions @@ -6,6 +8,6 @@ internal static class Exceptions private static string BuildUrl(string title) => DOCUMENTATION_URL + title; public static JsonApiException UnSupportedRequestMethod { get; } - = new JsonApiException(405, "Request method is not supported.", BuildUrl(nameof(UnSupportedRequestMethod))); + = new JsonApiException(HttpStatusCode.MethodNotAllowed, "Request method is not supported.", BuildUrl(nameof(UnSupportedRequestMethod))); } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index a79f3be7..f09ac1a4 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -1,4 +1,5 @@ using System; +using System.Net; namespace JsonApiDotNetCore.Internal { @@ -14,21 +15,21 @@ public JsonApiException(ErrorCollection errorCollection) public JsonApiException(Error error) : base(error.Title) => _errors.Add(error); - public JsonApiException(int statusCode, string message, ErrorSource source = null) + public JsonApiException(HttpStatusCode status, string message, ErrorSource source = null) : base(message) - => _errors.Add(new Error(statusCode, message, null, GetMeta(), source)); + => _errors.Add(new Error(status, message, null, GetMeta(), source)); - public JsonApiException(int statusCode, string message, string detail, ErrorSource source = null) + public JsonApiException(HttpStatusCode status, string message, string detail, ErrorSource source = null) : base(message) - => _errors.Add(new Error(statusCode, message, detail, GetMeta(), source)); + => _errors.Add(new Error(status, message, detail, GetMeta(), source)); - public JsonApiException(int statusCode, string message, Exception innerException) + public JsonApiException(HttpStatusCode status, string message, Exception innerException) : base(message, innerException) - => _errors.Add(new Error(statusCode, message, innerException.Message, GetMeta(innerException))); + => _errors.Add(new Error(status, message, innerException.Message, GetMeta(innerException))); - public ErrorCollection GetError() => _errors; + public ErrorCollection GetErrors() => _errors; - public int GetStatusCode() + public HttpStatusCode GetStatusCode() { return _errors.GetErrorStatusCode(); } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs index 3b95e85b..c1baf096 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Net; namespace JsonApiDotNetCore.Internal { @@ -11,7 +12,7 @@ public static JsonApiException GetException(Exception exception) if (exceptionType == typeof(JsonApiException)) return (JsonApiException)exception; - return new JsonApiException(500, exceptionType.Name, exception); + return new JsonApiException(HttpStatusCode.InternalServerError, exceptionType.Name, exception); } } } diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 6b7adf2c..c84d3a88 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; @@ -63,7 +64,7 @@ private string GetBaseId() { if ((string)stringId == string.Empty) { - throw new JsonApiException(400, "No empty string as id please."); + throw new JsonApiException(HttpStatusCode.BadRequest, "No empty string as id please."); } return (string)stringId; } @@ -148,7 +149,7 @@ private bool IsValidContentTypeHeader(HttpContext context) var contentType = context.Request.ContentType; if (contentType != null && ContainsMediaTypeParameters(contentType)) { - FlushResponse(context, 415); + FlushResponse(context, HttpStatusCode.UnsupportedMediaType); return false; } return true; @@ -166,7 +167,7 @@ private bool IsValidAcceptHeader(HttpContext context) continue; } - FlushResponse(context, 406); + FlushResponse(context, HttpStatusCode.NotAcceptable); return false; } return true; @@ -193,9 +194,9 @@ private static bool ContainsMediaTypeParameters(string mediaType) ); } - private void FlushResponse(HttpContext context, int statusCode) + private void FlushResponse(HttpContext context, HttpStatusCode statusCode) { - context.Response.StatusCode = statusCode; + context.Response.StatusCode = (int)statusCode; context.Response.Body.Flush(); } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs index 9ba23bec..115e218d 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs @@ -23,10 +23,10 @@ public void OnException(ExceptionContext context) var jsonApiException = JsonApiExceptionFactory.GetException(context.Exception); - var error = jsonApiException.GetError(); - var result = new ObjectResult(error) + var errors = jsonApiException.GetErrors(); + var result = new ObjectResult(errors) { - StatusCode = jsonApiException.GetStatusCode() + StatusCode = (int)jsonApiException.GetStatusCode() }; context.Result = result; } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 5e428fa7..8a09a0f9 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using Microsoft.AspNetCore.Http; @@ -31,7 +32,7 @@ public void OnActionExecuting(ActionExecutingContext context) { var expectedJsonApiResource = _provider.GetResourceContext(targetType); - throw new JsonApiException(409, + throw new JsonApiException(HttpStatusCode.Conflict, $"Cannot '{context.HttpContext.Request.Method}' type '{deserializedType.Name}' " + $"to '{expectedJsonApiResource?.ResourceName}' endpoint.", detail: "Check that the request payload type matches the type expected by this endpoint."); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index fe3b830f..37477186 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; @@ -48,7 +49,7 @@ public virtual void Parse(DisableQueryAttribute disabled) continue; if (!_options.AllowCustomQueryParameters) - throw new JsonApiException(400, $"{pair} is not a valid query."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"{pair} is not a valid query."); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index 3bf99c73..0f3d9be0 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Linq; +using System.Net; using System.Text.RegularExpressions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; @@ -53,7 +54,7 @@ protected AttrAttribute GetAttribute(string target, RelationshipAttribute relati : _requestResource.Attributes.FirstOrDefault(attr => attr.Is(target)); if (attribute == null) - throw new JsonApiException(400, $"'{target}' is not a valid attribute."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"'{target}' is not a valid attribute."); return attribute; } @@ -66,7 +67,7 @@ protected RelationshipAttribute GetRelationship(string propertyName) if (propertyName == null) return null; var relationship = _requestResource.Relationships.FirstOrDefault(r => r.Is(propertyName)); if (relationship == null) - throw new JsonApiException(400, $"{propertyName} is not a valid relationship on {_requestResource.ResourceName}."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"{propertyName} is not a valid relationship on {_requestResource.ResourceName}."); return relationship; } @@ -78,7 +79,7 @@ protected void EnsureNoNestedResourceRoute() { if (_requestResource != _mainRequestResource) { - throw new JsonApiException(400, $"Query parameter {Name} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{Name}=...'"); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Query parameter {Name} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{Name}=...'"); } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index 2a10a698..6df45bd8 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -51,7 +52,7 @@ private FilterQueryContext GetQueryContexts(FilterQuery query) var attribute = GetAttribute(query.Attribute, queryContext.Relationship); if (attribute.IsFilterable == false) - throw new JsonApiException(400, $"Filter is not allowed for attribute '{attribute.PublicAttributeName}'."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Filter is not allowed for attribute '{attribute.PublicAttributeName}'."); queryContext.Attribute = attribute; return queryContext; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs index 4032da4c..ef07c2b2 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -30,7 +31,7 @@ public virtual void Parse(KeyValuePair queryParameter) { var value = (string)queryParameter.Value; if (string.IsNullOrWhiteSpace(value)) - throw new JsonApiException(400, "Include parameter must not be empty if provided"); + throw new JsonApiException(HttpStatusCode.BadRequest, "Include parameter must not be empty if provided"); var chains = value.Split(QueryConstants.COMMA).ToList(); foreach (var chain in chains) @@ -59,12 +60,12 @@ private void ParseChain(string chain) private JsonApiException CannotIncludeError(ResourceContext resourceContext, string requestedRelationship) { - return new JsonApiException(400, $"Including the relationship {requestedRelationship} on {resourceContext.ResourceName} is not allowed"); + return new JsonApiException(HttpStatusCode.BadRequest, $"Including the relationship {requestedRelationship} on {resourceContext.ResourceName} is not allowed"); } private JsonApiException InvalidRelationshipError(ResourceContext resourceContext, string requestedRelationship) { - return new JsonApiException(400, $"Invalid relationship {requestedRelationship} on {resourceContext.ResourceName}", + return new JsonApiException(HttpStatusCode.BadRequest, $"Invalid relationship {requestedRelationship} on {resourceContext.ResourceName}", $"{resourceContext.ResourceName} does not have a relationship named {requestedRelationship}"); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index d7da6292..df014c77 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; @@ -116,7 +117,7 @@ public virtual void Parse(KeyValuePair queryParameter) private void ThrowBadPagingRequest(KeyValuePair parameter, string message) { - throw new JsonApiException(400, $"Invalid page query parameter '{parameter.Key}={parameter.Value}': {message}"); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Invalid page query parameter '{parameter.Key}={parameter.Value}': {message}"); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index fe136993..14097f6c 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -51,7 +52,7 @@ private List BuildQueries(string value) var sortSegments = value.Split(QueryConstants.COMMA); if (sortSegments.Any(s => s == string.Empty)) - throw new JsonApiException(400, "The sort URI segment contained a null value."); + throw new JsonApiException(HttpStatusCode.BadRequest, "The sort URI segment contained a null value."); foreach (var sortSegment in sortSegments) { @@ -76,7 +77,7 @@ private SortQueryContext BuildQueryContext(SortQuery query) var attribute = GetAttribute(query.Attribute, relationship); if (attribute.IsSortable == false) - throw new JsonApiException(400, $"Sort is not allowed for attribute '{attribute.PublicAttributeName}'."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Sort is not allowed for attribute '{attribute.PublicAttributeName}'."); return new SortQueryContext(query) { diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index db669192..0256aaeb 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -62,16 +63,16 @@ public virtual void Parse(KeyValuePair queryParameter) // that is equal to the resource name, like with self-referencing data types (eg directory structures) // if not, no longer support this type of sparse field selection. if (navigation == _requestResource.ResourceName && !_requestResource.Relationships.Any(a => a.Is(navigation))) - throw new JsonApiException(400, $"Use '?fields=...' instead of 'fields[{navigation}]':" + + throw new JsonApiException(HttpStatusCode.BadRequest, $"Use '?fields=...' instead of 'fields[{navigation}]':" + " the square bracket navigations is now reserved " + "for relationships only. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/555#issuecomment-543100865"); if (navigation.Contains(QueryConstants.DOT)) - throw new JsonApiException(400, $"fields[{navigation}] is not valid: deeply nested sparse field selection is not yet supported."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"fields[{navigation}] is not valid: deeply nested sparse field selection is not yet supported."); var relationship = _requestResource.Relationships.SingleOrDefault(a => a.Is(navigation)); if (relationship == null) - throw new JsonApiException(400, $"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}"); + throw new JsonApiException(HttpStatusCode.BadRequest, $"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}"); foreach (var field in fields) RegisterRelatedResourceField(field, relationship); @@ -86,7 +87,7 @@ private void RegisterRelatedResourceField(string field, RelationshipAttribute re var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType); var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field)); if (attr == null) - throw new JsonApiException(400, $"'{relationship.RightType.Name}' does not contain '{field}'."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"'{relationship.RightType.Name}' does not contain '{field}'."); if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields)) _selectedRelationshipFields.Add(relationship, registeredFields = new List()); @@ -100,7 +101,7 @@ private void RegisterRequestResourceField(string field) { var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field)); if (attr == null) - throw new JsonApiException(400, $"'{_requestResource.ResourceName}' does not contain '{field}'."); + throw new JsonApiException(HttpStatusCode.BadRequest, $"'{_requestResource.ResourceName}' does not contain '{field}'."); (_selectedFields ??= new List()).Add(attr); } diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 2ab3e476..ed566b61 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Reflection; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; @@ -133,7 +134,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) var resourceContext = _provider.GetResourceContext(data.Type); if (resourceContext == null) { - throw new JsonApiException(400, + throw new JsonApiException(HttpStatusCode.BadRequest, message: $"This API does not contain a json:api resource named '{data.Type}'.", detail: "This resource is not registered on the ResourceGraph. " + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index d1c7c155..e5741fb8 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; @@ -142,7 +143,7 @@ public virtual async Task GetRelationshipsAsync(TId id, string relati { // TODO: this does not make sense. If the **parent** entity is not found, this error is thrown? // this error should be thrown when the relationship is not found. - throw new JsonApiException(404, $"Relationship '{relationshipName}' not found."); + throw new JsonApiException(HttpStatusCode.NotFound, $"Relationship '{relationshipName}' not found."); } if (!IsNull(_hookExecutor, entity)) @@ -181,7 +182,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa var entityQuery = _repository.Include(_repository.Get(id), new[] { relationship }); var entity = await _repository.FirstOrDefaultAsync(entityQuery); if (entity == null) - throw new JsonApiException(404, $"Entity with id {id} could not be found."); + throw new JsonApiException(HttpStatusCode.NotFound, $"Entity with id {id} could not be found."); entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.PatchRelationship).SingleOrDefault(); @@ -334,7 +335,7 @@ private RelationshipAttribute GetRelationship(string relationshipName) { var relationship = _currentRequestResource.Relationships.Single(r => r.Is(relationshipName)); if (relationship == null) - throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); + throw new JsonApiException(HttpStatusCode.UnprocessableEntity, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); return relationship; } diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index 975c8702..d35b302e 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -1,6 +1,7 @@ using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Http; using System; +using System.Net; namespace JsonApiDotNetCore.Services { @@ -27,7 +28,7 @@ public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) public object GetService(Type serviceType) { if (_httpContextAccessor.HttpContext == null) - throw new JsonApiException(500, + throw new JsonApiException(HttpStatusCode.InternalServerError, "Cannot resolve scoped service outside the context of an HTTP Request.", detail: "If you are hitting this error in automated tests, you should instead inject your own " + "IScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index bbe645d1..6c71e5f3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -59,7 +59,7 @@ public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() } [Fact] - public async Task Response400IfUpdatingNotSettableAttribute() + public async Task Response422IfUpdatingNotSettableAttribute() { // Arrange var builder = new WebHostBuilder().UseStartup(); @@ -78,7 +78,7 @@ public async Task Response400IfUpdatingNotSettableAttribute() var response = await client.SendAsync(request); // Assert - Assert.Equal(422, Convert.ToInt32(response.StatusCode)); + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); } [Fact] @@ -126,8 +126,7 @@ public async Task Respond_422_If_IdNotInAttributeList() var response = await client.SendAsync(request); // Assert - Assert.Equal(422, Convert.ToInt32(response.StatusCode)); - + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); } [Fact] diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index aec9a5a4..aaa093f2 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -1,3 +1,4 @@ +using System.Net; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -69,7 +70,7 @@ public async Task GetAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } [Fact] @@ -98,7 +99,7 @@ public async Task GetAsyncById_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } [Fact] @@ -127,7 +128,7 @@ public async Task GetRelationshipsAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } [Fact] @@ -156,7 +157,7 @@ public async Task GetRelationshipAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } [Fact] @@ -224,7 +225,7 @@ public async Task PatchAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } [Fact] @@ -318,7 +319,7 @@ public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } [Fact] @@ -347,7 +348,7 @@ public async Task DeleteAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); // Assert - Assert.Equal(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); } } } diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index 51fb451c..d8f7905a 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc; @@ -15,26 +16,26 @@ public void Errors_Correctly_Infers_Status_Code() // Arrange var errors422 = new ErrorCollection { Errors = new List { - new Error(422, "bad specific"), - new Error(422, "bad other specific"), + new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), + new Error(HttpStatusCode.UnprocessableEntity, "bad other specific"), } }; var errors400 = new ErrorCollection { Errors = new List { - new Error(200, "weird"), - new Error(400, "bad"), - new Error(422, "bad specific"), + new Error(HttpStatusCode.OK, "weird"), + new Error(HttpStatusCode.BadRequest, "bad"), + new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), } }; var errors500 = new ErrorCollection { Errors = new List { - new Error(200, "weird"), - new Error(400, "bad"), - new Error(422, "bad specific"), - new Error(500, "really bad"), - new Error(502, "really bad specific"), + new Error(HttpStatusCode.OK, "weird"), + new Error(HttpStatusCode.BadRequest, "bad"), + new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), + new Error(HttpStatusCode.InternalServerError, "really bad"), + new Error(HttpStatusCode.BadGateway, "really bad specific"), } }; @@ -48,9 +49,9 @@ public void Errors_Correctly_Infers_Status_Code() var response400 = Assert.IsType(result400); var response500 = Assert.IsType(result500); - Assert.Equal(422, response422.StatusCode); - Assert.Equal(400, response400.StatusCode); - Assert.Equal(500, response500.StatusCode); + Assert.Equal((int)HttpStatusCode.UnprocessableEntity, response422.StatusCode); + Assert.Equal((int)HttpStatusCode.BadRequest, response400.StatusCode); + Assert.Equal((int)HttpStatusCode.InternalServerError, response500.StatusCode); } } } diff --git a/test/UnitTests/Internal/JsonApiException_Test.cs b/test/UnitTests/Internal/JsonApiException_Test.cs index 989db27e..e30a88d9 100644 --- a/test/UnitTests/Internal/JsonApiException_Test.cs +++ b/test/UnitTests/Internal/JsonApiException_Test.cs @@ -1,3 +1,4 @@ +using System.Net; using JsonApiDotNetCore.Internal; using Xunit; @@ -12,20 +13,20 @@ public void Can_GetStatusCode() var exception = new JsonApiException(errors); // Add First 422 error - errors.Add(new Error(422, "Something wrong")); - Assert.Equal(422, exception.GetStatusCode()); + errors.Add(new Error(HttpStatusCode.UnprocessableEntity, "Something wrong")); + Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.GetStatusCode()); // Add a second 422 error - errors.Add(new Error(422, "Something else wrong")); - Assert.Equal(422, exception.GetStatusCode()); + errors.Add(new Error(HttpStatusCode.UnprocessableEntity, "Something else wrong")); + Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.GetStatusCode()); // Add 4xx error not 422 - errors.Add(new Error(401, "Unauthorized")); - Assert.Equal(400, exception.GetStatusCode()); + errors.Add(new Error(HttpStatusCode.Unauthorized, "Unauthorized")); + Assert.Equal(HttpStatusCode.BadRequest, exception.GetStatusCode()); // Add 5xx error not 4xx - errors.Add(new Error(502, "Not good")); - Assert.Equal(500, exception.GetStatusCode()); + errors.Add(new Error(HttpStatusCode.BadGateway, "Not good")); + Assert.Equal(HttpStatusCode.InternalServerError, exception.GetStatusCode()); } } } diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index 120c0cd4..aef323c1 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -10,6 +10,7 @@ using System; using System.IO; using System.Linq; +using System.Net; using System.Threading.Tasks; using Xunit; @@ -88,7 +89,7 @@ public async Task ParseUrlBase_UrlHasIncorrectBaseIdSet_ShouldThrowException(str { await task; }); - Assert.Equal(400, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, exception.GetStatusCode()); Assert.Contains(baseId, exception.Message); } diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index d157d1ef..490c6f9e 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Query; @@ -47,7 +48,7 @@ public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximu if (shouldThrow) { var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(400, ex.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.GetStatusCode()); } else { @@ -72,7 +73,7 @@ public void Parse_PageNumber_CanParse(string value, int expectedValue, int? maxi if (shouldThrow) { var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(400, ex.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.GetStatusCode()); } else { diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index c6158540..9dd42906 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; @@ -79,6 +80,7 @@ public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessag // Act, assert var ex = Assert.Throws(() => service.Parse(query)); Assert.Contains("relationships only", ex.Message); + Assert.Equal(HttpStatusCode.BadRequest, ex.GetErrors().GetErrorStatusCode()); } [Fact] @@ -104,6 +106,7 @@ public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() // Act, assert var ex = Assert.Throws(() => service.Parse(query)); Assert.Contains("deeply nested", ex.Message); + Assert.Equal(HttpStatusCode.BadRequest, ex.GetErrors().GetErrorStatusCode()); } [Fact] @@ -126,7 +129,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() // Act , assert var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(400, ex.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.GetStatusCode()); } } } diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 45140557..627f4b81 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net; using System.Text.RegularExpressions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -452,7 +453,7 @@ public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_C public void SerializeError_CustomError_CanSerialize() { // Arrange - var error = new CustomError(507, "title", "detail", "custom"); + var error = new CustomError(HttpStatusCode.InsufficientStorage, "title", "detail", "custom"); var errorCollection = new ErrorCollection(); errorCollection.Add(error); @@ -481,7 +482,7 @@ public void SerializeError_CustomError_CanSerialize() private sealed class CustomError : Error { - public CustomError(int status, string title, string detail, string myProp) + public CustomError(HttpStatusCode status, string title, string detail, string myProp) : base(status, title, detail) { MyCustomProperty = myProp; From a2b873c8472a36b137a952aa697ea5d1408c236c Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 12:26:11 +0200 Subject: [PATCH 04/60] Expose HttpStatusCode on Error object --- src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs | 4 ++-- src/JsonApiDotNetCore/Internal/Exceptions/Error.cs | 13 ++++++++++--- .../Internal/Exceptions/ErrorCollection.cs | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 957f4452..2416c760 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -82,8 +82,8 @@ private static Error ConvertProblemDetailsToError(ProblemDetails problemDetails) ? new ErrorLinks {About = problemDetails.Type} : null, Status = problemDetails.Status != null - ? problemDetails.Status.Value.ToString() - : HttpStatusCode.InternalServerError.ToString("d"), + ? (HttpStatusCode)problemDetails.Status.Value + : HttpStatusCode.InternalServerError, Title = problemDetails.Title, Detail = problemDetails.Detail }; diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs index 9adc01c4..1bc8aed4 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs @@ -14,7 +14,7 @@ public Error() { } public Error(HttpStatusCode status, string title, ErrorMeta meta = null, ErrorSource source = null) { - Status = status.ToString("d"); + Status = status; Title = title; Meta = meta; Source = source; @@ -22,7 +22,7 @@ public Error(HttpStatusCode status, string title, ErrorMeta meta = null, ErrorSo public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta = null, ErrorSource source = null) { - Status = status.ToString("d"); + Status = status; Title = title; Detail = detail; Meta = meta; @@ -35,8 +35,15 @@ public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta [JsonProperty("links")] public ErrorLinks Links { get; set; } + [JsonIgnore] + public HttpStatusCode Status { get; set; } + [JsonProperty("status")] - public string Status { get; set; } + public string StatusText + { + get => Status.ToString("d"); + set => Status = (HttpStatusCode)int.Parse(value); + } [JsonProperty("code")] public string Code { get; set; } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs index 08e2665c..67fff7ec 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs @@ -33,7 +33,7 @@ public string GetJson() public HttpStatusCode GetErrorStatusCode() { var statusCodes = Errors - .Select(e => int.Parse(e.Status)) + .Select(e => (int)e.Status) .Distinct() .ToList(); From 6c08343fe9aaf2144bd01395d9fa7d44abcdee58 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 12:57:35 +0200 Subject: [PATCH 05/60] Replaced static error class with custom exception --- .../Services/CustomArticleService.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 18 +++++++-------- .../Internal/Exceptions/Exceptions.cs | 13 ----------- .../RequestMethodNotAllowedException.cs | 21 ++++++++++++++++++ .../Services/DefaultResourceService.cs | 2 +- .../BaseJsonApiController_Tests.cs | 22 +++++++++++++------ 6 files changed, 47 insertions(+), 31 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs create mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index fb500be7..c68a6db5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -30,7 +30,7 @@ public override async Task
GetAsync(int id) var newEntity = await base.GetAsync(id); if(newEntity == null) { - throw new JsonApiException(HttpStatusCode.NotFound, "The entity could not be found"); + throw new JsonApiException(HttpStatusCode.NotFound, "The resource could not be found."); } newEntity.Name = "None for you Glen Coco"; return newEntity; diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 5b098364..5f27d7e0 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; @@ -67,14 +68,14 @@ protected BaseJsonApiController( public virtual async Task GetAsync() { - if (_getAll == null) throw Exceptions.UnSupportedRequestMethod; + if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var entities = await _getAll.GetAsync(); return Ok(entities); } public virtual async Task GetAsync(TId id) { - if (_getById == null) throw Exceptions.UnSupportedRequestMethod; + if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var entity = await _getById.GetAsync(id); if (entity == null) { @@ -88,8 +89,7 @@ public virtual async Task GetAsync(TId id) public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) { - if (_getRelationships == null) - throw Exceptions.UnSupportedRequestMethod; + if (_getRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getRelationships.GetRelationshipsAsync(id, relationshipName); if (relationship == null) { @@ -103,7 +103,7 @@ public virtual async Task GetRelationshipsAsync(TId id, string re public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - if (_getRelationship == null) throw Exceptions.UnSupportedRequestMethod; + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); return Ok(relationship); } @@ -111,7 +111,7 @@ public virtual async Task GetRelationshipAsync(TId id, string rel public virtual async Task PostAsync([FromBody] T entity) { if (_create == null) - throw Exceptions.UnSupportedRequestMethod; + throw new RequestMethodNotAllowedException(HttpMethod.Post); if (entity == null) return UnprocessableEntity(); @@ -129,7 +129,7 @@ public virtual async Task PostAsync([FromBody] T entity) public virtual async Task PatchAsync(TId id, [FromBody] T entity) { - if (_update == null) throw Exceptions.UnSupportedRequestMethod; + if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); if (entity == null) return UnprocessableEntity(); @@ -151,14 +151,14 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships) { - if (_updateRelationships == null) throw Exceptions.UnSupportedRequestMethod; + if (_updateRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); await _updateRelationships.UpdateRelationshipsAsync(id, relationshipName, relationships); return Ok(); } public virtual async Task DeleteAsync(TId id) { - if (_delete == null) throw Exceptions.UnSupportedRequestMethod; + if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); var wasDeleted = await _delete.DeleteAsync(id); if (!wasDeleted) return NotFound(); diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs deleted file mode 100644 index c01bf0b5..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Net; - -namespace JsonApiDotNetCore.Internal -{ - internal static class Exceptions - { - private const string DOCUMENTATION_URL = "https://json-api-dotnet.github.io/#/errors/"; - private static string BuildUrl(string title) => DOCUMENTATION_URL + title; - - public static JsonApiException UnSupportedRequestMethod { get; } - = new JsonApiException(HttpStatusCode.MethodNotAllowed, "Request method is not supported.", BuildUrl(nameof(UnSupportedRequestMethod))); - } -} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs new file mode 100644 index 00000000..ec721a9d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs @@ -0,0 +1,21 @@ +using System.Net; +using System.Net.Http; + +namespace JsonApiDotNetCore.Internal +{ + public sealed class RequestMethodNotAllowedException : JsonApiException + { + public HttpMethod Method { get; } + + public RequestMethodNotAllowedException(HttpMethod method) + : base(new Error + { + Status = HttpStatusCode.MethodNotAllowed, + Title = "The request method is not allowed.", + Detail = $"Resource does not support {method} requests." + }) + { + Method = method; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index e5741fb8..9b36a7ad 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -182,7 +182,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa var entityQuery = _repository.Include(_repository.Get(id), new[] { relationship }); var entity = await _repository.FirstOrDefaultAsync(entityQuery); if (entity == null) - throw new JsonApiException(HttpStatusCode.NotFound, $"Entity with id {id} could not be found."); + throw new JsonApiException(HttpStatusCode.NotFound, $"Resource with id {id} could not be found."); entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.PatchRelationship).SingleOrDefault(); diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index aaa093f2..3e56c9b1 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -67,10 +68,11 @@ public async Task GetAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, null); // Act - var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); + var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -96,10 +98,11 @@ public async Task GetAsyncById_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); + var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -125,10 +128,11 @@ public async Task GetRelationshipsAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); + var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -154,10 +158,11 @@ public async Task GetRelationshipAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); + var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -222,10 +227,11 @@ public async Task PatchAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); + var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Patch, exception.Method); } [Fact] @@ -316,10 +322,11 @@ public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); + var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Patch, exception.Method); } [Fact] @@ -345,10 +352,11 @@ public async Task DeleteAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); + var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpMethod.Delete, exception.Method); } } } From 6511e575dac1719922d1cb9d03837d2da2c63912 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 13:57:59 +0200 Subject: [PATCH 06/60] Moved Error type to non-internal namespace --- .../Controllers/JsonApiControllerMixin.cs | 1 + .../Extensions/ModelStateExtensions.cs | 1 + .../Formatters/JsonApiWriter.cs | 1 + .../Internal/Exceptions/JsonApiException.cs | 1 + .../RequestMethodNotAllowedException.cs | 1 + .../JsonApiDocuments}/Error.cs | 31 ++----------------- .../JsonApiDocuments}/ErrorCollection.cs | 5 ++- .../Models/JsonApiDocuments/ErrorLinks.cs | 10 ++++++ .../Models/JsonApiDocuments/ErrorMeta.cs | 18 +++++++++++ .../Models/JsonApiDocuments/ErrorSource.cs | 13 ++++++++ .../Server/ResponseSerializer.cs | 1 + .../Acceptance/Spec/QueryParameters.cs | 1 + .../BaseJsonApiController_Tests.cs | 1 + .../JsonApiControllerMixin_Tests.cs | 1 + .../Internal/JsonApiException_Test.cs | 1 + .../Server/ResponseSerializerTests.cs | 1 + 16 files changed, 56 insertions(+), 32 deletions(-) rename src/JsonApiDotNetCore/{Internal/Exceptions => Models/JsonApiDocuments}/Error.cs (72%) rename src/JsonApiDotNetCore/{Internal/Exceptions => Models/JsonApiDocuments}/ErrorCollection.cs (96%) create mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs create mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs create mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index 326dda8e..1fb48a99 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -1,6 +1,7 @@ using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; namespace JsonApiDotNetCore.Controllers diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index 1cb4a42b..b72818a6 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -3,6 +3,7 @@ using System.Reflection; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace JsonApiDotNetCore.Extensions diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 2416c760..fe5c84cf 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index f09ac1a4..49248a2a 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Internal { diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs index ec721a9d..939dc22e 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Internal { diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs similarity index 72% rename from src/JsonApiDotNetCore/Internal/Exceptions/Error.cs rename to src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 1bc8aed4..ec2775ce 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -1,12 +1,11 @@ using System; -using System.Diagnostics; using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; -using Newtonsoft.Json; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Models.JsonApiDocuments { public class Error { @@ -73,30 +72,4 @@ public IActionResult AsActionResult() return errorCollection.AsActionResult(); } } - - public class ErrorLinks - { - [JsonProperty("about")] - public string About { get; set; } - } - - public class ErrorMeta - { - [JsonProperty("stackTrace")] - public ICollection StackTrace { get; set; } - - public static ErrorMeta FromException(Exception e) - => new ErrorMeta { - StackTrace = e.Demystify().ToString().Split(new[] { "\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries) - }; - } - - public class ErrorSource - { - [JsonProperty("pointer")] - public string Pointer { get; set; } - - [JsonProperty("parameter")] - public string Parameter { get; set; } - } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorCollection.cs similarity index 96% rename from src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs rename to src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorCollection.cs index 67fff7ec..4ce0b6cd 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorCollection.cs @@ -1,12 +1,11 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; +using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Models.JsonApiDocuments { public class ErrorCollection { diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs new file mode 100644 index 00000000..d459a95a --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.JsonApiDocuments +{ + public class ErrorLinks + { + [JsonProperty("about")] + public string About { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs new file mode 100644 index 00000000..c655f589 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.JsonApiDocuments +{ + public class ErrorMeta + { + [JsonProperty("stackTrace")] + public ICollection StackTrace { get; set; } + + public static ErrorMeta FromException(Exception e) + => new ErrorMeta { + StackTrace = e.Demystify().ToString().Split(new[] { "\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries) + }; + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs new file mode 100644 index 00000000..a64b2757 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.JsonApiDocuments +{ + public class ErrorSource + { + [JsonProperty("pointer")] + public string Pointer { get; set; } + + [JsonProperty("parameter")] + public string Parameter { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index cca8487f..11b6634d 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Serialization.Server { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs index 2cacb7c3..6c2c82cc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 3e56c9b1..9cb1d72f 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index d8f7905a..78b3e6eb 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -2,6 +2,7 @@ using System.Net; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; using Xunit; diff --git a/test/UnitTests/Internal/JsonApiException_Test.cs b/test/UnitTests/Internal/JsonApiException_Test.cs index e30a88d9..118443b3 100644 --- a/test/UnitTests/Internal/JsonApiException_Test.cs +++ b/test/UnitTests/Internal/JsonApiException_Test.cs @@ -1,5 +1,6 @@ using System.Net; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Xunit; namespace UnitTests.Internal diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 627f4b81..c6c614dc 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; using Xunit; using UnitTests.TestModels; From e4b93412124b3fe2e5719079e6e95c2fbf1dc8c6 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 14:59:46 +0200 Subject: [PATCH 07/60] Cleanup errors produced from model state validation --- .../Extensions/ModelStateExtensions.cs | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index b72818a6..5e5bee02 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -10,40 +10,46 @@ namespace JsonApiDotNetCore.Extensions { public static class ModelStateExtensions { - public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) where T : class, IIdentifiable + public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) + where TResource : class, IIdentifiable { ErrorCollection collection = new ErrorCollection(); - foreach (var entry in modelState) - { - if (entry.Value.Errors.Any() == false) - { - continue; - } - var targetedProperty = typeof(T).GetProperty(entry.Key); - var attrName = targetedProperty.GetCustomAttribute().PublicAttributeName; + foreach (var pair in modelState.Where(x => x.Value.Errors.Any())) + { + var propertyName = pair.Key; + PropertyInfo property = typeof(TResource).GetProperty(propertyName); + string attributeName = property?.GetCustomAttribute().PublicAttributeName; - foreach (var modelError in entry.Value.Errors) + foreach (var modelError in pair.Value.Errors) { - if (modelError.Exception is JsonApiException jex) + if (modelError.Exception is JsonApiException jsonApiException) { - collection.Errors.AddRange(jex.GetErrors().Errors); + collection.Errors.AddRange(jsonApiException.GetErrors().Errors); } else { - collection.Errors.Add(new Error( - status: HttpStatusCode.UnprocessableEntity, - title: entry.Key, - detail: modelError.ErrorMessage, - meta: modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null, - source: attrName == null ? null : new ErrorSource - { - Pointer = $"/data/attributes/{attrName}" - })); + collection.Errors.Add(FromModelError(modelError, propertyName, attributeName)); } } } + return collection; } + + private static Error FromModelError(ModelError modelError, string propertyName, string attributeName) + { + return new Error + { + Status = HttpStatusCode.UnprocessableEntity, + Title = "Input validation failed.", + Detail = propertyName + ": " + modelError.ErrorMessage, + Source = attributeName == null ? null : new ErrorSource + { + Pointer = $"/data/attributes/{attributeName}" + }, + Meta = modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null + }; + } } } From b851a776892eab21554ce9ebdb3ef6be38f2e98b Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 17:37:53 +0200 Subject: [PATCH 08/60] Renamed ErrorCollection to ErrorDocument, because it *contains* a collection of errors, instead of *being* one. Changed JsonApiException to contain a single error instead of a collection. --- .../Controllers/BaseJsonApiController.cs | 4 +- .../Controllers/JsonApiControllerMixin.cs | 10 +++-- .../Extensions/ModelStateExtensions.cs | 10 ++--- .../Formatters/JsonApiWriter.cs | 12 +++--- .../Internal/Exceptions/JsonApiException.cs | 40 ++++++++++-------- .../Middleware/DefaultExceptionFilter.cs | 7 ++-- .../Models/JsonApiDocuments/Error.cs | 12 ------ .../{ErrorCollection.cs => ErrorDocument.cs} | 25 +++++++---- .../Server/ResponseSerializer.cs | 4 +- .../Acceptance/Spec/QueryParameters.cs | 6 +-- .../BaseJsonApiController_Tests.cs | 18 ++++---- .../JsonApiControllerMixin_Tests.cs | 41 +++++++++---------- ...xception_Test.cs => ErrorDocumentTests.cs} | 16 ++++---- .../CurrentRequestMiddlewareTests.cs | 2 +- .../QueryParameters/PageServiceTests.cs | 4 +- .../SparseFieldsServiceTests.cs | 7 ++-- .../Server/ResponseSerializerTests.cs | 6 +-- 17 files changed, 112 insertions(+), 112 deletions(-) rename src/JsonApiDotNetCore/Models/JsonApiDocuments/{ErrorCollection.cs => ErrorDocument.cs} (76%) rename test/UnitTests/Internal/{JsonApiException_Test.cs => ErrorDocumentTests.cs} (55%) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 5f27d7e0..fcfa845e 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -120,7 +120,7 @@ public virtual async Task PostAsync([FromBody] T entity) return Forbidden(); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) - return UnprocessableEntity(ModelState.ConvertToErrorCollection()); + return UnprocessableEntity(ModelState.ConvertToErrorDocument()); entity = await _create.CreateAsync(entity); @@ -134,7 +134,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) return UnprocessableEntity(); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) - return UnprocessableEntity(ModelState.ConvertToErrorCollection()); + return UnprocessableEntity(ModelState.ConvertToErrorDocument()); var updatedEntity = await _update.UpdateAsync(id, entity); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index 1fb48a99..25f533c5 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -1,5 +1,6 @@ +using System.Collections.Generic; +using System.Linq; using System.Net; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; @@ -16,12 +17,13 @@ protected IActionResult Forbidden() protected IActionResult Error(Error error) { - return error.AsActionResult(); + return Errors(new[] {error}); } - protected IActionResult Errors(ErrorCollection errors) + protected IActionResult Errors(IEnumerable errors) { - return errors.AsActionResult(); + var document = new ErrorDocument(errors.ToList()); + return document.AsActionResult(); } } } diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index 5e5bee02..42102525 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -10,10 +10,10 @@ namespace JsonApiDotNetCore.Extensions { public static class ModelStateExtensions { - public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) + public static ErrorDocument ConvertToErrorDocument(this ModelStateDictionary modelState) where TResource : class, IIdentifiable { - ErrorCollection collection = new ErrorCollection(); + ErrorDocument document = new ErrorDocument(); foreach (var pair in modelState.Where(x => x.Value.Errors.Any())) { @@ -25,16 +25,16 @@ public static ErrorCollection ConvertToErrorCollection(this ModelStat { if (modelError.Exception is JsonApiException jsonApiException) { - collection.Errors.AddRange(jsonApiException.GetErrors().Errors); + document.Errors.Add(jsonApiException.Error); } else { - collection.Errors.Add(FromModelError(modelError, propertyName, attributeName)); + document.Errors.Add(FromModelError(modelError, propertyName, attributeName)); } } } - return collection; + return document; } private static Error FromModelError(ModelError modelError, string propertyName, string attributeName) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index fe5c84cf..ed280948 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -51,9 +51,9 @@ public async Task WriteAsync(OutputFormatterWriteContext context) { if (context.Object is ProblemDetails problemDetails) { - var errors = new ErrorCollection(); - errors.Add(ConvertProblemDetailsToError(problemDetails)); - responseContent = _serializer.Serialize(errors); + var document = new ErrorDocument(); + document.Errors.Add(ConvertProblemDetailsToError(problemDetails)); + responseContent = _serializer.Serialize(document); } else { responseContent = _serializer.Serialize(context.Object); @@ -62,9 +62,9 @@ public async Task WriteAsync(OutputFormatterWriteContext context) catch (Exception e) { _logger.LogError(new EventId(), e, "An error occurred while formatting the response"); - var errors = new ErrorCollection(); - errors.Add(new Error(HttpStatusCode.InternalServerError, e.Message, ErrorMeta.FromException(e))); - responseContent = _serializer.Serialize(errors); + var document = new ErrorDocument(); + document.Errors.Add(new Error(HttpStatusCode.InternalServerError, e.Message, ErrorMeta.FromException(e))); + responseContent = _serializer.Serialize(document); response.StatusCode = (int)HttpStatusCode.InternalServerError; } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index 49248a2a..2d3b559c 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -6,36 +6,40 @@ namespace JsonApiDotNetCore.Internal { public class JsonApiException : Exception { - private readonly ErrorCollection _errors = new ErrorCollection(); + public Error Error { get; } - public JsonApiException(ErrorCollection errorCollection) + public JsonApiException(Error error) + : base(error.Title) { - _errors = errorCollection; + Error = error; } - public JsonApiException(Error error) - : base(error.Title) => _errors.Add(error); - public JsonApiException(HttpStatusCode status, string message, ErrorSource source = null) - : base(message) - => _errors.Add(new Error(status, message, null, GetMeta(), source)); + : base(message) + { + Error = new Error(status, message, null, GetMeta(), source); + } public JsonApiException(HttpStatusCode status, string message, string detail, ErrorSource source = null) - : base(message) - => _errors.Add(new Error(status, message, detail, GetMeta(), source)); + : base(message) + { + Error = new Error(status, message, detail, GetMeta(), source); + } public JsonApiException(HttpStatusCode status, string message, Exception innerException) - : base(message, innerException) - => _errors.Add(new Error(status, message, innerException.Message, GetMeta(innerException))); - - public ErrorCollection GetErrors() => _errors; + : base(message, innerException) + { + Error = new Error(status, message, innerException.Message, GetMeta(innerException)); + } - public HttpStatusCode GetStatusCode() + private ErrorMeta GetMeta() { - return _errors.GetErrorStatusCode(); + return ErrorMeta.FromException(this); } - private ErrorMeta GetMeta() => ErrorMeta.FromException(this); - private ErrorMeta GetMeta(Exception e) => ErrorMeta.FromException(e); + private ErrorMeta GetMeta(Exception e) + { + return ErrorMeta.FromException(e); + } } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs index 115e218d..ede0a666 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; @@ -23,12 +24,10 @@ public void OnException(ExceptionContext context) var jsonApiException = JsonApiExceptionFactory.GetException(context.Exception); - var errors = jsonApiException.GetErrors(); - var result = new ObjectResult(errors) + context.Result = new ObjectResult(new ErrorDocument(jsonApiException.Error)) { - StatusCode = (int)jsonApiException.GetStatusCode() + StatusCode = (int) jsonApiException.Error.Status }; - context.Result = result; } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index ec2775ce..03204963 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models.JsonApiDocuments @@ -61,15 +59,5 @@ public string StatusText public bool ShouldSerializeMeta() => JsonApiOptions.DisableErrorStackTraces == false; public bool ShouldSerializeSource() => JsonApiOptions.DisableErrorSource == false; - - public IActionResult AsActionResult() - { - var errorCollection = new ErrorCollection - { - Errors = new List { this } - }; - - return errorCollection.AsActionResult(); - } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorCollection.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs similarity index 76% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorCollection.cs rename to src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs index 4ce0b6cd..0de731a5 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorCollection.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs @@ -7,23 +7,32 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { - public class ErrorCollection + public class ErrorDocument { - public ErrorCollection() - { + public IList Errors { get; } + + public ErrorDocument() + { Errors = new List(); } - - public List Errors { get; set; } - public void Add(Error error) + public ErrorDocument(Error error) + { + Errors = new List + { + error + }; + } + + public ErrorDocument(IList errors) { - Errors.Add(error); + Errors = errors; } public string GetJson() { - return JsonConvert.SerializeObject(this, new JsonSerializerSettings { + return JsonConvert.SerializeObject(this, new JsonSerializerSettings + { NullValueHandling = NullValueHandling.Ignore, ContractResolver = new CamelCasePropertyNamesContractResolver() }); diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index 11b6634d..42d73783 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -51,8 +51,8 @@ public ResponseSerializer(IMetaBuilder metaBuilder, /// public string Serialize(object data) { - if (data is ErrorCollection error) - return error.GetJson(); + if (data is ErrorDocument errorDocument) + return errorDocument.GetJson(); if (data is IEnumerable entities) return SerializeMany(entities); return SerializeSingle((IIdentifiable)data); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs index 6c2c82cc..707d4375 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs @@ -31,12 +31,12 @@ public async Task Server_Returns_400_ForUnknownQueryParam() // Act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var errorCollection = JsonConvert.DeserializeObject(body); + var errorDocument = JsonConvert.DeserializeObject(body); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Single(errorCollection.Errors); - Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", errorCollection.Errors[0].Title); + Assert.Single(errorDocument.Errors); + Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", errorDocument.Errors[0].Title); } } } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 9cb1d72f..17d68dfe 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -72,7 +72,7 @@ public async Task GetAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -102,7 +102,7 @@ public async Task GetAsyncById_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -132,7 +132,7 @@ public async Task GetRelationshipsAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -162,7 +162,7 @@ public async Task GetRelationshipAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -217,7 +217,7 @@ public async Task PatchAsync_ModelStateInvalid_ValidateModelStateEnabled() // Assert serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Never); Assert.IsType(response); - Assert.IsType(((UnprocessableEntityObjectResult)response).Value); + Assert.IsType(((UnprocessableEntityObjectResult)response).Value); } [Fact] @@ -231,7 +231,7 @@ public async Task PatchAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Patch, exception.Method); } @@ -297,7 +297,7 @@ public async Task PostAsync_ModelStateInvalid_ValidateModelStateEnabled() // Assert serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Never); Assert.IsType(response); - Assert.IsType(((UnprocessableEntityObjectResult)response).Value); + Assert.IsType(((UnprocessableEntityObjectResult)response).Value); } [Fact] @@ -326,7 +326,7 @@ public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Patch, exception.Method); } @@ -356,7 +356,7 @@ public async Task DeleteAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); Assert.Equal(HttpMethod.Delete, exception.Method); } } diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index 78b3e6eb..db7323c2 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -15,35 +15,32 @@ public sealed class JsonApiControllerMixin_Tests : JsonApiControllerMixin public void Errors_Correctly_Infers_Status_Code() { // Arrange - var errors422 = new ErrorCollection { - Errors = new List { - new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), - new Error(HttpStatusCode.UnprocessableEntity, "bad other specific"), - } + var errors422 = new List + { + new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), + new Error(HttpStatusCode.UnprocessableEntity, "bad other specific") }; - var errors400 = new ErrorCollection { - Errors = new List { - new Error(HttpStatusCode.OK, "weird"), - new Error(HttpStatusCode.BadRequest, "bad"), - new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), - } + var errors400 = new List + { + new Error(HttpStatusCode.OK, "weird"), + new Error(HttpStatusCode.BadRequest, "bad"), + new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), }; - var errors500 = new ErrorCollection { - Errors = new List { - new Error(HttpStatusCode.OK, "weird"), - new Error(HttpStatusCode.BadRequest, "bad"), - new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), - new Error(HttpStatusCode.InternalServerError, "really bad"), - new Error(HttpStatusCode.BadGateway, "really bad specific"), - } + var errors500 = new List + { + new Error(HttpStatusCode.OK, "weird"), + new Error(HttpStatusCode.BadRequest, "bad"), + new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), + new Error(HttpStatusCode.InternalServerError, "really bad"), + new Error(HttpStatusCode.BadGateway, "really bad specific"), }; // Act - var result422 = this.Errors(errors422); - var result400 = this.Errors(errors400); - var result500 = this.Errors(errors500); + var result422 = Errors(errors422); + var result400 = Errors(errors400); + var result500 = Errors(errors500); // Assert var response422 = Assert.IsType(result422); diff --git a/test/UnitTests/Internal/JsonApiException_Test.cs b/test/UnitTests/Internal/ErrorDocumentTests.cs similarity index 55% rename from test/UnitTests/Internal/JsonApiException_Test.cs rename to test/UnitTests/Internal/ErrorDocumentTests.cs index 118443b3..412ba257 100644 --- a/test/UnitTests/Internal/JsonApiException_Test.cs +++ b/test/UnitTests/Internal/ErrorDocumentTests.cs @@ -1,33 +1,33 @@ +using System.Collections.Generic; using System.Net; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; using Xunit; namespace UnitTests.Internal { - public sealed class JsonApiException_Test + public sealed class ErrorDocumentTests { [Fact] public void Can_GetStatusCode() { - var errors = new ErrorCollection(); - var exception = new JsonApiException(errors); + List errors = new List(); + var document = new ErrorDocument(errors); // Add First 422 error errors.Add(new Error(HttpStatusCode.UnprocessableEntity, "Something wrong")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.UnprocessableEntity, document.GetErrorStatusCode()); // Add a second 422 error errors.Add(new Error(HttpStatusCode.UnprocessableEntity, "Something else wrong")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.UnprocessableEntity, document.GetErrorStatusCode()); // Add 4xx error not 422 errors.Add(new Error(HttpStatusCode.Unauthorized, "Unauthorized")); - Assert.Equal(HttpStatusCode.BadRequest, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, document.GetErrorStatusCode()); // Add 5xx error not 4xx errors.Add(new Error(HttpStatusCode.BadGateway, "Not good")); - Assert.Equal(HttpStatusCode.InternalServerError, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.InternalServerError, document.GetErrorStatusCode()); } } } diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index aef323c1..6f681cdb 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -89,7 +89,7 @@ public async Task ParseUrlBase_UrlHasIncorrectBaseIdSet_ShouldThrowException(str { await task; }); - Assert.Equal(HttpStatusCode.BadRequest, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); Assert.Contains(baseId, exception.Message); } diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index 490c6f9e..87f527bd 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -48,7 +48,7 @@ public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximu if (shouldThrow) { var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(HttpStatusCode.BadRequest, ex.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } else { @@ -73,7 +73,7 @@ public void Parse_PageNumber_CanParse(string value, int expectedValue, int? maxi if (shouldThrow) { var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(HttpStatusCode.BadRequest, ex.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } else { diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 9dd42906..1668f6b8 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -3,6 +3,7 @@ using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -80,7 +81,7 @@ public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessag // Act, assert var ex = Assert.Throws(() => service.Parse(query)); Assert.Contains("relationships only", ex.Message); - Assert.Equal(HttpStatusCode.BadRequest, ex.GetErrors().GetErrorStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } [Fact] @@ -106,7 +107,7 @@ public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() // Act, assert var ex = Assert.Throws(() => service.Parse(query)); Assert.Contains("deeply nested", ex.Message); - Assert.Equal(HttpStatusCode.BadRequest, ex.GetErrors().GetErrorStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } [Fact] @@ -129,7 +130,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() // Act , assert var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(HttpStatusCode.BadRequest, ex.GetStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } } } diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index c6c614dc..0d22993b 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -455,8 +455,8 @@ public void SerializeError_CustomError_CanSerialize() { // Arrange var error = new CustomError(HttpStatusCode.InsufficientStorage, "title", "detail", "custom"); - var errorCollection = new ErrorCollection(); - errorCollection.Add(error); + var errorDocument = new ErrorDocument(); + errorDocument.Errors.Add(error); var expectedJson = JsonConvert.SerializeObject(new { @@ -475,7 +475,7 @@ public void SerializeError_CustomError_CanSerialize() var serializer = GetResponseSerializer(); // Act - var result = serializer.Serialize(errorCollection); + var result = serializer.Serialize(errorDocument); // Assert Assert.Equal(expectedJson, result); From e7df39ab129d9fc570943901e4e6dd99c780c0dd Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 30 Mar 2020 17:53:18 +0200 Subject: [PATCH 09/60] Removed custom error object; made json:api objects sealed --- .../Models/JsonApiDocuments/Error.cs | 2 +- .../Models/JsonApiDocuments/ErrorDocument.cs | 2 +- .../Models/JsonApiDocuments/ErrorLinks.cs | 2 +- .../Models/JsonApiDocuments/ErrorMeta.cs | 2 +- .../Models/JsonApiDocuments/ErrorSource.cs | 2 +- .../Models/JsonApiDocuments/ExposableData.cs | 2 +- .../Models/JsonApiDocuments/TopLevelLinks.cs | 2 +- .../Server/ResponseSerializerTests.cs | 15 ++------------- 8 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 03204963..60d00a1d 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { - public class Error + public sealed class Error { public Error() { } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs index 0de731a5..2bd78bbc 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { - public class ErrorDocument + public sealed class ErrorDocument { public IList Errors { get; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs index d459a95a..9d825516 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { - public class ErrorLinks + public sealed class ErrorLinks { [JsonProperty("about")] public string About { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs index c655f589..ffa344c3 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { - public class ErrorMeta + public sealed class ErrorMeta { [JsonProperty("stackTrace")] public ICollection StackTrace { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs index a64b2757..3273651b 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { - public class ErrorSource + public sealed class ErrorSource { [JsonProperty("pointer")] public string Pointer { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs index 567f24e9..1b7ac1c9 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models { - public class ExposableData where T : class + public abstract class ExposableData where T : class { /// /// see "primary data" in https://jsonapi.org/format/#document-top-level. diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs index 22c8d12f..adb2ddbc 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models.Links /// /// see links section in https://jsonapi.org/format/#document-top-level /// - public class TopLevelLinks + public sealed class TopLevelLinks { [JsonProperty("self")] public string Self { get; set; } diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 0d22993b..dd8c9c3a 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -451,10 +451,10 @@ public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_C } [Fact] - public void SerializeError_CustomError_CanSerialize() + public void SerializeError_Error_CanSerialize() { // Arrange - var error = new CustomError(HttpStatusCode.InsufficientStorage, "title", "detail", "custom"); + var error = new Error(HttpStatusCode.InsufficientStorage, "title", "detail"); var errorDocument = new ErrorDocument(); errorDocument.Errors.Add(error); @@ -464,7 +464,6 @@ public void SerializeError_CustomError_CanSerialize() { new { - myCustomProperty = "custom", id = error.Id, status = "507", title = "title", @@ -480,15 +479,5 @@ public void SerializeError_CustomError_CanSerialize() // Assert Assert.Equal(expectedJson, result); } - - private sealed class CustomError : Error - { - public CustomError(HttpStatusCode status, string title, string detail, string myProp) - : base(status, title, detail) - { - MyCustomProperty = myProp; - } - public string MyCustomProperty { get; set; } - } } } From 809a444d6e6c2ca9e2c598215597652bea72b7a8 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 31 Mar 2020 15:44:32 +0200 Subject: [PATCH 10/60] Removed ErrorSource from constructors and options, because it is almost always inappropriate to set. Note it is intended to indicate on which query string parameter the error applies or on which json path (example: /data/attributes/lastName). These being strings may make users believe they can put custom erorr details in them, which violates the json:api spec. ErrorSource should only be filled from model validation or query string parsing, and that should not be an option that can be disabled. --- src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs | 5 ----- .../Extensions/IApplicationBuilderExtensions.cs | 1 - .../Internal/Exceptions/JsonApiException.cs | 8 ++++---- src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs | 9 ++++----- .../Models/JsonApiDocuments/ErrorSource.cs | 6 ++++++ 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index d63701cc..5ddee498 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -32,11 +32,6 @@ public class JsonApiOptions : IJsonApiOptions /// public static bool DisableErrorStackTraces { get; set; } = true; - /// - /// Whether or not source URLs should be serialized in Error objects - /// - public static bool DisableErrorSource { get; set; } = true; - /// /// Whether or not ResourceHooks are enabled. /// diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 5525e744..f4e3cbd4 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -71,7 +71,6 @@ public static void UseJsonApi(this IApplicationBuilder app, bool skipRegisterMid public static void EnableDetailedErrors(this IApplicationBuilder app) { JsonApiOptions.DisableErrorStackTraces = false; - JsonApiOptions.DisableErrorSource = false; } private static void LogResourceGraphValidations(IApplicationBuilder app) diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index 2d3b559c..99c4ef8f 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -14,16 +14,16 @@ public JsonApiException(Error error) Error = error; } - public JsonApiException(HttpStatusCode status, string message, ErrorSource source = null) + public JsonApiException(HttpStatusCode status, string message) : base(message) { - Error = new Error(status, message, null, GetMeta(), source); + Error = new Error(status, message, null, GetMeta()); } - public JsonApiException(HttpStatusCode status, string message, string detail, ErrorSource source = null) + public JsonApiException(HttpStatusCode status, string message, string detail) : base(message) { - Error = new Error(status, message, detail, GetMeta(), source); + Error = new Error(status, message, detail, GetMeta()); } public JsonApiException(HttpStatusCode status, string message, Exception innerException) diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 60d00a1d..ffea4057 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -9,21 +9,19 @@ public sealed class Error { public Error() { } - public Error(HttpStatusCode status, string title, ErrorMeta meta = null, ErrorSource source = null) + public Error(HttpStatusCode status, string title, ErrorMeta meta = null) { Status = status; Title = title; Meta = meta; - Source = source; } - public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta = null, ErrorSource source = null) + public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta = null) { Status = status; Title = title; Detail = detail; Meta = meta; - Source = source; } [JsonProperty("id")] @@ -54,10 +52,11 @@ public string StatusText [JsonProperty("source")] public ErrorSource Source { get; set; } + public bool ShouldSerializeSource() => Source != null && (Source.Pointer != null || Source.Parameter != null); + [JsonProperty("meta")] public ErrorMeta Meta { get; set; } public bool ShouldSerializeMeta() => JsonApiOptions.DisableErrorStackTraces == false; - public bool ShouldSerializeSource() => JsonApiOptions.DisableErrorSource == false; } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs index 3273651b..ea426073 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs @@ -4,9 +4,15 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { public sealed class ErrorSource { + /// + /// Optional. A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. + /// [JsonProperty("pointer")] public string Pointer { get; set; } + /// + /// Optional. A string indicating which URI query parameter caused the error. + /// [JsonProperty("parameter")] public string Parameter { get; set; } } From e81d946b83e5389ea09582e517b62d693cc74f4e Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 31 Mar 2020 15:59:37 +0200 Subject: [PATCH 11/60] Added error documentation from json:api spec. --- .../Models/JsonApiDocuments/Error.cs | 30 +++++++++++++++++++ .../Models/JsonApiDocuments/ErrorLinks.cs | 3 ++ 2 files changed, 33 insertions(+) diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index ffea4057..2bd42256 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -5,6 +5,10 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { + /// + /// Provides additional information about a problem encountered while performing an operation. + /// Error objects MUST be returned as an array keyed by errors in the top level of a JSON:API document. + /// public sealed class Error { public Error() { } @@ -24,12 +28,23 @@ public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta Meta = meta; } + /// + /// A unique identifier for this particular occurrence of the problem. + /// [JsonProperty("id")] public string Id { get; set; } = Guid.NewGuid().ToString(); + /// + /// A link that leads to further details about this particular occurrence of the problem. + /// [JsonProperty("links")] public ErrorLinks Links { get; set; } + public bool ShouldSerializeLinks() => Links?.About != null; + + /// + /// The HTTP status code applicable to this problem. + /// [JsonIgnore] public HttpStatusCode Status { get; set; } @@ -40,20 +55,35 @@ public string StatusText set => Status = (HttpStatusCode)int.Parse(value); } + /// + /// An application-specific error code. + /// [JsonProperty("code")] public string Code { get; set; } + /// + /// A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. + /// [JsonProperty("title")] public string Title { get; set; } + /// + /// A human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. + /// [JsonProperty("detail")] public string Detail { get; set; } + /// + /// An object containing references to the source of the error. + /// [JsonProperty("source")] public ErrorSource Source { get; set; } public bool ShouldSerializeSource() => Source != null && (Source.Pointer != null || Source.Parameter != null); + /// + /// An object containing non-standard meta-information (key/value pairs) about the error. + /// [JsonProperty("meta")] public ErrorMeta Meta { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs index 9d825516..b2e807df 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs @@ -4,6 +4,9 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { public sealed class ErrorLinks { + /// + /// A URL that leads to further details about this particular occurrence of the problem. + /// [JsonProperty("about")] public string About { get; set; } } From 39b6f6fd594b673ce1b767112e705c6102dc2309 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 31 Mar 2020 16:29:42 +0200 Subject: [PATCH 12/60] Changed Error.Meta to contain multiple key/value pairs. Replaced static method. --- .../Extensions/ModelStateExtensions.cs | 11 ++++++-- .../Formatters/JsonApiWriter.cs | 13 +++++++--- .../Internal/Exceptions/JsonApiException.cs | 26 ++++++++++++------- .../Models/JsonApiDocuments/Error.cs | 9 +++---- .../Models/JsonApiDocuments/ErrorMeta.cs | 16 +++++++----- 5 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index 42102525..b40b2eb7 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -39,7 +39,7 @@ public static ErrorDocument ConvertToErrorDocument(this ModelStateDic private static Error FromModelError(ModelError modelError, string propertyName, string attributeName) { - return new Error + var error = new Error { Status = HttpStatusCode.UnprocessableEntity, Title = "Input validation failed.", @@ -48,8 +48,15 @@ private static Error FromModelError(ModelError modelError, string propertyName, { Pointer = $"/data/attributes/{attributeName}" }, - Meta = modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null }; + + if (modelError.Exception != null) + { + error.Meta = new ErrorMeta(); + error.Meta.IncludeExceptionStackTrace(modelError.Exception); + } + + return error; } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index ed280948..91d4128d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -51,8 +51,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) { if (context.Object is ProblemDetails problemDetails) { - var document = new ErrorDocument(); - document.Errors.Add(ConvertProblemDetailsToError(problemDetails)); + var document = new ErrorDocument(ConvertProblemDetailsToError(problemDetails)); responseContent = _serializer.Serialize(document); } else { @@ -62,8 +61,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) catch (Exception e) { _logger.LogError(new EventId(), e, "An error occurred while formatting the response"); - var document = new ErrorDocument(); - document.Errors.Add(new Error(HttpStatusCode.InternalServerError, e.Message, ErrorMeta.FromException(e))); + var document = new ErrorDocument(ConvertExceptionToError(e)); responseContent = _serializer.Serialize(document); response.StatusCode = (int)HttpStatusCode.InternalServerError; } @@ -72,6 +70,13 @@ public async Task WriteAsync(OutputFormatterWriteContext context) await writer.FlushAsync(); } + private static Error ConvertExceptionToError(Exception exception) + { + var error = new Error(HttpStatusCode.InternalServerError, exception.Message) {Meta = new ErrorMeta()}; + error.Meta.IncludeExceptionStackTrace(exception); + return error; + } + private static Error ConvertProblemDetailsToError(ProblemDetails problemDetails) { return new Error diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index 99c4ef8f..3cc6612c 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -17,29 +17,35 @@ public JsonApiException(Error error) public JsonApiException(HttpStatusCode status, string message) : base(message) { - Error = new Error(status, message, null, GetMeta()); + Error = new Error(status, message) + { + Meta = CreateErrorMeta(this) + }; } public JsonApiException(HttpStatusCode status, string message, string detail) : base(message) { - Error = new Error(status, message, detail, GetMeta()); + Error = new Error(status, message, detail) + { + Meta = CreateErrorMeta(this) + }; } public JsonApiException(HttpStatusCode status, string message, Exception innerException) : base(message, innerException) { - Error = new Error(status, message, innerException.Message, GetMeta(innerException)); + Error = new Error(status, message, innerException.Message) + { + Meta = CreateErrorMeta(innerException) + }; } - private ErrorMeta GetMeta() + private static ErrorMeta CreateErrorMeta(Exception exception) { - return ErrorMeta.FromException(this); - } - - private ErrorMeta GetMeta(Exception e) - { - return ErrorMeta.FromException(e); + var meta = new ErrorMeta(); + meta.IncludeExceptionStackTrace(exception); + return meta; } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 2bd42256..6b3935b9 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net; using JsonApiDotNetCore.Configuration; using Newtonsoft.Json; @@ -13,19 +14,17 @@ public sealed class Error { public Error() { } - public Error(HttpStatusCode status, string title, ErrorMeta meta = null) + public Error(HttpStatusCode status, string title) { Status = status; Title = title; - Meta = meta; } - public Error(HttpStatusCode status, string title, string detail, ErrorMeta meta = null) + public Error(HttpStatusCode status, string title, string detail) { Status = status; Title = title; Detail = detail; - Meta = meta; } /// @@ -87,6 +86,6 @@ public string StatusText [JsonProperty("meta")] public ErrorMeta Meta { get; set; } - public bool ShouldSerializeMeta() => JsonApiOptions.DisableErrorStackTraces == false; + public bool ShouldSerializeMeta() => Meta != null && Meta.Data.Any() && !JsonApiOptions.DisableErrorStackTraces; } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs index ffa344c3..24c825a9 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs @@ -5,14 +5,18 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { + /// + /// A meta object containing non-standard meta-information about the error. + /// public sealed class ErrorMeta { - [JsonProperty("stackTrace")] - public ICollection StackTrace { get; set; } + [JsonExtensionData] + public Dictionary Data { get; } = new Dictionary(); - public static ErrorMeta FromException(Exception e) - => new ErrorMeta { - StackTrace = e.Demystify().ToString().Split(new[] { "\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries) - }; + public void IncludeExceptionStackTrace(Exception exception) + { + Data["stackTrace"] = exception.Demystify().ToString() + .Split(new[] {"\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries); + } } } From aa2fee23e3545d8e12b4d38ff8dcb11d165a4408 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 31 Mar 2020 16:56:26 +0200 Subject: [PATCH 13/60] Cleanup Error constructor overloads (only accept required parameter) --- .../Extensions/ModelStateExtensions.cs | 3 +-- .../Formatters/JsonApiWriter.cs | 15 +++++++++----- .../Internal/Exceptions/JsonApiException.cs | 11 +++++++--- .../RequestMethodNotAllowedException.cs | 3 +-- .../Models/JsonApiDocuments/Error.cs | 12 +---------- .../JsonApiControllerMixin_Tests.cs | 20 +++++++++---------- test/UnitTests/Internal/ErrorDocumentTests.cs | 8 ++++---- .../Server/ResponseSerializerTests.cs | 2 +- 8 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index b40b2eb7..d2967a85 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -39,9 +39,8 @@ public static ErrorDocument ConvertToErrorDocument(this ModelStateDic private static Error FromModelError(ModelError modelError, string propertyName, string attributeName) { - var error = new Error + var error = new Error(HttpStatusCode.UnprocessableEntity) { - Status = HttpStatusCode.UnprocessableEntity, Title = "Input validation failed.", Detail = propertyName + ": " + modelError.ErrorMessage, Source = attributeName == null ? null : new ErrorSource diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 91d4128d..e12fcfed 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -72,14 +72,22 @@ public async Task WriteAsync(OutputFormatterWriteContext context) private static Error ConvertExceptionToError(Exception exception) { - var error = new Error(HttpStatusCode.InternalServerError, exception.Message) {Meta = new ErrorMeta()}; + var error = new Error(HttpStatusCode.InternalServerError) + { + Title = exception.Message, + Meta = new ErrorMeta() + }; error.Meta.IncludeExceptionStackTrace(exception); return error; } private static Error ConvertProblemDetailsToError(ProblemDetails problemDetails) { - return new Error + var status = problemDetails.Status != null + ? (HttpStatusCode)problemDetails.Status.Value + : HttpStatusCode.InternalServerError; + + return new Error(status) { Id = !string.IsNullOrWhiteSpace(problemDetails.Instance) ? problemDetails.Instance @@ -87,9 +95,6 @@ private static Error ConvertProblemDetailsToError(ProblemDetails problemDetails) Links = !string.IsNullOrWhiteSpace(problemDetails.Type) ? new ErrorLinks {About = problemDetails.Type} : null, - Status = problemDetails.Status != null - ? (HttpStatusCode)problemDetails.Status.Value - : HttpStatusCode.InternalServerError, Title = problemDetails.Title, Detail = problemDetails.Detail }; diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index 3cc6612c..093cd622 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -17,8 +17,9 @@ public JsonApiException(Error error) public JsonApiException(HttpStatusCode status, string message) : base(message) { - Error = new Error(status, message) + Error = new Error(status) { + Title = message, Meta = CreateErrorMeta(this) }; } @@ -26,8 +27,10 @@ public JsonApiException(HttpStatusCode status, string message) public JsonApiException(HttpStatusCode status, string message, string detail) : base(message) { - Error = new Error(status, message, detail) + Error = new Error(status) { + Title = message, + Detail = detail, Meta = CreateErrorMeta(this) }; } @@ -35,8 +38,10 @@ public JsonApiException(HttpStatusCode status, string message, string detail) public JsonApiException(HttpStatusCode status, string message, Exception innerException) : base(message, innerException) { - Error = new Error(status, message, innerException.Message) + Error = new Error(status) { + Title = message, + Detail = innerException.Message, Meta = CreateErrorMeta(innerException) }; } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs index 939dc22e..abb9698a 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs @@ -9,9 +9,8 @@ public sealed class RequestMethodNotAllowedException : JsonApiException public HttpMethod Method { get; } public RequestMethodNotAllowedException(HttpMethod method) - : base(new Error + : base(new Error(HttpStatusCode.MethodNotAllowed) { - Status = HttpStatusCode.MethodNotAllowed, Title = "The request method is not allowed.", Detail = $"Resource does not support {method} requests." }) diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 6b3935b9..9a8a3be4 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -12,19 +12,9 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments /// public sealed class Error { - public Error() { } - - public Error(HttpStatusCode status, string title) - { - Status = status; - Title = title; - } - - public Error(HttpStatusCode status, string title, string detail) + public Error(HttpStatusCode status) { Status = status; - Title = title; - Detail = detail; } /// diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index db7323c2..7b625065 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -17,24 +17,24 @@ public void Errors_Correctly_Infers_Status_Code() // Arrange var errors422 = new List { - new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), - new Error(HttpStatusCode.UnprocessableEntity, "bad other specific") + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad other specific"} }; var errors400 = new List { - new Error(HttpStatusCode.OK, "weird"), - new Error(HttpStatusCode.BadRequest, "bad"), - new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), + new Error(HttpStatusCode.OK) {Title = "weird"}, + new Error(HttpStatusCode.BadRequest) {Title = "bad"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, }; var errors500 = new List { - new Error(HttpStatusCode.OK, "weird"), - new Error(HttpStatusCode.BadRequest, "bad"), - new Error(HttpStatusCode.UnprocessableEntity, "bad specific"), - new Error(HttpStatusCode.InternalServerError, "really bad"), - new Error(HttpStatusCode.BadGateway, "really bad specific"), + new Error(HttpStatusCode.OK) {Title = "weird"}, + new Error(HttpStatusCode.BadRequest) {Title = "bad"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, + new Error(HttpStatusCode.InternalServerError) {Title = "really bad"}, + new Error(HttpStatusCode.BadGateway) {Title = "really bad specific"}, }; // Act diff --git a/test/UnitTests/Internal/ErrorDocumentTests.cs b/test/UnitTests/Internal/ErrorDocumentTests.cs index 412ba257..42817287 100644 --- a/test/UnitTests/Internal/ErrorDocumentTests.cs +++ b/test/UnitTests/Internal/ErrorDocumentTests.cs @@ -14,19 +14,19 @@ public void Can_GetStatusCode() var document = new ErrorDocument(errors); // Add First 422 error - errors.Add(new Error(HttpStatusCode.UnprocessableEntity, "Something wrong")); + errors.Add(new Error(HttpStatusCode.UnprocessableEntity) {Title = "Something wrong"}); Assert.Equal(HttpStatusCode.UnprocessableEntity, document.GetErrorStatusCode()); // Add a second 422 error - errors.Add(new Error(HttpStatusCode.UnprocessableEntity, "Something else wrong")); + errors.Add(new Error(HttpStatusCode.UnprocessableEntity) {Title = "Something else wrong"}); Assert.Equal(HttpStatusCode.UnprocessableEntity, document.GetErrorStatusCode()); // Add 4xx error not 422 - errors.Add(new Error(HttpStatusCode.Unauthorized, "Unauthorized")); + errors.Add(new Error(HttpStatusCode.Unauthorized) {Title = "Unauthorized"}); Assert.Equal(HttpStatusCode.BadRequest, document.GetErrorStatusCode()); // Add 5xx error not 4xx - errors.Add(new Error(HttpStatusCode.BadGateway, "Not good")); + errors.Add(new Error(HttpStatusCode.BadGateway) {Title = "Not good"}); Assert.Equal(HttpStatusCode.InternalServerError, document.GetErrorStatusCode()); } } diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index dd8c9c3a..02307143 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -454,7 +454,7 @@ public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_C public void SerializeError_Error_CanSerialize() { // Arrange - var error = new Error(HttpStatusCode.InsufficientStorage, "title", "detail"); + var error = new Error(HttpStatusCode.InsufficientStorage) {Title = "title", Detail = "detail"}; var errorDocument = new ErrorDocument(); errorDocument.Errors.Add(error); From f057b8b80108333f94da2617a4787ca340b75a99 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 1 Apr 2020 22:10:33 +0200 Subject: [PATCH 14/60] Fixed broken modelstate validation; converted unit tests into TestServer tests --- .../JsonApiDotNetCoreExample/Models/Tag.cs | 4 +- .../Startups/Startup.cs | 1 + .../Builders/JsonApiApplicationBuilder.cs | 6 + .../Extensions/ModelStateExtensions.cs | 4 +- .../Acceptance/ModelStateValidationTests.cs | 171 ++++++++++++++++++ .../BaseJsonApiController_Tests.cs | 84 --------- 6 files changed, 183 insertions(+), 87 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 5ccb57a1..5bd05256 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCoreExample.Models @@ -5,6 +6,7 @@ namespace JsonApiDotNetCoreExample.Models public class Tag : Identifiable { [Attr] + [MaxLength(15)] public string Name { get; set; } } -} \ No newline at end of file +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 5c7a7164..0bc87e33 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -38,6 +38,7 @@ public virtual void ConfigureServices(IServiceCollection services) options.DefaultPageSize = 5; options.IncludeTotalRecordCount = true; options.LoadDatabaseValues = true; + options.ValidateModelState = true; }, discovery => discovery.AddCurrentAssembly()); // once all tests have been moved to WebApplicationFactory format we can get rid of this line below diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index 2b7b023d..bbd64333 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -74,6 +74,12 @@ public void ConfigureMvc() options.OutputFormatters.Insert(0, new JsonApiOutputFormatter()); options.Conventions.Insert(0, routingConvention); }); + + if (JsonApiOptions.ValidateModelState) + { + _mvcBuilder.AddDataAnnotations(); + } + _services.AddSingleton(routingConvention); } diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index d2967a85..d6188963 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -19,7 +19,7 @@ public static ErrorDocument ConvertToErrorDocument(this ModelStateDic { var propertyName = pair.Key; PropertyInfo property = typeof(TResource).GetProperty(propertyName); - string attributeName = property?.GetCustomAttribute().PublicAttributeName; + string attributeName = property?.GetCustomAttribute().PublicAttributeName ?? property?.Name; foreach (var modelError in pair.Value.Errors) { @@ -42,7 +42,7 @@ private static Error FromModelError(ModelError modelError, string propertyName, var error = new Error(HttpStatusCode.UnprocessableEntity) { Title = "Input validation failed.", - Detail = propertyName + ": " + modelError.ErrorMessage, + Detail = modelError.ErrorMessage, Source = attributeName == null ? null : new ErrorSource { Pointer = $"/data/attributes/{attributeName}" diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs new file mode 100644 index 00000000..6b47eeaf --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Acceptance.Spec; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + public sealed class ModelStateValidationTests : FunctionalTestCollection + { + public ModelStateValidationTests(StandardApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task When_posting_tag_with_long_name_it_must_fail() + { + // Arrange + var tag = new Tag + { + Name = new string('X', 50) + }; + + var serializer = GetSerializer(); + var content = serializer.Serialize(tag); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/tags") + { + Content = new StringContent(content) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + + var options = (JsonApiOptions)_factory.GetService(); + options.ValidateModelState = true; + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("422", errorDocument.Errors[0].StatusText); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("The field Name must be a string or array type with a maximum length of '15'.", errorDocument.Errors[0].Detail); + Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); + } + + [Fact] + public async Task When_posting_tag_with_long_name_without_model_state_validation_it_must_succeed() + { + // Arrange + var tag = new Tag + { + Name = new string('X', 50) + }; + + var serializer = GetSerializer(); + var content = serializer.Serialize(tag); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/tags") + { + Content = new StringContent(content) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + + var options = (JsonApiOptions)_factory.GetService(); + options.ValidateModelState = false; + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task When_patching_tag_with_long_name_it_must_fail() + { + // Arrange + var existingTag = new Tag + { + Name = "Technology" + }; + + var context = _factory.GetService(); + context.Tags.Add(existingTag); + context.SaveChanges(); + + var updatedTag = new Tag + { + Id = existingTag.Id, + Name = new string('X', 50) + }; + + var serializer = GetSerializer(); + var content = serializer.Serialize(updatedTag); + + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/tags/" + existingTag.StringId) + { + Content = new StringContent(content) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + + var options = (JsonApiOptions)_factory.GetService(); + options.ValidateModelState = true; + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("422", errorDocument.Errors[0].StatusText); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("The field Name must be a string or array type with a maximum length of '15'.", errorDocument.Errors[0].Detail); + Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); + } + + [Fact] + public async Task When_patching_tag_with_long_name_without_model_state_validation_it_must_succeed() + { + // Arrange + var existingTag = new Tag + { + Name = "Technology" + }; + + var context = _factory.GetService(); + context.Tags.Add(existingTag); + context.SaveChanges(); + + var updatedTag = new Tag + { + Id = existingTag.Id, + Name = new string('X', 50) + }; + + var serializer = GetSerializer(); + var content = serializer.Serialize(updatedTag); + + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/tags/" + existingTag.StringId) + { + Content = new StringContent(content) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + + var options = (JsonApiOptions)_factory.GetService(); + options.ValidateModelState = false; + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } +} diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 17d68dfe..17ff44ee 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -183,43 +183,6 @@ public async Task PatchAsync_Calls_Service() serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Once); } - [Fact] - public async Task PatchAsync_ModelStateInvalid_ValidateModelStateDisabled() - { - // Arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, update: serviceMock.Object); - - // Act - var response = await controller.PatchAsync(id, resource); - - // Assert - serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Once); - Assert.IsNotType(response); - } - - [Fact] - public async Task PatchAsync_ModelStateInvalid_ValidateModelStateEnabled() - { - // Arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - - var controller = new ResourceController(new JsonApiOptions { ValidateModelState = true }, NullLoggerFactory.Instance, update: serviceMock.Object); - controller.ModelState.AddModelError("TestAttribute", "Failed Validation"); - - // Act - var response = await controller.PatchAsync(id, resource); - - // Assert - serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Never); - Assert.IsType(response); - Assert.IsType(((UnprocessableEntityObjectResult)response).Value); - } - [Fact] public async Task PatchAsync_Throws_405_If_No_Service() { @@ -253,53 +216,6 @@ public async Task PostAsync_Calls_Service() serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); } - [Fact] - public async Task PostAsync_ModelStateInvalid_ValidateModelStateDisabled() - { - // Arrange - var resource = new Resource(); - var serviceMock = new Mock>(); - var controller = new ResourceController(new JsonApiOptions {ValidateModelState = false}, - NullLoggerFactory.Instance, create: serviceMock.Object) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext() - } - }; - serviceMock.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(resource); - - // Act - var response = await controller.PostAsync(resource); - - // Assert - serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); - Assert.IsNotType(response); - } - - [Fact] - public async Task PostAsync_ModelStateInvalid_ValidateModelStateEnabled() - { - // Arrange - var resource = new Resource(); - var serviceMock = new Mock>(); - var controller = new ResourceController(new JsonApiOptions {ValidateModelState = true}, - NullLoggerFactory.Instance, create: serviceMock.Object) - { - ControllerContext = new ControllerContext {HttpContext = new DefaultHttpContext()} - }; - controller.ModelState.AddModelError("TestAttribute", "Failed Validation"); - serviceMock.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(resource); - - // Act - var response = await controller.PostAsync(resource); - - // Assert - serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Never); - Assert.IsType(response); - Assert.IsType(((UnprocessableEntityObjectResult)response).Value); - } - [Fact] public async Task PatchRelationshipsAsync_Calls_Service() { From d8b2ae52206f02e60d8ff62dbf4bc65dc602a4f5 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 1 Apr 2020 23:30:50 +0200 Subject: [PATCH 15/60] Major rewrite for error handling: - All errors are now directed to IExceptionHandler, which translates Exception into Error response and logs at level based on Exception type - Added example that overrides Error response and changes log level - Replaced options.DisableErrorStackTraces with IncludeExceptionStackTraceInErrors; no longer static -- this option is used by IExceptionHandler - Added IAlwaysRunResultFilter to replace NotFound() with NotFound(null) so it hits our output formatter (workaround for https://github.com/dotnet/aspnetcore/issues/16969) - Fixes #655: throw instead of writing to ModelState if input invalid - Added test that uses [ApiController], which translates ActionResults into ProblemDetails --- .../Controllers/TodoItemsCustomController.cs | 1 + .../Startups/Startup.cs | 2 +- .../JsonApiDotNetCoreExample/web.config | 14 --- .../Builders/JsonApiApplicationBuilder.cs | 3 +- .../Configuration/IJsonApiOptions.cs | 8 ++ .../Configuration/JsonApiOptions.cs | 7 +- .../Controllers/BaseJsonApiController.cs | 18 ++- .../IApplicationBuilderExtensions.cs | 10 -- .../Formatters/JsonApiReader.cs | 48 +++----- .../Formatters/JsonApiWriter.cs | 76 +++++------- .../Exceptions/ActionResultException.cs | 47 +++++++ .../Exceptions/InvalidModelStateException.cs} | 38 +++--- .../Exceptions/InvalidRequestBodyException.cs | 19 +++ .../InvalidResponseBodyException.cs | 17 +++ .../Internal/Exceptions/JsonApiException.cs | 22 ++-- .../Exceptions/JsonApiExceptionFactory.cs | 18 --- .../ConvertEmptyActionResultFilter.cs | 30 +++++ .../Middleware/DefaultExceptionFilter.cs | 19 ++- .../Middleware/DefaultExceptionHandler.cs | 74 +++++++++++ .../Middleware/IExceptionHandler.cs | 13 ++ .../Models/JsonApiDocuments/Error.cs | 11 +- .../Extensibility/CustomControllerTests.cs | 26 ++++ .../Extensibility/CustomErrorHandlingTests.cs | 115 ++++++++++++++++++ .../Acceptance/Spec/FetchingDataTests.cs | 12 +- .../Acceptance/Spec/UpdatingDataTests.cs | 50 +++++++- 25 files changed, 513 insertions(+), 185 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/web.config create mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs rename src/JsonApiDotNetCore/{Extensions/ModelStateExtensions.cs => Internal/Exceptions/InvalidModelStateException.cs} (53%) create mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs create mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs create mode 100644 src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs create mode 100644 src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs create mode 100644 src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 8ee6f5e4..0ada9700 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -11,6 +11,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + [ApiController] [DisableRoutingConvention, Route("custom/route/todoItems")] public class TodoItemsCustomController : CustomJsonApiController { diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 0bc87e33..acc005e3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -34,6 +34,7 @@ public virtual void ConfigureServices(IServiceCollection services) }, ServiceLifetime.Transient) .AddJsonApi(options => { + options.IncludeExceptionStackTraceInErrors = true; options.Namespace = "api/v1"; options.DefaultPageSize = 5; options.IncludeTotalRecordCount = true; @@ -50,7 +51,6 @@ public void Configure( AppDbContext context) { context.Database.EnsureCreated(); - app.EnableDetailedErrors(); app.UseJsonApi(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/web.config b/src/Examples/JsonApiDotNetCoreExample/web.config deleted file mode 100644 index 50d0b027..00000000 --- a/src/Examples/JsonApiDotNetCoreExample/web.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index bbd64333..8ff48140 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -70,6 +70,7 @@ public void ConfigureMvc() options.EnableEndpointRouting = true; options.Filters.Add(exceptionFilterProvider.Get()); options.Filters.Add(typeMatchFilterProvider.Get()); + options.Filters.Add(new ConvertEmptyActionResultFilter()); options.InputFormatters.Insert(0, new JsonApiInputFormatter()); options.OutputFormatters.Insert(0, new JsonApiOutputFormatter()); options.Conventions.Insert(0, routingConvention); @@ -146,9 +147,9 @@ public void ConfigureServices() _services.AddSingleton(JsonApiOptions); _services.AddSingleton(resourceGraph); _services.AddSingleton(); - _services.AddSingleton(resourceGraph); _services.AddSingleton(resourceGraph); _services.AddSingleton(); + _services.AddSingleton(); _services.AddScoped(); _services.AddScoped(); diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 591545f4..c5843683 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,7 +1,15 @@ +using System; +using JsonApiDotNetCore.Models.JsonApiDocuments; + namespace JsonApiDotNetCore.Configuration { public interface IJsonApiOptions : ILinksConfiguration, ISerializerOptions { + /// + /// Whether or not stack traces should be serialized in objects. + /// + bool IncludeExceptionStackTraceInErrors { get; set; } + /// /// Whether or not database values should be included by default /// for resource hooks. Ignored if EnableResourceHooks is set false. diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 5ddee498..c791b56a 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -9,7 +9,6 @@ namespace JsonApiDotNetCore.Configuration /// public class JsonApiOptions : IJsonApiOptions { - /// public bool RelativeLinks { get; set; } = false; @@ -27,10 +26,8 @@ public class JsonApiOptions : IJsonApiOptions /// public static IRelatedIdMapper RelatedIdMapper { get; set; } = new DefaultRelatedIdMapper(); - /// - /// Whether or not stack traces should be serialized in Error objects - /// - public static bool DisableErrorStackTraces { get; set; } = true; + /// + public bool IncludeExceptionStackTraceInErrors { get; set; } = false; /// /// Whether or not ResourceHooks are enabled. diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index fcfa845e..2d37c167 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,9 +1,11 @@ +using System; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -79,9 +81,7 @@ public virtual async Task GetAsync(TId id) var entity = await _getById.GetAsync(id); if (entity == null) { - // remove the null argument as soon as this has been resolved: - // https://github.com/aspnet/AspNetCore/issues/16969 - return NotFound(null); + return NotFound(); } return Ok(entity); @@ -93,9 +93,7 @@ public virtual async Task GetRelationshipsAsync(TId id, string re var relationship = await _getRelationships.GetRelationshipsAsync(id, relationshipName); if (relationship == null) { - // remove the null argument as soon as this has been resolved: - // https://github.com/aspnet/AspNetCore/issues/16969 - return NotFound(null); + return NotFound(); } return Ok(relationship); @@ -120,7 +118,7 @@ public virtual async Task PostAsync([FromBody] T entity) return Forbidden(); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) - return UnprocessableEntity(ModelState.ConvertToErrorDocument()); + throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions); entity = await _create.CreateAsync(entity); @@ -134,15 +132,13 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) return UnprocessableEntity(); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) - return UnprocessableEntity(ModelState.ConvertToErrorDocument()); + throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions); var updatedEntity = await _update.UpdateAsync(id, entity); if (updatedEntity == null) { - // remove the null argument as soon as this has been resolved: - // https://github.com/aspnet/AspNetCore/issues/16969 - return NotFound(null); + return NotFound(); } diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index f4e3cbd4..2ff5e73a 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Middleware; @@ -64,15 +63,6 @@ public static void UseJsonApi(this IApplicationBuilder app, bool skipRegisterMid } } - /// - /// Configures your application to return stack traces in error results. - /// - /// - public static void EnableDetailedErrors(this IApplicationBuilder app) - { - JsonApiOptions.DisableErrorStackTraces = false; - } - private static void LogResourceGraphValidations(IApplicationBuilder app) { var logger = app.ApplicationServices.GetService(typeof(ILogger)) as ILogger; diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index af29def7..68914cf8 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.IO; -using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -26,7 +25,7 @@ public JsonApiReader(IJsonApiDeserializer deserializer, _logger.LogTrace("Executing constructor."); } - public async Task ReadAsync(InputFormatterContext context) + public async Task ReadAsync(InputFormatterContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); @@ -37,39 +36,28 @@ public async Task ReadAsync(InputFormatterContext context return await InputFormatterResult.SuccessAsync(null); } + string body = await GetRequestBody(context.HttpContext.Request.Body); + + object model; try { - var body = await GetRequestBody(context.HttpContext.Request.Body); - object model = _deserializer.Deserialize(body); - if (model == null) - { - _logger.LogError("An error occurred while de-serializing the payload"); - } - if (context.HttpContext.Request.Method == "PATCH") - { - bool idMissing; - if (model is IList list) - { - idMissing = CheckForId(list); - } - else - { - idMissing = CheckForId(model); - } - if (idMissing) - { - _logger.LogError("Payload must include id attribute"); - throw new JsonApiException(HttpStatusCode.BadRequest, "Payload must include id attribute"); - } - } - return await InputFormatterResult.SuccessAsync(model); + model = _deserializer.Deserialize(body); } - catch (Exception ex) + catch (Exception exception) { - _logger.LogError(new EventId(), ex, "An error occurred while de-serializing the payload"); - context.ModelState.AddModelError(context.ModelName, ex, context.Metadata); - return await InputFormatterResult.FailureAsync(); + throw new InvalidRequestBodyException(null, exception); } + + if (context.HttpContext.Request.Method == "PATCH") + { + var hasMissingId = model is IList list ? CheckForId(list) : CheckForId(model); + if (hasMissingId) + { + throw new InvalidRequestBodyException("Payload must include id attribute."); + } + } + + return await InputFormatterResult.SuccessAsync(model); } /// Checks if the deserialized payload has an ID included diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index e12fcfed..c234b17c 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -1,13 +1,14 @@ using System; using System.Net; +using System.Net.Http; using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Internal.Exceptions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace JsonApiDotNetCore.Formatters @@ -15,20 +16,16 @@ namespace JsonApiDotNetCore.Formatters /// /// Formats the response data used https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting?view=aspnetcore-3.0. /// It was intended to have as little dependencies as possible in formatting layer for greater extensibility. - /// It only depends on . /// public class JsonApiWriter : IJsonApiWriter { - private readonly ILogger _logger; private readonly IJsonApiSerializer _serializer; + private readonly IExceptionHandler _exceptionHandler; - public JsonApiWriter(IJsonApiSerializer serializer, - ILoggerFactory loggerFactory) + public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler) { _serializer = serializer; - _logger = loggerFactory.CreateLogger(); - - _logger.LogTrace("Executing constructor."); + _exceptionHandler = exceptionHandler; } public async Task WriteAsync(OutputFormatterWriteContext context) @@ -49,55 +46,44 @@ public async Task WriteAsync(OutputFormatterWriteContext context) response.ContentType = Constants.ContentType; try { - if (context.Object is ProblemDetails problemDetails) - { - var document = new ErrorDocument(ConvertProblemDetailsToError(problemDetails)); - responseContent = _serializer.Serialize(document); - } else - { - responseContent = _serializer.Serialize(context.Object); - } + responseContent = SerializeResponse(context.Object, (HttpStatusCode)response.StatusCode); } - catch (Exception e) + catch (Exception exception) { - _logger.LogError(new EventId(), e, "An error occurred while formatting the response"); - var document = new ErrorDocument(ConvertExceptionToError(e)); - responseContent = _serializer.Serialize(document); - response.StatusCode = (int)HttpStatusCode.InternalServerError; + var errorDocument = _exceptionHandler.HandleException(exception); + responseContent = _serializer.Serialize(errorDocument); } } + await writer.WriteAsync(responseContent); await writer.FlushAsync(); } - private static Error ConvertExceptionToError(Exception exception) + private string SerializeResponse(object contextObject, HttpStatusCode statusCode) { - var error = new Error(HttpStatusCode.InternalServerError) + if (contextObject is ProblemDetails problemDetails) { - Title = exception.Message, - Meta = new ErrorMeta() - }; - error.Meta.IncludeExceptionStackTrace(exception); - return error; - } + throw new ActionResultException(problemDetails); + } - private static Error ConvertProblemDetailsToError(ProblemDetails problemDetails) - { - var status = problemDetails.Status != null - ? (HttpStatusCode)problemDetails.Status.Value - : HttpStatusCode.InternalServerError; + if (contextObject == null && !IsSuccessStatusCode(statusCode)) + { + throw new ActionResultException(statusCode); + } - return new Error(status) + try { - Id = !string.IsNullOrWhiteSpace(problemDetails.Instance) - ? problemDetails.Instance - : Guid.NewGuid().ToString(), - Links = !string.IsNullOrWhiteSpace(problemDetails.Type) - ? new ErrorLinks {About = problemDetails.Type} - : null, - Title = problemDetails.Title, - Detail = problemDetails.Detail - }; + return _serializer.Serialize(contextObject); + } + catch (Exception exception) + { + throw new InvalidResponseBodyException(exception); + } + } + + private bool IsSuccessStatusCode(HttpStatusCode statusCode) + { + return new HttpResponseMessage(statusCode).IsSuccessStatusCode; } } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs new file mode 100644 index 00000000..6c6dc87e --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs @@ -0,0 +1,47 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCore.Internal.Exceptions +{ + public sealed class ActionResultException : JsonApiException + { + public ActionResultException(HttpStatusCode status) + : base(new Error(status) + { + Title = status.ToString() + }) + { + } + + public ActionResultException(ProblemDetails problemDetails) + : base(ToError(problemDetails)) + { + } + + private static Error ToError(ProblemDetails problemDetails) + { + var status = problemDetails.Status != null + ? (HttpStatusCode) problemDetails.Status.Value + : HttpStatusCode.InternalServerError; + + var error = new Error(status) + { + Title = problemDetails.Title, + Detail = problemDetails.Detail + }; + + if (!string.IsNullOrWhiteSpace(problemDetails.Instance)) + { + error.Id = problemDetails.Instance; + } + + if (!string.IsNullOrWhiteSpace(problemDetails.Type)) + { + error.Links.About = problemDetails.Type; + } + + return error; + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs similarity index 53% rename from src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs rename to src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs index d6188963..7cdbdbb2 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs @@ -1,43 +1,54 @@ +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Reflection; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace JsonApiDotNetCore.Extensions +namespace JsonApiDotNetCore.Internal { - public static class ModelStateExtensions + public class InvalidModelStateException : Exception { - public static ErrorDocument ConvertToErrorDocument(this ModelStateDictionary modelState) - where TResource : class, IIdentifiable + public IList Errors { get; } + + public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, IJsonApiOptions options) { - ErrorDocument document = new ErrorDocument(); + Errors = FromModelState(modelState, resourceType, options); + } + + private static List FromModelState(ModelStateDictionary modelState, Type resourceType, + IJsonApiOptions options) + { + List errors = new List(); foreach (var pair in modelState.Where(x => x.Value.Errors.Any())) { var propertyName = pair.Key; - PropertyInfo property = typeof(TResource).GetProperty(propertyName); + PropertyInfo property = resourceType.GetProperty(propertyName); + + // TODO: Need access to ResourceContext here, in order to determine attribute name when not explicitly set. string attributeName = property?.GetCustomAttribute().PublicAttributeName ?? property?.Name; foreach (var modelError in pair.Value.Errors) { if (modelError.Exception is JsonApiException jsonApiException) { - document.Errors.Add(jsonApiException.Error); + errors.Add(jsonApiException.Error); } else { - document.Errors.Add(FromModelError(modelError, propertyName, attributeName)); + errors.Add(FromModelError(modelError, attributeName, options)); } } } - return document; + return errors; } - private static Error FromModelError(ModelError modelError, string propertyName, string attributeName) + private static Error FromModelError(ModelError modelError, string attributeName, IJsonApiOptions options) { var error = new Error(HttpStatusCode.UnprocessableEntity) { @@ -46,12 +57,11 @@ private static Error FromModelError(ModelError modelError, string propertyName, Source = attributeName == null ? null : new ErrorSource { Pointer = $"/data/attributes/{attributeName}" - }, + } }; - if (modelError.Exception != null) + if (options.IncludeExceptionStackTraceInErrors && modelError.Exception != null) { - error.Meta = new ErrorMeta(); error.Meta.IncludeExceptionStackTrace(modelError.Exception); } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs new file mode 100644 index 00000000..9969c0a1 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs @@ -0,0 +1,19 @@ +using System; +using System.Net; +using System.Net.Http; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Internal +{ + public sealed class InvalidRequestBodyException : JsonApiException + { + public InvalidRequestBodyException(string message, Exception innerException = null) + : base(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = message ?? "Failed to deserialize request body.", + Detail = innerException?.Message + }, innerException) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs new file mode 100644 index 00000000..01d60be4 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs @@ -0,0 +1,17 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Internal.Exceptions +{ + public sealed class InvalidResponseBodyException : JsonApiException + { + public InvalidResponseBodyException(Exception innerException) : base(new Error(HttpStatusCode.InternalServerError) + { + Title = "Failed to serialize response body.", + Detail = innerException.Message + }, innerException) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs index 093cd622..4e8ccb04 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs @@ -14,13 +14,18 @@ public JsonApiException(Error error) Error = error; } + public JsonApiException(Error error, Exception innerException) + : base(error.Title, innerException) + { + Error = error; + } + public JsonApiException(HttpStatusCode status, string message) : base(message) { Error = new Error(status) { - Title = message, - Meta = CreateErrorMeta(this) + Title = message }; } @@ -30,8 +35,7 @@ public JsonApiException(HttpStatusCode status, string message, string detail) Error = new Error(status) { Title = message, - Detail = detail, - Meta = CreateErrorMeta(this) + Detail = detail }; } @@ -41,16 +45,8 @@ public JsonApiException(HttpStatusCode status, string message, Exception innerEx Error = new Error(status) { Title = message, - Detail = innerException.Message, - Meta = CreateErrorMeta(innerException) + Detail = innerException.Message }; } - - private static ErrorMeta CreateErrorMeta(Exception exception) - { - var meta = new ErrorMeta(); - meta.IncludeExceptionStackTrace(exception); - return meta; - } } } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs deleted file mode 100644 index c1baf096..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Net; - -namespace JsonApiDotNetCore.Internal -{ - public static class JsonApiExceptionFactory - { - public static JsonApiException GetException(Exception exception) - { - var exceptionType = exception.GetType(); - - if (exceptionType == typeof(JsonApiException)) - return (JsonApiException)exception; - - return new JsonApiException(HttpStatusCode.InternalServerError, exceptionType.Name, exception); - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs new file mode 100644 index 00000000..3b1e82b4 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace JsonApiDotNetCore.Middleware +{ + public sealed class ConvertEmptyActionResultFilter : IAlwaysRunResultFilter + { + public void OnResultExecuted(ResultExecutedContext context) + { + } + + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ObjectResult objectResult && objectResult.Value != null) + { + return; + } + + // Convert action result without parameters into action result with null parameter. + // For example: return NotFound() -> return NotFound(null) + // This ensures our formatter is invoked, where we'll build a json:api compliant response. + // For details, see: https://github.com/dotnet/aspnetcore/issues/16969 + if (context.Result is IStatusCodeActionResult statusCodeResult) + { + context.Result = new ObjectResult(null) {StatusCode = statusCodeResult.StatusCode}; + } + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs index ede0a666..242106f7 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs @@ -1,32 +1,27 @@ -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Middleware { /// /// Global exception filter that wraps any thrown error with a JsonApiException. /// - public sealed class DefaultExceptionFilter : ActionFilterAttribute, IExceptionFilter + public class DefaultExceptionFilter : ActionFilterAttribute, IExceptionFilter { - private readonly ILogger _logger; + private readonly IExceptionHandler _exceptionHandler; - public DefaultExceptionFilter(ILoggerFactory loggerFactory) + public DefaultExceptionFilter(IExceptionHandler exceptionHandler) { - _logger = loggerFactory.CreateLogger(); + _exceptionHandler = exceptionHandler; } public void OnException(ExceptionContext context) { - _logger.LogError(new EventId(), context.Exception, "An unhandled exception occurred during the request"); + var errorDocument = _exceptionHandler.HandleException(context.Exception); - var jsonApiException = JsonApiExceptionFactory.GetException(context.Exception); - - context.Result = new ObjectResult(new ErrorDocument(jsonApiException.Error)) + context.Result = new ObjectResult(errorDocument) { - StatusCode = (int) jsonApiException.Error.Status + StatusCode = (int) errorDocument.GetErrorStatusCode() }; } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs new file mode 100644 index 00000000..dd85953c --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs @@ -0,0 +1,74 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Exceptions; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Middleware +{ + public class DefaultExceptionHandler : IExceptionHandler + { + private readonly IJsonApiOptions _options; + private readonly ILogger _logger; + + public DefaultExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + { + _options = options; + _logger = loggerFactory.CreateLogger(); + } + + public ErrorDocument HandleException(Exception exception) + { + LogException(exception); + + return CreateErrorDocument(exception); + } + + private void LogException(Exception exception) + { + var level = GetLogLevel(exception); + + _logger.Log(level, exception, exception.Message); + } + + protected virtual LogLevel GetLogLevel(Exception exception) + { + if (exception is JsonApiException || exception is InvalidModelStateException) + { + return LogLevel.Information; + } + + return LogLevel.Error; + } + + protected virtual ErrorDocument CreateErrorDocument(Exception exception) + { + if (exception is InvalidModelStateException modelStateException) + { + return new ErrorDocument(modelStateException.Errors); + } + + Error error = exception is JsonApiException jsonApiException + ? jsonApiException.Error + : new Error(HttpStatusCode.InternalServerError) + { + Title = "An unhandled error occurred while processing this request.", + Detail = exception.Message + }; + + ApplyOptions(error, exception); + + return new ErrorDocument(error); + } + + private void ApplyOptions(Error error, Exception exception) + { + if (_options.IncludeExceptionStackTraceInErrors) + { + error.Meta.IncludeExceptionStackTrace(exception); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs new file mode 100644 index 00000000..3b3a55d1 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs @@ -0,0 +1,13 @@ +using System; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Central place to handle all exceptions. Log them and translate into Error response. + /// + public interface IExceptionHandler + { + ErrorDocument HandleException(Exception exception); + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 9a8a3be4..8ed2eb80 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using System.Net; -using JsonApiDotNetCore.Configuration; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models.JsonApiDocuments @@ -27,7 +26,7 @@ public Error(HttpStatusCode status) /// A link that leads to further details about this particular occurrence of the problem. /// [JsonProperty("links")] - public ErrorLinks Links { get; set; } + public ErrorLinks Links { get; set; } = new ErrorLinks(); public bool ShouldSerializeLinks() => Links?.About != null; @@ -57,7 +56,7 @@ public string StatusText public string Title { get; set; } /// - /// A human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. + /// A human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. /// [JsonProperty("detail")] public string Detail { get; set; } @@ -66,7 +65,7 @@ public string StatusText /// An object containing references to the source of the error. /// [JsonProperty("source")] - public ErrorSource Source { get; set; } + public ErrorSource Source { get; set; } = new ErrorSource(); public bool ShouldSerializeSource() => Source != null && (Source.Pointer != null || Source.Parameter != null); @@ -74,8 +73,8 @@ public string StatusText /// An object containing non-standard meta-information (key/value pairs) about the error. ///
[JsonProperty("meta")] - public ErrorMeta Meta { get; set; } + public ErrorMeta Meta { get; set; } = new ErrorMeta(); - public bool ShouldSerializeMeta() => Meta != null && Meta.Data.Any() && !JsonApiOptions.DisableErrorStackTraces; + public bool ShouldSerializeMeta() => Meta != null && Meta.Data.Any(); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index a5dacd39..6b385645 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -130,5 +131,30 @@ public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() var result = deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString(); Assert.EndsWith($"{route}/owner", result); } + + [Fact] + public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() + { + // Arrange + var builder = new WebHostBuilder().UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/custom/route/todoItems/99999999"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + Assert.Single(errorDocument.Errors); + Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.4", errorDocument.Errors[0].Links.About); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs new file mode 100644 index 00000000..8ced34d3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility +{ + public sealed class CustomErrorHandlingTests + { + [Fact] + public void When_using_custom_exception_handler_it_must_create_error_document_and_log() + { + // Arrange + var loggerFactory = new FakeLoggerFactory(); + var options = new JsonApiOptions {IncludeExceptionStackTraceInErrors = true}; + var handler = new CustomExceptionHandler(loggerFactory, options); + + // Act + var errorDocument = handler.HandleException(new NoPermissionException("YouTube")); + + // Assert + Assert.Single(errorDocument.Errors); + Assert.Equal("For support, email to: support@company.com?subject=YouTube", + errorDocument.Errors[0].Meta.Data["support"]); + Assert.NotEmpty((string[]) errorDocument.Errors[0].Meta.Data["stackTrace"]); + + Assert.Single(loggerFactory.Logger.Messages); + Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); + Assert.Equal("Access is denied.", loggerFactory.Logger.Messages[0].Text); + } + + public class CustomExceptionHandler : DefaultExceptionHandler + { + public CustomExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + : base(loggerFactory, options) + { + } + + protected override LogLevel GetLogLevel(Exception exception) + { + if (exception is NoPermissionException) + { + return LogLevel.Warning; + } + + return base.GetLogLevel(exception); + } + + protected override ErrorDocument CreateErrorDocument(Exception exception) + { + if (exception is NoPermissionException noPermissionException) + { + noPermissionException.Error.Meta.Data.Add("support", + "For support, email to: support@company.com?subject=" + noPermissionException.CustomerCode); + } + + return base.CreateErrorDocument(exception); + } + } + + public class NoPermissionException : JsonApiException + { + public string CustomerCode { get; } + + public NoPermissionException(string customerCode) : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Access is denied.", + Detail = $"Customer '{customerCode}' does not have permission to access this location." + }) + { + CustomerCode = customerCode; + } + } + + internal sealed class FakeLoggerFactory : ILoggerFactory + { + public FakeLogger Logger { get; } + + public FakeLoggerFactory() + { + Logger = new FakeLogger(); + } + + public ILogger CreateLogger(string categoryName) => Logger; + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } + + internal sealed class FakeLogger : ILogger + { + public List<(LogLevel LogLevel, string Text)> Messages = new List<(LogLevel, string)>(); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + var message = formatter(state, exception); + Messages.Add((logLevel, message)); + } + + public bool IsEnabled(LogLevel logLevel) => true; + public IDisposable BeginScope(TState state) => null; + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index 40b5bd0f..ec3c2db6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -1,9 +1,10 @@ -using System.Linq; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -125,7 +126,7 @@ public async Task GetResources_NoDefaultPageSize_ReturnsResources() } [Fact] - public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFoundWithNullData() + public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() { // Arrange var context = _fixture.GetService(); @@ -143,11 +144,14 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFoundWithNull // Act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); + var errorDocument = JsonConvert.DeserializeObject(body); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Null(document.Data); + + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].Status); + Assert.Equal("NotFound", errorDocument.Errors[0].Title); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 6c71e5f3..4f8424e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -79,6 +80,15 @@ public async Task Response422IfUpdatingNotSettableAttribute() // Assert Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + Assert.Single(document.Errors); + + var error = document.Errors.Single(); + Assert.Equal(HttpStatusCode.UnprocessableEntity, error.Status); + Assert.Equal("Failed to deserialize request body.", error.Title); + Assert.Equal("Property set method not found.", error.Detail); } [Fact] @@ -118,7 +128,7 @@ public async Task Respond_422_If_IdNotInAttributeList() var server = new TestServer(builder); var client = server.CreateClient(); - var serializer = _fixture.GetSerializer(ti => new { ti.Description, ti.Ordinal, ti.CreatedDate }); + var serializer = _fixture.GetSerializer(ti => new {ti.Description, ti.Ordinal, ti.CreatedDate}); var content = serializer.Serialize(todoItem); var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{maxPersonId}", content); @@ -127,6 +137,44 @@ public async Task Respond_422_If_IdNotInAttributeList() // Assert Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + Assert.Single(document.Errors); + + var error = document.Errors.Single(); + Assert.Equal(HttpStatusCode.UnprocessableEntity, error.Status); + Assert.Equal("Payload must include id attribute.", error.Title); + Assert.Null(error.Detail); + } + + [Fact] + public async Task Respond_422_If_Broken_JSON_Payload() + { + // Arrange + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = "{ \"data\" {"; + var request = PrepareRequest("POST", $"/api/v1/todoItems", content); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + Assert.Single(document.Errors); + + var error = document.Errors.Single(); + Assert.Equal(HttpStatusCode.UnprocessableEntity, error.Status); + Assert.Equal("Failed to deserialize request body.", error.Title); + Assert.StartsWith("Invalid character after parsing", error.Detail); } [Fact] From bfb9b33fd5c5893cc4096b489180a5cd9fbf7fa7 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Thu, 2 Apr 2020 10:36:33 +0200 Subject: [PATCH 16/60] Fixed: use enum instead of magic numbers --- .../Acceptance/ModelStateValidationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs index 6b47eeaf..33417c51 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -50,7 +50,7 @@ public async Task When_posting_tag_with_long_name_it_must_fail() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal("422", errorDocument.Errors[0].StatusText); + Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].Status); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("The field Name must be a string or array type with a maximum length of '15'.", errorDocument.Errors[0].Detail); Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); @@ -124,7 +124,7 @@ public async Task When_patching_tag_with_long_name_it_must_fail() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal("422", errorDocument.Errors[0].StatusText); + Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].Status); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("The field Name must be a string or array type with a maximum length of '15'.", errorDocument.Errors[0].Detail); Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); From 9e0b3442728579d365a65db3589dad3fe0c0339b Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Thu, 2 Apr 2020 10:44:19 +0200 Subject: [PATCH 17/60] Fixed: setting MaxLength on attribute is picked up by EF Core and translated into a database constraint. This makes it impossible to toggle per test. So using regex constraint instead. --- .../JsonApiDotNetCoreExample/Models/Tag.cs | 2 +- .../Acceptance/ModelStateValidationTests.cs | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 5bd05256..86ceed40 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreExample.Models public class Tag : Identifiable { [Attr] - [MaxLength(15)] + [RegularExpression(@"^\W$")] public string Name { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs index 33417c51..9fb67c05 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -21,12 +21,12 @@ public ModelStateValidationTests(StandardApplicationFactory factory) } [Fact] - public async Task When_posting_tag_with_long_name_it_must_fail() + public async Task When_posting_tag_with_invalid_name_it_must_fail() { // Arrange var tag = new Tag { - Name = new string('X', 50) + Name = "!@#$%^&*().-" }; var serializer = GetSerializer(); @@ -52,17 +52,17 @@ public async Task When_posting_tag_with_long_name_it_must_fail() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].Status); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); - Assert.Equal("The field Name must be a string or array type with a maximum length of '15'.", errorDocument.Errors[0].Detail); + Assert.Equal("The field Name must match the regular expression '^\\W$'.", errorDocument.Errors[0].Detail); Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); } [Fact] - public async Task When_posting_tag_with_long_name_without_model_state_validation_it_must_succeed() + public async Task When_posting_tag_with_invalid_name_without_model_state_validation_it_must_succeed() { // Arrange var tag = new Tag { - Name = new string('X', 50) + Name = "!@#$%^&*().-" }; var serializer = GetSerializer(); @@ -85,7 +85,7 @@ public async Task When_posting_tag_with_long_name_without_model_state_validation } [Fact] - public async Task When_patching_tag_with_long_name_it_must_fail() + public async Task When_patching_tag_with_invalid_name_it_must_fail() { // Arrange var existingTag = new Tag @@ -100,7 +100,7 @@ public async Task When_patching_tag_with_long_name_it_must_fail() var updatedTag = new Tag { Id = existingTag.Id, - Name = new string('X', 50) + Name = "!@#$%^&*().-" }; var serializer = GetSerializer(); @@ -126,12 +126,12 @@ public async Task When_patching_tag_with_long_name_it_must_fail() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].Status); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); - Assert.Equal("The field Name must be a string or array type with a maximum length of '15'.", errorDocument.Errors[0].Detail); + Assert.Equal("The field Name must match the regular expression '^\\W$'.", errorDocument.Errors[0].Detail); Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); } [Fact] - public async Task When_patching_tag_with_long_name_without_model_state_validation_it_must_succeed() + public async Task When_patching_tag_with_invalid_name_without_model_state_validation_it_must_succeed() { // Arrange var existingTag = new Tag @@ -146,7 +146,7 @@ public async Task When_patching_tag_with_long_name_without_model_state_validatio var updatedTag = new Tag { Id = existingTag.Id, - Name = new string('X', 50) + Name = "!@#$%^&*().-" }; var serializer = GetSerializer(); From 2dd32c243ee051f3e1ca59bb0aac5b5b7348a466 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Thu, 2 Apr 2020 16:52:29 +0200 Subject: [PATCH 18/60] Fixed spelling error in comment --- .../Server/Builders/ResponseResourceObjectBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index 7f0b25d0..c2f95099 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -57,7 +57,7 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r // if links relationshipLinks should be built for this entry, populate the "links" field. (relationshipEntry ??= new RelationshipEntry()).Links = links; - // if neither "links" nor "data" was popupated, return null, which will omit this entry from the output. + // if neither "links" nor "data" was populated, return null, which will omit this entry from the output. // (see the NullValueHandling settings on ) return relationshipEntry; } From 9c91392086e0138889bad579f647d20eee1692bb Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Thu, 2 Apr 2020 18:29:18 +0200 Subject: [PATCH 19/60] Changes on the flow of parsing query strings, in order to throw better errors. Replaced class-name-to-query-string-parameter-name convention with pluggable logic. Parsers now have a CanParse phase that indicates whether the service supports parsing the parameter. Their IsEnabled method checks if the query parameter is blocked by a controller attribute (which now supports passing multiple parameters in a comma-separated list). Finally, support has been added for omitNull/omitDefault to be blocked on a controller. And instead of silently ignoring, a HTTP 400 is returned passing omitNull/omitDefault from query string when that is not enabled. --- .../DefaultAttributeResponseBehavior.cs | 29 ++++------ .../Configuration/JsonApiOptions.cs | 13 ++--- .../NullAttributeResponseBehavior.cs | 28 ++++------ .../Controllers/DisableQueryAttribute.cs | 39 ++++++++++---- .../Controllers/QueryParams.cs | 13 ----- .../StandardQueryStringParameters.cs | 20 +++++++ .../EntityFrameworkCoreExtension.cs | 2 +- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 4 +- .../Middleware/QueryParameterFilter.cs | 5 +- .../Common/IQueryParameterParser.cs | 11 ++-- .../Common/IQueryParameterService.cs | 19 ++++--- .../Common/QueryParameterParser.cs | 42 +++++---------- .../Common/QueryParameterService.cs | 24 ++------- .../Contracts/IOmitDefaultService.cs | 8 +-- .../Contracts/IOmitNullService.cs | 8 +-- .../QueryParameterServices/FilterService.cs | 29 +++++++--- .../QueryParameterServices/IncludeService.cs | 17 +++++- .../OmitDefaultService.cs | 31 +++++++---- .../QueryParameterServices/OmitNullService.cs | 32 +++++++---- .../QueryParameterServices/PageService.cs | 41 ++++++++------ .../QueryParameterServices/SortService.cs | 31 +++++++---- .../SparseFieldsService.cs | 26 ++++++--- .../Common/ResourceObjectBuilder.cs | 2 +- .../Common/ResourceObjectBuilderSettings.cs | 16 +++--- .../ResourceObjectBuilderSettingsProvider.cs | 2 +- ....cs => OmitAttributeIfValueIsNullTests.cs} | 53 +++++++++++-------- .../Acceptance/Spec/PagingTests.cs | 2 +- .../QueryParameters/FilterServiceTests.cs | 21 ++++++-- .../QueryParameters/IncludeServiceTests.cs | 30 +++++++---- ...tService.cs => OmitDefaultServiceTests.cs} | 31 ++++++++--- ...NullService.cs => OmitNullServiceTests.cs} | 29 +++++++--- .../QueryParameters/PageServiceTests.cs | 29 +++++++--- .../QueryParameters/SortServiceTests.cs | 21 ++++++-- .../SparseFieldsServiceTests.cs | 28 +++++++--- 34 files changed, 456 insertions(+), 280 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Controllers/QueryParams.cs create mode 100644 src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs rename test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/{NullValuedAttributeHandlingTests.cs => OmitAttributeIfValueIsNullTests.cs} (61%) rename test/UnitTests/QueryParameters/{OmitDefaultService.cs => OmitDefaultServiceTests.cs} (59%) rename test/UnitTests/QueryParameters/{OmitNullService.cs => OmitNullServiceTests.cs} (61%) diff --git a/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs b/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs index fe14393b..dcb9a34e 100644 --- a/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs +++ b/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs @@ -1,35 +1,26 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Allows default valued attributes to be omitted from the response payload + /// Determines how attributes that contain a default value are serialized in the response payload. /// public struct DefaultAttributeResponseBehavior { - - /// Do not serialize default value attributes - /// - /// Allow clients to override the serialization behavior through a query parameter. - /// - /// ``` - /// GET /articles?omitDefaultValuedAttributes=true - /// ``` - /// - /// - public DefaultAttributeResponseBehavior(bool omitNullValuedAttributes = false, bool allowClientOverride = false) + /// Determines whether to serialize attributes that contain their types' default value. + /// Determines whether serialization behavior can be controlled by a query string parameter. + public DefaultAttributeResponseBehavior(bool omitAttributeIfValueIsDefault = false, bool allowQueryStringOverride = false) { - OmitDefaultValuedAttributes = omitNullValuedAttributes; - AllowClientOverride = allowClientOverride; + OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; + AllowQueryStringOverride = allowQueryStringOverride; } /// - /// Do (not) include default valued attributes in the response payload. + /// Determines whether to serialize attributes that contain their types' default value. /// - public bool OmitDefaultValuedAttributes { get; } + public bool OmitAttributeIfValueIsDefault { get; } /// - /// Allows clients to specify a `omitDefaultValuedAttributes` boolean query param to control - /// serialization behavior. + /// Determines whether serialization behavior can be controlled by a query string parameter. /// - public bool AllowClientOverride { get; } + public bool AllowQueryStringOverride { get; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index c791b56a..2b10bd76 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -108,16 +108,13 @@ public class JsonApiOptions : IJsonApiOptions public bool AllowCustomQueryParameters { get; set; } /// - /// The default behavior for serializing null attributes. + /// The default behavior for serializing attributes that contain null. /// - /// - /// - /// options.NullAttributeResponseBehavior = new NullAttributeResponseBehavior { - /// // ... - ///}; - /// - /// public NullAttributeResponseBehavior NullAttributeResponseBehavior { get; set; } + + /// + /// The default behavior for serializing attributes that contain their types' default value. + /// public DefaultAttributeResponseBehavior DefaultAttributeResponseBehavior { get; set; } /// diff --git a/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs b/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs index f1b26e11..c1b1e7c3 100644 --- a/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs +++ b/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs @@ -1,34 +1,26 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Allows null attributes to be omitted from the response payload + /// Determines how attributes that contain null are serialized in the response payload. /// public struct NullAttributeResponseBehavior { - /// Do not serialize null attributes - /// - /// Allow clients to override the serialization behavior through a query parameter. - /// - /// ``` - /// GET /articles?omitNullValuedAttributes=true - /// ``` - /// - /// - public NullAttributeResponseBehavior(bool omitNullValuedAttributes = false, bool allowClientOverride = false) + /// Determines whether to serialize attributes that contain null. + /// Determines whether serialization behavior can be controlled by a query string parameter. + public NullAttributeResponseBehavior(bool omitAttributeIfValueIsNull = false, bool allowQueryStringOverride = false) { - OmitNullValuedAttributes = omitNullValuedAttributes; - AllowClientOverride = allowClientOverride; + OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; + AllowQueryStringOverride = allowQueryStringOverride; } /// - /// Do not include null attributes in the response payload. + /// Determines whether to serialize attributes that contain null. /// - public bool OmitNullValuedAttributes { get; } + public bool OmitAttributeIfValueIsNull { get; } /// - /// Allows clients to specify a `omitNullValuedAttributes` boolean query param to control - /// serialization behavior. + /// Determines whether serialization behavior can be controlled by a query string parameter. /// - public bool AllowClientOverride { get; } + public bool AllowQueryStringOverride { get; } } } diff --git a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs index 0dbc45f4..6525a82b 100644 --- a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs @@ -1,29 +1,46 @@ using System; +using System.Collections.Generic; +using System.Linq; namespace JsonApiDotNetCore.Controllers { public sealed class DisableQueryAttribute : Attribute { + private readonly List _parameterNames; + + public IReadOnlyCollection ParameterNames => _parameterNames.AsReadOnly(); + + public static readonly DisableQueryAttribute Empty = new DisableQueryAttribute(StandardQueryStringParameters.None); + /// - /// Disabled one of the native query parameters for a controller. + /// Disables one or more of the builtin query parameters for a controller. /// - /// - public DisableQueryAttribute(QueryParams queryParams) + public DisableQueryAttribute(StandardQueryStringParameters parameters) { - QueryParams = queryParams.ToString("G").ToLower(); + _parameterNames = parameters != StandardQueryStringParameters.None + ? ParseList(parameters.ToString()) + : new List(); } /// - /// It is allowed to use strings to indicate which query parameters - /// should be disabled, because the user may have defined a custom - /// query parameter that is not included in the enum. + /// It is allowed to use a comma-separated list of strings to indicate which query parameters + /// should be disabled, because the user may have defined custom query parameters that are + /// not included in the enum. /// - /// - public DisableQueryAttribute(string customQueryParams) + public DisableQueryAttribute(string parameterNames) + { + _parameterNames = ParseList(parameterNames); + } + + private static List ParseList(string parameterNames) { - QueryParams = customQueryParams.ToLower(); + return parameterNames.Split(",").Select(x => x.Trim().ToLowerInvariant()).ToList(); } - public string QueryParams { get; } + public bool ContainsParameter(StandardQueryStringParameters parameter) + { + var name = parameter.ToString().ToLowerInvariant(); + return _parameterNames.Contains(name); + } } } diff --git a/src/JsonApiDotNetCore/Controllers/QueryParams.cs b/src/JsonApiDotNetCore/Controllers/QueryParams.cs deleted file mode 100644 index 6e5e3901..00000000 --- a/src/JsonApiDotNetCore/Controllers/QueryParams.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace JsonApiDotNetCore.Controllers -{ - public enum QueryParams - { - Filters = 1 << 0, - Sort = 1 << 1, - Include = 1 << 2, - Page = 1 << 3, - Fields = 1 << 4, - All = ~(-1 << 5), - None = 1 << 6, - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs b/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs new file mode 100644 index 00000000..9bd8d1d6 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs @@ -0,0 +1,20 @@ +using System; + +namespace JsonApiDotNetCore.Controllers +{ + [Flags] + public enum StandardQueryStringParameters + { + None = 0, + Filter = 1, + Sort = 2, + Include = 4, + Page = 8, + Fields = 16, + // TODO: Rename to single-word to prevent violating casing conventions. + OmitNull = 32, + // TODO: Rename to single-word to prevent violating casing conventions. + OmitDefault = 64, + All = Filter | Sort | Include | Page | Fields | OmitNull | OmitDefault + } +} diff --git a/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs b/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs index f2b73169..3629a783 100644 --- a/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs +++ b/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs @@ -29,7 +29,7 @@ public static IResourceGraphBuilder AddDbContext(this IResourceGraph foreach (var property in contextProperties) { var dbSetType = property.PropertyType; - if (dbSetType.GetTypeInfo().IsGenericType + if (dbSetType.IsGenericType && dbSetType.GetGenericTypeDefinition() == typeof(DbSet<>)) { var resourceType = dbSetType.GetGenericArguments()[0]; diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 4a0be08c..8fd71ffd 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -53,7 +53,7 @@ public static object ConvertType(object value, Type type) if (type == typeof(TimeSpan)) return TimeSpan.Parse(stringValue); - if (type.GetTypeInfo().IsEnum) + if (type.IsEnum) return Enum.Parse(type, stringValue); return Convert.ChangeType(stringValue, type); @@ -66,7 +66,7 @@ public static object ConvertType(object value, Type type) private static object GetDefaultType(Type type) { - if (type.GetTypeInfo().IsValueType) + if (type.IsValueType) { return Activator.CreateInstance(type); } diff --git a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs b/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs index cdc233ae..a3f5e5bd 100644 --- a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs @@ -13,10 +13,9 @@ public sealed class QueryParameterActionFilter : IAsyncActionFilter, IQueryParam public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - // gets the DisableQueryAttribute if set on the controller that is targeted by the current request. - DisableQueryAttribute disabledQuery = context.Controller.GetType().GetTypeInfo().GetCustomAttribute(typeof(DisableQueryAttribute)) as DisableQueryAttribute; + DisableQueryAttribute disableQueryAttribute = context.Controller.GetType().GetCustomAttribute(); - _queryParser.Parse(disabledQuery); + _queryParser.Parse(disableQueryAttribute); await next(); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs index edabd64e..1b0afb96 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs @@ -4,11 +4,16 @@ namespace JsonApiDotNetCore.Services { /// - /// Responsible for populating the various service implementations of - /// . + /// Responsible for populating the various service implementations of . /// public interface IQueryParameterParser { - void Parse(DisableQueryAttribute disabledQuery = null); + /// + /// Parses the parameters from the request query string. + /// + /// + /// The if set on the controller that is targeted by the current request. + /// + void Parse(DisableQueryAttribute disableQueryAttribute); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs index 64df236a..a67e56ce 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs @@ -1,21 +1,26 @@ -using System.Collections.Generic; +using JsonApiDotNetCore.Controllers; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query { /// - /// Base interface that all query parameter services should inherit. + /// The interface to implement for parsing specific query string parameters. /// public interface IQueryParameterService { /// - /// Parses the value of the query parameter. Invoked in the middleware. + /// Indicates whether using this service is blocked using on a controller. /// - /// the value of the query parameter as retrieved from the url - void Parse(KeyValuePair queryParameter); + bool IsEnabled(DisableQueryAttribute disableQueryAttribute); + + /// + /// Indicates whether this service supports parsing the specified query string parameter. + /// + bool CanParse(string parameterName); + /// - /// The name of the query parameter as matched in the URL query string. + /// Parses the value of the query string parameter. /// - string Name { get; } + void Parse(string parameterName, StringValues parameterValue); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index 37477186..8dda9e19 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; @@ -23,45 +24,28 @@ public QueryParameterParser(IJsonApiOptions options, IRequestQueryStringAccessor _queryServices = queryServices; } - /// - /// For a parameter in the query string of the request URL, calls - /// the - /// method of the corresponding service. - /// - public virtual void Parse(DisableQueryAttribute disabled) + /// + public virtual void Parse(DisableQueryAttribute disableQueryAttribute) { - var disabledQuery = disabled?.QueryParams; + disableQueryAttribute ??= DisableQueryAttribute.Empty; foreach (var pair in _queryStringAccessor.Query) { - bool parsed = false; - foreach (var service in _queryServices) + var service = _queryServices.FirstOrDefault(s => s.CanParse(pair.Key)); + if (service != null) { - if (pair.Key.ToLower().StartsWith(service.Name, StringComparison.Ordinal)) + if (!service.IsEnabled(disableQueryAttribute)) { - if (disabledQuery == null || !IsDisabled(disabledQuery, service)) - service.Parse(pair); - parsed = true; - break; + throw new JsonApiException(HttpStatusCode.BadRequest, $"{pair} is not available for this resource."); } - } - if (parsed) - continue; - if (!_options.AllowCustomQueryParameters) + service.Parse(pair.Key, pair.Value); + } + else if (!_options.AllowCustomQueryParameters) + { throw new JsonApiException(HttpStatusCode.BadRequest, $"{pair} is not a valid query."); + } } } - - private bool IsDisabled(string disabledQuery, IQueryParameterService targetsService) - { - if (disabledQuery == QueryParams.All.ToString("G").ToLower()) - return true; - - if (disabledQuery == targetsService.Name) - return true; - - return false; - } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index 0f3d9be0..3d2b34dd 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -17,6 +17,8 @@ public abstract class QueryParameterService protected readonly ResourceContext _requestResource; private readonly ResourceContext _mainRequestResource; + protected QueryParameterService() { } + protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) { _mainRequestResource = currentRequest.GetRequestResource(); @@ -26,24 +28,6 @@ protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest cu : _mainRequestResource; } - protected QueryParameterService() { } - - /// - /// Derives the name of the query parameter from the name of the implementing type. - /// - /// - /// The following query param service will match the query displayed in URL - /// `?include=some-relationship` - /// public class IncludeService : QueryParameterService { /* ... */ } - /// - public virtual string Name => GetParameterNameFromType(); - - /// - /// Gets the query parameter name from the implementing class name. Trims "Service" - /// from the name if present. - /// - private string GetParameterNameFromType() => new Regex("Service$").Replace(GetType().Name, string.Empty).ToLower(); - /// /// Helper method for parsing query parameters into attributes /// @@ -75,11 +59,11 @@ protected RelationshipAttribute GetRelationship(string propertyName) /// /// Throw an exception if query parameters are requested that are unsupported on nested resource routes. /// - protected void EnsureNoNestedResourceRoute() + protected void EnsureNoNestedResourceRoute(string parameterName) { if (_requestResource != _mainRequestResource) { - throw new JsonApiException(HttpStatusCode.BadRequest, $"Query parameter {Name} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{Name}=...'"); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Query parameter {parameterName} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{parameterName}=...'"); } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs index eab63994..ec8213fd 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Query { /// /// Query parameter service responsible for url queries of the form ?omitDefault=true @@ -6,8 +6,8 @@ public interface IOmitDefaultService : IQueryParameterService { /// - /// Gets the parsed config + /// Contains the effective value of default configuration and query string override, after parsing has occured. /// - bool Config { get; } + bool OmitAttributeIfValueIsDefault { get; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs index 519d8add..1b21ee8b 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Query { /// /// Query parameter service responsible for url queries of the form ?omitNull=true @@ -6,8 +6,8 @@ public interface IOmitNullService : IQueryParameterService { /// - /// Gets the parsed config + /// Contains the effective value of default configuration and query string override, after parsing has occured. /// - bool Config { get; } + bool OmitAttributeIfValueIsNull { get; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index 6df45bd8..c0d63118 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -30,10 +31,22 @@ public List Get() } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - EnsureNoNestedResourceRoute(); - var queries = GetFilterQueries(queryParameter); + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Filter); + } + + /// + public bool CanParse(string parameterName) + { + return parameterName.StartsWith("filter"); + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + EnsureNoNestedResourceRoute(parameterName); + var queries = GetFilterQueries(parameterName, parameterValue); _filters.AddRange(queries.Select(GetQueryContexts)); } @@ -59,23 +72,23 @@ private FilterQueryContext GetQueryContexts(FilterQuery query) } /// todo: this could be simplified a bunch - private List GetFilterQueries(KeyValuePair queryParameter) + private List GetFilterQueries(string parameterName, StringValues parameterValue) { // expected input = filter[id]=1 // expected input = filter[id]=eq:1 - var propertyName = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; + var propertyName = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; var queries = new List(); // InArray case - string op = GetFilterOperation(queryParameter.Value); + string op = GetFilterOperation(parameterValue); if (string.Equals(op, FilterOperation.@in.ToString(), StringComparison.OrdinalIgnoreCase) || string.Equals(op, FilterOperation.nin.ToString(), StringComparison.OrdinalIgnoreCase)) { - var (_, filterValue) = ParseFilterOperation(queryParameter.Value); + var (_, filterValue) = ParseFilterOperation(parameterValue); queries.Add(new FilterQuery(propertyName, filterValue, op)); } else { - var values = ((string)queryParameter.Value).Split(QueryConstants.COMMA); + var values = ((string)parameterValue).Split(QueryConstants.COMMA); foreach (var val in values) { var (operation, filterValue) = ParseFilterOperation(val); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs index ef07c2b2..405cf3d0 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -27,9 +28,21 @@ public List> Get() } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - var value = (string)queryParameter.Value; + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Include); + } + + /// + public bool CanParse(string parameterName) + { + return parameterName == "include"; + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + var value = (string)parameterValue; if (string.IsNullOrWhiteSpace(value)) throw new JsonApiException(HttpStatusCode.BadRequest, "Include parameter must not be empty if provided"); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs index 0887f414..83613a7f 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -11,23 +13,34 @@ public class OmitDefaultService : QueryParameterService, IOmitDefaultService public OmitDefaultService(IJsonApiOptions options) { - Config = options.DefaultAttributeResponseBehavior.OmitDefaultValuedAttributes; + OmitAttributeIfValueIsDefault = options.DefaultAttributeResponseBehavior.OmitAttributeIfValueIsDefault; _options = options; } /// - public bool Config { get; private set; } + public bool OmitAttributeIfValueIsDefault { get; private set; } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return _options.DefaultAttributeResponseBehavior.AllowQueryStringOverride && + !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.OmitDefault); + } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool CanParse(string parameterName) { - if (!_options.DefaultAttributeResponseBehavior.AllowClientOverride) - return; + return parameterName == "omitDefault"; + } - if (!bool.TryParse(queryParameter.Value, out var config)) - return; + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out var omitAttributeIfValueIsDefault)) + { + throw new JsonApiException(HttpStatusCode.BadRequest, "Value must be 'true' or 'false'."); + } - Config = config; + OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs index 57d69866..9cd4aaae 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -11,23 +13,35 @@ public class OmitNullService : QueryParameterService, IOmitNullService public OmitNullService(IJsonApiOptions options) { - Config = options.NullAttributeResponseBehavior.OmitNullValuedAttributes; + OmitAttributeIfValueIsNull = options.NullAttributeResponseBehavior.OmitAttributeIfValueIsNull; _options = options; } /// - public bool Config { get; private set; } + public bool OmitAttributeIfValueIsNull { get; private set; } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - if (!_options.NullAttributeResponseBehavior.AllowClientOverride) - return; + return _options.NullAttributeResponseBehavior.AllowQueryStringOverride && + !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.OmitNull); + } - if (!bool.TryParse(queryParameter.Value, out var config)) - return; + /// + public bool CanParse(string parameterName) + { + return parameterName == "omitNull"; + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out var omitAttributeIfValueIsNull)) + { + throw new JsonApiException(HttpStatusCode.BadRequest, "Value must be 'true' or 'false'."); + } - Config = config; + OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index df014c77..cc9575e5 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -64,29 +64,41 @@ public int PageSize public int? TotalRecords { get; set; } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - EnsureNoNestedResourceRoute(); + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Page); + } + + /// + public bool CanParse(string parameterName) + { + return parameterName == "page[size]" || parameterName == "page[number]"; + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + EnsureNoNestedResourceRoute(parameterName); // expected input = page[size]= // page[number]= - var propertyName = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; + var propertyName = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; const string SIZE = "size"; const string NUMBER = "number"; if (propertyName == SIZE) { - if (!int.TryParse(queryParameter.Value, out var size)) + if (!int.TryParse(parameterValue, out var size)) { - ThrowBadPagingRequest(queryParameter, "value could not be parsed as an integer"); + ThrowBadPagingRequest(parameterName, parameterValue, "value could not be parsed as an integer"); } else if (size < 1) { - ThrowBadPagingRequest(queryParameter, "value needs to be greater than zero"); + ThrowBadPagingRequest(parameterName, parameterValue, "value needs to be greater than zero"); } else if (size > _options.MaximumPageSize) { - ThrowBadPagingRequest(queryParameter, $"page size cannot be higher than {_options.MaximumPageSize}."); + ThrowBadPagingRequest(parameterName, parameterValue, $"page size cannot be higher than {_options.MaximumPageSize}."); } else { @@ -95,17 +107,17 @@ public virtual void Parse(KeyValuePair queryParameter) } else if (propertyName == NUMBER) { - if (!int.TryParse(queryParameter.Value, out var number)) + if (!int.TryParse(parameterValue, out var number)) { - ThrowBadPagingRequest(queryParameter, "value could not be parsed as an integer"); + ThrowBadPagingRequest(parameterName, parameterValue, "value could not be parsed as an integer"); } else if (number == 0) { - ThrowBadPagingRequest(queryParameter, "page index is not zero-based"); + ThrowBadPagingRequest(parameterName, parameterValue, "page index is not zero-based"); } else if (number > _options.MaximumPageNumber) { - ThrowBadPagingRequest(queryParameter, $"page index cannot be higher than {_options.MaximumPageNumber}."); + ThrowBadPagingRequest(parameterName, parameterValue, $"page index cannot be higher than {_options.MaximumPageNumber}."); } else { @@ -115,10 +127,9 @@ public virtual void Parse(KeyValuePair queryParameter) } } - private void ThrowBadPagingRequest(KeyValuePair parameter, string message) + private void ThrowBadPagingRequest(string parameterName, StringValues parameterValue, string message) { - throw new JsonApiException(HttpStatusCode.BadRequest, $"Invalid page query parameter '{parameter.Key}={parameter.Value}': {message}"); + throw new JsonApiException(HttpStatusCode.BadRequest, $"Invalid page query parameter '{parameterName}={parameterValue}': {message}"); } - } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index 14097f6c..0e2fcb52 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -25,15 +26,6 @@ public SortService(IResourceDefinitionProvider resourceDefinitionProvider, _queries = new List(); } - /// - public virtual void Parse(KeyValuePair queryParameter) - { - EnsureNoNestedResourceRoute(); - var queries = BuildQueries(queryParameter.Value); - - _queries = queries.Select(BuildQueryContext).ToList(); - } - /// public List Get() { @@ -46,6 +38,27 @@ public List Get() return _queries.ToList(); } + /// + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Sort); + } + + /// + public bool CanParse(string parameterName) + { + return parameterName == "sort"; + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + EnsureNoNestedResourceRoute(parameterName); + var queries = BuildQueries(parameterValue); + + _queries = queries.Select(BuildQueryContext).ToList(); + } + private List BuildQueries(string value) { var sortParameters = new List(); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index 0256aaeb..8c2240a2 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; @@ -22,8 +23,6 @@ public class SparseFieldsService : QueryParameterService, ISparseFieldsService /// private readonly Dictionary> _selectedRelationshipFields; - public override string Name => "fields"; - public SparseFieldsService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest) { _selectedFields = new List(); @@ -41,15 +40,28 @@ public List Get(RelationshipAttribute relationship = null) } /// - public virtual void Parse(KeyValuePair queryParameter) - { // expected: articles?fields=prop1,prop2 + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Fields); + } + + /// + public bool CanParse(string parameterName) + { + return parameterName.StartsWith("fields"); + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + // expected: articles?fields=prop1,prop2 // articles?fields[articles]=prop1,prop2 <-- this form in invalid UNLESS "articles" is actually a relationship on Article // articles?fields[relationship]=prop1,prop2 - EnsureNoNestedResourceRoute(); + EnsureNoNestedResourceRoute(parameterName); var fields = new List { nameof(Identifiable.Id) }; - fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA)); + fields.AddRange(((string)parameterValue).Split(QueryConstants.COMMA)); - var keySplit = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET); + var keySplit = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET); if (keySplit.Length == 1) { // input format: fields=prop1,prop2 diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs index d62434b8..061dd9e9 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs @@ -140,7 +140,7 @@ private void ProcessAttributes(IIdentifiable entity, IEnumerable foreach (var attr in attributes) { var value = attr.GetValue(entity); - if (!(value == default && _settings.OmitDefaultValuedAttributes) && !(value == null && _settings.OmitNullValuedAttributes)) + if (!(value == default && _settings.OmitAttributeIfValueIsDefault) && !(value == null && _settings.OmitAttributeIfValueIsNull)) ro.Attributes.Add(attr.PublicAttributeName, value); } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs index 2e84b798..e7583660 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCore.Serialization /// public sealed class ResourceObjectBuilderSettings { - /// Omit null values from attributes - /// Omit default values from attributes - public ResourceObjectBuilderSettings(bool omitNullValuedAttributes = false, bool omitDefaultValuedAttributes = false) + /// Omit null values from attributes + /// Omit default values from attributes + public ResourceObjectBuilderSettings(bool omitAttributeIfValueIsNull = false, bool omitAttributeIfValueIsDefault = false) { - OmitNullValuedAttributes = omitNullValuedAttributes; - OmitDefaultValuedAttributes = omitDefaultValuedAttributes; + OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; + OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; } /// @@ -26,7 +26,7 @@ public ResourceObjectBuilderSettings(bool omitNullValuedAttributes = false, bool /// options.NullAttributeResponseBehavior = new NullAttributeResponseBehavior(true); /// /// - public bool OmitNullValuedAttributes { get; } + public bool OmitAttributeIfValueIsNull { get; } /// /// Prevent attributes with default values from being included in the response. @@ -38,8 +38,6 @@ public ResourceObjectBuilderSettings(bool omitNullValuedAttributes = false, bool /// options.DefaultAttributeResponseBehavior = new DefaultAttributeResponseBehavior(true); /// /// - public bool OmitDefaultValuedAttributes { get; } + public bool OmitAttributeIfValueIsDefault { get; } } - } - diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs index eaddf818..d75e7bde 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs @@ -22,7 +22,7 @@ public ResourceObjectBuilderSettingsProvider(IOmitDefaultService defaultAttribut /// public ResourceObjectBuilderSettings Get() { - return new ResourceObjectBuilderSettings(_nullAttributeValues.Config, _defaultAttributeValues.Config); + return new ResourceObjectBuilderSettings(_nullAttributeValues.OmitAttributeIfValueIsNull, _defaultAttributeValues.OmitAttributeIfValueIsDefault); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/NullValuedAttributeHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs similarity index 61% rename from test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/NullValuedAttributeHandlingTests.cs rename to test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs index 1aaa56a3..35d828a5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/NullValuedAttributeHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -12,13 +13,13 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility { [Collection("WebHostCollection")] - public sealed class NullValuedAttributeHandlingTests : IAsyncLifetime + public sealed class OmitAttributeIfValueIsNullTests : IAsyncLifetime { private readonly TestFixture _fixture; private readonly AppDbContext _dbContext; private readonly TodoItem _todoItem; - public NullValuedAttributeHandlingTests(TestFixture fixture) + public OmitAttributeIfValueIsNullTests(TestFixture fixture) { _fixture = fixture; _dbContext = fixture.GetService(); @@ -55,34 +56,33 @@ public Task DisposeAsync() [InlineData(null, false, "true", false)] [InlineData(null, true, "true", true)] [InlineData(null, true, "false", false)] - [InlineData(null, true, "foo", false)] - [InlineData(null, false, "foo", false)] - [InlineData(true, true, "foo", true)] - [InlineData(true, false, "foo", true)] + [InlineData(null, true, "this-is-not-a-boolean-value", false)] + [InlineData(null, false, "this-is-not-a-boolean-value", false)] + [InlineData(true, true, "this-is-not-a-boolean-value", true)] + [InlineData(true, false, "this-is-not-a-boolean-value", true)] [InlineData(null, true, null, false)] [InlineData(null, false, null, false)] - public async Task CheckNullBehaviorCombination(bool? omitNullValuedAttributes, bool? allowClientOverride, - string clientOverride, bool omitsNulls) + public async Task CheckNullBehaviorCombination(bool? omitAttributeIfValueIsNull, bool? allowQueryStringOverride, + string queryStringOverride, bool expectNullsMissing) { // Override some null handling options NullAttributeResponseBehavior nullAttributeResponseBehavior; - if (omitNullValuedAttributes.HasValue && allowClientOverride.HasValue) - nullAttributeResponseBehavior = new NullAttributeResponseBehavior(omitNullValuedAttributes.Value, allowClientOverride.Value); - else if (omitNullValuedAttributes.HasValue) - nullAttributeResponseBehavior = new NullAttributeResponseBehavior(omitNullValuedAttributes.Value); - else if (allowClientOverride.HasValue) - nullAttributeResponseBehavior = new NullAttributeResponseBehavior(allowClientOverride: allowClientOverride.Value); + if (omitAttributeIfValueIsNull.HasValue && allowQueryStringOverride.HasValue) + nullAttributeResponseBehavior = new NullAttributeResponseBehavior(omitAttributeIfValueIsNull.Value, allowQueryStringOverride.Value); + else if (omitAttributeIfValueIsNull.HasValue) + nullAttributeResponseBehavior = new NullAttributeResponseBehavior(omitAttributeIfValueIsNull.Value); + else if (allowQueryStringOverride.HasValue) + nullAttributeResponseBehavior = new NullAttributeResponseBehavior(allowQueryStringOverride: allowQueryStringOverride.Value); else nullAttributeResponseBehavior = new NullAttributeResponseBehavior(); var jsonApiOptions = _fixture.GetService(); jsonApiOptions.NullAttributeResponseBehavior = nullAttributeResponseBehavior; - jsonApiOptions.AllowCustomQueryParameters = true; var httpMethod = new HttpMethod("GET"); - var queryString = allowClientOverride.HasValue - ? $"&omitNull={clientOverride}" + var queryString = allowQueryStringOverride.HasValue + ? $"&omitNull={queryStringOverride}" : ""; var route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; var request = new HttpRequestMessage(httpMethod, route); @@ -92,11 +92,22 @@ public async Task CheckNullBehaviorCombination(bool? omitNullValuedAttributes, b var body = await response.Content.ReadAsStringAsync(); var deserializeBody = JsonConvert.DeserializeObject(body); - // Assert: does response contain a null valued attribute? - Assert.Equal(omitsNulls, !deserializeBody.SingleData.Attributes.ContainsKey("description")); - Assert.Equal(omitsNulls, !deserializeBody.Included[0].Attributes.ContainsKey("lastName")); + if (queryString.Length > 0 && !bool.TryParse(queryStringOverride, out _)) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + else if (allowQueryStringOverride == false && queryStringOverride != null) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + else + { + // Assert: does response contain a null valued attribute? + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectNullsMissing, !deserializeBody.SingleData.Attributes.ContainsKey("description")); + Assert.Equal(expectNullsMissing, !deserializeBody.Included[0].Attributes.ContainsKey("lastName")); + } } } - } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs index 7f38b586..418233b2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs @@ -96,7 +96,7 @@ public async Task Pagination_OnGivenPage_DisplaysCorrectTopLevelLinks(int pageNu options.AllowCustomQueryParameters = true; string routePrefix = "/api/v1/todoItems?filter[owner.lastName]=" + WebUtility.UrlEncode(person.LastName) + - "&fields[owner]=firstName&include=owner&sort=ordinal&omitDefault=true&omitNull=true&foo=bar,baz"; + "&fields[owner]=firstName&include=owner&sort=ordinal&foo=bar,baz"; string route = pageNum != 1 ? routePrefix + $"&page[size]=5&page[number]={pageNum}" : routePrefix; // Act diff --git a/test/UnitTests/QueryParameters/FilterServiceTests.cs b/test/UnitTests/QueryParameters/FilterServiceTests.cs index df739012..0226747e 100644 --- a/test/UnitTests/QueryParameters/FilterServiceTests.cs +++ b/test/UnitTests/QueryParameters/FilterServiceTests.cs @@ -15,16 +15,29 @@ public FilterService GetService() } [Fact] - public void Name_FilterService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() { // Arrange var filterService = GetService(); // Act - var name = filterService.Name; + bool result = filterService.CanParse("filter[age]"); // Assert - Assert.Equal("filter", name); + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() + { + // Arrange + var filterService = GetService(); + + // Act + bool result = filterService.CanParse("other"); + + // Assert + Assert.False(result); } [Theory] @@ -50,7 +63,7 @@ public void Parse_ValidFilters_CanParse(string key, string @operator, string val var filterService = GetService(); // Act - filterService.Parse(query); + filterService.Parse(query.Key, query.Value); var filter = filterService.Get().Single(); // Assert diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index d2eef40f..8a524f57 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -10,23 +10,35 @@ namespace UnitTests.QueryParameters { public sealed class IncludeServiceTests : QueryParametersUnitTestCollection { - public IncludeService GetService(ResourceContext resourceContext = null) { return new IncludeService(_resourceGraph, MockCurrentRequest(resourceContext ?? _articleResourceContext)); } [Fact] - public void Name_IncludeService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() + { + // Arrange + var filterService = GetService(); + + // Act + bool result = filterService.CanParse("include"); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() { // Arrange var filterService = GetService(); // Act - var name = filterService.Name; + bool result = filterService.CanParse("includes"); // Assert - Assert.Equal("include", name); + Assert.False(result); } [Fact] @@ -38,7 +50,7 @@ public void Parse_MultipleNestedChains_CanParse() var service = GetService(); // Act - service.Parse(query); + service.Parse(query.Key, query.Value); // Assert var chains = service.Get(); @@ -60,7 +72,7 @@ public void Parse_ChainsOnWrongMainResource_ThrowsJsonApiException() var service = GetService(_resourceGraph.GetResourceContext()); // Act, assert - var exception = Assert.Throws( () => service.Parse(query)); + var exception = Assert.Throws( () => service.Parse(query.Key, query.Value)); Assert.Contains("Invalid", exception.Message); } @@ -73,7 +85,7 @@ public void Parse_NotIncludable_ThrowsJsonApiException() var service = GetService(); // Act, assert - var exception = Assert.Throws(() => service.Parse(query)); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Contains("not allowed", exception.Message); } @@ -86,7 +98,7 @@ public void Parse_NonExistingRelationship_ThrowsJsonApiException() var service = GetService(); // Act, assert - var exception = Assert.Throws(() => service.Parse(query)); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Contains("Invalid", exception.Message); } @@ -99,7 +111,7 @@ public void Parse_EmptyChain_ThrowsJsonApiException() var service = GetService(); // Act, assert - var exception = Assert.Throws(() => service.Parse(query)); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Contains("Include parameter must not be empty if provided", exception.Message); } } diff --git a/test/UnitTests/QueryParameters/OmitDefaultService.cs b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs similarity index 59% rename from test/UnitTests/QueryParameters/OmitDefaultService.cs rename to test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs index 32b5aa09..5d7a91f0 100644 --- a/test/UnitTests/QueryParameters/OmitDefaultService.cs +++ b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -19,16 +20,29 @@ public OmitDefaultService GetService(bool @default, bool @override) } [Fact] - public void Name_OmitNullService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() { // Arrange - var service = GetService(true, true); + var filterService = GetService(true, true); // Act - var name = service.Name; + bool result = filterService.CanParse("omitDefault"); // Assert - Assert.Equal("omitdefault", name); + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() + { + // Arrange + var filterService = GetService(true, true); + + // Act + bool result = filterService.CanParse("omit-default"); + + // Assert + Assert.False(result); } [Theory] @@ -39,14 +53,17 @@ public void Name_OmitNullService_IsCorrect() public void Parse_QueryConfigWithApiSettings_CanParse(string queryConfig, bool @default, bool @override, bool expected) { // Arrange - var query = new KeyValuePair("omitNull", new StringValues(queryConfig)); + var query = new KeyValuePair("omitDefault", new StringValues(queryConfig)); var service = GetService(@default, @override); // Act - service.Parse(query); + if (service.CanParse(query.Key) && service.IsEnabled(DisableQueryAttribute.Empty)) + { + service.Parse(query.Key, query.Value); + } // Assert - Assert.Equal(expected, service.Config); + Assert.Equal(expected, service.OmitAttributeIfValueIsDefault); } } } diff --git a/test/UnitTests/QueryParameters/OmitNullService.cs b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs similarity index 61% rename from test/UnitTests/QueryParameters/OmitNullService.cs rename to test/UnitTests/QueryParameters/OmitNullServiceTests.cs index 98758cd1..0b16fd93 100644 --- a/test/UnitTests/QueryParameters/OmitNullService.cs +++ b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -19,16 +20,29 @@ public OmitNullService GetService(bool @default, bool @override) } [Fact] - public void Name_OmitNullService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() { // Arrange - var service = GetService(true, true); + var filterService = GetService(true, true); // Act - var name = service.Name; + bool result = filterService.CanParse("omitNull"); // Assert - Assert.Equal("omitnull", name); + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() + { + // Arrange + var filterService = GetService(true, true); + + // Act + bool result = filterService.CanParse("omit-null"); + + // Assert + Assert.False(result); } [Theory] @@ -43,10 +57,13 @@ public void Parse_QueryConfigWithApiSettings_CanParse(string queryConfig, bool @ var service = GetService(@default, @override); // Act - service.Parse(query); + if (service.CanParse(query.Key) && service.IsEnabled(DisableQueryAttribute.Empty)) + { + service.Parse(query.Key, query.Value); + } // Assert - Assert.Equal(expected, service.Config); + Assert.Equal(expected, service.OmitAttributeIfValueIsNull); } } } diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index 87f527bd..67afa3c1 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -10,7 +10,7 @@ namespace UnitTests.QueryParameters { public sealed class PageServiceTests : QueryParametersUnitTestCollection { - public IPageService GetService(int? maximumPageSize = null, int? maximumPageNumber = null) + public PageService GetService(int? maximumPageSize = null, int? maximumPageNumber = null) { return new PageService(new JsonApiOptions { @@ -20,16 +20,29 @@ public IPageService GetService(int? maximumPageSize = null, int? maximumPageNumb } [Fact] - public void Name_PageService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() { // Arrange var filterService = GetService(); // Act - var name = filterService.Name; + bool result = filterService.CanParse("page[size]"); // Assert - Assert.Equal("page", name); + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() + { + // Arrange + var filterService = GetService(); + + // Act + bool result = filterService.CanParse("page[some]"); + + // Assert + Assert.False(result); } [Theory] @@ -47,12 +60,12 @@ public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximu // Act if (shouldThrow) { - var ex = Assert.Throws(() => service.Parse(query)); + var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } else { - service.Parse(query); + service.Parse(query.Key, query.Value); Assert.Equal(expectedValue, service.PageSize); } } @@ -72,12 +85,12 @@ public void Parse_PageNumber_CanParse(string value, int expectedValue, int? maxi // Act if (shouldThrow) { - var ex = Assert.Throws(() => service.Parse(query)); + var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } else { - service.Parse(query); + service.Parse(query.Key, query.Value); Assert.Equal(expectedValue, service.CurrentPage); } } diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs index ca861836..4980ccfd 100644 --- a/test/UnitTests/QueryParameters/SortServiceTests.cs +++ b/test/UnitTests/QueryParameters/SortServiceTests.cs @@ -14,16 +14,29 @@ public SortService GetService() } [Fact] - public void Name_SortService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() { // Arrange var filterService = GetService(); // Act - var name = filterService.Name; + bool result = filterService.CanParse("sort"); // Assert - Assert.Equal("sort", name); + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() + { + // Arrange + var filterService = GetService(); + + // Act + bool result = filterService.CanParse("sorting"); + + // Assert + Assert.False(result); } [Theory] @@ -37,7 +50,7 @@ public void Parse_InvalidSortQuery_ThrowsJsonApiException(string stringSortQuery var sortService = GetService(); // Act, assert - var exception = Assert.Throws(() => sortService.Parse(query)); + var exception = Assert.Throws(() => sortService.Parse(query.Key, query.Value)); Assert.Contains("sort", exception.Message); } } diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 1668f6b8..150f9867 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -3,7 +3,6 @@ using System.Net; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -18,16 +17,29 @@ public SparseFieldsService GetService(ResourceContext resourceContext = null) } [Fact] - public void Name_SparseFieldsService_IsCorrect() + public void CanParse_FilterService_SucceedOnMatch() { // Arrange var filterService = GetService(); // Act - var name = filterService.Name; + bool result = filterService.CanParse("fields[customer]"); // Assert - Assert.Equal("fields", name); + Assert.True(result); + } + + [Fact] + public void CanParse_FilterService_FailOnMismatch() + { + // Arrange + var filterService = GetService(); + + // Act + bool result = filterService.CanParse("other"); + + // Assert + Assert.False(result); } [Fact] @@ -50,7 +62,7 @@ public void Parse_ValidSelection_CanParse() var service = GetService(resourceContext); // Act - service.Parse(query); + service.Parse(query.Key, query.Value); var result = service.Get(); // Assert @@ -79,7 +91,7 @@ public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessag var service = GetService(resourceContext); // Act, assert - var ex = Assert.Throws(() => service.Parse(query)); + var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Contains("relationships only", ex.Message); Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } @@ -105,7 +117,7 @@ public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() var service = GetService(resourceContext); // Act, assert - var ex = Assert.Throws(() => service.Parse(query)); + var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Contains("deeply nested", ex.Message); Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } @@ -129,7 +141,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() var service = GetService(resourceContext); // Act , assert - var ex = Assert.Throws(() => service.Parse(query)); + var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); } } From 5b7a33f2b4901cb89d3710b69f10f01f8e08f10c Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Thu, 2 Apr 2020 19:41:23 +0200 Subject: [PATCH 20/60] Query strings: moved the check for empty value up the call stack, created custom exception, updated Errors to match with json:api spec and added error details validation to tests. Fixed missing lowerbound check on negative page numbers. Tweaks in query parameter name checks, to reduce chance of collisions. --- .../Properties/launchSettings.json | 6 +- .../Configuration/IJsonApiOptions.cs | 2 +- .../Configuration/JsonApiOptions.cs | 6 +- .../Formatters/JsonApiWriter.cs | 4 +- .../Exceptions/InvalidModelStateException.cs | 3 + .../InvalidQueryStringParameterException.cs | 28 +++++++ .../Exceptions/InvalidRequestBodyException.cs | 4 +- .../InvalidResponseBodyException.cs | 3 + .../RequestMethodNotAllowedException.cs | 3 + ...s => UnsuccessfulActionResultException.cs} | 9 +- .../Common/QueryParameterParser.cs | 12 ++- .../QueryParameterServices/FilterService.cs | 17 ++-- .../QueryParameterServices/IncludeService.cs | 31 +++---- .../QueryParameterServices/PageService.cs | 84 ++++++++++--------- .../QueryParameterServices/SortService.cs | 17 ++-- .../SparseFieldsService.cs | 46 ++++++---- .../Acceptance/Spec/AttributeFilterTests.cs | 9 ++ .../Acceptance/Spec/AttributeSortTests.cs | 10 +++ .../Acceptance/Spec/PagingTests.cs | 3 +- .../Acceptance/Spec/QueryParameterTests.cs | 70 ++++++++++++++++ .../Acceptance/Spec/QueryParameters.cs | 42 ---------- .../QueryParameters/FilterServiceTests.cs | 2 +- .../QueryParameters/IncludeServiceTests.cs | 48 ++++++----- .../OmitDefaultServiceTests.cs | 4 +- .../QueryParameters/OmitNullServiceTests.cs | 4 +- .../QueryParameters/PageServiceTests.cs | 24 ++++-- .../QueryParameters/SortServiceTests.cs | 12 ++- .../SparseFieldsServiceTests.cs | 80 ++++++++++++++---- 28 files changed, 387 insertions(+), 196 deletions(-) create mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs rename src/JsonApiDotNetCore/Internal/Exceptions/{ActionResultException.cs => UnsuccessfulActionResultException.cs} (73%) create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index fa59af8d..0e97dd48 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -10,7 +10,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "api/values", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -18,7 +18,7 @@ }, "JsonApiDotNetCoreExample": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "http://localhost:5000/api/values", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -26,4 +26,4 @@ "applicationUrl": "http://localhost:5000/" } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index c5843683..c20e2eaf 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -31,7 +31,7 @@ public interface IJsonApiOptions : ILinksConfiguration, ISerializerOptions int? MaximumPageNumber { get; } bool ValidateModelState { get; } bool AllowClientGeneratedIds { get; } - bool AllowCustomQueryParameters { get; set; } + bool AllowCustomQueryStringParameters { get; set; } string Namespace { get; set; } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 2b10bd76..05bd69ba 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -98,14 +98,14 @@ public class JsonApiOptions : IJsonApiOptions public bool AllowClientGeneratedIds { get; set; } /// - /// Whether or not to allow all custom query parameters. + /// Whether or not to allow all custom query string parameters. /// /// /// - /// options.AllowCustomQueryParameters = true; + /// options.AllowCustomQueryStringParameters = true; /// /// - public bool AllowCustomQueryParameters { get; set; } + public bool AllowCustomQueryStringParameters { get; set; } /// /// The default behavior for serializing attributes that contain null. diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index c234b17c..5e0f6879 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -63,12 +63,12 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode { if (contextObject is ProblemDetails problemDetails) { - throw new ActionResultException(problemDetails); + throw new UnsuccessfulActionResultException(problemDetails); } if (contextObject == null && !IsSuccessStatusCode(statusCode)) { - throw new ActionResultException(statusCode); + throw new UnsuccessfulActionResultException(statusCode); } try diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs index 7cdbdbb2..3449dde8 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs @@ -10,6 +10,9 @@ namespace JsonApiDotNetCore.Internal { + /// + /// The error that is thrown when model state validation fails. + /// public class InvalidModelStateException : Exception { public IList Errors { get; } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs new file mode 100644 index 00000000..557ea46f --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs @@ -0,0 +1,28 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Internal.Exceptions +{ + /// + /// The error that is thrown when parsing the request query string fails. + /// + public sealed class InvalidQueryStringParameterException : JsonApiException + { + public string QueryParameterName { get; } + + public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, + string specificMessage) + : base(new Error(HttpStatusCode.BadRequest) + { + Title = genericMessage, + Detail = specificMessage, + Source = + { + Parameter = queryParameterName + } + }) + { + QueryParameterName = queryParameterName; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs index 9969c0a1..018d47b3 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs @@ -1,10 +1,12 @@ using System; using System.Net; -using System.Net.Http; using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Internal { + /// + /// The error that is thrown when deserializing the request body fails. + /// public sealed class InvalidRequestBodyException : JsonApiException { public InvalidRequestBodyException(string message, Exception innerException = null) diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs index 01d60be4..19ab431e 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs @@ -4,6 +4,9 @@ namespace JsonApiDotNetCore.Internal.Exceptions { + /// + /// The error that is thrown when serializing the response body fails. + /// public sealed class InvalidResponseBodyException : JsonApiException { public InvalidResponseBodyException(Exception innerException) : base(new Error(HttpStatusCode.InternalServerError) diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs index abb9698a..b636894e 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs @@ -4,6 +4,9 @@ namespace JsonApiDotNetCore.Internal { + /// + /// The error that is thrown when a request is received that contains an unsupported HTTP verb. + /// public sealed class RequestMethodNotAllowedException : JsonApiException { public HttpMethod Method { get; } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/UnsuccessfulActionResultException.cs similarity index 73% rename from src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs rename to src/JsonApiDotNetCore/Internal/Exceptions/UnsuccessfulActionResultException.cs index 6c6dc87e..458ba425 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/ActionResultException.cs +++ b/src/JsonApiDotNetCore/Internal/Exceptions/UnsuccessfulActionResultException.cs @@ -4,9 +4,12 @@ namespace JsonApiDotNetCore.Internal.Exceptions { - public sealed class ActionResultException : JsonApiException + /// + /// The error that is thrown when an with non-success status is returned from a controller method. + /// + public sealed class UnsuccessfulActionResultException : JsonApiException { - public ActionResultException(HttpStatusCode status) + public UnsuccessfulActionResultException(HttpStatusCode status) : base(new Error(status) { Title = status.ToString() @@ -14,7 +17,7 @@ public ActionResultException(HttpStatusCode status) { } - public ActionResultException(ProblemDetails problemDetails) + public UnsuccessfulActionResultException(ProblemDetails problemDetails) : base(ToError(problemDetails)) { } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index 8dda9e19..fdb773ef 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.QueryParameterServices.Common; @@ -31,6 +32,12 @@ public virtual void Parse(DisableQueryAttribute disableQueryAttribute) foreach (var pair in _queryStringAccessor.Query) { + if (string.IsNullOrWhiteSpace(pair.Value)) + { + throw new InvalidQueryStringParameterException(pair.Key, "Missing query string parameter value.", + $"Missing value for '{pair.Key}' query string parameter."); + } + var service = _queryServices.FirstOrDefault(s => s.CanParse(pair.Key)); if (service != null) { @@ -41,9 +48,10 @@ public virtual void Parse(DisableQueryAttribute disableQueryAttribute) service.Parse(pair.Key, pair.Value); } - else if (!_options.AllowCustomQueryParameters) + else if (!_options.AllowCustomQueryStringParameters) { - throw new JsonApiException(HttpStatusCode.BadRequest, $"{pair} is not a valid query."); + throw new InvalidQueryStringParameterException(pair.Key, "Unknown query string parameter.", + $"Query string parameter '{pair.Key}' is unknown. Set '{nameof(IJsonApiOptions.AllowCustomQueryStringParameters)}' to 'true' in options to ignore unknown parameters."); } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index c0d63118..dd1bf747 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; @@ -39,7 +38,7 @@ public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) /// public bool CanParse(string parameterName) { - return parameterName.StartsWith("filter"); + return parameterName.StartsWith("filter[") && parameterName.EndsWith("]"); } /// @@ -47,10 +46,10 @@ public virtual void Parse(string parameterName, StringValues parameterValue) { EnsureNoNestedResourceRoute(parameterName); var queries = GetFilterQueries(parameterName, parameterValue); - _filters.AddRange(queries.Select(GetQueryContexts)); + _filters.AddRange(queries.Select(x => GetQueryContexts(x, parameterName))); } - private FilterQueryContext GetQueryContexts(FilterQuery query) + private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterName) { var queryContext = new FilterQueryContext(query); var customQuery = _requestResourceDefinition?.GetCustomQueryFilter(query.Target); @@ -64,8 +63,12 @@ private FilterQueryContext GetQueryContexts(FilterQuery query) queryContext.Relationship = GetRelationship(query.Relationship); var attribute = GetAttribute(query.Attribute, queryContext.Relationship); - if (attribute.IsFilterable == false) - throw new JsonApiException(HttpStatusCode.BadRequest, $"Filter is not allowed for attribute '{attribute.PublicAttributeName}'."); + if (!attribute.IsFilterable) + { + throw new InvalidQueryStringParameterException(parameterName, "Filtering on the requested attribute is not allowed.", + $"Filtering on attribute '{attribute.PublicAttributeName}' is not allowed."); + } + queryContext.Attribute = attribute; return queryContext; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs index 405cf3d0..36c10d29 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Models; using Microsoft.Extensions.Primitives; @@ -43,15 +44,12 @@ public bool CanParse(string parameterName) public virtual void Parse(string parameterName, StringValues parameterValue) { var value = (string)parameterValue; - if (string.IsNullOrWhiteSpace(value)) - throw new JsonApiException(HttpStatusCode.BadRequest, "Include parameter must not be empty if provided"); - var chains = value.Split(QueryConstants.COMMA).ToList(); foreach (var chain in chains) - ParseChain(chain); + ParseChain(chain, parameterName); } - private void ParseChain(string chain) + private void ParseChain(string chain, string parameterName) { var parsedChain = new List(); var chainParts = chain.Split(QueryConstants.DOT); @@ -60,26 +58,21 @@ private void ParseChain(string chain) { var relationship = resourceContext.Relationships.SingleOrDefault(r => r.PublicRelationshipName == relationshipName); if (relationship == null) - throw InvalidRelationshipError(resourceContext, relationshipName); + { + throw new InvalidQueryStringParameterException(parameterName, "The requested relationship to include does not exist.", + $"The relationship '{relationshipName}' on '{resourceContext.ResourceName}' does not exist."); + } - if (relationship.CanInclude == false) - throw CannotIncludeError(resourceContext, relationshipName); + if (!relationship.CanInclude) + { + throw new InvalidQueryStringParameterException(parameterName, "Including the requested relationship is not allowed.", + $"Including the relationship '{relationshipName}' on '{resourceContext.ResourceName}' is not allowed."); + } parsedChain.Add(relationship); resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); } _includedChains.Add(parsedChain); } - - private JsonApiException CannotIncludeError(ResourceContext resourceContext, string requestedRelationship) - { - return new JsonApiException(HttpStatusCode.BadRequest, $"Including the relationship {requestedRelationship} on {resourceContext.ResourceName} is not allowed"); - } - - private JsonApiException InvalidRelationshipError(ResourceContext resourceContext, string requestedRelationship) - { - return new JsonApiException(HttpStatusCode.BadRequest, $"Invalid relationship {requestedRelationship} on {resourceContext.ResourceName}", - $"{resourceContext.ResourceName} does not have a relationship named {requestedRelationship}"); - } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index cc9575e5..90eb1a4f 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using Microsoft.Extensions.Primitives; @@ -83,53 +85,59 @@ public virtual void Parse(string parameterName, StringValues parameterValue) // page[number]= var propertyName = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; - const string SIZE = "size"; - const string NUMBER = "number"; + if (propertyName == "size") + { + RequestedPageSize = ParsePageSize(parameterValue, _options.MaximumPageSize); + } + else if (propertyName == "number") + { + var number = ParsePageNumber(parameterValue, _options.MaximumPageNumber); + + // TODO: It doesn't seem right that a negative paging value reverses the sort order. + // A better way would be to allow ?sort=- to indicate reversing results. + // Then a negative paging value, like -5, could mean: "5 pages back from the last page" - if (propertyName == SIZE) + Backwards = number < 0; + CurrentPage = Backwards ? -number : number; + } + } + + private int ParsePageSize(string parameterValue, int? maxValue) + { + bool success = int.TryParse(parameterValue, out int number); + if (success && number >= 1) { - if (!int.TryParse(parameterValue, out var size)) + if (maxValue == null || number <= maxValue) { - ThrowBadPagingRequest(parameterName, parameterValue, "value could not be parsed as an integer"); - } - else if (size < 1) - { - ThrowBadPagingRequest(parameterName, parameterValue, "value needs to be greater than zero"); - } - else if (size > _options.MaximumPageSize) - { - ThrowBadPagingRequest(parameterName, parameterValue, $"page size cannot be higher than {_options.MaximumPageSize}."); - } - else - { - RequestedPageSize = size; + return number; } } - else if (propertyName == NUMBER) - { - if (!int.TryParse(parameterValue, out var number)) - { - ThrowBadPagingRequest(parameterName, parameterValue, "value could not be parsed as an integer"); - } - else if (number == 0) - { - ThrowBadPagingRequest(parameterName, parameterValue, "page index is not zero-based"); - } - else if (number > _options.MaximumPageNumber) - { - ThrowBadPagingRequest(parameterName, parameterValue, $"page index cannot be higher than {_options.MaximumPageNumber}."); - } - else + + var message = maxValue == null + ? $"Value '{parameterValue}' is invalid, because it must be a whole number that is greater than zero." + : $"Value '{parameterValue}' is invalid, because it must be a whole number that is greater than zero and not higher than {maxValue}."; + + throw new InvalidQueryStringParameterException("page[size]", + "The specified value is not in the range of valid values.", message); + } + + private int ParsePageNumber(string parameterValue, int? maxValue) + { + bool success = int.TryParse(parameterValue, out int number); + if (success && number != 0) + { + if (maxValue == null || (number >= 0 ? number <= maxValue : number >= -maxValue)) { - Backwards = (number < 0); - CurrentPage = Math.Abs(number); + return number; } } - } - private void ThrowBadPagingRequest(string parameterName, StringValues parameterValue, string message) - { - throw new JsonApiException(HttpStatusCode.BadRequest, $"Invalid page query parameter '{parameterName}={parameterValue}': {message}"); + var message = maxValue == null + ? $"Value '{parameterValue}' is invalid, because it must be a whole number that is non-zero." + : $"Value '{parameterValue}' is invalid, because it must be a whole number that is non-zero and not higher than {maxValue} or lower than -{maxValue}."; + + throw new InvalidQueryStringParameterException("page[number]", + "The specified value is not in the range of valid values.", message); } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index 0e2fcb52..7f9a3d31 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -1,9 +1,11 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using Microsoft.Extensions.Primitives; @@ -54,18 +56,20 @@ public bool CanParse(string parameterName) public virtual void Parse(string parameterName, StringValues parameterValue) { EnsureNoNestedResourceRoute(parameterName); - var queries = BuildQueries(parameterValue); + var queries = BuildQueries(parameterValue, parameterName); _queries = queries.Select(BuildQueryContext).ToList(); } - private List BuildQueries(string value) + private List BuildQueries(string value, string parameterName) { var sortParameters = new List(); var sortSegments = value.Split(QueryConstants.COMMA); if (sortSegments.Any(s => s == string.Empty)) - throw new JsonApiException(HttpStatusCode.BadRequest, "The sort URI segment contained a null value."); + { + throw new InvalidQueryStringParameterException(parameterName, "The list of fields to sort on contains empty elements.", null); + } foreach (var sortSegment in sortSegments) { @@ -89,8 +93,11 @@ private SortQueryContext BuildQueryContext(SortQuery query) var relationship = GetRelationship(query.Relationship); var attribute = GetAttribute(query.Attribute, relationship); - if (attribute.IsSortable == false) - throw new JsonApiException(HttpStatusCode.BadRequest, $"Sort is not allowed for attribute '{attribute.PublicAttributeName}'."); + if (!attribute.IsSortable) + { + throw new InvalidQueryStringParameterException("sort", "Sorting on the requested attribute is not allowed.", + $"Sorting on attribute '{attribute.PublicAttributeName}' is not allowed."); + } return new SortQueryContext(query) { diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index 8c2240a2..9c96dba1 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; using System.Linq; -using System.Net; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; @@ -48,7 +47,8 @@ public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) /// public bool CanParse(string parameterName) { - return parameterName.StartsWith("fields"); + var isRelated = parameterName.StartsWith("fields[") && parameterName.EndsWith("]"); + return parameterName == "fields" || isRelated; } /// @@ -66,7 +66,7 @@ public virtual void Parse(string parameterName, StringValues parameterValue) if (keySplit.Length == 1) { // input format: fields=prop1,prop2 foreach (var field in fields) - RegisterRequestResourceField(field); + RegisterRequestResourceField(field, parameterName); } else { // input format: fields[articles]=prop1,prop2 @@ -75,31 +75,45 @@ public virtual void Parse(string parameterName, StringValues parameterValue) // that is equal to the resource name, like with self-referencing data types (eg directory structures) // if not, no longer support this type of sparse field selection. if (navigation == _requestResource.ResourceName && !_requestResource.Relationships.Any(a => a.Is(navigation))) - throw new JsonApiException(HttpStatusCode.BadRequest, $"Use '?fields=...' instead of 'fields[{navigation}]':" + - " the square bracket navigations is now reserved " + - "for relationships only. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/555#issuecomment-543100865"); + { + throw new InvalidQueryStringParameterException(parameterName, + "Square bracket notation in 'filter' is now reserved for relationships only. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/555#issuecomment-543100865 for details.", + $"Use '?fields=...' instead of '?fields[{navigation}]=...'."); + } if (navigation.Contains(QueryConstants.DOT)) - throw new JsonApiException(HttpStatusCode.BadRequest, $"fields[{navigation}] is not valid: deeply nested sparse field selection is not yet supported."); + { + throw new InvalidQueryStringParameterException(parameterName, + "Deeply nested sparse field selection is currently not supported.", + $"Parameter fields[{navigation}] is currently not supported."); + } var relationship = _requestResource.Relationships.SingleOrDefault(a => a.Is(navigation)); if (relationship == null) - throw new JsonApiException(HttpStatusCode.BadRequest, $"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}"); + { + throw new InvalidQueryStringParameterException(parameterName, "Sparse field navigation path refers to an invalid relationship.", + $"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}."); + } foreach (var field in fields) - RegisterRelatedResourceField(field, relationship); + RegisterRelatedResourceField(field, relationship, parameterName); } } /// /// Registers field selection queries of the form articles?fields[author]=firstName /// - private void RegisterRelatedResourceField(string field, RelationshipAttribute relationship) + private void RegisterRelatedResourceField(string field, RelationshipAttribute relationship, string parameterName) { var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType); var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field)); if (attr == null) - throw new JsonApiException(HttpStatusCode.BadRequest, $"'{relationship.RightType.Name}' does not contain '{field}'."); + { + // TODO: Add unit test for this error, once the nesting limitation is removed and this code becomes reachable again. + + throw new InvalidQueryStringParameterException(parameterName, "Sparse field navigation path refers to an invalid related field.", + $"Related resource '{relationship.RightType.Name}' does not contain an attribute named '{field}'."); + } if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields)) _selectedRelationshipFields.Add(relationship, registeredFields = new List()); @@ -109,11 +123,15 @@ private void RegisterRelatedResourceField(string field, RelationshipAttribute re /// /// Registers field selection queries of the form articles?fields=title /// - private void RegisterRequestResourceField(string field) + private void RegisterRequestResourceField(string field, string parameterName) { var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field)); if (attr == null) - throw new JsonApiException(HttpStatusCode.BadRequest, $"'{_requestResource.ResourceName}' does not contain '{field}'."); + { + throw new InvalidQueryStringParameterException(parameterName, + "The specified field does not exist on the requested resource.", + $"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'."); + } (_selectedFields ??= new List()).Add(attr); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index 2f77e2f9..3be8e351 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -100,7 +101,15 @@ public async Task Cannot_Filter_If_Explicitly_Forbidden() var response = await _fixture.Client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal("Filtering on the requested attribute is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Filtering on attribute 'achievedDate' is not allowed.", errorDocument.Errors[0].Detail); + Assert.Equal("filter[achievedDate]", errorDocument.Errors[0].Source.Parameter); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs index 7998df34..c5bd5901 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs @@ -2,6 +2,8 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -28,7 +30,15 @@ public async Task Cannot_Sort_If_Explicitly_Forbidden() var response = await _fixture.Client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal("Sorting on the requested attribute is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Sorting on attribute 'achievedDate' is not allowed.", errorDocument.Errors[0].Detail); + Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs index 418233b2..357fe9b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs @@ -7,7 +7,6 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Http; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -93,7 +92,7 @@ public async Task Pagination_OnGivenPage_DisplaysCorrectTopLevelLinks(int pageNu Context.SaveChanges(); var options = GetService(); - options.AllowCustomQueryParameters = true; + options.AllowCustomQueryStringParameters = true; string routePrefix = "/api/v1/todoItems?filter[owner.lastName]=" + WebUtility.UrlEncode(person.LastName) + "&fields[owner]=firstName&include=owner&sort=ordinal&foo=bar,baz"; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs new file mode 100644 index 00000000..6cc974e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public sealed class QueryParameterTests + { + [Fact] + public async Task Server_Returns_400_ForUnknownQueryParam() + { + // Arrange + const string queryString = "?someKey=someValue"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/todoItems" + queryString); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal("Unknown query string parameter.", errorDocument.Errors[0].Title); + Assert.Equal("Query string parameter 'someKey' is unknown. Set 'AllowCustomQueryStringParameters' to 'true' in options to ignore unknown parameters.", errorDocument.Errors[0].Detail); + Assert.Equal("someKey", errorDocument.Errors[0].Source.Parameter); + } + + [Fact] + public async Task Server_Returns_400_ForMissingQueryParameterValue() + { + // Arrange + const string queryString = "?include="; + + var builder = new WebHostBuilder().UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todoItems" + queryString; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); + Assert.Equal("Missing value for 'include' query string parameter.", errorDocument.Errors[0].Detail); + Assert.Equal("include", errorDocument.Errors[0].Source.Parameter); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs deleted file mode 100644 index 707d4375..00000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class QueryParameters - { - [Fact] - public async Task Server_Returns_400_ForUnknownQueryParam() - { - // Arrange - const string queryKey = "unknownKey"; - const string queryValue = "value"; - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?{queryKey}={queryValue}"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); - - // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Single(errorDocument.Errors); - Assert.Equal($"[{queryKey}, {queryValue}] is not a valid query.", errorDocument.Errors[0].Title); - } - } -} diff --git a/test/UnitTests/QueryParameters/FilterServiceTests.cs b/test/UnitTests/QueryParameters/FilterServiceTests.cs index 0226747e..ce62ac9e 100644 --- a/test/UnitTests/QueryParameters/FilterServiceTests.cs +++ b/test/UnitTests/QueryParameters/FilterServiceTests.cs @@ -59,7 +59,7 @@ public void Parse_ValidFilters_CanParse(string key, string @operator, string val { // Arrange var queryValue = @operator + value; - var query = new KeyValuePair($"filter[{key}]", new StringValues(queryValue)); + var query = new KeyValuePair($"filter[{key}]", queryValue); var filterService = GetService(); // Act diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index 8a524f57..bd29abd2 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Linq; +using System.Net; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using UnitTests.TestModels; @@ -46,7 +48,7 @@ public void Parse_MultipleNestedChains_CanParse() { // Arrange const string chain = "author.blogs.reviewer.favoriteFood,reviewer.blogs.author.favoriteSong"; - var query = new KeyValuePair("include", new StringValues(chain)); + var query = new KeyValuePair("include", chain); var service = GetService(); // Act @@ -68,12 +70,17 @@ public void Parse_ChainsOnWrongMainResource_ThrowsJsonApiException() { // Arrange const string chain = "author.blogs.reviewer.favoriteFood,reviewer.blogs.author.favoriteSong"; - var query = new KeyValuePair("include", new StringValues(chain)); + var query = new KeyValuePair("include", chain); var service = GetService(_resourceGraph.GetResourceContext()); // Act, assert - var exception = Assert.Throws( () => service.Parse(query.Key, query.Value)); - Assert.Contains("Invalid", exception.Message); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal("include", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("The requested relationship to include does not exist.", exception.Error.Title); + Assert.Equal("The relationship 'author' on 'foods' does not exist.", exception.Error.Detail); + Assert.Equal("include", exception.Error.Source.Parameter); } [Fact] @@ -81,12 +88,17 @@ public void Parse_NotIncludable_ThrowsJsonApiException() { // Arrange const string chain = "cannotInclude"; - var query = new KeyValuePair("include", new StringValues(chain)); + var query = new KeyValuePair("include", chain); var service = GetService(); // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Contains("not allowed", exception.Message); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal("include", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("Including the requested relationship is not allowed.", exception.Error.Title); + Assert.Equal("Including the relationship 'cannotInclude' on 'articles' is not allowed.", exception.Error.Detail); + Assert.Equal("include", exception.Error.Source.Parameter); } [Fact] @@ -94,25 +106,17 @@ public void Parse_NonExistingRelationship_ThrowsJsonApiException() { // Arrange const string chain = "nonsense"; - var query = new KeyValuePair("include", new StringValues(chain)); + var query = new KeyValuePair("include", chain); var service = GetService(); // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Contains("Invalid", exception.Message); - } + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - [Fact] - public void Parse_EmptyChain_ThrowsJsonApiException() - { - // Arrange - const string chain = ""; - var query = new KeyValuePair("include", new StringValues(chain)); - var service = GetService(); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Contains("Include parameter must not be empty if provided", exception.Message); + Assert.Equal("include", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("The requested relationship to include does not exist.", exception.Error.Title); + Assert.Equal("The relationship 'nonsense' on 'articles' does not exist.", exception.Error.Detail); + Assert.Equal("include", exception.Error.Source.Parameter); } } } diff --git a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs index 5d7a91f0..863429f6 100644 --- a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs @@ -50,10 +50,10 @@ public void CanParse_FilterService_FailOnMismatch() [InlineData("false", true, false, true)] [InlineData("true", false, true, true)] [InlineData("true", false, false, false)] - public void Parse_QueryConfigWithApiSettings_CanParse(string queryConfig, bool @default, bool @override, bool expected) + public void Parse_QueryConfigWithApiSettings_CanParse(string queryValue, bool @default, bool @override, bool expected) { // Arrange - var query = new KeyValuePair("omitDefault", new StringValues(queryConfig)); + var query = new KeyValuePair("omitDefault", queryValue); var service = GetService(@default, @override); // Act diff --git a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs index 0b16fd93..3ae52055 100644 --- a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs @@ -50,10 +50,10 @@ public void CanParse_FilterService_FailOnMismatch() [InlineData("false", true, false, true)] [InlineData("true", false, true, true)] [InlineData("true", false, false, false)] - public void Parse_QueryConfigWithApiSettings_CanParse(string queryConfig, bool @default, bool @override, bool expected) + public void Parse_QueryConfigWithApiSettings_CanParse(string queryValue, bool @default, bool @override, bool expected) { // Arrange - var query = new KeyValuePair("omitNull", new StringValues(queryConfig)); + var query = new KeyValuePair("omitNull", queryValue); var service = GetService(@default, @override); // Act diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index 67afa3c1..17080bd2 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -54,14 +54,19 @@ public void CanParse_FilterService_FailOnMismatch() public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximumPageSize, bool shouldThrow) { // Arrange - var query = new KeyValuePair("page[size]", new StringValues(value)); + var query = new KeyValuePair("page[size]", value); var service = GetService(maximumPageSize: maximumPageSize); // Act if (shouldThrow) { - var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal("page[size]", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); + Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is greater than zero", exception.Error.Detail); + Assert.Equal("page[size]", exception.Error.Source.Parameter); } else { @@ -79,14 +84,19 @@ public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximu public void Parse_PageNumber_CanParse(string value, int expectedValue, int? maximumPageNumber, bool shouldThrow) { // Arrange - var query = new KeyValuePair("page[number]", new StringValues(value)); + var query = new KeyValuePair("page[number]", value); var service = GetService(maximumPageNumber: maximumPageNumber); // Act if (shouldThrow) { - var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal("page[number]", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); + Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is non-zero", exception.Error.Detail); + Assert.Equal("page[number]", exception.Error.Source.Parameter); } else { diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs index 4980ccfd..723046d3 100644 --- a/test/UnitTests/QueryParameters/SortServiceTests.cs +++ b/test/UnitTests/QueryParameters/SortServiceTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Internal; +using System.Net; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -50,8 +51,13 @@ public void Parse_InvalidSortQuery_ThrowsJsonApiException(string stringSortQuery var sortService = GetService(); // Act, assert - var exception = Assert.Throws(() => sortService.Parse(query.Key, query.Value)); - Assert.Contains("sort", exception.Message); + var exception = Assert.Throws(() => sortService.Parse(query.Key, query.Value)); + + Assert.Equal("sort", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("The list of fields to sort on contains empty elements.", exception.Error.Title); + Assert.Null(exception.Error.Detail); + Assert.Equal("sort", exception.Error.Source.Parameter); } } } diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 150f9867..70807709 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; @@ -36,7 +37,7 @@ public void CanParse_FilterService_FailOnMismatch() var filterService = GetService(); // Act - bool result = filterService.CanParse("other"); + bool result = filterService.CanParse("fieldset"); // Assert Assert.False(result); @@ -51,7 +52,7 @@ public void Parse_ValidSelection_CanParse() var attribute = new AttrAttribute(attrName); var idAttribute = new AttrAttribute("id"); - var query = new KeyValuePair("fields", new StringValues(attrName)); + var query = new KeyValuePair("fields", attrName); var resourceContext = new ResourceContext { @@ -72,15 +73,16 @@ public void Parse_ValidSelection_CanParse() } [Fact] - public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessage() + public void Parse_InvalidRelationship_ThrowsJsonApiException() { // Arrange const string type = "articles"; - const string attrName = "someField"; + var attrName = "someField"; var attribute = new AttrAttribute(attrName); var idAttribute = new AttrAttribute("id"); + var queryParameterName = "fields[missing]"; - var query = new KeyValuePair($"fields[{type}]", new StringValues(attrName)); + var query = new KeyValuePair(queryParameterName, attrName); var resourceContext = new ResourceContext { @@ -91,13 +93,17 @@ public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessag var service = GetService(resourceContext); // Act, assert - var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Contains("relationships only", ex.Message); - Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal(queryParameterName, exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("Sparse field navigation path refers to an invalid relationship.", exception.Error.Title); + Assert.Equal("'missing' in 'fields[missing]' is not a valid relationship of articles.", exception.Error.Detail); + Assert.Equal(queryParameterName, exception.Error.Source.Parameter); } [Fact] - public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() + public void Parse_DeeplyNestedSelection_ThrowsJsonApiException() { // Arrange const string type = "articles"; @@ -105,8 +111,9 @@ public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() const string attrName = "someField"; var attribute = new AttrAttribute(attrName); var idAttribute = new AttrAttribute("id"); + var queryParameterName = $"fields[{relationship}]"; - var query = new KeyValuePair($"fields[{relationship}]", new StringValues(attrName)); + var query = new KeyValuePair(queryParameterName, attrName); var resourceContext = new ResourceContext { @@ -117,9 +124,13 @@ public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() var service = GetService(resourceContext); // Act, assert - var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Contains("deeply nested", ex.Message); - Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal(queryParameterName, exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("Deeply nested sparse field selection is currently not supported.", exception.Error.Title); + Assert.Equal($"Parameter fields[{relationship}] is currently not supported.", exception.Error.Detail); + Assert.Equal(queryParameterName, exception.Error.Source.Parameter); } [Fact] @@ -128,8 +139,38 @@ public void Parse_InvalidField_ThrowsJsonApiException() // Arrange const string type = "articles"; const string attrName = "dne"; + var idAttribute = new AttrAttribute("id"); - var query = new KeyValuePair($"fields[{type}]", new StringValues(attrName)); + var query = new KeyValuePair("fields", attrName); + + var resourceContext = new ResourceContext + { + ResourceName = type, + Attributes = new List {idAttribute}, + Relationships = new List() + }; + + var service = GetService(resourceContext); + + // Act, assert + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal("fields", exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal("The specified field does not exist on the requested resource.", exception.Error.Title); + Assert.Equal($"The field '{attrName}' does not exist on resource '{type}'.", exception.Error.Detail); + Assert.Equal("fields", exception.Error.Source.Parameter); + } + + [Fact] + public void Parse_LegacyNotation_ThrowsJsonApiException() + { + // Arrange + const string type = "articles"; + const string attrName = "dne"; + var queryParameterName = $"fields[{type}]"; + + var query = new KeyValuePair(queryParameterName, attrName); var resourceContext = new ResourceContext { @@ -140,9 +181,14 @@ public void Parse_InvalidField_ThrowsJsonApiException() var service = GetService(resourceContext); - // Act , assert - var ex = Assert.Throws(() => service.Parse(query.Key, query.Value)); - Assert.Equal(HttpStatusCode.BadRequest, ex.Error.Status); + // Act, assert + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal(queryParameterName, exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.StartsWith("Square bracket notation in 'filter' is now reserved for relationships only", exception.Error.Title); + Assert.Equal($"Use '?fields=...' instead of '?fields[{type}]=...'.", exception.Error.Detail); + Assert.Equal(queryParameterName, exception.Error.Source.Parameter); } } } From 5514bac0f2d3d2a5f5b3249299f1151886fdc1d6 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 10:51:43 +0200 Subject: [PATCH 21/60] Removed wrong usage of UnauthorizedAccessException: "The exception that is thrown when the operating system denies access because of...." Adding this as InnerException is not only wrong (this is not an OS error), but it adds no extra info because there is no nested call stack. If you want to catch this, create a custom exception that derives fromJsonApiException instead. --- .../JsonApiDotNetCoreExample/Resources/ArticleResource.cs | 2 +- .../JsonApiDotNetCoreExample/Resources/LockableResource.cs | 2 +- .../JsonApiDotNetCoreExample/Resources/PassportResource.cs | 4 ++-- .../JsonApiDotNetCoreExample/Resources/TodoResource.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs index 67379628..668c1d59 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs @@ -18,7 +18,7 @@ public override IEnumerable
OnReturn(HashSet
entities, Resourc { if (pipeline == ResourcePipeline.GetSingle && entities.Single().Name == "Classified") { - throw new JsonApiException(HttpStatusCode.Forbidden, "You are not allowed to see this article!", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "You are not allowed to see this article!"); } return entities.Where(t => t.Name != "This should be not be included"); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs index 0dcf39bb..71d86316 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs @@ -19,7 +19,7 @@ protected void DisallowLocked(IEnumerable entities) { if (e.IsLocked) { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked todo item", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked todo item"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs index 33ec6a49..b081b859 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -20,7 +20,7 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal { if (pipeline == ResourcePipeline.GetSingle && isIncluded) { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to include passports on individual people", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to include passports on individual people"); } } @@ -35,7 +35,7 @@ private void DoesNotTouchLockedPassports(IEnumerable entities) { if (entity.IsLocked) { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked persons", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked persons"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs index 93cea03c..a9990d7d 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs @@ -17,7 +17,7 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal { if (stringId == "1337") { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update author of any TodoItem", new UnauthorizedAccessException()); + throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update author of any TodoItem"); } } From 23105e724b0af44c6f587c51577d27ae9563caef Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 10:54:40 +0200 Subject: [PATCH 22/60] Moved exceptions into public namespace --- .../JsonApiDotNetCoreExample/Resources/ArticleResource.cs | 1 + .../JsonApiDotNetCoreExample/Resources/LockableResource.cs | 1 + .../JsonApiDotNetCoreExample/Resources/PassportResource.cs | 1 + src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs | 1 + .../JsonApiDotNetCoreExample/Services/CustomArticleService.cs | 1 + src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 1 + .../Controllers/HttpMethodRestrictionFilter.cs | 1 + .../{Internal => }/Exceptions/InvalidModelStateException.cs | 2 +- .../Exceptions/InvalidQueryStringParameterException.cs | 2 +- .../{Internal => }/Exceptions/InvalidRequestBodyException.cs | 2 +- .../{Internal => }/Exceptions/InvalidResponseBodyException.cs | 2 +- .../{Internal => }/Exceptions/JsonApiException.cs | 2 +- .../Exceptions/RequestMethodNotAllowedException.cs | 2 +- .../Exceptions/UnsuccessfulActionResultException.cs | 2 +- src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs | 1 + src/JsonApiDotNetCore/Extensions/TypeExtensions.cs | 1 + src/JsonApiDotNetCore/Formatters/JsonApiReader.cs | 1 + src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs | 2 +- src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs | 1 + src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs | 2 +- src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs | 1 + .../QueryParameterServices/Common/QueryParameterParser.cs | 2 +- .../QueryParameterServices/Common/QueryParameterService.cs | 1 + src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs | 2 +- src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs | 2 +- .../QueryParameterServices/OmitDefaultService.cs | 1 + src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs | 1 + src/JsonApiDotNetCore/QueryParameterServices/PageService.cs | 2 +- src/JsonApiDotNetCore/QueryParameterServices/SortService.cs | 2 +- .../QueryParameterServices/SparseFieldsService.cs | 2 +- .../Serialization/Common/BaseDocumentParser.cs | 1 + src/JsonApiDotNetCore/Services/DefaultResourceService.cs | 1 + src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs | 1 + .../Acceptance/Extensibility/CustomErrorHandlingTests.cs | 1 + test/UnitTests/Controllers/BaseJsonApiController_Tests.cs | 1 + test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs | 1 + test/UnitTests/QueryParameters/IncludeServiceTests.cs | 2 +- test/UnitTests/QueryParameters/PageServiceTests.cs | 2 +- test/UnitTests/QueryParameters/SortServiceTests.cs | 2 +- test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs | 2 +- 40 files changed, 40 insertions(+), 19 deletions(-) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/InvalidModelStateException.cs (98%) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/InvalidQueryStringParameterException.cs (94%) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/InvalidRequestBodyException.cs (94%) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/InvalidResponseBodyException.cs (92%) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/JsonApiException.cs (97%) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/RequestMethodNotAllowedException.cs (94%) rename src/JsonApiDotNetCore/{Internal => }/Exceptions/UnsuccessfulActionResultException.cs (96%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs index 668c1d59..ba10b877 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs @@ -2,6 +2,7 @@ using System.Linq; using System; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs index 71d86316..647c5ba3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs index b081b859..a8eab571 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs index a9990d7d..98969143 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index c68a6db5..d731df15 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Net; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; namespace JsonApiDotNetCoreExample.Services { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 2d37c167..28a0e2f9 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; diff --git a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs index 46fc57c2..6e6f729a 100644 --- a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs +++ b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc.Filters; diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs similarity index 98% rename from src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs rename to src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs index 3449dde8..577e9875 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Exceptions { /// /// The error that is thrown when model state validation fails. diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs similarity index 94% rename from src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs rename to src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs index 557ea46f..321bafb4 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs @@ -1,7 +1,7 @@ using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; -namespace JsonApiDotNetCore.Internal.Exceptions +namespace JsonApiDotNetCore.Exceptions { /// /// The error that is thrown when parsing the request query string fails. diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs similarity index 94% rename from src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs rename to src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs index 018d47b3..f9f68bc5 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs @@ -2,7 +2,7 @@ using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Exceptions { /// /// The error that is thrown when deserializing the request body fails. diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs similarity index 92% rename from src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs rename to src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs index 19ab431e..95279b97 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/InvalidResponseBodyException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs @@ -2,7 +2,7 @@ using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; -namespace JsonApiDotNetCore.Internal.Exceptions +namespace JsonApiDotNetCore.Exceptions { /// /// The error that is thrown when serializing the response body fails. diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs similarity index 97% rename from src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs rename to src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index 4e8ccb04..a55a2654 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -2,7 +2,7 @@ using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Exceptions { public class JsonApiException : Exception { diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs similarity index 94% rename from src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs rename to src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs index b636894e..3dfbffa2 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs @@ -2,7 +2,7 @@ using System.Net.Http; using JsonApiDotNetCore.Models.JsonApiDocuments; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Exceptions { /// /// The error that is thrown when a request is received that contains an unsupported HTTP verb. diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/UnsuccessfulActionResultException.cs b/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs similarity index 96% rename from src/JsonApiDotNetCore/Internal/Exceptions/UnsuccessfulActionResultException.cs rename to src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs index 458ba425..7bf8bb89 100644 --- a/src/JsonApiDotNetCore/Internal/Exceptions/UnsuccessfulActionResultException.cs +++ b/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Internal.Exceptions +namespace JsonApiDotNetCore.Exceptions { /// /// The error that is thrown when an with non-success status is returned from a controller method. diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 09fa107e..3dcb4d92 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -5,6 +5,7 @@ using System.Linq.Expressions; using System.Net; using System.Reflection; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index d48255e5..6fcf3595 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; namespace JsonApiDotNetCore.Extensions { diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 68914cf8..8916a349 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -2,6 +2,7 @@ using System.Collections; using System.IO; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Server; diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 5e0f6879..ce54ddfe 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -3,8 +3,8 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Mvc; diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index c84d3a88..61cad6aa 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs index dd85953c..f9a84867 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs @@ -1,8 +1,8 @@ using System; using System.Net; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.Extensions.Logging; diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 8a09a0f9..8e07700b 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using Microsoft.AspNetCore.Http; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index fdb773ef..6ebd430c 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -4,8 +4,8 @@ using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.QueryParameterServices.Common; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index 3d2b34dd..b8fa6bb2 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index dd1bf747..728003a2 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs index 36c10d29..e33c1c3e 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -2,11 +2,11 @@ using System.Linq; using System.Net; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Models; using Microsoft.Extensions.Primitives; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs index 83613a7f..af98087d 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs @@ -1,6 +1,7 @@ using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Primitives; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs index 9cd4aaae..85e206dc 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs @@ -1,6 +1,7 @@ using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Primitives; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index 90eb1a4f..1246cb91 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -3,9 +3,9 @@ using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using Microsoft.Extensions.Primitives; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index 7f9a3d31..2c2c6006 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -3,9 +3,9 @@ using System.Linq; using System.Net; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using Microsoft.Extensions.Primitives; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index 9c96dba1..04b41a21 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index ed566b61..27f4fbf8 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Reflection; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index 9b36a7ad..d36609d2 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Extensions; diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index d35b302e..ac43ec06 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using System; using System.Net; +using JsonApiDotNetCore.Exceptions; namespace JsonApiDotNetCore.Services { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs index 8ced34d3..aa0c227f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 17ff44ee..c20b7ebb 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -7,6 +7,7 @@ using Xunit; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Http; diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index 6f681cdb..f1e8e6c0 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -12,6 +12,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using Xunit; namespace UnitTests.Middleware diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index bd29abd2..7785b2da 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using UnitTests.TestModels; diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index 17080bd2..22463982 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Exceptions; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs index 723046d3..13cd8a96 100644 --- a/test/UnitTests/QueryParameters/SortServiceTests.cs +++ b/test/UnitTests/QueryParameters/SortServiceTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Net; -using JsonApiDotNetCore.Internal.Exceptions; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 70807709..2b9115eb 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; From 97995a2c3c393599aeda590a949c397733668124 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 11:50:12 +0200 Subject: [PATCH 23/60] Added ObjectCreationException with a unit test. Redirected parameterless object construction to central place. --- .../Exceptions/JsonApiException.cs | 10 ---- .../Exceptions/ObjectCreationException.cs | 21 +++++++ .../Extensions/TypeExtensions.cs | 17 ++++-- .../RepositoryRelationshipUpdateHelper.cs | 2 +- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 3 +- .../Annotation/HasManyThroughAttribute.cs | 5 +- .../Common/BaseDocumentParser.cs | 2 +- .../Services/DefaultResourceService.cs | 2 +- test/UnitTests/Models/ConstructionTests.cs | 56 +++++++++++++++++++ 9 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs create mode 100644 test/UnitTests/Models/ConstructionTests.cs diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index a55a2654..39689427 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -38,15 +38,5 @@ public JsonApiException(HttpStatusCode status, string message, string detail) Detail = detail }; } - - public JsonApiException(HttpStatusCode status, string message, Exception innerException) - : base(message, innerException) - { - Error = new Error(status) - { - Title = message, - Detail = innerException.Message - }; - } } } diff --git a/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs b/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs new file mode 100644 index 00000000..a89fcacd --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs @@ -0,0 +1,21 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + public sealed class ObjectCreationException : JsonApiException + { + public Type Type { get; } + + public ObjectCreationException(Type type, Exception innerException) + : base(new Error(HttpStatusCode.InternalServerError) + { + Title = "Failed to create an object instance using its default constructor.", + Detail = $"Failed to create an instance of '{type.FullName}' using its default constructor." + }, innerException) + { + Type = type; + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 6fcf3595..191b8b09 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -79,18 +79,23 @@ public static IEnumerable GetEmptyCollection(this Type t) if (t == null) throw new ArgumentNullException(nameof(t)); var listType = typeof(List<>).MakeGenericType(t); - var list = (IEnumerable)Activator.CreateInstance(listType); + var list = (IEnumerable)CreateNewInstance(listType); return list; } + public static object New(this Type t) + { + return New(t); + } + /// - /// Creates a new instance of type t, casting it to the specified TInterface + /// Creates a new instance of type t, casting it to the specified type. /// - public static TInterface New(this Type t) + public static T New(this Type t) { if (t == null) throw new ArgumentNullException(nameof(t)); - var instance = (TInterface)CreateNewInstance(t); + var instance = (T)CreateNewInstance(t); return instance; } @@ -100,9 +105,9 @@ private static object CreateNewInstance(Type type) { return Activator.CreateInstance(type); } - catch (Exception e) + catch (Exception exception) { - throw new JsonApiException(HttpStatusCode.InternalServerError, $"Type '{type}' cannot be instantiated using the default constructor.", e); + throw new ObjectCreationException(type, exception); } } diff --git a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs index a2082738..f899de63 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs @@ -112,7 +112,7 @@ private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAtt var newLinks = relationshipIds.Select(x => { - var link = Activator.CreateInstance(relationship.ThroughType); + var link = relationship.ThroughType.New(); relationship.LeftIdProperty.SetValue(link, TypeHelper.ConvertType(parentId, relationship.LeftIdProperty.PropertyType)); relationship.RightIdProperty.SetValue(link, TypeHelper.ConvertType(x, relationship.RightIdProperty.PropertyType)); return link; diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 8fd71ffd..759dd88d 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using System.Linq.Expressions; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal @@ -68,7 +69,7 @@ private static object GetDefaultType(Type type) { if (type.IsValueType) { - return Activator.CreateInstance(type); + return type.New(); } return null; } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index 84ff0cfc..7a36158c 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; +using TypeExtensions = JsonApiDotNetCore.Extensions.TypeExtensions; namespace JsonApiDotNetCore.Models { @@ -112,12 +113,12 @@ public override void SetValue(object entity, object newValue) } else { - var throughRelationshipCollection = (IList)Activator.CreateInstance(ThroughProperty.PropertyType); + var throughRelationshipCollection = ThroughProperty.PropertyType.New(); ThroughProperty.SetValue(entity, throughRelationshipCollection); foreach (IIdentifiable pointer in (IList)newValue) { - var throughInstance = Activator.CreateInstance(ThroughType); + var throughInstance = ThroughType.New(); LeftProperty.SetValue(throughInstance, entity); RightProperty.SetValue(throughInstance, pointer); throughRelationshipCollection.Add(throughInstance); diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 27f4fbf8..9b4cff8f 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -142,7 +142,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); } - var entity = (IIdentifiable)Activator.CreateInstance(resourceContext.ResourceType); + var entity = resourceContext.ResourceType.New(); entity = SetAttributes(entity, data.Attributes, resourceContext.Attributes); entity = SetRelationships(entity, data.Relationships, resourceContext.Relationships); diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index d36609d2..54ecd6d9 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -76,7 +76,7 @@ public virtual async Task CreateAsync(TResource entity) public virtual async Task DeleteAsync(TId id) { - var entity = (TResource)Activator.CreateInstance(typeof(TResource)); + var entity = typeof(TResource).New(); entity.Id = id; if (!IsNull(_hookExecutor, entity)) _hookExecutor.BeforeDelete(AsList(entity), ResourcePipeline.Delete); var succeeded = await _repository.DeleteAsync(entity.Id); diff --git a/test/UnitTests/Models/ConstructionTests.cs b/test/UnitTests/Models/ConstructionTests.cs new file mode 100644 index 00000000..64754e3d --- /dev/null +++ b/test/UnitTests/Models/ConstructionTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Server; +using Xunit; + +namespace UnitTests.Models +{ + public sealed class ConstructionTests + { + [Fact] + public void When_model_has_no_parameterless_contructor_it_must_fail() + { + // Arrange + var graph = new ResourceGraphBuilder().AddResource().Build(); + + var serializer = new RequestDeserializer(graph, new TargetedFields()); + + var body = new + { + data = new + { + id = "1", + type = "resourceWithParameters" + } + }; + string content = Newtonsoft.Json.JsonConvert.SerializeObject(body); + + // Act + Action action = () => serializer.Deserialize(content); + + // Assert + var exception = Assert.Throws(action); + + Assert.Equal(typeof(ResourceWithParameters), exception.Type); + Assert.Equal(HttpStatusCode.InternalServerError, exception.Error.Status); + Assert.Equal("Failed to create an object instance using its default constructor.", exception.Error.Title); + Assert.Equal("Failed to create an instance of 'UnitTests.Models.ConstructionTests+ResourceWithParameters' using its default constructor.", exception.Error.Detail); + } + + public class ResourceWithParameters : Identifiable + { + [Attr] public string Title { get; } + + public ResourceWithParameters(string title) + { + Title = title; + } + } + } +} From f55b9f88d4130c8f122c009f8e5f7963dafb9676 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 12:26:46 +0200 Subject: [PATCH 24/60] Added exception + test for resource type mismatch between endpoint url and request body. --- .../ResourceTypeMismatchException.cs | 22 +++++++++++++++++++ .../Middleware/DefaultTypeMatchFilter.cs | 9 ++++---- .../Acceptance/Spec/CreatingDataTests.cs | 19 +++++++++++++--- 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs new file mode 100644 index 00000000..05eca67e --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs @@ -0,0 +1,22 @@ +using System.Net; +using System.Net.Http; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when the resource type in the request body does not match the type expected at the current endpoint URL. + /// + public sealed class ResourceTypeMismatchException : JsonApiException + { + public ResourceTypeMismatchException(HttpMethod method, string requestPath, ResourceContext expected, ResourceContext actual) + : base(new Error(HttpStatusCode.Conflict) + { + Title = "Resource type mismatch between request body and endpoint URL.", + Detail = $"Expected resource of type '{expected.ResourceName}' in {method} request body at endpoint '{requestPath}', instead of '{actual.ResourceName}'." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 8e07700b..388c31d3 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net; +using System.Net.Http; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; @@ -31,12 +32,10 @@ public void OnActionExecuting(ActionExecutingContext context) if (deserializedType != null && targetType != null && deserializedType != targetType) { - var expectedJsonApiResource = _provider.GetResourceContext(targetType); + ResourceContext resourceFromEndpoint = _provider.GetResourceContext(targetType); + ResourceContext resourceFromBody = _provider.GetResourceContext(deserializedType); - throw new JsonApiException(HttpStatusCode.Conflict, - $"Cannot '{context.HttpContext.Request.Method}' type '{deserializedType.Name}' " - + $"to '{expectedJsonApiResource?.ResourceName}' endpoint.", - detail: "Check that the request payload type matches the type expected by this endpoint."); + throw new ResourceTypeMismatchException(new HttpMethod(request.Method), request.Path, resourceFromEndpoint, resourceFromBody); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index d1acf36c..e699f6dc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -4,9 +4,11 @@ using System.Net; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Helpers.Models; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -214,14 +216,25 @@ public async Task CreateResource_SimpleResource_HeaderLocationsAreCorrect() public async Task CreateResource_EntityTypeMismatch_IsConflict() { // Arrange - var serializer = GetSerializer(e => new { }, e => new { e.Owner }); - var content = serializer.Serialize(_todoItemFaker.Generate()).Replace("todoItems", "people"); + string content = JsonConvert.SerializeObject(new + { + data = new + { + type = "people" + } + }); // Act - var (_, response) = await Post("/api/v1/todoItems", content); + var (body, response) = await Post("/api/v1/todoItems", content); // Assert AssertEqualStatusCode(HttpStatusCode.Conflict, response); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].Status); + Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); + Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); } [Fact] From ad3447857a8f74267e086b0acd2f0bfa230232d4 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 13:40:56 +0200 Subject: [PATCH 25/60] Wrap errors returned from ActionResults, to help being json:api compliant --- .../Formatters/JsonApiWriter.cs | 19 +++++++++++++++++++ .../Models/JsonApiDocuments/ErrorDocument.cs | 16 +++++++--------- test/UnitTests/Internal/ErrorDocumentTests.cs | 9 ++++----- .../Server/ResponseSerializerTests.cs | 3 +-- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index ce54ddfe..89b4cf2f 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; @@ -6,6 +7,7 @@ using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; @@ -71,6 +73,8 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode throw new UnsuccessfulActionResultException(statusCode); } + contextObject = WrapErrors(contextObject); + try { return _serializer.Serialize(contextObject); @@ -81,6 +85,21 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode } } + private static object WrapErrors(object contextObject) + { + if (contextObject is IEnumerable errors) + { + contextObject = new ErrorDocument(errors); + } + + if (contextObject is Error error) + { + contextObject = new ErrorDocument(error); + } + + return contextObject; + } + private bool IsSuccessStatusCode(HttpStatusCode statusCode) { return new HttpResponseMessage(statusCode).IsSuccessStatusCode; diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs index 2bd78bbc..84e9c3dc 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using JsonApiDotNetCore.Graph; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -9,24 +10,21 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments { public sealed class ErrorDocument { - public IList Errors { get; } + public IReadOnlyList Errors { get; } public ErrorDocument() + : this(new List()) { - Errors = new List(); } - public ErrorDocument(Error error) + public ErrorDocument(Error error) + : this(new[] {error}) { - Errors = new List - { - error - }; } - public ErrorDocument(IList errors) + public ErrorDocument(IEnumerable errors) { - Errors = errors; + Errors = errors.ToList(); } public string GetJson() diff --git a/test/UnitTests/Internal/ErrorDocumentTests.cs b/test/UnitTests/Internal/ErrorDocumentTests.cs index 42817287..a8b2946c 100644 --- a/test/UnitTests/Internal/ErrorDocumentTests.cs +++ b/test/UnitTests/Internal/ErrorDocumentTests.cs @@ -11,23 +11,22 @@ public sealed class ErrorDocumentTests public void Can_GetStatusCode() { List errors = new List(); - var document = new ErrorDocument(errors); // Add First 422 error errors.Add(new Error(HttpStatusCode.UnprocessableEntity) {Title = "Something wrong"}); - Assert.Equal(HttpStatusCode.UnprocessableEntity, document.GetErrorStatusCode()); + Assert.Equal(HttpStatusCode.UnprocessableEntity, new ErrorDocument(errors).GetErrorStatusCode()); // Add a second 422 error errors.Add(new Error(HttpStatusCode.UnprocessableEntity) {Title = "Something else wrong"}); - Assert.Equal(HttpStatusCode.UnprocessableEntity, document.GetErrorStatusCode()); + Assert.Equal(HttpStatusCode.UnprocessableEntity, new ErrorDocument(errors).GetErrorStatusCode()); // Add 4xx error not 422 errors.Add(new Error(HttpStatusCode.Unauthorized) {Title = "Unauthorized"}); - Assert.Equal(HttpStatusCode.BadRequest, document.GetErrorStatusCode()); + Assert.Equal(HttpStatusCode.BadRequest, new ErrorDocument(errors).GetErrorStatusCode()); // Add 5xx error not 4xx errors.Add(new Error(HttpStatusCode.BadGateway) {Title = "Not good"}); - Assert.Equal(HttpStatusCode.InternalServerError, document.GetErrorStatusCode()); + Assert.Equal(HttpStatusCode.InternalServerError, new ErrorDocument(errors).GetErrorStatusCode()); } } } diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 02307143..90c83e99 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -455,8 +455,7 @@ public void SerializeError_Error_CanSerialize() { // Arrange var error = new Error(HttpStatusCode.InsufficientStorage) {Title = "title", Detail = "detail"}; - var errorDocument = new ErrorDocument(); - errorDocument.Errors.Add(error); + var errorDocument = new ErrorDocument(error); var expectedJson = JsonConvert.SerializeObject(new { From d7da6a3fa7bd6a940253c467e52f3e9cc709c674 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 14:59:44 +0200 Subject: [PATCH 26/60] Fixed: respect casing convention when serializing error responses --- .../JsonApiSerializerBenchmarks.cs | 3 +- .../Middleware/DefaultExceptionHandler.cs | 5 +- .../Models/JsonApiDocuments/Error.cs | 28 +++++------ .../Models/JsonApiDocuments/ErrorDocument.cs | 20 ++------ .../Models/JsonApiDocuments/ErrorLinks.cs | 2 +- .../Models/JsonApiDocuments/ErrorMeta.cs | 2 +- .../Models/JsonApiDocuments/ErrorSource.cs | 4 +- .../Server/ResponseSerializer.cs | 49 ++++++++++++++++--- .../Extensibility/CustomErrorHandlingTests.cs | 3 +- .../Acceptance/KebabCaseFormatterTests.cs | 19 +++++++ .../Acceptance/ModelStateValidationTests.cs | 4 +- .../Acceptance/Spec/AttributeFilterTests.cs | 2 +- .../Acceptance/Spec/AttributeSortTests.cs | 2 +- .../Acceptance/Spec/CreatingDataTests.cs | 2 +- .../Acceptance/Spec/FetchingDataTests.cs | 2 +- .../Acceptance/Spec/QueryParameterTests.cs | 4 +- .../Acceptance/Spec/UpdatingDataTests.cs | 6 +-- .../BaseJsonApiController_Tests.cs | 14 +++--- .../CurrentRequestMiddlewareTests.cs | 2 +- test/UnitTests/Models/ConstructionTests.cs | 2 +- .../QueryParameters/IncludeServiceTests.cs | 6 +-- .../QueryParameters/PageServiceTests.cs | 4 +- .../QueryParameters/SortServiceTests.cs | 2 +- .../SparseFieldsServiceTests.cs | 8 +-- .../Serialization/SerializerTestsSetup.cs | 3 +- 25 files changed, 119 insertions(+), 79 deletions(-) diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index 3d2aa30f..5493c3d5 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -1,5 +1,6 @@ using System; using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers; using JsonApiDotNetCore.Query; @@ -33,7 +34,7 @@ public JsonApiSerializerBenchmarks() var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, new ResourceObjectBuilderSettings()); _jsonApiSerializer = new ResponseSerializer(metaBuilderMock.Object, linkBuilderMock.Object, - includeBuilderMock.Object, fieldsToSerialize, resourceObjectBuilder); + includeBuilderMock.Object, fieldsToSerialize, resourceObjectBuilder, new CamelCaseFormatter()); } private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs index f9a84867..26a676f7 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs @@ -65,10 +65,7 @@ protected virtual ErrorDocument CreateErrorDocument(Exception exception) private void ApplyOptions(Error error, Exception exception) { - if (_options.IncludeExceptionStackTraceInErrors) - { - error.Meta.IncludeExceptionStackTrace(exception); - } + error.Meta.IncludeExceptionStackTrace(_options.IncludeExceptionStackTraceInErrors ? exception : null); } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs index 8ed2eb80..b93c3c07 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -11,21 +11,21 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments /// public sealed class Error { - public Error(HttpStatusCode status) + public Error(HttpStatusCode statusCode) { - Status = status; + StatusCode = statusCode; } /// /// A unique identifier for this particular occurrence of the problem. /// - [JsonProperty("id")] + [JsonProperty] public string Id { get; set; } = Guid.NewGuid().ToString(); /// /// A link that leads to further details about this particular occurrence of the problem. /// - [JsonProperty("links")] + [JsonProperty] public ErrorLinks Links { get; set; } = new ErrorLinks(); public bool ShouldSerializeLinks() => Links?.About != null; @@ -34,37 +34,37 @@ public Error(HttpStatusCode status) /// The HTTP status code applicable to this problem. /// [JsonIgnore] - public HttpStatusCode Status { get; set; } + public HttpStatusCode StatusCode { get; set; } - [JsonProperty("status")] - public string StatusText + [JsonProperty] + public string Status { - get => Status.ToString("d"); - set => Status = (HttpStatusCode)int.Parse(value); + get => StatusCode.ToString("d"); + set => StatusCode = (HttpStatusCode)int.Parse(value); } /// /// An application-specific error code. /// - [JsonProperty("code")] + [JsonProperty] public string Code { get; set; } /// /// A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. /// - [JsonProperty("title")] + [JsonProperty] public string Title { get; set; } /// /// A human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. /// - [JsonProperty("detail")] + [JsonProperty] public string Detail { get; set; } /// /// An object containing references to the source of the error. /// - [JsonProperty("source")] + [JsonProperty] public ErrorSource Source { get; set; } = new ErrorSource(); public bool ShouldSerializeSource() => Source != null && (Source.Pointer != null || Source.Parameter != null); @@ -72,7 +72,7 @@ public string StatusText /// /// An object containing non-standard meta-information (key/value pairs) about the error. /// - [JsonProperty("meta")] + [JsonProperty] public ErrorMeta Meta { get; set; } = new ErrorMeta(); public bool ShouldSerializeMeta() => Meta != null && Meta.Data.Any(); diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs index 84e9c3dc..ffb45840 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs @@ -1,10 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using JsonApiDotNetCore.Graph; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Models.JsonApiDocuments { @@ -13,13 +10,13 @@ public sealed class ErrorDocument public IReadOnlyList Errors { get; } public ErrorDocument() - : this(new List()) { + Errors = new List(); } - public ErrorDocument(Error error) - : this(new[] {error}) + public ErrorDocument(Error error) { + Errors = new List {error}; } public ErrorDocument(IEnumerable errors) @@ -27,19 +24,10 @@ public ErrorDocument(IEnumerable errors) Errors = errors.ToList(); } - public string GetJson() - { - return JsonConvert.SerializeObject(this, new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore, - ContractResolver = new CamelCasePropertyNamesContractResolver() - }); - } - public HttpStatusCode GetErrorStatusCode() { var statusCodes = Errors - .Select(e => (int)e.Status) + .Select(e => (int)e.StatusCode) .Distinct() .ToList(); diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs index b2e807df..a2c06ff1 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs @@ -7,7 +7,7 @@ public sealed class ErrorLinks /// /// A URL that leads to further details about this particular occurrence of the problem. /// - [JsonProperty("about")] + [JsonProperty] public string About { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs index 24c825a9..f2719247 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs @@ -15,7 +15,7 @@ public sealed class ErrorMeta public void IncludeExceptionStackTrace(Exception exception) { - Data["stackTrace"] = exception.Demystify().ToString() + Data["StackTrace"] = exception?.Demystify().ToString() .Split(new[] {"\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries); } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs index ea426073..b5eb4c3f 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs @@ -7,13 +7,13 @@ public sealed class ErrorSource /// /// Optional. A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. /// - [JsonProperty("pointer")] + [JsonProperty] public string Pointer { get; set; } /// /// Optional. A string indicating which URI query parameter caused the error. /// - [JsonProperty("parameter")] + [JsonProperty] public string Parameter { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index 42d73783..274c6eed 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -1,12 +1,13 @@ using System; using System.Collections; using System.Collections.Generic; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; using Newtonsoft.Json; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Serialization.Server.Builders; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; +using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Serialization.Server { @@ -33,31 +34,47 @@ public class ResponseSerializer : BaseDocumentBuilder, IJsonApiSerial private readonly Type _primaryResourceType; private readonly ILinkBuilder _linkBuilder; private readonly IIncludedResourceObjectBuilder _includedBuilder; + private readonly JsonSerializerSettings _errorSerializerSettings; public ResponseSerializer(IMetaBuilder metaBuilder, - ILinkBuilder linkBuilder, - IIncludedResourceObjectBuilder includedBuilder, - IFieldsToSerialize fieldsToSerialize, - IResourceObjectBuilder resourceObjectBuilder) : - base(resourceObjectBuilder) + ILinkBuilder linkBuilder, + IIncludedResourceObjectBuilder includedBuilder, + IFieldsToSerialize fieldsToSerialize, + IResourceObjectBuilder resourceObjectBuilder, + IResourceNameFormatter formatter) + : base(resourceObjectBuilder) { _fieldsToSerialize = fieldsToSerialize; _linkBuilder = linkBuilder; _metaBuilder = metaBuilder; _includedBuilder = includedBuilder; _primaryResourceType = typeof(TResource); + + _errorSerializerSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new NewtonsoftNamingStrategyAdapter(formatter) + } + }; } /// public string Serialize(object data) { if (data is ErrorDocument errorDocument) - return errorDocument.GetJson(); + return SerializeErrorDocument(errorDocument); if (data is IEnumerable entities) return SerializeMany(entities); return SerializeSingle((IIdentifiable)data); } + private string SerializeErrorDocument(ErrorDocument errorDocument) + { + return JsonConvert.SerializeObject(errorDocument, _errorSerializerSettings); + } + /// /// Convert a single entity into a serialized /// @@ -159,5 +176,23 @@ private void AddTopLevelObjects(Document document) document.Meta = _metaBuilder.GetMeta(); document.Included = _includedBuilder.Build(); } + + private sealed class NewtonsoftNamingStrategyAdapter : NamingStrategy + { + private readonly IResourceNameFormatter _formatter; + + public NewtonsoftNamingStrategyAdapter(IResourceNameFormatter formatter) + { + _formatter = formatter; + + ProcessDictionaryKeys = true; + ProcessExtensionDataNames = true; + } + + protected override string ResolvePropertyName(string name) + { + return _formatter.ApplyCasingConvention(name); + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs index aa0c227f..d21ffefc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -3,7 +3,6 @@ using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.Extensions.Logging; @@ -28,7 +27,7 @@ public void When_using_custom_exception_handler_it_must_create_error_document_an Assert.Single(errorDocument.Errors); Assert.Equal("For support, email to: support@company.com?subject=YouTube", errorDocument.Errors[0].Meta.Data["support"]); - Assert.NotEmpty((string[]) errorDocument.Errors[0].Meta.Data["stackTrace"]); + Assert.NotEmpty((string[]) errorDocument.Errors[0].Meta.Data["StackTrace"]); Assert.Single(loggerFactory.Logger.Messages); Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index 2aaf4ef4..792de4bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -3,6 +3,8 @@ using Bogus; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Acceptance.Spec; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -84,5 +86,22 @@ public async Task KebabCaseFormatter_Update_IsUpdated() var responseItem = _deserializer.DeserializeSingle(body).Data; Assert.Equal(model.CompoundAttr, responseItem.CompoundAttr); } + + [Fact] + public async Task KebabCaseFormatter_ErrorWithStackTrace_CasingConventionIsApplied() + { + // Arrange + const string content = "{ \"data\": {"; + + // Act + var (body, response) = await Patch($"api/v1/kebab-cased-models/1", content); + + // Assert + var document = JsonConvert.DeserializeObject(body); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); + + var meta = document["errors"][0]["meta"]; + Assert.NotNull(meta["stack-trace"]); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs index 9fb67c05..ba01e32f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -50,7 +50,7 @@ public async Task When_posting_tag_with_invalid_name_it_must_fail() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("The field Name must match the regular expression '^\\W$'.", errorDocument.Errors[0].Detail); Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); @@ -124,7 +124,7 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("The field Name must match the regular expression '^\\W$'.", errorDocument.Errors[0].Detail); Assert.Equal("/data/attributes/Name", errorDocument.Errors[0].Source.Pointer); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index 3be8e351..af43e816 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -106,7 +106,7 @@ public async Task Cannot_Filter_If_Explicitly_Forbidden() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); Assert.Equal("Filtering on the requested attribute is not allowed.", errorDocument.Errors[0].Title); Assert.Equal("Filtering on attribute 'achievedDate' is not allowed.", errorDocument.Errors[0].Detail); Assert.Equal("filter[achievedDate]", errorDocument.Errors[0].Source.Parameter); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs index c5bd5901..1266afbf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs @@ -35,7 +35,7 @@ public async Task Cannot_Sort_If_Explicitly_Forbidden() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); Assert.Equal("Sorting on the requested attribute is not allowed.", errorDocument.Errors[0].Title); Assert.Equal("Sorting on attribute 'achievedDate' is not allowed.", errorDocument.Errors[0].Detail); Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index e699f6dc..1d8c0bea 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -232,7 +232,7 @@ public async Task CreateResource_EntityTypeMismatch_IsConflict() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index ec3c2db6..024c6ae4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -150,7 +150,7 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("NotFound", errorDocument.Errors[0].Title); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs index 6cc974e2..79af7d0e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs @@ -33,7 +33,7 @@ public async Task Server_Returns_400_ForUnknownQueryParam() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); Assert.Equal("Unknown query string parameter.", errorDocument.Errors[0].Title); Assert.Equal("Query string parameter 'someKey' is unknown. Set 'AllowCustomQueryStringParameters' to 'true' in options to ignore unknown parameters.", errorDocument.Errors[0].Detail); Assert.Equal("someKey", errorDocument.Errors[0].Source.Parameter); @@ -61,7 +61,7 @@ public async Task Server_Returns_400_ForMissingQueryParameterValue() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].Status); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); Assert.Equal("Missing value for 'include' query string parameter.", errorDocument.Errors[0].Detail); Assert.Equal("include", errorDocument.Errors[0].Source.Parameter); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 4f8424e2..f5e73341 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -86,7 +86,7 @@ public async Task Response422IfUpdatingNotSettableAttribute() Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, error.Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body.", error.Title); Assert.Equal("Property set method not found.", error.Detail); } @@ -143,7 +143,7 @@ public async Task Respond_422_If_IdNotInAttributeList() Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, error.Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Payload must include id attribute.", error.Title); Assert.Null(error.Detail); } @@ -172,7 +172,7 @@ public async Task Respond_422_If_Broken_JSON_Payload() Assert.Single(document.Errors); var error = document.Errors.Single(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, error.Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body.", error.Title); Assert.StartsWith("Invalid character after parsing", error.Detail); } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index c20b7ebb..97235552 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -73,7 +73,7 @@ public async Task GetAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -103,7 +103,7 @@ public async Task GetAsyncById_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -133,7 +133,7 @@ public async Task GetRelationshipsAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -163,7 +163,7 @@ public async Task GetRelationshipAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Get, exception.Method); } @@ -195,7 +195,7 @@ public async Task PatchAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Patch, exception.Method); } @@ -243,7 +243,7 @@ public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Patch, exception.Method); } @@ -273,7 +273,7 @@ public async Task DeleteAsync_Throws_405_If_No_Service() var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.Status); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); Assert.Equal(HttpMethod.Delete, exception.Method); } } diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index f1e8e6c0..24dae147 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -90,7 +90,7 @@ public async Task ParseUrlBase_UrlHasIncorrectBaseIdSet_ShouldThrowException(str { await task; }); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Contains(baseId, exception.Message); } diff --git a/test/UnitTests/Models/ConstructionTests.cs b/test/UnitTests/Models/ConstructionTests.cs index 64754e3d..f71b1eac 100644 --- a/test/UnitTests/Models/ConstructionTests.cs +++ b/test/UnitTests/Models/ConstructionTests.cs @@ -38,7 +38,7 @@ public void When_model_has_no_parameterless_contructor_it_must_fail() var exception = Assert.Throws(action); Assert.Equal(typeof(ResourceWithParameters), exception.Type); - Assert.Equal(HttpStatusCode.InternalServerError, exception.Error.Status); + Assert.Equal(HttpStatusCode.InternalServerError, exception.Error.StatusCode); Assert.Equal("Failed to create an object instance using its default constructor.", exception.Error.Title); Assert.Equal("Failed to create an instance of 'UnitTests.Models.ConstructionTests+ResourceWithParameters' using its default constructor.", exception.Error.Detail); } diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index 7785b2da..0e68ed05 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -77,7 +77,7 @@ public void Parse_ChainsOnWrongMainResource_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal("include", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The requested relationship to include does not exist.", exception.Error.Title); Assert.Equal("The relationship 'author' on 'foods' does not exist.", exception.Error.Detail); Assert.Equal("include", exception.Error.Source.Parameter); @@ -95,7 +95,7 @@ public void Parse_NotIncludable_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal("include", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("Including the requested relationship is not allowed.", exception.Error.Title); Assert.Equal("Including the relationship 'cannotInclude' on 'articles' is not allowed.", exception.Error.Detail); Assert.Equal("include", exception.Error.Source.Parameter); @@ -113,7 +113,7 @@ public void Parse_NonExistingRelationship_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal("include", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The requested relationship to include does not exist.", exception.Error.Title); Assert.Equal("The relationship 'nonsense' on 'articles' does not exist.", exception.Error.Detail); Assert.Equal("include", exception.Error.Source.Parameter); diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index 22463982..c1d20ac4 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -63,7 +63,7 @@ public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximu var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal("page[size]", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is greater than zero", exception.Error.Detail); Assert.Equal("page[size]", exception.Error.Source.Parameter); @@ -93,7 +93,7 @@ public void Parse_PageNumber_CanParse(string value, int expectedValue, int? maxi var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal("page[number]", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is non-zero", exception.Error.Detail); Assert.Equal("page[number]", exception.Error.Source.Parameter); diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs index 13cd8a96..32b341a3 100644 --- a/test/UnitTests/QueryParameters/SortServiceTests.cs +++ b/test/UnitTests/QueryParameters/SortServiceTests.cs @@ -54,7 +54,7 @@ public void Parse_InvalidSortQuery_ThrowsJsonApiException(string stringSortQuery var exception = Assert.Throws(() => sortService.Parse(query.Key, query.Value)); Assert.Equal("sort", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The list of fields to sort on contains empty elements.", exception.Error.Title); Assert.Null(exception.Error.Detail); Assert.Equal("sort", exception.Error.Source.Parameter); diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 2b9115eb..afe269ef 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -96,7 +96,7 @@ public void Parse_InvalidRelationship_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal(queryParameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("Sparse field navigation path refers to an invalid relationship.", exception.Error.Title); Assert.Equal("'missing' in 'fields[missing]' is not a valid relationship of articles.", exception.Error.Detail); Assert.Equal(queryParameterName, exception.Error.Source.Parameter); @@ -127,7 +127,7 @@ public void Parse_DeeplyNestedSelection_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal(queryParameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("Deeply nested sparse field selection is currently not supported.", exception.Error.Title); Assert.Equal($"Parameter fields[{relationship}] is currently not supported.", exception.Error.Detail); Assert.Equal(queryParameterName, exception.Error.Source.Parameter); @@ -156,7 +156,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal("fields", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The specified field does not exist on the requested resource.", exception.Error.Title); Assert.Equal($"The field '{attrName}' does not exist on resource '{type}'.", exception.Error.Detail); Assert.Equal("fields", exception.Error.Source.Parameter); @@ -185,7 +185,7 @@ public void Parse_LegacyNotation_ThrowsJsonApiException() var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); Assert.Equal(queryParameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.Status); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.StartsWith("Square bracket notation in 'filter' is now reserved for relationships only", exception.Error.Title); Assert.Equal($"Use '?fields=...' instead of '?fields[{type}]=...'.", exception.Error.Detail); Assert.Equal(queryParameterName, exception.Error.Source.Parameter); diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 3b7ac36d..5796d3fc 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCore.Query; @@ -45,7 +46,7 @@ protected ResponseSerializer GetResponseSerializer(List(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder); + return new ResponseSerializer(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, new CamelCaseFormatter()); } protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(List> inclusionChains = null, ResourceLinks resourceLinks = null, RelationshipLinks relationshipLinks = null) From 68db80fcb80257f4f08508662fb1d31700a58eb1 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 15:29:06 +0200 Subject: [PATCH 27/60] Another conversion from generic exception to typed exception + test --- .../Exceptions/InvalidRequestBodyException.cs | 23 ++++++++++++++--- .../Formatters/JsonApiReader.cs | 6 ++++- .../Common/BaseDocumentParser.cs | 9 +++---- .../Acceptance/Spec/CreatingDataTests.cs | 25 +++++++++++++++++++ .../Acceptance/Spec/UpdatingDataTests.cs | 2 +- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs index f9f68bc5..6b95abad 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs @@ -9,11 +9,28 @@ namespace JsonApiDotNetCore.Exceptions /// public sealed class InvalidRequestBodyException : JsonApiException { - public InvalidRequestBodyException(string message, Exception innerException = null) + public InvalidRequestBodyException(string reason) + : this(reason, null, null) + { + } + + public InvalidRequestBodyException(string reason, string details) + : this(reason, details, null) + { + } + + public InvalidRequestBodyException(Exception innerException) + : this(null, null, innerException) + { + } + + private InvalidRequestBodyException(string reason, string details = null, Exception innerException = null) : base(new Error(HttpStatusCode.UnprocessableEntity) { - Title = message ?? "Failed to deserialize request body.", - Detail = innerException?.Message + Title = reason != null + ? "Failed to deserialize request body: " + reason + : "Failed to deserialize request body.", + Detail = details ?? innerException?.Message }, innerException) { } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 8916a349..e8e7f387 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -44,9 +44,13 @@ public async Task ReadAsync(InputFormatterContext context) { model = _deserializer.Deserialize(body); } + catch (InvalidRequestBodyException) + { + throw; + } catch (Exception exception) { - throw new InvalidRequestBodyException(null, exception); + throw new InvalidRequestBodyException(exception); } if (context.HttpContext.Request.Method == "PATCH") diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 9b4cff8f..ee58993d 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -135,11 +135,10 @@ private IIdentifiable ParseResourceObject(ResourceObject data) var resourceContext = _provider.GetResourceContext(data.Type); if (resourceContext == null) { - throw new JsonApiException(HttpStatusCode.BadRequest, - message: $"This API does not contain a json:api resource named '{data.Type}'.", - detail: "This resource is not registered on the ResourceGraph. " - + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " - + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); + throw new InvalidRequestBodyException("Payload includes unknown resource type.", + $"The resource '{data.Type}' is not registered on the resource graph. " + + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " + + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); } var entity = resourceContext.ResourceType.New(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index 1d8c0bea..a583dabb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -237,6 +237,31 @@ public async Task CreateResource_EntityTypeMismatch_IsConflict() Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); } + [Fact] + public async Task CreateResource_UnknownEntityType_Fails() + { + // Arrange + string content = JsonConvert.SerializeObject(new + { + data = new + { + type = "something" + } + }); + + // Act + var (body, response) = await Post("/api/v1/todoItems", content); + + // Assert + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); + Assert.Equal("Failed to deserialize request body: Payload includes unknown resource type.", errorDocument.Errors[0].Title); + Assert.StartsWith("The resource 'something' is not registered on the resource graph.", errorDocument.Errors[0].Detail); + } + [Fact] public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index f5e73341..2f5879ec 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -144,7 +144,7 @@ public async Task Respond_422_If_IdNotInAttributeList() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); - Assert.Equal("Payload must include id attribute.", error.Title); + Assert.Equal("Failed to deserialize request body: Payload must include id attribute.", error.Title); Assert.Null(error.Detail); } From 29e342a5efb047dbe3346fc1ea447e89bbe495c1 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 16:07:39 +0200 Subject: [PATCH 28/60] Reuse error for method not allowed + unit tests --- .../HttpMethodRestrictionFilter.cs | 5 ++- .../HttpReadOnlyTests.cs | 43 ++++++++++++++----- .../NoHttpDeleteTests.cs | 29 ++++++++----- .../NoHttpPatchTests.cs | 29 ++++++++----- .../HttpMethodRestrictions/NoHttpPostTests.cs | 29 ++++++++----- 5 files changed, 94 insertions(+), 41 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs index 6e6f729a..e280b3bf 100644 --- a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs +++ b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Net; +using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; @@ -18,7 +19,9 @@ public override async Task OnActionExecutionAsync( var method = context.HttpContext.Request.Method; if (CanExecuteAction(method) == false) - throw new JsonApiException(HttpStatusCode.MethodNotAllowed, $"This resource does not support {method} requests."); + { + throw new RequestMethodNotAllowedException(new HttpMethod(method)); + } await next(); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs index 3171edda..c7428dfa 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,17 @@ public async Task Rejects_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -47,10 +56,17 @@ public async Task Rejects_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -61,13 +77,20 @@ public async Task Rejects_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +99,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs index 0702a77c..d58df911 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,10 @@ public async Task Allows_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -47,10 +49,10 @@ public async Task Allows_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -61,13 +63,20 @@ public async Task Rejects_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +85,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs index 97975c50..e8c1b5bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,10 @@ public async Task Allows_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -47,10 +49,17 @@ public async Task Rejects_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -61,13 +70,13 @@ public async Task Allows_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +85,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs index 8aa2be9c..c7d2d074 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,17 @@ public async Task Rejects_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -47,10 +56,10 @@ public async Task Allows_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -61,13 +70,13 @@ public async Task Allows_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +85,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } From c197c89bcb047ea6cb541e28f0fd3ef8c3cdd699 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 17:32:14 +0200 Subject: [PATCH 29/60] Added test for using DisableQueryAttribute --- .../Controllers/ArticlesController.cs | 1 + .../Controllers/TagsController.cs | 1 + .../SkipCacheQueryParameterService.cs | 36 ++++++++++ .../Startups/Startup.cs | 5 ++ .../Controllers/DisableQueryAttribute.cs | 1 + .../Common/QueryParameterParser.cs | 7 +- .../Spec/DisableQueryAttributeTests.cs | 67 +++++++++++++++++++ 7 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs index 4ae287c6..270aeee9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs @@ -6,6 +6,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + [DisableQuery(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] public sealed class ArticlesController : JsonApiController
{ public ArticlesController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index d9e1382c..c134c842 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -6,6 +6,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + [DisableQuery("skipCache")] public sealed class TagsController : JsonApiController { public TagsController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs new file mode 100644 index 00000000..e5892ccf --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs @@ -0,0 +1,36 @@ +using System.Linq; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Query; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreExample.Services +{ + public class SkipCacheQueryParameterService : IQueryParameterService + { + private const string _skipCacheParameterName = "skipCache"; + + public bool SkipCache { get; private set; } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ParameterNames.Contains(_skipCacheParameterName.ToLowerInvariant()); + } + + public bool CanParse(string parameterName) + { + return parameterName == _skipCacheParameterName; + } + + public void Parse(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out bool skipCache)) + { + throw new InvalidQueryStringParameterException(parameterName, "Boolean value required.", + $"The value {parameterValue} is not a valid boolean."); + } + + SkipCache = skipCache; + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index acc005e3..fb2ea4fd 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore; using JsonApiDotNetCore.Extensions; using System; +using JsonApiDotNetCore.Query; +using JsonApiDotNetCoreExample.Services; namespace JsonApiDotNetCoreExample { @@ -25,6 +27,9 @@ public Startup(IWebHostEnvironment env) public virtual void ConfigureServices(IServiceCollection services) { + services.AddScoped(); + services.AddScoped(sp => sp.GetService()); + services .AddDbContext(options => { diff --git a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs index 6525a82b..e94cce42 100644 --- a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs @@ -4,6 +4,7 @@ namespace JsonApiDotNetCore.Controllers { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class DisableQueryAttribute : Attribute { private readonly List _parameterNames; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index 6ebd430c..e36ef033 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -1,11 +1,8 @@ -using System; using System.Collections.Generic; using System.Linq; -using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.QueryParameterServices.Common; @@ -43,7 +40,9 @@ public virtual void Parse(DisableQueryAttribute disableQueryAttribute) { if (!service.IsEnabled(disableQueryAttribute)) { - throw new JsonApiException(HttpStatusCode.BadRequest, $"{pair} is not available for this resource."); + throw new InvalidQueryStringParameterException(pair.Key, + "Usage of one or more query string parameters is not allowed at the requested endpoint.", + $"The parameter '{pair.Key}' cannot be used at this endpoint."); } service.Parse(pair.Key, pair.Value); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs new file mode 100644 index 00000000..5a360186 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public sealed class DisableQueryAttributeTests + { + private readonly TestFixture _fixture; + + public DisableQueryAttributeTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Cannot_Sort_If_Blocked_By_Controller() + { + // Arrange + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/articles?sort=name"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); + Assert.Equal("The parameter 'sort' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); + Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); + } + + [Fact] + public async Task Cannot_Use_Custom_Query_Parameter_If_Blocked_By_Controller() + { + // Arrange + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/tags?skipCache=true"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); + Assert.Equal("The parameter 'skipCache' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); + Assert.Equal("skipCache", errorDocument.Errors[0].Source.Parameter); + } + } +} From 35989eef703cf82ed71f9cc04489ec42bc570968 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 17:55:37 +0200 Subject: [PATCH 30/60] Added AttributeUsage on attributes --- .../Controllers/DisableRoutingConventionAttribute.cs | 1 + src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs | 4 ++-- .../Hooks/Discovery/LoadDatabaseValuesAttribute.cs | 5 +++-- .../Hooks/Execution/DiffableEntityHashSet.cs | 2 +- src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs | 1 + .../Models/Annotation/EagerLoadAttribute.cs | 1 + src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs | 2 ++ .../Models/Annotation/HasManyThroughAttribute.cs | 2 +- src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs | 2 ++ src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs | 2 +- src/JsonApiDotNetCore/Models/ResourceAttribute.cs | 1 + 11 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs index 8e23b3fb..9b2090f6 100644 --- a/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs @@ -2,6 +2,7 @@ namespace JsonApiDotNetCore.Controllers { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class DisableRoutingConventionAttribute : Attribute { } } diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs index 6505ccef..86166600 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs +++ b/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs @@ -64,12 +64,12 @@ private void DiscoverImplementedHooks(Type containerType) continue; implementedHooks.Add(hook); - var attr = method.GetCustomAttributes(true).OfType().SingleOrDefault(); + var attr = method.GetCustomAttributes(true).OfType().SingleOrDefault(); if (attr != null) { if (!_databaseValuesAttributeAllowed.Contains(hook)) { - throw new JsonApiSetupException("DatabaseValuesAttribute cannot be used on hook" + + throw new JsonApiSetupException($"{nameof(LoadDatabaseValuesAttribute)} cannot be used on hook" + $"{hook:G} in resource definition {containerType.Name}"); } var targetList = attr.Value ? databaseValuesEnabledHooks : databaseValuesDisabledHooks; diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs b/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs index ebf816d3..3caf4b5e 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs +++ b/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs @@ -1,11 +1,12 @@ using System; namespace JsonApiDotNetCore.Hooks { - public sealed class LoadDatabaseValues : Attribute + [AttributeUsage(AttributeTargets.Method)] + public sealed class LoadDatabaseValuesAttribute : Attribute { public readonly bool Value; - public LoadDatabaseValues(bool mode = true) + public LoadDatabaseValuesAttribute(bool mode = true) { Value = mode; } diff --git a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs index cb1ecbdb..fd2a5565 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs @@ -90,7 +90,7 @@ public IEnumerable> GetDiffs() private void ThrowNoDbValuesError() { - throw new MemberAccessException($"Cannot iterate over the diffs if the ${nameof(LoadDatabaseValues)} option is set to false"); + throw new MemberAccessException($"Cannot iterate over the diffs if the ${nameof(LoadDatabaseValuesAttribute)} option is set to false"); } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index 7e7dacbb..88446416 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -4,6 +4,7 @@ namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Property)] public sealed class AttrAttribute : Attribute, IResourceField { /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs index 50c2d9e7..ade7cc66 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs @@ -32,6 +32,7 @@ namespace JsonApiDotNetCore.Models /// } /// ]]> /// + [AttributeUsage(AttributeTargets.Property)] public sealed class EagerLoadAttribute : Attribute { public PropertyInfo Property { get; internal set; } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs index 0c04d30f..d7ad78ef 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs @@ -1,7 +1,9 @@ +using System; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Property)] public class HasManyAttribute : RelationshipAttribute { /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index 7a36158c..0fb9889e 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; -using TypeExtensions = JsonApiDotNetCore.Extensions.TypeExtensions; namespace JsonApiDotNetCore.Models { @@ -27,6 +26,7 @@ namespace JsonApiDotNetCore.Models /// public List<ArticleTag> ArticleTags { get; set; } /// /// + [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs index 7e7152f1..48bceae5 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs @@ -1,9 +1,11 @@ +using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Property)] public sealed class HasOneAttribute : RelationshipAttribute { /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs index b6777697..251f569b 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Models.Links { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class LinksAttribute : Attribute { public LinksAttribute(Link topLevelLinks = Link.NotConfigured, Link resourceLinks = Link.NotConfigured, Link relationshipLinks = Link.NotConfigured) diff --git a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs index 7b618ea6..2f43e830 100644 --- a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs +++ b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs @@ -2,6 +2,7 @@ namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class ResourceAttribute : Attribute { public ResourceAttribute(string resourceName) From d6f500af25187e54d1d2d0c6fae4d14b82b8d03d Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Fri, 3 Apr 2020 18:27:45 +0200 Subject: [PATCH 31/60] Better messages and tests for more query string errors --- .../Common/QueryParameterService.cs | 17 ++++-- .../QueryParameterServices/FilterService.cs | 4 +- .../QueryParameterServices/SortService.cs | 4 +- .../Acceptance/Spec/QueryParameterTests.cs | 59 ++++++++++++++++++- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index b8fa6bb2..a4fbacd6 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -1,6 +1,5 @@ using System.Linq; using System.Net; -using System.Text.RegularExpressions; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; @@ -32,14 +31,18 @@ protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest cu /// /// Helper method for parsing query parameters into attributes /// - protected AttrAttribute GetAttribute(string target, RelationshipAttribute relationship = null) + protected AttrAttribute GetAttribute(string queryParameterName, string target, RelationshipAttribute relationship = null) { var attribute = relationship != null ? _resourceGraph.GetAttributes(relationship.RightType).FirstOrDefault(a => a.Is(target)) : _requestResource.Attributes.FirstOrDefault(attr => attr.Is(target)); if (attribute == null) - throw new JsonApiException(HttpStatusCode.BadRequest, $"'{target}' is not a valid attribute."); + { + throw new InvalidQueryStringParameterException(queryParameterName, + "The attribute requested in query string does not exist.", + $"The attribute '{target}' does not exist on resource '{_requestResource.ResourceName}'."); + } return attribute; } @@ -47,12 +50,16 @@ protected AttrAttribute GetAttribute(string target, RelationshipAttribute relati /// /// Helper method for parsing query parameters into relationships attributes /// - protected RelationshipAttribute GetRelationship(string propertyName) + protected RelationshipAttribute GetRelationship(string queryParameterName, string propertyName) { if (propertyName == null) return null; var relationship = _requestResource.Relationships.FirstOrDefault(r => r.Is(propertyName)); if (relationship == null) - throw new JsonApiException(HttpStatusCode.BadRequest, $"{propertyName} is not a valid relationship on {_requestResource.ResourceName}."); + { + throw new InvalidQueryStringParameterException(queryParameterName, + "The relationship requested in query string does not exist.", + $"The relationship '{propertyName}' does not exist on resource '{_requestResource.ResourceName}'."); + } return relationship; } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index 728003a2..77a356f9 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -60,8 +60,8 @@ private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterN return queryContext; } - queryContext.Relationship = GetRelationship(query.Relationship); - var attribute = GetAttribute(query.Attribute, queryContext.Relationship); + queryContext.Relationship = GetRelationship(parameterName, query.Relationship); + var attribute = GetAttribute(parameterName, query.Attribute, queryContext.Relationship); if (!attribute.IsFilterable) { diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index 2c2c6006..99117a92 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -90,8 +90,8 @@ private List BuildQueries(string value, string parameterName) private SortQueryContext BuildQueryContext(SortQuery query) { - var relationship = GetRelationship(query.Relationship); - var attribute = GetAttribute(query.Attribute, relationship); + var relationship = GetRelationship("sort", query.Relationship); + var attribute = GetAttribute("sort", query.Attribute, relationship); if (!attribute.IsSortable) { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs index 79af7d0e..7949b61b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs @@ -47,7 +47,7 @@ public async Task Server_Returns_400_ForMissingQueryParameterValue() var builder = new WebHostBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems" + queryString; + var route = "/api/v1/todoItems" + queryString; var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -66,5 +66,62 @@ public async Task Server_Returns_400_ForMissingQueryParameterValue() Assert.Equal("Missing value for 'include' query string parameter.", errorDocument.Errors[0].Detail); Assert.Equal("include", errorDocument.Errors[0].Source.Parameter); } + + [Fact] + public async Task Server_Returns_400_ForUnknownQueryParameter_Attribute() + { + // Arrange + const string queryString = "?sort=notSoGood"; + + var builder = new WebHostBuilder().UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/todoItems" + queryString; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The attribute requested in query string does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The attribute 'notSoGood' does not exist on resource 'todoItems'.", errorDocument.Errors[0].Detail); + Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); + } + + [Fact] + public async Task Server_Returns_400_ForUnknownQueryParameter_RelatedAttribute() + { + // Arrange + const string queryString = "?sort=notSoGood.evenWorse"; + + var builder = new WebHostBuilder().UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/todoItems" + queryString; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The relationship requested in query string does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'notSoGood' does not exist on resource 'todoItems'.", errorDocument.Errors[0].Detail); + Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); + } + } } From eb5895920ac68dc6346caa88ed0297574435f8f4 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Sat, 4 Apr 2020 16:44:35 +0200 Subject: [PATCH 32/60] Fixed copy/paste error --- .../UnitTests/QueryParameters/IncludeServiceTests.cs | 12 ++++++------ .../QueryParameters/OmitDefaultServiceTests.cs | 12 ++++++------ .../QueryParameters/OmitNullServiceTests.cs | 12 ++++++------ test/UnitTests/QueryParameters/PageServiceTests.cs | 12 ++++++------ test/UnitTests/QueryParameters/SortServiceTests.cs | 12 ++++++------ .../QueryParameters/SparseFieldsServiceTests.cs | 12 ++++++------ 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index 0e68ed05..ad3aee7d 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -18,26 +18,26 @@ public IncludeService GetService(ResourceContext resourceContext = null) } [Fact] - public void CanParse_FilterService_SucceedOnMatch() + public void CanParse_IncludeService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("include"); + bool result = service.CanParse("include"); // Assert Assert.True(result); } [Fact] - public void CanParse_FilterService_FailOnMismatch() + public void CanParse_IncludeService_FailOnMismatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("includes"); + bool result = service.CanParse("includes"); // Assert Assert.False(result); diff --git a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs index 863429f6..92748d9f 100644 --- a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs @@ -20,26 +20,26 @@ public OmitDefaultService GetService(bool @default, bool @override) } [Fact] - public void CanParse_FilterService_SucceedOnMatch() + public void CanParse_OmitDefaultService_SucceedOnMatch() { // Arrange - var filterService = GetService(true, true); + var service = GetService(true, true); // Act - bool result = filterService.CanParse("omitDefault"); + bool result = service.CanParse("omitDefault"); // Assert Assert.True(result); } [Fact] - public void CanParse_FilterService_FailOnMismatch() + public void CanParse_OmitDefaultService_FailOnMismatch() { // Arrange - var filterService = GetService(true, true); + var service = GetService(true, true); // Act - bool result = filterService.CanParse("omit-default"); + bool result = service.CanParse("omit-default"); // Assert Assert.False(result); diff --git a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs index 3ae52055..c7bb5c9d 100644 --- a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs @@ -20,26 +20,26 @@ public OmitNullService GetService(bool @default, bool @override) } [Fact] - public void CanParse_FilterService_SucceedOnMatch() + public void CanParse_OmitNullService_SucceedOnMatch() { // Arrange - var filterService = GetService(true, true); + var service = GetService(true, true); // Act - bool result = filterService.CanParse("omitNull"); + bool result = service.CanParse("omitNull"); // Assert Assert.True(result); } [Fact] - public void CanParse_FilterService_FailOnMismatch() + public void CanParse_OmitNullService_FailOnMismatch() { // Arrange - var filterService = GetService(true, true); + var service = GetService(true, true); // Act - bool result = filterService.CanParse("omit-null"); + bool result = service.CanParse("omit-null"); // Assert Assert.False(result); diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index c1d20ac4..9033652f 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -20,26 +20,26 @@ public PageService GetService(int? maximumPageSize = null, int? maximumPageNumbe } [Fact] - public void CanParse_FilterService_SucceedOnMatch() + public void CanParse_PageService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("page[size]"); + bool result = service.CanParse("page[size]"); // Assert Assert.True(result); } [Fact] - public void CanParse_FilterService_FailOnMismatch() + public void CanParse_PageService_FailOnMismatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("page[some]"); + bool result = service.CanParse("page[some]"); // Assert Assert.False(result); diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs index 32b341a3..60a47198 100644 --- a/test/UnitTests/QueryParameters/SortServiceTests.cs +++ b/test/UnitTests/QueryParameters/SortServiceTests.cs @@ -15,26 +15,26 @@ public SortService GetService() } [Fact] - public void CanParse_FilterService_SucceedOnMatch() + public void CanParse_SortService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("sort"); + bool result = service.CanParse("sort"); // Assert Assert.True(result); } [Fact] - public void CanParse_FilterService_FailOnMismatch() + public void CanParse_SortService_FailOnMismatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("sorting"); + bool result = service.CanParse("sorting"); // Assert Assert.False(result); diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index afe269ef..02dc3dfe 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -18,26 +18,26 @@ public SparseFieldsService GetService(ResourceContext resourceContext = null) } [Fact] - public void CanParse_FilterService_SucceedOnMatch() + public void CanParse_SparseFieldsService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("fields[customer]"); + bool result = service.CanParse("fields[customer]"); // Assert Assert.True(result); } [Fact] - public void CanParse_FilterService_FailOnMismatch() + public void CanParse_SparseFieldsService_FailOnMismatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - bool result = filterService.CanParse("fieldset"); + bool result = service.CanParse("fieldset"); // Assert Assert.False(result); From 380fd1e8d42294c9c6735631cfef943e939f8bd7 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Sat, 4 Apr 2020 17:05:20 +0200 Subject: [PATCH 33/60] More query string exception usage with tests --- .../Common/QueryParameterService.cs | 4 +++- .../OmitDefaultService.cs | 4 +++- .../QueryParameterServices/OmitNullService.cs | 4 +++- .../Acceptance/Spec/NestedResourceTests.cs | 18 +++++++++++++---- .../OmitDefaultServiceTests.cs | 20 +++++++++++++++++++ .../QueryParameters/OmitNullServiceTests.cs | 19 ++++++++++++++++++ 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index a4fbacd6..e38f0e35 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -71,7 +71,9 @@ protected void EnsureNoNestedResourceRoute(string parameterName) { if (_requestResource != _mainRequestResource) { - throw new JsonApiException(HttpStatusCode.BadRequest, $"Query parameter {parameterName} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{parameterName}=...'"); + throw new InvalidQueryStringParameterException(parameterName, + "The specified query string parameter is currently not supported on nested resource endpoints.", + $"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')"); } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs index af98087d..c7f6a917 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs @@ -38,7 +38,9 @@ public virtual void Parse(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out var omitAttributeIfValueIsDefault)) { - throw new JsonApiException(HttpStatusCode.BadRequest, "Value must be 'true' or 'false'."); + throw new InvalidQueryStringParameterException(parameterName, + "The specified query string value must be 'true' or 'false'.", + $"The value '{parameterValue}' for parameter '{parameterName}' is not a valid boolean value."); } OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs index 85e206dc..d5b2e520 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs @@ -39,7 +39,9 @@ public virtual void Parse(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out var omitAttributeIfValueIsNull)) { - throw new JsonApiException(HttpStatusCode.BadRequest, "Value must be 'true' or 'false'."); + throw new InvalidQueryStringParameterException(parameterName, + "The specified query string value must be 'true' or 'false'.", + $"The value '{parameterValue}' for parameter '{parameterName}' is not a valid boolean value."); } OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs index 5fbd8f7e..f920b433 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs @@ -2,8 +2,10 @@ using System.Net; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -61,14 +63,22 @@ public async Task NestedResourceRoute_RequestWithIncludeQueryParam_ReturnsReques [InlineData("sort=ordinal")] [InlineData("page[number]=1")] [InlineData("page[size]=10")] - public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParam) + public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParameter) { + string parameterName = queryParameter.Split('=')[0]; + // Act - var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParam}"); + var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParameter}"); // Assert - AssertEqualStatusCode(HttpStatusCode.BadRequest, response); - Assert.Contains("currently not supported", body); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified query string parameter is currently not supported on nested resource endpoints.", errorDocument.Errors[0].Title); + Assert.Equal($"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')", errorDocument.Errors[0].Detail); + Assert.Equal(parameterName, errorDocument.Errors[0].Source.Parameter); } } } diff --git a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs index 92748d9f..55c01b54 100644 --- a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs @@ -1,6 +1,9 @@ +using System; using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -65,5 +68,22 @@ public void Parse_QueryConfigWithApiSettings_CanParse(string queryValue, bool @d // Assert Assert.Equal(expected, service.OmitAttributeIfValueIsDefault); } + + [Fact] + public void Parse_OmitDefaultService_FailOnNonBooleanValue() + { + // Arrange + const string parameterName = "omit-default"; + var service = GetService(true, true); + + // Act, assert + var exception = Assert.Throws(() => service.Parse(parameterName, "some")); + + Assert.Equal(parameterName, exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); + Assert.Equal("The specified query string value must be 'true' or 'false'.", exception.Error.Title); + Assert.Equal($"The value 'some' for parameter '{parameterName}' is not a valid boolean value.", exception.Error.Detail); + Assert.Equal(parameterName, exception.Error.Source.Parameter); + } } } diff --git a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs index c7bb5c9d..bb23bdd4 100644 --- a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -65,5 +67,22 @@ public void Parse_QueryConfigWithApiSettings_CanParse(string queryValue, bool @d // Assert Assert.Equal(expected, service.OmitAttributeIfValueIsNull); } + + [Fact] + public void Parse_OmitNullService_FailOnNonBooleanValue() + { + // Arrange + const string parameterName = "omit-null"; + var service = GetService(true, true); + + // Act, assert + var exception = Assert.Throws(() => service.Parse(parameterName, "some")); + + Assert.Equal(parameterName, exception.QueryParameterName); + Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); + Assert.Equal("The specified query string value must be 'true' or 'false'.", exception.Error.Title); + Assert.Equal($"The value 'some' for parameter '{parameterName}' is not a valid boolean value.", exception.Error.Detail); + Assert.Equal(parameterName, exception.Error.Source.Parameter); + } } } From 1e2880e0e5bce1f16771c6eb98788c258263c6fe Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Sat, 4 Apr 2020 17:16:57 +0200 Subject: [PATCH 34/60] Updated comments --- .../Exceptions/InvalidQueryStringParameterException.cs | 2 +- src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs index 321bafb4..df94a4eb 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Exceptions { /// - /// The error that is thrown when parsing the request query string fails. + /// The error that is thrown when processing the request fails due to an error in the request query string. /// public sealed class InvalidQueryStringParameterException : JsonApiException { diff --git a/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs b/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs index a89fcacd..28e8913e 100644 --- a/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs +++ b/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs @@ -4,6 +4,9 @@ namespace JsonApiDotNetCore.Exceptions { + /// + /// The error that is thrown of resource object creation fails. + /// public sealed class ObjectCreationException : JsonApiException { public Type Type { get; } From d54f91ad2dab707f6a2b5dc3816e836320105c30 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Sat, 4 Apr 2020 17:50:10 +0200 Subject: [PATCH 35/60] More cleanup of errors --- .../Resources/ArticleResource.cs | 11 ++++--- .../Resources/LockableResource.cs | 8 +++-- .../Resources/PassportResource.cs | 11 +++++-- .../Resources/TagResource.cs | 2 +- .../Resources/TodoResource.cs | 6 +++- .../Services/CustomArticleService.cs | 11 +++---- .../Exceptions/JsonApiException.cs | 18 +---------- ...opedServiceRequiresHttpContextException.cs | 27 ++++++++++++++++ .../Services/ScopedServiceProvider.cs | 8 ++--- .../ResourceDefinitionTests.cs | 4 +-- .../RequestScopedServiceProviderTests.cs | 31 +++++++++++++++++++ 11 files changed, 95 insertions(+), 42 deletions(-) create mode 100644 src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs create mode 100644 test/UnitTests/Internal/RequestScopedServiceProviderTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs index ba10b877..1a598d58 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs @@ -1,13 +1,12 @@ using System.Collections.Generic; using System.Linq; -using System; using System.Net; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Resources { @@ -19,9 +18,13 @@ public override IEnumerable
OnReturn(HashSet
entities, Resourc { if (pipeline == ResourcePipeline.GetSingle && entities.Single().Name == "Classified") { - throw new JsonApiException(HttpStatusCode.Forbidden, "You are not allowed to see this article!"); + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to see this article." + }); } - return entities.Where(t => t.Name != "This should be not be included"); + + return entities.Where(t => t.Name != "This should not be included"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs index 647c5ba3..b5b08b9c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs @@ -1,11 +1,10 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Resources @@ -20,7 +19,10 @@ protected void DisallowLocked(IEnumerable entities) { if (e.IsLocked) { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked todo item"); + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update fields or relations of locked todo items." + }); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs index a8eab571..c51c1eeb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Resources { @@ -21,7 +22,10 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal { if (pipeline == ResourcePipeline.GetSingle && isIncluded) { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to include passports on individual people"); + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to include passports on individual persons." + }); } } @@ -36,7 +40,10 @@ private void DoesNotTouchLockedPassports(IEnumerable entities) { if (entity.IsLocked) { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update fields or relations of locked persons"); + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update fields or relations of locked persons." + }); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs index f65a0490..c9b90a0b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs @@ -18,7 +18,7 @@ public override IEnumerable BeforeCreate(IEntityHashSet affected, Reso public override IEnumerable OnReturn(HashSet entities, ResourcePipeline pipeline) { - return entities.Where(t => t.Name != "This should be not be included"); + return entities.Where(t => t.Name != "This should not be included"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs index 98969143..30f0d900 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Resources { @@ -18,7 +19,10 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal { if (stringId == "1337") { - throw new JsonApiException(HttpStatusCode.Forbidden, "Not allowed to update author of any TodoItem"); + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update the author of todo items." + }); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index d731df15..cee29e04 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -1,16 +1,13 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; using System.Collections.Generic; -using System.Net; using System.Threading.Tasks; -using JsonApiDotNetCore.Exceptions; namespace JsonApiDotNetCoreExample.Services { @@ -29,13 +26,13 @@ public CustomArticleService( public override async Task
GetAsync(int id) { var newEntity = await base.GetAsync(id); - if(newEntity == null) + + if (newEntity != null) { - throw new JsonApiException(HttpStatusCode.NotFound, "The resource could not be found."); + newEntity.Name = "None for you Glen Coco"; } - newEntity.Name = "None for you Glen Coco"; + return newEntity; } } - } diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index 39689427..d27683a7 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -8,13 +8,7 @@ public class JsonApiException : Exception { public Error Error { get; } - public JsonApiException(Error error) - : base(error.Title) - { - Error = error; - } - - public JsonApiException(Error error, Exception innerException) + public JsonApiException(Error error, Exception innerException = null) : base(error.Title, innerException) { Error = error; @@ -28,15 +22,5 @@ public JsonApiException(HttpStatusCode status, string message) Title = message }; } - - public JsonApiException(HttpStatusCode status, string message, string detail) - : base(message) - { - Error = new Error(status) - { - Title = message, - Detail = detail - }; - } } } diff --git a/src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs b/src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs new file mode 100644 index 00000000..ada8b9fe --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs @@ -0,0 +1,27 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when attempting to resolve an injected object instance that is scoped to a HTTP request, while no HTTP request is currently in progress. + /// + public sealed class ResolveScopedServiceRequiresHttpContextException : JsonApiException + { + public Type ServiceType { get; } + + public ResolveScopedServiceRequiresHttpContextException(Type serviceType) + : base(new Error(HttpStatusCode.InternalServerError) + { + Title = "Cannot resolve scoped service outside the context of an HTTP request.", + Detail = + $"Type requested was '{serviceType.FullName}'. If you are hitting this error in automated tests, you should instead inject your own " + + "IScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + + "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider" + }) + { + ServiceType = serviceType; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index ac43ec06..1813262d 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -29,11 +29,9 @@ public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) public object GetService(Type serviceType) { if (_httpContextAccessor.HttpContext == null) - throw new JsonApiException(HttpStatusCode.InternalServerError, - "Cannot resolve scoped service outside the context of an HTTP Request.", - detail: "If you are hitting this error in automated tests, you should instead inject your own " - + "IScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " - + "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider"); + { + throw new ResolveScopedServiceRequiresHttpContextException(serviceType); + } return _httpContextAccessor.HttpContext.RequestServices.GetService(serviceType); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 4dc6c682..3daeb706 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -196,7 +196,7 @@ public async Task Article_Is_Hidden() var context = _fixture.GetService(); var articles = _articleFaker.Generate(3).ToList(); - string toBeExcluded = "This should be not be included"; + string toBeExcluded = "This should not be included"; articles[0].Name = toBeExcluded; @@ -223,7 +223,7 @@ public async Task Tag_Is_Hidden() var article = _articleFaker.Generate(); var tags = _tagFaker.Generate(2); - string toBeExcluded = "This should be not be included"; + string toBeExcluded = "This should not be included"; tags[0].Name = toBeExcluded; var articleTags = new[] diff --git a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs new file mode 100644 index 00000000..12c44d4a --- /dev/null +++ b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs @@ -0,0 +1,31 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace UnitTests.Internal +{ + public sealed class RequestScopedServiceProviderTests + { + [Fact] + public void When_http_context_is_unavailable_it_must_fail() + { + // Arrange + var provider = new RequestScopedServiceProvider(new HttpContextAccessor()); + + // Act + Action action = () => provider.GetService(typeof(AppDbContext)); + + // Assert + var exception = Assert.Throws(action); + + Assert.Equal(HttpStatusCode.InternalServerError, exception.Error.StatusCode); + Assert.Equal("Cannot resolve scoped service outside the context of an HTTP request.", exception.Error.Title); + Assert.StartsWith("Type requested was 'JsonApiDotNetCoreExample.Data.AppDbContext'. If you are hitting this error in automated tests", exception.Error.Detail); + Assert.Equal(typeof(AppDbContext), exception.ServiceType); + } + } +} From 98f874ccc7d7de6f9ac79a65c685c17ace5fa8a2 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Sat, 4 Apr 2020 18:46:46 +0200 Subject: [PATCH 36/60] More converted exceptions with tests --- .../Extensions/IQueryableExtensions.cs | 108 ++++++++++-------- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 2 +- .../Acceptance/Spec/AttributeFilterTests.cs | 46 ++++++++ 3 files changed, 107 insertions(+), 49 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 3dcb4d92..53bc8cc3 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using System.Net; using System.Reflection; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; @@ -183,7 +182,7 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression } break; default: - throw new JsonApiException(HttpStatusCode.InternalServerError, $"Unknown filter operation {operation}"); + throw new NotSupportedException($"Filter operation '{operation}' is not supported."); } return body; @@ -194,42 +193,52 @@ private static IQueryable CallGenericWhereContainsMethod(IQuer var concreteType = typeof(TSource); var property = concreteType.GetProperty(filter.Attribute.PropertyInfo.Name); - try + var propertyValues = filter.Value.Split(QueryConstants.COMMA); + ParameterExpression entity = Expression.Parameter(concreteType, "entity"); + MemberExpression member; + if (filter.IsAttributeOfRelationship) { - var propertyValues = filter.Value.Split(QueryConstants.COMMA); - ParameterExpression entity = Expression.Parameter(concreteType, "entity"); - MemberExpression member; - if (filter.IsAttributeOfRelationship) - { - var relation = Expression.PropertyOrField(entity, filter.Relationship.InternalRelationshipName); - member = Expression.Property(relation, filter.Attribute.PropertyInfo.Name); - } - else - member = Expression.Property(entity, filter.Attribute.PropertyInfo.Name); - - var method = ContainsMethod.MakeGenericMethod(member.Type); - var obj = TypeHelper.ConvertListType(propertyValues, member.Type); + var relation = Expression.PropertyOrField(entity, filter.Relationship.InternalRelationshipName); + member = Expression.Property(relation, filter.Attribute.PropertyInfo.Name); + } + else + member = Expression.Property(entity, filter.Attribute.PropertyInfo.Name); - if (filter.Operation == FilterOperation.@in) + var method = ContainsMethod.MakeGenericMethod(member.Type); + + var list = TypeHelper.CreateListFor(member.Type); + foreach (var value in propertyValues) + { + object targetType; + try { - // Where(i => arr.Contains(i.column)) - var contains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); - var lambda = Expression.Lambda>(contains, entity); - - return source.Where(lambda); + targetType = TypeHelper.ConvertType(value, member.Type); } - else + catch (FormatException) { - // Where(i => !arr.Contains(i.column)) - var notContains = Expression.Not(Expression.Call(method, new Expression[] { Expression.Constant(obj), member })); - var lambda = Expression.Lambda>(notContains, entity); - - return source.Where(lambda); + throw new InvalidQueryStringParameterException("filter", + "Mismatch between query string parameter value and resource attribute type.", + $"Failed to convert '{value}' in set '{filter.Value}' to '{property.PropertyType.Name}' for filtering on '{filter.Query.Attribute}' attribute."); } + + list.Add(targetType); + } + + if (filter.Operation == FilterOperation.@in) + { + // Where(i => arr.Contains(i.column)) + var contains = Expression.Call(method, new Expression[] { Expression.Constant(list), member }); + var lambda = Expression.Lambda>(contains, entity); + + return source.Where(lambda); } - catch (FormatException) + else { - throw new JsonApiException(HttpStatusCode.BadRequest, $"Could not cast {filter.Value} to {property.PropertyType.Name}"); + // Where(i => !arr.Contains(i.column)) + var notContains = Expression.Not(Expression.Call(method, new Expression[] { Expression.Constant(list), member })); + var lambda = Expression.Lambda>(notContains, entity); + + return source.Where(lambda); } } @@ -277,29 +286,32 @@ private static IQueryable CallGenericWhereMethod(IQueryable 1 + object convertedValue; + try { - // convert the incoming value to the target value type - // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(filter.Value, property.PropertyType); - - right = CreateTupleAccessForConstantExpression(convertedValue, property.PropertyType); + convertedValue = TypeHelper.ConvertType(filter.Value, property.PropertyType); + } + catch (FormatException) + { + throw new InvalidQueryStringParameterException("filter", + "Mismatch between query string parameter value and resource attribute type.", + $"Failed to convert '{filter.Value}' to '{property.PropertyType.Name}' for filtering on '{filter.Query.Attribute}' attribute."); } - var body = GetFilterExpressionLambda(left, right, filter.Operation); - var lambda = Expression.Lambda>(body, parameter); - - return source.Where(lambda); - } - catch (FormatException) - { - throw new JsonApiException(HttpStatusCode.BadRequest, $"Could not cast {filter.Value} to {property.PropertyType.Name}"); + right = CreateTupleAccessForConstantExpression(convertedValue, property.PropertyType); } + + var body = GetFilterExpressionLambda(left, right, filter.Operation); + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); } private static Expression CreateTupleAccessForConstantExpression(object value, Type type) diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 759dd88d..cfb3094c 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -61,7 +61,7 @@ public static object ConvertType(object value, Type type) } catch (Exception e) { - throw new FormatException($"{ typeOfValue } cannot be converted to { type }", e); + throw new FormatException($"{typeOfValue} cannot be converted to {type}", e); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index af43e816..25165bc9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -112,6 +112,52 @@ public async Task Cannot_Filter_If_Explicitly_Forbidden() Assert.Equal("filter[achievedDate]", errorDocument.Errors[0].Source.Parameter); } + [Fact] + public async Task Cannot_Filter_Equality_If_Type_Mismatch() + { + // Arrange + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todoItems?filter[ordinal]=ABC"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Mismatch between query string parameter value and resource attribute type.", errorDocument.Errors[0].Title); + Assert.Equal("Failed to convert 'ABC' to 'Int64' for filtering on 'ordinal' attribute.", errorDocument.Errors[0].Detail); + Assert.Equal("filter", errorDocument.Errors[0].Source.Parameter); + } + + [Fact] + public async Task Cannot_Filter_In_Set_If_Type_Mismatch() + { + // Arrange + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todoItems?filter[ordinal]=in:1,ABC,2"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Mismatch between query string parameter value and resource attribute type.", errorDocument.Errors[0].Title); + Assert.Equal("Failed to convert 'ABC' in set '1,ABC,2' to 'Int64' for filtering on 'ordinal' attribute.", errorDocument.Errors[0].Detail); + Assert.Equal("filter", errorDocument.Errors[0].Source.Parameter); + } + [Fact] public async Task Can_Filter_On_Not_Equal_Values() { From b16b52e93244683360daf1c6156f228552915bb4 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 12:32:23 +0200 Subject: [PATCH 37/60] Reverted some exceptions because there are not user-facing but helping the developer identify wrong setup. --- .../Exceptions/ObjectCreationException.cs | 24 ----------------- ...opedServiceRequiresHttpContextException.cs | 27 ------------------- .../Extensions/TypeExtensions.cs | 2 +- .../Services/ScopedServiceProvider.cs | 6 ++++- .../RequestScopedServiceProviderTests.cs | 16 +++++------ test/UnitTests/Models/ConstructionTests.cs | 8 ++---- 6 files changed, 15 insertions(+), 68 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs delete mode 100644 src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs diff --git a/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs b/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs deleted file mode 100644 index 28e8913e..00000000 --- a/src/JsonApiDotNetCore/Exceptions/ObjectCreationException.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; - -namespace JsonApiDotNetCore.Exceptions -{ - /// - /// The error that is thrown of resource object creation fails. - /// - public sealed class ObjectCreationException : JsonApiException - { - public Type Type { get; } - - public ObjectCreationException(Type type, Exception innerException) - : base(new Error(HttpStatusCode.InternalServerError) - { - Title = "Failed to create an object instance using its default constructor.", - Detail = $"Failed to create an instance of '{type.FullName}' using its default constructor." - }, innerException) - { - Type = type; - } - } -} diff --git a/src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs b/src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs deleted file mode 100644 index ada8b9fe..00000000 --- a/src/JsonApiDotNetCore/Exceptions/ResolveScopedServiceRequiresHttpContextException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; - -namespace JsonApiDotNetCore.Exceptions -{ - /// - /// The error that is thrown when attempting to resolve an injected object instance that is scoped to a HTTP request, while no HTTP request is currently in progress. - /// - public sealed class ResolveScopedServiceRequiresHttpContextException : JsonApiException - { - public Type ServiceType { get; } - - public ResolveScopedServiceRequiresHttpContextException(Type serviceType) - : base(new Error(HttpStatusCode.InternalServerError) - { - Title = "Cannot resolve scoped service outside the context of an HTTP request.", - Detail = - $"Type requested was '{serviceType.FullName}'. If you are hitting this error in automated tests, you should instead inject your own " + - "IScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + - "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider" - }) - { - ServiceType = serviceType; - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 191b8b09..af1bd79a 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -107,7 +107,7 @@ private static object CreateNewInstance(Type type) } catch (Exception exception) { - throw new ObjectCreationException(type, exception); + throw new InvalidOperationException($"Failed to create an instance of '{type.FullName}' using its default constructor.", exception); } } diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index 1813262d..5c005834 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -30,7 +30,11 @@ public object GetService(Type serviceType) { if (_httpContextAccessor.HttpContext == null) { - throw new ResolveScopedServiceRequiresHttpContextException(serviceType); + throw new InvalidOperationException( + $"Cannot resolve scoped service '{serviceType.FullName}' outside the context of an HTTP request. " + + "If you are hitting this error in automated tests, you should instead inject your own " + + "IScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + + "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider"); } return _httpContextAccessor.HttpContext.RequestServices.GetService(serviceType); diff --git a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs index 12c44d4a..2b5fbe91 100644 --- a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs +++ b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs @@ -1,8 +1,7 @@ using System; -using System.Net; -using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Http; using Xunit; @@ -17,15 +16,14 @@ public void When_http_context_is_unavailable_it_must_fail() var provider = new RequestScopedServiceProvider(new HttpContextAccessor()); // Act - Action action = () => provider.GetService(typeof(AppDbContext)); + Action action = () => provider.GetService(typeof(IIdentifiable)); // Assert - var exception = Assert.Throws(action); + var exception = Assert.Throws(action); - Assert.Equal(HttpStatusCode.InternalServerError, exception.Error.StatusCode); - Assert.Equal("Cannot resolve scoped service outside the context of an HTTP request.", exception.Error.Title); - Assert.StartsWith("Type requested was 'JsonApiDotNetCoreExample.Data.AppDbContext'. If you are hitting this error in automated tests", exception.Error.Detail); - Assert.Equal(typeof(AppDbContext), exception.ServiceType); + Assert.StartsWith("Cannot resolve scoped service " + + "'JsonApiDotNetCore.Models.IIdentifiable`1[[JsonApiDotNetCoreExample.Models.Tag, JsonApiDotNetCoreExample, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]' " + + "outside the context of an HTTP request.", exception.Message); } } } diff --git a/test/UnitTests/Models/ConstructionTests.cs b/test/UnitTests/Models/ConstructionTests.cs index f71b1eac..7bbcac7b 100644 --- a/test/UnitTests/Models/ConstructionTests.cs +++ b/test/UnitTests/Models/ConstructionTests.cs @@ -35,12 +35,8 @@ public void When_model_has_no_parameterless_contructor_it_must_fail() Action action = () => serializer.Deserialize(content); // Assert - var exception = Assert.Throws(action); - - Assert.Equal(typeof(ResourceWithParameters), exception.Type); - Assert.Equal(HttpStatusCode.InternalServerError, exception.Error.StatusCode); - Assert.Equal("Failed to create an object instance using its default constructor.", exception.Error.Title); - Assert.Equal("Failed to create an instance of 'UnitTests.Models.ConstructionTests+ResourceWithParameters' using its default constructor.", exception.Error.Detail); + var exception = Assert.Throws(action); + Assert.Equal("Failed to create an instance of 'UnitTests.Models.ConstructionTests+ResourceWithParameters' using its default constructor.", exception.Message); } public class ResourceWithParameters : Identifiable From b52b4f2dd4a8aed7f19161279c1ed80866e1c058 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 13:19:26 +0200 Subject: [PATCH 38/60] Fixed: broke dependency between action results and json:api error document structure --- .../Controllers/JsonApiControllerMixin.cs | 6 +++++- .../Models/JsonApiDocuments/ErrorDocument.cs | 9 --------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index 25f533c5..d6aeb88e 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -23,7 +23,11 @@ protected IActionResult Error(Error error) protected IActionResult Errors(IEnumerable errors) { var document = new ErrorDocument(errors.ToList()); - return document.AsActionResult(); + + return new ObjectResult(document) + { + StatusCode = (int) document.GetErrorStatusCode() + }; } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs index ffb45840..452b12ad 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using Microsoft.AspNetCore.Mvc; namespace JsonApiDotNetCore.Models.JsonApiDocuments { @@ -37,13 +36,5 @@ public HttpStatusCode GetErrorStatusCode() var statusCode = int.Parse(statusCodes.Max().ToString()[0] + "00"); return (HttpStatusCode)statusCode; } - - public IActionResult AsActionResult() - { - return new ObjectResult(this) - { - StatusCode = (int)GetErrorStatusCode() - }; - } } } From 726ab700f6202a2d6c2c080e98dee6586701f359 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 14:52:59 +0200 Subject: [PATCH 39/60] Fixed: use status code from error; unwrap reflection errors --- .../ThrowingResourcesController.cs | 18 ++++++++ .../Data/AppDbContext.cs | 1 + .../Models/ThrowingResource.cs | 29 ++++++++++++ .../Formatters/JsonApiWriter.cs | 7 +++ .../Server/ResponseSerializer.cs | 2 +- .../Acceptance/Spec/ThrowingResourceTests.cs | 46 +++++++++++++++++++ 6 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs new file mode 100644 index 00000000..3c87a767 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class ThrowingResourcesController : JsonApiController + { + public ThrowingResourcesController( + IJsonApiOptions jsonApiOptions, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index cb8876b3..2d42d1f4 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -19,6 +19,7 @@ public sealed class AppDbContext : DbContext public DbSet ArticleTags { get; set; } public DbSet IdentifiableArticleTags { get; set; } public DbSet Tags { get; set; } + public DbSet ThrowingResources { get; set; } public AppDbContext(DbContextOptions options) : base(options) { } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs new file mode 100644 index 00000000..01eda5d7 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics; +using System.Linq; +using JsonApiDotNetCore.Formatters; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class ThrowingResource : Identifiable + { + [Attr] + public string FailsOnSerialize + { + get + { + var isSerializingResponse = new StackTrace().GetFrames() + .Any(frame => frame.GetMethod().DeclaringType == typeof(JsonApiWriter)); + + if (isSerializingResponse) + { + throw new InvalidOperationException($"The value for the '{nameof(FailsOnSerialize)}' property is currently unavailable."); + } + + return string.Empty; + } + set { } + } + } +} diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 89b4cf2f..30d8298a 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Reflection; using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; @@ -54,6 +55,8 @@ public async Task WriteAsync(OutputFormatterWriteContext context) { var errorDocument = _exceptionHandler.HandleException(exception); responseContent = _serializer.Serialize(errorDocument); + + response.StatusCode = (int)errorDocument.GetErrorStatusCode(); } } @@ -79,6 +82,10 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode { return _serializer.Serialize(contextObject); } + catch (TargetInvocationException exception) + { + throw new InvalidResponseBodyException(exception.InnerException); + } catch (Exception exception) { throw new InvalidResponseBodyException(exception); diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index 274c6eed..3891e31c 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -83,7 +83,7 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) /// internal string SerializeSingle(IIdentifiable entity) { - if (RequestRelationship != null) + if (RequestRelationship != null && entity != null) return JsonConvert.SerializeObject(((ResponseResourceObjectBuilder)_resourceObjectBuilder).Build(entity, RequestRelationship)); var (attributes, relationships) = GetFieldsToSerialize(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs new file mode 100644 index 00000000..7569f38a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + public class ThrowingResourceTests : FunctionalTestCollection + { + public ThrowingResourceTests(StandardApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetThrowingResource_Fails() + { + // Arrange + var throwingResource = new ThrowingResource(); + _dbContext.Add(throwingResource); + _dbContext.SaveChanges(); + + // Act + var (body, response) = await Get($"/api/v1/throwingResources/{throwingResource.Id}"); + + // Assert + AssertEqualStatusCode(HttpStatusCode.InternalServerError, response); + + var errorDocument = JsonConvert.DeserializeObject(body); + + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.InternalServerError, errorDocument.Errors[0].StatusCode); + Assert.Equal("Failed to serialize response body.", errorDocument.Errors[0].Title); + Assert.Equal("The value for the 'FailsOnSerialize' property is currently unavailable.", errorDocument.Errors[0].Detail); + + var stackTraceLines = + ((JArray) errorDocument.Errors[0].Meta.Data["stackTrace"]).Select(token => token.Value()); + + Assert.Contains(stackTraceLines, line => line.Contains( + "System.InvalidOperationException: The value for the 'FailsOnSerialize' property is currently unavailable.")); + } + } +} From a769eb96ffb4b023a8e2d5c6ad602b6ad3a62233 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 15:57:20 +0200 Subject: [PATCH 40/60] Tweaks in error logging --- src/JsonApiDotNetCore/Exceptions/JsonApiException.cs | 3 +++ src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs | 5 +++-- .../Acceptance/Extensibility/CustomErrorHandlingTests.cs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index d27683a7..7e3fd2f5 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -1,6 +1,7 @@ using System; using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; +using Newtonsoft.Json; namespace JsonApiDotNetCore.Exceptions { @@ -22,5 +23,7 @@ public JsonApiException(HttpStatusCode status, string message) Title = message }; } + + public override string Message => "Error = " + JsonConvert.SerializeObject(Error, Formatting.Indented); } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs index 26a676f7..fea77a68 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs @@ -1,8 +1,8 @@ using System; +using System.Diagnostics; using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.Extensions.Logging; @@ -30,7 +30,8 @@ private void LogException(Exception exception) { var level = GetLogLevel(exception); - _logger.Log(level, exception, exception.Message); + Exception demystified = exception.Demystify(); + _logger.Log(level, demystified, $"Intercepted {demystified.GetType().Name}: {demystified.Message}"); } protected virtual LogLevel GetLogLevel(Exception exception) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs index d21ffefc..a5f70644 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -31,7 +31,7 @@ public void When_using_custom_exception_handler_it_must_create_error_document_an Assert.Single(loggerFactory.Logger.Messages); Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); - Assert.Equal("Access is denied.", loggerFactory.Logger.Messages[0].Text); + Assert.Contains("Access is denied.", loggerFactory.Logger.Messages[0].Text); } public class CustomExceptionHandler : DefaultExceptionHandler From cb39998bc373db507097f2d06afa660ab20ce001 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 16:14:13 +0200 Subject: [PATCH 41/60] Include request body in logged exception when available --- .../Exceptions/InvalidRequestBodyException.cs | 37 ++++++++++--------- .../Formatters/JsonApiReader.cs | 4 +- .../Common/BaseDocumentParser.cs | 2 +- .../Acceptance/Spec/UpdatingDataTests.cs | 4 +- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs index 6b95abad..b25c9058 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Text; using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Exceptions @@ -9,30 +10,32 @@ namespace JsonApiDotNetCore.Exceptions ///
public sealed class InvalidRequestBodyException : JsonApiException { - public InvalidRequestBodyException(string reason) - : this(reason, null, null) - { - } - - public InvalidRequestBodyException(string reason, string details) - : this(reason, details, null) - { - } - - public InvalidRequestBodyException(Exception innerException) - : this(null, null, innerException) - { - } - - private InvalidRequestBodyException(string reason, string details = null, Exception innerException = null) + public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) : base(new Error(HttpStatusCode.UnprocessableEntity) { Title = reason != null ? "Failed to deserialize request body: " + reason : "Failed to deserialize request body.", - Detail = details ?? innerException?.Message + Detail = FormatDetails(details, requestBody, innerException) }, innerException) { } + + private static string FormatDetails(string details, string requestBody, Exception innerException) + { + string text = details ?? innerException?.Message; + + if (requestBody != null) + { + if (text != null) + { + text += Environment.NewLine; + } + + text += "Request body: <<" + requestBody + ">>"; + } + + return text; + } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index e8e7f387..de6f436a 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -50,7 +50,7 @@ public async Task ReadAsync(InputFormatterContext context) } catch (Exception exception) { - throw new InvalidRequestBodyException(exception); + throw new InvalidRequestBodyException(null, null, body, exception); } if (context.HttpContext.Request.Method == "PATCH") @@ -58,7 +58,7 @@ public async Task ReadAsync(InputFormatterContext context) var hasMissingId = model is IList list ? CheckForId(list) : CheckForId(model); if (hasMissingId) { - throw new InvalidRequestBodyException("Payload must include id attribute."); + throw new InvalidRequestBodyException("Payload must include id attribute.", null, body); } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index ee58993d..9cd0af33 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -138,7 +138,7 @@ private IIdentifiable ParseResourceObject(ResourceObject data) throw new InvalidRequestBodyException("Payload includes unknown resource type.", $"The resource '{data.Type}' is not registered on the resource graph. " + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " + - "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name.", null); } var entity = resourceContext.ResourceType.New(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 2f5879ec..f038d08f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -88,7 +88,7 @@ public async Task Response422IfUpdatingNotSettableAttribute() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body.", error.Title); - Assert.Equal("Property set method not found.", error.Detail); + Assert.StartsWith("Property set method not found." + Environment.NewLine + "Request body: <<", error.Detail); } [Fact] @@ -145,7 +145,7 @@ public async Task Respond_422_If_IdNotInAttributeList() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body: Payload must include id attribute.", error.Title); - Assert.Null(error.Detail); + Assert.StartsWith("Request body: <<", error.Detail); } [Fact] From 7d5cbb6cf281fc8e41aa2f467c914131cd2a66d1 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 17:47:48 +0200 Subject: [PATCH 42/60] More assertions on non-success status codes and exceptions. Removed empty string check from CurrentRequestMiddleware because I found now way to get there. The unit-test was misleading: throwing JsonApiException from CurrentRequestMiddleware does not hit any handler, so it always results in HTTP 500 without a body. Better not throw from there. --- .../Resources/LockableResource.cs | 2 +- .../Resources/PassportResource.cs | 2 +- .../Middleware/CurrentRequestMiddleware.cs | 47 ++++++---- .../OmitAttributeIfValueIsNullTests.cs | 36 +++++++- .../ResourceDefinitionTests.cs | 85 +++++++++++++++---- .../Acceptance/Spec/ContentNegotiation.cs | 16 ++++ .../Acceptance/Spec/DeletingDataTests.cs | 9 ++ .../Acceptance/Spec/DocumentTests/Included.cs | 46 ++++++---- .../Acceptance/Spec/FetchingDataTests.cs | 5 +- .../Spec/FetchingRelationshipsTests.cs | 21 ++--- .../Acceptance/Spec/SparseFieldSetTests.cs | 9 +- .../Acceptance/Spec/UpdatingDataTests.cs | 7 ++ .../CurrentRequestMiddlewareTests.cs | 21 ----- 13 files changed, 211 insertions(+), 95 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs index b5b08b9c..c9addf5e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs @@ -21,7 +21,7 @@ protected void DisallowLocked(IEnumerable entities) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { - Title = "You are not allowed to update fields or relations of locked todo items." + Title = "You are not allowed to update fields or relationships of locked todo items." }); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs index c51c1eeb..3eb4537a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -42,7 +42,7 @@ private void DoesNotTouchLockedPassports(IEnumerable entities) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { - Title = "You are not allowed to update fields or relations of locked persons." + Title = "You are not allowed to update fields or relationships of locked persons." }); } } diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 61cad6aa..1cecd3a1 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -7,9 +8,11 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; namespace JsonApiDotNetCore.Middleware { @@ -53,7 +56,7 @@ public async Task Invoke(HttpContext httpContext, _currentRequest.RelationshipId = GetRelationshipId(); } - if (IsValid()) + if (await IsValidAsync()) { await _next(httpContext); } @@ -63,16 +66,10 @@ private string GetBaseId() { if (_routeValues.TryGetValue("id", out object stringId)) { - if ((string)stringId == string.Empty) - { - throw new JsonApiException(HttpStatusCode.BadRequest, "No empty string as id please."); - } return (string)stringId; } - else - { - return null; - } + + return null; } private string GetRelationshipId() { @@ -140,23 +137,28 @@ private bool PathIsRelationship() return actionName.ToLower().Contains("relationships"); } - private bool IsValid() + private async Task IsValidAsync() { - return IsValidContentTypeHeader(_httpContext) && IsValidAcceptHeader(_httpContext); + return await IsValidContentTypeHeaderAsync(_httpContext) && await IsValidAcceptHeaderAsync(_httpContext); } - private bool IsValidContentTypeHeader(HttpContext context) + private static async Task IsValidContentTypeHeaderAsync(HttpContext context) { var contentType = context.Request.ContentType; if (contentType != null && ContainsMediaTypeParameters(contentType)) { - FlushResponse(context, HttpStatusCode.UnsupportedMediaType); + await FlushResponseAsync(context, new Error(HttpStatusCode.UnsupportedMediaType) + { + Title = "The specified Content-Type header value is not supported.", + Detail = $"Please specify '{Constants.ContentType}' for the Content-Type header value." + }); + return false; } return true; } - private bool IsValidAcceptHeader(HttpContext context) + private static async Task IsValidAcceptHeaderAsync(HttpContext context) { if (context.Request.Headers.TryGetValue(Constants.AcceptHeader, out StringValues acceptHeaders) == false) return true; @@ -168,7 +170,11 @@ private bool IsValidAcceptHeader(HttpContext context) continue; } - FlushResponse(context, HttpStatusCode.NotAcceptable); + await FlushResponseAsync(context, new Error(HttpStatusCode.NotAcceptable) + { + Title = "The specified Accept header value is not supported.", + Detail = $"Please specify '{Constants.ContentType}' for the Accept header value." + }); return false; } return true; @@ -195,9 +201,16 @@ private static bool ContainsMediaTypeParameters(string mediaType) ); } - private void FlushResponse(HttpContext context, HttpStatusCode statusCode) + private static async Task FlushResponseAsync(HttpContext context, Error error) { - context.Response.StatusCode = (int)statusCode; + context.Response.StatusCode = (int) error.StatusCode; + + string responseBody = JsonConvert.SerializeObject(new ErrorDocument(error)); + await using (var writer = new StreamWriter(context.Response.Body)) + { + await writer.WriteAsync(responseBody); + } + context.Response.Body.Flush(); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs index 35d828a5..ec98d103 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -90,21 +91,50 @@ public async Task CheckNullBehaviorCombination(bool? omitAttributeIfValueIsNull, // Act var response = await _fixture.Client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - if (queryString.Length > 0 && !bool.TryParse(queryStringOverride, out _)) + var isQueryStringMissing = queryString.Length > 0 && queryStringOverride == null; + var isQueryStringInvalid = queryString.Length > 0 && queryStringOverride != null && !bool.TryParse(queryStringOverride, out _); + var isDisallowedOverride = allowQueryStringOverride == false && queryStringOverride != null; + + if (isDisallowedOverride) { Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); + Assert.Equal("The parameter 'omitNull' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); + Assert.Equal("omitNull", errorDocument.Errors[0].Source.Parameter); } - else if (allowQueryStringOverride == false && queryStringOverride != null) + else if (isQueryStringMissing) { Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); + Assert.Equal("Missing value for 'omitNull' query string parameter.", errorDocument.Errors[0].Detail); + Assert.Equal("omitNull", errorDocument.Errors[0].Source.Parameter); + } + else if (isQueryStringInvalid) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified query string value must be 'true' or 'false'.", errorDocument.Errors[0].Title); + Assert.Equal("The value 'this-is-not-a-boolean-value' for parameter 'omitNull' is not a valid boolean value.", errorDocument.Errors[0].Detail); + Assert.Equal("omitNull", errorDocument.Errors[0].Source.Parameter); } else { // Assert: does response contain a null valued attribute? Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var deserializeBody = JsonConvert.DeserializeObject(body); Assert.Equal(expectNullsMissing, !deserializeBody.SingleData.Attributes.ContainsKey("description")); Assert.Equal(expectNullsMissing, !deserializeBody.Included[0].Attributes.ContainsKey("lastName")); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 3daeb706..c5b3195b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -149,7 +150,13 @@ public async Task Unauthorized_TodoItem() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update the author of todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -163,7 +170,13 @@ public async Task Unauthorized_Passport() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to include passports on individual persons.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -185,8 +198,13 @@ public async Task Unauthorized_Article() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to see this article.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -300,10 +318,14 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - // should throw 403 in PersonResource implicit hook - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); + } [Fact] public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() @@ -348,8 +370,13 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked persons.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -395,12 +422,15 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked persons.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } - - [Fact] public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() { @@ -422,10 +452,14 @@ public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - } - + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); + } [Fact] public async Task Cascade_Permission_Error_Create_ToMany_Relationship() @@ -473,7 +507,13 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -525,10 +565,13 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - // were unrelating a persons from a locked todo, so this should be unauthorized - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); - + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -552,7 +595,13 @@ public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs index dc026a6f..930b301f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs @@ -2,9 +2,11 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -50,7 +52,14 @@ public async Task Server_Responds_415_With_MediaType_Parameters() var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' for the Content-Type header value.", errorDocument.Errors[0].Detail); } [Fact] @@ -73,7 +82,14 @@ public async Task ServerResponds_406_If_RequestAcceptHeader_Contains_MediaTypePa var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotAcceptable, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Accept header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' for the Accept header value.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index eb18ce54..bc5a2d49 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -2,10 +2,12 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -41,7 +43,14 @@ public async Task Respond_404_If_EntityDoesNotExist() var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("NotFound", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs index 0c844e0d..23472800 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -327,19 +328,22 @@ public async Task Request_ToIncludeUnknownRelationship_Returns_400() var route = $"/api/v1/people/{person.Id}?include=nonExistentRelationship"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + using var server = new TestServer(builder); + using var client = server.CreateClient(); + using var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - server.Dispose(); - request.Dispose(); - response.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'nonExistentRelationship' on 'people' does not exist.", errorDocument.Errors[0].Detail); } [Fact] @@ -355,19 +359,22 @@ public async Task Request_ToIncludeDeeplyNestedRelationships_Returns_400() var route = $"/api/v1/people/{person.Id}?include=owner.name"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + using var server = new TestServer(builder); + using var client = server.CreateClient(); + using var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - server.Dispose(); - request.Dispose(); - response.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'owner' on 'people' does not exist.", errorDocument.Errors[0].Detail); } [Fact] @@ -383,19 +390,22 @@ public async Task Request_ToIncludeRelationshipMarkedCanIncludeFalse_Returns_400 var route = $"/api/v1/people/{person.Id}?include=unincludeableItem"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + using var server = new TestServer(builder); + using var client = server.CreateClient(); + using var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - server.Dispose(); - request.Dispose(); - response.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'unincludeableItem' on 'people' does not exist.", errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index 024c6ae4..ab8a9aa4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -143,15 +143,16 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() // Act var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("NotFound", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 3c816d1e..064ba24d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -2,11 +2,13 @@ using System.Net.Http; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -61,21 +63,11 @@ public async Task Request_UnsetRelationship_Returns_Null_DataObject() public async Task Request_ForRelationshipLink_ThatDoesNotExist_Returns_404() { // Arrange - var context = _fixture.GetService(); - - var todoItem = _todoItemFaker.Generate(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var todoItemId = todoItem.Id; - context.TodoItems.Remove(todoItem); - await context.SaveChangesAsync(); - var builder = new WebHostBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItemId}/owner"; + var route = "/api/v1/todoItems/99998888/owner"; var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -84,9 +76,14 @@ public async Task Request_ForRelationshipLink_ThatDoesNotExist_Returns_404() var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - context.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("Relationship 'owner' not found.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs index 558268aa..7da5372c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -19,6 +19,7 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCoreExampleTests.Helpers.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -131,12 +132,16 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation // Act var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Contains("relationships only", body); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.StartsWith("Square bracket notation in 'filter' is now reserved for relationships only", errorDocument.Errors[0].Title); + Assert.Equal("Use '?fields=...' instead of '?fields[todoItems]=...'.", errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index f038d08f..527c28e5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -113,7 +113,14 @@ public async Task Respond_404_If_EntityDoesNotExist() var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("NotFound", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index 24dae147..dec6e76c 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -73,27 +73,6 @@ public async Task ParseUrlBase_UrlHasNegativeBaseIdAndTypeIsInt_ShouldNotThrowJA await RunMiddlewareTask(configuration); } - [Theory] - [InlineData("", false)] - [InlineData("", true)] - public async Task ParseUrlBase_UrlHasIncorrectBaseIdSet_ShouldThrowException(string baseId, bool addSlash) - { - // Arrange - var url = addSlash ? $"/users/{baseId}/" : $"/users/{baseId}"; - var configuration = GetConfiguration(url, id: baseId); - - // Act - var task = RunMiddlewareTask(configuration); - - // Assert - var exception = await Assert.ThrowsAsync(async () => - { - await task; - }); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Contains(baseId, exception.Message); - } - private sealed class InvokeConfiguration { public CurrentRequestMiddleware MiddleWare; From d1c33264f9256135a4a332b683750724bc5d0f88 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Mon, 6 Apr 2020 18:02:08 +0200 Subject: [PATCH 43/60] Removed exception for unable to serialize response --- .../InvalidResponseBodyException.cs | 20 ------------------- .../Formatters/JsonApiWriter.cs | 14 +------------ .../Acceptance/Spec/ThrowingResourceTests.cs | 4 ++-- 3 files changed, 3 insertions(+), 35 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs deleted file mode 100644 index 95279b97..00000000 --- a/src/JsonApiDotNetCore/Exceptions/InvalidResponseBodyException.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; - -namespace JsonApiDotNetCore.Exceptions -{ - /// - /// The error that is thrown when serializing the response body fails. - /// - public sealed class InvalidResponseBodyException : JsonApiException - { - public InvalidResponseBodyException(Exception innerException) : base(new Error(HttpStatusCode.InternalServerError) - { - Title = "Failed to serialize response body.", - Detail = innerException.Message - }, innerException) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 30d8298a..1760be19 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; -using System.Reflection; using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; @@ -78,18 +77,7 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode contextObject = WrapErrors(contextObject); - try - { - return _serializer.Serialize(contextObject); - } - catch (TargetInvocationException exception) - { - throw new InvalidResponseBodyException(exception.InnerException); - } - catch (Exception exception) - { - throw new InvalidResponseBodyException(exception); - } + return _serializer.Serialize(contextObject); } private static object WrapErrors(object contextObject) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs index 7569f38a..b582bbe3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs @@ -33,8 +33,8 @@ public async Task GetThrowingResource_Fails() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.InternalServerError, errorDocument.Errors[0].StatusCode); - Assert.Equal("Failed to serialize response body.", errorDocument.Errors[0].Title); - Assert.Equal("The value for the 'FailsOnSerialize' property is currently unavailable.", errorDocument.Errors[0].Detail); + Assert.Equal("An unhandled error occurred while processing this request.", errorDocument.Errors[0].Title); + Assert.Equal("Exception has been thrown by the target of an invocation.", errorDocument.Errors[0].Detail); var stackTraceLines = ((JArray) errorDocument.Errors[0].Meta.Data["stackTrace"]).Select(token => token.Value()); From 46729cf5254ab84c20a96a80a03ce5ccaf2c5dba Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 06:30:50 +0200 Subject: [PATCH 44/60] Lowered log level in example --- src/Examples/ReportsExample/Services/ReportService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index f3295586..7fa801d8 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -17,7 +17,7 @@ public ReportService(ILoggerFactory loggerFactory) public Task> GetAsync() { - _logger.LogError("GetAsync"); + _logger.LogWarning("GetAsync"); var task = new Task>(Get); From 9e159c398c30aa11fbf22e52b93e0b2e0eae45a7 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 07:31:16 +0200 Subject: [PATCH 45/60] Tweaks in log formatting --- .../Exceptions/InvalidRequestBodyException.cs | 3 +-- .../Exceptions/JsonApiException.cs | 8 +++++++- .../Middleware/DefaultExceptionHandler.cs | 19 ++++++++++++++----- .../Acceptance/Spec/UpdatingDataTests.cs | 2 +- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs index b25c9058..35aa02fa 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs @@ -1,6 +1,5 @@ using System; using System.Net; -using System.Text; using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Exceptions @@ -29,7 +28,7 @@ private static string FormatDetails(string details, string requestBody, Exceptio { if (text != null) { - text += Environment.NewLine; + text += " - "; } text += "Request body: <<" + requestBody + ">>"; diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index 7e3fd2f5..a44b7fa4 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -7,6 +7,12 @@ namespace JsonApiDotNetCore.Exceptions { public class JsonApiException : Exception { + private static readonly JsonSerializerSettings _errorSerializerSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented + }; + public Error Error { get; } public JsonApiException(Error error, Exception innerException = null) @@ -24,6 +30,6 @@ public JsonApiException(HttpStatusCode status, string message) }; } - public override string Message => "Error = " + JsonConvert.SerializeObject(Error, Formatting.Indented); + public override string Message => "Error = " + JsonConvert.SerializeObject(Error, _errorSerializerSettings); } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs index fea77a68..877166aa 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs @@ -21,17 +21,19 @@ public DefaultExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions opt public ErrorDocument HandleException(Exception exception) { - LogException(exception); + Exception demystified = exception.Demystify(); + + LogException(demystified); - return CreateErrorDocument(exception); + return CreateErrorDocument(demystified); } private void LogException(Exception exception) { var level = GetLogLevel(exception); - - Exception demystified = exception.Demystify(); - _logger.Log(level, demystified, $"Intercepted {demystified.GetType().Name}: {demystified.Message}"); + var message = GetLogMessage(exception); + + _logger.Log(level, exception, message); } protected virtual LogLevel GetLogLevel(Exception exception) @@ -44,6 +46,13 @@ protected virtual LogLevel GetLogLevel(Exception exception) return LogLevel.Error; } + protected virtual string GetLogMessage(Exception exception) + { + return exception is JsonApiException jsonApiException + ? jsonApiException.Error.Title + : exception.Message; + } + protected virtual ErrorDocument CreateErrorDocument(Exception exception) { if (exception is InvalidModelStateException modelStateException) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 527c28e5..9816d860 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -88,7 +88,7 @@ public async Task Response422IfUpdatingNotSettableAttribute() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body.", error.Title); - Assert.StartsWith("Property set method not found." + Environment.NewLine + "Request body: <<", error.Detail); + Assert.StartsWith("Property set method not found. - Request body: <<", error.Detail); } [Fact] From 0417afab7400b078e1b158b7b0eb6f1e66e715ae Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 07:39:46 +0200 Subject: [PATCH 46/60] Added logging for query string parsing --- .../Common/QueryParameterParser.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index e36ef033..bb0004a5 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.QueryParameterServices.Common; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Services { @@ -14,12 +15,15 @@ public class QueryParameterParser : IQueryParameterParser private readonly IJsonApiOptions _options; private readonly IRequestQueryStringAccessor _queryStringAccessor; private readonly IEnumerable _queryServices; + private ILogger _logger; - public QueryParameterParser(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, IEnumerable queryServices) + public QueryParameterParser(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, IEnumerable queryServices, ILoggerFactory loggerFactory) { _options = options; _queryStringAccessor = queryStringAccessor; _queryServices = queryServices; + + _logger = loggerFactory.CreateLogger(); } /// @@ -38,6 +42,8 @@ public virtual void Parse(DisableQueryAttribute disableQueryAttribute) var service = _queryServices.FirstOrDefault(s => s.CanParse(pair.Key)); if (service != null) { + _logger.LogDebug($"Query string parameter '{pair.Key}' with value '{pair.Value}' was accepted by {service.GetType().Name}."); + if (!service.IsEnabled(disableQueryAttribute)) { throw new InvalidQueryStringParameterException(pair.Key, @@ -46,6 +52,7 @@ public virtual void Parse(DisableQueryAttribute disableQueryAttribute) } service.Parse(pair.Key, pair.Value); + _logger.LogDebug($"Query string parameter '{pair.Key}' was successfully parsed."); } else if (!_options.AllowCustomQueryStringParameters) { From 2f095cf463f4845289a793f82236d7465abc1d51 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 08:21:11 +0200 Subject: [PATCH 47/60] Added JSON request/response logging --- benchmarks/Query/QueryParserBenchmarks.cs | 5 ++- .../Formatters/JsonApiReader.cs | 7 ++-- .../Formatters/JsonApiWriter.cs | 10 ++++- .../Extensibility/CustomErrorHandlingTests.cs | 35 ---------------- .../Acceptance/Spec/UpdatingDataTests.cs | 19 +++++++++ .../FakeLoggerFactory.cs | 41 +++++++++++++++++++ 6 files changed, 76 insertions(+), 41 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs index 336e7588..0bf78b34 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/Query/QueryParserBenchmarks.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging.Abstractions; namespace Benchmarks.Query { @@ -44,7 +45,7 @@ private static QueryParameterParser CreateQueryParameterDiscoveryForSort(IResour sortService }; - return new QueryParameterParser(options, queryStringAccessor, queryServices); + return new QueryParameterParser(options, queryStringAccessor, queryServices, NullLoggerFactory.Instance); } private static QueryParameterParser CreateQueryParameterDiscoveryForAll(IResourceGraph resourceGraph, @@ -65,7 +66,7 @@ private static QueryParameterParser CreateQueryParameterDiscoveryForAll(IResourc omitNullService }; - return new QueryParameterParser(options, queryStringAccessor, queryServices); + return new QueryParameterParser(options, queryStringAccessor, queryServices, NullLoggerFactory.Instance); } [Benchmark] diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index de6f436a..5e4221ba 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -3,9 +3,9 @@ using System.IO; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Server; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; @@ -22,8 +22,6 @@ public JsonApiReader(IJsonApiDeserializer deserializer, { _deserializer = deserializer; _logger = loggerFactory.CreateLogger(); - - _logger.LogTrace("Executing constructor."); } public async Task ReadAsync(InputFormatterContext context) @@ -39,6 +37,9 @@ public async Task ReadAsync(InputFormatterContext context) string body = await GetRequestBody(context.HttpContext.Request.Body); + string url = context.HttpContext.Request.GetEncodedUrl(); + _logger.LogTrace($"Received request at '{url}' with body: <<{body}>>"); + object model; try { diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 1760be19..5c30793d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -9,8 +9,10 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Serialization.Server; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace JsonApiDotNetCore.Formatters @@ -23,11 +25,14 @@ public class JsonApiWriter : IJsonApiWriter { private readonly IJsonApiSerializer _serializer; private readonly IExceptionHandler _exceptionHandler; + private readonly ILogger _logger; - public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler) + public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, ILoggerFactory loggerFactory) { _serializer = serializer; _exceptionHandler = exceptionHandler; + + _logger = loggerFactory.CreateLogger(); } public async Task WriteAsync(OutputFormatterWriteContext context) @@ -59,6 +64,9 @@ public async Task WriteAsync(OutputFormatterWriteContext context) } } + var url = context.HttpContext.Request.GetEncodedUrl(); + _logger.LogTrace($"Sending {response.StatusCode} response for request at '{url}' with body: <<{responseContent}>>"); + await writer.WriteAsync(responseContent); await writer.FlushAsync(); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs index a5f70644..25716f3f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -76,40 +76,5 @@ public NoPermissionException(string customerCode) : base(new Error(HttpStatusCod CustomerCode = customerCode; } } - - internal sealed class FakeLoggerFactory : ILoggerFactory - { - public FakeLogger Logger { get; } - - public FakeLoggerFactory() - { - Logger = new FakeLogger(); - } - - public ILogger CreateLogger(string categoryName) => Logger; - - public void AddProvider(ILoggerProvider provider) - { - } - - public void Dispose() - { - } - - internal sealed class FakeLogger : ILogger - { - public List<(LogLevel LogLevel, string Text)> Messages = new List<(LogLevel, string)>(); - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, - Func formatter) - { - var message = formatter(state, exception); - Messages.Add((logLevel, message)); - } - - public bool IsEnabled(LogLevel logLevel) => true; - public IDisposable BeginScope(TState state) => null; - } - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 9816d860..dfd3f640 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Formatters; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; @@ -13,7 +14,9 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using NLog.Extensions.Logging; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -64,6 +67,16 @@ public async Task Response422IfUpdatingNotSettableAttribute() { // Arrange var builder = new WebHostBuilder().UseStartup(); + + var loggerFactory = new FakeLoggerFactory(); + builder.ConfigureLogging(options => + { + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, level) => level == LogLevel.Trace && + (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); + }); + var server = new TestServer(builder); var client = server.CreateClient(); @@ -89,6 +102,12 @@ public async Task Response422IfUpdatingNotSettableAttribute() Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); Assert.Equal("Failed to deserialize request body.", error.Title); Assert.StartsWith("Property set method not found. - Request body: <<", error.Detail); + + Assert.NotEmpty(loggerFactory.Logger.Messages); + Assert.Contains(loggerFactory.Logger.Messages, + x => x.Text.StartsWith("Received request at ") && x.Text.Contains("with body:")); + Assert.Contains(loggerFactory.Logger.Messages, + x => x.Text.StartsWith("Sending 422 response for request at ") && x.Text.Contains("Failed to deserialize request body.")); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs new file mode 100644 index 00000000..39d28aae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests +{ + internal sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider + { + public FakeLogger Logger { get; } + + public FakeLoggerFactory() + { + Logger = new FakeLogger(); + } + + public ILogger CreateLogger(string categoryName) => Logger; + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } + + internal sealed class FakeLogger : ILogger + { + public List<(LogLevel LogLevel, string Text)> Messages = new List<(LogLevel, string)>(); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + var message = formatter(state, exception); + Messages.Add((logLevel, message)); + } + + public bool IsEnabled(LogLevel logLevel) => true; + public IDisposable BeginScope(TState state) => null; + } + } +} From d424307f118b2f283d090566d0a1af77ae48fca3 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 09:07:54 +0200 Subject: [PATCH 48/60] Added trace-level logging for the controller > service > repository chain --- .../Controllers/BaseJsonApiController.cs | 22 ++++++++---- .../Data/DefaultResourceRepository.cs | 35 +++++++++++++++++-- .../IApplicationBuilderExtensions.cs | 8 +---- .../Services/DefaultResourceService.cs | 25 ++++++++++--- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 28a0e2f9..49220549 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,12 +1,8 @@ -using System; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -65,12 +61,12 @@ protected BaseJsonApiController( _update = update; _updateRelationships = updateRelationships; _delete = delete; - - _logger.LogTrace("Executing constructor."); } public virtual async Task GetAsync() { + _logger.LogTrace($"Entering {nameof(GetAsync)}()."); + if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var entities = await _getAll.GetAsync(); return Ok(entities); @@ -78,6 +74,8 @@ public virtual async Task GetAsync() public virtual async Task GetAsync(TId id) { + _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); + if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var entity = await _getById.GetAsync(id); if (entity == null) @@ -90,6 +88,8 @@ public virtual async Task GetAsync(TId id) public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) { + _logger.LogTrace($"Entering {nameof(GetRelationshipsAsync)}('{id}', '{relationshipName}')."); + if (_getRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getRelationships.GetRelationshipsAsync(id, relationshipName); if (relationship == null) @@ -102,6 +102,8 @@ public virtual async Task GetRelationshipsAsync(TId id, string re public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { + _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); return Ok(relationship); @@ -109,6 +111,8 @@ public virtual async Task GetRelationshipAsync(TId id, string rel public virtual async Task PostAsync([FromBody] T entity) { + _logger.LogTrace($"Entering {nameof(PostAsync)}({(entity == null ? "null" : "object")})."); + if (_create == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); @@ -128,6 +132,8 @@ public virtual async Task PostAsync([FromBody] T entity) public virtual async Task PatchAsync(TId id, [FromBody] T entity) { + _logger.LogTrace($"Entering {nameof(PatchAsync)}('{id}', {(entity == null ? "null" : "object")})."); + if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); if (entity == null) return UnprocessableEntity(); @@ -148,6 +154,8 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships) { + _logger.LogTrace($"Entering {nameof(PatchRelationshipsAsync)}('{id}', '{relationshipName}', {(relationships == null ? "null" : "object")})."); + if (_updateRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); await _updateRelationships.UpdateRelationshipsAsync(id, relationshipName, relationships); return Ok(); @@ -155,6 +163,8 @@ public virtual async Task PatchRelationshipsAsync(TId id, string public virtual async Task DeleteAsync(TId id) { + _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id})."); + if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); var wasDeleted = await _delete.DeleteAsync(id); if (!wasDeleted) diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs index 2b082596..0c3e2e94 100644 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs @@ -42,23 +42,30 @@ public DefaultResourceRepository( _context = contextResolver.GetContext(); _dbSet = _context.Set(); _logger = loggerFactory.CreateLogger>(); - - _logger.LogTrace("Executing constructor."); } /// public virtual IQueryable Get() { + _logger.LogTrace($"Entering {nameof(Get)}()."); + var resourceContext = _resourceGraph.GetResourceContext(); return EagerLoad(_dbSet, resourceContext.EagerLoads); } /// - public virtual IQueryable Get(TId id) => Get().Where(e => e.Id.Equals(id)); + public virtual IQueryable Get(TId id) + { + _logger.LogTrace($"Entering {nameof(Get)}('{id}')."); + + return Get().Where(e => e.Id.Equals(id)); + } /// public virtual IQueryable Select(IQueryable entities, IEnumerable fields = null) { + _logger.LogTrace($"Entering {nameof(Select)}({nameof(entities)}, {nameof(fields)})."); + if (fields != null && fields.Any()) return entities.Select(fields); @@ -68,6 +75,8 @@ public virtual IQueryable Select(IQueryable entities, IEnu /// public virtual IQueryable Filter(IQueryable entities, FilterQueryContext filterQueryContext) { + _logger.LogTrace($"Entering {nameof(Filter)}({nameof(entities)}, {nameof(filterQueryContext)})."); + if (filterQueryContext.IsCustom) { var query = (Func, FilterQuery, IQueryable>)filterQueryContext.CustomQuery; @@ -79,12 +88,16 @@ public virtual IQueryable Filter(IQueryable entities, Filt /// public virtual IQueryable Sort(IQueryable entities, SortQueryContext sortQueryContext) { + _logger.LogTrace($"Entering {nameof(Sort)}({nameof(entities)}, {nameof(sortQueryContext)})."); + return entities.Sort(sortQueryContext); } /// public virtual async Task CreateAsync(TResource entity) { + _logger.LogTrace($"Entering {nameof(CreateAsync)}({(entity == null ? "null" : "object")})."); + foreach (var relationshipAttr in _targetedFields.Relationships) { object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, entity, out bool relationshipWasAlreadyTracked); @@ -184,6 +197,8 @@ private void DetachRelationships(TResource entity) /// public virtual async Task UpdateAsync(TResource updatedEntity) { + _logger.LogTrace($"Entering {nameof(UpdateAsync)}({(updatedEntity == null ? "null" : "object")})."); + var databaseEntity = await Get(updatedEntity.Id).FirstOrDefaultAsync(); if (databaseEntity == null) return null; @@ -264,6 +279,8 @@ private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationsh /// public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { + _logger.LogTrace($"Entering {nameof(UpdateRelationshipsAsync)}({nameof(parent)}, {nameof(relationship)}, {nameof(relationshipIds)})."); + var typeToUpdate = (relationship is HasManyThroughAttribute hasManyThrough) ? hasManyThrough.ThroughType : relationship.RightType; @@ -277,6 +294,8 @@ public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute /// public virtual async Task DeleteAsync(TId id) { + _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id}')."); + var entity = await Get(id).FirstOrDefaultAsync(); if (entity == null) return false; _dbSet.Remove(entity); @@ -299,6 +318,8 @@ private IQueryable EagerLoad(IQueryable entities, IEnumera public virtual IQueryable Include(IQueryable entities, IEnumerable inclusionChain = null) { + _logger.LogTrace($"Entering {nameof(Include)}({nameof(entities)}, {nameof(inclusionChain)})."); + if (inclusionChain == null || !inclusionChain.Any()) { return entities; @@ -321,6 +342,8 @@ public virtual IQueryable Include(IQueryable entities, IEn /// public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { + _logger.LogTrace($"Entering {nameof(PageAsync)}({nameof(entities)}, {pageSize}, {pageNumber})."); + // the IQueryable returned from the hook executor is sometimes consumed here. // In this case, it does not support .ToListAsync(), so we use the method below. if (pageNumber >= 0) @@ -351,6 +374,8 @@ public virtual async Task> PageAsync(IQueryable public async Task CountAsync(IQueryable entities) { + _logger.LogTrace($"Entering {nameof(CountAsync)}({nameof(entities)})."); + if (entities is IAsyncEnumerable) { return await entities.CountAsync(); @@ -361,6 +386,8 @@ public async Task CountAsync(IQueryable entities) /// public virtual async Task FirstOrDefaultAsync(IQueryable entities) { + _logger.LogTrace($"Entering {nameof(FirstOrDefaultAsync)}({nameof(entities)})."); + return (entities is IAsyncEnumerable) ? await entities.FirstOrDefaultAsync() : entities.FirstOrDefault(); @@ -369,6 +396,8 @@ public virtual async Task FirstOrDefaultAsync(IQueryable e /// public async Task> ToListAsync(IQueryable entities) { + _logger.LogTrace($"Entering {nameof(ToListAsync)}({nameof(entities)})."); + if (entities is IAsyncEnumerable) { return await entities.ToListAsync(); diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 2ff5e73a..0bb6411b 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -70,13 +70,7 @@ private static void LogResourceGraphValidations(IApplicationBuilder app) if (logger != null) { - resourceGraph?.ValidationResults.ForEach((v) => - logger.Log( - v.LogLevel, - new EventId(), - v.Message, - exception: null, - formatter: (m, e) => m)); + resourceGraph?.ValidationResults.ForEach((v) => logger.Log(v.LogLevel, null, v.Message)); } } } diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index 54ecd6d9..ecdd0a57 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -54,12 +54,12 @@ public DefaultResourceService( _repository = repository; _hookExecutor = hookExecutor; _currentRequestResource = provider.GetResourceContext(); - - _logger.LogTrace("Executing constructor."); } public virtual async Task CreateAsync(TResource entity) { + _logger.LogTrace($"Entering {nameof(CreateAsync)}({(entity == null ? "null" : "object")})."); + entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeCreate(AsList(entity), ResourcePipeline.Post).SingleOrDefault(); entity = await _repository.CreateAsync(entity); @@ -76,6 +76,8 @@ public virtual async Task CreateAsync(TResource entity) public virtual async Task DeleteAsync(TId id) { + _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id}')."); + var entity = typeof(TResource).New(); entity.Id = id; if (!IsNull(_hookExecutor, entity)) _hookExecutor.BeforeDelete(AsList(entity), ResourcePipeline.Delete); @@ -86,6 +88,8 @@ public virtual async Task DeleteAsync(TId id) public virtual async Task> GetAsync() { + _logger.LogTrace($"Entering {nameof(GetAsync)}()."); + _hookExecutor?.BeforeRead(ResourcePipeline.Get); var entityQuery = _repository.Get(); @@ -111,6 +115,8 @@ public virtual async Task> GetAsync() public virtual async Task GetAsync(TId id) { + _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); + var pipeline = ResourcePipeline.GetSingle; _hookExecutor?.BeforeRead(pipeline, id.ToString()); @@ -130,6 +136,8 @@ public virtual async Task GetAsync(TId id) // triggered by GET /articles/1/relationships/{relationshipName} public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) { + _logger.LogTrace($"Entering {nameof(GetRelationshipsAsync)}('{id}', '{relationshipName}')."); + var relationship = GetRelationship(relationshipName); // BeforeRead hook execution @@ -159,6 +167,8 @@ public virtual async Task GetRelationshipsAsync(TId id, string relati // triggered by GET /articles/1/{relationshipName} public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { + _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); + var relationship = GetRelationship(relationshipName); var resource = await GetRelationshipsAsync(id, relationshipName); return relationship.GetValue(resource); @@ -166,6 +176,8 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh public virtual async Task UpdateAsync(TId id, TResource entity) { + _logger.LogTrace($"Entering {nameof(UpdateAsync)}('{id}', {(entity == null ? "null" : "object")})."); + entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.Patch).SingleOrDefault(); entity = await _repository.UpdateAsync(entity); if (!IsNull(_hookExecutor, entity)) @@ -179,6 +191,8 @@ public virtual async Task UpdateAsync(TId id, TResource entity) // triggered by PATCH /articles/1/relationships/{relationshipName} public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, object related) { + _logger.LogTrace($"Entering {nameof(UpdateRelationshipsAsync)}('{id}', '{relationshipName}', {(related == null ? "null" : "object")})."); + var relationship = GetRelationship(relationshipName); var entityQuery = _repository.Include(_repository.Get(id), new[] { relationship }); var entity = await _repository.FirstOrDefaultAsync(entityQuery); @@ -202,8 +216,10 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa protected virtual async Task> ApplyPageQueryAsync(IQueryable entities) { - if (!(_pageService.PageSize > 0)) + if (_pageService.PageSize <= 0) { + _logger.LogDebug("Fetching complete result set."); + return await _repository.ToListAsync(entities); } @@ -213,8 +229,7 @@ protected virtual async Task> ApplyPageQueryAsync(IQuerya pageOffset = -pageOffset; } - _logger.LogInformation($"Applying paging query. Fetching page {pageOffset} " + - $"with {_pageService.PageSize} entities"); + _logger.LogDebug($"Fetching paged result set at page {pageOffset} with size {_pageService.PageSize}."); return await _repository.PageAsync(entities, _pageService.PageSize, pageOffset); } From c149cf1de7340187709277a22929262c9b005623 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 09:30:28 +0200 Subject: [PATCH 49/60] Fixed: allow unpaged result when no maximum page size is set Fixed: having default page size of 0 is dangerous (returns complete tables) --- .../Startups/NoDefaultPageSizeStartup.cs | 1 + src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs | 4 ++-- src/JsonApiDotNetCore/QueryParameterServices/PageService.cs | 6 ++++-- .../Acceptance/Spec/FetchingDataTests.cs | 5 ++++- .../Acceptance/Spec/UpdatingDataTests.cs | 1 - test/UnitTests/QueryParameters/PageServiceTests.cs | 4 +++- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs index 2d990ce7..33a98680 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs @@ -25,6 +25,7 @@ public override void ConfigureServices(IServiceCollection services) options.IncludeTotalRecordCount = true; options.LoadDatabaseValues = true; options.AllowClientGeneratedIds = true; + options.DefaultPageSize = 0; }, discovery => discovery.AddAssembly(Assembly.Load(nameof(JsonApiDotNetCoreExample))), mvcBuilder: mvcBuilder); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 05bd69ba..6466216a 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -53,12 +53,12 @@ public class JsonApiOptions : IJsonApiOptions public string Namespace { get; set; } /// - /// The default page size for all resources + /// The default page size for all resources. The value zero means: no paging. /// /// /// options.DefaultPageSize = 10; /// - public int DefaultPageSize { get; set; } + public int DefaultPageSize { get; set; } = 10; /// /// Optional. When set, limits the maximum page size for all resources. diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index 1246cb91..24c50193 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -105,7 +105,9 @@ public virtual void Parse(string parameterName, StringValues parameterValue) private int ParsePageSize(string parameterValue, int? maxValue) { bool success = int.TryParse(parameterValue, out int number); - if (success && number >= 1) + int minValue = maxValue != null ? 1 : 0; + + if (success && number >= minValue) { if (maxValue == null || number <= maxValue) { @@ -115,7 +117,7 @@ private int ParsePageSize(string parameterValue, int? maxValue) var message = maxValue == null ? $"Value '{parameterValue}' is invalid, because it must be a whole number that is greater than zero." - : $"Value '{parameterValue}' is invalid, because it must be a whole number that is greater than zero and not higher than {maxValue}."; + : $"Value '{parameterValue}' is invalid, because it must be a whole number that is zero or greater and not higher than {maxValue}."; throw new InvalidQueryStringParameterException("page[size]", "The specified value is not in the range of valid values.", message); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index ab8a9aa4..25bf4e9f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -104,6 +104,9 @@ public async Task GetResources_NoDefaultPageSize_ReturnsResources() { // Arrange var context = _fixture.GetService(); + context.TodoItems.RemoveRange(context.TodoItems); + await context.SaveChangesAsync(); + var todoItems = _todoItemFaker.Generate(20).ToList(); context.TodoItems.AddRange(todoItems); await context.SaveChangesAsync(); @@ -122,7 +125,7 @@ public async Task GetResources_NoDefaultPageSize_ReturnsResources() var result = _fixture.GetDeserializer().DeserializeList(body); // Assert - Assert.True(result.Data.Count >= 20); + Assert.True(result.Data.Count == 20); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index dfd3f640..fa83bbfe 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -16,7 +16,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using NLog.Extensions.Logging; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index 9033652f..ee9a078d 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -46,6 +46,8 @@ public void CanParse_PageService_FailOnMismatch() } [Theory] + [InlineData("0", 0, null, false)] + [InlineData("0", 0, 50, true)] [InlineData("1", 1, null, false)] [InlineData("abcde", 0, null, true)] [InlineData("", 0, null, true)] @@ -65,7 +67,7 @@ public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximu Assert.Equal("page[size]", exception.QueryParameterName); Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); - Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is greater than zero", exception.Error.Detail); + Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is", exception.Error.Detail); Assert.Equal("page[size]", exception.Error.Source.Parameter); } else From a93863b62ce39e480d702e23a4dc04fec5bbee5b Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 10:53:59 +0200 Subject: [PATCH 50/60] Fixed: duplicate tests --- .../Spec/DocumentTests/Relationships.cs | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index 96b6d3d6..71b7cf03 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -11,6 +11,7 @@ using System.Linq; using Bogus; using JsonApiDotNetCoreExample.Models; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { @@ -19,6 +20,7 @@ public sealed class Relationships { private readonly AppDbContext _context; private readonly Faker _todoItemFaker; + private readonly Faker _personFaker; public Relationships(TestFixture fixture) { @@ -27,32 +29,36 @@ public Relationships(TestFixture fixture) .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()) .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); } [Fact] public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() { // Arrange - var builder = new WebHostBuilder() - .UseStartup(); - + _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.SaveChangesAsync(); + var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; + var route = "/api/v1/todoItems"; + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); - var document = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - var data = document.SingleData; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todoItems/{data.Id}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todoItems/{data.Id}/owner"; + var responseString = await response.Content.ReadAsStringAsync(); + var data = JsonConvert.DeserializeObject(responseString).ManyData[0]; + var expectedOwnerSelfLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/relationships/owner"; + var expectedOwnerRelatedLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/owner"; // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -64,9 +70,6 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() { // Arrange - var builder = new WebHostBuilder() - .UseStartup(); - var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); await _context.SaveChangesAsync(); @@ -74,6 +77,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/todoItems/{todoItem.Id}"; + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -87,7 +91,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["owner"].Links?.Self); + Assert.Equal(expectedOwnerSelfLink, data.Relationships["owner"].Links.Self); Assert.Equal(expectedOwnerRelatedLink, data.Relationships["owner"].Links.Related); } @@ -95,22 +99,27 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() { // Arrange - var builder = new WebHostBuilder() - .UseStartup(); + _context.People.RemoveRange(_context.People); + await _context.SaveChangesAsync(); + + var person = _personFaker.Generate(); + _context.People.Add(person); + await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/people"; + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); - var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - var data = documents.ManyData.First(); - var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{data.Id}/relationships/todoItems"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{data.Id}/todoItems"; + var responseString = await response.Content.ReadAsStringAsync(); + var data = JsonConvert.DeserializeObject(responseString).ManyData[0]; + var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{person.Id}/relationships/todoItems"; + var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{person.Id}/todoItems"; // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -122,14 +131,14 @@ public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() public async Task Correct_RelationshipObjects_For_OneToMany_Relationships_ById() { // Arrange - var personId = _context.People.AsEnumerable().Last().Id; - - var builder = new WebHostBuilder() - .UseStartup(); + var person = _personFaker.Generate(); + _context.People.Add(person); + await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/people/{personId}"; + var route = $"/api/v1/people/{person.Id}"; + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -138,12 +147,12 @@ public async Task Correct_RelationshipObjects_For_OneToMany_Relationships_ById() var response = await client.SendAsync(request); var responseString = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject(responseString).SingleData; - var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{personId}/relationships/todoItems"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{personId}/todoItems"; + var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{person.Id}/relationships/todoItems"; + var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{person.Id}/todoItems"; // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["todoItems"].Links?.Self); + Assert.Equal(expectedOwnerSelfLink, data.Relationships["todoItems"].Links.Self); Assert.Equal(expectedOwnerRelatedLink, data.Relationships["todoItems"].Links.Related); } } From a558df9d0742f3ff16da269de41b9a29a74b8b08 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 11:14:24 +0200 Subject: [PATCH 51/60] separate constructors (easier to derive) --- src/JsonApiDotNetCore/Exceptions/JsonApiException.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index a44b7fa4..52f6ffb2 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -15,7 +15,12 @@ public class JsonApiException : Exception public Error Error { get; } - public JsonApiException(Error error, Exception innerException = null) + public JsonApiException(Error error) + : this(error, null) + { + } + + public JsonApiException(Error error, Exception innerException) : base(error.Title, innerException) { Error = error; From fee853ae3185551bb8f97c3ff6c4b6eff8c04105 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 13:31:45 +0200 Subject: [PATCH 52/60] Replaced ActionResults with rich exceptions Fixes in handling of missing relationships + extra tests --- .../Controllers/TodoItemsCustomController.cs | 54 ++--- .../Services/TodoItemService.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 29 +-- .../Controllers/JsonApiControllerMixin.cs | 10 +- .../Exceptions/JsonApiException.cs | 10 - .../RelationshipNotFoundException.cs | 19 ++ ...ourceIdInPostRequestNotAllowedException.cs | 23 ++ .../Exceptions/ResourceNotFoundException.cs | 19 ++ .../Extensions/TypeExtensions.cs | 8 + .../Middleware/CurrentRequestMiddleware.cs | 2 +- .../Services/Contract/IDeleteService.cs | 2 +- .../Services/DefaultResourceService.cs | 46 +++- .../Extensibility/CustomControllerTests.cs | 26 ++- .../Acceptance/Spec/CreatingDataTests.cs | 8 +- .../Acceptance/Spec/DeletingDataTests.cs | 10 +- .../Acceptance/Spec/FetchingDataTests.cs | 4 +- .../Spec/FetchingRelationshipsTests.cs | 210 ++++++++++++++++-- .../Acceptance/Spec/UpdatingDataTests.cs | 12 +- .../Spec/UpdatingRelationshipsTests.cs | 78 +++++++ .../JsonApiControllerMixin_Tests.cs | 6 +- .../IServiceCollectionExtensionsTests.cs | 4 +- 21 files changed, 461 insertions(+), 121 deletions(-) create mode 100644 src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs create mode 100644 src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs create mode 100644 src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 0ada9700..1b2865c0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -3,11 +3,11 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { @@ -17,9 +17,8 @@ public class TodoItemsCustomController : CustomJsonApiController { public TodoItemsCustomController( IJsonApiOptions options, - IResourceService resourceService, - ILoggerFactory loggerFactory) - : base(options, resourceService, loggerFactory) + IResourceService resourceService) + : base(options, resourceService) { } } @@ -28,8 +27,7 @@ public class CustomJsonApiController { public CustomJsonApiController( IJsonApiOptions options, - IResourceService resourceService, - ILoggerFactory loggerFactory) + IResourceService resourceService) : base(options, resourceService) { } @@ -70,22 +68,29 @@ public async Task GetAsync() [HttpGet("{id}")] public async Task GetAsync(TId id) { - var entity = await _resourceService.GetAsync(id); - - if (entity == null) + try + { + var entity = await _resourceService.GetAsync(id); + return Ok(entity); + } + catch (ResourceNotFoundException) + { return NotFound(); - - return Ok(entity); + } } [HttpGet("{id}/relationships/{relationshipName}")] public async Task GetRelationshipsAsync(TId id, string relationshipName) { - var relationship = _resourceService.GetRelationshipAsync(id, relationshipName); - if (relationship == null) + try + { + var relationship = await _resourceService.GetRelationshipsAsync(id, relationshipName); + return Ok(relationship); + } + catch (ResourceNotFoundException) + { return NotFound(); - - return await GetRelationshipAsync(id, relationshipName); + } } [HttpGet("{id}/{relationshipName}")] @@ -115,12 +120,15 @@ public async Task PatchAsync(TId id, [FromBody] T entity) if (entity == null) return UnprocessableEntity(); - var updatedEntity = await _resourceService.UpdateAsync(id, entity); - - if (updatedEntity == null) + try + { + var updatedEntity = await _resourceService.UpdateAsync(id, entity); + return Ok(updatedEntity); + } + catch (ResourceNotFoundException) + { return NotFound(); - - return Ok(updatedEntity); + } } [HttpPatch("{id}/relationships/{relationshipName}")] @@ -133,11 +141,7 @@ public async Task PatchRelationshipsAsync(TId id, string relation [HttpDelete("{id}")] public async Task DeleteAsync(TId id) { - var wasDeleted = await _resourceService.DeleteAsync(id); - - if (!wasDeleted) - return NotFound(); - + await _resourceService.DeleteAsync(id); return NoContent(); } } diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs index bbf43006..c68977c5 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -63,7 +63,7 @@ public async Task CreateAsync(TodoItem entity) })).SingleOrDefault(); } - public Task DeleteAsync(int id) + public Task DeleteAsync(int id) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 49220549..b90c9a49 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -78,11 +78,6 @@ public virtual async Task GetAsync(TId id) if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var entity = await _getById.GetAsync(id); - if (entity == null) - { - return NotFound(); - } - return Ok(entity); } @@ -92,10 +87,6 @@ public virtual async Task GetRelationshipsAsync(TId id, string re if (_getRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); var relationship = await _getRelationships.GetRelationshipsAsync(id, relationshipName); - if (relationship == null) - { - return NotFound(); - } return Ok(relationship); } @@ -117,17 +108,17 @@ public virtual async Task PostAsync([FromBody] T entity) throw new RequestMethodNotAllowedException(HttpMethod.Post); if (entity == null) - return UnprocessableEntity(); + throw new InvalidRequestBodyException(null, null, null); if (!_jsonApiOptions.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) - return Forbidden(); + throw new ResourceIdInPostRequestNotAllowedException(); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions); entity = await _create.CreateAsync(entity); - return Created($"{HttpContext.Request.Path}/{entity.Id}", entity); + return Created($"{HttpContext.Request.Path}/{entity.StringId}", entity); } public virtual async Task PatchAsync(TId id, [FromBody] T entity) @@ -136,19 +127,12 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); if (entity == null) - return UnprocessableEntity(); + throw new InvalidRequestBodyException(null, null, null); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions); var updatedEntity = await _update.UpdateAsync(id, entity); - - if (updatedEntity == null) - { - return NotFound(); - } - - return Ok(updatedEntity); } @@ -166,9 +150,8 @@ public virtual async Task DeleteAsync(TId id) _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id})."); if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); - var wasDeleted = await _delete.DeleteAsync(id); - if (!wasDeleted) - return NotFound(); + await _delete.DeleteAsync(id); + return NoContent(); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index d6aeb88e..8396ccff 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using System.Net; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; @@ -10,17 +9,12 @@ namespace JsonApiDotNetCore.Controllers [ServiceFilter(typeof(IQueryParameterActionFilter))] public abstract class JsonApiControllerMixin : ControllerBase { - protected IActionResult Forbidden() - { - return new StatusCodeResult((int)HttpStatusCode.Forbidden); - } - protected IActionResult Error(Error error) { - return Errors(new[] {error}); + return Error(new[] {error}); } - protected IActionResult Errors(IEnumerable errors) + protected IActionResult Error(IEnumerable errors) { var document = new ErrorDocument(errors.ToList()); diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs index 52f6ffb2..381040b4 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -1,5 +1,4 @@ using System; -using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; @@ -26,15 +25,6 @@ public JsonApiException(Error error, Exception innerException) Error = error; } - public JsonApiException(HttpStatusCode status, string message) - : base(message) - { - Error = new Error(status) - { - Title = message - }; - } - public override string Message => "Error = " + JsonConvert.SerializeObject(Error, _errorSerializerSettings); } } diff --git a/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs new file mode 100644 index 00000000..a8c2fc3b --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when a relationship does not exist. + /// + public sealed class RelationshipNotFoundException : JsonApiException + { + public RelationshipNotFoundException(string relationshipName, string containingResourceName) : base(new Error(HttpStatusCode.NotFound) + { + Title = "The requested relationship does not exist.", + Detail = $"The resource '{containingResourceName}' does not contain a relationship named '{relationshipName}'." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs new file mode 100644 index 00000000..fdf6a028 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs @@ -0,0 +1,23 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when a POST request is received that contains a client-generated ID. + /// + public sealed class ResourceIdInPostRequestNotAllowedException : JsonApiException + { + public ResourceIdInPostRequestNotAllowedException() + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Specifying the resource id in POST requests is not allowed.", + Source = + { + Pointer = "/data/id" + } + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs new file mode 100644 index 00000000..28f8ac73 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when a resource does not exist. + /// + public sealed class ResourceNotFoundException : JsonApiException + { + public ResourceNotFoundException(string resourceId, string resourceType) : base(new Error(HttpStatusCode.NotFound) + { + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with id '{resourceId}' does not exist." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index af1bd79a..c4d210aa 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Extensions { @@ -83,6 +84,13 @@ public static IEnumerable GetEmptyCollection(this Type t) return list; } + public static string GetResourceStringId(TId id) where TResource : class, IIdentifiable + { + var tempResource = typeof(TResource).New(); + tempResource.Id = id; + return tempResource.StringId; + } + public static object New(this Type t) { return New(t); diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 1cecd3a1..92962c86 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -233,7 +233,7 @@ private ResourceContext GetCurrentEntity() } if (_routeValues.TryGetValue("relationshipName", out object relationshipName)) { - _currentRequest.RequestRelationship = requestResource.Relationships.Single(r => r.PublicRelationshipName == (string)relationshipName); + _currentRequest.RequestRelationship = requestResource.Relationships.SingleOrDefault(r => r.PublicRelationshipName == (string)relationshipName); } return requestResource; } diff --git a/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs b/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs index 52e4ca17..8ee8c11b 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs @@ -10,6 +10,6 @@ public interface IDeleteService : IDeleteService public interface IDeleteService where T : class, IIdentifiable { - Task DeleteAsync(TId id); + Task DeleteAsync(TId id); } } diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index ecdd0a57..31b2e7da 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; @@ -74,16 +73,22 @@ public virtual async Task CreateAsync(TResource entity) return entity; } - public virtual async Task DeleteAsync(TId id) + public virtual async Task DeleteAsync(TId id) { _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id}')."); var entity = typeof(TResource).New(); entity.Id = id; if (!IsNull(_hookExecutor, entity)) _hookExecutor.BeforeDelete(AsList(entity), ResourcePipeline.Delete); + var succeeded = await _repository.DeleteAsync(entity.Id); + if (!succeeded) + { + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); + } + if (!IsNull(_hookExecutor, entity)) _hookExecutor.AfterDelete(AsList(entity), ResourcePipeline.Delete, succeeded); - return succeeded; } public virtual async Task> GetAsync() @@ -125,11 +130,18 @@ public virtual async Task GetAsync(TId id) entityQuery = ApplySelect(entityQuery); var entity = await _repository.FirstOrDefaultAsync(entityQuery); + if (entity == null) + { + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); + } + if (!IsNull(_hookExecutor, entity)) { _hookExecutor.AfterRead(AsList(entity), pipeline); entity = _hookExecutor.OnReturn(AsList(entity), pipeline).SingleOrDefault(); } + return entity; } @@ -143,16 +155,13 @@ public virtual async Task GetRelationshipsAsync(TId id, string relati // BeforeRead hook execution _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - // TODO: it would be better if we could distinguish whether or not the relationship was not found, - // vs the relationship not being set on the instance of T - var entityQuery = ApplyInclude(_repository.Get(id), chainPrefix: new List { relationship }); var entity = await _repository.FirstOrDefaultAsync(entityQuery); + if (entity == null) { - // TODO: this does not make sense. If the **parent** entity is not found, this error is thrown? - // this error should be thrown when the relationship is not found. - throw new JsonApiException(HttpStatusCode.NotFound, $"Relationship '{relationshipName}' not found."); + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } if (!IsNull(_hookExecutor, entity)) @@ -180,6 +189,13 @@ public virtual async Task UpdateAsync(TId id, TResource entity) entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.Patch).SingleOrDefault(); entity = await _repository.UpdateAsync(entity); + + if (entity == null) + { + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); + } + if (!IsNull(_hookExecutor, entity)) { _hookExecutor.AfterUpdate(AsList(entity), ResourcePipeline.Patch); @@ -196,8 +212,12 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa var relationship = GetRelationship(relationshipName); var entityQuery = _repository.Include(_repository.Get(id), new[] { relationship }); var entity = await _repository.FirstOrDefaultAsync(entityQuery); + if (entity == null) - throw new JsonApiException(HttpStatusCode.NotFound, $"Resource with id {id} could not be found."); + { + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); + } entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.PatchRelationship).SingleOrDefault(); @@ -349,9 +369,11 @@ private bool IsNull(params object[] values) private RelationshipAttribute GetRelationship(string relationshipName) { - var relationship = _currentRequestResource.Relationships.Single(r => r.Is(relationshipName)); + var relationship = _currentRequestResource.Relationships.SingleOrDefault(r => r.Is(relationshipName)); if (relationship == null) - throw new JsonApiException(HttpStatusCode.UnprocessableEntity, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); + { + throw new RelationshipNotFoundException(relationshipName, _currentRequestResource.ResourceName); + } return relationship; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index 6b385645..d31002ec 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -1,7 +1,10 @@ +using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -137,12 +140,27 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with { // Arrange var builder = new WebHostBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); var route = "/custom/route/todoItems/99999999"; + var requestBody = new + { + data = new + { + type = "todoItems", + id = "99999999", + attributes = new Dictionary + { + ["ordinal"] = 1 + } + } + }; + + var content = JsonConvert.SerializeObject(requestBody); + var server = new TestServer(builder); var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent(content)}; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); // Act var response = await client.SendAsync(request); @@ -150,8 +168,8 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + var responseBody = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(responseBody); Assert.Single(errorDocument.Errors); Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.4", errorDocument.Errors[0].Links.About); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index a583dabb..84548b8b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -74,10 +74,16 @@ public async Task ClientGeneratedId_IntegerIdAndNotEnabled_IsForbidden() todoItem.Id = clientDefinedId; // Act - var (_, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); + var (body, response) = await Post("/api/v1/todoItems", serializer.Serialize(todoItem)); // Assert AssertEqualStatusCode(HttpStatusCode.Forbidden, response); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("Specifying the resource id in POST requests is not allowed.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index bc5a2d49..278ce46c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -26,8 +26,8 @@ public DeletingDataTests(TestFixture fixture) public async Task Respond_404_If_EntityDoesNotExist() { // Arrange - var lastTodo = _context.TodoItems.AsEnumerable().LastOrDefault(); - var lastTodoId = lastTodo?.Id ?? 0; + _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.SaveChangesAsync(); var builder = new WebHostBuilder() .UseStartup(); @@ -36,7 +36,7 @@ public async Task Respond_404_If_EntityDoesNotExist() var client = server.CreateClient(); var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/todoItems/{lastTodoId + 100}"; + var route = "/api/v1/todoItems/123"; var request = new HttpRequestMessage(httpMethod, route); // Act @@ -49,8 +49,8 @@ public async Task Respond_404_If_EntityDoesNotExist() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '123' does not exist.",errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index 25bf4e9f..13ef6a4f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -154,8 +154,8 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '123' does not exist.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index 064ba24d..55552a69 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -1,7 +1,9 @@ +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -29,61 +31,233 @@ public FetchingRelationshipsTests(TestFixture fixture) } [Fact] - public async Task Request_UnsetRelationship_Returns_Null_DataObject() + public async Task When_getting_related_missing_to_one_resource_it_should_succeed_with_null_data() { // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = null; + var context = _fixture.GetService(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = $"/api/v1/todoItems/{todoItem.Id}/owner"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var doc = JsonConvert.DeserializeObject(body); + Assert.False(doc.IsManyData); + Assert.Null(doc.Data); + + Assert.Equal("{\"meta\":{\"copyright\":\"Copyright 2015 Example Corp.\",\"authors\":[\"Jared Nance\",\"Maurits Moeys\",\"Harro van der Kroft\"]},\"links\":{\"self\":\"http://localhost" + route + "\"},\"data\":null}", body); + } + + [Fact] + public async Task When_getting_relationship_for_missing_to_one_resource_it_should_succeed_with_null_data() + { + // Arrange var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = null; + + var context = _fixture.GetService(); context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() - .UseStartup(); + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}/owner"; + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var expectedBody = "{\"meta\":{\"copyright\":\"Copyright 2015 Example Corp.\",\"authors\":[\"Jared Nance\",\"Maurits Moeys\",\"Harro van der Kroft\"]},\"links\":{\"self\":\"http://localhost" + route + "\"},\"data\":null}"; + var request = new HttpRequestMessage(HttpMethod.Get, route); // Act var response = await client.SendAsync(request); + + // Assert var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var doc = JsonConvert.DeserializeObject(body); + Assert.False(doc.IsManyData); + Assert.Null(doc.Data); + } + + [Fact] + public async Task When_getting_related_missing_to_many_resource_it_should_succeed_with_null_data() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.ChildrenTodos = new List(); + + var context = _fixture.GetService(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = $"/api/v1/todoItems/{todoItem.Id}/childrenTodos"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); - Assert.Equal(expectedBody, body); - context.Dispose(); + var doc = JsonConvert.DeserializeObject(body); + Assert.True(doc.IsManyData); + Assert.Empty(doc.ManyData); } [Fact] - public async Task Request_ForRelationshipLink_ThatDoesNotExist_Returns_404() + public async Task When_getting_relationship_for_missing_to_many_resource_it_should_succeed_with_null_data() { // Arrange - var builder = new WebHostBuilder() - .UseStartup(); + var todoItem = _todoItemFaker.Generate(); + todoItem.ChildrenTodos = new List(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems/99998888/owner"; + var context = _fixture.GetService(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/childrenTodos"; + + var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + var request = new HttpRequestMessage(HttpMethod.Get, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var doc = JsonConvert.DeserializeObject(body); + Assert.True(doc.IsManyData); + Assert.Empty(doc.ManyData); + } + + [Fact] + public async Task When_getting_related_for_missing_parent_resource_it_should_fail() + { + // Arrange + var route = "/api/v1/todoItems/99999999/owner"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task When_getting_relationship_for_missing_parent_resource_it_should_fail() + { + // Arrange + var route = "/api/v1/todoItems/99999999/relationships/owner"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task When_getting_unknown_related_resource_it_should_fail() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + + var context = _fixture.GetService(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = $"/api/v1/todoItems/{todoItem.Id}/invalid"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task When_getting_unknown_relationship_for_resource_it_should_fail() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + + var context = _fixture.GetService(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; + + var builder = new WebHostBuilder().UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, route); + + // Act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("Relationship 'owner' not found.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); + Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index fa83bbfe..661fc9c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -113,9 +113,11 @@ public async Task Response422IfUpdatingNotSettableAttribute() public async Task Respond_404_If_EntityDoesNotExist() { // Arrange - var maxPersonId = _context.TodoItems.ToList().LastOrDefault()?.Id ?? 0; + _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.SaveChangesAsync(); + var todoItem = _todoItemFaker.Generate(); - todoItem.Id = maxPersonId + 100; + todoItem.Id = 100; todoItem.CreatedDate = DateTime.Now; var builder = new WebHostBuilder() .UseStartup(); @@ -125,7 +127,7 @@ public async Task Respond_404_If_EntityDoesNotExist() var serializer = _fixture.GetSerializer(ti => new { ti.Description, ti.Ordinal, ti.CreatedDate }); var content = serializer.Serialize(todoItem); - var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{maxPersonId + 100}", content); + var request = PrepareRequest("PATCH", $"/api/v1/todoItems/{todoItem.Id}", content); // Act var response = await client.SendAsync(request); @@ -137,8 +139,8 @@ public async Task Respond_404_If_EntityDoesNotExist() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '100' does not exist.", errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index d2c0a46b..945df5b8 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -770,5 +771,82 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem1Id)); Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem2Id)); } + + [Fact] + public async Task Fails_On_Unknown_Relationship() + { + // Arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(person); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Fails_On_Missing_Resource() + { + // Arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(person); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/99999999/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + } } } diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index 7b625065..56a1bf44 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -38,9 +38,9 @@ public void Errors_Correctly_Infers_Status_Code() }; // Act - var result422 = Errors(errors422); - var result400 = Errors(errors400); - var result500 = Errors(errors500); + var result422 = Error(errors422); + var result400 = Error(errors400); + var result500 = Error(errors500); // Assert var response422 = Assert.IsType(result422); diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index d42be16d..0791f8cb 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -160,7 +160,7 @@ public class GuidResource : Identifiable { } private class IntResourceService : IResourceService { public Task CreateAsync(IntResource entity) => throw new NotImplementedException(); - public Task DeleteAsync(int id) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); @@ -172,7 +172,7 @@ private class IntResourceService : IResourceService private class GuidResourceService : IResourceService { public Task CreateAsync(GuidResource entity) => throw new NotImplementedException(); - public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); From c7da9daedb35d058563e16b6852d67efedcaa088 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 13:57:07 +0200 Subject: [PATCH 53/60] Removed unused usings --- .../JsonApiDotNetCoreExample/Resources/PassportResource.cs | 2 -- .../JsonApiDotNetCoreExample/Resources/TodoResource.cs | 2 -- .../Controllers/HttpMethodRestrictionFilter.cs | 2 -- src/JsonApiDotNetCore/Extensions/TypeExtensions.cs | 2 -- src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs | 1 - src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs | 1 - .../QueryParameterServices/Common/QueryParameterService.cs | 1 - .../QueryParameterServices/IncludeService.cs | 2 -- .../QueryParameterServices/OmitDefaultService.cs | 2 -- .../QueryParameterServices/OmitNullService.cs | 2 -- src/JsonApiDotNetCore/QueryParameterServices/PageService.cs | 3 --- src/JsonApiDotNetCore/QueryParameterServices/SortService.cs | 3 --- .../Serialization/Common/BaseDocumentParser.cs | 1 - src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs | 3 --- .../Acceptance/Extensibility/CustomErrorHandlingTests.cs | 1 - .../Acceptance/Spec/DeletingDataTests.cs | 1 - .../Acceptance/Spec/DocumentTests/Relationships.cs | 1 - test/UnitTests/Controllers/BaseJsonApiController_Tests.cs | 2 -- test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs | 1 - test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs | 2 -- test/UnitTests/Models/ConstructionTests.cs | 4 ---- test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs | 1 - test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs | 1 - .../UnitTests/Serialization/Server/ResponseSerializerTests.cs | 1 - 24 files changed, 42 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs index 3eb4537a..30fe8d0d 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs index 30f0d900..a741d60e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; diff --git a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs index e280b3bf..7f797419 100644 --- a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs +++ b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs @@ -1,9 +1,7 @@ using System.Linq; -using System.Net; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Controllers diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index c4d210aa..3fbdc45b 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -3,8 +3,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Net; -using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Extensions diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 92962c86..6c02396a 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -4,7 +4,6 @@ using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 388c31d3..628b5f73 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Net; using System.Net.Http; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index e38f0e35..a2f1bb64 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -1,5 +1,4 @@ using System.Linq; -using System.Net; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs index e33c1c3e..a2f54d55 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -1,9 +1,7 @@ using System.Collections.Generic; using System.Linq; -using System.Net; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs index c7f6a917..80d26127 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs @@ -1,8 +1,6 @@ -using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs index d5b2e520..3fb26dec 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs @@ -1,8 +1,6 @@ -using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index 24c50193..ad3d6254 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -1,10 +1,7 @@ using System; -using System.Collections.Generic; -using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index 99117a92..54e0d555 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -1,10 +1,7 @@ -using System; using System.Collections.Generic; using System.Linq; -using System.Net; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 9cd0af33..08318caf 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; using System.Reflection; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Extensions; diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index 5c005834..65ea08df 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -1,8 +1,5 @@ -using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Http; using System; -using System.Net; -using JsonApiDotNetCore.Exceptions; namespace JsonApiDotNetCore.Services { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs index 25716f3f..cedd7d61 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Exceptions; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index 278ce46c..f78c7d4f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index 71b7cf03..782a42eb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -8,7 +8,6 @@ using Xunit; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; -using System.Linq; using Bogus; using JsonApiDotNetCoreExample.Models; using Person = JsonApiDotNetCoreExample.Models.Person; diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 97235552..c61dfddb 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -8,8 +8,6 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index 56a1bf44..46d5ecf7 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; using Xunit; diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index dec6e76c..73c36821 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -10,9 +10,7 @@ using System; using System.IO; using System.Linq; -using System.Net; using System.Threading.Tasks; -using JsonApiDotNetCore.Exceptions; using Xunit; namespace UnitTests.Middleware diff --git a/test/UnitTests/Models/ConstructionTests.cs b/test/UnitTests/Models/ConstructionTests.cs index 7bbcac7b..401472e4 100644 --- a/test/UnitTests/Models/ConstructionTests.cs +++ b/test/UnitTests/Models/ConstructionTests.cs @@ -1,9 +1,5 @@ using System; -using System.Collections.Generic; -using System.Net; -using System.Text; using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; diff --git a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs index 55c01b54..75ee3517 100644 --- a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs +++ b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Configuration; diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 0b3751e0..c200a2dd 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -13,7 +13,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Graph; using Person = JsonApiDotNetCoreExample.Models.Person; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Serialization; diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 90c83e99..1e83595d 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; From 20ba96dc3c51234577a0f5375148408262d050dd Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 15:00:08 +0200 Subject: [PATCH 54/60] Various small fixes --- .../Services/CustomArticleService.cs | 7 +----- .../Exceptions/InvalidRequestBodyException.cs | 24 ++++++++++++++----- .../Formatters/JsonApiReader.cs | 3 ++- .../Services/DefaultResourceService.cs | 2 +- .../Acceptance/Spec/CreatingDataTests.cs | 1 + 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index cee29e04..e28b7659 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -26,12 +26,7 @@ public CustomArticleService( public override async Task
GetAsync(int id) { var newEntity = await base.GetAsync(id); - - if (newEntity != null) - { - newEntity.Name = "None for you Glen Coco"; - } - + newEntity.Name = "None for you Glen Coco"; return newEntity; } } diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs index 35aa02fa..5f976001 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs @@ -9,32 +9,44 @@ namespace JsonApiDotNetCore.Exceptions /// public sealed class InvalidRequestBodyException : JsonApiException { + private readonly string _details; + private string _requestBody; + public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) : base(new Error(HttpStatusCode.UnprocessableEntity) { Title = reason != null ? "Failed to deserialize request body: " + reason : "Failed to deserialize request body.", - Detail = FormatDetails(details, requestBody, innerException) }, innerException) { + _details = details; + _requestBody = requestBody; + + UpdateErrorDetail(); } - private static string FormatDetails(string details, string requestBody, Exception innerException) + private void UpdateErrorDetail() { - string text = details ?? innerException?.Message; + string text = _details ?? InnerException?.Message; - if (requestBody != null) + if (_requestBody != null) { if (text != null) { text += " - "; } - text += "Request body: <<" + requestBody + ">>"; + text += "Request body: <<" + _requestBody + ">>"; } - return text; + Error.Detail = text; + } + + public void SetRequestBody(string requestBody) + { + _requestBody = requestBody; + UpdateErrorDetail(); } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 5e4221ba..6e0241b2 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -45,8 +45,9 @@ public async Task ReadAsync(InputFormatterContext context) { model = _deserializer.Deserialize(body); } - catch (InvalidRequestBodyException) + catch (InvalidRequestBodyException exception) { + exception.SetRequestBody(body); throw; } catch (Exception exception) diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index 31b2e7da..7466b4c4 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -236,7 +236,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa protected virtual async Task> ApplyPageQueryAsync(IQueryable entities) { - if (_pageService.PageSize <= 0) + if (_pageService.PageSize == 0) { _logger.LogDebug("Fetching complete result set."); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index 84548b8b..d9d4f0cb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -266,6 +266,7 @@ public async Task CreateResource_UnknownEntityType_Fails() Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); Assert.Equal("Failed to deserialize request body: Payload includes unknown resource type.", errorDocument.Errors[0].Title); Assert.StartsWith("The resource 'something' is not registered on the resource graph.", errorDocument.Errors[0].Detail); + Assert.Contains("Request body: <<", errorDocument.Errors[0].Detail); } [Fact] From 28d59c188719bdb7b83aa0953bf521426edbec1d Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 15:03:58 +0200 Subject: [PATCH 55/60] Fixed: update file name to match with class --- ...amelCasedModelsController.cs => KebabCasedModelsController.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Examples/JsonApiDotNetCoreExample/Controllers/{CamelCasedModelsController.cs => KebabCasedModelsController.cs} (100%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs similarity index 100% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs rename to src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs From fd567fda0b3d79080e5e3ed3237969a94b1f259c Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Tue, 7 Apr 2020 15:37:41 +0200 Subject: [PATCH 56/60] Added tests for using ActionResult without [ApiController] --- .../Controllers/TodoItemsTestController.cs | 50 +++++++++++- .../Acceptance/ActionResultTests.cs | 80 +++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index 379b1dd2..f7085a18 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -1,6 +1,9 @@ +using System.Net; +using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -9,7 +12,7 @@ namespace JsonApiDotNetCoreExample.Controllers { public abstract class AbstractTodoItemsController - : JsonApiController where T : class, IIdentifiable + : BaseJsonApiController where T : class, IIdentifiable { protected AbstractTodoItemsController( IJsonApiOptions jsonApiOptions, @@ -19,6 +22,7 @@ protected AbstractTodoItemsController( { } } + [DisableRoutingConvention] [Route("/abstract")] public class TodoItemsTestController : AbstractTodoItemsController { @@ -28,5 +32,49 @@ public TodoItemsTestController( IResourceService service) : base(jsonApiOptions, loggerFactory, service) { } + + [HttpGet] + public override async Task GetAsync() => await base.GetAsync(); + + [HttpGet("{id}")] + public override async Task GetAsync(int id) => await base.GetAsync(id); + + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipsAsync(int id, string relationshipName) + => await base.GetRelationshipsAsync(id, relationshipName); + + [HttpGet("{id}/{relationshipName}")] + public override async Task GetRelationshipAsync(int id, string relationshipName) + => await base.GetRelationshipAsync(id, relationshipName); + + [HttpPost] + public override async Task PostAsync(TodoItem entity) + { + await Task.Yield(); + + return NotFound(new Error(HttpStatusCode.NotFound) + { + Title = "NotFound ActionResult with explicit error object." + }); + } + + [HttpPatch("{id}")] + public override async Task PatchAsync(int id, [FromBody] TodoItem entity) + { + return await base.PatchAsync(id, entity); + } + + [HttpPatch("{id}/relationships/{relationshipName}")] + public override async Task PatchRelationshipsAsync( + int id, string relationshipName, [FromBody] object relationships) + => await base.PatchRelationshipsAsync(id, relationshipName, relationships); + + [HttpDelete("{id}")] + public override async Task DeleteAsync(int id) + { + await Task.Yield(); + + return NotFound(); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs new file mode 100644 index 00000000..3ed690b9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + [Collection("WebHostCollection")] + public sealed class ActionResultTests + { + private readonly TestFixture _fixture; + + public ActionResultTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task ActionResult_With_Error_Object_Is_Converted_To_Error_Collection() + { + // Arrange + var route = "/abstract"; + var request = new HttpRequestMessage(HttpMethod.Post, route); + var content = new + { + data = new + { + type = "todoItems", + id = 1, + attributes = new Dictionary + { + {"ordinal", 1} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("NotFound ActionResult with explicit error object.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Empty_ActionResult_Is_Converted_To_Error_Collection() + { + // Arrange + var route = "/abstract/123"; + var request = new HttpRequestMessage(HttpMethod.Delete, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("NotFound", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); + } + } +} From 051e3d897d7443cae8441a64c75715ef220db850 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 8 Apr 2020 12:09:19 +0200 Subject: [PATCH 57/60] Fixed: De-duplicate field names in sparse fieldset; do not ignore case Inlined TypeHelper.ConvertCollection --- .../Extensions/SystemCollectionExtensions.cs | 31 ++++++++++++ .../Extensions/TypeExtensions.cs | 49 ++----------------- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 7 --- .../Models/Annotation/AttrAttribute.cs | 3 +- .../Annotation/RelationshipAttribute.cs | 5 +- .../SparseFieldsService.cs | 7 ++- .../Common/BaseDocumentParser.cs | 2 +- .../SparseFieldsServiceTests.cs | 4 +- 8 files changed, 46 insertions(+), 62 deletions(-) create mode 100644 src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs diff --git a/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs new file mode 100644 index 00000000..04eb2bd8 --- /dev/null +++ b/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Extensions +{ + internal static class SystemCollectionExtensions + { + public static void AddRange(this ICollection source, IEnumerable items) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (items == null) throw new ArgumentNullException(nameof(items)); + + foreach (var item in items) + { + source.Add(item); + } + } + + public static void AddRange(this IList source, IEnumerable items) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (items == null) throw new ArgumentNullException(nameof(items)); + + foreach (var item in items) + { + source.Add(item); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 3fbdc45b..82fc618a 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -9,28 +9,6 @@ namespace JsonApiDotNetCore.Extensions { internal static class TypeExtensions { - - /// - /// Extension to use the LINQ AddRange method on an IList - /// - public static void AddRange(this IList list, IEnumerable items) - { - if (list == null) throw new ArgumentNullException(nameof(list)); - if (items == null) throw new ArgumentNullException(nameof(items)); - - if (list is List genericList) - { - genericList.AddRange(items); - } - else - { - foreach (var item in items) - { - list.Add(item); - } - } - } - /// /// Extension to use the LINQ cast method in a non-generic way: /// @@ -42,32 +20,13 @@ public static IEnumerable Cast(this IEnumerable source, Type type) { if (source == null) throw new ArgumentNullException(nameof(source)); if (type == null) throw new ArgumentNullException(nameof(type)); - return TypeHelper.ConvertCollection(source.Cast(), type); - } - - public static Type GetElementType(this IEnumerable enumerable) - { - var enumerableTypes = enumerable.GetType() - .GetInterfaces() - .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - .ToList(); - - var numberOfEnumerableTypes = enumerableTypes.Count; - - if (numberOfEnumerableTypes == 0) - { - throw new ArgumentException($"{nameof(enumerable)} of type {enumerable.GetType().FullName} does not implement a generic variant of {nameof(IEnumerable)}"); - } - if (numberOfEnumerableTypes > 1) + var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(type)); + foreach (var item in source.Cast()) { - throw new ArgumentException($"{nameof(enumerable)} of type {enumerable.GetType().FullName} implements more than one generic variant of {nameof(IEnumerable)}:\n" + - $"{string.Join("\n", enumerableTypes.Select(t => t.FullName))}"); + list.Add(TypeHelper.ConvertType(item, type)); } - - var elementType = enumerableTypes[0].GenericTypeArguments[0]; - - return elementType; + return list; } /// diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index cfb3094c..d68bcde7 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -11,13 +11,6 @@ namespace JsonApiDotNetCore.Internal { internal static class TypeHelper { - public static IList ConvertCollection(IEnumerable collection, Type targetType) - { - var list = Activator.CreateInstance(typeof(List<>).MakeGenericType(targetType)) as IList; - foreach (var item in collection) - list.Add(ConvertType(item, targetType)); - return list; - } private static bool IsNullable(Type type) { return (!type.IsValueType || Nullable.GetUnderlyingType(type) != null); diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index 88446416..a3992222 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -124,7 +124,6 @@ private PropertyInfo GetResourceProperty(object resource) /// /// Whether or not the provided exposed name is equivalent to the one defined in on the model /// - public bool Is(string publicRelationshipName) - => string.Equals(publicRelationshipName, PublicAttributeName, StringComparison.OrdinalIgnoreCase); + public bool Is(string publicRelationshipName) => publicRelationshipName == PublicAttributeName; } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs index a5b61cce..83a3eb3f 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs @@ -77,10 +77,9 @@ public override int GetHashCode() } /// - /// Whether or not the provided exposed name is equivalent to the one defined in on the model + /// Whether or not the provided exposed name is equivalent to the one defined in the model /// - public virtual bool Is(string publicRelationshipName) - => string.Equals(publicRelationshipName, PublicRelationshipName, StringComparison.OrdinalIgnoreCase); + public virtual bool Is(string publicRelationshipName) => publicRelationshipName == PublicRelationshipName; /// /// The internal navigation property path to the related entity. diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index 04b41a21..f88e3c1e 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -2,6 +2,7 @@ using System.Linq; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; @@ -58,8 +59,10 @@ public virtual void Parse(string parameterName, StringValues parameterValue) // articles?fields[articles]=prop1,prop2 <-- this form in invalid UNLESS "articles" is actually a relationship on Article // articles?fields[relationship]=prop1,prop2 EnsureNoNestedResourceRoute(parameterName); - var fields = new List { nameof(Identifiable.Id) }; - fields.AddRange(((string)parameterValue).Split(QueryConstants.COMMA)); + + HashSet fields = new HashSet(); + fields.Add(nameof(Identifiable.Id).ToLowerInvariant()); + fields.AddRange(((string) parameterValue).Split(QueryConstants.COMMA)); var keySplit = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET); diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 08318caf..fbabd46d 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -234,7 +234,7 @@ private void SetHasManyRelationship( relatedInstance.StringId = rio.Id; return relatedInstance; }); - var convertedCollection = TypeHelper.ConvertCollection(relatedResources, attr.RightType); + var convertedCollection = relatedResources.Cast(attr.RightType); attr.SetValue(entity, convertedCollection); } diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 02dc3dfe..b33663da 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -68,8 +68,8 @@ public void Parse_ValidSelection_CanParse() // Assert Assert.NotEmpty(result); - Assert.Equal(idAttribute, result.First()); - Assert.Equal(attribute, result[1]); + Assert.Contains(idAttribute, result); + Assert.Contains(attribute, result); } [Fact] From 630a6d32d703d700abb73ae37664843ce9f22767 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 8 Apr 2020 13:20:19 +0200 Subject: [PATCH 58/60] Removed more case-insensitive string comparisons. They are tricky, because they hide potential usages of property names instead of resource names, which only works with the default camel-case convention but breaks on kebab casing. --- .../Formatters/JsonApiInputFormatter.cs | 2 +- .../Formatters/JsonApiOutputFormatter.cs | 2 +- .../Formatters/JsonApiWriter.cs | 2 +- .../Contracts/IResourceContextProvider.cs | 2 +- .../{Constants.cs => HeaderConstants.cs} | 2 +- src/JsonApiDotNetCore/Internal/ResourceGraph.cs | 12 ++++++------ .../Middleware/CurrentRequestMiddleware.cs | 16 ++++++++-------- .../Middleware/DefaultTypeMatchFilter.cs | 2 +- .../Models/Annotation/HasManyThroughAttribute.cs | 4 ++-- .../Common/QueryParameterParser.cs | 2 +- .../QueryParameterServices/FilterService.cs | 3 +-- .../Extensibility/CustomControllerTests.cs | 2 +- .../Acceptance/ModelStateValidationTests.cs | 8 ++++---- 13 files changed, 29 insertions(+), 30 deletions(-) rename src/JsonApiDotNetCore/Internal/{Constants.cs => HeaderConstants.cs} (81%) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index 017c5751..681b22db 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -15,7 +15,7 @@ public bool CanRead(InputFormatterContext context) var contentTypeString = context.HttpContext.Request.ContentType; - return contentTypeString == Constants.ContentType; + return contentTypeString == HeaderConstants.ContentType; } public async Task ReadAsync(InputFormatterContext context) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index aed26685..8638201f 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -15,7 +15,7 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) var contentTypeString = context.HttpContext.Request.ContentType; - return string.IsNullOrEmpty(contentTypeString) || contentTypeString == Constants.ContentType; + return string.IsNullOrEmpty(contentTypeString) || contentTypeString == HeaderConstants.ContentType; } public async Task WriteAsync(OutputFormatterWriteContext context) { diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 5c30793d..5685d87e 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -50,7 +50,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) } else { - response.ContentType = Constants.ContentType; + response.ContentType = HeaderConstants.ContentType; try { responseContent = SerializeResponse(context.Object, (HttpStatusCode)response.StatusCode); diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs index c975211b..f1ecbe90 100644 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs +++ b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs @@ -16,7 +16,7 @@ public interface IResourceContextProvider /// /// Get the resource metadata by the DbSet property name /// - ResourceContext GetResourceContext(string exposedResourceName); + ResourceContext GetResourceContext(string resourceName); /// /// Get the resource metadata by the resource type diff --git a/src/JsonApiDotNetCore/Internal/Constants.cs b/src/JsonApiDotNetCore/Internal/HeaderConstants.cs similarity index 81% rename from src/JsonApiDotNetCore/Internal/Constants.cs rename to src/JsonApiDotNetCore/Internal/HeaderConstants.cs index 750d94ba..b3086b09 100644 --- a/src/JsonApiDotNetCore/Internal/Constants.cs +++ b/src/JsonApiDotNetCore/Internal/HeaderConstants.cs @@ -1,6 +1,6 @@ namespace JsonApiDotNetCore.Internal { - public static class Constants + public static class HeaderConstants { public const string AcceptHeader = "Accept"; public const string ContentType = "application/vnd.api+json"; diff --git a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs index b9f89420..4a5e1f4f 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs @@ -15,20 +15,20 @@ public class ResourceGraph : IResourceGraph internal List ValidationResults { get; } private List Resources { get; } - public ResourceGraph(List entities, List validationResults = null) + public ResourceGraph(List resources, List validationResults = null) { - Resources = entities; + Resources = resources; ValidationResults = validationResults; } /// public ResourceContext[] GetResourceContexts() => Resources.ToArray(); /// - public ResourceContext GetResourceContext(string entityName) - => Resources.SingleOrDefault(e => string.Equals(e.ResourceName, entityName, StringComparison.OrdinalIgnoreCase)); + public ResourceContext GetResourceContext(string resourceName) + => Resources.SingleOrDefault(e => e.ResourceName == resourceName); /// - public ResourceContext GetResourceContext(Type entityType) - => Resources.SingleOrDefault(e => e.ResourceType == entityType); + public ResourceContext GetResourceContext(Type resourceType) + => Resources.SingleOrDefault(e => e.ResourceType == resourceType); /// public ResourceContext GetResourceContext() where TResource : class, IIdentifiable => GetResourceContext(typeof(TResource)); diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 6c02396a..6f296135 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -149,7 +149,7 @@ private static async Task IsValidContentTypeHeaderAsync(HttpContext contex await FlushResponseAsync(context, new Error(HttpStatusCode.UnsupportedMediaType) { Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify '{Constants.ContentType}' for the Content-Type header value." + Detail = $"Please specify '{HeaderConstants.ContentType}' for the Content-Type header value." }); return false; @@ -159,7 +159,7 @@ private static async Task IsValidContentTypeHeaderAsync(HttpContext contex private static async Task IsValidAcceptHeaderAsync(HttpContext context) { - if (context.Request.Headers.TryGetValue(Constants.AcceptHeader, out StringValues acceptHeaders) == false) + if (context.Request.Headers.TryGetValue(HeaderConstants.AcceptHeader, out StringValues acceptHeaders) == false) return true; foreach (var acceptHeader in acceptHeaders) @@ -172,7 +172,7 @@ private static async Task IsValidAcceptHeaderAsync(HttpContext context) await FlushResponseAsync(context, new Error(HttpStatusCode.NotAcceptable) { Title = "The specified Accept header value is not supported.", - Detail = $"Please specify '{Constants.ContentType}' for the Accept header value." + Detail = $"Please specify '{HeaderConstants.ContentType}' for the Accept header value." }); return false; } @@ -184,19 +184,19 @@ private static bool ContainsMediaTypeParameters(string mediaType) var incomingMediaTypeSpan = mediaType.AsSpan(); // if the content type is not application/vnd.api+json then continue on - if (incomingMediaTypeSpan.Length < Constants.ContentType.Length) + if (incomingMediaTypeSpan.Length < HeaderConstants.ContentType.Length) { return false; } - var incomingContentType = incomingMediaTypeSpan.Slice(0, Constants.ContentType.Length); - if (incomingContentType.SequenceEqual(Constants.ContentType.AsSpan()) == false) + var incomingContentType = incomingMediaTypeSpan.Slice(0, HeaderConstants.ContentType.Length); + if (incomingContentType.SequenceEqual(HeaderConstants.ContentType.AsSpan()) == false) return false; // anything appended to "application/vnd.api+json;" will be considered a media type param return ( - incomingMediaTypeSpan.Length >= Constants.ContentType.Length + 2 - && incomingMediaTypeSpan[Constants.ContentType.Length] == ';' + incomingMediaTypeSpan.Length >= HeaderConstants.ContentType.Length + 2 + && incomingMediaTypeSpan[HeaderConstants.ContentType.Length] == ';' ); } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 628b5f73..d16fd607 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -41,7 +41,7 @@ public void OnActionExecuting(ActionExecutingContext context) private bool IsJsonApiRequest(HttpRequest request) { - return (request.ContentType?.Equals(Constants.ContentType, StringComparison.OrdinalIgnoreCase) == true); + return request.ContentType == HeaderConstants.ContentType; } public void OnActionExecuted(ActionExecutedContext context) { /* noop */ } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index 0fb9889e..52cddf20 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -77,8 +77,8 @@ public HasManyThroughAttribute(string publicName, string internalThroughName, Li public override object GetValue(object entity) { var throughNavigationProperty = entity.GetType() - .GetProperties() - .SingleOrDefault(p => string.Equals(p.Name, InternalThroughName, StringComparison.OrdinalIgnoreCase)); + .GetProperties() + .SingleOrDefault(p => p.Name == InternalThroughName); var throughEntities = throughNavigationProperty.GetValue(entity); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index bb0004a5..572e425b 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -33,7 +33,7 @@ public virtual void Parse(DisableQueryAttribute disableQueryAttribute) foreach (var pair in _queryStringAccessor.Query) { - if (string.IsNullOrWhiteSpace(pair.Value)) + if (string.IsNullOrEmpty(pair.Value)) { throw new InvalidQueryStringParameterException(pair.Key, "Missing query string parameter value.", $"Missing value for '{pair.Key}' query string parameter."); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index 77a356f9..94c0a9fe 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -83,8 +83,7 @@ private List GetFilterQueries(string parameterName, StringValues pa var queries = new List(); // InArray case string op = GetFilterOperation(parameterValue); - if (string.Equals(op, FilterOperation.@in.ToString(), StringComparison.OrdinalIgnoreCase) - || string.Equals(op, FilterOperation.nin.ToString(), StringComparison.OrdinalIgnoreCase)) + if (op == FilterOperation.@in.ToString() || op == FilterOperation.nin.ToString()) { var (_, filterValue) = ParseFilterOperation(parameterValue); queries.Add(new FilterQuery(propertyName, filterValue, op)); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index d31002ec..cb047ff9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -160,7 +160,7 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); // Act var response = await client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs index ba01e32f..5a13fa3e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -36,7 +36,7 @@ public async Task When_posting_tag_with_invalid_name_it_must_fail() { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = true; @@ -72,7 +72,7 @@ public async Task When_posting_tag_with_invalid_name_without_model_state_validat { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = false; @@ -110,7 +110,7 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = true; @@ -156,7 +156,7 @@ public async Task When_patching_tag_with_invalid_name_without_model_state_valida { Content = new StringContent(content) }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.ContentType); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); var options = (JsonApiOptions)_factory.GetService(); options.ValidateModelState = false; From 9d447b4df9ac7124fdadb59ef404ee66b836642a Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 8 Apr 2020 13:28:22 +0200 Subject: [PATCH 59/60] Inlined casing conversions --- .../Extensions/StringExtensions.cs | 65 ------------------- .../CamelCaseFormatter.cs | 5 +- .../KebabCaseFormatter.cs | 25 ++++++- .../Common/ResourceObjectBuilder.cs | 7 +- test/UnitTests/Builders/LinkBuilderTests.cs | 16 ++--- 5 files changed, 34 insertions(+), 84 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Extensions/StringExtensions.cs diff --git a/src/JsonApiDotNetCore/Extensions/StringExtensions.cs b/src/JsonApiDotNetCore/Extensions/StringExtensions.cs deleted file mode 100644 index 1b2bd76c..00000000 --- a/src/JsonApiDotNetCore/Extensions/StringExtensions.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Text; - -namespace JsonApiDotNetCore.Extensions -{ - public static class StringExtensions - { - public static string ToProperCase(this string str) - { - var chars = str.ToCharArray(); - if (chars.Length > 0) - { - chars[0] = char.ToUpper(chars[0]); - var builder = new StringBuilder(); - for (var i = 0; i < chars.Length; i++) - { - if ((chars[i]) == '-') - { - i++; - builder.Append(char.ToUpper(chars[i])); - } - else - { - builder.Append(chars[i]); - } - } - return builder.ToString(); - } - return str; - } - - public static string Dasherize(this string str) - { - var chars = str.ToCharArray(); - if (chars.Length > 0) - { - var builder = new StringBuilder(); - for (var i = 0; i < chars.Length; i++) - { - if (char.IsUpper(chars[i])) - { - var hashedString = (i > 0) ? $"-{char.ToLower(chars[i])}" : $"{char.ToLower(chars[i])}"; - builder.Append(hashedString); - } - else - { - builder.Append(chars[i]); - } - } - return builder.ToString(); - } - return str; - } - - public static string Camelize(this string str) - { - return char.ToLowerInvariant(str[0]) + str.Substring(1); - } - - public static string NullIfEmpty(this string value) - { - if (value == "") return null; - return value; - } - } -} diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs index 8ea50e5d..904dd07b 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs @@ -1,5 +1,3 @@ -using str = JsonApiDotNetCore.Extensions.StringExtensions; - namespace JsonApiDotNetCore.Graph { /// @@ -34,7 +32,6 @@ namespace JsonApiDotNetCore.Graph public sealed class CamelCaseFormatter: BaseResourceNameFormatter { /// - public override string ApplyCasingConvention(string properName) => str.Camelize(properName); + public override string ApplyCasingConvention(string properName) => char.ToLowerInvariant(properName[0]) + properName.Substring(1); } } - diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs index dbf7f1ab..b07c273f 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs @@ -1,4 +1,4 @@ -using str = JsonApiDotNetCore.Extensions.StringExtensions; +using System.Text; namespace JsonApiDotNetCore.Graph { @@ -34,6 +34,27 @@ namespace JsonApiDotNetCore.Graph public sealed class KebabCaseFormatter : BaseResourceNameFormatter { /// - public override string ApplyCasingConvention(string properName) => str.Dasherize(properName); + public override string ApplyCasingConvention(string properName) + { + var chars = properName.ToCharArray(); + if (chars.Length > 0) + { + var builder = new StringBuilder(); + for (var i = 0; i < chars.Length; i++) + { + if (char.IsUpper(chars[i])) + { + var hashedString = i > 0 ? $"-{char.ToLower(chars[i])}" : $"{char.ToLower(chars[i])}"; + builder.Append(hashedString); + } + else + { + builder.Append(chars[i]); + } + } + return builder.ToString(); + } + return properName; + } } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs index 061dd9e9..d4942d5c 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; @@ -28,7 +27,7 @@ public ResourceObject Build(IIdentifiable entity, IEnumerable att var resourceContext = _provider.GetResourceContext(entity.GetType()); // populating the top-level "type" and "id" members. - var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = entity.StringId.NullIfEmpty() }; + var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = entity.StringId == string.Empty ? null : entity.StringId }; // populating the top-level "attribute" member of a resource object. never include "id" as an attribute if (attributes != null && (attributes = attributes.Where(attr => attr.PropertyInfo.Name != _identifiablePropertyName)).Any()) diff --git a/test/UnitTests/Builders/LinkBuilderTests.cs b/test/UnitTests/Builders/LinkBuilderTests.cs index 63dda1b1..89df84eb 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCoreExample.Models; using Moq; @@ -50,7 +49,7 @@ public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Lin { // Arrange var config = GetConfiguration(resourceLinks: global); - var primaryResource = GetResourceContext
(resourceLinks: resource); + var primaryResource = GetArticleResourceContext(resourceLinks: resource); _provider.Setup(m => m.GetResourceContext("articles")).Returns(primaryResource); var builder = new LinkBuilder(config, GetRequestManager(), null, _provider.Object, _queryStringAccessor); @@ -98,7 +97,7 @@ public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLi { // Arrange var config = GetConfiguration(relationshipLinks: global); - var primaryResource = GetResourceContext
(relationshipLinks: resource); + var primaryResource = GetArticleResourceContext(relationshipLinks: resource); _provider.Setup(m => m.GetResourceContext(typeof(Article))).Returns(primaryResource); var builder = new LinkBuilder(config, GetRequestManager(), null, _provider.Object, _queryStringAccessor); var attr = new HasOneAttribute(links: relationship) { RightType = typeof(Author), PublicRelationshipName = "author" }; @@ -154,7 +153,7 @@ public void BuildTopLevelLinks_GlobalAndResourceConfiguration_ExpectedLinks(Link { // Arrange var config = GetConfiguration(topLevelLinks: global); - var primaryResource = GetResourceContext
(topLevelLinks: resource); + var primaryResource = GetArticleResourceContext(topLevelLinks: resource); _provider.Setup(m => m.GetResourceContext
()).Returns(primaryResource); bool useBaseId = expectedSelfLink != _topSelf; @@ -220,19 +219,18 @@ private IPageService GetPageManager() mock.Setup(m => m.TotalPages).Returns(3); mock.Setup(m => m.PageSize).Returns(10); return mock.Object; - } - private ResourceContext GetResourceContext(Link resourceLinks = Link.NotConfigured, - Link topLevelLinks = Link.NotConfigured, - Link relationshipLinks = Link.NotConfigured) where TResource : class, IIdentifiable + private ResourceContext GetArticleResourceContext(Link resourceLinks = Link.NotConfigured, + Link topLevelLinks = Link.NotConfigured, + Link relationshipLinks = Link.NotConfigured) { return new ResourceContext { ResourceLinks = resourceLinks, TopLevelLinks = topLevelLinks, RelationshipLinks = relationshipLinks, - ResourceName = typeof(TResource).Name.Dasherize() + "s" + ResourceName = "articles" }; } From affb075314510a7b0bbaeb02e6305347bb2ff227 Mon Sep 17 00:00:00 2001 From: Nicole Standifer Date: Wed, 8 Apr 2020 13:50:12 +0200 Subject: [PATCH 60/60] More casing-related updates --- .../CamelCaseFormatter.cs | 2 +- .../KebabCaseFormatter.cs | 31 ++++++++++++------- .../Middleware/CurrentRequestMiddleware.cs | 18 ++++------- .../Builders/ContextGraphBuilder_Tests.cs | 15 ++------- 4 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs index 904dd07b..20b955ce 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs @@ -32,6 +32,6 @@ namespace JsonApiDotNetCore.Graph public sealed class CamelCaseFormatter: BaseResourceNameFormatter { /// - public override string ApplyCasingConvention(string properName) => char.ToLowerInvariant(properName[0]) + properName.Substring(1); + public override string ApplyCasingConvention(string properName) => char.ToLower(properName[0]) + properName.Substring(1); } } diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs index b07c273f..62baa3de 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs @@ -36,25 +36,32 @@ public sealed class KebabCaseFormatter : BaseResourceNameFormatter /// public override string ApplyCasingConvention(string properName) { + if (properName.Length == 0) + { + return properName; + } + var chars = properName.ToCharArray(); - if (chars.Length > 0) + var builder = new StringBuilder(); + + for (var i = 0; i < chars.Length; i++) { - var builder = new StringBuilder(); - for (var i = 0; i < chars.Length; i++) + if (char.IsUpper(chars[i])) { - if (char.IsUpper(chars[i])) - { - var hashedString = i > 0 ? $"-{char.ToLower(chars[i])}" : $"{char.ToLower(chars[i])}"; - builder.Append(hashedString); - } - else + if (i > 0) { - builder.Append(chars[i]); + builder.Append('-'); } + + builder.Append(char.ToLower(chars[i])); + } + else + { + builder.Append(chars[i]); } - return builder.ToString(); } - return properName; + + return builder.ToString(); } } } diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 6f296135..ff19a00e 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -84,7 +84,7 @@ private string GetRelationshipId() private string[] SplitCurrentPath() { var path = _httpContext.Request.Path.Value; - var ns = $"/{GetNameSpace()}"; + var ns = $"/{_options.Namespace}"; var nonNameSpaced = path.Replace(ns, ""); nonNameSpaced = nonNameSpaced.Trim('/'); var individualComponents = nonNameSpaced.Split('/'); @@ -96,11 +96,11 @@ private string GetBasePath(string resourceName = null) var r = _httpContext.Request; if (_options.RelativeLinks) { - return GetNameSpace(); + return _options.Namespace; } - var ns = GetNameSpace(); + var customRoute = GetCustomRoute(r.Path.Value, resourceName); - var toReturn = $"{r.Scheme}://{r.Host}/{ns}"; + var toReturn = $"{r.Scheme}://{r.Host}/{_options.Namespace}"; if (customRoute != null) { toReturn += $"/{customRoute}"; @@ -110,12 +110,11 @@ private string GetBasePath(string resourceName = null) private object GetCustomRoute(string path, string resourceName) { - var ns = GetNameSpace(); var trimmedComponents = path.Trim('/').Split('/').ToList(); var resourceNameIndex = trimmedComponents.FindIndex(c => c == resourceName); var newComponents = trimmedComponents.Take(resourceNameIndex).ToArray(); var customRoute = string.Join('/', newComponents); - if (customRoute == ns) + if (customRoute == _options.Namespace) { return null; } @@ -125,15 +124,10 @@ private object GetCustomRoute(string path, string resourceName) } } - private string GetNameSpace() - { - return _options.Namespace; - } - private bool PathIsRelationship() { var actionName = (string)_routeValues["action"]; - return actionName.ToLower().Contains("relationships"); + return actionName.ToLowerInvariant().Contains("relationships"); } private async Task IsValidAsync() diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index ccb8eb2a..fc66d36f 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -63,7 +63,7 @@ public void Resources_Without_Names_Specified_Will_Use_Default_Formatter() public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new CamelCaseNameFormatter()); + var builder = new ResourceGraphBuilder(new CamelCaseFormatter()); builder.AddResource(); // Act @@ -93,7 +93,7 @@ public void Attrs_Without_Names_Specified_Will_Use_Default_Formatter() public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new CamelCaseNameFormatter()); + var builder = new ResourceGraphBuilder(new CamelCaseFormatter()); builder.AddResource(); // Act @@ -128,16 +128,5 @@ public sealed class TestResource : Identifiable } public class RelatedResource : Identifiable { } - - public sealed class CamelCaseNameFormatter : IResourceNameFormatter - { - public string ApplyCasingConvention(string properName) => ToCamelCase(properName); - - public string FormatPropertyName(PropertyInfo property) => ToCamelCase(property.Name); - - public string FormatResourceName(Type resourceType) => ToCamelCase(resourceType.Name.Pluralize()); - - private string ToCamelCase(string str) => Char.ToLowerInvariant(str[0]) + str.Substring(1); - } } }