diff --git a/README.md b/README.md index 3c91c6492e..866c88f48a 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,25 @@

-# JSON API .Net Core +# JSON API .Net Core -[![Build status](https://ci.appveyor.com/api/projects/status/9fvgeoxdikwkom10?svg=true)](https://ci.appveyor.com/project/jaredcnance/jsonapidotnetcore) -[![Travis](https://travis-ci.org/json-api-dotnet/JsonApiDotNetCore.svg?branch=master)](https://travis-ci.org/json-api-dotnet/JsonApiDotNetCore) -[![NuGet](https://img.shields.io/nuget/v/JsonApiDotNetCore.svg)](https://www.nuget.org/packages/JsonApiDotNetCore/) -[![Join the chat at https://gitter.im/json-api-dotnet-core/Lobby](https://badges.gitter.im/json-api-dotnet-core/Lobby.svg)](https://gitter.im/json-api-dotnet-core/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![FIRST-TIMERS](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/) +[![Build status](https://ci.appveyor.com/api/projects/status/9fvgeoxdikwkom10?svg=true)](https://ci.appveyor.com/project/jaredcnance/jsonapidotnetcore) [![Travis](https://travis-ci.org/json-api-dotnet/JsonApiDotNetCore.svg?branch=master)](https://travis-ci.org/json-api-dotnet/JsonApiDotNetCore) [![NuGet](https://img.shields.io/nuget/v/JsonApiDotNetCore.svg)](https://www.nuget.org/packages/JsonApiDotNetCore/) [![Join the chat at https://gitter.im/json-api-dotnet-core/Lobby](https://badges.gitter.im/json-api-dotnet-core/Lobby.svg)](https://gitter.im/json-api-dotnet-core/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![FIRST-TIMERS](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/) A framework for building [json:api](http://jsonapi.org/) compliant web APIs. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. +## Table of Contents +- [Getting Started](#getting-started) +- [Related Projects](#related-projects) +- [Examples](#examples) +- [Compatibility](#compatibility) +- [Installation And Usage](#installation-and-usage) + - [Models](#models) + - [Controllers](#controllers) + - [Middleware](#middleware) +- [Development](#development) + - [Testing](#testing) + - [Cleaning](#cleaning) + ## Getting Started These are some steps you can take to help you understand what this project is and how you can use it: @@ -34,6 +43,15 @@ These are some steps you can take to help you understand what this project is an See the [examples](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples) directory for up-to-date sample applications. There is also a [Todo List App](https://github.com/json-api-dotnet/TodoListExample) that includes a JADNC API and an EmberJs client. +## Compatibility + +A lot of changes were introduced in v4.0.0, the following chart should help you with compatibility issues between .NET Core versions + +| .NET Core Version | JADNC Version | +| ----------------- | ------------- | +| 2.* | v3.* | +| 3.* | v4.* | + ## Installation And Usage See [the documentation](https://json-api-dotnet.github.io/#/) for detailed usage. @@ -79,7 +97,7 @@ public class Startup } ``` -### Development +## Development Restore all NuGet packages with: @@ -109,13 +127,5 @@ Sometimes the compiled files can be dirty / corrupt from other branches / failed dotnet clean ``` -## Compatibility - -A lot of changes were introduced in v4.0.0, the following chart should help you with compatibility issues between .NET Core versions - -| .NET Core Version | JADNC Version | -| ----------------- | ------------- | -| 2.* | v3.* | -| 3.* | v4.* | diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index c241c5b81d..f44927ccef 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; @@ -23,7 +25,7 @@ public class BaseJsonApiController private readonly IDeleteService _delete; private readonly ILogger> _logger; private readonly IJsonApiOptions _jsonApiOptions; - + public BaseJsonApiController( IJsonApiOptions jsonApiOptions, IResourceService resourceService, @@ -101,37 +103,68 @@ public virtual async Task GetAsync() public virtual async Task GetAsync(TId id) { - if (_getById == null) throw Exceptions.UnSupportedRequestMethod; + 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); + return NoResultFound(); } return Ok(entity); } - public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) + private NotFoundObjectResult NoResultFound() { - if (_getRelationships == null) - throw Exceptions.UnSupportedRequestMethod; - 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); - } + // 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 GetRelationshipsAsync(TId id, string relationshipName) + { + return await GetRelationshipInternal(id, relationshipName, relationshipInUrl: true); } public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - if (_getRelationship == null) throw Exceptions.UnSupportedRequestMethod; - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + return await GetRelationshipInternal(id, relationshipName, relationshipInUrl: false); + } + + protected virtual async Task GetRelationshipInternal(TId id, string relationshipName, bool relationshipInUrl) + { + + object relationship; + if (relationshipInUrl) + { + if (_getRelationships == null) + { + throw Exceptions.UnSupportedRequestMethod; + } + relationship = await _getRelationships.GetRelationshipsAsync(id, relationshipName); + } + else + { + if (_getRelationship == null) + { + throw Exceptions.UnSupportedRequestMethod; + } + relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + } + if (relationship == null) + { + return Ok(relationship); + } + + if (relationship.GetType() != typeof(T)) + { + if (((IEnumerable)relationship).Count() == 0) + { + return Ok(null); + } + } return Ok(relationship); } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index aa8b779992..19595494ec 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -2,6 +2,8 @@ using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; @@ -18,11 +20,14 @@ namespace JsonApiDotNetCore.Formatters public class JsonApiWriter : IJsonApiWriter { private readonly ILogger _logger; + private readonly ICurrentRequest _currentRequest; private readonly IJsonApiSerializer _serializer; public JsonApiWriter(IJsonApiSerializer serializer, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + ICurrentRequest currentRequest) { + _currentRequest = currentRequest; _serializer = serializer; _logger = loggerFactory.CreateLogger(); } @@ -30,40 +35,55 @@ public JsonApiWriter(IJsonApiSerializer serializer, public async Task WriteAsync(OutputFormatterWriteContext context) { if (context == null) + { throw new ArgumentNullException(nameof(context)); + } var response = context.HttpContext.Response; using var writer = context.WriterFactory(response.Body, Encoding.UTF8); string responseContent; - if (_serializer == null) + if (response.StatusCode == 404) { - responseContent = JsonConvert.SerializeObject(context.Object); + var requestedModel = _currentRequest.GetRequestResource(); + var errors = new ErrorCollection(); + errors.Add(new Error(404, $"The resource with type '{requestedModel.ResourceName}' and id '{_currentRequest.BaseId}' could not be found")); + responseContent = _serializer.Serialize(errors); + response.StatusCode = 404; } else { - response.ContentType = Constants.ContentType; - try + if (_serializer == null) { - if (context.Object is ProblemDetails pd) + responseContent = JsonConvert.SerializeObject(context.Object); + } + else + { + response.ContentType = Constants.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); + } + } + catch (Exception e) { + _logger?.LogError(new EventId(), e, "An error ocurred while formatting the response"); var errors = new ErrorCollection(); - errors.Add(new Error(pd.Status.Value, pd.Title, pd.Detail)); + errors.Add(new Error(500, e.Message, ErrorMeta.FromException(e))); responseContent = _serializer.Serialize(errors); - } else - { - responseContent = _serializer.Serialize(context.Object); + response.StatusCode = 500; } } - catch (Exception e) - { - _logger?.LogError(new EventId(), e, "An error ocurred 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; - } } + await writer.WriteAsync(responseContent); await writer.FlushAsync(); } diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/ResourceNotFoundException.cs new file mode 100644 index 0000000000..6f72cfc9b5 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Exceptions/ResourceNotFoundException.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCore.Internal +{ + public class ResourceNotFoundException : Exception + { + private readonly ErrorCollection _errors = new ErrorCollection(); + + public ResourceNotFoundException() + { } + + } +} diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs index b6c82b27e3..3ed2da84a5 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs @@ -1,7 +1,9 @@ -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Managers.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using System.Collections.Generic; namespace JsonApiDotNetCore.Middleware { @@ -10,19 +12,19 @@ namespace JsonApiDotNetCore.Middleware /// public class DefaultExceptionFilter : ActionFilterAttribute, IExceptionFilter { + private readonly ICurrentRequest _currentRequest; private readonly ILogger _logger; - public DefaultExceptionFilter(ILoggerFactory loggerFactory) + public DefaultExceptionFilter(ILoggerFactory loggerFactory, ICurrentRequest currentRequest) { + _currentRequest = currentRequest; _logger = loggerFactory.CreateLogger(); } public void OnException(ExceptionContext context) { _logger?.LogError(new EventId(), context.Exception, "An unhandled exception occurred during the request"); - var jsonApiException = JsonApiExceptionFactory.GetException(context.Exception); - var error = jsonApiException.GetError(); var result = new ObjectResult(error) { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs index 8fe73a289d..0ae8894eaf 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs @@ -110,6 +110,10 @@ public ResourceLinks GetResourceLinks(string resourceName, string id) /// public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent) { + if(parent == null) + { + return null; + } var parentResourceContext = _provider.GetResourceContext(parent.GetType()); var childNavigation = relationship.PublicRelationshipName; RelationshipLinks links = null; diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index cb4328947a..73ead184f5 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -11,6 +11,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Extensions; +using System.Collections; namespace JsonApiDotNetCore.Services { @@ -124,6 +125,7 @@ public virtual async Task GetAsync(TId id) return entity; } + // triggered by GET /articles/1/relationships/{relationshipName} public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) { @@ -135,15 +137,36 @@ public virtual async Task GetRelationshipsAsync(TId id, string relati // 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 baseQuery = _repository.Get(id); + var entityQuery = ApplyInclude(baseQuery, chainPrefix: new List { relationship }); + var entity = await _repository.FirstOrDefaultAsync(entityQuery); - if (entity == null) + + + // lol + var relationshipValue = typeof(TResource).GetProperty(relationship.InternalRelationshipName).GetValue(entity) ; + var relEmpty = relationshipValue == null; + + if(relationshipValue == 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."); + return null; + } + var listCast = (IList) relationshipValue; + if(listCast != null) + { + if(listCast.Count == 0) + { + return null; + } } + //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."); + //} + if (!IsNull(_hookExecutor, entity)) { // AfterRead and OnReturn resource hook execution. _hookExecutor.AfterRead(AsList(entity), ResourcePipeline.GetRelationship); @@ -339,7 +362,9 @@ private RelationshipAttribute GetRelationship(string relationshipName) { var relationship = _currentRequestResource.Relationships.Single(r => r.Is(relationshipName)); if (relationship == null) + { throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); + } return relationship; } diff --git a/src/JsonApiDotNetCore/index.md b/src/JsonApiDotNetCore/index.md deleted file mode 100644 index 3ae2506361..0000000000 --- a/src/JsonApiDotNetCore/index.md +++ /dev/null @@ -1,4 +0,0 @@ -# This is the **HOMEPAGE**. -Refer to [Markdown](http://daringfireball.net/projects/markdown/) for how to write markdown files. -## Quick Start Notes: -1. Add images to the *images* folder if the file is referencing an image. diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs index e3cb86dd9d..1cbd66bf80 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs @@ -36,7 +36,7 @@ public FunctionalTestCollection(TFactory factory) ClearDbContext(); } - protected Task<(string, HttpResponseMessage)> Get(string route) + protected Task<(string Body, HttpResponseMessage Response)> Get(string route) { return SendRequest("GET", route); } @@ -109,7 +109,7 @@ protected void ClearDbContext() _dbContext.SaveChanges(); } - private async Task<(string, HttpResponseMessage)> SendRequest(string method, string route, string content = null) + private async Task<(string body, HttpResponseMessage response)> SendRequest(string method, string route, string content = null) { var request = new HttpRequestMessage(new HttpMethod(method), route); if (content != null) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NonExistentResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NonExistentResourceTests.cs new file mode 100644 index 0000000000..cab735bfcd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/NonExistentResourceTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Client; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + public class NonExistentResourceTests : FunctionalTestCollection + { + private StandardApplicationFactory _factory; + private Faker _todoItemFaker; + private readonly Faker _personFaker; + + public NonExistentResourceTests(StandardApplicationFactory factory) : base(factory) + { + _factory = factory; + _todoItemFaker = new Faker() + .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()); + } + + public class ErrorInnerMessage + { + [JsonProperty("title")] + public string Title; + [JsonProperty("status")] + public string Status; + } + public class ErrorMessage + { + [JsonProperty("errors")] + public List Errors; + } + + [Fact] + public async Task Resource_PersonNonExistent_ShouldReturn404WithCorrectError() + { + // Arrange + var context = _factory.GetService(); + var person = _personFaker.Generate(); + context.People.Add(person); + await context.SaveChangesAsync(); + var nonExistingId = person.Id; + context.People.Remove(person); + context.SaveChanges(); + var route = $"/api/v1/people/{nonExistingId}"; + + // Act + var response = (await Get(route)).Response; + var body = await response.Content.ReadAsStringAsync(); + + // Assert + var errorResult = JsonConvert.DeserializeObject(body); + var errorParsed = errorResult.Errors.First(); + var title = errorParsed.Title; + var code = errorParsed.Status; + Assert.Contains("found", title); + Assert.Contains("people", title); + Assert.Contains(nonExistingId.ToString(), title); + Assert.Equal("404", code); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ResourceRelatedHasOne_TodoItemExistentToOneRelationshipIsNonExistent_ShouldReturn200WithNullData(bool full) + { + // Arrange + var context = _factory.GetService(); + context.TodoItems.RemoveRange(context.TodoItems.ToList()); + var todoItem = _todoItemFaker.Generate(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + var existingId = todoItem.Id; + var deserializer = new ResponseDeserializer(_factory.GetService()); + + var appendix = full ? "oneToOnePerson" : "relationships/oneToOnePerson"; + var route = $"/api/v1/todoItems/{existingId}/{appendix}"; + + + // Act + var response = (await Get(route)).Response; + var body = await response.Content.ReadAsStringAsync(); + + // Assert + var document = deserializer.DeserializeList(body); + Assert.Null(document.Data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ResourceRelatedHasMany_TodoItemExistsToManyRelationshipHasNoData_ShouldReturn200WithNullData(bool withBaseEntity) + { + // Arrange + var context = _factory.GetService(); + context.TodoItems.RemoveRange(context.TodoItems.ToList()); + var todoItem = _todoItemFaker.Generate(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + var existingId = todoItem.Id; + + var httpMethod = HttpMethod.Get; + var appendix = withBaseEntity ? "stakeHolders" : "relationships/stakeHolders"; + var route = $"/api/v1/todoItems/{existingId}/{appendix}"; + var request = new HttpRequestMessage(httpMethod, route); + var deserializer = new ResponseDeserializer(_factory.GetService()); + + // Act + var response = await _factory.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + var parsed = deserializer.DeserializeList(body); + Assert.NotNull(parsed.Data); + Assert.Empty(parsed.Data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } +}