diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs index 336e7588..0bf78b34 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/Query/QueryParserBenchmarks.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging.Abstractions; namespace Benchmarks.Query { @@ -44,7 +45,7 @@ public QueryParserBenchmarks() 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 @@ public QueryParserBenchmarks() omitNullService }; - return new QueryParameterParser(options, queryStringAccessor, queryServices); + return new QueryParameterParser(options, queryStringAccessor, queryServices, NullLoggerFactory.Instance); } [Benchmark] diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index 3d2aa30f..5493c3d5 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -1,5 +1,6 @@ using System; using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers; using JsonApiDotNetCore.Query; @@ -33,7 +34,7 @@ public JsonApiSerializerBenchmarks() var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, new ResourceObjectBuilderSettings()); _jsonApiSerializer = new ResponseSerializer(metaBuilderMock.Object, linkBuilderMock.Object, - includeBuilderMock.Object, fieldsToSerialize, resourceObjectBuilder); + includeBuilderMock.Object, fieldsToSerialize, resourceObjectBuilder, new CamelCaseFormatter()); } private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs index 4ae287c6..270aeee9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs @@ -6,6 +6,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + [DisableQuery(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] public sealed class ArticlesController : JsonApiController
{ public ArticlesController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/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 diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index d9e1382c..c134c842 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -6,6 +6,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + [DisableQuery("skipCache")] public sealed class TagsController : JsonApiController { public TagsController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs new file mode 100644 index 00000000..3c87a767 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class ThrowingResourcesController : JsonApiController + { + public ThrowingResourcesController( + IJsonApiOptions jsonApiOptions, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 476ae486..1b2865c0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -1,23 +1,24 @@ using System.Collections.Generic; +using System.Net; 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 { + [ApiController] [DisableRoutingConvention, Route("custom/route/todoItems")] public class TodoItemsCustomController : CustomJsonApiController { public TodoItemsCustomController( IJsonApiOptions options, - IResourceService resourceService, - ILoggerFactory loggerFactory) - : base(options, resourceService, loggerFactory) + IResourceService resourceService) + : base(options, resourceService) { } } @@ -26,8 +27,7 @@ public class CustomJsonApiController { public CustomJsonApiController( IJsonApiOptions options, - IResourceService resourceService, - ILoggerFactory loggerFactory) + IResourceService resourceService) : base(options, resourceService) { } @@ -41,7 +41,7 @@ public class CustomJsonApiController private IActionResult Forbidden() { - return new StatusCodeResult(403); + return new StatusCodeResult((int)HttpStatusCode.Forbidden); } public CustomJsonApiController( @@ -68,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}")] @@ -113,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}")] @@ -131,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/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index 379b1dd2..f7085a18 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -1,6 +1,9 @@ +using System.Net; +using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -9,7 +12,7 @@ namespace JsonApiDotNetCoreExample.Controllers { public abstract class AbstractTodoItemsController - : JsonApiController where T : class, IIdentifiable + : BaseJsonApiController where T : class, IIdentifiable { protected AbstractTodoItemsController( IJsonApiOptions jsonApiOptions, @@ -19,6 +22,7 @@ public abstract class AbstractTodoItemsController { } } + [DisableRoutingConvention] [Route("/abstract")] public class TodoItemsTestController : AbstractTodoItemsController { @@ -28,5 +32,49 @@ public class TodoItemsTestController : AbstractTodoItemsController 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/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index cb8876b3..2d42d1f4 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -19,6 +19,7 @@ public sealed class AppDbContext : DbContext public DbSet ArticleTags { get; set; } public DbSet IdentifiableArticleTags { get; set; } public DbSet Tags { get; set; } + public DbSet ThrowingResources { get; set; } public AppDbContext(DbContextOptions options) : base(options) { } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 5ccb57a1..86ceed40 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] + [RegularExpression(@"^\W$")] public string Name { get; set; } } -} \ No newline at end of file +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs new file mode 100644 index 00000000..01eda5d7 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics; +using System.Linq; +using JsonApiDotNetCore.Formatters; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class ThrowingResource : Identifiable + { + [Attr] + public string FailsOnSerialize + { + get + { + var isSerializingResponse = new StackTrace().GetFrames() + .Any(frame => frame.GetMethod().DeclaringType == typeof(JsonApiWriter)); + + if (isSerializingResponse) + { + throw new InvalidOperationException($"The value for the '{nameof(FailsOnSerialize)}' property is currently unavailable."); + } + + return string.Empty; + } + set { } + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index fa59af8d..0e97dd48 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -10,7 +10,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "api/values", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -18,7 +18,7 @@ }, "JsonApiDotNetCoreExample": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "http://localhost:5000/api/values", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -26,4 +26,4 @@ "applicationUrl": "http://localhost:5000/" } } -} \ No newline at end of file +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs index 9a36eb27..1a598d58 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/ArticleResource.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; using System.Linq; -using System; -using JsonApiDotNetCore.Internal; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Resources { @@ -17,9 +18,13 @@ 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(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 d0743968..c9addf5e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/LockableResource.cs @@ -1,9 +1,10 @@ -using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Resources @@ -18,7 +19,10 @@ 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(new Error(HttpStatusCode.Forbidden) + { + 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 25cc4afb..30fe8d0d 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/PassportResource.cs @@ -1,11 +1,12 @@ -using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Resources { @@ -19,7 +20,10 @@ 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(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to include passports on individual persons." + }); } } @@ -34,7 +38,10 @@ 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(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update fields or relationships of locked persons." + }); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs index f65a0490..c9b90a0b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TagResource.cs @@ -18,7 +18,7 @@ public override IEnumerable BeforeCreate(IEntityHashSet affected, Reso public override IEnumerable OnReturn(HashSet entities, ResourcePipeline pipeline) { - return entities.Where(t => t.Name != "This should be not be included"); + return entities.Where(t => t.Name != "This should not be included"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs index 26f6c69c..a741d60e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Resources/TodoResource.cs @@ -1,10 +1,11 @@ -using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Resources { @@ -16,7 +17,10 @@ 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(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 8b7d07a1..e28b7659 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -1,7 +1,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Services; @@ -27,13 +26,8 @@ public class CustomArticleService : DefaultResourceService
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"); - } newEntity.Name = "None for you Glen Coco"; return newEntity; } } - } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs new file mode 100644 index 00000000..e5892ccf --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs @@ -0,0 +1,36 @@ +using System.Linq; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Query; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreExample.Services +{ + public class SkipCacheQueryParameterService : IQueryParameterService + { + private const string _skipCacheParameterName = "skipCache"; + + public bool SkipCache { get; private set; } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ParameterNames.Contains(_skipCacheParameterName.ToLowerInvariant()); + } + + public bool CanParse(string parameterName) + { + return parameterName == _skipCacheParameterName; + } + + public void Parse(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out bool skipCache)) + { + throw new InvalidQueryStringParameterException(parameterName, "Boolean value required.", + $"The value {parameterValue} is not a valid boolean."); + } + + SkipCache = skipCache; + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs index 2d990ce7..33a98680 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs @@ -25,6 +25,7 @@ public override void ConfigureServices(IServiceCollection services) options.IncludeTotalRecordCount = true; options.LoadDatabaseValues = true; options.AllowClientGeneratedIds = true; + options.DefaultPageSize = 0; }, discovery => discovery.AddAssembly(Assembly.Load(nameof(JsonApiDotNetCoreExample))), mvcBuilder: mvcBuilder); diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 5c7a7164..fb2ea4fd 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore; using JsonApiDotNetCore.Extensions; using System; +using JsonApiDotNetCore.Query; +using JsonApiDotNetCoreExample.Services; namespace JsonApiDotNetCoreExample { @@ -25,6 +27,9 @@ public Startup(IWebHostEnvironment env) public virtual void ConfigureServices(IServiceCollection services) { + services.AddScoped(); + services.AddScoped(sp => sp.GetService()); + services .AddDbContext(options => { @@ -34,10 +39,12 @@ public virtual void ConfigureServices(IServiceCollection services) }, ServiceLifetime.Transient) .AddJsonApi(options => { + options.IncludeExceptionStackTraceInErrors = true; options.Namespace = "api/v1"; 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 @@ -49,7 +56,6 @@ public virtual void ConfigureServices(IServiceCollection services) AppDbContext context) { context.Database.EnsureCreated(); - app.EnableDetailedErrors(); app.UseJsonApi(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/web.config b/src/Examples/JsonApiDotNetCoreExample/web.config deleted file mode 100644 index 50d0b027..00000000 --- a/src/Examples/JsonApiDotNetCoreExample/web.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs index bbf43006..c68977c5 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -63,7 +63,7 @@ public async Task CreateAsync(TodoItem entity) })).SingleOrDefault(); } - public Task DeleteAsync(int id) + public Task DeleteAsync(int id) { throw new NotImplementedException(); } diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index f3295586..7fa801d8 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -17,7 +17,7 @@ public ReportService(ILoggerFactory loggerFactory) public Task> GetAsync() { - _logger.LogError("GetAsync"); + _logger.LogWarning("GetAsync"); var task = new Task>(Get); diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index 2b7b023d..8ff48140 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -70,10 +70,17 @@ 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); }); + + if (JsonApiOptions.ValidateModelState) + { + _mvcBuilder.AddDataAnnotations(); + } + _services.AddSingleton(routingConvention); } @@ -140,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/DefaultAttributeResponseBehavior.cs b/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs index fe14393b..dcb9a34e 100644 --- a/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs +++ b/src/JsonApiDotNetCore/Configuration/DefaultAttributeResponseBehavior.cs @@ -1,35 +1,26 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Allows default valued attributes to be omitted from the response payload + /// Determines how attributes that contain a default value are serialized in the response payload. /// public struct DefaultAttributeResponseBehavior { - - /// Do not serialize default value attributes - /// - /// Allow clients to override the serialization behavior through a query parameter. - /// - /// ``` - /// GET /articles?omitDefaultValuedAttributes=true - /// ``` - /// - /// - public DefaultAttributeResponseBehavior(bool omitNullValuedAttributes = false, bool allowClientOverride = false) + /// Determines whether to serialize attributes that contain their types' default value. + /// Determines whether serialization behavior can be controlled by a query string parameter. + public DefaultAttributeResponseBehavior(bool omitAttributeIfValueIsDefault = false, bool allowQueryStringOverride = false) { - OmitDefaultValuedAttributes = omitNullValuedAttributes; - AllowClientOverride = allowClientOverride; + OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; + AllowQueryStringOverride = allowQueryStringOverride; } /// - /// Do (not) include default valued attributes in the response payload. + /// Determines whether to serialize attributes that contain their types' default value. /// - public bool OmitDefaultValuedAttributes { get; } + public bool OmitAttributeIfValueIsDefault { get; } /// - /// Allows clients to specify a `omitDefaultValuedAttributes` boolean query param to control - /// serialization behavior. + /// Determines whether serialization behavior can be controlled by a query string parameter. /// - public bool AllowClientOverride { get; } + public bool AllowQueryStringOverride { get; } } } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 591545f4..c20e2eaf 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. @@ -23,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 d63701cc..6466216a 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,15 +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; - - /// - /// Whether or not source URLs should be serialized in Error objects - /// - public static bool DisableErrorSource { get; set; } = true; + /// + public bool IncludeExceptionStackTraceInErrors { get; set; } = false; /// /// Whether or not ResourceHooks are enabled. @@ -61,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. @@ -106,26 +98,23 @@ 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 null attributes. + /// The default behavior for serializing attributes that contain null. /// - /// - /// - /// options.NullAttributeResponseBehavior = new NullAttributeResponseBehavior { - /// // ... - ///}; - /// - /// public NullAttributeResponseBehavior NullAttributeResponseBehavior { get; set; } + + /// + /// The default behavior for serializing attributes that contain their types' default value. + /// public DefaultAttributeResponseBehavior DefaultAttributeResponseBehavior { get; set; } /// diff --git a/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs b/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs index f1b26e11..c1b1e7c3 100644 --- a/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs +++ b/src/JsonApiDotNetCore/Configuration/NullAttributeResponseBehavior.cs @@ -1,34 +1,26 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Allows null attributes to be omitted from the response payload + /// Determines how attributes that contain null are serialized in the response payload. /// public struct NullAttributeResponseBehavior { - /// Do not serialize null attributes - /// - /// Allow clients to override the serialization behavior through a query parameter. - /// - /// ``` - /// GET /articles?omitNullValuedAttributes=true - /// ``` - /// - /// - public NullAttributeResponseBehavior(bool omitNullValuedAttributes = false, bool allowClientOverride = false) + /// Determines whether to serialize attributes that contain null. + /// Determines whether serialization behavior can be controlled by a query string parameter. + public NullAttributeResponseBehavior(bool omitAttributeIfValueIsNull = false, bool allowQueryStringOverride = false) { - OmitNullValuedAttributes = omitNullValuedAttributes; - AllowClientOverride = allowClientOverride; + OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; + AllowQueryStringOverride = allowQueryStringOverride; } /// - /// Do not include null attributes in the response payload. + /// Determines whether to serialize attributes that contain null. /// - public bool OmitNullValuedAttributes { get; } + public bool OmitAttributeIfValueIsNull { get; } /// - /// Allows clients to specify a `omitNullValuedAttributes` boolean query param to control - /// serialization behavior. + /// Determines whether serialization behavior can be controlled by a query string parameter. /// - public bool AllowClientOverride { get; } + public bool AllowQueryStringOverride { get; } } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 5b098364..b90c9a49 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,7 +1,7 @@ +using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; @@ -61,107 +61,97 @@ public abstract class BaseJsonApiController : JsonApiControllerMixin whe _update = update; _updateRelationships = updateRelationships; _delete = delete; - - _logger.LogTrace("Executing constructor."); } public virtual async Task GetAsync() { - if (_getAll == null) throw Exceptions.UnSupportedRequestMethod; + _logger.LogTrace($"Entering {nameof(GetAsync)}()."); + + 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; - 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); - } + _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); + if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var entity = await _getById.GetAsync(id); return Ok(entity); } public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) { - if (_getRelationships == null) - throw Exceptions.UnSupportedRequestMethod; + _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) - { - // remove the null argument as soon as this has been resolved: - // https://github.com/aspnet/AspNetCore/issues/16969 - return NotFound(null); - } return Ok(relationship); } public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - if (_getRelationship == null) throw Exceptions.UnSupportedRequestMethod; + _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); } public virtual async Task PostAsync([FromBody] T entity) { + _logger.LogTrace($"Entering {nameof(PostAsync)}({(entity == null ? "null" : "object")})."); + if (_create == null) - throw Exceptions.UnSupportedRequestMethod; + 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) - return UnprocessableEntity(ModelState.ConvertToErrorCollection()); + 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) { - if (_update == null) throw Exceptions.UnSupportedRequestMethod; + _logger.LogTrace($"Entering {nameof(PatchAsync)}('{id}', {(entity == null ? "null" : "object")})."); + + if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); if (entity == null) - return UnprocessableEntity(); + throw new InvalidRequestBodyException(null, null, null); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) - return UnprocessableEntity(ModelState.ConvertToErrorCollection()); + 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 Ok(updatedEntity); } public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships) { - if (_updateRelationships == null) throw Exceptions.UnSupportedRequestMethod; + _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(); } public virtual async Task DeleteAsync(TId id) { - if (_delete == null) throw Exceptions.UnSupportedRequestMethod; - var wasDeleted = await _delete.DeleteAsync(id); - if (!wasDeleted) - return NotFound(); + _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id})."); + + if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); + await _delete.DeleteAsync(id); + return NoContent(); } } diff --git a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs index 0dbc45f4..e94cce42 100644 --- a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs @@ -1,29 +1,47 @@ using System; +using System.Collections.Generic; +using System.Linq; namespace JsonApiDotNetCore.Controllers { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] 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/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs index 8e23b3fb..9b2090f6 100644 --- a/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs @@ -2,6 +2,7 @@ namespace JsonApiDotNetCore.Controllers { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class DisableRoutingConventionAttribute : Attribute { } } diff --git a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs index 35341781..7f797419 100644 --- a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs +++ b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs @@ -1,6 +1,7 @@ using System.Linq; +using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Exceptions; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Controllers @@ -16,7 +17,9 @@ public abstract class HttpRestrictAttribute : ActionFilterAttribute var method = context.HttpContext.Request.Method; if (CanExecuteAction(method) == false) - throw new JsonApiException(405, $"This resource does not support {method} requests."); + { + throw new RequestMethodNotAllowedException(new HttpMethod(method)); + } await next(); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 088dc31a..86835506 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCore.Controllers { - public class JsonApiCommandController : JsonApiCommandController where T : class, IIdentifiable + public abstract class JsonApiCommandController : JsonApiCommandController where T : class, IIdentifiable { - public JsonApiCommandController( + protected JsonApiCommandController( IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, IResourceCommandService commandService) @@ -17,9 +17,9 @@ public class JsonApiCommandController : JsonApiCommandController wher { } } - 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/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index 0fa06cab..8396ccff 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -1,5 +1,7 @@ -using JsonApiDotNetCore.Internal; +using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; namespace JsonApiDotNetCore.Controllers @@ -7,19 +9,19 @@ namespace JsonApiDotNetCore.Controllers [ServiceFilter(typeof(IQueryParameterActionFilter))] public abstract class JsonApiControllerMixin : ControllerBase { - protected IActionResult Forbidden() - { - return new StatusCodeResult(403); - } - protected IActionResult Error(Error error) { - return error.AsActionResult(); + return Error(new[] {error}); } - protected IActionResult Errors(ErrorCollection errors) + protected IActionResult Error(IEnumerable errors) { - return errors.AsActionResult(); + var document = new ErrorDocument(errors.ToList()); + + return new ObjectResult(document) + { + StatusCode = (int) document.GetErrorStatusCode() + }; } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index d3120268..e7a357ca 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCore.Controllers { - public class JsonApiQueryController : JsonApiQueryController where T : class, IIdentifiable + public abstract class JsonApiQueryController : JsonApiQueryController where T : class, IIdentifiable { - public JsonApiQueryController( + protected JsonApiQueryController( IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, IResourceQueryService queryService) @@ -17,9 +17,9 @@ public class JsonApiQueryController : JsonApiQueryController where T { } } - 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/Controllers/QueryParams.cs b/src/JsonApiDotNetCore/Controllers/QueryParams.cs deleted file mode 100644 index 6e5e3901..00000000 --- a/src/JsonApiDotNetCore/Controllers/QueryParams.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace JsonApiDotNetCore.Controllers -{ - public enum QueryParams - { - Filters = 1 << 0, - Sort = 1 << 1, - Include = 1 << 2, - Page = 1 << 3, - Fields = 1 << 4, - All = ~(-1 << 5), - None = 1 << 6, - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs b/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs new file mode 100644 index 00000000..9bd8d1d6 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs @@ -0,0 +1,20 @@ +using System; + +namespace JsonApiDotNetCore.Controllers +{ + [Flags] + public enum StandardQueryStringParameters + { + None = 0, + Filter = 1, + Sort = 2, + Include = 4, + Page = 8, + Fields = 16, + // TODO: Rename to single-word to prevent violating casing conventions. + OmitNull = 32, + // TODO: Rename to single-word to prevent violating casing conventions. + OmitDefault = 64, + All = Filter | Sort | Include | Page | Fields | OmitNull | OmitDefault + } +} diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs index 2b082596..0c3e2e94 100644 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs @@ -42,23 +42,30 @@ public class DefaultResourceRepository : IResourceRepository(); _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/Exceptions/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs new file mode 100644 index 00000000..577e9875 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when model state validation fails. + /// + public class InvalidModelStateException : Exception + { + public IList Errors { get; } + + public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, IJsonApiOptions options) + { + 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 = 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) + { + errors.Add(jsonApiException.Error); + } + else + { + errors.Add(FromModelError(modelError, attributeName, options)); + } + } + } + + return errors; + } + + private static Error FromModelError(ModelError modelError, string attributeName, IJsonApiOptions options) + { + var error = new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Input validation failed.", + Detail = modelError.ErrorMessage, + Source = attributeName == null ? null : new ErrorSource + { + Pointer = $"/data/attributes/{attributeName}" + } + }; + + if (options.IncludeExceptionStackTraceInErrors && modelError.Exception != null) + { + error.Meta.IncludeExceptionStackTrace(modelError.Exception); + } + + return error; + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs new file mode 100644 index 00000000..df94a4eb --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs @@ -0,0 +1,28 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when processing the request fails due to an error in the request query string. + /// + 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/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs new file mode 100644 index 00000000..5f976001 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs @@ -0,0 +1,52 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when deserializing the request body fails. + /// + 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.", + }, innerException) + { + _details = details; + _requestBody = requestBody; + + UpdateErrorDetail(); + } + + private void UpdateErrorDetail() + { + string text = _details ?? InnerException?.Message; + + if (_requestBody != null) + { + if (text != null) + { + text += " - "; + } + + text += "Request body: <<" + _requestBody + ">>"; + } + + Error.Detail = text; + } + + public void SetRequestBody(string requestBody) + { + _requestBody = requestBody; + UpdateErrorDetail(); + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs new file mode 100644 index 00000000..381040b4 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs @@ -0,0 +1,30 @@ +using System; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Newtonsoft.Json; + +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) + : this(error, null) + { + } + + public JsonApiException(Error error, Exception innerException) + : base(error.Title, innerException) + { + Error = error; + } + + public override string Message => "Error = " + JsonConvert.SerializeObject(Error, _errorSerializerSettings); + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs new file mode 100644 index 00000000..a8c2fc3b --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when a relationship does not exist. + /// + public sealed class RelationshipNotFoundException : JsonApiException + { + public RelationshipNotFoundException(string relationshipName, string containingResourceName) : base(new Error(HttpStatusCode.NotFound) + { + Title = "The requested relationship does not exist.", + Detail = $"The resource '{containingResourceName}' does not contain a relationship named '{relationshipName}'." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs new file mode 100644 index 00000000..3dfbffa2 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs @@ -0,0 +1,24 @@ +using System.Net; +using System.Net.Http; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// 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; } + + public RequestMethodNotAllowedException(HttpMethod method) + : base(new Error(HttpStatusCode.MethodNotAllowed) + { + Title = "The request method is not allowed.", + Detail = $"Resource does not support {method} requests." + }) + { + Method = method; + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs new file mode 100644 index 00000000..fdf6a028 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs @@ -0,0 +1,23 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when a POST request is received that contains a client-generated ID. + /// + public sealed class ResourceIdInPostRequestNotAllowedException : JsonApiException + { + public ResourceIdInPostRequestNotAllowedException() + : base(new Error(HttpStatusCode.Forbidden) + { + Title = "Specifying the resource id in POST requests is not allowed.", + Source = + { + Pointer = "/data/id" + } + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs new file mode 100644 index 00000000..28f8ac73 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when a resource does not exist. + /// + public sealed class ResourceNotFoundException : JsonApiException + { + public ResourceNotFoundException(string resourceId, string resourceType) : base(new Error(HttpStatusCode.NotFound) + { + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with id '{resourceId}' does not exist." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs b/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs new file mode 100644 index 00000000..05eca67e --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs @@ -0,0 +1,22 @@ +using System.Net; +using System.Net.Http; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when the resource type in the request body does not match the type expected at the current endpoint URL. + /// + public sealed class ResourceTypeMismatchException : JsonApiException + { + public ResourceTypeMismatchException(HttpMethod method, string requestPath, ResourceContext expected, ResourceContext actual) + : base(new Error(HttpStatusCode.Conflict) + { + Title = "Resource type mismatch between request body and endpoint URL.", + Detail = $"Expected resource of type '{expected.ResourceName}' in {method} request body at endpoint '{requestPath}', instead of '{actual.ResourceName}'." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs b/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs new file mode 100644 index 00000000..7bf8bb89 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs @@ -0,0 +1,50 @@ +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when an with non-success status is returned from a controller method. + /// + public sealed class UnsuccessfulActionResultException : JsonApiException + { + public UnsuccessfulActionResultException(HttpStatusCode status) + : base(new Error(status) + { + Title = status.ToString() + }) + { + } + + public UnsuccessfulActionResultException(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/EntityFrameworkCoreExtension.cs b/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs index f2b73169..3629a783 100644 --- a/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs +++ b/src/JsonApiDotNetCore/Extensions/EntityFrameworkCoreExtension.cs @@ -29,7 +29,7 @@ public static class IResourceGraphBuilderExtensions 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/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 5525e744..0bb6411b 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,16 +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; - JsonApiOptions.DisableErrorSource = false; - } - private static void LogResourceGraphValidations(IApplicationBuilder app) { var logger = app.ApplicationServices.GetService(typeof(ILogger)) as ILogger; @@ -81,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/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 2132374a..53bc8cc3 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; @@ -181,7 +182,7 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression } break; default: - throw new JsonApiException(500, $"Unknown filter operation {operation}"); + throw new NotSupportedException($"Filter operation '{operation}' is not supported."); } return body; @@ -192,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(400, $"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); } } @@ -275,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(400, $"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/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs deleted file mode 100644 index ba5219be..00000000 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace JsonApiDotNetCore.Extensions -{ - public static class ModelStateExtensions - { - public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) where T : 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 modelError in entry.Value.Errors) - { - if (modelError.Exception is JsonApiException jex) - { - collection.Errors.AddRange(jex.GetError().Errors); - } - else - { - collection.Errors.Add(new Error( - status: 422, - title: entry.Key, - detail: modelError.ErrorMessage, - meta: modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null, - source: attrName == null ? null : new - { - pointer = $"/data/attributes/{attrName}" - })); - } - } - } - return collection; - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/StringExtensions.cs b/src/JsonApiDotNetCore/Extensions/StringExtensions.cs deleted file mode 100644 index 1b2bd76c..00000000 --- a/src/JsonApiDotNetCore/Extensions/StringExtensions.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Text; - -namespace JsonApiDotNetCore.Extensions -{ - public static class StringExtensions - { - public static string ToProperCase(this string str) - { - var chars = str.ToCharArray(); - if (chars.Length > 0) - { - chars[0] = char.ToUpper(chars[0]); - var builder = new StringBuilder(); - for (var i = 0; i < chars.Length; i++) - { - if ((chars[i]) == '-') - { - i++; - builder.Append(char.ToUpper(chars[i])); - } - else - { - builder.Append(chars[i]); - } - } - return builder.ToString(); - } - return str; - } - - public static string Dasherize(this string str) - { - var chars = str.ToCharArray(); - if (chars.Length > 0) - { - var builder = new StringBuilder(); - for (var i = 0; i < chars.Length; i++) - { - if (char.IsUpper(chars[i])) - { - var hashedString = (i > 0) ? $"-{char.ToLower(chars[i])}" : $"{char.ToLower(chars[i])}"; - builder.Append(hashedString); - } - else - { - builder.Append(chars[i]); - } - } - return builder.ToString(); - } - return str; - } - - public static string Camelize(this string str) - { - return char.ToLowerInvariant(str[0]) + str.Substring(1); - } - - public static string NullIfEmpty(this string value) - { - if (value == "") return null; - return value; - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs new file mode 100644 index 00000000..04eb2bd8 --- /dev/null +++ b/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Extensions +{ + internal static class SystemCollectionExtensions + { + public static void AddRange(this ICollection source, IEnumerable items) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (items == null) throw new ArgumentNullException(nameof(items)); + + foreach (var item in items) + { + source.Add(item); + } + } + + public static void AddRange(this IList source, IEnumerable items) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (items == null) throw new ArgumentNullException(nameof(items)); + + foreach (var item in items) + { + source.Add(item); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 305e7745..82fc618a 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -3,33 +3,12 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Models; 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: /// @@ -41,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; } /// @@ -77,18 +37,30 @@ 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 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); + } + /// - /// 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; } @@ -98,9 +70,9 @@ private static object CreateNewInstance(Type type) { return Activator.CreateInstance(type); } - catch (Exception e) + catch (Exception exception) { - throw new JsonApiException(500, $"Type '{type}' cannot be instantiated using the default constructor.", e); + throw new InvalidOperationException($"Failed to create an instance of '{type.FullName}' using its default constructor.", exception); } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index 017c5751..681b22db 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -15,7 +15,7 @@ public bool CanRead(InputFormatterContext context) var contentTypeString = context.HttpContext.Request.ContentType; - return contentTypeString == Constants.ContentType; + return contentTypeString == HeaderConstants.ContentType; } public async Task ReadAsync(InputFormatterContext context) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index aed26685..8638201f 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -15,7 +15,7 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) var contentTypeString = context.HttpContext.Request.ContentType; - return string.IsNullOrEmpty(contentTypeString) || contentTypeString == Constants.ContentType; + return string.IsNullOrEmpty(contentTypeString) || contentTypeString == HeaderConstants.ContentType; } public async Task WriteAsync(OutputFormatterWriteContext context) { diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 309705e0..6e0241b2 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -2,9 +2,10 @@ using System.Collections; using System.IO; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Server; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; @@ -21,11 +22,9 @@ public class JsonApiReader : IJsonApiReader { _deserializer = deserializer; _logger = loggerFactory.CreateLogger(); - - _logger.LogTrace("Executing constructor."); } - public async Task ReadAsync(InputFormatterContext context) + public async Task ReadAsync(InputFormatterContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); @@ -36,39 +35,36 @@ public async Task ReadAsync(InputFormatterContext context return await InputFormatterResult.SuccessAsync(null); } + 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 { - 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(400, "Payload must include id attribute"); - } - } - return await InputFormatterResult.SuccessAsync(model); + model = _deserializer.Deserialize(body); } - catch (Exception ex) + catch (InvalidRequestBodyException 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(); + exception.SetRequestBody(body); + throw; } + catch (Exception exception) + { + throw new InvalidRequestBodyException(null, null, body, 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.", null, body); + } + } + + 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 2620fcdd..5685d87e 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -1,8 +1,15 @@ using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; using System.Text; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; +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; @@ -13,20 +20,19 @@ 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; + private readonly ILogger _logger; - public JsonApiWriter(IJsonApiSerializer serializer, - ILoggerFactory loggerFactory) + public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, ILoggerFactory loggerFactory) { _serializer = serializer; - _logger = loggerFactory.CreateLogger(); + _exceptionHandler = exceptionHandler; - _logger.LogTrace("Executing constructor."); + _logger = loggerFactory.CreateLogger(); } public async Task WriteAsync(OutputFormatterWriteContext context) @@ -44,30 +50,62 @@ public async Task WriteAsync(OutputFormatterWriteContext context) } else { - response.ContentType = Constants.ContentType; + response.ContentType = HeaderConstants.ContentType; try { - if (context.Object is ProblemDetails pd) - { - var errors = new ErrorCollection(); - errors.Add(new Error(pd.Status.Value, pd.Title, pd.Detail)); - responseContent = _serializer.Serialize(errors); - } 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 errors = new ErrorCollection(); - errors.Add(new Error(500, e.Message, ErrorMeta.FromException(e))); - responseContent = _serializer.Serialize(errors); - response.StatusCode = 500; + var errorDocument = _exceptionHandler.HandleException(exception); + responseContent = _serializer.Serialize(errorDocument); + + response.StatusCode = (int)errorDocument.GetErrorStatusCode(); } } + + 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(); } + + private string SerializeResponse(object contextObject, HttpStatusCode statusCode) + { + if (contextObject is ProblemDetails problemDetails) + { + throw new UnsuccessfulActionResultException(problemDetails); + } + + if (contextObject == null && !IsSuccessStatusCode(statusCode)) + { + throw new UnsuccessfulActionResultException(statusCode); + } + + contextObject = WrapErrors(contextObject); + + return _serializer.Serialize(contextObject); + } + + 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/Graph/ResourceNameFormatters/CamelCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs index 8ea50e5d..20b955ce 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.ToLower(properName[0]) + properName.Substring(1); } } - diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs index dbf7f1ab..62baa3de 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,34 @@ namespace JsonApiDotNetCore.Graph public sealed class KebabCaseFormatter : BaseResourceNameFormatter { /// - public override string ApplyCasingConvention(string properName) => str.Dasherize(properName); + public override string ApplyCasingConvention(string properName) + { + if (properName.Length == 0) + { + return properName; + } + + var chars = properName.ToCharArray(); + var builder = new StringBuilder(); + + for (var i = 0; i < chars.Length; i++) + { + if (char.IsUpper(chars[i])) + { + if (i > 0) + { + builder.Append('-'); + } + + builder.Append(char.ToLower(chars[i])); + } + else + { + builder.Append(chars[i]); + } + } + + return builder.ToString(); + } } } diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs index 6505ccef..86166600 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs +++ b/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs @@ -64,12 +64,12 @@ private void DiscoverImplementedHooks(Type containerType) continue; implementedHooks.Add(hook); - var attr = method.GetCustomAttributes(true).OfType().SingleOrDefault(); + var attr = method.GetCustomAttributes(true).OfType().SingleOrDefault(); if (attr != null) { if (!_databaseValuesAttributeAllowed.Contains(hook)) { - throw new JsonApiSetupException("DatabaseValuesAttribute cannot be used on hook" + + throw new JsonApiSetupException($"{nameof(LoadDatabaseValuesAttribute)} cannot be used on hook" + $"{hook:G} in resource definition {containerType.Name}"); } var targetList = attr.Value ? databaseValuesEnabledHooks : databaseValuesDisabledHooks; diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs b/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs index ebf816d3..3caf4b5e 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs +++ b/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs @@ -1,11 +1,12 @@ using System; namespace JsonApiDotNetCore.Hooks { - public sealed class LoadDatabaseValues : Attribute + [AttributeUsage(AttributeTargets.Method)] + public sealed class LoadDatabaseValuesAttribute : Attribute { public readonly bool Value; - public LoadDatabaseValues(bool mode = true) + public LoadDatabaseValuesAttribute(bool mode = true) { Value = mode; } diff --git a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs index cb1ecbdb..fd2a5565 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs @@ -90,7 +90,7 @@ public new HashSet GetAffected(Expression> na 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/Internal/Contracts/IResourceContextProvider.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs index c975211b..f1ecbe90 100644 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs +++ b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs @@ -16,7 +16,7 @@ public interface IResourceContextProvider /// /// Get the resource metadata by the DbSet property name /// - ResourceContext GetResourceContext(string exposedResourceName); + ResourceContext GetResourceContext(string resourceName); /// /// Get the resource metadata by the resource type diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs deleted file mode 100644 index 058fdf6f..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Error.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Diagnostics; -using System.Collections.Generic; -using JsonApiDotNetCore.Configuration; -using Newtonsoft.Json; -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCore.Internal -{ - public class Error - { - public Error() { } - - public Error(int status, string title, ErrorMeta meta = null, object source = null) - { - Status = status.ToString(); - Title = title; - Meta = meta; - Source = source; - } - - public Error(int status, string title, string detail, ErrorMeta meta = null, object source = null) - { - Status = status.ToString(); - Title = title; - Detail = detail; - Meta = meta; - Source = source; - } - - [JsonProperty("title")] - public string Title { get; set; } - - [JsonProperty("detail")] - public string Detail { get; set; } - - [JsonProperty("status")] - public string Status { get; set; } - - [JsonIgnore] - public int StatusCode => int.Parse(Status); - - [JsonProperty("source")] - public object Source { get; set; } - - [JsonProperty("meta")] - public ErrorMeta Meta { get; set; } - - 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(); - } - } - - public class ErrorMeta - { - [JsonProperty("stackTrace")] - public string[] 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/Internal/Exceptions/ErrorCollection.cs b/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs deleted file mode 100644 index 91e6d962..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/ErrorCollection.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCore.Internal -{ - public class ErrorCollection - { - public ErrorCollection() - { - Errors = new List(); - } - - public List Errors { get; set; } - - public void Add(Error error) - { - Errors.Add(error); - } - - public string GetJson() - { - return JsonConvert.SerializeObject(this, new JsonSerializerSettings { - NullValueHandling = NullValueHandling.Ignore, - ContractResolver = new CamelCasePropertyNamesContractResolver() - }); - } - - public int GetErrorStatusCode() - { - var statusCodes = Errors - .Select(e => e.StatusCode) - .Distinct() - .ToList(); - - if (statusCodes.Count == 1) - return statusCodes[0]; - - return int.Parse(statusCodes.Max().ToString()[0] + "00"); - } - - public IActionResult AsActionResult() - { - return new ObjectResult(this) - { - StatusCode = GetErrorStatusCode() - }; - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs b/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs deleted file mode 100644 index 6c510e56..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/Exceptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -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(405, "Request method is not supported.", BuildUrl(nameof(UnSupportedRequestMethod))); - } -} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs deleted file mode 100644 index 9f94800a..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiException.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Internal -{ - public class JsonApiException : Exception - { - private readonly ErrorCollection _errors = new ErrorCollection(); - - public JsonApiException(ErrorCollection errorCollection) - { - _errors = errorCollection; - } - - public JsonApiException(Error error) - : base(error.Title) => _errors.Add(error); - - public JsonApiException(int statusCode, string message, string source = null) - : base(message) - => _errors.Add(new Error(statusCode, message, null, GetMeta(), source)); - - public JsonApiException(int statusCode, string message, string detail, string source = null) - : base(message) - => _errors.Add(new Error(statusCode, message, detail, GetMeta(), source)); - - public JsonApiException(int statusCode, string message, Exception innerException) - : base(message, innerException) - => _errors.Add(new Error(statusCode, message, innerException.Message, GetMeta(innerException))); - - public ErrorCollection GetError() => _errors; - - public int GetStatusCode() - { - return _errors.GetErrorStatusCode(); - } - - private ErrorMeta GetMeta() => ErrorMeta.FromException(this); - private ErrorMeta GetMeta(Exception e) => ErrorMeta.FromException(e); - } -} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs deleted file mode 100644 index 3b95e85b..00000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiExceptionFactory.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -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(500, exceptionType.Name, exception); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs index a2082738..f899de63 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs @@ -112,7 +112,7 @@ private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAtt var newLinks = relationshipIds.Select(x => { - var link = Activator.CreateInstance(relationship.ThroughType); + var link = relationship.ThroughType.New(); relationship.LeftIdProperty.SetValue(link, TypeHelper.ConvertType(parentId, relationship.LeftIdProperty.PropertyType)); relationship.RightIdProperty.SetValue(link, TypeHelper.ConvertType(x, relationship.RightIdProperty.PropertyType)); return link; diff --git a/src/JsonApiDotNetCore/Internal/Constants.cs b/src/JsonApiDotNetCore/Internal/HeaderConstants.cs similarity index 81% rename from src/JsonApiDotNetCore/Internal/Constants.cs rename to src/JsonApiDotNetCore/Internal/HeaderConstants.cs index 750d94ba..b3086b09 100644 --- a/src/JsonApiDotNetCore/Internal/Constants.cs +++ b/src/JsonApiDotNetCore/Internal/HeaderConstants.cs @@ -1,6 +1,6 @@ namespace JsonApiDotNetCore.Internal { - public static class Constants + public static class HeaderConstants { public const string AcceptHeader = "Accept"; public const string ContentType = "application/vnd.api+json"; diff --git a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs index b9f89420..4a5e1f4f 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs @@ -15,20 +15,20 @@ public class ResourceGraph : IResourceGraph internal List ValidationResults { get; } private List Resources { get; } - public ResourceGraph(List entities, List validationResults = null) + public ResourceGraph(List resources, List validationResults = null) { - Resources = entities; + Resources = resources; ValidationResults = validationResults; } /// public ResourceContext[] GetResourceContexts() => Resources.ToArray(); /// - public ResourceContext GetResourceContext(string entityName) - => Resources.SingleOrDefault(e => string.Equals(e.ResourceName, entityName, StringComparison.OrdinalIgnoreCase)); + public ResourceContext GetResourceContext(string resourceName) + => Resources.SingleOrDefault(e => e.ResourceName == resourceName); /// - public ResourceContext GetResourceContext(Type entityType) - => Resources.SingleOrDefault(e => e.ResourceType == entityType); + public ResourceContext GetResourceContext(Type resourceType) + => Resources.SingleOrDefault(e => e.ResourceType == resourceType); /// public ResourceContext GetResourceContext() where TResource : class, IIdentifiable => GetResourceContext(typeof(TResource)); diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 4a0be08c..d68bcde7 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -4,19 +4,13 @@ using System.Linq; using System.Reflection; using System.Linq.Expressions; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; 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); @@ -53,22 +47,22 @@ 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); } catch (Exception e) { - throw new FormatException($"{ typeOfValue } cannot be converted to { type }", e); + throw new FormatException($"{typeOfValue} cannot be converted to {type}", e); } } private static object GetDefaultType(Type type) { - if (type.GetTypeInfo().IsValueType) + if (type.IsValueType) { - return Activator.CreateInstance(type); + return type.New(); } return null; } diff --git a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs new file mode 100644 index 00000000..3b1e82b4 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace JsonApiDotNetCore.Middleware +{ + public sealed class ConvertEmptyActionResultFilter : IAlwaysRunResultFilter + { + public void OnResultExecuted(ResultExecutedContext context) + { + } + + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ObjectResult objectResult && objectResult.Value != null) + { + return; + } + + // Convert action result without parameters into action result with null parameter. + // For example: return NotFound() -> return NotFound(null) + // This ensures our formatter is invoked, where we'll build a json:api compliant response. + // For details, see: https://github.com/dotnet/aspnetcore/issues/16969 + if (context.Result is IStatusCodeActionResult statusCodeResult) + { + context.Result = new ObjectResult(null) {StatusCode = statusCodeResult.StatusCode}; + } + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 6b7adf2c..ff19a00e 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -1,13 +1,17 @@ using System; +using System.IO; using System.Linq; +using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; 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 { @@ -51,7 +55,7 @@ public CurrentRequestMiddleware(RequestDelegate next) _currentRequest.RelationshipId = GetRelationshipId(); } - if (IsValid()) + if (await IsValidAsync()) { await _next(httpContext); } @@ -61,16 +65,10 @@ private string GetBaseId() { if (_routeValues.TryGetValue("id", out object stringId)) { - if ((string)stringId == string.Empty) - { - throw new JsonApiException(400, "No empty string as id please."); - } return (string)stringId; } - else - { - return null; - } + + return null; } private string GetRelationshipId() { @@ -86,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('/'); @@ -98,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}"; @@ -112,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; } @@ -127,36 +124,36 @@ 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 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, 415); + await FlushResponseAsync(context, new Error(HttpStatusCode.UnsupportedMediaType) + { + Title = "The specified Content-Type header value is not supported.", + Detail = $"Please specify '{HeaderConstants.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) + if (context.Request.Headers.TryGetValue(HeaderConstants.AcceptHeader, out StringValues acceptHeaders) == false) return true; foreach (var acceptHeader in acceptHeaders) @@ -166,7 +163,11 @@ private bool IsValidAcceptHeader(HttpContext context) continue; } - FlushResponse(context, 406); + await FlushResponseAsync(context, new Error(HttpStatusCode.NotAcceptable) + { + Title = "The specified Accept header value is not supported.", + Detail = $"Please specify '{HeaderConstants.ContentType}' for the Accept header value." + }); return false; } return true; @@ -177,25 +178,32 @@ 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] == ';' ); } - private void FlushResponse(HttpContext context, int statusCode) + private static async Task FlushResponseAsync(HttpContext context, Error error) { - context.Response.StatusCode = 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(); } @@ -218,7 +226,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/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs index 9ba23bec..242106f7 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs @@ -1,34 +1,28 @@ -using JsonApiDotNetCore.Internal; 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); - - var error = jsonApiException.GetError(); - var result = new ObjectResult(error) + context.Result = new ObjectResult(errorDocument) { - StatusCode = jsonApiException.GetStatusCode() + StatusCode = (int) errorDocument.GetErrorStatusCode() }; - context.Result = result; } } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs new file mode 100644 index 00000000..877166aa --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.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) + { + Exception demystified = exception.Demystify(); + + LogException(demystified); + + return CreateErrorDocument(demystified); + } + + private void LogException(Exception exception) + { + var level = GetLogLevel(exception); + var message = GetLogMessage(exception); + + _logger.Log(level, exception, message); + } + + protected virtual LogLevel GetLogLevel(Exception exception) + { + if (exception is JsonApiException || exception is InvalidModelStateException) + { + return LogLevel.Information; + } + + 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) + { + 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) + { + error.Meta.IncludeExceptionStackTrace(_options.IncludeExceptionStackTraceInErrors ? exception : null); + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs index 5e428fa7..d16fd607 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs @@ -1,5 +1,7 @@ using System; using System.Linq; +using System.Net.Http; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using Microsoft.AspNetCore.Http; @@ -29,19 +31,17 @@ 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(409, - $"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); } } } 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/Middleware/IExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs new file mode 100644 index 00000000..3b3a55d1 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs @@ -0,0 +1,13 @@ +using System; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Central place to handle all exceptions. Log them and translate into Error response. + /// + public interface IExceptionHandler + { + ErrorDocument HandleException(Exception exception); + } +} diff --git a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs b/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs index cdc233ae..a3f5e5bd 100644 --- a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs @@ -13,10 +13,9 @@ public sealed class QueryParameterActionFilter : IAsyncActionFilter, IQueryParam public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - // gets the DisableQueryAttribute if set on the controller that is targeted by the current request. - DisableQueryAttribute disabledQuery = context.Controller.GetType().GetTypeInfo().GetCustomAttribute(typeof(DisableQueryAttribute)) as DisableQueryAttribute; + DisableQueryAttribute disableQueryAttribute = context.Controller.GetType().GetCustomAttribute(); - _queryParser.Parse(disabledQuery); + _queryParser.Parse(disableQueryAttribute); await next(); } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index 7e7dacbb..a3992222 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 { /// @@ -123,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/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs index 50c2d9e7..ade7cc66 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs @@ -32,6 +32,7 @@ namespace JsonApiDotNetCore.Models /// } /// ]]> /// + [AttributeUsage(AttributeTargets.Property)] public sealed class EagerLoadAttribute : Attribute { public PropertyInfo Property { get; internal set; } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs index 0c04d30f..d7ad78ef 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs @@ -1,7 +1,9 @@ +using System; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Property)] public class HasManyAttribute : RelationshipAttribute { /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index 84ff0cfc..52cddf20 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -26,6 +26,7 @@ namespace JsonApiDotNetCore.Models /// public List<ArticleTag> ArticleTags { get; set; } /// /// + [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { /// @@ -76,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); @@ -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/Models/Annotation/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs index 7e7152f1..48bceae5 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs @@ -1,9 +1,11 @@ +using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Property)] public sealed class HasOneAttribute : RelationshipAttribute { /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs index b6777697..251f569b 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Models.Links { - [AttributeUsage(AttributeTargets.Class, Inherited = false)] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class LinksAttribute : Attribute { public LinksAttribute(Link topLevelLinks = Link.NotConfigured, Link resourceLinks = Link.NotConfigured, Link relationshipLinks = Link.NotConfigured) diff --git a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs index a5b61cce..83a3eb3f 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs @@ -77,10 +77,9 @@ public override int GetHashCode() } /// - /// Whether or not the provided exposed name is equivalent to the one defined in on the model + /// Whether or not the provided exposed name is equivalent to the one defined in the model /// - public virtual bool Is(string publicRelationshipName) - => string.Equals(publicRelationshipName, PublicRelationshipName, StringComparison.OrdinalIgnoreCase); + public virtual bool Is(string publicRelationshipName) => publicRelationshipName == PublicRelationshipName; /// /// The internal navigation property path to the related entity. diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs new file mode 100644 index 00000000..b93c3c07 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Net; +using Newtonsoft.Json; + +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(HttpStatusCode statusCode) + { + StatusCode = statusCode; + } + + /// + /// A unique identifier for this particular occurrence of the problem. + /// + [JsonProperty] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// A link that leads to further details about this particular occurrence of the problem. + /// + [JsonProperty] + public ErrorLinks Links { get; set; } = new ErrorLinks(); + + public bool ShouldSerializeLinks() => Links?.About != null; + + /// + /// The HTTP status code applicable to this problem. + /// + [JsonIgnore] + public HttpStatusCode StatusCode { get; set; } + + [JsonProperty] + public string Status + { + get => StatusCode.ToString("d"); + set => StatusCode = (HttpStatusCode)int.Parse(value); + } + + /// + /// An application-specific error 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] + 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] + public string Detail { get; set; } + + /// + /// An object containing references to the source of the error. + /// + [JsonProperty] + public ErrorSource Source { get; set; } = new ErrorSource(); + + 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] + 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 new file mode 100644 index 00000000..452b12ad --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace JsonApiDotNetCore.Models.JsonApiDocuments +{ + public sealed class ErrorDocument + { + public IReadOnlyList Errors { get; } + + public ErrorDocument() + { + Errors = new List(); + } + + public ErrorDocument(Error error) + { + Errors = new List {error}; + } + + public ErrorDocument(IEnumerable errors) + { + Errors = errors.ToList(); + } + + public HttpStatusCode GetErrorStatusCode() + { + var statusCodes = Errors + .Select(e => (int)e.StatusCode) + .Distinct() + .ToList(); + + if (statusCodes.Count == 1) + return (HttpStatusCode)statusCodes[0]; + + var statusCode = int.Parse(statusCodes.Max().ToString()[0] + "00"); + return (HttpStatusCode)statusCode; + } + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs new file mode 100644 index 00000000..a2c06ff1 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.JsonApiDocuments +{ + public sealed class ErrorLinks + { + /// + /// A URL that leads to further details about this particular occurrence of the problem. + /// + [JsonProperty] + public string About { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs new file mode 100644 index 00000000..f2719247 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.JsonApiDocuments +{ + /// + /// A meta object containing non-standard meta-information about the error. + /// + public sealed class ErrorMeta + { + [JsonExtensionData] + public Dictionary Data { get; } = new Dictionary(); + + public void IncludeExceptionStackTrace(Exception exception) + { + 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 new file mode 100644 index 00000000..b5eb4c3f --- /dev/null +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +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] + public string Pointer { get; set; } + + /// + /// Optional. A string indicating which URI query parameter caused the error. + /// + [JsonProperty] + public string Parameter { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs index 567f24e9..1b7ac1c9 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models { - public class ExposableData where T : class + public abstract class ExposableData where T : class { /// /// see "primary data" in https://jsonapi.org/format/#document-top-level. diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs index b8512844..f559cf9a 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs @@ -4,10 +4,10 @@ namespace JsonApiDotNetCore.Models { - public class Identifiable : Identifiable + public abstract class Identifiable : Identifiable { } - public class Identifiable : IIdentifiable + public abstract class Identifiable : IIdentifiable { /// /// The resource identifier diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs index 22c8d12f..adb2ddbc 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models.Links /// /// see links section in https://jsonapi.org/format/#document-top-level /// - public class TopLevelLinks + public sealed class TopLevelLinks { [JsonProperty("self")] public string Self { get; set; } diff --git a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs index 7b618ea6..2f43e830 100644 --- a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs +++ b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs @@ -2,6 +2,7 @@ namespace JsonApiDotNetCore.Models { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class ResourceAttribute : Attribute { public ResourceAttribute(string resourceName) diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs index edabd64e..1b0afb96 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs @@ -4,11 +4,16 @@ namespace JsonApiDotNetCore.Services { /// - /// Responsible for populating the various service implementations of - /// . + /// Responsible for populating the various service implementations of . /// public interface IQueryParameterParser { - void Parse(DisableQueryAttribute disabledQuery = null); + /// + /// Parses the parameters from the request query string. + /// + /// + /// The if set on the controller that is targeted by the current request. + /// + void Parse(DisableQueryAttribute disableQueryAttribute); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs index 64df236a..a67e56ce 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs @@ -1,21 +1,26 @@ -using System.Collections.Generic; +using JsonApiDotNetCore.Controllers; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query { /// - /// Base interface that all query parameter services should inherit. + /// The interface to implement for parsing specific query string parameters. /// public interface IQueryParameterService { /// - /// Parses the value of the query parameter. Invoked in the middleware. + /// Indicates whether using this service is blocked using on a controller. /// - /// the value of the query parameter as retrieved from the url - void Parse(KeyValuePair queryParameter); + bool IsEnabled(DisableQueryAttribute disableQueryAttribute); + + /// + /// Indicates whether this service supports parsing the specified query string parameter. + /// + bool CanParse(string parameterName); + /// - /// The name of the query parameter as matched in the URL query string. + /// Parses the value of the query string parameter. /// - string Name { get; } + void Parse(string parameterName, StringValues parameterValue); } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs index fe3b830f..572e425b 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs @@ -1,10 +1,11 @@ -using System; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.QueryParameterServices.Common; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Services { @@ -14,53 +15,51 @@ 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(); } - /// - /// 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) + if (string.IsNullOrEmpty(pair.Value)) { - if (pair.Key.ToLower().StartsWith(service.Name, StringComparison.Ordinal)) - { - if (disabledQuery == null || !IsDisabled(disabledQuery, service)) - service.Parse(pair); - parsed = true; - break; - } + throw new InvalidQueryStringParameterException(pair.Key, "Missing query string parameter value.", + $"Missing value for '{pair.Key}' query string parameter."); } - if (parsed) - continue; - if (!_options.AllowCustomQueryParameters) - throw new JsonApiException(400, $"{pair} is not a valid query."); - } - } - - private bool IsDisabled(string disabledQuery, IQueryParameterService targetsService) - { - if (disabledQuery == QueryParams.All.ToString("G").ToLower()) - return true; + 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 (disabledQuery == targetsService.Name) - return true; + if (!service.IsEnabled(disableQueryAttribute)) + { + 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."); + } - return false; + service.Parse(pair.Key, pair.Value); + _logger.LogDebug($"Query string parameter '{pair.Key}' was successfully parsed."); + } + else if (!_options.AllowCustomQueryStringParameters) + { + 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/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index 3bf99c73..a2f1bb64 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -1,5 +1,5 @@ -using System.Linq; -using System.Text.RegularExpressions; +using System.Linq; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; @@ -16,6 +16,8 @@ public abstract class QueryParameterService protected readonly ResourceContext _requestResource; private readonly ResourceContext _mainRequestResource; + protected QueryParameterService() { } + protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) { _mainRequestResource = currentRequest.GetRequestResource(); @@ -25,35 +27,21 @@ 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 /// - 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(400, $"'{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; } @@ -61,12 +49,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(400, $"{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; } @@ -74,11 +66,13 @@ 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(400, $"Query parameter {Name} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{Name}=...'"); + 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/Contracts/IOmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs index eab63994..ec8213fd 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitDefaultService.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Query { /// /// Query parameter service responsible for url queries of the form ?omitDefault=true @@ -6,8 +6,8 @@ public interface IOmitDefaultService : IQueryParameterService { /// - /// Gets the parsed config + /// Contains the effective value of default configuration and query string override, after parsing has occured. /// - bool Config { get; } + bool OmitAttributeIfValueIsDefault { get; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs index 519d8add..1b21ee8b 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IOmitNullService.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Query { /// /// Query parameter service responsible for url queries of the form ?omitNull=true @@ -6,8 +6,8 @@ public interface IOmitNullService : IQueryParameterService { /// - /// Gets the parsed config + /// Contains the effective value of default configuration and query string override, after parsing has occured. /// - bool Config { get; } + bool OmitAttributeIfValueIsNull { get; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index 2a10a698..94c0a9fe 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; @@ -29,14 +30,26 @@ public List Get() } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - EnsureNoNestedResourceRoute(); - var queries = GetFilterQueries(queryParameter); - _filters.AddRange(queries.Select(GetQueryContexts)); + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Filter); } - private FilterQueryContext GetQueryContexts(FilterQuery query) + /// + public bool CanParse(string parameterName) + { + return parameterName.StartsWith("filter[") && parameterName.EndsWith("]"); + } + + /// + public virtual void Parse(string parameterName, StringValues parameterValue) + { + EnsureNoNestedResourceRoute(parameterName); + var queries = GetFilterQueries(parameterName, parameterValue); + _filters.AddRange(queries.Select(x => GetQueryContexts(x, parameterName))); + } + + private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterName) { var queryContext = new FilterQueryContext(query); var customQuery = _requestResourceDefinition?.GetCustomQueryFilter(query.Target); @@ -47,34 +60,37 @@ private FilterQueryContext GetQueryContexts(FilterQuery query) 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) + { + throw new InvalidQueryStringParameterException(parameterName, "Filtering on the requested attribute is not allowed.", + $"Filtering on attribute '{attribute.PublicAttributeName}' is not allowed."); + } - if (attribute.IsFilterable == false) - throw new JsonApiException(400, $"Filter is not allowed for attribute '{attribute.PublicAttributeName}'."); queryContext.Attribute = attribute; return queryContext; } /// 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); - if (string.Equals(op, FilterOperation.@in.ToString(), StringComparison.OrdinalIgnoreCase) - || string.Equals(op, FilterOperation.nin.ToString(), StringComparison.OrdinalIgnoreCase)) + string op = GetFilterOperation(parameterValue); + if (op == FilterOperation.@in.ToString() || op == FilterOperation.nin.ToString()) { - 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 4032da4c..a2f54d55 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; @@ -26,18 +27,27 @@ public List> Get() } /// - public virtual void Parse(KeyValuePair queryParameter) + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - var value = (string)queryParameter.Value; - if (string.IsNullOrWhiteSpace(value)) - throw new JsonApiException(400, "Include parameter must not be empty if provided"); + 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; 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); @@ -46,26 +56,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(400, $"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}", - $"{resourceContext.ResourceName} does not have a relationship named {requestedRelationship}"); - } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs index 0887f414..80d26127 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitDefaultService.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -11,23 +12,36 @@ 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 InvalidQueryStringParameterException(parameterName, + "The specified query string value must be 'true' or 'false'.", + $"The value '{parameterValue}' for parameter '{parameterName}' is not a valid boolean value."); + } - Config = config; + OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs index 57d69866..3fb26dec 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/OmitNullService.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -11,23 +12,37 @@ 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 InvalidQueryStringParameterException(parameterName, + "The specified query string value must be 'true' or 'false'.", + $"The value '{parameterValue}' for parameter '{parameterName}' is not a valid boolean value."); + } - Config = config; + OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; } } } diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs index d7da6292..ad3d6254 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; @@ -63,61 +63,80 @@ 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]; + + 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" + + Backwards = number < 0; + CurrentPage = Backwards ? -number : number; + } + } - const string SIZE = "size"; - const string NUMBER = "number"; + private int ParsePageSize(string parameterValue, int? maxValue) + { + bool success = int.TryParse(parameterValue, out int number); + int minValue = maxValue != null ? 1 : 0; - if (propertyName == SIZE) + if (success && number >= minValue) { - if (!int.TryParse(queryParameter.Value, out var size)) + if (maxValue == null || number <= maxValue) { - ThrowBadPagingRequest(queryParameter, "value could not be parsed as an integer"); - } - else if (size < 1) - { - ThrowBadPagingRequest(queryParameter, "value needs to be greater than zero"); - } - else if (size > _options.MaximumPageSize) - { - ThrowBadPagingRequest(queryParameter, $"page size cannot be higher than {_options.MaximumPageSize}."); - } - else - { - RequestedPageSize = size; + return number; } } - else if (propertyName == NUMBER) - { - if (!int.TryParse(queryParameter.Value, out var number)) - { - ThrowBadPagingRequest(queryParameter, "value could not be parsed as an integer"); - } - else if (number == 0) - { - ThrowBadPagingRequest(queryParameter, "page index is not zero-based"); - } - else if (number > _options.MaximumPageNumber) - { - ThrowBadPagingRequest(queryParameter, $"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 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); + } + + 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(KeyValuePair parameter, string message) - { - throw new JsonApiException(400, $"Invalid page query parameter '{parameter.Key}={parameter.Value}': {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 fe136993..54e0d555 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 JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; @@ -24,15 +25,6 @@ public class SortService : QueryParameterService, ISortService _queries = new List(); } - /// - public virtual void Parse(KeyValuePair queryParameter) - { - EnsureNoNestedResourceRoute(); - var queries = BuildQueries(queryParameter.Value); - - _queries = queries.Select(BuildQueryContext).ToList(); - } - /// public List Get() { @@ -45,13 +37,36 @@ public List Get() return _queries.ToList(); } - private List BuildQueries(string value) + /// + 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, parameterName); + + _queries = queries.Select(BuildQueryContext).ToList(); + } + + 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(400, "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) { @@ -72,11 +87,14 @@ private List BuildQueries(string value) 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 == false) - throw new JsonApiException(400, $"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 db669192..f88e3c1e 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; @@ -21,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(); @@ -40,20 +40,36 @@ 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) + { + var isRelated = parameterName.StartsWith("fields[") && parameterName.EndsWith("]"); + return parameterName == "fields" || isRelated; + } + + /// + 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(); - var fields = new List { nameof(Identifiable.Id) }; - fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA)); + EnsureNoNestedResourceRoute(parameterName); + + HashSet fields = new HashSet(); + fields.Add(nameof(Identifiable.Id).ToLowerInvariant()); + 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 foreach (var field in fields) - RegisterRequestResourceField(field); + RegisterRequestResourceField(field, parameterName); } else { // input format: fields[articles]=prop1,prop2 @@ -62,31 +78,45 @@ 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}]':" + - " 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(400, $"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(400, $"'{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(400, $"'{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()); @@ -96,11 +126,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(400, $"'{_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/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 2ab3e476..fbabd46d 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; @@ -133,14 +134,13 @@ private IIdentifiable ParseResourceObject(ResourceObject data) var resourceContext = _provider.GetResourceContext(data.Type); if (resourceContext == null) { - throw new JsonApiException(400, - 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.", null); } - 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); @@ -234,7 +234,7 @@ private void SetNavigation(IIdentifiable entity, HasOneAttribute attr, string re 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/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs index d62434b8..d4942d5c 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; @@ -28,7 +27,7 @@ public ResourceObject Build(IIdentifiable entity, IEnumerable att var resourceContext = _provider.GetResourceContext(entity.GetType()); // populating the top-level "type" and "id" members. - var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = entity.StringId.NullIfEmpty() }; + var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = entity.StringId == string.Empty ? null : entity.StringId }; // populating the top-level "attribute" member of a resource object. never include "id" as an attribute if (attributes != null && (attributes = attributes.Where(attr => attr.PropertyInfo.Name != _identifiablePropertyName)).Any()) @@ -140,7 +139,7 @@ private void ProcessAttributes(IIdentifiable entity, IEnumerable foreach (var attr in attributes) { var value = attr.GetValue(entity); - if (!(value == default && _settings.OmitDefaultValuedAttributes) && !(value == null && _settings.OmitNullValuedAttributes)) + if (!(value == default && _settings.OmitAttributeIfValueIsDefault) && !(value == null && _settings.OmitAttributeIfValueIsNull)) ro.Attributes.Add(attr.PublicAttributeName, value); } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs index 2e84b798..e7583660 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCore.Serialization /// public sealed class ResourceObjectBuilderSettings { - /// Omit null values from attributes - /// Omit default values from attributes - public ResourceObjectBuilderSettings(bool omitNullValuedAttributes = false, bool omitDefaultValuedAttributes = false) + /// Omit null values from attributes + /// Omit default values from attributes + public ResourceObjectBuilderSettings(bool omitAttributeIfValueIsNull = false, bool omitAttributeIfValueIsDefault = false) { - OmitNullValuedAttributes = omitNullValuedAttributes; - OmitDefaultValuedAttributes = omitDefaultValuedAttributes; + OmitAttributeIfValueIsNull = omitAttributeIfValueIsNull; + OmitAttributeIfValueIsDefault = omitAttributeIfValueIsDefault; } /// @@ -26,7 +26,7 @@ public ResourceObjectBuilderSettings(bool omitNullValuedAttributes = false, bool /// options.NullAttributeResponseBehavior = new NullAttributeResponseBehavior(true); /// /// - public bool OmitNullValuedAttributes { get; } + public bool OmitAttributeIfValueIsNull { get; } /// /// Prevent attributes with default values from being included in the response. @@ -38,8 +38,6 @@ public ResourceObjectBuilderSettings(bool omitNullValuedAttributes = false, bool /// options.DefaultAttributeResponseBehavior = new DefaultAttributeResponseBehavior(true); /// /// - public bool OmitDefaultValuedAttributes { get; } + public bool OmitAttributeIfValueIsDefault { get; } } - } - diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index 7f0b25d0..c2f95099 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -57,7 +57,7 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r // if links relationshipLinks should be built for this entry, populate the "links" field. (relationshipEntry ??= new RelationshipEntry()).Links = links; - // if neither "links" nor "data" was popupated, return null, which will omit this entry from the output. + // if neither "links" nor "data" was populated, return null, which will omit this entry from the output. // (see the NullValueHandling settings on ) return relationshipEntry; } diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs index eaddf818..d75e7bde 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs @@ -22,7 +22,7 @@ public sealed class ResourceObjectBuilderSettingsProvider : IResourceObjectBuild /// public ResourceObjectBuilderSettings Get() { - return new ResourceObjectBuilderSettings(_nullAttributeValues.Config, _defaultAttributeValues.Config); + return new ResourceObjectBuilderSettings(_nullAttributeValues.OmitAttributeIfValueIsNull, _defaultAttributeValues.OmitAttributeIfValueIsDefault); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index cca8487f..3891e31c 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -1,11 +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 { @@ -32,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 ErrorCollection error) - return error.GetJson(); + if (data is ErrorDocument errorDocument) + 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 /// @@ -65,7 +83,7 @@ public string Serialize(object data) /// 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(); @@ -158,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/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs b/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs index 52e4ca17..8ee8c11b 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs @@ -10,6 +10,6 @@ public interface IDeleteService : IDeleteService public interface IDeleteService where T : class, IIdentifiable { - Task DeleteAsync(TId id); + Task DeleteAsync(TId id); } } diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index d1c7c155..7466b4c4 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Extensions; @@ -52,12 +53,12 @@ public class 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); @@ -72,18 +73,28 @@ public virtual async Task CreateAsync(TResource entity) return entity; } - public virtual async Task DeleteAsync(TId id) + public virtual async Task DeleteAsync(TId id) { - var entity = (TResource)Activator.CreateInstance(typeof(TResource)); + _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() { + _logger.LogTrace($"Entering {nameof(GetAsync)}()."); + _hookExecutor?.BeforeRead(ResourcePipeline.Get); var entityQuery = _repository.Get(); @@ -109,6 +120,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()); @@ -117,32 +130,38 @@ 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; } // 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 _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(404, $"Relationship '{relationshipName}' not found."); + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); } if (!IsNull(_hookExecutor, entity)) @@ -157,6 +176,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); @@ -164,8 +185,17 @@ 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 (entity == null) + { + string resourceId = TypeExtensions.GetResourceStringId(id); + throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); + } + if (!IsNull(_hookExecutor, entity)) { _hookExecutor.AfterUpdate(AsList(entity), ResourcePipeline.Patch); @@ -177,11 +207,17 @@ 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); + if (entity == null) - throw new JsonApiException(404, $"Entity 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(); @@ -200,8 +236,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); } @@ -211,8 +249,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); } @@ -332,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(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); + { + throw new RelationshipNotFoundException(relationshipName, _currentRequestResource.ResourceName); + } return relationship; } diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index 975c8702..65ea08df 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -1,4 +1,3 @@ -using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Http; using System; @@ -27,11 +26,13 @@ public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) public object GetService(Type serviceType) { if (_httpContextAccessor.HttpContext == null) - throw new JsonApiException(500, - "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 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/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs new file mode 100644 index 00000000..3ed690b9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + [Collection("WebHostCollection")] + public sealed class ActionResultTests + { + private readonly TestFixture _fixture; + + public ActionResultTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task ActionResult_With_Error_Object_Is_Converted_To_Error_Collection() + { + // Arrange + var route = "/abstract"; + var request = new HttpRequestMessage(HttpMethod.Post, route); + var content = new + { + data = new + { + type = "todoItems", + id = 1, + attributes = new Dictionary + { + {"ordinal", 1} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("NotFound ActionResult with explicit error object.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Empty_ActionResult_Is_Converted_To_Error_Collection() + { + // Arrange + var route = "/abstract/123"; + var request = new HttpRequestMessage(HttpMethod.Delete, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("NotFound", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index a5dacd39..cb047ff9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -1,7 +1,11 @@ +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; using JsonApiDotNetCoreExample.Models; @@ -130,5 +134,45 @@ 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 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.Patch, route) {Content = new StringContent(content)}; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.ContentType); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + 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/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs new file mode 100644 index 00000000..cedd7d61 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Exceptions; +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.Contains("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; + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/NullValuedAttributeHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/NullValuedAttributeHandlingTests.cs deleted file mode 100644 index 1aaa56a3..00000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/NullValuedAttributeHandlingTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - [Collection("WebHostCollection")] - public sealed class NullValuedAttributeHandlingTests : IAsyncLifetime - { - private readonly TestFixture _fixture; - private readonly AppDbContext _dbContext; - private readonly TodoItem _todoItem; - - public NullValuedAttributeHandlingTests(TestFixture fixture) - { - _fixture = fixture; - _dbContext = fixture.GetService(); - var person = new Person { FirstName = "Bob", LastName = null }; - _todoItem = new TodoItem - { - Description = null, - Ordinal = 1, - CreatedDate = DateTime.Now, - AchievedDate = DateTime.Now.AddDays(2), - Owner = person - }; - _todoItem = _dbContext.TodoItems.Add(_todoItem).Entity; - } - - public async Task InitializeAsync() - { - await _dbContext.SaveChangesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - [Theory] - [InlineData(null, null, null, false)] - [InlineData(true, null, null, true)] - [InlineData(false, true, "true", true)] - [InlineData(false, false, "true", false)] - [InlineData(true, true, "false", false)] - [InlineData(true, false, "false", true)] - [InlineData(null, false, "false", false)] - [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, null, false)] - [InlineData(null, false, null, false)] - public async Task CheckNullBehaviorCombination(bool? omitNullValuedAttributes, bool? allowClientOverride, - string clientOverride, bool omitsNulls) - { - - // 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); - 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 route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - 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")); - - } - } - -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs new file mode 100644 index 00000000..ec98d103 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/OmitAttributeIfValueIsNullTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility +{ + [Collection("WebHostCollection")] + public sealed class OmitAttributeIfValueIsNullTests : IAsyncLifetime + { + private readonly TestFixture _fixture; + private readonly AppDbContext _dbContext; + private readonly TodoItem _todoItem; + + public OmitAttributeIfValueIsNullTests(TestFixture fixture) + { + _fixture = fixture; + _dbContext = fixture.GetService(); + var person = new Person { FirstName = "Bob", LastName = null }; + _todoItem = new TodoItem + { + Description = null, + Ordinal = 1, + CreatedDate = DateTime.Now, + AchievedDate = DateTime.Now.AddDays(2), + Owner = person + }; + _todoItem = _dbContext.TodoItems.Add(_todoItem).Entity; + } + + public async Task InitializeAsync() + { + await _dbContext.SaveChangesAsync(); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + [Theory] + [InlineData(null, null, null, false)] + [InlineData(true, null, null, true)] + [InlineData(false, true, "true", true)] + [InlineData(false, false, "true", false)] + [InlineData(true, true, "false", false)] + [InlineData(true, false, "false", true)] + [InlineData(null, false, "false", false)] + [InlineData(null, false, "true", false)] + [InlineData(null, true, "true", true)] + [InlineData(null, true, "false", false)] + [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? omitAttributeIfValueIsNull, bool? allowQueryStringOverride, + string queryStringOverride, bool expectNullsMissing) + { + + // Override some null handling options + NullAttributeResponseBehavior nullAttributeResponseBehavior; + 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; + + var httpMethod = new HttpMethod("GET"); + var queryString = allowQueryStringOverride.HasValue + ? $"&omitNull={queryStringOverride}" + : ""; + var route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + 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 (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/HttpMethodRestrictions/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs index 3171edda..c7428dfa 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,17 @@ public async Task Rejects_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -47,10 +56,17 @@ public async Task Rejects_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -61,13 +77,20 @@ public async Task Rejects_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +99,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs index 0702a77c..d58df911 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,10 @@ public async Task Allows_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -47,10 +49,10 @@ public async Task Allows_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -61,13 +63,20 @@ public async Task Rejects_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +85,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs index 97975c50..e8c1b5bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,10 @@ public async Task Allows_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -47,10 +49,17 @@ public async Task Rejects_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -61,13 +70,13 @@ public async Task Allows_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +85,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs index 8aa2be9c..c7d2d074 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -19,10 +21,10 @@ public async Task Allows_GET_Requests() const string method = "GET"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -33,10 +35,17 @@ public async Task Rejects_POST_Requests() const string method = "POST"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.MethodNotAllowed, statusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); + Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); } [Fact] @@ -47,10 +56,10 @@ public async Task Allows_PATCH_Requests() const string method = "PATCH"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] @@ -61,13 +70,13 @@ public async Task Allows_DELETE_Requests() const string method = "DELETE"; // Act - var statusCode = await MakeRequestAsync(route, method); + var response = await MakeRequestAsync(route, method); // Assert - Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task MakeRequestAsync(string route, string method) + private async Task MakeRequestAsync(string route, string method) { var builder = new WebHostBuilder() .UseStartup(); @@ -76,7 +85,7 @@ private async Task MakeRequestAsync(string route, string method) var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); var response = await client.SendAsync(request); - return response.StatusCode; + return response; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index 2aaf4ef4..792de4bf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -3,6 +3,8 @@ using Bogus; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Acceptance.Spec; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -84,5 +86,22 @@ public async Task KebabCaseFormatter_Update_IsUpdated() var responseItem = _deserializer.DeserializeSingle(body).Data; Assert.Equal(model.CompoundAttr, responseItem.CompoundAttr); } + + [Fact] + public async Task KebabCaseFormatter_ErrorWithStackTrace_CasingConventionIsApplied() + { + // Arrange + const string content = "{ \"data\": {"; + + // Act + var (body, response) = await Patch($"api/v1/kebab-cased-models/1", content); + + // Assert + var document = JsonConvert.DeserializeObject(body); + AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); + + var meta = document["errors"][0]["meta"]; + Assert.NotNull(meta["stack-trace"]); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs new file mode 100644 index 00000000..5a13fa3e --- /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_invalid_name_it_must_fail() + { + // Arrange + var tag = new Tag + { + Name = "!@#$%^&*().-" + }; + + 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(HeaderConstants.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(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); + } + + [Fact] + public async Task When_posting_tag_with_invalid_name_without_model_state_validation_it_must_succeed() + { + // Arrange + var tag = new Tag + { + Name = "!@#$%^&*().-" + }; + + 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(HeaderConstants.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_invalid_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 = "!@#$%^&*().-" + }; + + 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(HeaderConstants.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(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); + } + + [Fact] + public async Task When_patching_tag_with_invalid_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 = "!@#$%^&*().-" + }; + + 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(HeaderConstants.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/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 4dc6c682..c5b3195b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -149,7 +150,13 @@ public async Task Unauthorized_TodoItem() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to update the author of todo items.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -163,7 +170,13 @@ public async Task Unauthorized_Passport() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to include passports on individual persons.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -185,8 +198,13 @@ public async Task Unauthorized_Article() // Assert var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.Forbidden == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); + Assert.Equal("You are not allowed to see this article.", errorDocument.Errors[0].Title); + Assert.Null(errorDocument.Errors[0].Detail); } [Fact] @@ -196,7 +214,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 +241,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[] @@ -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/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index 2f77e2f9..25165bc9 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,61 @@ 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].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); + } + + [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] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs index 7998df34..1266afbf 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].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/ContentNegotiation.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs index dc026a6f..930b301f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiation.cs @@ -2,9 +2,11 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -50,7 +52,14 @@ public async Task Server_Responds_415_With_MediaType_Parameters() var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Content-Type header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' for the Content-Type header value.", errorDocument.Errors[0].Detail); } [Fact] @@ -73,7 +82,14 @@ public async Task ServerResponds_406_If_RequestAcceptHeader_Contains_MediaTypePa var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotAcceptable, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified Accept header value is not supported.", errorDocument.Errors[0].Title); + Assert.Equal("Please specify 'application/vnd.api+json' for the Accept header value.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index d1acf36c..d9d4f0cb 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; @@ -72,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] @@ -214,14 +222,51 @@ 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].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); + } + + [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); + Assert.Contains("Request body: <<", errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index eb18ce54..f78c7d4f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -1,11 +1,12 @@ -using System.Linq; 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 @@ -24,8 +25,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(); @@ -34,14 +35,21 @@ 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 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("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/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs new file mode 100644 index 00000000..5a360186 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public sealed class DisableQueryAttributeTests + { + private readonly TestFixture _fixture; + + public DisableQueryAttributeTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Cannot_Sort_If_Blocked_By_Controller() + { + // Arrange + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/articles?sort=name"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); + Assert.Equal("The parameter 'sort' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); + Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); + } + + [Fact] + public async Task Cannot_Use_Custom_Query_Parameter_If_Blocked_By_Controller() + { + // Arrange + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/tags?skipCache=true"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); + Assert.Equal("The parameter 'skipCache' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); + Assert.Equal("skipCache", errorDocument.Errors[0].Source.Parameter); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs index 0c844e0d..23472800 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -327,19 +328,22 @@ public async Task Request_ToIncludeUnknownRelationship_Returns_400() var route = $"/api/v1/people/{person.Id}?include=nonExistentRelationship"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + using var server = new TestServer(builder); + using var client = server.CreateClient(); + using var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - server.Dispose(); - request.Dispose(); - response.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'nonExistentRelationship' on 'people' does not exist.", errorDocument.Errors[0].Detail); } [Fact] @@ -355,19 +359,22 @@ public async Task Request_ToIncludeDeeplyNestedRelationships_Returns_400() var route = $"/api/v1/people/{person.Id}?include=owner.name"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + using var server = new TestServer(builder); + using var client = server.CreateClient(); + using var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - server.Dispose(); - request.Dispose(); - response.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'owner' on 'people' does not exist.", errorDocument.Errors[0].Detail); } [Fact] @@ -383,19 +390,22 @@ public async Task Request_ToIncludeRelationshipMarkedCanIncludeFalse_Returns_400 var route = $"/api/v1/people/{person.Id}?include=unincludeableItem"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); + using var server = new TestServer(builder); + using var client = server.CreateClient(); + using var request = new HttpRequestMessage(httpMethod, route); // Act var response = await client.SendAsync(request); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - server.Dispose(); - request.Dispose(); - response.Dispose(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The relationship 'unincludeableItem' on 'people' does not exist.", errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index 96b6d3d6..782a42eb 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -8,9 +8,9 @@ using Xunit; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; -using System.Linq; using Bogus; using JsonApiDotNetCoreExample.Models; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { @@ -19,6 +19,7 @@ public sealed class Relationships { private readonly AppDbContext _context; private readonly Faker _todoItemFaker; + private readonly Faker _personFaker; public Relationships(TestFixture fixture) { @@ -27,32 +28,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 +69,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 +76,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 +90,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 +98,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 +130,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 +146,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); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index 40b5bd0f..13ef6a4f 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; @@ -103,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(); @@ -121,11 +125,11 @@ 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] - public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFoundWithNullData() + public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() { // Arrange var context = _fixture.GetService(); @@ -142,12 +146,16 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFoundWithNull // Act var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Null(document.Data); + + 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 '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 3c816d1e..55552a69 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -1,12 +1,16 @@ +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; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec @@ -27,66 +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 context = _fixture.GetService(); var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = null; + + var context = _fixture.GetService(); context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() - .UseStartup(); - - 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); - var body = await response.Content.ReadAsStringAsync(); // 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.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 Request_ForRelationshipLink_ThatDoesNotExist_Returns_404() + 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 route = $"/api/v1/todoItems/{todoItem.Id}/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); + + // 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 todoItemId = todoItem.Id; - context.TodoItems.Remove(todoItem); + 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); + + var doc = JsonConvert.DeserializeObject(body); + Assert.True(doc.IsManyData); + Assert.Empty(doc.ManyData); + } + + [Fact] + public async Task When_getting_relationship_for_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 builder = new WebHostBuilder() - .UseStartup(); + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/childrenTodos"; - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItemId}/owner"; + 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); - context.Dispose(); + 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); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs index 5fbd8f7e..f920b433 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NestedResourceTests.cs @@ -2,8 +2,10 @@ using System.Net; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -61,14 +63,22 @@ public async Task NestedResourceRoute_RequestWithIncludeQueryParam_ReturnsReques [InlineData("sort=ordinal")] [InlineData("page[number]=1")] [InlineData("page[size]=10")] - public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParam) + public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParameter) { + string parameterName = queryParameter.Split('=')[0]; + // Act - var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParam}"); + var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParameter}"); // Assert - AssertEqualStatusCode(HttpStatusCode.BadRequest, response); - Assert.Contains("currently not supported", body); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.Equal("The specified query string parameter is currently not supported on nested resource endpoints.", errorDocument.Errors[0].Title); + Assert.Equal($"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')", errorDocument.Errors[0].Detail); + Assert.Equal(parameterName, errorDocument.Errors[0].Source.Parameter); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs index 7f38b586..357fe9b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs @@ -7,7 +7,6 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Http; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -93,10 +92,10 @@ 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&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/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs new file mode 100644 index 00000000..7949b61b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs @@ -0,0 +1,127 @@ +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].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); + } + + [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].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); + } + + [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); + } + + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs deleted file mode 100644 index 2cacb7c3..00000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameters.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; -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 errorCollection = 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); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs index 558268aa..7da5372c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -19,6 +19,7 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCoreExampleTests.Helpers.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -131,12 +132,16 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation // Act var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); // Assert + var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Contains("relationships only", body); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); + Assert.StartsWith("Square bracket notation in 'filter' is now reserved for relationships only", errorDocument.Errors[0].Title); + Assert.Equal("Use '?fields=...' instead of '?fields[todoItems]=...'.", errorDocument.Errors[0].Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs new file mode 100644 index 00000000..b582bbe3 --- /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("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()); + + Assert.Contains(stackTraceLines, line => line.Contains( + "System.InvalidOperationException: The value for the 'FailsOnSerialize' property is currently unavailable.")); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index bbe645d1..661fc9c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -5,13 +5,16 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Formatters; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -59,10 +62,20 @@ public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() } [Fact] - public async Task Response400IfUpdatingNotSettableAttribute() + 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(); @@ -78,16 +91,33 @@ public async Task Response400IfUpdatingNotSettableAttribute() var response = await client.SendAsync(request); // Assert - Assert.Equal(422, Convert.ToInt32(response.StatusCode)); + 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.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] 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(); @@ -97,13 +127,20 @@ 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); // 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("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] @@ -118,7 +155,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); @@ -126,8 +163,45 @@ 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); + + 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.StatusCode); + Assert.Equal("Failed to deserialize request body: Payload must include id attribute.", error.Title); + Assert.StartsWith("Request body: <<", 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.StatusCode); + Assert.Equal("Failed to deserialize request body.", error.Title); + Assert.StartsWith("Invalid character after parsing", error.Detail); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index d2c0a46b..945df5b8 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -770,5 +771,82 @@ public async Task Updating_ToMany_Relationship_With_Implicit_Remove() Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem1Id)); Assert.NotNull(dbPerson.TodoItems.SingleOrDefault(ti => ti.Id == todoItem2Id)); } + + [Fact] + public async Task Fails_On_Unknown_Relationship() + { + // Arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(person); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested relationship does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("The resource 'todoItems' does not contain a relationship named 'invalid'.",errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Fails_On_Missing_Resource() + { + // Arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var serializer = _fixture.GetSerializer(p => new { }); + var content = serializer.Serialize(person); + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/99999999/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(content)}; + + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // Act + var response = await client.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); + Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); + Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs new file mode 100644 index 00000000..39d28aae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests +{ + internal sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider + { + public FakeLogger Logger { get; } + + public FakeLoggerFactory() + { + Logger = new FakeLogger(); + } + + public ILogger CreateLogger(string categoryName) => Logger; + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } + + internal sealed class FakeLogger : ILogger + { + public List<(LogLevel LogLevel, string Text)> Messages = new List<(LogLevel, string)>(); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + var message = formatter(state, exception); + Messages.Add((logLevel, message)); + } + + public bool IsEnabled(LogLevel logLevel) => true; + public IDisposable BeginScope(TState state) => null; + } + } +} diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index ccb8eb2a..fc66d36f 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -63,7 +63,7 @@ public void Resources_Without_Names_Specified_Will_Use_Default_Formatter() public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new CamelCaseNameFormatter()); + var builder = new ResourceGraphBuilder(new CamelCaseFormatter()); builder.AddResource(); // Act @@ -93,7 +93,7 @@ public void Attrs_Without_Names_Specified_Will_Use_Default_Formatter() public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange - var builder = new ResourceGraphBuilder(new CamelCaseNameFormatter()); + var builder = new ResourceGraphBuilder(new CamelCaseFormatter()); builder.AddResource(); // Act @@ -128,16 +128,5 @@ public sealed class TestResource : Identifiable } public class RelatedResource : Identifiable { } - - public sealed class CamelCaseNameFormatter : IResourceNameFormatter - { - public string ApplyCasingConvention(string properName) => ToCamelCase(properName); - - public string FormatPropertyName(PropertyInfo property) => ToCamelCase(property.Name); - - public string FormatResourceName(Type resourceType) => ToCamelCase(resourceType.Name.Pluralize()); - - private string ToCamelCase(string str) => Char.ToLowerInvariant(str[0]) + str.Substring(1); - } } } diff --git a/test/UnitTests/Builders/LinkBuilderTests.cs b/test/UnitTests/Builders/LinkBuilderTests.cs index 63dda1b1..89df84eb 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCoreExample.Models; using Moq; @@ -50,7 +49,7 @@ public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Lin { // Arrange var config = GetConfiguration(resourceLinks: global); - var primaryResource = GetResourceContext
(resourceLinks: resource); + var primaryResource = GetArticleResourceContext(resourceLinks: resource); _provider.Setup(m => m.GetResourceContext("articles")).Returns(primaryResource); var builder = new LinkBuilder(config, GetRequestManager(), null, _provider.Object, _queryStringAccessor); @@ -98,7 +97,7 @@ public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Lin { // 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 BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Lin { // 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" }; } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index aec9a5a4..c61dfddb 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Net.Http; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -5,7 +7,7 @@ using Xunit; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -66,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -95,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -124,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -153,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Get, exception.Method); } [Fact] @@ -176,43 +182,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() { @@ -221,10 +190,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Patch, exception.Method); } [Fact] @@ -245,53 +215,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() { @@ -315,10 +238,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Patch, exception.Method); } [Fact] @@ -344,10 +268,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(405, exception.GetStatusCode()); + Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); + Assert.Equal(HttpMethod.Delete, exception.Method); } } } diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs index 51fb451c..46d5ecf7 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc; using Xunit; @@ -13,44 +14,41 @@ public sealed class JsonApiControllerMixin_Tests : JsonApiControllerMixin 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"), - } + var errors422 = new List + { + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad specific"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "bad other specific"} }; - var errors400 = new ErrorCollection { - Errors = new List { - new Error(200, "weird"), - new Error(400, "bad"), - new Error(422, "bad specific"), - } + var errors400 = new List + { + new Error(HttpStatusCode.OK) {Title = "weird"}, + new Error(HttpStatusCode.BadRequest) {Title = "bad"}, + new Error(HttpStatusCode.UnprocessableEntity) {Title = "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"), - } + var errors500 = new List + { + 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 - var result422 = this.Errors(errors422); - var result400 = this.Errors(errors400); - var result500 = this.Errors(errors500); + var result422 = Error(errors422); + var result400 = Error(errors400); + var result500 = Error(errors500); // Assert var response422 = Assert.IsType(result422); 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/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index d42be16d..0791f8cb 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -160,7 +160,7 @@ public class GuidResource : Identifiable { } private class IntResourceService : IResourceService { public Task CreateAsync(IntResource entity) => throw new NotImplementedException(); - public Task DeleteAsync(int id) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); @@ -172,7 +172,7 @@ private class IntResourceService : IResourceService private class GuidResourceService : IResourceService { public Task CreateAsync(GuidResource entity) => throw new NotImplementedException(); - public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); public Task> GetAsync() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); diff --git a/test/UnitTests/Internal/ErrorDocumentTests.cs b/test/UnitTests/Internal/ErrorDocumentTests.cs new file mode 100644 index 00000000..a8b2946c --- /dev/null +++ b/test/UnitTests/Internal/ErrorDocumentTests.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Net; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Xunit; + +namespace UnitTests.Internal +{ + public sealed class ErrorDocumentTests + { + [Fact] + public void Can_GetStatusCode() + { + List errors = new List(); + + // Add First 422 error + errors.Add(new Error(HttpStatusCode.UnprocessableEntity) {Title = "Something wrong"}); + 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, new ErrorDocument(errors).GetErrorStatusCode()); + + // Add 4xx error not 422 + errors.Add(new Error(HttpStatusCode.Unauthorized) {Title = "Unauthorized"}); + 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, new ErrorDocument(errors).GetErrorStatusCode()); + } + } +} diff --git a/test/UnitTests/Internal/JsonApiException_Test.cs b/test/UnitTests/Internal/JsonApiException_Test.cs deleted file mode 100644 index 989db27e..00000000 --- a/test/UnitTests/Internal/JsonApiException_Test.cs +++ /dev/null @@ -1,31 +0,0 @@ -using JsonApiDotNetCore.Internal; -using Xunit; - -namespace UnitTests.Internal -{ - public sealed class JsonApiException_Test - { - [Fact] - public void Can_GetStatusCode() - { - var errors = new ErrorCollection(); - var exception = new JsonApiException(errors); - - // Add First 422 error - errors.Add(new Error(422, "Something wrong")); - Assert.Equal(422, exception.GetStatusCode()); - - // Add a second 422 error - errors.Add(new Error(422, "Something else wrong")); - Assert.Equal(422, exception.GetStatusCode()); - - // Add 4xx error not 422 - errors.Add(new Error(401, "Unauthorized")); - Assert.Equal(400, exception.GetStatusCode()); - - // Add 5xx error not 4xx - errors.Add(new Error(502, "Not good")); - Assert.Equal(500, exception.GetStatusCode()); - } - } -} diff --git a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs new file mode 100644 index 00000000..2b5fbe91 --- /dev/null +++ b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs @@ -0,0 +1,29 @@ +using System; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +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(IIdentifiable)); + + // Assert + var exception = Assert.Throws(action); + + 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/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs index 120c0cd4..73c36821 100644 --- a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -71,27 +71,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(400, exception.GetStatusCode()); - Assert.Contains(baseId, exception.Message); - } - private sealed class InvokeConfiguration { public CurrentRequestMiddleware MiddleWare; diff --git a/test/UnitTests/Models/ConstructionTests.cs b/test/UnitTests/Models/ConstructionTests.cs new file mode 100644 index 00000000..401472e4 --- /dev/null +++ b/test/UnitTests/Models/ConstructionTests.cs @@ -0,0 +1,48 @@ +using System; +using JsonApiDotNetCore.Builders; +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("Failed to create an instance of 'UnitTests.Models.ConstructionTests+ResourceWithParameters' using its default constructor.", exception.Message); + } + + public class ResourceWithParameters : Identifiable + { + [Attr] public string Title { get; } + + public ResourceWithParameters(string title) + { + Title = title; + } + } + } +} diff --git a/test/UnitTests/QueryParameters/FilterServiceTests.cs b/test/UnitTests/QueryParameters/FilterServiceTests.cs index df739012..ce62ac9e 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] @@ -46,11 +59,11 @@ 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 - filterService.Parse(query); + filterService.Parse(query.Key, query.Value); var filter = filterService.Get().Single(); // Assert diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index d2eef40f..ad3aee7d 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Linq; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; @@ -10,23 +12,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_IncludeService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - var name = filterService.Name; + bool result = service.CanParse("include"); // Assert - Assert.Equal("include", name); + Assert.True(result); + } + + [Fact] + public void CanParse_IncludeService_FailOnMismatch() + { + // Arrange + var service = GetService(); + + // Act + bool result = service.CanParse("includes"); + + // Assert + Assert.False(result); } [Fact] @@ -34,11 +48,11 @@ 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 - service.Parse(query); + service.Parse(query.Key, query.Value); // Assert var chains = service.Get(); @@ -56,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)); - 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.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); } [Fact] @@ -69,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)); - 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.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); } [Fact] @@ -82,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)); - Assert.Contains("Invalid", exception.Message); - } - - [Fact] - public void Parse_EmptyChain_ThrowsJsonApiException() - { - // Arrange - const string chain = ""; - var query = new KeyValuePair("include", new StringValues(chain)); - var service = GetService(); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - // Act, assert - var exception = Assert.Throws(() => service.Parse(query)); - Assert.Contains("Include parameter must not be empty if provided", exception.Message); + Assert.Equal("include", exception.QueryParameterName); + 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/OmitDefaultService.cs b/test/UnitTests/QueryParameters/OmitDefaultService.cs deleted file mode 100644 index 32b5aa09..00000000 --- a/test/UnitTests/QueryParameters/OmitDefaultService.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Primitives; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class OmitDefaultServiceTests : QueryParametersUnitTestCollection - { - public OmitDefaultService GetService(bool @default, bool @override) - { - var options = new JsonApiOptions - { - DefaultAttributeResponseBehavior = new DefaultAttributeResponseBehavior(@default, @override) - }; - - return new OmitDefaultService(options); - } - - [Fact] - public void Name_OmitNullService_IsCorrect() - { - // Arrange - var service = GetService(true, true); - - // Act - var name = service.Name; - - // Assert - Assert.Equal("omitdefault", name); - } - - [Theory] - [InlineData("false", true, true, false)] - [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) - { - // Arrange - var query = new KeyValuePair("omitNull", new StringValues(queryConfig)); - var service = GetService(@default, @override); - - // Act - service.Parse(query); - - // Assert - Assert.Equal(expected, service.Config); - } - } -} diff --git a/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs new file mode 100644 index 00000000..75ee3517 --- /dev/null +++ b/test/UnitTests/QueryParameters/OmitDefaultServiceTests.cs @@ -0,0 +1,88 @@ +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; + +namespace UnitTests.QueryParameters +{ + public sealed class OmitDefaultServiceTests : QueryParametersUnitTestCollection + { + public OmitDefaultService GetService(bool @default, bool @override) + { + var options = new JsonApiOptions + { + DefaultAttributeResponseBehavior = new DefaultAttributeResponseBehavior(@default, @override) + }; + + return new OmitDefaultService(options); + } + + [Fact] + public void CanParse_OmitDefaultService_SucceedOnMatch() + { + // Arrange + var service = GetService(true, true); + + // Act + bool result = service.CanParse("omitDefault"); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanParse_OmitDefaultService_FailOnMismatch() + { + // Arrange + var service = GetService(true, true); + + // Act + bool result = service.CanParse("omit-default"); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("false", true, true, false)] + [InlineData("false", true, false, true)] + [InlineData("true", false, true, true)] + [InlineData("true", false, false, false)] + public void Parse_QueryConfigWithApiSettings_CanParse(string queryValue, bool @default, bool @override, bool expected) + { + // Arrange + var query = new KeyValuePair("omitDefault", queryValue); + var service = GetService(@default, @override); + + // Act + if (service.CanParse(query.Key) && service.IsEnabled(DisableQueryAttribute.Empty)) + { + service.Parse(query.Key, query.Value); + } + + // 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/OmitNullService.cs b/test/UnitTests/QueryParameters/OmitNullService.cs deleted file mode 100644 index 98758cd1..00000000 --- a/test/UnitTests/QueryParameters/OmitNullService.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Primitives; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class OmitNullServiceTests : QueryParametersUnitTestCollection - { - public OmitNullService GetService(bool @default, bool @override) - { - var options = new JsonApiOptions - { - NullAttributeResponseBehavior = new NullAttributeResponseBehavior(@default, @override) - }; - - return new OmitNullService(options); - } - - [Fact] - public void Name_OmitNullService_IsCorrect() - { - // Arrange - var service = GetService(true, true); - - // Act - var name = service.Name; - - // Assert - Assert.Equal("omitnull", name); - } - - [Theory] - [InlineData("false", true, true, false)] - [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) - { - // Arrange - var query = new KeyValuePair("omitNull", new StringValues(queryConfig)); - var service = GetService(@default, @override); - - // Act - service.Parse(query); - - // Assert - Assert.Equal(expected, service.Config); - } - } -} diff --git a/test/UnitTests/QueryParameters/OmitNullServiceTests.cs b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs new file mode 100644 index 00000000..bb23bdd4 --- /dev/null +++ b/test/UnitTests/QueryParameters/OmitNullServiceTests.cs @@ -0,0 +1,88 @@ +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; + +namespace UnitTests.QueryParameters +{ + public sealed class OmitNullServiceTests : QueryParametersUnitTestCollection + { + public OmitNullService GetService(bool @default, bool @override) + { + var options = new JsonApiOptions + { + NullAttributeResponseBehavior = new NullAttributeResponseBehavior(@default, @override) + }; + + return new OmitNullService(options); + } + + [Fact] + public void CanParse_OmitNullService_SucceedOnMatch() + { + // Arrange + var service = GetService(true, true); + + // Act + bool result = service.CanParse("omitNull"); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanParse_OmitNullService_FailOnMismatch() + { + // Arrange + var service = GetService(true, true); + + // Act + bool result = service.CanParse("omit-null"); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("false", true, true, false)] + [InlineData("false", true, false, true)] + [InlineData("true", false, true, true)] + [InlineData("true", false, false, false)] + public void Parse_QueryConfigWithApiSettings_CanParse(string queryValue, bool @default, bool @override, bool expected) + { + // Arrange + var query = new KeyValuePair("omitNull", queryValue); + var service = GetService(@default, @override); + + // Act + if (service.CanParse(query.Key) && service.IsEnabled(DisableQueryAttribute.Empty)) + { + service.Parse(query.Key, query.Value); + } + + // 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); + } + } +} diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs index d157d1ef..ee9a078d 100644 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ b/test/UnitTests/QueryParameters/PageServiceTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; +using System.Net; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -9,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 { @@ -19,19 +20,34 @@ public IPageService GetService(int? maximumPageSize = null, int? maximumPageNumb } [Fact] - public void Name_PageService_IsCorrect() + public void CanParse_PageService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - var name = filterService.Name; + bool result = service.CanParse("page[size]"); // Assert - Assert.Equal("page", name); + Assert.True(result); + } + + [Fact] + public void CanParse_PageService_FailOnMismatch() + { + // Arrange + var service = GetService(); + + // Act + bool result = service.CanParse("page[some]"); + + // Assert + Assert.False(result); } [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)] @@ -40,18 +56,23 @@ public void Name_PageService_IsCorrect() 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)); - Assert.Equal(400, ex.GetStatusCode()); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + 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", exception.Error.Detail); + Assert.Equal("page[size]", exception.Error.Source.Parameter); } else { - service.Parse(query); + service.Parse(query.Key, query.Value); Assert.Equal(expectedValue, service.PageSize); } } @@ -65,18 +86,23 @@ 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)); - Assert.Equal(400, ex.GetStatusCode()); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal("page[number]", 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 non-zero", exception.Error.Detail); + Assert.Equal("page[number]", exception.Error.Source.Parameter); } else { - service.Parse(query); + service.Parse(query.Key, query.Value); Assert.Equal(expectedValue, service.CurrentPage); } } diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs index ca861836..60a47198 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.Exceptions; using JsonApiDotNetCore.Query; using Microsoft.Extensions.Primitives; using Xunit; @@ -14,16 +15,29 @@ public SortService GetService() } [Fact] - public void Name_SortService_IsCorrect() + public void CanParse_SortService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - var name = filterService.Name; + bool result = service.CanParse("sort"); // Assert - Assert.Equal("sort", name); + Assert.True(result); + } + + [Fact] + public void CanParse_SortService_FailOnMismatch() + { + // Arrange + var service = GetService(); + + // Act + bool result = service.CanParse("sorting"); + + // Assert + Assert.False(result); } [Theory] @@ -37,8 +51,13 @@ public void Parse_InvalidSortQuery_ThrowsJsonApiException(string stringSortQuery var sortService = GetService(); // Act, assert - var exception = Assert.Throws(() => sortService.Parse(query)); - 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.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 c6158540..b33663da 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Linq; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; @@ -16,16 +18,29 @@ public SparseFieldsService GetService(ResourceContext resourceContext = null) } [Fact] - public void Name_SparseFieldsService_IsCorrect() + public void CanParse_SparseFieldsService_SucceedOnMatch() { // Arrange - var filterService = GetService(); + var service = GetService(); // Act - var name = filterService.Name; + bool result = service.CanParse("fields[customer]"); // Assert - Assert.Equal("fields", name); + Assert.True(result); + } + + [Fact] + public void CanParse_SparseFieldsService_FailOnMismatch() + { + // Arrange + var service = GetService(); + + // Act + bool result = service.CanParse("fieldset"); + + // Assert + Assert.False(result); } [Fact] @@ -37,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 { @@ -48,25 +63,26 @@ public void Parse_ValidSelection_CanParse() var service = GetService(resourceContext); // Act - service.Parse(query); + service.Parse(query.Key, query.Value); var result = service.Get(); // Assert Assert.NotEmpty(result); - Assert.Equal(idAttribute, result.First()); - Assert.Equal(attribute, result[1]); + Assert.Contains(idAttribute, result); + Assert.Contains(attribute, result); } [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 { @@ -77,12 +93,17 @@ public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessag var service = GetService(resourceContext); // Act, assert - var ex = Assert.Throws(() => service.Parse(query)); - Assert.Contains("relationships only", ex.Message); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal(queryParameterName, exception.QueryParameterName); + 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); } [Fact] - public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() + public void Parse_DeeplyNestedSelection_ThrowsJsonApiException() { // Arrange const string type = "articles"; @@ -90,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 { @@ -102,8 +124,13 @@ public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage() var service = GetService(resourceContext); // Act, assert - var ex = Assert.Throws(() => service.Parse(query)); - Assert.Contains("deeply nested", ex.Message); + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal(queryParameterName, exception.QueryParameterName); + 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); } [Fact] @@ -112,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", 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.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); + } + + [Fact] + public void Parse_LegacyNotation_ThrowsJsonApiException() + { + // Arrange + const string type = "articles"; + const string attrName = "dne"; + var queryParameterName = $"fields[{type}]"; - var query = new KeyValuePair($"fields[{type}]", new StringValues(attrName)); + var query = new KeyValuePair(queryParameterName, attrName); var resourceContext = new ResourceContext { @@ -124,9 +181,14 @@ public void Parse_InvalidField_ThrowsJsonApiException() var service = GetService(resourceContext); - // Act , assert - var ex = Assert.Throws(() => service.Parse(query)); - Assert.Equal(400, ex.GetStatusCode()); + // Act, assert + var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); + + Assert.Equal(queryParameterName, exception.QueryParameterName); + 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/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 0b3751e0..c200a2dd 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -13,7 +13,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Graph; using Person = JsonApiDotNetCoreExample.Models.Person; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Serialization; diff --git a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs index 69b7f17d..6c498a79 100644 --- a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs @@ -50,7 +50,7 @@ public void EntityToDocument_EmptyList_CanBuild() public void EntityToDocument_SingleEntity_CanBuild() { // Arrange - IIdentifiable dummy = new Identifiable(); + IIdentifiable dummy = new DummyResource(); // Act var document = _builder.Build(dummy); @@ -64,7 +64,7 @@ public void EntityToDocument_SingleEntity_CanBuild() public void EntityToDocument_EntityList_CanBuild() { // Arrange - var entities = new List { new Identifiable(), new Identifiable() }; + var entities = new List { new DummyResource(), new DummyResource() }; // Act var document = _builder.Build(entities); @@ -73,5 +73,9 @@ public void EntityToDocument_EntityList_CanBuild() // Assert Assert.Equal(2, data.Count); } + + public sealed class DummyResource : Identifiable + { + } } } diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 3b7ac36d..5796d3fc 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCore.Query; @@ -45,7 +46,7 @@ public SerializerTestsSetup() var includedBuilder = GetIncludedBuilder(); var fieldsToSerialize = GetSerializableFields(); ResponseResourceObjectBuilder resourceObjectBuilder = new ResponseResourceObjectBuilder(link, includedBuilder, included, _resourceGraph, GetSerializerSettingsProvider()); - return new ResponseSerializer(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) diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index a93e2a24..1e83595d 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -1,8 +1,9 @@ using System.Collections.Generic; using System.Linq; +using System.Net; using System.Text.RegularExpressions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; using Xunit; using UnitTests.TestModels; @@ -449,41 +450,32 @@ public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_C } [Fact] - public void SerializeError_CustomError_CanSerialize() + public void SerializeError_Error_CanSerialize() { // Arrange - var error = new CustomError(507, "title", "detail", "custom"); - var errorCollection = new ErrorCollection(); - errorCollection.Add(error); + var error = new Error(HttpStatusCode.InsufficientStorage) {Title = "title", Detail = "detail"}; + var errorDocument = new ErrorDocument(error); var expectedJson = JsonConvert.SerializeObject(new { - errors = new dynamic[] { - new { - myCustomProperty = "custom", + errors = new[] + { + new + { + id = error.Id, + status = "507", title = "title", - detail = "detail", - status = "507" + detail = "detail" } } }); var serializer = GetResponseSerializer(); // Act - var result = serializer.Serialize(errorCollection); + var result = serializer.Serialize(errorDocument); // Assert Assert.Equal(expectedJson, result); } - - private sealed class CustomError : Error - { - public CustomError(int status, string title, string detail, string myProp) - : base(status, title, detail) - { - MyCustomProperty = myProp; - } - public string MyCustomProperty { get; set; } - } } }