From b894993a404fd596a662be7dd916409f6a319ebc Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 088dc31acc..868355062c 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 d312026804..e7a357caf3 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 b85128444e..f559cf9aa3 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 69b7f17d75..6c498a7964 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 07b9f1d3fe999010406080e7ad12544a5a8bdb78 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 ba5219be99..3ad7c8054a 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 058fdf6f36..acf075096d 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 91e6d962da..b8d41d88d9 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 9f94800a98..a79f3be75a 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 a93e2a242d..45140557a5 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 726a8e80e6dee077c629b0d76e3a94c2528b6863 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 476ae4860b..8ee6f5e46a 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 9a36eb27dc..6737962819 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 d0743968ae..0dcf39bb2b 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 25cc4afb72..33ec6a4939 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 26f6c69c64..93cea03c20 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 8b7d07a15b..fb500be72f 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 35341781dc..46fc57c272 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 0fa06cab27..326dda8ee9 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 2132374a8a..09fa107ebf 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 305e7745be..d48255e50b 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 309705e03d..af29def7e1 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 2620fcdd25..957f44527e 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 acf075096d..9adc01c4a6 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 b8d41d88d9..08e2665c0d 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 6c510e562b..c01bf0b542 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 a79f3be75a..f09ac1a496 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 3b95e85b01..c1baf09610 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 6b7adf2c20..c84d3a88c6 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 9ba23becdf..115e218dea 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 5e428fa772..8a09a0f977 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 fe3b830f69..37477186d4 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 3bf99c7376..0f3d9be0df 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 2a10a698c3..6df45bd85e 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 4032da4c53..ef07c2b282 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 d7da6292ae..df014c7704 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 fe136993eb..14097f6c32 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 db6691923b..0256aaeb54 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 2ab3e47655..ed566b614d 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 d1c7c1553a..e5741fb8d2 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 975c87029c..d35b302e10 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 bbe645d17c..6c71e5f387 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 aec9a5a438..aaa093f2ba 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 51fb451c42..d8f7905aeb 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 989db27ef3..e30a88d9b9 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 120c0cd41d..aef323c1c6 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 d157d1efda..490c6f9e88 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 c615854066..9dd429065b 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 45140557a5..627f4b81ae 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 00c6af84e657d48da40c8f2a4501e7a8acebb6ec Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 957f44527e..2416c760b8 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 9adc01c4a6..1bc8aed408 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 08e2665c0d..67fff7ecc2 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 10afd16db176aea955dd3a8be446b55d9218557b Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 fb500be72f..c68a6db5fa 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 5b09836440..5f27d7e018 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 c01bf0b542..0000000000 --- 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 0000000000..ec721a9dc8 --- /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 e5741fb8d2..9b36a7ad87 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 aaa093f2ba..3e56c9b128 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 eb76b0d4b0dc0fdc3072f9f7c0bd8aa09bb758f1 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 326dda8ee9..1fb48a995d 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 1cb4a42b74..b72818a67e 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 2416c760b8..fe5c84cf4d 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 f09ac1a496..49248a2a2f 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 ec721a9dc8..939dc22ecc 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 1bc8aed408..ec2775cedc 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 67fff7ecc2..4ce0b6cd8a 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 0000000000..d459a95a58 --- /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 0000000000..c655f58981 --- /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 0000000000..a64b27576f --- /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 cca8487f15..11b6634d22 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 2cacb7c302..6c2c82cc42 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 3e56c9b128..9cb1d72fe6 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 d8f7905aeb..78b3e6ebcb 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 e30a88d9b9..118443b310 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 627f4b81ae..c6c614dc4f 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 940bb1dda7132407d8677fe268c1a5cf6ad0878f Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 b72818a67e..5e5bee029c 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 8660887733e05610aac18c379cfeeb23ba18d7cd Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 5f27d7e018..fcfa845e99 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 1fb48a995d..25f533c528 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 5e5bee029c..4210252579 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 fe5c84cf4d..ed28094878 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 49248a2a2f..2d3b559c54 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 115e218dea..ede0a66671 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 ec2775cedc..032049637e 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 4ce0b6cd8a..0de731a5f5 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 11b6634d22..42d737839b 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 6c2c82cc42..707d4375d4 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 9cb1d72fe6..17d68dfe1c 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 78b3e6ebcb..db7323c2eb 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 118443b310..412ba2572c 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 aef323c1c6..6f681cdbf1 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 490c6f9e88..87f527bd75 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 9dd429065b..1668f6b844 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 c6c614dc4f..0d22993bf7 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 4f44a1959561f2a34007638ea5fbe2b338391906 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 032049637e..60d00a1d2e 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 0de731a5f5..2bd78bbcf5 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 d459a95a58..9d82551628 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 c655f58981..ffa344c3b2 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 a64b27576f..3273651b8f 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 567f24e930..1b7ac1c9de 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 22c8d12f16..adb2ddbc6d 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 0d22993bf7..dd8c9c3a08 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 99c250213268fb2ed7fa909811dd26dc9c6c9e2e Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 d63701cc97..5ddee49899 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 5525e74417..f4e3cbd41c 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 2d3b559c54..99c4ef8fa5 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 60d00a1d2e..ffea405749 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 3273651b8f..ea426073f5 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 bc897628ce9c8ffac87cd0b8247ae13d4baa17ff Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 ffea405749..2bd42256a3 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 9d82551628..b2e807df6d 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 f7f58dbc3826eed73cfc1dbcd0497126168f9127 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 4210252579..b40b2eb7e6 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 ed28094878..91d4128d96 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 99c4ef8fa5..3cc6612cfe 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 2bd42256a3..6b3935b976 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 ffa344c3b2..24c825a9a7 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 f9ad031c10fb63ffd7a77ca0f482f4035e7152a0 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 b40b2eb7e6..d2967a85a1 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 91d4128d96..e12fcfed85 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 3cc6612cfe..093cd62244 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 939dc22ecc..abb9698ae8 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 6b3935b976..9a8a3be4ee 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 db7323c2eb..7b62506548 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 412ba2572c..4281728703 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 dd8c9c3a08..0230714354 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 16c1448c5557561f7674935c986cbc916f5e4e12 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 5ccb57a119..5bd0525616 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 5c7a7164b2..0bc87e3319 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 2b7b023deb..bbd6433391 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 d2967a85a1..d6188963f7 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 0000000000..6b47eeaf71 --- /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 17d68dfe1c..17ff44ee19 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 e0012fdc4443235a7d7e2ad7a2f4c68066155a93 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 8ee6f5e46a..0ada97002e 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 0bc87e3319..acc005e333 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 50d0b02786..0000000000 --- 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 bbd6433391..8ff48140cc 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 591545f48d..c584368341 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 5ddee49899..c791b56aca 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 fcfa845e99..2d37c167cb 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 f4e3cbd41c..2ff5e73a07 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 af29def7e1..68914cf8a0 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 e12fcfed85..c234b17c35 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 0000000000..6c6dc87ea0 --- /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 d6188963f7..7cdbdbb234 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 0000000000..9969c0a14a --- /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 0000000000..01d60be47c --- /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 093cd62244..4e8ccb04b0 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 c1baf09610..0000000000 --- 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 0000000000..3b1e82b4d8 --- /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 ede0a66671..242106f7d1 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 0000000000..dd85953cdf --- /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 0000000000..3b3a55d10c --- /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 9a8a3be4ee..8ed2eb8022 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 a5dacd3921..6b38564517 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 0000000000..8ced34d354 --- /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 40b5bd0f71..ec3c2db62f 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 6c71e5f387..4f8424e26a 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 602adad64a44eed24f1538bf773eb11080481211 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 6b47eeaf71..33417c51f9 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 ef88938fdee9d672fb0e23c8ae123a099c175833 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 5bd0525616..86ceed40d4 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 33417c51f9..9fb67c051a 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 03117a2017b0b3503178f87cc71e293c68c81e08 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 7f0b25d09f..c2f950995f 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 46d23e07a159b48435d0473a14c1a19ad61dbe11 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 fe14393b95..dcb9a34e71 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 c791b56aca..2b10bd7656 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 f1b26e110d..c1b1e7c37e 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 0dbc45f49a..6525a82b28 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 6e5e3901fb..0000000000 --- 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 0000000000..9bd8d1d673 --- /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 f2b731694c..3629a783a2 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 4a0be08cb4..8fd71ffd5c 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 cdc233ae87..a3f5e5bdf7 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 edabd64edd..1b0afb9623 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 64df236abc..a67e56ce22 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 37477186d4..8dda9e1924 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 0f3d9be0df..3d2b34dd32 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 eab6399407..ec8213fdb7 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 519d8add42..1b21ee8b37 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 6df45bd85e..c0d63118a5 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 ef07c2b282..405cf3d097 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 0887f414b0..83613a7fa9 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 57d69866af..9cd4aaae61 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 df014c7704..cc9575e54b 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 14097f6c32..0e2fcb52dc 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 0256aaeb54..8c2240a2c7 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 d62434b8df..061dd9e97e 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 2e84b7988e..e758366040 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 eaddf81891..d75e7bde98 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 1aaa56a309..35d828a5da 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 7f38b586fd..418233b24f 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 df7390124a..0226747edc 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 d2eef40f9c..8a524f57c9 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 32b5aa0976..5d7a91f089 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 98758cd171..0b16fd9300 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 87f527bd75..67afa3c1cc 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 ca86183626..4980ccfd64 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 1668f6b844..150f98675f 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 a6fef14e80e88fee7bddd397031876deffcccdff Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 fa59af8d9d..0e97dd4898 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 c584368341..c20e2eaf7f 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 2b10bd7656..05bd69ba53 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 c234b17c35..5e0f6879c4 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 7cdbdbb234..3449dde87a 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 0000000000..557ea46f10 --- /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 9969c0a14a..018d47b3a2 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 01d60be47c..19ab431e04 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 abb9698ae8..b636894e50 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 6c6dc87ea0..458ba42534 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 8dda9e1924..fdb773efd9 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 c0d63118a5..dd1bf747da 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 405cf3d097..36c10d29ba 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 cc9575e54b..90eb1a4f80 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 0e2fcb52dc..7f9a3d3147 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 8c2240a2c7..9c96dba182 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 2f77e2f9bb..3be8e35163 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 7998df3431..c5bd590109 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 418233b24f..357fe9b472 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 0000000000..6cc974e24f --- /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 707d4375d4..0000000000 --- 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 0226747edc..ce62ac9ef2 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 8a524f57c9..bd29abd2ce 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 5d7a91f089..863429f669 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 0b16fd9300..3ae5205589 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 67afa3c1cc..17080bd2cd 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 4980ccfd64..723046d3f6 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 150f98675f..708077090c 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 a6302d910f4478024807c70b4e424801deff763b Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 6737962819..668c1d5907 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 0dcf39bb2b..71d86316ca 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 33ec6a4939..b081b8592a 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 93cea03c20..a9990d7d36 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 2a2475716d5c3f7a6082ccd2b040a5cc1a5b13f1 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 668c1d5907..ba10b8772a 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 71d86316ca..647c5ba344 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 b081b8592a..a8eab57104 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 a9990d7d36..98969143fb 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 c68a6db5fa..d731df15bd 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 2d37c167cb..28a0e2f9bd 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 46fc57c272..6e6f729ad1 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 3449dde87a..577e987572 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 557ea46f10..321bafb4ca 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 018d47b3a2..f9f68bc5a3 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 19ab431e04..95279b9790 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 4e8ccb04b0..a55a2654a6 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 b636894e50..3dfbffa2ef 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 458ba42534..7bf8bb89fc 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 09fa107ebf..3dcb4d9220 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 d48255e50b..6fcf359538 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 68914cf8a0..8916a3498c 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 5e0f6879c4..ce54ddfe18 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 c84d3a88c6..61cad6aa0b 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 dd85953cdf..f9a848677c 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 8a09a0f977..8e07700bcc 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 fdb773efd9..6ebd430c9c 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 3d2b34dd32..b8fa6bb226 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 dd1bf747da..728003a231 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 36c10d29ba..e33c1c3e31 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 83613a7fa9..af98087d30 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 9cd4aaae61..85e206dca5 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 90eb1a4f80..1246cb91b3 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 7f9a3d3147..2c2c60060e 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 9c96dba182..04b41a2125 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 ed566b614d..27f4fbf8f3 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 9b36a7ad87..d36609d222 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 d35b302e10..ac43ec06b9 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 8ced34d354..aa0c227fc8 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 17ff44ee19..c20b7ebbc0 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 6f681cdbf1..f1e8e6c06e 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 bd29abd2ce..7785b2da4e 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 17080bd2cd..22463982c8 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 723046d3f6..13cd8a9653 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 708077090c..2b9115eb12 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 d57ff7aefb41d835d2c32fb0da523e31fc89f586 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 a55a2654a6..39689427d0 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 0000000000..a89fcacd4f --- /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 6fcf359538..191b8b094b 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 a20827381a..f899de631c 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 8fd71ffd5c..759dd88d83 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 84ff0cfcfa..7a36158cab 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 27f4fbf8f3..9b4cff8fdb 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 d36609d222..54ecd6d936 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 0000000000..64754e3d86 --- /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 1a7130e97c761765a4c3f0753cfb9995e0be5e4a Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 0000000000..05eca67e1d --- /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 8e07700bcc..388c31d3c8 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 d1acf36c65..e699f6dc1e 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 3bc5bc6bc16b48526121b87421c760c37968bd61 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 ce54ddfe18..89b4cf2fea 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 2bd78bbcf5..84e9c3dcd0 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 4281728703..a8b2946ca2 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 0230714354..90c83e99e3 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 f66c7eebbb912cb4b5f49acb342e7fc29bc3bd68 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 3d2aa30f27..5493c3d54f 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 f9a848677c..26a676f7d7 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 8ed2eb8022..b93c3c0790 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 84e9c3dcd0..ffb458405c 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 b2e807df6d..a2c06ff1af 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 24c825a9a7..f271924768 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 ea426073f5..b5eb4c3f70 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 42d737839b..274c6eed86 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 aa0c227fc8..d21ffefc49 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 2aaf4ef4dd..792de4bf58 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 9fb67c051a..ba01e32ff1 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 3be8e35163..af43e8168c 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 c5bd590109..1266afbf83 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 e699f6dc1e..1d8c0bea31 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 ec3c2db62f..024c6ae41c 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 6cc974e24f..79af7d0e6a 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 4f8424e26a..f5e7334141 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 c20b7ebbc0..9723555240 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 f1e8e6c06e..24dae147ae 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 64754e3d86..f71b1eac76 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 7785b2da4e..0e68ed05d4 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 22463982c8..c1d20ac410 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 13cd8a9653..32b341a307 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 2b9115eb12..afe269eff8 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 3b7ac36d81..5796d3fc4c 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 b688192cd7b2a2aa538213c1eb165705d0f431eb Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 f9f68bc5a3..6b95abad35 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 8916a3498c..e8e7f387ad 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 9b4cff8fdb..ee58993dad 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 1d8c0bea31..a583dabb04 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 f5e7334141..2f5879ec35 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 fc9c2956291a555e730fba2fdcf2d4115b1a0c79 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 6e6f729ad1..e280b3bf1b 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 3171eddace..c7428dfa12 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 0702a77c25..d58df9116f 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 97975c505a..e8c1b5bf02 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 8aa2be9c0f..c7d2d07482 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 c7ce3647b7ca5500445549a7032bd9aaa3b1f0b0 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 4ae287c670..270aeee9bf 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 d9e1382c33..c134c8422d 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 0000000000..e5892ccf3f --- /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 acc005e333..fb2ea4fd77 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 6525a82b28..e94cce4264 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 6ebd430c9c..e36ef0339c 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 0000000000..5a36018678 --- /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 a84d15f0c8d3bed2fff8e8931c2f8727f92b0bdf Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 8e23b3fb99..9b2090f6d6 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 6505ccef97..86166600e4 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 ebf816d3eb..3caf4b5ef6 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 cb1ecbdb64..fd2a5565b2 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 7e7dacbb7d..88446416ac 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 50c2d9e7f6..ade7cc66f5 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 0c04d30fab..d7ad78ef77 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 7a36158cab..0fb9889e84 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 7e7152f1e8..48bceae5ef 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 b677769744..251f569b43 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 7b618ea654..2f43e830a2 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 2f920da2b9194436dc3cd3b3f3acedab18595f0d Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 b8fa6bb226..a4fbacd6c6 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 728003a231..77a356f9f3 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 2c2c60060e..99117a9285 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 79af7d0e6a..7949b61bf8 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 80cd4bf9d94837e1e1a2d459bf3f38b626822298 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 0e68ed05d4..ad3aee7d83 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 863429f669..92748d9f5d 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 3ae5205589..c7bb5c9dde 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 c1d20ac410..9033652f3c 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 32b341a307..60a471982a 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 afe269eff8..02dc3dfe6e 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 2024d51863f533cf536f5ee31b38ad2f66ce5400 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 a4fbacd6c6..e38f0e3550 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 af98087d30..c7f6a91705 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 85e206dca5..d5b2e5209d 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 5fbd8f7ed8..f920b43336 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 92748d9f5d..55c01b5485 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 c7bb5c9dde..bb23bdd4b7 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 cd79fa50b4156d001ea2db52fde2fa58673ea92d Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 321bafb4ca..df94a4eb61 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 a89fcacd4f..28e8913ee4 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 08c426663077b8098cdf45eda2c5bbe5a1dfcfba Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 ba10b8772a..1a598d585e 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 647c5ba344..b5b08b9c43 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 a8eab57104..c51c1eeba2 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 f65a0490d0..c9b90a0bd3 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 98969143fb..30f0d90015 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 d731df15bd..cee29e04fa 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 39689427d0..d27683a74d 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 0000000000..ada8b9fe0e --- /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 ac43ec06b9..1813262d02 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 4dc6c68228..3daeb70692 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 0000000000..12c44d4a2f --- /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 52ee7b836df318727e3c91df44ce7b00206c11e2 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 3dcb4d9220..53bc8cc3ae 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 759dd88d83..cfb3094cb3 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 af43e8168c..25165bc961 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 a99cc595c75d7f7d4fd81d978bf4b4280ac8518b Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 28e8913ee4..0000000000 --- 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 ada8b9fe0e..0000000000 --- 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 191b8b094b..af1bd79a17 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 1813262d02..5c0058345a 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 12c44d4a2f..2b5fbe9119 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 f71b1eac76..7bbcac7bdf 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 7c40384cb4121f51b64df95c134da150a589b612 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 25f533c528..d6aeb88ecb 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 ffb458405c..452b12adbc 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 96769903040cdb175e9d1be3189f7d33c6d3e229 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 0000000000..3c87a76777 --- /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 cb8876b36a..2d42d1f475 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 0000000000..01eda5d7d0 --- /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 89b4cf2fea..30d8298a58 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 274c6eed86..3891e31cd0 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 0000000000..7569f38aa6 --- /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 6fae6f8af4d6724311fbcb1531e1ac5929271d95 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 d27683a74d..7e3fd2f508 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 26a676f7d7..fea77a680a 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 d21ffefc49..a5f706448d 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 d92b3b9959718cc7b7899549d6480b4b2485d49e Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 6b95abad35..b25c90588c 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 e8e7f387ad..de6f436afc 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 ee58993dad..9cd0af33fe 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 2f5879ec35..f038d08f09 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 f0df15b9c628230edc47a72d340a3103b4a761cc Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 b5b08b9c43..c9addf5e09 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 c51c1eeba2..3eb4537ad6 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 61cad6aa0b..1cecd3a1ae 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 35d828a5da..ec98d103fd 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 3daeb70692..c5b3195b39 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 dc026a6fd1..930b301fda 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 eb18ce542d..bc5a2d4971 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 0c844e0d23..234728004e 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 024c6ae41c..ab8a9aa4e7 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 3c816d1ec4..064ba24d5e 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 558268aa44..7da5372c3d 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 f038d08f09..527c28e5ee 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 24dae147ae..dec6e76c7c 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 d7a81d0a89932566f80a8907e938538b24d46591 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 95279b9790..0000000000 --- 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 30d8298a58..1760be19f2 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 7569f38aa6..b582bbe390 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 0d5b897886dcf3e62928f9f5aa5a4d1d7a7d47fc Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 f329558648..7fa801d827 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 cad4834ce2a97a63c4f8e215ac69dab6d27a322f Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 b25c90588c..35aa02faba 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 7e3fd2f508..a44b7fa465 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 fea77a680a..877166aafe 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 527c28e5ee..9816d8602a 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 6c2a6590a9820e266fc2c7e773f3a94154d63a46 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 e36ef0339c..bb0004a572 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 d5dbf7243eb34d1930b7e63dbf97a201409fa768 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 336e758843..0bf78b34a1 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 de6f436afc..5e4221bab6 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 1760be19f2..5c30793d6c 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 a5f706448d..25716f3fda 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 9816d8602a..dfd3f6403d 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 0000000000..39d28aaeab --- /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 674668e3cb22cc8cc4843d7820fd18e96933c3b4 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 28a0e2f9bd..49220549b5 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 2b082596d6..0c3e2e948a 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 2ff5e73a07..0bb6411b20 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 54ecd6d936..ecdd0a5727 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 36979679fc50edecef3889c68bcf7edb6e025b17 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 2d990ce784..33a986805e 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 05bd69ba53..6466216a36 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 1246cb91b3..24c5019397 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 ab8a9aa4e7..25bf4e9f06 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 dfd3f6403d..fa83bbfebf 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 9033652f3c..ee9a078d19 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 5680190bccb1109b1c04b27f60b48e9b7d985520 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 96b6d3d6fa..71b7cf0350 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 e5530707457386257b1aa3f42a3ce3813da1b0a6 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 a44b7fa465..52f6ffb24b 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 8f96c227a07364b642bc6cfe7ba2c2fd54efa0ff Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 0ada97002e..1b2865c098 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 bbf430060f..c68977c547 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 49220549b5..b90c9a4991 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 d6aeb88ecb..8396ccff22 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 52f6ffb24b..381040b495 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 0000000000..a8c2fc3be2 --- /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 0000000000..fdf6a0287d --- /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 0000000000..28f8ac73cf --- /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 af1bd79a17..c4d210aad5 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 1cecd3a1ae..92962c8640 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 52e4ca17f4..8ee8c11b12 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 ecdd0a5727..31b2e7da05 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 6b38564517..d31002ec73 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 a583dabb04..84548b8b60 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 bc5a2d4971..278ce46c99 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 25bf4e9f06..13ef6a4fe9 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 064ba24d5e..55552a69fa 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 fa83bbfebf..661fc9c541 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 d2c0a46ba0..945df5b88b 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 7b62506548..56a1bf4414 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 d42be16de5..0791f8cb22 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 a5dddad6e0d3b532fd521966c3644801b40b79bd Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 3eb4537ad6..30fe8d0d2a 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 30f0d90015..a741d60eda 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 e280b3bf1b..7f797419a4 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 c4d210aad5..3fbdc45b8a 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 92962c8640..6c02396a5d 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 388c31d3c8..628b5f7378 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 e38f0e3550..a2f1bb6425 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 e33c1c3e31..a2f54d55f9 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 c7f6a91705..80d26127d4 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 d5b2e5209d..3fb26decf5 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 24c5019397..ad3d625458 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 99117a9285..54e0d5553e 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 9cd0af33fe..08318caf89 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 5c0058345a..65ea08df58 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 25716f3fda..cedd7d61d6 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 278ce46c99..f78c7d4f59 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 71b7cf0350..782a42ebbd 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 9723555240..c61dfddb3a 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 56a1bf4414..46d5ecf784 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 dec6e76c7c..73c368215f 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 7bbcac7bdf..401472e491 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 55c01b5485..75ee3517ff 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 0b3751e0fa..c200a2dd58 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 90c83e99e3..1e83595d14 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 f889c906bccf1f9d5c8071dca622c97d13b8041d Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 cee29e04fa..e28b765960 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 35aa02faba..5f976001e0 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 5e4221bab6..6e0241b295 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 31b2e7da05..7466b4c461 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 84548b8b60..d9d4f0cb74 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 2b65e32410e65edc298f83fd26f9d14cb1e31234 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 1439d4fe14de0bb8245bede97574cf9739b2758a Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 379b1dd2ba..f7085a1890 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 0000000000..3ed690b9b9 --- /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 ef8d650309cc998ea4f43621edf778ed6a25ead1 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 0000000000..04eb2bd8e4 --- /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 3fbdc45b8a..82fc618aa4 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 cfb3094cb3..d68bcde72e 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 88446416ac..a399222264 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 a5b61cce62..83a3eb3f90 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 04b41a2125..f88e3c1e5e 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 08318caf89..fbabd46d37 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 02dc3dfe6e..b33663da31 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 e0a139b5fd9af0b4736e290e7c824c7ab286ef82 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 017c5751e0..681b22dbf4 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 aed26685d2..8638201ff6 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 5c30793d6c..5685d87e21 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 c975211b6d..f1ecbe903b 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 750d94ba07..b3086b09c9 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 b9f89420a4..4a5e1f4fcc 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 6c02396a5d..6f29613543 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 628b5f7378..d16fd6075d 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 0fb9889e84..52cddf20fb 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 bb0004a572..572e425b76 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 77a356f9f3..94c0a9fecd 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 d31002ec73..cb047ff96a 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 ba01e32ff1..5a13fa3e3d 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 6969ce35e5e77016b54ba3ebf1881505a69e4132 Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 1b2bd76c34..0000000000 --- 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 8ea50e5dd7..904dd07b0d 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 dbf7f1ab86..b07c273fc0 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 061dd9e97e..d4942d5c59 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 63dda1b10f..89df84eb82 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 05a9dbe0b32e23beecba18dc358f6813ee18851d Mon Sep 17 00:00:00 2001 From: Bart Koelman 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 904dd07b0d..20b955cea9 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 b07c273fc0..62baa3def6 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 6f29613543..ff19a00e97 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 ccb8eb2ad9..fc66d36f2f 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); - } } }