From 2cbf4b8b9a1878ef2e15f5c81a814f72bfdec4a5 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 21:03:30 -0700 Subject: [PATCH 01/74] doc(ContextGraph): add API documentation --- .../Internal/ContextGraph.cs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index 9c62ae4d94..c0c7f2274b 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -6,10 +6,45 @@ namespace JsonApiDotNetCore.Internal { public interface IContextGraph { - object GetRelationship(TParent entity, string relationshipName); + /// + /// Gets the value of the navigation property, defined by the relationshipName, + /// on the provided instance. + /// + /// The resource instance + /// The navigation property name. + /// + /// + /// _graph.GetRelationship(todoItem, nameof(TodoItem.Owner)); + /// + /// + object GetRelationship(TParent resource, string propertyName); + + /// + /// Get the internal navigation property name for the specified public + /// relationship name. + /// + /// The public relationship name specified by a or + /// + /// + /// _graph.GetRelationshipName<TodoItem>("achieved-date"); + /// // returns "AchievedDate" + /// + /// string GetRelationshipName(string relationshipName); + + /// + /// Get the resource metadata by the DbSet property name + /// ContextEntity GetContextEntity(string dbSetName); + + /// + /// Get the resource metadata by the resource type + /// ContextEntity GetContextEntity(Type entityType); + + /// + /// Was built against an EntityFrameworkCore DbContext ? + /// bool UsesDbContext { get; } } @@ -40,14 +75,18 @@ internal ContextGraph(List entities, bool usesDbContext, List public bool UsesDbContext { get; } + /// public ContextEntity GetContextEntity(string entityName) => Entities.SingleOrDefault(e => string.Equals(e.EntityName, entityName, StringComparison.OrdinalIgnoreCase)); + /// public ContextEntity GetContextEntity(Type entityType) => Entities.SingleOrDefault(e => e.EntityType == entityType); + /// public object GetRelationship(TParent entity, string relationshipName) { var parentEntityType = entity.GetType(); @@ -62,6 +101,7 @@ public object GetRelationship(TParent entity, string relationshipName) return navigationProperty.GetValue(entity); } + /// public string GetRelationshipName(string relationshipName) { var entityType = typeof(TParent); From d92752b7d399684661b06976ab61d3d3f3c859fe Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 21:07:43 -0700 Subject: [PATCH 02/74] fix(#354): transform property name Should consider creating a new API on the graph that is more intuitive and doesn't require both steps. --- .../Services/EntityResourceService.cs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index dc78463b05..f3180709a6 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -122,17 +122,24 @@ public virtual async Task GetRelationshipsAsync(TId id, string relations public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - var entity = await _entities.GetAndIncludeAsync(id, relationshipName); + // compound-property -> CompoundProperty + var navigationPropertyName = _jsonApiContext.ContextGraph.GetRelationshipName(relationshipName); + if (navigationPropertyName == null) + throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); + + var entity = await _entities.GetAndIncludeAsync(id, navigationPropertyName); + // 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 if (entity == null) { - throw new JsonApiException(404, $"Relationship {relationshipName} not found."); + throw new JsonApiException(404, $"Relationship '{navigationPropertyName}' not found."); } - var resource = (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); - var relationship = _jsonApiContext.ContextGraph.GetRelationship(resource, relationshipName); + var resource = MapOut(entity); + + var relationship = _jsonApiContext.ContextGraph.GetRelationship(resource, navigationPropertyName); + return relationship; } @@ -242,5 +249,10 @@ private async Task GetWithRelationshipsAsync(TId id) private bool ShouldIncludeRelationships() => (_jsonApiContext.QuerySet?.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0); + + private TResource MapOut(TEntity entity) + => (typeof(TResource) == typeof(TEntity)) + ? entity as TResource : + _mapper.Map(entity); } } From f02f264735da22c9c6696e87d948a8db18535eab Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 21:08:40 -0700 Subject: [PATCH 03/74] style(EntityResourceService) --- .../Services/EntityResourceService.cs | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index f3180709a6..cdd658d27d 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Services { - public class EntityResourceService : EntityResourceService, + public class EntityResourceService : EntityResourceService, IResourceService where TResource : class, IIdentifiable { @@ -21,7 +21,7 @@ public EntityResourceService( { } } - public class EntityResourceService : EntityResourceService, + public class EntityResourceService : EntityResourceService, IResourceService where TResource : class, IIdentifiable { @@ -33,7 +33,7 @@ public EntityResourceService( { } } - public class EntityResourceService : + public class EntityResourceService : IResourceService where TResource : class, IIdentifiable where TEntity : class, IIdentifiable @@ -51,8 +51,7 @@ public EntityResourceService( // no mapper provided, TResource & TEntity must be the same type if (typeof(TResource) != typeof(TEntity)) { - throw new InvalidOperationException("Resource and Entity types are NOT the same. " + - "Please provide a mapper."); + throw new InvalidOperationException("Resource and Entity types are NOT the same. Please provide a mapper."); } _jsonApiContext = jsonApiContext; @@ -74,11 +73,11 @@ public EntityResourceService( public virtual async Task CreateAsync(TResource resource) { - var entity = (typeof(TResource) == typeof(TEntity)) ? resource as TEntity : - _mapper.Map(resource); + var entity = MapIn(resource); + entity = await _entities.CreateAsync(entity); - return (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); + + return MapOut(entity); } public virtual async Task DeleteAsync(TId id) @@ -105,16 +104,12 @@ public virtual async Task> GetAsync() public virtual async Task GetAsync(TId id) { - TResource dto; if (ShouldIncludeRelationships()) - dto = await GetWithRelationshipsAsync(id); - else - { - TEntity entity = await _entities.GetAsync(id); - dto = (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); - } - return dto; + return await GetWithRelationshipsAsync(id); + + TEntity entity = await _entities.GetAsync(id); + + return MapOut(entity); } public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) @@ -145,15 +140,14 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh public virtual async Task UpdateAsync(TId id, TResource resource) { - var entity = (typeof(TResource) == typeof(TEntity)) ? resource as TEntity : - _mapper.Map(resource); + var entity = MapIn(resource); + entity = await _entities.UpdateAsync(id, entity); - return (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); + + return MapOut(entity); } - public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, - List relationships) + public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships) { var entity = await _entities.GetAndIncludeAsync(id, relationshipName); if (entity == null) @@ -165,6 +159,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa .GetContextEntity(typeof(TResource)) .Relationships .FirstOrDefault(r => r.Is(relationshipName)); + var relationshipType = relationship.Type; // update relationship type with internalname @@ -174,8 +169,10 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa throw new JsonApiException(404, $"Property {relationship.InternalRelationshipName} " + $"could not be found on entity."); } - relationship.Type = relationship.IsHasMany ? entityProperty.PropertyType.GetGenericArguments()[0] : - entityProperty.PropertyType; + + relationship.Type = relationship.IsHasMany + ? entityProperty.PropertyType.GetGenericArguments()[0] + : entityProperty.PropertyType; var relationshipIds = relationships.Select(r => r?.Id?.ToString()); @@ -200,10 +197,9 @@ protected virtual async Task> ApplyPageQueryAsync(IQuerya $"with {pageManager.PageSize} entities"); } - var pagedEntities = await _entities.PageAsync(entities, pageManager.PageSize, - pageManager.CurrentPage); - return (typeof(TResource) == typeof(TEntity)) ? pagedEntities as IEnumerable : - _mapper.Map>(pagedEntities); + var pagedEntities = await _entities.PageAsync(entities, pageManager.PageSize, pageManager.CurrentPage); + + return MapOut(pagedEntities); } protected virtual IQueryable ApplySortAndFilterQuery(IQueryable entities) @@ -223,8 +219,7 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable IncludeRelationships(IQueryable entities, - List relationships) + protected virtual IQueryable IncludeRelationships(IQueryable entities, List relationships) { _jsonApiContext.IncludedRelationships = relationships; @@ -237,22 +232,34 @@ protected virtual IQueryable IncludeRelationships(IQueryable e private async Task GetWithRelationshipsAsync(TId id) { var query = _entities.Get().Where(e => e.Id.Equals(id)); + _jsonApiContext.QuerySet.IncludedRelationships.ForEach(r => { query = _entities.Include(query, r); }); + var value = await _entities.FirstOrDefaultAsync(query); - return (typeof(TResource) == typeof(TEntity)) ? value as TResource : - _mapper.Map(value); + + return MapOut(value); } private bool ShouldIncludeRelationships() - => (_jsonApiContext.QuerySet?.IncludedRelationships != null && + => (_jsonApiContext.QuerySet?.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0); private TResource MapOut(TEntity entity) => (typeof(TResource) == typeof(TEntity)) ? entity as TResource : _mapper.Map(entity); + + private IEnumerable MapOut(IEnumerable entities) + => (typeof(TResource) == typeof(TEntity)) + ? entities as IEnumerable + : _mapper.Map>(entities); + + private TEntity MapIn(TResource resource) + => (typeof(TResource) == typeof(TEntity)) + ? resource as TEntity + : _mapper.Map(resource); } } From 48467f1f7cc76175f6b7833102a63cc3f21d97b0 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 22:02:36 -0700 Subject: [PATCH 04/74] fix(EntityResourceService): pass relationship name to repository --- .../Services/EntityResourceService.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index cdd658d27d..300c858c43 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -117,25 +117,24 @@ public virtual async Task GetRelationshipsAsync(TId id, string relations public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - // compound-property -> CompoundProperty - var navigationPropertyName = _jsonApiContext.ContextGraph.GetRelationshipName(relationshipName); - if (navigationPropertyName == null) - throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); - - var entity = await _entities.GetAndIncludeAsync(id, navigationPropertyName); + var entity = await _entities.GetAndIncludeAsync(id, relationshipName); // 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 if (entity == null) { - throw new JsonApiException(404, $"Relationship '{navigationPropertyName}' not found."); + throw new JsonApiException(404, $"Relationship '{relationshipName}' not found."); } var resource = MapOut(entity); - var relationship = _jsonApiContext.ContextGraph.GetRelationship(resource, navigationPropertyName); + // compound-property -> CompoundProperty + var navigationPropertyName = _jsonApiContext.ContextGraph.GetRelationshipName(relationshipName); + if (navigationPropertyName == null) + throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); - return relationship; + var relationshipValue = _jsonApiContext.ContextGraph.GetRelationship(resource, navigationPropertyName); + return relationshipValue; } public virtual async Task UpdateAsync(TId id, TResource resource) From 5a7d7daf1bfdd7506f70733c3a8f69c6a9f0d662 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 22:03:02 -0700 Subject: [PATCH 05/74] docs(IEntityReadRepository): add API documentation --- .../Data/DefaultEntityRepository.cs | 20 ++++++-- .../Data/IEntityReadRepository.cs | 49 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 8cd3200a4e..7de24d7360 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -48,6 +48,7 @@ public DefaultEntityRepository( _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; } + /// public virtual IQueryable Get() { if (_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Count > 0) @@ -56,21 +57,25 @@ public virtual IQueryable Get() return _dbSet; } + /// public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { return entities.Filter(_jsonApiContext, filterQuery); } + /// public virtual IQueryable Sort(IQueryable entities, List sortQueries) { return entities.Sort(sortQueries); } + /// public virtual async Task GetAsync(TId id) { return await Get().SingleOrDefaultAsync(e => e.Id.Equals(id)); } + /// public virtual async Task GetAndIncludeAsync(TId id, string relationshipName) { _logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); @@ -80,6 +85,7 @@ public virtual async Task GetAndIncludeAsync(TId id, string relationshi return result; } + /// public virtual async Task CreateAsync(TEntity entity) { AttachRelationships(); @@ -102,9 +108,9 @@ protected virtual void AttachRelationships() private void AttachHasManyPointers() { var relationships = _jsonApiContext.HasManyRelationshipPointers.Get(); - foreach(var relationship in relationships) + foreach (var relationship in relationships) { - foreach(var pointer in relationship.Value) + foreach (var pointer in relationship.Value) { _context.Entry(pointer).State = EntityState.Unchanged; } @@ -123,6 +129,7 @@ private void AttachHasOnePointers() _context.Entry(relationship.Value).State = EntityState.Unchanged; } + /// public virtual async Task UpdateAsync(TId id, TEntity entity) { var oldEntity = await GetAsync(id); @@ -141,12 +148,14 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) return oldEntity; } + /// public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { var genericProcessor = _genericProcessorFactory.GetProcessor(typeof(GenericProcessor<>), relationship.Type); await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } + /// public virtual async Task DeleteAsync(TId id) { var entity = await GetAsync(id); @@ -161,11 +170,12 @@ public virtual async Task DeleteAsync(TId id) return true; } + /// public virtual IQueryable Include(IQueryable entities, string relationshipName) { var entity = _jsonApiContext.RequestEntity; var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); - if (relationship == null) + if (relationship == null) { throw new JsonApiException(400, $"Invalid relationship {relationshipName} on {entity.EntityName}", $"{entity.EntityName} does not have a relationship named {relationshipName}"); @@ -178,6 +188,7 @@ public virtual IQueryable Include(IQueryable entities, string return entities.Include(relationship.InternalRelationshipName); } + /// public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { if (pageNumber >= 0) @@ -198,6 +209,7 @@ public virtual async Task> PageAsync(IQueryable en .ToListAsync(); } + /// public async Task CountAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -205,6 +217,7 @@ public async Task CountAsync(IQueryable entities) : entities.Count(); } + /// public async Task FirstOrDefaultAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -212,6 +225,7 @@ public async Task FirstOrDefaultAsync(IQueryable entities) : entities.FirstOrDefault(); } + /// public async Task> ToListAsync(IQueryable entities) { return (entities is IAsyncEnumerable) diff --git a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs index a86b7334a9..f861ce10e2 100644 --- a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs @@ -6,32 +6,75 @@ namespace JsonApiDotNetCore.Data { - public interface IEntityReadRepository - : IEntityReadRepository - where TEntity : class, IIdentifiable + public interface IEntityReadRepository + : IEntityReadRepository + where TEntity : class, IIdentifiable { } public interface IEntityReadRepository where TEntity : class, IIdentifiable { + /// + /// The base GET query. This is a good place to apply rules that should affect all reads, + /// such as authorization of resources. + /// IQueryable Get(); + /// + /// Include a relationship in the query + /// + /// + /// + /// _todoItemsRepository.GetAndIncludeAsync(1, "achieved-date"); + /// + /// IQueryable Include(IQueryable entities, string relationshipName); + /// + /// Apply a filter to the provided queryable + /// IQueryable Filter(IQueryable entities, FilterQuery filterQuery); + /// + /// Apply a sort to the provided queryable + /// IQueryable Sort(IQueryable entities, List sortQueries); + /// + /// Paginate the provided queryable + /// Task> PageAsync(IQueryable entities, int pageSize, int pageNumber); + /// + /// Get the entity by id + /// Task GetAsync(TId id); + /// + /// Get the entity with the specified id and include the relationship. + /// + /// The entity id + /// The exposed relationship name + /// + /// + /// _todoItemsRepository.GetAndIncludeAsync(1, "achieved-date"); + /// + /// Task GetAndIncludeAsync(TId id, string relationshipName); + /// + /// Count the total number of records + /// Task CountAsync(IQueryable entities); + /// + /// Get the first element in the collection, return the default value if collection is empty + /// Task FirstOrDefaultAsync(IQueryable entities); + /// + /// Convert the collection to a materialized list + /// Task> ToListAsync(IQueryable entities); } } From 46f1e2d8841d2333354d3f98812b70ee3072a5dd Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 22:23:39 -0700 Subject: [PATCH 06/74] test(#354): add EntityResourceService tests --- .../Services/EntityResourceService_Tests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/UnitTests/Services/EntityResourceService_Tests.cs diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs new file mode 100644 index 0000000000..0f27f70924 --- /dev/null +++ b/test/UnitTests/Services/EntityResourceService_Tests.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace UnitTests.Services +{ + public class EntityResourceService_Tests + { + private readonly Mock _jsonApiContextMock = new Mock(); + private readonly Mock> _repositoryMock = new Mock>(); + private readonly ILoggerFactory _loggerFactory = new Mock().Object; + + public EntityResourceService_Tests() + { + _jsonApiContextMock + .Setup(m => m.ContextGraph) + .Returns( + new ContextGraphBuilder() + .AddResource("todo-items") + .Build() + ); + } + + [Fact] + public async Task GetRelationshipAsync_Passes_Public_ResourceName_To_Repository() + { + // arrange + const int id = 1; + const string relationshipName = "collection"; + + _repositoryMock.Setup(m => m.GetAndIncludeAsync(id, relationshipName)) + .ReturnsAsync(new TodoItem()); + + var repository = GetService(); + + // act + await repository.GetRelationshipAsync(id, relationshipName); + + // assert + _repositoryMock.Verify(m => m.GetAndIncludeAsync(id, relationshipName), Times.Once); + } + + [Fact] + public async Task GetRelationshipAsync_Returns_Relationship_Value() + { + // arrange + const int id = 1; + const string relationshipName = "collection"; + + var todoItem = new TodoItem + { + Collection = new TodoItemCollection { Id = Guid.NewGuid() } + }; + + _repositoryMock.Setup(m => m.GetAndIncludeAsync(id, relationshipName)) + .ReturnsAsync(todoItem); + + var repository = GetService(); + + // act + var result = await repository.GetRelationshipAsync(id, relationshipName); + + // assert + Assert.NotNull(result); + var collection = Assert.IsType(result); + Assert.Equal(todoItem.Collection.Id, collection.Id); + } + + private EntityResourceService GetService() => + new EntityResourceService(_jsonApiContextMock.Object, _repositoryMock.Object, _loggerFactory); + } +} From 66492a225281cbaee8d52b54ea9db3e5e5ccdebc Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Thu, 26 Jul 2018 21:22:48 -0700 Subject: [PATCH 07/74] fix(GetOpProcessor): #340.1 not properly handling HasMany relationships --- .../Builders/DocumentBuilder.cs | 1 - .../Operations/Processors/GetOpProcessor.cs | 40 ++++++++++++++++--- .../Get/GetRelationshipTests.cs | 37 ++++++++++++++++- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index c6f5f999b4..ce060f57b7 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -138,7 +138,6 @@ public DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity, I return data; } - private bool ShouldIncludeAttribute(AttrAttribute attr, object attributeValue) { return OmitNullValuedAttribute(attr, attributeValue) == false diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs index a3cb6e7da8..954bfadd55 100644 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -83,10 +84,10 @@ public async Task ProcessAsync(Operation operation) }; operationResult.Data = string.IsNullOrWhiteSpace(operation.Ref.Id) - ? await GetAllAsync(operation) - : string.IsNullOrWhiteSpace(operation.Ref.Relationship) - ? await GetByIdAsync(operation) - : await GetRelationshipAsync(operation); + ? await GetAllAsync(operation) + : string.IsNullOrWhiteSpace(operation.Ref.Relationship) + ? await GetByIdAsync(operation) + : await GetRelationshipAsync(operation); return operationResult; } @@ -135,11 +136,38 @@ private async Task GetRelationshipAsync(Operation operation) // when no generic parameter is available var relationshipType = _contextGraph.GetContextEntity(operation.GetResourceTypeName()) .Relationships.Single(r => r.Is(operation.Ref.Relationship)).Type; + var relatedContextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationshipType); - var doc = _documentBuilder.GetData(relatedContextEntity, result as IIdentifiable); // TODO: if this is safe, then it should be cast in the GetRelationshipAsync call + if (result == null) + return null; + + if (result is IIdentifiable singleResource) + return GetData(relatedContextEntity, singleResource); - return doc; + if (result is IEnumerable multipleResults) + return GetData(relatedContextEntity, multipleResults); + + throw new JsonApiException(500, + $"An unexpected type was returned from '{_getRelationship.GetType()}.{nameof(IGetRelationshipService.GetRelationshipAsync)}'.", + detail: $"Type '{result.GetType()} does not implement {nameof(IIdentifiable)} nor {nameof(IEnumerable)}'"); + } + + private DocumentData GetData(ContextEntity contextEntity, IIdentifiable singleResource) + { + return _documentBuilder.GetData(contextEntity, singleResource); + } + + private List GetData(ContextEntity contextEntity, IEnumerable multipleResults) + { + var resources = new List(); + foreach (var singleResult in multipleResults) + { + if (singleResult is IIdentifiable resource) + resources.Add(_documentBuilder.GetData(contextEntity, resource)); + } + + return resources; } private TId GetReferenceId(Operation operation) => TypeHelper.ConvertType(operation.Ref.Id); diff --git a/test/OperationsExampleTests/Get/GetRelationshipTests.cs b/test/OperationsExampleTests/Get/GetRelationshipTests.cs index 87edaba282..0aeef6f3ec 100644 --- a/test/OperationsExampleTests/Get/GetRelationshipTests.cs +++ b/test/OperationsExampleTests/Get/GetRelationshipTests.cs @@ -16,7 +16,7 @@ public class GetRelationshipTests : Fixture, IDisposable private readonly Faker _faker = new Faker(); [Fact] - public async Task Can_Get_Article_Author() + public async Task Can_Get_HasOne_Relationship() { // arrange var context = GetService(); @@ -48,5 +48,40 @@ public async Task Can_Get_Article_Author() Assert.Equal(author.Id.ToString(), resourceObject.Id); Assert.Equal("authors", resourceObject.Type); } + + [Fact] + public async Task Can_Get_HasMany_Relationship() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var article = ArticleFactory.Get(); + article.Author = author; + context.Articles.Add(article); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "authors", id = author.StringId, relationship = "articles" } } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.NotNull(data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Single(data.Operations); + + var resourceObject = data.Operations.Single().DataList.Single(); + Assert.Equal(article.Id.ToString(), resourceObject.Id); + Assert.Equal("articles", resourceObject.Type); + } } } From 8e065ce18c30cd38406ebecd29cf440032bfd319 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Thu, 26 Jul 2018 21:45:30 -0700 Subject: [PATCH 08/74] fix(OperationProcessorResolver): #340.2 wrong type throws null ref --- .../Operations/OperationProcessorResolver.cs | 22 +++- test/OperationsExampleTests/Get/GetTests.cs | 118 +++++++++++------- .../OperationsProcessorResolverTests.cs | 102 +++++++++++++++ 3 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 test/UnitTests/Services/Operations/OperationsProcessorResolverTests.cs diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs index 1004ed30dc..a702638c5d 100644 --- a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs @@ -51,7 +51,8 @@ public IOpProcessor LocateCreateService(Operation operation) { var resource = operation.GetResourceTypeName(); - var contextEntity = _context.ContextGraph.GetContextEntity(resource); + var contextEntity = GetResourceMetadata(resource); + var processor = _processorFactory.GetProcessor( typeof(ICreateOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType ); @@ -64,7 +65,8 @@ public IOpProcessor LocateGetService(Operation operation) { var resource = operation.GetResourceTypeName(); - var contextEntity = _context.ContextGraph.GetContextEntity(resource); + var contextEntity = GetResourceMetadata(resource); + var processor = _processorFactory.GetProcessor( typeof(IGetOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType ); @@ -77,7 +79,8 @@ public IOpProcessor LocateRemoveService(Operation operation) { var resource = operation.GetResourceTypeName(); - var contextEntity = _context.ContextGraph.GetContextEntity(resource); + var contextEntity = GetResourceMetadata(resource); + var processor = _processorFactory.GetProcessor( typeof(IRemoveOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType ); @@ -90,9 +93,7 @@ public IOpProcessor LocateUpdateService(Operation operation) { var resource = operation.GetResourceTypeName(); - var contextEntity = _context.ContextGraph.GetContextEntity(resource); - if (contextEntity == null) - throw new JsonApiException(400, $"This API does not expose a resource of type '{resource}'."); + var contextEntity = GetResourceMetadata(resource); var processor = _processorFactory.GetProcessor( typeof(IUpdateOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType @@ -100,5 +101,14 @@ public IOpProcessor LocateUpdateService(Operation operation) return processor; } + + private ContextEntity GetResourceMetadata(string resourceName) + { + var contextEntity = _context.ContextGraph.GetContextEntity(resourceName); + if(contextEntity == null) + throw new JsonApiException(400, $"This API does not expose a resource of type '{resourceName}'."); + + return contextEntity; + } } } diff --git a/test/OperationsExampleTests/Get/GetTests.cs b/test/OperationsExampleTests/Get/GetTests.cs index 78e7eeb976..f0d3fdffd8 100644 --- a/test/OperationsExampleTests/Get/GetTests.cs +++ b/test/OperationsExampleTests/Get/GetTests.cs @@ -1,51 +1,73 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Models.Operations; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCoreExample.Data; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests -{ - public class GetByIdTests : Fixture, IDisposable - { +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class GetByIdTests : Fixture, IDisposable + { private readonly Faker _faker = new Faker(); - - [Fact] - public async Task Can_Get_Authors() - { - // arrange - var expectedCount = _faker.Random.Int(1, 10); - var context = GetService(); - context.Articles.RemoveRange(context.Articles); - context.Authors.RemoveRange(context.Authors); - var authors = AuthorFactory.Get(expectedCount); - context.AddRange(authors); - context.SaveChanges(); - - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "authors" } } - } - } - }; - - // act - var result = await PatchAsync("api/bulk", content); - - // assert - Assert.NotNull(result.response); - Assert.NotNull(result.data); - Assert.Equal(HttpStatusCode.OK, result.response.StatusCode); - Assert.Single(result.data.Operations); - Assert.Equal(expectedCount, result.data.Operations.Single().DataList.Count); + + [Fact] + public async Task Can_Get_Authors() + { + // arrange + var expectedCount = _faker.Random.Int(1, 10); + var context = GetService(); + context.Articles.RemoveRange(context.Articles); + context.Authors.RemoveRange(context.Authors); + var authors = AuthorFactory.Get(expectedCount); + context.AddRange(authors); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "authors" } } + } + } + }; + + // act + var result = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(result.response); + Assert.NotNull(result.data); + Assert.Equal(HttpStatusCode.OK, result.response.StatusCode); + Assert.Single(result.data.Operations); + Assert.Equal(expectedCount, result.data.Operations.Single().DataList.Count); } - } -} + + [Fact] + public async Task Get_Non_Existent_Type_Returns_400() + { + // arrange + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "non-existent-type" } } + } + } + }; + + // act + var result = await PatchAsync("api/bulk", content); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, result.response.StatusCode); + } + } +} diff --git a/test/UnitTests/Services/Operations/OperationsProcessorResolverTests.cs b/test/UnitTests/Services/Operations/OperationsProcessorResolverTests.cs new file mode 100644 index 0000000000..1e7026f2ee --- /dev/null +++ b/test/UnitTests/Services/Operations/OperationsProcessorResolverTests.cs @@ -0,0 +1,102 @@ +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Generics; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Services.Operations; +using Moq; +using Xunit; + +namespace UnitTests.Services +{ + public class OperationProcessorResolverTests + { + private readonly Mock _processorFactoryMock; + public readonly Mock _jsonApiContextMock; + + public OperationProcessorResolverTests() + { + _processorFactoryMock = new Mock(); + _jsonApiContextMock = new Mock(); + } + + [Fact] + public void LocateCreateService_Throws_400_For_Entity_Not_Registered() + { + // arrange + _jsonApiContextMock.Setup(m => m.ContextGraph).Returns(new ContextGraphBuilder().Build()); + var service = GetService(); + var op = new Operation + { + Ref = new ResourceReference + { + Type = "non-existent-type" + } + }; + + // act, assert + var e = Assert.Throws(() => service.LocateCreateService(op)); + Assert.Equal(400, e.GetStatusCode()); + } + + [Fact] + public void LocateGetService_Throws_400_For_Entity_Not_Registered() + { + // arrange + _jsonApiContextMock.Setup(m => m.ContextGraph).Returns(new ContextGraphBuilder().Build()); + var service = GetService(); + var op = new Operation + { + Ref = new ResourceReference + { + Type = "non-existent-type" + } + }; + + // act, assert + var e = Assert.Throws(() => service.LocateGetService(op)); + Assert.Equal(400, e.GetStatusCode()); + } + + [Fact] + public void LocateRemoveService_Throws_400_For_Entity_Not_Registered() + { + // arrange + _jsonApiContextMock.Setup(m => m.ContextGraph).Returns(new ContextGraphBuilder().Build()); + var service = GetService(); + var op = new Operation + { + Ref = new ResourceReference + { + Type = "non-existent-type" + } + }; + + // act, assert + var e = Assert.Throws(() => service.LocateRemoveService(op)); + Assert.Equal(400, e.GetStatusCode()); + } + + [Fact] + public void LocateUpdateService_Throws_400_For_Entity_Not_Registered() + { + // arrange + _jsonApiContextMock.Setup(m => m.ContextGraph).Returns(new ContextGraphBuilder().Build()); + var service = GetService(); + var op = new Operation + { + Ref = new ResourceReference + { + Type = "non-existent-type" + } + }; + + // act, assert + var e = Assert.Throws(() => service.LocateUpdateService(op)); + Assert.Equal(400, e.GetStatusCode()); + } + + private OperationProcessorResolver GetService() + => new OperationProcessorResolver(_processorFactoryMock.Object, _jsonApiContextMock.Object); + } +} From 13404ab802a5b2a53f2d9f53bd9ed89f465a296e Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Fri, 27 Jul 2018 16:32:37 -0700 Subject: [PATCH 09/74] Update PULL_REQUEST_TEMPLATE.md --- PULL_REQUEST_TEMPLATE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index 02e5c26b9b..7ef22fc730 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,5 @@ + + Closes #{ISSUE_NUMBER} #### BUG FIX @@ -9,3 +11,7 @@ Closes #{ISSUE_NUMBER} - [ ] write tests that address the requirements outlined in the issue - [ ] fulfill the feature requirements - [ ] bump package version + +#### RELATED REPOSITORY UPDATES +- does this feature require documentation? if so, open an issue in the [docs repo](https://github.com/json-api-dotnet/json-api-dotnet.github.io/issues/new) +- does this feature break an API that is implemented in the templates repository? if so, [open an issue](https://github.com/json-api-dotnet/Templates/issues/new) From b515283ba0a6f1b5d6e84149ba8e5d7f4e3d6b31 Mon Sep 17 00:00:00 2001 From: Milos Date: Tue, 31 Jul 2018 08:44:22 +0200 Subject: [PATCH 10/74] Not IN filter --- .../Extensions/IQueryableExtensions.cs | 24 +++++++---- .../Internal/Query/FilterOperations.cs | 1 + src/JsonApiDotNetCore/Services/QueryParser.cs | 3 +- .../Acceptance/Spec/AttributeFilterTests.cs | 40 +++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 994fc08070..03f74b9e59 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -119,10 +119,10 @@ public static IQueryable Filter(this IQueryable sourc try { - if (filterQuery.FilterOperation == FilterOperations.@in ) + if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin) { string[] propertyValues = filterQuery.PropertyValue.Split(','); - var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name); + var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name, filterQuery.FilterOperation); return source.Where(lambdaIn); } @@ -167,10 +167,10 @@ public static IQueryable Filter(this IQueryable sourc try { - if (filterQuery.FilterOperation == FilterOperations.@in) + if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin) { string[] propertyValues = filterQuery.PropertyValue.Split(','); - var lambdaIn = ArrayContainsPredicate(propertyValues, relatedAttr.Name, relation.Name); + var lambdaIn = ArrayContainsPredicate(propertyValues, relatedAttr.Name, filterQuery.FilterOperation, relation.Name); return source.Where(lambdaIn); } @@ -243,7 +243,7 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression return body; } - private static Expression> ArrayContainsPredicate(string[] propertyValues, string fieldname, string relationName = null) + private static Expression> ArrayContainsPredicate(string[] propertyValues, string fieldname, FilterOperations op, string relationName = null) { ParameterExpression entity = Expression.Parameter(typeof(TSource), "entity"); MemberExpression member; @@ -258,8 +258,18 @@ private static Expression> ArrayContainsPredicate(s var method = ContainsMethod.MakeGenericMethod(member.Type); var obj = TypeHelper.ConvertListType(propertyValues, member.Type); - var exprContains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); - return Expression.Lambda>(exprContains, entity); + if (op == FilterOperations.@in) + { + // Where(i => arr.Contains(i.column)) + var contains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); + return Expression.Lambda>(contains, entity); + } + else + { + // Where(i => !arr.Contains(i.column)) + var notContains = Expression.Not(Expression.Call(method, new Expression[] { Expression.Constant(obj), member })); + return Expression.Lambda>(notContains, entity); + } } public static IQueryable Select(this IQueryable source, List columns) diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs index 88a2da2ee8..554e6f98a4 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs @@ -11,5 +11,6 @@ public enum FilterOperations like = 5, ne = 6, @in = 7, // prefix with @ to use keyword + nin = 8 } } diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index 7e17352815..0a6d4f2c16 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -86,7 +86,8 @@ protected virtual List ParseFilterQuery(string key, string value) // InArray case string op = GetFilterOperation(value); - if (string.Equals(op, FilterOperations.@in.ToString(), StringComparison.OrdinalIgnoreCase)) + if (string.Equals(op, FilterOperations.@in.ToString(), StringComparison.OrdinalIgnoreCase) + || string.Equals(op, FilterOperations.nin.ToString(), StringComparison.OrdinalIgnoreCase)) { (var operation, var filterValue) = ParseFilterOperation(value); queries.Add(new FilterQuery(propertyName, filterValue, op)); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index 9e154f9b47..591dfa4c7f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -209,5 +209,45 @@ public async Task Can_Filter_On_Related_In_Array_Values() Assert.Contains(item.Attributes["first-name"], ownerFirstNames); } + + [Fact] + public async Task Can_Filter_On_Not_In_Array_Values() + { + // arrange + var context = _fixture.GetService(); + var todoItems = _todoItemFaker.Generate(5); + var guids = new List(); + var notInGuids = new List(); + foreach (var item in todoItems) + { + context.TodoItems.Add(item); + // Exclude 2 items + if (guids.Count < (todoItems.Count() - 2)) + guids.Add(item.GuidProperty); + else + notInGuids.Add(item.GuidProperty); + } + context.SaveChanges(); + + var totalCount = context.TodoItems.Count(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?page[size]={totalCount}&filter[guid-property]=nin:{string.Join(",", notInGuids)}"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedTodoItems = _fixture + .GetService() + .DeserializeList(body); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(totalCount - notInGuids.Count(), deserializedTodoItems.Count()); + foreach (var item in deserializedTodoItems) + { + Assert.DoesNotContain(item.GuidProperty, notInGuids); + } + } } } From 5f4a91d0e144c1f37dc4ada6657a25a78bb7cb4e Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Tue, 31 Jul 2018 21:27:13 -0700 Subject: [PATCH 11/74] doc(Identifiable): add xml docs --- src/JsonApiDotNetCore/Models/Identifiable.cs | 30 ++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Models/Identifiable.cs b/src/JsonApiDotNetCore/Models/Identifiable.cs index 703cc2f051..08dd43bf22 100644 --- a/src/JsonApiDotNetCore/Models/Identifiable.cs +++ b/src/JsonApiDotNetCore/Models/Identifiable.cs @@ -5,12 +5,26 @@ namespace JsonApiDotNetCore.Models { public class Identifiable : Identifiable - {} - + { } + public class Identifiable : IIdentifiable { + /// + /// The resource identifier + /// public virtual T Id { get; set; } + /// + /// The string representation of the `Id`. + /// + /// This is used in serialization and deserialization. + /// The getters should handle the conversion + /// from `typeof(T)` to a string and the setter vice versa. + /// + /// To override this behavior, you can either implement the + /// interface directly or override + /// `GetStringId` and `GetTypedId` methods. + /// [NotMapped] public string StringId { @@ -18,22 +32,28 @@ public string StringId set => Id = GetTypedId(value); } + /// + /// Convert the provided resource identifier to a string. + /// protected virtual string GetStringId(object value) { var type = typeof(T); var stringValue = value.ToString(); - if(type == typeof(Guid)) + if (type == typeof(Guid)) { var guid = Guid.Parse(stringValue); return guid == Guid.Empty ? string.Empty : stringValue; } - return stringValue == "0" - ? string.Empty + return stringValue == "0" + ? string.Empty : stringValue; } + /// + /// Convert a string to a typed resource identifier. + /// protected virtual T GetTypedId(string value) { var convertedValue = TypeHelper.ConvertType(value, typeof(T)); From a050acd5ba768dec2e466771e2e07662a601ba5c Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 15:46:35 -0500 Subject: [PATCH 12/74] feat: Error should accept objects for source --- src/JsonApiDotNetCore/Internal/Error.cs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Internal/Error.cs b/src/JsonApiDotNetCore/Internal/Error.cs index 999611d79e..71852e28ea 100644 --- a/src/JsonApiDotNetCore/Internal/Error.cs +++ b/src/JsonApiDotNetCore/Internal/Error.cs @@ -9,9 +9,9 @@ public class Error { public Error() { } - + [Obsolete("Use Error constructors with int typed status")] - public Error(string status, string title, ErrorMeta meta = null, string source = null) + public Error(string status, string title, ErrorMeta meta = null, object source = null) { Status = status; Title = title; @@ -19,7 +19,7 @@ public Error(string status, string title, ErrorMeta meta = null, string source = Source = source; } - public Error(int status, string title, ErrorMeta meta = null, string source = null) + public Error(int status, string title, ErrorMeta meta = null, object source = null) { Status = status.ToString(); Title = title; @@ -28,7 +28,7 @@ public Error(int status, string title, ErrorMeta meta = null, string source = nu } [Obsolete("Use Error constructors with int typed status")] - public Error(string status, string title, string detail, ErrorMeta meta = null, string source = null) + public Error(string status, string title, string detail, ErrorMeta meta = null, object source = null) { Status = status; Title = title; @@ -37,7 +37,7 @@ public Error(string status, string title, string detail, ErrorMeta meta = null, Source = source; } - public Error(int status, string title, string detail, ErrorMeta meta = null, string source = null) + public Error(int status, string title, string detail, ErrorMeta meta = null, object source = null) { Status = status.ToString(); Title = title; @@ -45,13 +45,13 @@ public Error(int status, string title, string detail, ErrorMeta meta = null, str 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; } @@ -59,7 +59,7 @@ public Error(int status, string title, string detail, ErrorMeta meta = null, str public int StatusCode => int.Parse(Status); [JsonProperty("source")] - public string Source { get; set; } + public object Source { get; set; } [JsonProperty("meta")] public ErrorMeta Meta { get; set; } @@ -73,8 +73,8 @@ public class ErrorMeta [JsonProperty("stackTrace")] public string[] StackTrace { get; set; } - public static ErrorMeta FromException(Exception e) - => new ErrorMeta { + public static ErrorMeta FromException(Exception e) + => new ErrorMeta { StackTrace = e.Demystify().ToString().Split(new[] { "\n"}, int.MaxValue, StringSplitOptions.RemoveEmptyEntries) }; } From 79fb2856af7f9b475396f7dbcff31eb37be660a4 Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 17:20:06 -0500 Subject: [PATCH 13/74] feat: add 'GetPublicAttributeName' to ContextGraph --- src/JsonApiDotNetCore/Internal/ContextGraph.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index c0c7f2274b..6a39a292de 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Internal public interface IContextGraph { /// - /// Gets the value of the navigation property, defined by the relationshipName, + /// Gets the value of the navigation property, defined by the relationshipName, /// on the provided instance. /// /// The resource instance @@ -42,6 +42,12 @@ public interface IContextGraph /// ContextEntity GetContextEntity(Type entityType); + /// + /// Get the public attribute name for a type based on the internal attribute name. + /// + /// The internal attribute name for a . + string GetPublicAttributeName(string internalAttributeName); + /// /// Was built against an EntityFrameworkCore DbContext ? /// @@ -111,5 +117,13 @@ public string GetRelationshipName(string relationshipName) .SingleOrDefault(r => r.Is(relationshipName)) ?.InternalRelationshipName; } - } + + public string GetPublicAttributeName(string internalAttributeName) + { + return GetContextEntity(typeof(TParent)) + .Attributes + .Single(a => a.InternalAttributeName == internalAttributeName) + .PublicAttributeName; + } + } } From 30c531d490cf005722362c2eef408fe602867ec9 Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 17:31:01 -0500 Subject: [PATCH 14/74] wip: new 422 validation errors with source.pointer --- .../Controllers/BaseJsonApiController.cs | 8 ++++---- .../Extensions/ModelStateExtensions.cs | 13 +++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index fd6ec8947a..de6aab46e1 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Controllers { - public class BaseJsonApiController + public class BaseJsonApiController : BaseJsonApiController where T : class, IIdentifiable { @@ -47,7 +47,7 @@ public class BaseJsonApiController private readonly ICreateService _create; private readonly IUpdateService _update; private readonly IUpdateRelationshipService _updateRelationships; - private readonly IDeleteService _delete; + private readonly IDeleteService _delete; private readonly IJsonApiContext _jsonApiContext; public BaseJsonApiController( @@ -156,7 +156,7 @@ public virtual async Task PostAsync([FromBody] T entity) return Forbidden(); if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) - return BadRequest(ModelState.ConvertToErrorCollection()); + return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); entity = await _create.CreateAsync(entity); @@ -170,7 +170,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) if (entity == null) return UnprocessableEntity(); if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) - return BadRequest(ModelState.ConvertToErrorCollection()); + return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); var updatedEntity = await _update.UpdateAsync(id, entity); diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index d67f7e66c4..0d5701013d 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Extensions { public static class ModelStateExtensions { - public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) + public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState, IContextGraph contextGraph) { ErrorCollection collection = new ErrorCollection(); foreach (var entry in modelState) @@ -16,10 +16,19 @@ public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary foreach (var modelError in entry.Value.Errors) { + var attrName = entry.Key; + if (modelError.Exception is JsonApiException jex) collection.Errors.AddRange(jex.GetError().Errors); else - collection.Errors.Add(new Error(400, entry.Key, modelError.ErrorMessage, modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null)); + collection.Errors.Add(new Error( + status: 422, + title: entry.Key, + detail: modelError.ErrorMessage, + meta: modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null, + source: new { + pointer = $"/data/attributes/{attrName}" + })); } } From 0e170593cc992f2749ffd634ef31e9e5e56c0665 Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 17:46:34 -0500 Subject: [PATCH 15/74] feat: validations send source.pointer --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 4 ++-- src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index de6aab46e1..407899051d 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -156,7 +156,7 @@ public virtual async Task PostAsync([FromBody] T entity) return Forbidden(); if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) - return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); + return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); entity = await _create.CreateAsync(entity); @@ -170,7 +170,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) if (entity == null) return UnprocessableEntity(); if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) - return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); + return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); var updatedEntity = await _update.UpdateAsync(id, entity); diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index 0d5701013d..47f56a2570 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Extensions { public static class ModelStateExtensions { - public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState, IContextGraph contextGraph) + public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState, IContextGraph contextGraph) { ErrorCollection collection = new ErrorCollection(); foreach (var entry in modelState) @@ -16,7 +16,7 @@ public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary foreach (var modelError in entry.Value.Errors) { - var attrName = entry.Key; + var attrName =contextGraph.GetPublicAttributeName(entry.Key); if (modelError.Exception is JsonApiException jex) collection.Errors.AddRange(jex.GetError().Errors); From b841433be399374feb70390e9ca5a990a883f0a3 Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 18:51:20 -0500 Subject: [PATCH 16/74] fix: handle 'GetPublicAttributeName' null and mock for tests --- .../Extensions/ModelStateExtensions.cs | 6 +- .../Internal/ContextGraph.cs | 2 +- .../BaseJsonApiController_Tests.cs | 696 +++++++++--------- 3 files changed, 356 insertions(+), 348 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index 47f56a2570..3a742617db 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -14,10 +14,10 @@ public static ErrorCollection ConvertToErrorCollection(this ModelStateDiction if (entry.Value.Errors.Any() == false) continue; + var attrName = contextGraph.GetPublicAttributeName(entry.Key); + foreach (var modelError in entry.Value.Errors) { - var attrName =contextGraph.GetPublicAttributeName(entry.Key); - if (modelError.Exception is JsonApiException jex) collection.Errors.AddRange(jex.GetError().Errors); else @@ -26,7 +26,7 @@ public static ErrorCollection ConvertToErrorCollection(this ModelStateDiction title: entry.Key, detail: modelError.ErrorMessage, meta: modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null, - source: new { + source: attrName == null ? null : new { pointer = $"/data/attributes/{attrName}" })); } diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index 6a39a292de..4b6a310527 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -122,7 +122,7 @@ public string GetPublicAttributeName(string internalAttributeName) { return GetContextEntity(typeof(TParent)) .Attributes - .Single(a => a.InternalAttributeName == internalAttributeName) + .SingleOrDefault(a => a.InternalAttributeName == internalAttributeName)? .PublicAttributeName; } } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 873b3f50d2..c0f0c0cbe6 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -1,344 +1,352 @@ -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; -using Moq; -using Xunit; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace UnitTests -{ - public class BaseJsonApiController_Tests - { - public class Resource : Identifiable { } - private Mock _jsonApiContextMock = new Mock(); - - [Fact] - public async Task GetAsync_Calls_Service() - { - // arrange - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getAll: serviceMock.Object); - - // act - await controller.GetAsync(); - - // assert - serviceMock.Verify(m => m.GetAsync(), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task GetAsync_Throws_405_If_No_Service() - { - // arrange - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - [Fact] - public async Task GetAsyncById_Calls_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getById: serviceMock.Object); - - // act - await controller.GetAsync(id); - - // assert - serviceMock.Verify(m => m.GetAsync(id), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task GetAsyncById_Throws_405_If_No_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getById: null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - [Fact] - public async Task GetRelationshipsAsync_Calls_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationships: serviceMock.Object); - - // act - await controller.GetRelationshipsAsync(id, string.Empty); - - // assert - serviceMock.Verify(m => m.GetRelationshipsAsync(id, string.Empty), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task GetRelationshipsAsync_Throws_405_If_No_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationships: null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - [Fact] - public async Task GetRelationshipAsync_Calls_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationship: serviceMock.Object); - - // act - await controller.GetRelationshipAsync(id, string.Empty); - - // assert - serviceMock.Verify(m => m.GetRelationshipAsync(id, string.Empty), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task GetRelationshipAsync_Throws_405_If_No_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationship: null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - [Fact] - public async Task PatchAsync_Calls_Service() - { - // arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); - _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions()); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: serviceMock.Object); - - // act - await controller.PatchAsync(id, resource); - - // assert - serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task PatchAsync_ModelStateInvalid_ValidateModelStateDisbled() - { - // arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); - _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = false }); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: serviceMock.Object); - - // act - var response = await controller.PatchAsync(id, resource); - - // assert - serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Once); - VerifyApplyContext(); - Assert.IsNotType(response); - } - - [Fact] - public async Task PatchAsync_ModelStateInvalid_ValidateModelStateEnabled() - { - // arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); - _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions{ValidateModelState = true}); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: serviceMock.Object); - controller.ModelState.AddModelError("Id", "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(((BadRequestObjectResult) response).Value); - } - - [Fact] - public async Task PatchAsync_Throws_405_If_No_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - [Fact] - public async Task PostAsync_Calls_Service() - { - // arrange - var resource = new Resource(); - var serviceMock = new Mock>(); - _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); - _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions()); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, create: serviceMock.Object); - serviceMock.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(resource); - controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext {HttpContext = new DefaultHttpContext()}; - - // act - await controller.PostAsync(resource); - - // assert - serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task PostAsync_ModelStateInvalid_ValidateModelStateDisabled() - { - // arrange - var resource = new Resource(); - var serviceMock = new Mock>(); - _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); - _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = false }); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, create: serviceMock.Object); - serviceMock.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(resource); - controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new DefaultHttpContext() }; - - // act - var response = await controller.PostAsync(resource); - - // assert - serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); - VerifyApplyContext(); - Assert.IsNotType(response); - } - - [Fact] - public async Task PostAsync_ModelStateInvalid_ValidateModelStateEnabled() - { - // arrange - var resource = new Resource(); - var serviceMock = new Mock>(); - _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); - _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = true }); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, create: serviceMock.Object); - controller.ModelState.AddModelError("Id", "Failed Validation"); - - // act - var response = await controller.PostAsync(resource); - - // assert - serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Never); - Assert.IsType(response); - Assert.IsType(((BadRequestObjectResult)response).Value); - } - - [Fact] - public async Task PatchRelationshipsAsync_Calls_Service() - { - // arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, updateRelationships: serviceMock.Object); - - // act - await controller.PatchRelationshipsAsync(id, string.Empty, null); - - // assert - serviceMock.Verify(m => m.UpdateRelationshipsAsync(id, string.Empty, null), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, updateRelationships: null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - [Fact] - public async Task DeleteAsync_Calls_Service() - { - // arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, delete: serviceMock.Object); - - // act - await controller.DeleteAsync(id); - - // assert - serviceMock.Verify(m => m.DeleteAsync(id), Times.Once); - VerifyApplyContext(); - } - - [Fact] - public async Task DeleteAsync_Throws_405_If_No_Service() - { - // arrange - const int id = 0; - var serviceMock = new Mock>(); - var controller = new BaseJsonApiController(_jsonApiContextMock.Object, delete: null); - - // act - var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); - - // assert - Assert.Equal(405, exception.GetStatusCode()); - } - - private void VerifyApplyContext() - => _jsonApiContextMock.Verify(m => m.ApplyContext(It.IsAny>()), Times.Once); - } -} +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using Moq; +using Xunit; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace UnitTests +{ + public class BaseJsonApiController_Tests + { + public class Resource : Identifiable + { + [Attr("test-attribute")] public string TestAttribute { get; set; } + } + private Mock _jsonApiContextMock = new Mock(); + private Mock _contextGraphMock = new Mock(); + + [Fact] + public async Task GetAsync_Calls_Service() + { + // arrange + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getAll: serviceMock.Object); + + // act + await controller.GetAsync(); + + // assert + serviceMock.Verify(m => m.GetAsync(), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task GetAsync_Throws_405_If_No_Service() + { + // arrange + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.GetAsync()); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + [Fact] + public async Task GetAsyncById_Calls_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getById: serviceMock.Object); + + // act + await controller.GetAsync(id); + + // assert + serviceMock.Verify(m => m.GetAsync(id), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task GetAsyncById_Throws_405_If_No_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getById: null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.GetAsync(id)); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + [Fact] + public async Task GetRelationshipsAsync_Calls_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationships: serviceMock.Object); + + // act + await controller.GetRelationshipsAsync(id, string.Empty); + + // assert + serviceMock.Verify(m => m.GetRelationshipsAsync(id, string.Empty), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task GetRelationshipsAsync_Throws_405_If_No_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationships: null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + [Fact] + public async Task GetRelationshipAsync_Calls_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationship: serviceMock.Object); + + // act + await controller.GetRelationshipAsync(id, string.Empty); + + // assert + serviceMock.Verify(m => m.GetRelationshipAsync(id, string.Empty), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task GetRelationshipAsync_Throws_405_If_No_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, getRelationship: null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + [Fact] + public async Task PatchAsync_Calls_Service() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock>(); + _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions()); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: serviceMock.Object); + + // act + await controller.PatchAsync(id, resource); + + // assert + serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task PatchAsync_ModelStateInvalid_ValidateModelStateDisbled() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock>(); + _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = false }); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: serviceMock.Object); + + // act + var response = await controller.PatchAsync(id, resource); + + // assert + serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Once); + VerifyApplyContext(); + Assert.IsNotType(response); + } + + [Fact] + public async Task PatchAsync_ModelStateInvalid_ValidateModelStateEnabled() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock>(); + _jsonApiContextMock.SetupGet(a => a.ContextGraph).Returns(_contextGraphMock.Object); + _contextGraphMock.Setup(a => a.GetPublicAttributeName("TestAttribute")).Returns("test-attribute"); + _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions{ValidateModelState = true}); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, 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(((BadRequestObjectResult) response).Value); + } + + [Fact] + public async Task PatchAsync_Throws_405_If_No_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, update: null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.PatchAsync(id, It.IsAny())); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + [Fact] + public async Task PostAsync_Calls_Service() + { + // arrange + var resource = new Resource(); + var serviceMock = new Mock>(); + _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions()); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, create: serviceMock.Object); + serviceMock.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(resource); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext {HttpContext = new DefaultHttpContext()}; + + // act + await controller.PostAsync(resource); + + // assert + serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task PostAsync_ModelStateInvalid_ValidateModelStateDisabled() + { + // arrange + var resource = new Resource(); + var serviceMock = new Mock>(); + _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = false }); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, create: serviceMock.Object); + serviceMock.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(resource); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new DefaultHttpContext() }; + + // act + var response = await controller.PostAsync(resource); + + // assert + serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); + VerifyApplyContext(); + Assert.IsNotType(response); + } + + [Fact] + public async Task PostAsync_ModelStateInvalid_ValidateModelStateEnabled() + { + // arrange + var resource = new Resource(); + var serviceMock = new Mock>(); + _jsonApiContextMock.SetupGet(a => a.ContextGraph).Returns(_contextGraphMock.Object); + _contextGraphMock.Setup(a => a.GetPublicAttributeName("TestAttribute")).Returns("test-attribute"); + _jsonApiContextMock.Setup(a => a.ApplyContext(It.IsAny>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = true }); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, create: serviceMock.Object); + controller.ModelState.AddModelError("TestAttribute", "Failed Validation"); + + // act + var response = await controller.PostAsync(resource); + + // assert + serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Never); + Assert.IsType(response); + Assert.IsType(((BadRequestObjectResult)response).Value); + } + + [Fact] + public async Task PatchRelationshipsAsync_Calls_Service() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, updateRelationships: serviceMock.Object); + + // act + await controller.PatchRelationshipsAsync(id, string.Empty, null); + + // assert + serviceMock.Verify(m => m.UpdateRelationshipsAsync(id, string.Empty, null), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, updateRelationships: null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + [Fact] + public async Task DeleteAsync_Calls_Service() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, delete: serviceMock.Object); + + // act + await controller.DeleteAsync(id); + + // assert + serviceMock.Verify(m => m.DeleteAsync(id), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task DeleteAsync_Throws_405_If_No_Service() + { + // arrange + const int id = 0; + var serviceMock = new Mock>(); + var controller = new BaseJsonApiController(_jsonApiContextMock.Object, delete: null); + + // act + var exception = await Assert.ThrowsAsync(() => controller.DeleteAsync(id)); + + // assert + Assert.Equal(405, exception.GetStatusCode()); + } + + private void VerifyApplyContext() + => _jsonApiContextMock.Verify(m => m.ApplyContext(It.IsAny>()), Times.Once); + } +} From 524232e1eb9d670b40f14d6fad1097a1ee2a2f06 Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 22:22:04 -0500 Subject: [PATCH 17/74] feat: throw 422 status code when validation fails --- .../Controllers/BaseJsonApiController.cs | 5 +++-- test/UnitTests/Controllers/BaseJsonApiController_Tests.cs | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 407899051d..aec2d16cfe 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -156,7 +156,7 @@ public virtual async Task PostAsync([FromBody] T entity) return Forbidden(); if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) - return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); + return UnprocessableEntity(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); entity = await _create.CreateAsync(entity); @@ -169,8 +169,9 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) if (entity == null) return UnprocessableEntity(); + if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) - return BadRequest(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); + return UnprocessableEntity(ModelState.ConvertToErrorCollection(_jsonApiContext.ContextGraph)); var updatedEntity = await _update.UpdateAsync(id, entity); diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index c0f0c0cbe6..7327bb18a3 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -200,8 +200,8 @@ public async Task PatchAsync_ModelStateInvalid_ValidateModelStateEnabled() // assert serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny()), Times.Never); - Assert.IsType(response); - Assert.IsType(((BadRequestObjectResult) response).Value); + Assert.IsType(response); + Assert.IsType(((UnprocessableEntityObjectResult) response).Value); } [Fact] @@ -278,8 +278,8 @@ public async Task PostAsync_ModelStateInvalid_ValidateModelStateEnabled() // assert serviceMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Never); - Assert.IsType(response); - Assert.IsType(((BadRequestObjectResult)response).Value); + Assert.IsType(response); + Assert.IsType(((UnprocessableEntityObjectResult)response).Value); } [Fact] From 7e22eebc583d33c4ba7b4a91a493619bd9bc47ec Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 7 Aug 2018 23:55:08 -0500 Subject: [PATCH 18/74] feat: obsolete method ConvertToErrorCollection --- .../Extensions/ModelStateExtensions.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs index 3a742617db..43c33c21f2 100644 --- a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.EntityFrameworkCore.Internal; @@ -6,6 +7,26 @@ namespace JsonApiDotNetCore.Extensions { public static class ModelStateExtensions { + [Obsolete("Use Generic Method ConvertToErrorCollection(IContextGraph contextGraph) instead for full validation errors")] + public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) + { + ErrorCollection collection = new ErrorCollection(); + foreach (var entry in modelState) + { + if (entry.Value.Errors.Any() == false) + continue; + + 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(400, entry.Key, modelError.ErrorMessage, modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null)); + } + } + + return collection; + } public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState, IContextGraph contextGraph) { ErrorCollection collection = new ErrorCollection(); From 262d34175c186598ead8e20302c40024f63723cb Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Wed, 8 Aug 2018 13:06:02 -0700 Subject: [PATCH 19/74] chore(csproj): bump package version to 2.5.0 --- src/JsonApiDotNetCore/JsonApiDotNetCore.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 224c39d594..4af725919b 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - 2.4.0 + 2.5.0 $(NetStandardVersion) JsonApiDotNetCore JsonApiDotNetCore From d7d16bda33427993c7835904b07d9cfd6c99c0df Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Thu, 9 Aug 2018 21:57:31 -0700 Subject: [PATCH 20/74] fix(#343): reload relationships from database if included during POST --- .../Data/DefaultEntityRepository.cs | 85 +++++++++--- .../Data/IEntityReadRepository.cs | 6 +- .../Services/EntityResourceService.cs | 27 +++- .../Acceptance/Spec/CreatingDataTests.cs | 121 ++++++++++++++++++ 4 files changed, 218 insertions(+), 21 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 7de24d7360..3cbcc828ff 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -13,6 +13,7 @@ namespace JsonApiDotNetCore.Data { + /// public class DefaultEntityRepository : DefaultEntityRepository, IEntityRepository @@ -26,6 +27,10 @@ public DefaultEntityRepository( { } } + /// + /// Provides a default repository implementation and is responsible for + /// abstracting any EF Core APIs away from the service layer. + /// public class DefaultEntityRepository : IEntityRepository where TEntity : class, IIdentifiable @@ -48,7 +53,7 @@ public DefaultEntityRepository( _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; } - /// + /// public virtual IQueryable Get() { if (_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Count > 0) @@ -57,41 +62,43 @@ public virtual IQueryable Get() return _dbSet; } - /// + /// public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { return entities.Filter(_jsonApiContext, filterQuery); } - /// + /// public virtual IQueryable Sort(IQueryable entities, List sortQueries) { return entities.Sort(sortQueries); } - /// + /// public virtual async Task GetAsync(TId id) { return await Get().SingleOrDefaultAsync(e => e.Id.Equals(id)); } - /// + /// public virtual async Task GetAndIncludeAsync(TId id, string relationshipName) { _logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); - var result = await Include(Get(), relationshipName).SingleOrDefaultAsync(e => e.Id.Equals(id)); + var includedSet = await IncludeAsync(Get(), relationshipName); + var result = await includedSet.SingleOrDefaultAsync(e => e.Id.Equals(id)); return result; } - /// + /// public virtual async Task CreateAsync(TEntity entity) { AttachRelationships(); _dbSet.Add(entity); await _context.SaveChangesAsync(); + return entity; } @@ -129,7 +136,7 @@ private void AttachHasOnePointers() _context.Entry(relationship.Value).State = EntityState.Unchanged; } - /// + /// public virtual async Task UpdateAsync(TId id, TEntity entity) { var oldEntity = await GetAsync(id); @@ -148,14 +155,14 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) return oldEntity; } - /// + /// public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { var genericProcessor = _genericProcessorFactory.GetProcessor(typeof(GenericProcessor<>), relationship.Type); await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } - /// + /// public virtual async Task DeleteAsync(TId id) { var entity = await GetAsync(id); @@ -170,7 +177,8 @@ public virtual async Task DeleteAsync(TId id) return true; } - /// + /// + [Obsolete("Use IncludeAsync")] public virtual IQueryable Include(IQueryable entities, string relationshipName) { var entity = _jsonApiContext.RequestEntity; @@ -185,10 +193,57 @@ public virtual IQueryable Include(IQueryable entities, string { throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed"); } + + return entities.Include(relationship.InternalRelationshipName); + } + + /// + public virtual async Task> IncludeAsync(IQueryable entities, string relationshipName) + { + var entity = _jsonApiContext.RequestEntity; + var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); + if (relationship == null) + { + throw new JsonApiException(400, $"Invalid relationship {relationshipName} on {entity.EntityName}", + $"{entity.EntityName} does not have a relationship named {relationshipName}"); + } + + if (!relationship.CanInclude) + { + throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed"); + } + + await ReloadPointerAsync(relationship); + return entities.Include(relationship.InternalRelationshipName); } - /// + /// + /// Ensure relationships on the provided entity have been fully loaded from the database. + /// + /// + /// The only known case when this should be called is when a POST request is + /// sent with an ?include query. + /// + /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + /// + private async Task ReloadPointerAsync(RelationshipAttribute relationshipAttr) + { + if (relationshipAttr.IsHasOne && _jsonApiContext.HasOneRelationshipPointers.Get().TryGetValue(relationshipAttr, out var pointer)) + { + await _context.Entry(pointer).ReloadAsync(); + } + + if (relationshipAttr.IsHasMany && _jsonApiContext.HasManyRelationshipPointers.Get().TryGetValue(relationshipAttr, out var pointers)) + { + foreach (var hasManyPointer in pointers) + { + await _context.Entry(hasManyPointer).ReloadAsync(); + } + } + } + + /// public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { if (pageNumber >= 0) @@ -209,7 +264,7 @@ public virtual async Task> PageAsync(IQueryable en .ToListAsync(); } - /// + /// public async Task CountAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -217,7 +272,7 @@ public async Task CountAsync(IQueryable entities) : entities.Count(); } - /// + /// public async Task FirstOrDefaultAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -225,7 +280,7 @@ public async Task FirstOrDefaultAsync(IQueryable entities) : entities.FirstOrDefault(); } - /// + /// public async Task> ToListAsync(IQueryable entities) { return (entities is IAsyncEnumerable) diff --git a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs index f861ce10e2..f8110dd4f8 100644 --- a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -20,6 +21,9 @@ public interface IEntityReadRepository /// IQueryable Get(); + [Obsolete("Use IncludeAsync")] + IQueryable Include(IQueryable entities, string relationshipName); + /// /// Include a relationship in the query /// @@ -28,7 +32,7 @@ public interface IEntityReadRepository /// _todoItemsRepository.GetAndIncludeAsync(1, "achieved-date"); /// /// - IQueryable Include(IQueryable entities, string relationshipName); + Task> IncludeAsync(IQueryable entities, string relationshipName); /// /// Apply a filter to the provided queryable diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 300c858c43..b0ee28e8f0 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -77,6 +77,12 @@ public virtual async Task CreateAsync(TResource resource) entity = await _entities.CreateAsync(entity); + // this ensures relationships get reloaded from the database if they have + // been requested + // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + if (ShouldIncludeRelationships()) + return await GetWithRelationshipsAsync(entity.Id); + return MapOut(entity); } @@ -92,7 +98,7 @@ public virtual async Task> GetAsync() entities = ApplySortAndFilterQuery(entities); if (ShouldIncludeRelationships()) - entities = IncludeRelationships(entities, _jsonApiContext.QuerySet.IncludedRelationships); + entities = await IncludeRelationshipsAsync(entities, _jsonApiContext.QuerySet.IncludedRelationships); if (_jsonApiContext.Options.IncludeTotalRecordCount) _jsonApiContext.PageManager.TotalRecords = await _entities.CountAsync(entities); @@ -218,7 +224,8 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable IncludeRelationships(IQueryable entities, List relationships) + [Obsolete("Use IncludeRelationshipsAsync")] + protected IQueryable IncludeRelationships(IQueryable entities, List relationships) { _jsonApiContext.IncludedRelationships = relationships; @@ -228,14 +235,24 @@ protected virtual IQueryable IncludeRelationships(IQueryable e return entities; } + protected virtual async Task> IncludeRelationshipsAsync(IQueryable entities, List relationships) + { + _jsonApiContext.IncludedRelationships = relationships; + + foreach (var r in relationships) + entities = await _entities.IncludeAsync(entities, r); + + return entities; + } + private async Task GetWithRelationshipsAsync(TId id) { var query = _entities.Get().Where(e => e.Id.Equals(id)); - _jsonApiContext.QuerySet.IncludedRelationships.ForEach(r => + foreach (var r in _jsonApiContext.QuerySet.IncludedRelationships) { - query = _entities.Include(query, r); - }); + query = await _entities.IncludeAsync(query, r); + } var value = await _entities.FirstOrDefaultAsync(query); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index e65278db12..a34312ca2f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -285,6 +285,71 @@ public async Task Can_Create_And_Set_HasMany_Relationships() Assert.NotEmpty(contextCollection.TodoItems); } + [Fact] + public async Task Can_Create_With_HasMany_Relationship_And_Include_Result() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var context = _fixture.GetService(); + + var owner = new JsonApiDotNetCoreExample.Models.Person(); + var todoItem = new TodoItem(); + todoItem.Owner = owner; + todoItem.Description = "Description"; + context.People.Add(owner); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = "/api/v1/todo-collections?include=todo-items"; + var request = new HttpRequestMessage(httpMethod, route); + var content = new + { + data = new + { + type = "todo-collections", + relationships = new Dictionary + { + { "owner", new { + data = new + { + type = "people", + id = owner.Id.ToString() + } + } }, + { "todo-items", new { + data = new dynamic[] + { + new { + type = "todo-items", + id = todoItem.Id.ToString() + } + } + } } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var collectionResult = _fixture.GetService().Deserialize(body); + + Assert.NotNull(collectionResult); + Assert.NotEmpty(collectionResult.TodoItems); + Assert.Equal(todoItem.Description, collectionResult.TodoItems.Single().Description); + } + [Fact] public async Task Can_Create_And_Set_HasOne_Relationships() { @@ -342,6 +407,62 @@ public async Task Can_Create_And_Set_HasOne_Relationships() Assert.Equal(owner.Id, todoItemResult.OwnerId); } + [Fact] + public async Task Can_Create_With_HasOne_Relationship_And_Include_Result() + { + // arrange + var builder = new WebHostBuilder().UseStartup(); + + var httpMethod = new HttpMethod("POST"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var context = _fixture.GetService(); + + var todoItem = new TodoItem(); + var owner = new JsonApiDotNetCoreExample.Models.Person + { + FirstName = "Alice" + }; + context.People.Add(owner); + + await context.SaveChangesAsync(); + + var route = "/api/v1/todo-items?include=owner"; + var request = new HttpRequestMessage(httpMethod, route); + var content = new + { + data = new + { + type = "todo-items", + relationships = new Dictionary + { + { "owner", new { + data = new + { + type = "people", + id = owner.Id.ToString() + } + } } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(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 + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var todoItemResult = (TodoItem)_fixture.GetService().Deserialize(body); + Assert.NotNull(todoItemResult); + Assert.NotNull(todoItemResult.Owner); + Assert.Equal(owner.FirstName, todoItemResult.Owner.FirstName); + } + [Fact] public async Task Can_Create_And_Set_HasOne_Relationships_From_Independent_Side() { From 59bf942aaf092b82181560de531dc0dddd98ee43 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Thu, 9 Aug 2018 22:43:27 -0700 Subject: [PATCH 21/74] don't reload individual relationships, just detach and then fetch --- .../Data/DefaultEntityRepository.cs | 71 ++++++------------- .../Data/IEntityReadRepository.cs | 5 +- .../Data/IEntityRepository.cs | 2 +- .../Data/IEntityWriteRepository.cs | 14 ++++ .../Services/EntityResourceService.cs | 18 ++--- 5 files changed, 44 insertions(+), 66 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 3cbcc828ff..bc9bc90544 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -85,7 +85,7 @@ public virtual async Task GetAndIncludeAsync(TId id, string relationshi { _logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); - var includedSet = await IncludeAsync(Get(), relationshipName); + var includedSet = Include(Get(), relationshipName); var result = await includedSet.SingleOrDefaultAsync(e => e.Id.Equals(id)); return result; @@ -108,6 +108,28 @@ protected virtual void AttachRelationships() AttachHasOnePointers(); } + /// + public void DetachRelationshipPointers(TEntity entity) + { + foreach (var hasOneRelationship in _jsonApiContext.HasOneRelationshipPointers.Get()) + { + _context.Entry(hasOneRelationship.Value).State = EntityState.Detached; + } + + foreach (var hasManyRelationship in _jsonApiContext.HasManyRelationshipPointers.Get()) + { + foreach (var pointer in hasManyRelationship.Value) + { + _context.Entry(pointer).State = EntityState.Detached; + } + + // HACK: detaching has many relationships doesn't appear to be sufficient + // the navigation property actually needs to be nulled out, otherwise + // EF adds duplicate instances to the collection + hasManyRelationship.Key.SetValue(entity, null); + } + } + /// /// This is used to allow creation of HasMany relationships when the /// dependent side of the relationship already exists. @@ -178,7 +200,6 @@ public virtual async Task DeleteAsync(TId id) } /// - [Obsolete("Use IncludeAsync")] public virtual IQueryable Include(IQueryable entities, string relationshipName) { var entity = _jsonApiContext.RequestEntity; @@ -197,52 +218,6 @@ public virtual IQueryable Include(IQueryable entities, string return entities.Include(relationship.InternalRelationshipName); } - /// - public virtual async Task> IncludeAsync(IQueryable entities, string relationshipName) - { - var entity = _jsonApiContext.RequestEntity; - var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); - if (relationship == null) - { - throw new JsonApiException(400, $"Invalid relationship {relationshipName} on {entity.EntityName}", - $"{entity.EntityName} does not have a relationship named {relationshipName}"); - } - - if (!relationship.CanInclude) - { - throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed"); - } - - await ReloadPointerAsync(relationship); - - return entities.Include(relationship.InternalRelationshipName); - } - - /// - /// Ensure relationships on the provided entity have been fully loaded from the database. - /// - /// - /// The only known case when this should be called is when a POST request is - /// sent with an ?include query. - /// - /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 - /// - private async Task ReloadPointerAsync(RelationshipAttribute relationshipAttr) - { - if (relationshipAttr.IsHasOne && _jsonApiContext.HasOneRelationshipPointers.Get().TryGetValue(relationshipAttr, out var pointer)) - { - await _context.Entry(pointer).ReloadAsync(); - } - - if (relationshipAttr.IsHasMany && _jsonApiContext.HasManyRelationshipPointers.Get().TryGetValue(relationshipAttr, out var pointers)) - { - foreach (var hasManyPointer in pointers) - { - await _context.Entry(hasManyPointer).ReloadAsync(); - } - } - } - /// public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { diff --git a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs index f8110dd4f8..ea9450d726 100644 --- a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs @@ -21,9 +21,6 @@ public interface IEntityReadRepository /// IQueryable Get(); - [Obsolete("Use IncludeAsync")] - IQueryable Include(IQueryable entities, string relationshipName); - /// /// Include a relationship in the query /// @@ -32,7 +29,7 @@ public interface IEntityReadRepository /// _todoItemsRepository.GetAndIncludeAsync(1, "achieved-date"); /// /// - Task> IncludeAsync(IQueryable entities, string relationshipName); + IQueryable Include(IQueryable entities, string relationshipName); /// /// Apply a filter to the provided queryable diff --git a/src/JsonApiDotNetCore/Data/IEntityRepository.cs b/src/JsonApiDotNetCore/Data/IEntityRepository.cs index e8bb68ef90..290f860566 100644 --- a/src/JsonApiDotNetCore/Data/IEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityRepository.cs @@ -8,7 +8,7 @@ public interface IEntityRepository { } public interface IEntityRepository - : IEntityReadRepository, + : IEntityReadRepository, IEntityWriteRepository where TEntity : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs b/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs index 37c60b8521..38198fa676 100644 --- a/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs @@ -19,5 +19,19 @@ public interface IEntityWriteRepository Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); Task DeleteAsync(TId id); + + /// + /// Ensures that any relationship pointers created during a POST or PATCH + /// request are detached from the DbContext. + /// This allows the relationships to be fully loaded from the database. + /// + /// + /// + /// The only known case when this should be called is when a POST request is + /// sent with an ?include query. + /// + /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + /// + void DetachRelationshipPointers(TEntity entity); } } diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index b0ee28e8f0..0f8257011c 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -81,7 +81,10 @@ public virtual async Task CreateAsync(TResource resource) // been requested // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 if (ShouldIncludeRelationships()) + { + _entities.DetachRelationshipPointers(entity); return await GetWithRelationshipsAsync(entity.Id); + } return MapOut(entity); } @@ -98,7 +101,7 @@ public virtual async Task> GetAsync() entities = ApplySortAndFilterQuery(entities); if (ShouldIncludeRelationships()) - entities = await IncludeRelationshipsAsync(entities, _jsonApiContext.QuerySet.IncludedRelationships); + entities = IncludeRelationships(entities, _jsonApiContext.QuerySet.IncludedRelationships); if (_jsonApiContext.Options.IncludeTotalRecordCount) _jsonApiContext.PageManager.TotalRecords = await _entities.CountAsync(entities); @@ -224,7 +227,6 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable IncludeRelationships(IQueryable entities, List relationships) { _jsonApiContext.IncludedRelationships = relationships; @@ -235,23 +237,13 @@ protected IQueryable IncludeRelationships(IQueryable entities, return entities; } - protected virtual async Task> IncludeRelationshipsAsync(IQueryable entities, List relationships) - { - _jsonApiContext.IncludedRelationships = relationships; - - foreach (var r in relationships) - entities = await _entities.IncludeAsync(entities, r); - - return entities; - } - private async Task GetWithRelationshipsAsync(TId id) { var query = _entities.Get().Where(e => e.Id.Equals(id)); foreach (var r in _jsonApiContext.QuerySet.IncludedRelationships) { - query = await _entities.IncludeAsync(query, r); + query = _entities.Include(query, r); } var value = await _entities.FirstOrDefaultAsync(query); From 66ea1b3cdc25d6a2dcbf31ffd7af0da0311391e5 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Thu, 9 Aug 2018 22:46:09 -0700 Subject: [PATCH 22/74] cleanup --- src/JsonApiDotNetCore/Data/IEntityReadRepository.cs | 1 - src/JsonApiDotNetCore/Services/EntityResourceService.cs | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs index ea9450d726..f861ce10e2 100644 --- a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 0f8257011c..996fb29610 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -227,7 +227,7 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable IncludeRelationships(IQueryable entities, List relationships) + protected virtual IQueryable IncludeRelationships(IQueryable entities, List relationships) { _jsonApiContext.IncludedRelationships = relationships; @@ -241,10 +241,10 @@ private async Task GetWithRelationshipsAsync(TId id) { var query = _entities.Get().Where(e => e.Id.Equals(id)); - foreach (var r in _jsonApiContext.QuerySet.IncludedRelationships) + _jsonApiContext.QuerySet.IncludedRelationships.ForEach(r => { query = _entities.Include(query, r); - } + }); var value = await _entities.FirstOrDefaultAsync(query); From 09f38460905b009d26da9c5d00c6c2938d1bf189 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Thu, 9 Aug 2018 22:58:12 -0700 Subject: [PATCH 23/74] introduce IEntityFrameworkRepository and bump version allows changes to be rolled out in a non-breaking way --- .../Data/DefaultEntityRepository.cs | 3 ++- .../Data/IEntityRepository.cs | 21 +++++++++++++++++++ .../Data/IEntityWriteRepository.cs | 14 ------------- .../JsonApiDotNetCore.csproj | 2 +- .../Services/EntityResourceService.cs | 4 +++- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index bc9bc90544..862079aeee 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -32,7 +32,8 @@ public DefaultEntityRepository( /// abstracting any EF Core APIs away from the service layer. /// public class DefaultEntityRepository - : IEntityRepository + : IEntityRepository, + IEntityFrameworkRepository where TEntity : class, IIdentifiable { private readonly DbContext _context; diff --git a/src/JsonApiDotNetCore/Data/IEntityRepository.cs b/src/JsonApiDotNetCore/Data/IEntityRepository.cs index 290f860566..ac69f4fdac 100644 --- a/src/JsonApiDotNetCore/Data/IEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityRepository.cs @@ -12,4 +12,25 @@ public interface IEntityRepository IEntityWriteRepository where TEntity : class, IIdentifiable { } + + /// + /// A staging interface to avoid breaking changes that + /// specifically depend on EntityFramework. + /// + internal interface IEntityFrameworkRepository + { + /// + /// Ensures that any relationship pointers created during a POST or PATCH + /// request are detached from the DbContext. + /// This allows the relationships to be fully loaded from the database. + /// + /// + /// + /// The only known case when this should be called is when a POST request is + /// sent with an ?include query. + /// + /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + /// + void DetachRelationshipPointers(TEntity entity); + } } diff --git a/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs b/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs index 38198fa676..37c60b8521 100644 --- a/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityWriteRepository.cs @@ -19,19 +19,5 @@ public interface IEntityWriteRepository Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); Task DeleteAsync(TId id); - - /// - /// Ensures that any relationship pointers created during a POST or PATCH - /// request are detached from the DbContext. - /// This allows the relationships to be fully loaded from the database. - /// - /// - /// - /// The only known case when this should be called is when a POST request is - /// sent with an ?include query. - /// - /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 - /// - void DetachRelationshipPointers(TEntity entity); } } diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 4af725919b..c786cd5153 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - 2.5.0 + 2.5.1 $(NetStandardVersion) JsonApiDotNetCore JsonApiDotNetCore diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 996fb29610..1e8dc249a2 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -82,7 +82,9 @@ public virtual async Task CreateAsync(TResource resource) // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 if (ShouldIncludeRelationships()) { - _entities.DetachRelationshipPointers(entity); + if(_entities is IEntityFrameworkRepository efRepository) + efRepository.DetachRelationshipPointers(entity); + return await GetWithRelationshipsAsync(entity.Id); } From eb2d9e93569e6d7ddbf957e3f9876210e2578f8b Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Fri, 10 Aug 2018 20:36:03 -0700 Subject: [PATCH 24/74] fix(#362): improve error message for null HttpContext --- .../Services/ScopedServiceProvider.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs index eecd554e7c..51248f7593 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs @@ -1,3 +1,4 @@ +using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Http; using System; @@ -23,6 +24,16 @@ public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) } /// - public object GetService(Type serviceType) => _httpContextAccessor.HttpContext.RequestServices.GetService(serviceType); + 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"); + + return _httpContextAccessor.HttpContext.RequestServices.GetService(serviceType); + } } } From ee7d069a8caa142771af027a2ee6b0bc5f4092bf Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Fri, 10 Aug 2018 21:45:15 -0700 Subject: [PATCH 25/74] fix(#313): Do not return 409 for generic InvalidCastException --- .../IServiceCollectionExtensions.cs | 24 ++++----- .../Internal/JsonApiExceptionFactory.cs | 8 --- .../Middleware/TypeMatchFilter.cs | 49 +++++++++++++++++++ 3 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/TypeMatchFilter.cs diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 0199096316..4a56620b78 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using JsonApiDotNetCore.Services.Operations.Processors; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -44,12 +45,7 @@ public static IServiceCollection AddJsonApi(this IServiceCollection se config.BuildContextGraph(builder => builder.AddDbContext()); - mvcBuilder - .AddMvcOptions(opt => - { - opt.Filters.Add(typeof(JsonApiExceptionFilter)); - opt.SerializeAsJsonApi(config); - }); + mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, config)); AddJsonApiInternals(services, config); return services; @@ -63,17 +59,19 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, options(config); - mvcBuilder - .AddMvcOptions(opt => - { - opt.Filters.Add(typeof(JsonApiExceptionFilter)); - opt.SerializeAsJsonApi(config); - }); + mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, config)); AddJsonApiInternals(services, config); return services; } + private static void AddMvcOptions(MvcOptions options, JsonApiOptions config) + { + options.Filters.Add(typeof(JsonApiExceptionFilter)); + options.Filters.Add(typeof(TypeMatchFilter)); + options.SerializeAsJsonApi(config); + } + public static void AddJsonApiInternals( this IServiceCollection services, JsonApiOptions jsonApiOptions) where TContext : DbContext @@ -141,6 +139,8 @@ public static void AddJsonApiInternals( services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // services.AddScoped(); } private static void AddOperationServices(IServiceCollection services) diff --git a/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs index 159c9abc70..3b95e85b01 100644 --- a/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs +++ b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs @@ -11,14 +11,6 @@ public static JsonApiException GetException(Exception exception) if (exceptionType == typeof(JsonApiException)) return (JsonApiException)exception; - // TODO: this is for mismatching type requests (e.g. posting an author to articles endpoint) - // however, we can't actually guarantee that this is the source of this exception - // we should probably use an action filter or when we improve the ContextGraph - // we might be able to skip most of deserialization entirely by checking the JToken - // directly - if (exceptionType == typeof(InvalidCastException)) - return new JsonApiException(409, exception.Message, exception); - return new JsonApiException(500, exceptionType.Name, exception); } } diff --git a/src/JsonApiDotNetCore/Middleware/TypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/TypeMatchFilter.cs new file mode 100644 index 0000000000..5c061babe6 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/TypeMatchFilter.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + public class TypeMatchFilter : IActionFilter + { + private readonly IJsonApiContext _jsonApiContext; + + public TypeMatchFilter(IJsonApiContext jsonApiContext) + { + _jsonApiContext = jsonApiContext; + } + + /// + /// Used to verify the incoming type matches the target type, else return a 409 + /// + public void OnActionExecuting(ActionExecutingContext context) + { + var request = context.HttpContext.Request; + if (IsJsonApiRequest(request) && request.Method == "PATCH" || request.Method == "POST") + { + var deserializedType = context.ActionArguments.FirstOrDefault().Value?.GetType(); + var targetType = context.ActionDescriptor.Parameters.FirstOrDefault()?.ParameterType; + + if (deserializedType != null && targetType != null && deserializedType != targetType) + { + var expectedJsonApiResource = _jsonApiContext.ContextGraph.GetContextEntity(targetType); + + throw new JsonApiException(409, + $"Cannot '{context.HttpContext.Request.Method}' type '{_jsonApiContext.RequestEntity.EntityName}' " + + $"to '{expectedJsonApiResource?.EntityName}' endpoint.", + detail: "Check that the request payload type matches the type expected by this endpoint."); + } + } + } + + private bool IsJsonApiRequest(HttpRequest request) + { + return (request.ContentType?.Equals(Constants.ContentType, StringComparison.OrdinalIgnoreCase) == true); + } + + public void OnActionExecuted(ActionExecutedContext context) { /* noop */ } + } +} From 71e52aa2bbc064a4b5051924bd50e4c853df052f Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 11 Aug 2018 22:06:38 -0700 Subject: [PATCH 26/74] first pass at auto-discovery --- .../Data/AppDbContext.cs | 6 - .../Models/CamelCasedModel.cs | 1 + .../Models/TodoItemCollection.cs | 3 +- .../JsonApiDotNetCoreExample/Startup.cs | 22 +-- src/Examples/ReportsExample/Startup.cs | 15 +- .../Builders/ContextGraphBuilder.cs | 25 ++-- .../IServiceCollectionExtensions.cs | 30 ++-- .../Graph/IResourceNameFormatter.cs | 50 +++++++ .../Graph/ResourceDescriptor.cs | 16 ++ .../Graph/ServiceDiscoveryFacade.cs | 141 ++++++++++++++++++ src/JsonApiDotNetCore/Graph/TypeLocator.cs | 93 ++++++++++++ .../JsonApiDotNetCore.csproj | 1 + .../Serialization/JsonApiDeSerializer.cs | 2 +- 13 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs create mode 100644 src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs create mode 100644 src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs create mode 100644 src/JsonApiDotNetCore/Graph/TypeLocator.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 9c0ada4e4b..cee66678ab 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; using JsonApiDotNetCoreExample.Models.Entities; @@ -43,13 +42,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet TodoItems { get; set; } public DbSet People { get; set; } - - [Resource("todo-collections")] public DbSet TodoItemCollections { get; set; } - - [Resource("camelCasedModels")] public DbSet CamelCasedModels { get; set; } - public DbSet
Articles { get; set; } public DbSet Authors { get; set; } public DbSet NonJsonApiResources { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs b/src/Examples/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs index 7adf628f38..43d5a43272 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/CamelCasedModel.cs @@ -2,6 +2,7 @@ namespace JsonApiDotNetCoreExample.Models { + [Resource("camelCasedModels")] public class CamelCasedModel : Identifiable { [Attr("compoundAttr")] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index 95a523dff3..85877b3848 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -4,6 +4,7 @@ namespace JsonApiDotNetCoreExample.Models { + [Resource("todo-collections")] public class TodoItemCollection : Identifiable { [Attr("name")] @@ -16,4 +17,4 @@ public class TodoItemCollection : Identifiable [HasOne("owner")] public virtual Person Owner { get; set; } } -} \ No newline at end of file +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index ec1bdc544c..29dfc9e4b5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -7,9 +7,6 @@ using Microsoft.EntityFrameworkCore; using JsonApiDotNetCore.Extensions; using System; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Resources; -using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample { @@ -33,23 +30,20 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) var loggerFactory = new LoggerFactory(); loggerFactory.AddConsole(LogLevel.Warning); + var mvcBuilder = services.AddMvcCore(); + services .AddSingleton(loggerFactory) - .AddDbContext(options => - options.UseNpgsql(GetDbConnectionString()), ServiceLifetime.Transient) - .AddJsonApi(options => { + .AddDbContext(options => options.UseNpgsql(GetDbConnectionString()), ServiceLifetime.Transient) + .AddJsonApi(options => { options.Namespace = "api/v1"; options.DefaultPageSize = 5; options.IncludeTotalRecordCount = true; - }) - // TODO: this should be handled via auto-discovery - .AddScoped, UserResource>(); + }, + mvcBuilder, + discovery => discovery.AddCurrentAssemblyServices()); - var provider = services.BuildServiceProvider(); - var appContext = provider.GetRequiredService(); - if(appContext == null) - throw new ArgumentException(); - + var provider = services.BuildServiceProvider(); return provider; } diff --git a/src/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs index fe476c0406..43a910a0e6 100644 --- a/src/Examples/ReportsExample/Startup.cs +++ b/src/Examples/ReportsExample/Startup.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -26,16 +25,10 @@ public Startup(IHostingEnvironment env) public virtual void ConfigureServices(IServiceCollection services) { var mvcBuilder = services.AddMvcCore(); - services.AddJsonApi(opt => - { - opt.BuildContextGraph(builder => - { - builder.AddResource("reports"); - }); - opt.Namespace = "api"; - }, mvcBuilder); - - services.AddScoped, ReportService>(); + services.AddJsonApi( + opt => opt.Namespace = "api", + mvcBuilder, + discovery => discovery.AddCurrentAssemblyServices()); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index 080c0a6bb7..1d9ef83ecd 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; @@ -32,6 +33,14 @@ public interface IContextGraphBuilder /// The pluralized name that should be exposed by the API IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable; + /// + /// Add a json:api resource + /// + /// The resource model type + /// The resource model identifier type + /// The pluralized name that should be exposed by the API + IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName); + /// /// Add all the models that are part of the provided /// that also implement @@ -66,12 +75,13 @@ public IContextGraphBuilder AddResource(string pluralizedTypeName) wh => AddResource(pluralizedTypeName); public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable - { - var entityType = typeof(TResource); + => AddResource(typeof(TResource), typeof(TId), pluralizedTypeName); + public IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName) + { AssertEntityIsNotAlreadyDefined(entityType); - _entities.Add(GetEntity(pluralizedTypeName, entityType, typeof(TId))); + _entities.Add(GetEntity(pluralizedTypeName, entityType, idType)); return this; } @@ -182,12 +192,9 @@ private string GetResourceName(PropertyInfo property) private (bool isJsonApiResource, Type idType) GetIdType(Type resourceType) { - var interfaces = resourceType.GetInterfaces(); - foreach (var type in interfaces) - { - if (type.GetTypeInfo().IsGenericType && type.GetGenericTypeDefinition() == typeof(IIdentifiable<>)) - return (true, type.GetGenericArguments()[0]); - } + var possible = TypeLocator.GetIdType(resourceType); + if (possible.isJsonApiResource) + return possible; _validationResults.Add(new ValidationResult(LogLevel.Warning, $"{resourceType} does not implement 'IIdentifiable<>'. ")); diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 4a56620b78..09dedad001 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Formatters; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Generics; using JsonApiDotNetCore.Middleware; @@ -35,9 +36,10 @@ public static IServiceCollection AddJsonApi(this IServiceCollection se return AddJsonApi(services, options, mvcBuilder); } - public static IServiceCollection AddJsonApi(this IServiceCollection services, - Action options, - IMvcCoreBuilder mvcBuilder) where TContext : DbContext + public static IServiceCollection AddJsonApi( + this IServiceCollection services, + Action options, + IMvcCoreBuilder mvcBuilder) where TContext : DbContext { var config = new JsonApiOptions(); @@ -51,17 +53,24 @@ public static IServiceCollection AddJsonApi(this IServiceCollection se return services; } - public static IServiceCollection AddJsonApi(this IServiceCollection services, - Action options, - IMvcCoreBuilder mvcBuilder) + public static IServiceCollection AddJsonApi( + this IServiceCollection services, + Action configureOptions, + IMvcCoreBuilder mvcBuilder, + Action autoDiscover = null) { - var config = new JsonApiOptions(); + var options = new JsonApiOptions(); + configureOptions(options); - options(config); + if(autoDiscover != null) + { + var facade = new ServiceDiscoveryFacade(services, options.ContextGraphBuilder); + autoDiscover(facade); + } mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, config)); - AddJsonApiInternals(services, config); + AddJsonApiInternals(services, options); return services; } @@ -88,6 +97,9 @@ public static void AddJsonApiInternals( this IServiceCollection services, JsonApiOptions jsonApiOptions) { + if (jsonApiOptions.ContextGraph == null) + jsonApiOptions.ContextGraph = jsonApiOptions.ContextGraphBuilder.Build(); + if (jsonApiOptions.ContextGraph.UsesDbContext == false) { services.AddScoped(); diff --git a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs new file mode 100644 index 0000000000..021a3399e6 --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Reflection; +using Humanizer; +using JsonApiDotNetCore.Models; +using str = JsonApiDotNetCore.Extensions.StringExtensions; + + +namespace JsonApiDotNetCore.Graph +{ + /// + /// Provides an interface for formatting resource names by convention + /// + public interface IResourceNameFormatter + { + /// + /// Get the publicly visible resource name from the internal type name + /// + string FormatResourceName(Type resourceType); + } + + public class DefaultResourceNameFormatter : IResourceNameFormatter + { + /// + /// Uses the internal type name to determine the external resource name. + /// By default we us Humanizer for pluralization and then we dasherize the name. + /// + /// + /// + /// _default.FormatResourceName(typeof(TodoItem)).Dump(); + /// // > "todo-items" + /// + /// + public string FormatResourceName(Type type) + { + try + { + var attribute = type.GetCustomAttributes(typeof(ResourceAttribute)).SingleOrDefault() as ResourceAttribute; + if (attribute != null) + return attribute.ResourceName; + + return str.Dasherize(type.Name.Pluralize()); + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Cannot define multiple {nameof(ResourceAttribute)}s on type '{type}'.", e); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs new file mode 100644 index 0000000000..e90bcdc22c --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs @@ -0,0 +1,16 @@ +using System; + +namespace JsonApiDotNetCore.Graph +{ + internal struct ResourceDescriptor + { + public ResourceDescriptor(Type resourceType, Type idType) + { + ResourceType = resourceType; + IdType = idType; + } + + public Type ResourceType { get; set; } + public Type IdType { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs new file mode 100644 index 0000000000..ca156718fe --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -0,0 +1,141 @@ +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using System.Reflection; + +namespace JsonApiDotNetCore.Graph +{ + public class ServiceDiscoveryFacade + { + private readonly IServiceCollection _services; + private readonly IContextGraphBuilder _graphBuilder; + + public ServiceDiscoveryFacade(IServiceCollection services, IContextGraphBuilder graphBuilder) + { + _services = services; + _graphBuilder = graphBuilder; + } + + /// + /// Add resources, services and repository implementations to the container. + /// + /// The type name formatter used to get the string representation of resource names. + public ServiceDiscoveryFacade AddCurrentAssemblyServices(IResourceNameFormatter resourceNameFormatter = null) + => AddAssemblyServices(Assembly.GetCallingAssembly(), resourceNameFormatter); + + /// + /// Add resources, services and repository implementations to the container. + /// + /// The assembly to search for resources in. + /// The type name formatter used to get the string representation of resource names. + public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + { + AddDbContextResolvers(assembly); + AddAssemblyResources(assembly, resourceNameFormatter); + AddAssemblyServices(assembly); + AddAssemblyRepositories(assembly); + + return this; + } + + private void AddDbContextResolvers(Assembly assembly) + { + var dbContextTypes = TypeLocator.GetDerivedTypes(assembly, typeof(DbContext)); + foreach(var dbContextType in dbContextTypes) + { + var resolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.AddScoped(typeof(IDbContextResolver), resolverType); + } + } + + /// + /// Adds resources to the graph and registers types on the container. + /// + /// The assembly to search for resources in. + /// The type name formatter used to get the string representation of resource names. + public ServiceDiscoveryFacade AddAssemblyResources(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + { + var identifiables = TypeLocator.GetIdentifableTypes(assembly); + foreach (var identifiable in identifiables) + { + RegisterResourceDefinition(assembly, identifiable); + AddResourceToGraph(identifiable, resourceNameFormatter); + } + + return this; + } + + private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) + { + try + { + var resourceDefinition = TypeLocator.GetDerivedGenericTypes(assembly, typeof(ResourceDefinition<>), identifiable.ResourceType) + .SingleOrDefault(); + + if (resourceDefinition != null) + _services.AddScoped(typeof(ResourceDefinition<>).MakeGenericType(identifiable.ResourceType), resourceDefinition); + } + catch (InvalidOperationException e) + { + // TODO: need a better way to communicate failure since this is unlikely to occur during a web request + throw new JsonApiException(500, + $"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); + } + } + + private void AddResourceToGraph(ResourceDescriptor identifiable, IResourceNameFormatter resourceNameFormatter = null) + { + var resourceName = FormatResourceName(identifiable.ResourceType, resourceNameFormatter); + _graphBuilder.AddResource(identifiable.ResourceType, identifiable.IdType, resourceName); + } + + private string FormatResourceName(Type resourceType, IResourceNameFormatter resourceNameFormatter) + { + resourceNameFormatter = resourceNameFormatter ?? new DefaultResourceNameFormatter(); + return resourceNameFormatter.FormatResourceName(resourceType); + } + + /// + /// Add implementations to container. + /// + /// The assembly to search for resources in. + public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly) + { + RegisterServiceImplementations(assembly, typeof(IResourceService<,>)); + RegisterServiceImplementations(assembly, typeof(ICreateService<,>)); + RegisterServiceImplementations(assembly, typeof(IGetAllService<,>)); + RegisterServiceImplementations(assembly, typeof(IGetByIdService<,>)); + RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<,>)); + RegisterServiceImplementations(assembly, typeof(IUpdateService<,>)); + RegisterServiceImplementations(assembly, typeof(IDeleteService<,>)); + + return this; + } + + /// + /// Add implementations to container. + /// + /// The assembly to search for resources in. + public ServiceDiscoveryFacade AddAssemblyRepositories(Assembly assembly) + => RegisterServiceImplementations(assembly, typeof(IEntityRepository<,>)); + + private ServiceDiscoveryFacade RegisterServiceImplementations(Assembly assembly, Type interfaceType) + { + var identifiables = TypeLocator.GetIdentifableTypes(assembly); + foreach (var identifiable in identifiables) + { + var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, identifiable.ResourceType, identifiable.IdType); + if (service.implementation != null) + _services.AddScoped(service.registrationInterface, service.implementation); + } + + return this; + } + } +} diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Graph/TypeLocator.cs new file mode 100644 index 0000000000..6cd2e89c43 --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/TypeLocator.cs @@ -0,0 +1,93 @@ +using JsonApiDotNetCore.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace JsonApiDotNetCore.Graph +{ + internal static class TypeLocator + { + private static Dictionary _typeCache = new Dictionary(); + private static Dictionary> _identifiableTypeCache = new Dictionary>(); + + public static (bool isJsonApiResource, Type idType) GetIdType(Type resourceType) + { + var identitifableType = GetIdentifiableIdType(resourceType); + return (identitifableType != null) + ? (true, identitifableType) + : (false, null); + } + + private static Type GetIdentifiableIdType(Type identifiableType) + => GetIdentifiableInterface(identifiableType)?.GetGenericArguments()[0]; + + private static Type GetIdentifiableInterface(Type type) + => type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); + + // TODO: determine if this optimization is even helpful... + private static Type[] GetAssemblyTypes(Assembly assembly) + { + if (_typeCache.TryGetValue(assembly, out var types) == false) + { + types = assembly.GetTypes(); + _typeCache[assembly] = types; + } + + return types; + } + + public static List GetIdentifableTypes(Assembly assembly) + { + if (_identifiableTypeCache.TryGetValue(assembly, out var descriptors) == false) + { + descriptors = new List(); + _identifiableTypeCache[assembly] = descriptors; + + foreach (var type in assembly.GetTypes()) + { + var possible = GetIdType(type); + if (possible.isJsonApiResource) + descriptors.Add(new ResourceDescriptor(type, possible.idType)); + } + } + + return descriptors; + } + + public static (Type implementation, Type registrationInterface) GetGenericInterfaceImplementation(Assembly assembly, Type openGenericInterfaceType, params Type[] genericInterfaceArguments) + { + foreach (var type in assembly.GetTypes()) + { + var interfaces = type.GetInterfaces(); + foreach (var interfaceType in interfaces) + if (interfaceType.GetTypeInfo().IsGenericType && interfaceType.GetGenericTypeDefinition() == openGenericInterfaceType) + return ( + type, + interfaceType.MakeGenericType(genericInterfaceArguments) + ); + } + + return (null, null); + } + + public static IEnumerable GetDerivedGenericTypes(Assembly assembly, Type openGenericType, Type genericArgument) + { + var genericType = openGenericType.MakeGenericType(genericArgument); + foreach (var type in assembly.GetTypes()) + { + if (genericType.IsAssignableFrom(type)) + yield return type; + } + } + + public static IEnumerable GetDerivedTypes(Assembly assembly, Type inheritedType) + { + foreach (var type in assembly.GetTypes()) + { + if(inheritedType.IsAssignableFrom(type)) + yield return type; + } + } + } +} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index c786cd5153..e3a2ee9298 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -21,6 +21,7 @@ + diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index fc9f11d7e0..29569f43e9 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -121,7 +121,7 @@ public object DocumentToObject(DocumentData data, List included = message: $"This API does not contain a json:api resource named '{data.Type}'.", detail: "This resource is not registered on the ContextGraph. " + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " - + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); ; + + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); var entity = Activator.CreateInstance(contextEntity.EntityType); From ea7f78f2c9302fefde96ffb1f5ef4d73e97463e6 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 2 Jul 2018 16:41:06 -0700 Subject: [PATCH 27/74] fix tests --- .../Builders/ContextGraphBuilder.cs | 21 ++++++++++++------- .../Graph/IResourceNameFormatter.cs | 3 +-- .../Internal/ContextEntity.cs | 4 +++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index 1d9ef83ecd..e7de302075 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -174,20 +174,27 @@ public IContextGraphBuilder AddDbContext() where T : DbContext var (isJsonApiResource, idType) = GetIdType(entityType); if (isJsonApiResource) - _entities.Add(GetEntity(GetResourceName(property), entityType, idType)); + _entities.Add(GetEntity(GetResourceName(property, entityType), entityType, idType)); } } return this; } - private string GetResourceName(PropertyInfo property) + private string GetResourceName(PropertyInfo property, Type resourceType) { - var resourceAttribute = property.GetCustomAttribute(typeof(ResourceAttribute)); - if (resourceAttribute == null) - return property.Name.Dasherize(); - - return ((ResourceAttribute)resourceAttribute).ResourceName; + // check the class definition first + // [Resource("models"] public class Model : Identifiable { /* ... */ } + if (resourceType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute classResourceAttribute) + return classResourceAttribute.ResourceName; + + // check the DbContext member next + // [Resource("models")] public DbSet Models { get; set; } + if (property.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute resourceAttribute) + return resourceAttribute.ResourceName; + + // fallback to dsherized...this should actually check for a custom IResourceNameFormatter + return property.Name.Dasherize(); } private (bool isJsonApiResource, Type idType) GetIdType(Type resourceType) diff --git a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs index 021a3399e6..c67852620e 100644 --- a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs @@ -35,8 +35,7 @@ public string FormatResourceName(Type type) { try { - var attribute = type.GetCustomAttributes(typeof(ResourceAttribute)).SingleOrDefault() as ResourceAttribute; - if (attribute != null) + if (type.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) return attribute.ResourceName; return str.Dasherize(type.Name.Pluralize()); diff --git a/src/JsonApiDotNetCore/Internal/ContextEntity.cs b/src/JsonApiDotNetCore/Internal/ContextEntity.cs index 1e15a9c6bc..867a04350c 100644 --- a/src/JsonApiDotNetCore/Internal/ContextEntity.cs +++ b/src/JsonApiDotNetCore/Internal/ContextEntity.cs @@ -9,7 +9,9 @@ public class ContextEntity /// /// The exposed resource name /// - public string EntityName { get; set; } + public string EntityName { + get; + set; } /// /// The data model type From 9832241591de8bfb5097e285108b3a8459cc2a6d Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 11 Aug 2018 22:07:42 -0700 Subject: [PATCH 28/74] use IResourceNameFormatter in ContextGraph add documentation --- .../Builders/ContextGraphBuilder.cs | 25 +++++++-- src/JsonApiDotNetCore/Graph/TypeLocator.cs | 54 ++++++++++++++++--- .../JsonApiDotNetCore.csproj | 2 +- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index e7de302075..6c836cdaca 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -48,6 +48,12 @@ public interface IContextGraphBuilder /// The implementation type. IContextGraphBuilder AddDbContext() where T : DbContext; + /// + /// Specify the used to format resource names. + /// + /// Formatter used to define exposed resource names by convention. + IContextGraphBuilder UseNameFormatter(IResourceNameFormatter resourceNameFormatter); + /// /// Which links to include. Defaults to . /// @@ -60,6 +66,8 @@ public class ContextGraphBuilder : IContextGraphBuilder private List _validationResults = new List(); private bool _usesDbContext; + private IResourceNameFormatter _resourceNameFormatter = new DefaultResourceNameFormatter(); + public Link DocumentLinks { get; set; } = Link.All; public IContextGraph Build() @@ -71,12 +79,15 @@ public IContextGraph Build() return graph; } + /// public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable => AddResource(pluralizedTypeName); + /// public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable => AddResource(typeof(TResource), typeof(TId), pluralizedTypeName); + /// public IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName) { AssertEntityIsNotAlreadyDefined(entityType); @@ -152,6 +163,7 @@ protected virtual Type GetRelationshipType(RelationshipAttribute relation, Prope private Type GetResourceDefinitionType(Type entityType) => typeof(ResourceDefinition<>).MakeGenericType(entityType); + /// public IContextGraphBuilder AddDbContext() where T : DbContext { _usesDbContext = true; @@ -174,14 +186,14 @@ public IContextGraphBuilder AddDbContext() where T : DbContext var (isJsonApiResource, idType) = GetIdType(entityType); if (isJsonApiResource) - _entities.Add(GetEntity(GetResourceName(property, entityType), entityType, idType)); + _entities.Add(GetEntity(GetResourceNameFromDbSetProperty(property, entityType), entityType, idType)); } } return this; } - private string GetResourceName(PropertyInfo property, Type resourceType) + private string GetResourceNameFromDbSetProperty(PropertyInfo property, Type resourceType) { // check the class definition first // [Resource("models"] public class Model : Identifiable { /* ... */ } @@ -194,7 +206,7 @@ private string GetResourceName(PropertyInfo property, Type resourceType) return resourceAttribute.ResourceName; // fallback to dsherized...this should actually check for a custom IResourceNameFormatter - return property.Name.Dasherize(); + return _resourceNameFormatter.FormatResourceName(resourceType); } private (bool isJsonApiResource, Type idType) GetIdType(Type resourceType) @@ -213,5 +225,12 @@ private void AssertEntityIsNotAlreadyDefined(Type entityType) if (_entities.Any(e => e.EntityType == entityType)) throw new InvalidOperationException($"Cannot add entity type {entityType} to context graph, there is already an entity of that type configured."); } + + /// + public IContextGraphBuilder UseNameFormatter(IResourceNameFormatter resourceNameFormatter) + { + _resourceNameFormatter = resourceNameFormatter; + return this; + } } } diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Graph/TypeLocator.cs index 6cd2e89c43..5bdb029e50 100644 --- a/src/JsonApiDotNetCore/Graph/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Graph/TypeLocator.cs @@ -6,11 +6,19 @@ namespace JsonApiDotNetCore.Graph { + /// + /// Used to locate types and facilitate auto-resource discovery + /// internal static class TypeLocator { private static Dictionary _typeCache = new Dictionary(); private static Dictionary> _identifiableTypeCache = new Dictionary>(); + + /// + /// Determine whether or not this is a json:api resource by checking if it implements . + /// Returns the status and the resultant id type, either `(true, Type)` OR `(false, null)` + /// public static (bool isJsonApiResource, Type idType) GetIdType(Type resourceType) { var identitifableType = GetIdentifiableIdType(resourceType); @@ -37,6 +45,10 @@ private static Type[] GetAssemblyTypes(Assembly assembly) return types; } + + /// + /// Get all implementations of . in the assembly + /// public static List GetIdentifableTypes(Assembly assembly) { if (_identifiableTypeCache.TryGetValue(assembly, out var descriptors) == false) @@ -55,6 +67,17 @@ public static List GetIdentifableTypes(Assembly assembly) return descriptors; } + /// + /// Get all implementations of the generic interface + /// + /// The assembly to search + /// The open generic type, e.g. `typeof(IResourceService<>)` + /// Parameters to the generic type + /// + /// + /// GetGenericInterfaceImplementation(assembly, typeof(IResourceService<>), typeof(Article), typeof(Guid)); + /// + /// public static (Type implementation, Type registrationInterface) GetGenericInterfaceImplementation(Assembly assembly, Type openGenericInterfaceType, params Type[] genericInterfaceArguments) { foreach (var type in assembly.GetTypes()) @@ -71,16 +94,33 @@ public static (Type implementation, Type registrationInterface) GetGenericInterf return (null, null); } - public static IEnumerable GetDerivedGenericTypes(Assembly assembly, Type openGenericType, Type genericArgument) + /// + /// Get all derivitives of the concrete, generic type. + /// + /// The assembly to search + /// The open generic type, e.g. `typeof(ResourceDefinition<>)` + /// Parameters to the generic type + /// + /// + /// GetDerivedGenericTypes(assembly, typeof(ResourceDefinition<>), typeof(Article)) + /// + /// + public static IEnumerable GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) { - var genericType = openGenericType.MakeGenericType(genericArgument); - foreach (var type in assembly.GetTypes()) - { - if (genericType.IsAssignableFrom(type)) - yield return type; - } + var genericType = openGenericType.MakeGenericType(genericArguments); + return GetDerivedTypes(assembly, genericType); } + /// + /// Get all derivitives of the specified type. + /// + /// The assembly to search + /// The inherited type + /// + /// + /// GetDerivedGenericTypes(assembly, typeof(DbContext)) + /// + /// public static IEnumerable GetDerivedTypes(Assembly assembly, Type inheritedType) { foreach (var type in assembly.GetTypes()) diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index e3a2ee9298..183a7c8084 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - 2.5.1 + 3.0.0 $(NetStandardVersion) JsonApiDotNetCore JsonApiDotNetCore From cd2f92a3b680058368bcb68a9d939f84e431e9d9 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 11 Aug 2018 21:55:34 -0700 Subject: [PATCH 29/74] add documentation --- src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs | 4 ++++ src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index 6c836cdaca..ba71dbce06 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -195,6 +195,10 @@ public IContextGraphBuilder AddDbContext() where T : DbContext private string GetResourceNameFromDbSetProperty(PropertyInfo property, Type resourceType) { + // this check is actually duplicated in the DefaultResourceNameFormatter + // however, we perform it here so that we allow class attributes to be prioritized over + // the DbSet attribute. Eventually, the DbSet attribute should be deprecated. + // // check the class definition first // [Resource("models"] public class Model : Identifiable { /* ... */ } if (resourceType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute classResourceAttribute) diff --git a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs index c67852620e..57baca0901 100644 --- a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs @@ -35,6 +35,8 @@ public string FormatResourceName(Type type) { try { + // check the class definition first + // [Resource("models"] public class Model : Identifiable { /* ... */ } if (type.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) return attribute.ResourceName; From 4e3c46e08b1401e7a9bd5493e08ae003b6af49f2 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 11 Aug 2018 22:19:05 -0700 Subject: [PATCH 30/74] fix build --- .../Extensions/IServiceCollectionExtensions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 09dedad001..4c2d6c2f79 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -59,18 +59,18 @@ public static IServiceCollection AddJsonApi( IMvcCoreBuilder mvcBuilder, Action autoDiscover = null) { - var options = new JsonApiOptions(); - configureOptions(options); + var config = new JsonApiOptions(); + configureOptions(config); if(autoDiscover != null) { - var facade = new ServiceDiscoveryFacade(services, options.ContextGraphBuilder); + var facade = new ServiceDiscoveryFacade(services, config.ContextGraphBuilder); autoDiscover(facade); } mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, config)); - AddJsonApiInternals(services, options); + AddJsonApiInternals(services, config); return services; } From 0c27ed7bd80ad48c20683f661b0f9207a55fe61a Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sun, 12 Aug 2018 20:07:46 -0700 Subject: [PATCH 31/74] fix(#241): TypeLocator bug -- add tests --- src/JsonApiDotNetCore/Graph/TypeLocator.cs | 24 ++++-- test/UnitTests/Graph/TypeLocator_Tests.cs | 99 ++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 test/UnitTests/Graph/TypeLocator_Tests.cs diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Graph/TypeLocator.cs index 5bdb029e50..223845859a 100644 --- a/src/JsonApiDotNetCore/Graph/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Graph/TypeLocator.cs @@ -45,7 +45,6 @@ private static Type[] GetAssemblyTypes(Assembly assembly) return types; } - /// /// Get all implementations of . in the assembly /// @@ -80,15 +79,28 @@ public static List GetIdentifableTypes(Assembly assembly) /// public static (Type implementation, Type registrationInterface) GetGenericInterfaceImplementation(Assembly assembly, Type openGenericInterfaceType, params Type[] genericInterfaceArguments) { + if(assembly == null) throw new ArgumentNullException(nameof(assembly)); + if(openGenericInterfaceType == null) throw new ArgumentNullException(nameof(openGenericInterfaceType)); + if(genericInterfaceArguments == null) throw new ArgumentNullException(nameof(genericInterfaceArguments)); + if(genericInterfaceArguments.Length == 0) throw new ArgumentException("No arguments supplied for the generic interface.", nameof(genericInterfaceArguments)); + if(openGenericInterfaceType.IsGenericType == false) throw new ArgumentException("Requested type is not a generic type.", nameof(openGenericInterfaceType)); + foreach (var type in assembly.GetTypes()) { var interfaces = type.GetInterfaces(); foreach (var interfaceType in interfaces) - if (interfaceType.GetTypeInfo().IsGenericType && interfaceType.GetGenericTypeDefinition() == openGenericInterfaceType) - return ( - type, - interfaceType.MakeGenericType(genericInterfaceArguments) - ); + { + if (interfaceType.IsGenericType) + { + var genericTypeDefinition = interfaceType.GetGenericTypeDefinition(); + if(genericTypeDefinition == openGenericInterfaceType.GetGenericTypeDefinition()) { + return ( + type, + genericTypeDefinition.MakeGenericType(genericInterfaceArguments) + ); + } + } + } } return (null, null); diff --git a/test/UnitTests/Graph/TypeLocator_Tests.cs b/test/UnitTests/Graph/TypeLocator_Tests.cs new file mode 100644 index 0000000000..f0d1ce04ad --- /dev/null +++ b/test/UnitTests/Graph/TypeLocator_Tests.cs @@ -0,0 +1,99 @@ +using System; +using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Models; +using Xunit; + +namespace UnitTests.Internal +{ + public class TypeLocator_Tests + { + [Fact] + public void GetGenericInterfaceImplementation_Gets_Implementation() + { + // arrange + var assembly = GetType().Assembly; + var openGeneric = typeof(IGenericInterface<>); + var genericArg = typeof(int); + + var expectedImplementation = typeof(Implementation); + var expectedInterface = typeof(IGenericInterface); + + // act + var result = TypeLocator.GetGenericInterfaceImplementation( + assembly, + openGeneric, + genericArg + ); + + // assert + Assert.NotNull(result); + Assert.Equal(expectedImplementation, result.implementation); + Assert.Equal(expectedInterface, result.registrationInterface); + } + + [Fact] + public void GetDerivedGenericTypes_Gets_Implementation() + { + // arrange + var assembly = GetType().Assembly; + var openGeneric = typeof(BaseType<>); + var genericArg = typeof(int); + + var expectedImplementation = typeof(DerivedType); + + // act + var results = TypeLocator.GetDerivedGenericTypes( + assembly, + openGeneric, + genericArg + ); + + // assert + Assert.NotNull(results); + var result = Assert.Single(results); + Assert.Equal(expectedImplementation, result); + } + + [Fact] + public void GetIdType_Correctly_Identifies_JsonApiResource() + { + // arrange + var type = typeof(Model); + var exextedIdType = typeof(int); + + // act + var result = TypeLocator.GetIdType(type); + + // assert + Assert.NotNull(result); + Assert.True(result.isJsonApiResource); + Assert.Equal(exextedIdType, result.idType); + } + + [Fact] + public void GetIdType_Correctly_Identifies_NonJsonApiResource() + { + // arrange + var type = typeof(DerivedType); + Type exextedIdType = null; + + // act + var result = TypeLocator.GetIdType(type); + + // assert + Assert.NotNull(result); + Assert.False(result.isJsonApiResource); + Assert.Equal(exextedIdType, result.idType); + } + } + + + public interface IGenericInterface { } + public class Implementation : IGenericInterface { } + + + public class BaseType { } + public class DerivedType : BaseType { } + + public class Model : Identifiable { } +} \ No newline at end of file From d1777cf87bf8b4c28e19b3d92b8e54a9d5fd5e56 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 13 Aug 2018 20:32:16 -0700 Subject: [PATCH 32/74] add acceptance test for deeply nested inclusions --- .../Spec/DeeplyNestedInclusionTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs new file mode 100644 index 0000000000..7af5c3b594 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class DeeplyNestedInclusionTests + { + private TestFixture _fixture; + + public DeeplyNestedInclusionTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Can_Include_Nested_Relationships() + { + // arrange + const string route = "/api/v1/todo-items?include=collection.owner"; + + var todoItem = new TodoItem { + Collection = new TodoItemCollection { + Owner = new Person() + } + }; + + var context = _fixture.GetService(); + context.TodoItems.RemoveRange(context.TodoItems); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var todoItems = _fixture.DeSerializer.DeserializeList(body); + + var responseTodoItem = Assert.Single(todoItems); + Assert.NotNull(responseTodoItem); + Assert.NotNull(responseTodoItem.Collection); + Assert.NotNull(responseTodoItem.Collection.Owner); + } + } +} \ No newline at end of file From 3528e1f4ca9c86c563eebcb1daa363e68c541a1e Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 13 Aug 2018 22:08:09 -0700 Subject: [PATCH 33/74] first pass at deeply nested inclusion implementation --- .../Builders/DocumentBuilder.cs | 45 +++++++++++-------- .../Data/DefaultEntityRepository.cs | 36 +++++++++++---- .../Models/RelationshipAttribute.cs | 5 +++ .../Serialization/JsonApiDeSerializer.cs | 9 ++++ src/JsonApiDotNetCore/Services/QueryParser.cs | 4 -- 5 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index ce060f57b7..420fa7c36a 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -192,20 +192,35 @@ private RelationshipData GetRelationshipData(RelationshipAttribute attr, Context return relationshipData; } - private List GetIncludedEntities(List included, ContextEntity contextEntity, IIdentifiable entity) + private List GetIncludedEntities(List included, ContextEntity rootContextEntity, IIdentifiable rootResource) { - contextEntity.Relationships.ForEach(r => + if(_jsonApiContext.IncludedRelationships != null) { - if (!RelationshipIsIncluded(r.PublicRelationshipName)) return; - - var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, r.InternalRelationshipName); - - if (navigationEntity is IEnumerable hasManyNavigationEntity) - foreach (IIdentifiable includedEntity in hasManyNavigationEntity) - included = AddIncludedEntity(included, includedEntity); - else - included = AddIncludedEntity(included, (IIdentifiable)navigationEntity); - }); + foreach(var relationshipName in _jsonApiContext.IncludedRelationships) + { + var relationshipChain = relationshipName.Split('.'); + + var contextEntity = rootContextEntity; + var entity = rootResource; + + for(var i = 0; i < relationshipChain.Length; i++) + { + var requestedRelationship = relationshipChain[i]; + var relationship = contextEntity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == requestedRelationship); + var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, relationship.InternalRelationshipName); + if (navigationEntity is IEnumerable hasManyNavigationEntity) + foreach (IIdentifiable includedEntity in hasManyNavigationEntity) + included = AddIncludedEntity(included, includedEntity); + else + included = AddIncludedEntity(included, (IIdentifiable)navigationEntity); + + if(i < relationshipChain.Length) { + contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); + entity = (IIdentifiable)navigationEntity; // HACK: only handles HasOne case ... + } + } + } + } return included; } @@ -245,12 +260,6 @@ private DocumentData GetIncludedEntity(IIdentifiable entity) return data; } - private bool RelationshipIsIncluded(string relationshipName) - { - return _jsonApiContext.IncludedRelationships != null && - _jsonApiContext.IncludedRelationships.Contains(relationshipName); - } - private List GetRelationships(IEnumerable entities) { var objType = entities.GetElementType(); diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 862079aeee..512f45fe3c 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -203,20 +203,38 @@ public virtual async Task DeleteAsync(TId id) /// public virtual IQueryable Include(IQueryable entities, string relationshipName) { + if(string.IsNullOrWhiteSpace(relationshipName)) throw new JsonApiException(400, "Include parameter must not be empty if provided"); + + var relationshipChain = relationshipName.Split('.'); + + // variables mutated in recursive loop + // TODO: make recursive method + string internalRelationshipPath = null; var entity = _jsonApiContext.RequestEntity; - var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); - if (relationship == null) + for(var i = 0; i < relationshipChain.Length; i++) { - throw new JsonApiException(400, $"Invalid relationship {relationshipName} on {entity.EntityName}", - $"{entity.EntityName} does not have a relationship named {relationshipName}"); - } + var requestedRelationship = relationshipChain[i]; + var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == requestedRelationship); + if (relationship == null) + { + throw new JsonApiException(400, $"Invalid relationship {requestedRelationship} on {entity.EntityName}", + $"{entity.EntityName} does not have a relationship named {requestedRelationship}"); + } - if (!relationship.CanInclude) - { - throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed"); + if (relationship.CanInclude == false) + { + throw new JsonApiException(400, $"Including the relationship {requestedRelationship} on {entity.EntityName} is not allowed"); + } + + internalRelationshipPath = (internalRelationshipPath == null) + ? relationship.InternalRelationshipName + : $"{internalRelationshipPath}.{relationship.InternalRelationshipName}"; + + if(i < relationshipChain.Length) + entity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); } - return entities.Include(relationship.InternalRelationshipName); + return entities.Include(internalRelationshipPath); } /// diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index f6ca5b5d2a..7408df0998 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -54,6 +54,11 @@ public bool TryGetHasMany(out HasManyAttribute result) public abstract void SetValue(object entity, object newValue); + public object GetValue(object entity) => entity + ?.GetType() + .GetProperty(InternalRelationshipName) + .GetValue(entity); + public override string ToString() { return base.ToString() + ":" + PublicRelationshipName; diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 29569f43e9..f71e028626 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -214,6 +214,15 @@ private object SetHasOneRelationship(object entity, SetHasOneForeignKeyValue(entity, attr, foreignKeyProperty, rio); SetHasOneNavigationPropertyValue(entity, attr, rio, included); + // recursive call ... + var navigationPropertyValue = attr.GetValue(entity); + var contextGraphEntity = _jsonApiContext.ContextGraph.GetContextEntity(attr.Type); + if(navigationPropertyValue != null && contextGraphEntity != null) + { + var includedResource = included.Single(r => r.Type == rio.Type && r.Id == rio.Id); + SetRelationships(navigationPropertyValue, contextGraphEntity, includedResource.Relationships, included); + } + return entity; } diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index 0a6d4f2c16..b42d616a0c 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -179,10 +179,6 @@ protected virtual List ParseSortParameters(string value) protected virtual List ParseIncludedRelationships(string value) { - const string NESTED_DELIMITER = "."; - if (value.Contains(NESTED_DELIMITER)) - throw new JsonApiException(400, "Deeply nested relationships are not supported"); - return value .Split(QueryConstants.COMMA) .ToList(); From 1b80f05ce23f939c28626748d821fdf2c6e76e7e Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Tue, 14 Aug 2018 20:53:45 -0700 Subject: [PATCH 34/74] fix(JsonApiDeserializer): NullReferenceException don't do unnecessary work if no relationships were included --- .../Serialization/JsonApiDeSerializer.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index f71e028626..6dc5fe0b69 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -215,12 +215,15 @@ private object SetHasOneRelationship(object entity, SetHasOneNavigationPropertyValue(entity, attr, rio, included); // recursive call ... - var navigationPropertyValue = attr.GetValue(entity); - var contextGraphEntity = _jsonApiContext.ContextGraph.GetContextEntity(attr.Type); - if(navigationPropertyValue != null && contextGraphEntity != null) + if(included != null) { - var includedResource = included.Single(r => r.Type == rio.Type && r.Id == rio.Id); - SetRelationships(navigationPropertyValue, contextGraphEntity, includedResource.Relationships, included); + var navigationPropertyValue = attr.GetValue(entity); + var contextGraphEntity = _jsonApiContext.ContextGraph.GetContextEntity(attr.Type); + if(navigationPropertyValue != null && contextGraphEntity != null) + { + var includedResource = included.Single(r => r.Type == rio.Type && r.Id == rio.Id); + SetRelationships(navigationPropertyValue, contextGraphEntity, includedResource.Relationships, included); + } } return entity; From d930c5b4eadcc341f12702856a27367bd4976f62 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Tue, 14 Aug 2018 21:21:04 -0700 Subject: [PATCH 35/74] fix(DocumentBuilder): handle HasMany relationships --- .../Builders/DocumentBuilder.cs | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index 420fa7c36a..15e46ffd6f 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -202,29 +202,52 @@ private List GetIncludedEntities(List included, Cont var contextEntity = rootContextEntity; var entity = rootResource; - - for(var i = 0; i < relationshipChain.Length; i++) - { - var requestedRelationship = relationshipChain[i]; - var relationship = contextEntity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == requestedRelationship); - var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, relationship.InternalRelationshipName); - if (navigationEntity is IEnumerable hasManyNavigationEntity) - foreach (IIdentifiable includedEntity in hasManyNavigationEntity) - included = AddIncludedEntity(included, includedEntity); - else - included = AddIncludedEntity(included, (IIdentifiable)navigationEntity); - - if(i < relationshipChain.Length) { - contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); - entity = (IIdentifiable)navigationEntity; // HACK: only handles HasOne case ... - } - } + included = IncludeRelationshipChain(included, rootContextEntity, rootResource, relationshipChain, 0); } } return included; } + private List IncludeRelationshipChain( + List included, ContextEntity parentEntity, IIdentifiable parentResource, string[] relationshipChain, int relationshipChainIndex) + { + var requestedRelationship = relationshipChain[relationshipChainIndex]; + var relationship = parentEntity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == requestedRelationship); + var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(parentResource, relationship.InternalRelationshipName); + if (navigationEntity is IEnumerable hasManyNavigationEntity) + { + foreach (IIdentifiable includedEntity in hasManyNavigationEntity) + { + included = AddIncludedEntity(included, includedEntity); + included = IncludeSingleResourceRelationships(included, includedEntity, relationship, relationshipChain, relationshipChainIndex); + } + } + else + { + included = AddIncludedEntity(included, (IIdentifiable)navigationEntity); + included = IncludeSingleResourceRelationships(included, (IIdentifiable)navigationEntity, relationship, relationshipChain, relationshipChainIndex); + } + + return included; + } + + private List IncludeSingleResourceRelationships( + List included, IIdentifiable navigationEntity, RelationshipAttribute relationship, string[] relationshipChain, int relationshipChainIndex) + { + if(relationshipChainIndex < relationshipChain.Length) + { + var nextContextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); + var resource = (IIdentifiable)navigationEntity; + // recursive call + if(relationshipChainIndex < relationshipChain.Length - 1) + included = IncludeRelationshipChain(included, nextContextEntity, resource, relationshipChain, relationshipChainIndex + 1); + } + + return included; + } + + private List AddIncludedEntity(List entities, IIdentifiable entity) { var includedEntity = GetIncludedEntity(entity); From 88474039731510f2310fd015c1f077f6fd0038ae Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Tue, 14 Aug 2018 21:35:04 -0700 Subject: [PATCH 36/74] fix(JsonApiDeSerializer): allow empty collection Sequence contains no matching element --- src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 6dc5fe0b69..bd2a25b0ad 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -221,8 +221,9 @@ private object SetHasOneRelationship(object entity, var contextGraphEntity = _jsonApiContext.ContextGraph.GetContextEntity(attr.Type); if(navigationPropertyValue != null && contextGraphEntity != null) { - var includedResource = included.Single(r => r.Type == rio.Type && r.Id == rio.Id); - SetRelationships(navigationPropertyValue, contextGraphEntity, includedResource.Relationships, included); + var includedResource = included.SingleOrDefault(r => r.Type == rio.Type && r.Id == rio.Id); + if(includedResource != null) + SetRelationships(navigationPropertyValue, contextGraphEntity, includedResource.Relationships, included); } } From a2e6d0645de610e1bb61ebc926e1840959b0e8ad Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Tue, 14 Aug 2018 21:40:54 -0700 Subject: [PATCH 37/74] chore(#368): add isues template and move files to .github --- CONTRIBUTING.MD => .github/CONTRIBUTING.MD | 0 .github/ISSUE_TEMPLATE.md | 8 ++++++++ .../PULL_REQUEST_TEMPLATE.md | 0 3 files changed, 8 insertions(+) rename CONTRIBUTING.MD => .github/CONTRIBUTING.MD (100%) create mode 100644 .github/ISSUE_TEMPLATE.md rename PULL_REQUEST_TEMPLATE.md => .github/PULL_REQUEST_TEMPLATE.md (100%) diff --git a/CONTRIBUTING.MD b/.github/CONTRIBUTING.MD similarity index 100% rename from CONTRIBUTING.MD rename to .github/CONTRIBUTING.MD diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..08a871288a --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,8 @@ +## Description + +... + +## Environment + +- JsonApiDotNetCore Version: +- Other Relevant Package Versions: \ No newline at end of file diff --git a/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md From c457f6e5dd60a059ec003ca07127e8e935410b06 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 28 Aug 2018 14:11:06 -0400 Subject: [PATCH 38/74] failing test + document how to get started --- README.md | 38 +++++++++++++++++++ .../Models/TodoItem.cs | 6 +++ .../Acceptance/TodoItemsControllerTests.cs | 23 +++++++++++ 3 files changed, 67 insertions(+) diff --git a/README.md b/README.md index 81ebba79bf..f614dc4dbc 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,41 @@ public class Startup } } ``` + +### Development + +Restore all nuget packages with: + +```bash +dotnet restore +``` + +#### Testing + +Running tests locally requires access to a postgresql database. +If you have docker installed, this can be propped up via: + +```bash +docker run --rm --name jsonapi-dotnet-core-testing \ + -e POSTGRES_DB=JsonApiDotNetCoreExample \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + postgres +``` + +And then to run the tests: + +```bash +dotnet test +``` + +#### Cleaning + +Sometimes the compiled files can be dirty / corrupt from other branches / failed builds. +If your bash prompt supports the globstar, you can recursively delete the `bin` and `obj` directories: + +```bash +shopt -s globstar +rm -rf **/bin && rm -rf **/obj +``` \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index fecd16319d..7ae957f4a5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -24,6 +24,12 @@ public TodoItem() [Attr("achieved-date", isFilterable: false, isSortable: false)] public DateTime? AchievedDate { get; set; } + + + [Attr("updated-date")] + public DateTime? UpdatedDate { get; set; } + + public int? OwnerId { get; set; } public int? AssigneeId { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 0600fb402b..3350fd1340 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -90,6 +90,29 @@ public async Task Can_Filter_TodoItems() Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); } + [Fact] + public async Task Can_Filter_TodoItems_Using_NotEqual_Operator() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.UpdatedDate = null; + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?filter[updated-date]=ne:null"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetService().DeserializeList(body); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(deserializedBody); + } + [Fact] public async Task Can_Filter_TodoItems_Using_Like_Operator() { From a3d9e6c0c6fb5707df5703e0ce7faf38068263d6 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 28 Aug 2018 15:17:28 -0400 Subject: [PATCH 39/74] fixed it --- .../Extensions/IQueryableExtensions.cs | 12 ++++++++++-- .../Acceptance/TodoItemsControllerTests.cs | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 03f74b9e59..20e6902ee9 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -127,9 +127,14 @@ public static IQueryable Filter(this IQueryable sourc return source.Where(lambdaIn); } else - { // convert the incoming value to the target value type + { + var isNullabe = IsNullable(property.PropertyType); + var propertyValue = filterQuery.PropertyValue; + var value = isNullabe && propertyValue == "" ? null : propertyValue; + + // convert the incoming value to the target value type // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType); + var convertedValue = TypeHelper.ConvertType(value, property.PropertyType); // {model} var parameter = Expression.Parameter(concreteType, "model"); // {model.Id} @@ -204,6 +209,9 @@ public static IQueryable Filter(this IQueryable sourc } } + private static bool IsNullable(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + + private static Expression GetFilterExpressionLambda(Expression left, Expression right, FilterOperations operation) { Expression body; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 3350fd1340..6044463764 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -100,7 +100,7 @@ public async Task Can_Filter_TodoItems_Using_NotEqual_Operator() _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items?filter[updated-date]=ne:null"; + var route = $"/api/v1/todo-items?filter[updated-date]=ne:"; var request = new HttpRequestMessage(httpMethod, route); // Act From b34bd3f6e875961c4515fcf716714f39291ccb1f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 28 Aug 2018 17:34:07 -0400 Subject: [PATCH 40/74] DateTime doesn't have an IS method.... so I don't know how we'll use it.... --- .../Extensions/IQueryableExtensions.cs | 36 ++++++++++++++++--- .../Internal/Query/FilterOperations.cs | 4 ++- .../Acceptance/TodoItemsControllerTests.cs | 30 ++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 20e6902ee9..c94ca4a2c7 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -113,24 +113,44 @@ public static IQueryable Filter(this IQueryable sourc var concreteType = typeof(TSource); var property = concreteType.GetProperty(filterQuery.FilteredAttribute.InternalAttributeName); + var op = filterQuery.FilterOperation; if (property == null) throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid property of '{concreteType}'"); try { - if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin) + if (op == FilterOperations.@in || op == FilterOperations.nin) { string[] propertyValues = filterQuery.PropertyValue.Split(','); - var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name, filterQuery.FilterOperation); + var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name, op); return source.Where(lambdaIn); } + else if (op == FilterOperations.@is || op == FilterOperations.isnot) { + var parameter = Expression.Parameter(concreteType, "model"); + // {model.Id} + var left = Expression.PropertyOrField(parameter, property.Name); + var right = Expression.Constant(filterQuery.PropertyValue, typeof(string)); + + var body = GetFilterExpressionLambda(left, right, op); + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); + } else { var isNullabe = IsNullable(property.PropertyType); var propertyValue = filterQuery.PropertyValue; - var value = isNullabe && propertyValue == "" ? null : propertyValue; + var value = propertyValue; + + if (op == FilterOperations.@isnot || op == FilterOperations.isnot) + { + if (isNullabe && propertyValue == "null") + { + value = null; + } + } // convert the incoming value to the target value type // "1" -> 1 @@ -142,7 +162,7 @@ public static IQueryable Filter(this IQueryable sourc // {1} var right = Expression.Constant(convertedValue, property.PropertyType); - var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); + var body = GetFilterExpressionLambda(left, right, op); var lambda = Expression.Lambda>(body, parameter); @@ -244,6 +264,14 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression case FilterOperations.ne: body = Expression.NotEqual(left, right); break; + case FilterOperations.isnot: + // {model.Id != null} + body = Expression.NotEqual(left, right); + break; + case FilterOperations.@is: + // {model.Id == null} + body = Expression.Equal(left, right); + break; default: throw new JsonApiException(500, $"Unknown filter operation {operation}"); } diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs index 554e6f98a4..ec76afa87e 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs @@ -11,6 +11,8 @@ public enum FilterOperations like = 5, ne = 6, @in = 7, // prefix with @ to use keyword - nin = 8 + nin = 8, + @is = 9, + isnot = 10 } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 6044463764..991a4f26d3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -91,7 +92,30 @@ public async Task Can_Filter_TodoItems() } [Fact] - public async Task Can_Filter_TodoItems_Using_NotEqual_Operator() + public async Task Can_Filter_TodoItems_Using_IsNot_Operator() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.UpdatedDate = new DateTime(); + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?filter[updated-date]=isnot:null"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetService().DeserializeList(body); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(deserializedBody); + } + + [Fact] + public async Task Can_Filter_TodoItems_Using_Is_Operator() { // Arrange var todoItem = _todoItemFaker.Generate(); @@ -100,7 +124,7 @@ public async Task Can_Filter_TodoItems_Using_NotEqual_Operator() _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items?filter[updated-date]=ne:"; + var route = $"/api/v1/todo-items?filter[updated-date]=is:null"; var request = new HttpRequestMessage(httpMethod, route); // Act @@ -110,7 +134,7 @@ public async Task Can_Filter_TodoItems_Using_NotEqual_Operator() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Empty(deserializedBody); + Assert.NotEmpty(deserializedBody); } [Fact] From 1506270b3a46752f45a9491c5ef8b8e063d69e21 Mon Sep 17 00:00:00 2001 From: Ryan Tablada Date: Tue, 28 Aug 2018 16:11:52 -0700 Subject: [PATCH 41/74] feat: error status code should be first in collection --- .../Internal/JsonApiException.cs | 2 +- .../Internal/JsonApiException_Test.cs | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 test/UnitTests/Internal/JsonApiException_Test.cs diff --git a/src/JsonApiDotNetCore/Internal/JsonApiException.cs b/src/JsonApiDotNetCore/Internal/JsonApiException.cs index 8918d07c6c..49dc557e51 100644 --- a/src/JsonApiDotNetCore/Internal/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Internal/JsonApiException.cs @@ -41,7 +41,7 @@ public JsonApiException(int statusCode, string message, Exception innerException public int GetStatusCode() { - if (_errors.Errors.Count == 1) + if (_errors.Errors.Select(a => a.StatusCode).Distinct().Count() == 1) return _errors.Errors[0].StatusCode; if (_errors.Errors.FirstOrDefault(e => e.StatusCode >= 500) != null) diff --git a/test/UnitTests/Internal/JsonApiException_Test.cs b/test/UnitTests/Internal/JsonApiException_Test.cs new file mode 100644 index 0000000000..57ac29d480 --- /dev/null +++ b/test/UnitTests/Internal/JsonApiException_Test.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore.Internal; +using Xunit; + +namespace UnitTests.Internal +{ + public 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()); + } + } +} From 84a78a0f28661b3b19786356975ac6f5e8ec70e9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 10:45:52 -0400 Subject: [PATCH 42/74] apply pr feedback --- README.md | 4 +-- .../Extensions/IQueryableExtensions.cs | 26 +++++-------------- .../Internal/Query/FilterOperations.cs | 4 +-- .../Acceptance/TodoItemsControllerTests.cs | 16 +++++++----- 4 files changed, 20 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f614dc4dbc..6bbb7b0710 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,7 @@ dotnet test #### Cleaning Sometimes the compiled files can be dirty / corrupt from other branches / failed builds. -If your bash prompt supports the globstar, you can recursively delete the `bin` and `obj` directories: ```bash -shopt -s globstar -rm -rf **/bin && rm -rf **/obj +dotnet clean ``` \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index c94ca4a2c7..8afab4d45a 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -127,11 +127,12 @@ public static IQueryable Filter(this IQueryable sourc return source.Where(lambdaIn); } - else if (op == FilterOperations.@is || op == FilterOperations.isnot) { + else if (op == FilterOperations.isnotnull || op == FilterOperations.isnull) { + // {model} var parameter = Expression.Parameter(concreteType, "model"); // {model.Id} var left = Expression.PropertyOrField(parameter, property.Name); - var right = Expression.Constant(filterQuery.PropertyValue, typeof(string)); + var right = Expression.Constant(null); var body = GetFilterExpressionLambda(left, right, op); var lambda = Expression.Lambda>(body, parameter); @@ -139,22 +140,9 @@ public static IQueryable Filter(this IQueryable sourc return source.Where(lambda); } else - { - var isNullabe = IsNullable(property.PropertyType); - var propertyValue = filterQuery.PropertyValue; - var value = propertyValue; - - if (op == FilterOperations.@isnot || op == FilterOperations.isnot) - { - if (isNullabe && propertyValue == "null") - { - value = null; - } - } - - // convert the incoming value to the target value type + { // convert the incoming value to the target value type // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(value, property.PropertyType); + var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType); // {model} var parameter = Expression.Parameter(concreteType, "model"); // {model.Id} @@ -264,11 +252,11 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression case FilterOperations.ne: body = Expression.NotEqual(left, right); break; - case FilterOperations.isnot: + case FilterOperations.isnotnull: // {model.Id != null} body = Expression.NotEqual(left, right); break; - case FilterOperations.@is: + case FilterOperations.isnull: // {model.Id == null} body = Expression.Equal(left, right); break; diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs index ec76afa87e..60ae0af012 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs @@ -12,7 +12,7 @@ public enum FilterOperations ne = 6, @in = 7, // prefix with @ to use keyword nin = 8, - @is = 9, - isnot = 10 + isnull = 9, + isnotnull = 10 } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 991a4f26d3..bd29195193 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -92,7 +92,7 @@ public async Task Can_Filter_TodoItems() } [Fact] - public async Task Can_Filter_TodoItems_Using_IsNot_Operator() + public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator() { // Arrange var todoItem = _todoItemFaker.Generate(); @@ -101,21 +101,23 @@ public async Task Can_Filter_TodoItems_Using_IsNot_Operator() _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items?filter[updated-date]=isnot:null"; + var route = $"/api/v1/todo-items?filter[updated-date]=isnotnull:"; var request = new HttpRequestMessage(httpMethod, route); // Act var response = await _fixture.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotEmpty(deserializedBody); } [Fact] - public async Task Can_Filter_TodoItems_Using_Is_Operator() + public async Task Can_Filter_TodoItems_Using_IsNull_Operator() { // Arrange var todoItem = _todoItemFaker.Generate(); @@ -124,16 +126,18 @@ public async Task Can_Filter_TodoItems_Using_Is_Operator() _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items?filter[updated-date]=is:null"; + var route = $"/api/v1/todo-items?filter[updated-date]=isnull:"; var request = new HttpRequestMessage(httpMethod, route); // Act var response = await _fixture.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); var deserializedBody = _fixture.GetService().DeserializeList(body); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotEmpty(deserializedBody); } From bea642d45183268873571f70cd7eef3758fba6ca Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 11:40:45 -0400 Subject: [PATCH 43/74] work on adding tests for different scenarios --- .../Spec/DeeplyNestedInclusionTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index 7af5c3b594..63fa767328 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -53,5 +54,85 @@ public async Task Can_Include_Nested_Relationships() Assert.NotNull(responseTodoItem.Collection); Assert.NotNull(responseTodoItem.Collection.Owner); } + + [Fact] + public async Task Can_Include_Nested_HasMany_Relationships() + { + // arrange + const string route = "/api/v1/todo-items?include=collection.todo-items"; + + var todoItem = new TodoItem { + Collection = new TodoItemCollection { + Owner = new Person(), + TodoItems = new List { + new TodoItem(), + new TodoItem() + } + } + }; + + var context = _fixture.GetService(); + context.TodoItems.RemoveRange(context.TodoItems); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var todoItems = _fixture.DeSerializer.DeserializeList(body); + + var responseTodoItem = todoItems[0]; + Assert.NotNull(responseTodoItem); + Assert.NotNull(responseTodoItem.Collection); + Assert.NotNull(responseTodoItem.Collection.TodoItems); + Assert.Equal(2, responseTodoItem.Collection.TodoItems.Count); + + // TODO: assert number of things in included + } + + [Fact] + public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() + { + // arrange + const string route = "/api/v1/todo-items?include=collection.todo-items.owner"; + + var todoItem = new TodoItem { + Collection = new TodoItemCollection { + Owner = new Person(), + TodoItems = new List { + new TodoItem { + Owner = new Person() + }, + new TodoItem() + } + } + }; + + var context = _fixture.GetService(); + context.TodoItems.RemoveRange(context.TodoItems); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var todoItems = _fixture.DeSerializer.DeserializeList(body); + + var responseTodoItem = todoItems[0]; + Assert.NotNull(responseTodoItem); + Assert.NotNull(responseTodoItem.Collection); + Assert.NotNull(responseTodoItem.Collection.TodoItems); + Assert.Equal(2, responseTodoItem.Collection.TodoItems.Count); + + // TODO: assert number of things in included + } } } \ No newline at end of file From 4af95d117c8db22175c3d1e803743b2f4a485ed2 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 12:41:31 -0400 Subject: [PATCH 44/74] assert included counts --- .../Spec/DeeplyNestedInclusionTests.cs | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index 63fa767328..d89bd6739b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading.Tasks; using Bogus; +using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -83,15 +84,10 @@ public async Task Can_Include_Nested_HasMany_Relationships() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var todoItems = _fixture.DeSerializer.DeserializeList(body); - - var responseTodoItem = todoItems[0]; - Assert.NotNull(responseTodoItem); - Assert.NotNull(responseTodoItem.Collection); - Assert.NotNull(responseTodoItem.Collection.TodoItems); - Assert.Equal(2, responseTodoItem.Collection.TodoItems.Count); - - // TODO: assert number of things in included + var documents = JsonConvert.DeserializeObject(body); + var included = documents.Included; + + Assert.Equal(3, included.Count); // 1 collection, 2 todos } [Fact] @@ -124,15 +120,10 @@ public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var todoItems = _fixture.DeSerializer.DeserializeList(body); - - var responseTodoItem = todoItems[0]; - Assert.NotNull(responseTodoItem); - Assert.NotNull(responseTodoItem.Collection); - Assert.NotNull(responseTodoItem.Collection.TodoItems); - Assert.Equal(2, responseTodoItem.Collection.TodoItems.Count); - - // TODO: assert number of things in included + var documents = JsonConvert.DeserializeObject(body); + var included = documents.Included; + + Assert.Equal(4, included.Count); // 1 collection, 2 todos, 1 owner } } } \ No newline at end of file From 36c5eb6ebb4167324dfae07865428dc0c41998c0 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 12:59:35 -0400 Subject: [PATCH 45/74] these tests pass -- should probably add more specific tests though --- .../Spec/DeeplyNestedInclusionTests.cs | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index d89bd6739b..9e663f45bc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -87,7 +87,7 @@ public async Task Can_Include_Nested_HasMany_Relationships() var documents = JsonConvert.DeserializeObject(body); var included = documents.Included; - Assert.Equal(3, included.Count); // 1 collection, 2 todos + Assert.Equal(4, included.Count); // 1 collection, 3 todos } [Fact] @@ -123,7 +123,45 @@ public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() var documents = JsonConvert.DeserializeObject(body); var included = documents.Included; - Assert.Equal(4, included.Count); // 1 collection, 2 todos, 1 owner + Assert.Equal(5, included.Count); // 1 collection, 3 todos, 1 owner + } + + [Fact] + public async Task Can_Include_Nested_Relationships_With_Multiple_Paths() + { + // arrange + const string route = "/api/v1/todo-items?include=collection.owner.role,collection.todo-items.owner"; + + var todoItem = new TodoItem { + Collection = new TodoItemCollection { + Owner = new Person { + Role = new PersonRole() + }, + TodoItems = new List { + new TodoItem { + Owner = new Person() + }, + new TodoItem() + } + } + }; + + var context = _fixture.GetService(); + context.TodoItems.RemoveRange(context.TodoItems); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var documents = JsonConvert.DeserializeObject(body); + var included = documents.Included; + + Assert.Equal(7, included.Count); // 1 collection, 3 todos, 2 owners, 1 role } } } \ No newline at end of file From c7887b8d98ddc096d7a2519d446bacc51bc21625 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 13:41:19 -0400 Subject: [PATCH 46/74] try to assert some more complicated stuff, and check specific included records --- .../Spec/DeeplyNestedInclusionTests.cs | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index 9e663f45bc..2b03ddc002 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -24,6 +24,14 @@ public DeeplyNestedInclusionTests(TestFixture fixture) _fixture = fixture; } + private void ResetContext(AppDbContext context) + { + context.TodoItems.RemoveRange(context.TodoItems); + context.TodoItemCollections.RemoveRange(context.TodoItemCollections); + context.People.RemoveRange(context.People); + context.PersonRoles.RemoveRange(context.PersonRoles); + } + [Fact] public async Task Can_Include_Nested_Relationships() { @@ -72,8 +80,10 @@ public async Task Can_Include_Nested_HasMany_Relationships() } }; + var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); + ResetContext(context); + context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); @@ -109,7 +119,8 @@ public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() }; var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); + ResetContext(context); + context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); @@ -147,7 +158,8 @@ public async Task Can_Include_Nested_Relationships_With_Multiple_Paths() }; var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); + ResetContext(context); + context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); @@ -163,5 +175,59 @@ public async Task Can_Include_Nested_Relationships_With_Multiple_Paths() Assert.Equal(7, included.Count); // 1 collection, 3 todos, 2 owners, 1 role } + + [Fact] + public async Task Included_Resources_Are_Correct() + { + // arrange + var role = new PersonRole(); + var asignee = new Person { Role = role }; + var collectionOwner = new Person(); + var someOtherOwner = new Person(); + var collection = new TodoItemCollection { Owner = collectionOwner }; + var todoItem1 = new TodoItem { Collection = collection, Assignee = asignee }; + var todoItem2 = new TodoItem { Collection = collection, Assignee = asignee }; + var todoItem3 = new TodoItem { Collection = collection, Owner = someOtherOwner }; + var todoItem4 = new TodoItem { Collection = collection, Owner = asignee }; + + + string route = + "/api/v1/todo-items/" + todoItem1.Id + "?include=" + + "collection.owner," + + "asignee.role," + + "asignee.assigned-todo-items"; + + + var context = _fixture.GetService(); + ResetContext(context); + + context.TodoItems.Add(todoItem1); + context.TodoItems.Add(todoItem2); + context.TodoItems.Add(todoItem3); + context.TodoItems.Add(todoItem4); + context.PersonRoles.Add(role); + context.People.Add(asignee); + context.People.Add(collectionOwner); + context.People.Add(someOtherOwner); + context.TodoItemCollections.Add(collection); + + + await context.SaveChangesAsync(); + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var documents = JsonConvert.DeserializeObject(body); + var included = documents.Included; + + // 1 collection, 1 owner, + // 1 asignee, 1 asignee role, + // 2 assigned todo items + Assert.Equal(6, included.Count); + } } } \ No newline at end of file From b02b3ecdd7f4713a8ff34b7a8c21ffb7ffdde87f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 14:41:59 -0400 Subject: [PATCH 47/74] I think I found a bug in the current implementation? --- .../Spec/DeeplyNestedInclusionTests.cs | 55 ++++++++++++++----- .../Helpers/Extensions/DocumentExtensions.cs | 26 +++++++++ 2 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index 2b03ddc002..c2540f91c0 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Extensions; using Microsoft.AspNetCore.Hosting; using Newtonsoft.Json; using Xunit; @@ -181,22 +182,14 @@ public async Task Included_Resources_Are_Correct() { // arrange var role = new PersonRole(); - var asignee = new Person { Role = role }; + var assignee = new Person { Role = role }; var collectionOwner = new Person(); var someOtherOwner = new Person(); var collection = new TodoItemCollection { Owner = collectionOwner }; - var todoItem1 = new TodoItem { Collection = collection, Assignee = asignee }; - var todoItem2 = new TodoItem { Collection = collection, Assignee = asignee }; + var todoItem1 = new TodoItem { Collection = collection, Assignee = assignee }; + var todoItem2 = new TodoItem { Collection = collection, Assignee = assignee }; var todoItem3 = new TodoItem { Collection = collection, Owner = someOtherOwner }; - var todoItem4 = new TodoItem { Collection = collection, Owner = asignee }; - - - string route = - "/api/v1/todo-items/" + todoItem1.Id + "?include=" + - "collection.owner," + - "asignee.role," + - "asignee.assigned-todo-items"; - + var todoItem4 = new TodoItem { Collection = collection, Owner = assignee }; var context = _fixture.GetService(); ResetContext(context); @@ -206,7 +199,7 @@ public async Task Included_Resources_Are_Correct() context.TodoItems.Add(todoItem3); context.TodoItems.Add(todoItem4); context.PersonRoles.Add(role); - context.People.Add(asignee); + context.People.Add(assignee); context.People.Add(collectionOwner); context.People.Add(someOtherOwner); context.TodoItemCollections.Add(collection); @@ -214,6 +207,12 @@ public async Task Included_Resources_Are_Correct() await context.SaveChangesAsync(); + string route = + "/api/v1/todo-items/" + todoItem1.Id + "?include=" + + "collection.owner," + + "assignee.role," + + "assignee.assigned-todo-items"; + // act var response = await _fixture.Client.GetAsync(route); @@ -225,9 +224,35 @@ public async Task Included_Resources_Are_Correct() var included = documents.Included; // 1 collection, 1 owner, - // 1 asignee, 1 asignee role, - // 2 assigned todo items + // 1 assignee, 1 assignee role, + // 2 assigned todo items (including the primary resource) Assert.Equal(6, included.Count); + + + var collectionDocument = included.FindResource("todo-item-collections", collection.Id); + var ownerDocument = included.FindResource("people", collectionOwner.Id); + var assigneeDocument = included.FindResource("people", assignee.Id); + var roleDocument = included.FindResource("person-roles", role.Id); + var assignedTodo1 = included.FindResource("todo-items", todoItem1.Id); + var assignedTodo2 = included.FindResource("todo-items", todoItem2.Id); + + Assert.NotNull(assignedTodo1); + Assert.Equal(todoItem1.Id.ToString(), assignedTodo1.Id); + + Assert.NotNull(assignedTodo2); + Assert.Equal(todoItem2.Id.ToString(), assignedTodo2.Id); + + Assert.NotNull(collectionDocument); + Assert.Equal(collection.Id.ToString(), collectionDocument.Id); + + Assert.NotNull(ownerDocument); + Assert.Equal(collectionOwner.Id.ToString(), ownerDocument.Id); + + Assert.NotNull(assigneeDocument); + Assert.Equal(assignee.Id.ToString(), assigneeDocument.Id); + + Assert.NotNull(roleDocument); + Assert.Equal(role.Id.ToString(), roleDocument.Id); } } } \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs new file mode 100644 index 0000000000..4d7130def6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Models; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Microsoft.EntityFrameworkCore.Storage; +using Database = Microsoft.EntityFrameworkCore.Storage.Database; + +namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions +{ + public static class DocumentExtensions + { + public static DocumentData FindResource(this List included, string type, TId id) + { + var document = included.Where(documentData => ( + documentData.Type == type + && documentData.Id == id.ToString() + )).FirstOrDefault(); + + return document; + } + } +} \ No newline at end of file From 41ba2ed3d2b513a890079d29315eec542d6d5bbd Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 16:51:43 -0400 Subject: [PATCH 48/74] omg. im dumb --- .../Acceptance/Spec/DeeplyNestedInclusionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index c2540f91c0..7689376b49 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -229,7 +229,7 @@ public async Task Included_Resources_Are_Correct() Assert.Equal(6, included.Count); - var collectionDocument = included.FindResource("todo-item-collections", collection.Id); + var collectionDocument = included.FindResource("todo-collections", collection.Id); var ownerDocument = included.FindResource("people", collectionOwner.Id); var assigneeDocument = included.FindResource("people", assignee.Id); var roleDocument = included.FindResource("person-roles", role.Id); From bda10b854994aaac551982c456057b87f81460ad Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 17:09:18 -0400 Subject: [PATCH 49/74] hasMany.hasMany --- .../Spec/DeeplyNestedInclusionTests.cs | 48 ++++++++++++++++++- .../Helpers/Extensions/DocumentExtensions.cs | 4 ++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index 7689376b49..f5e35292ba 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -228,7 +228,6 @@ public async Task Included_Resources_Are_Correct() // 2 assigned todo items (including the primary resource) Assert.Equal(6, included.Count); - var collectionDocument = included.FindResource("todo-collections", collection.Id); var ownerDocument = included.FindResource("people", collectionOwner.Id); var assigneeDocument = included.FindResource("people", assignee.Id); @@ -254,5 +253,52 @@ public async Task Included_Resources_Are_Correct() Assert.NotNull(roleDocument); Assert.Equal(role.Id.ToString(), roleDocument.Id); } + + [Fact] + public async Task Can_Include_Doubly_HasMany_Relationships() + { + // arrange + var person = new Person { + TodoItemCollections = new List { + new TodoItemCollection { + TodoItems = new List { + new TodoItem(), + new TodoItem() + } + }, + new TodoItemCollection { + TodoItems = new List { + new TodoItem(), + new TodoItem(), + new TodoItem() + } + } + } + }; + + var context = _fixture.GetService(); + ResetContext(context); + + context.People.Add(person); + + await context.SaveChangesAsync(); + + string route = "/api/v1/people/" + person.Id + "?include=todo-collections.todo-items"; + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var documents = JsonConvert.DeserializeObject(body); + var included = documents.Included; + + Assert.Equal(7, included.Count); + + Assert.Equal(5, included.CountOfType("todo-items")); + Assert.Equal(2, included.CountOfType("todo-collections")); + } } } \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs index 4d7130def6..f467e17f5b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs @@ -22,5 +22,9 @@ public static DocumentData FindResource(this List included, s return document; } + + public static int CountOfType(this List included, string type) { + return included.Where(documentData => documentData.Type == type).Count(); + } } } \ No newline at end of file From daa61abaad7f4e3168a0b2bd503650b330d632eb Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 17:19:37 -0400 Subject: [PATCH 50/74] some more assertions --- .../Spec/DeeplyNestedInclusionTests.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs index f5e35292ba..5e4754c7c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs @@ -98,7 +98,10 @@ public async Task Can_Include_Nested_HasMany_Relationships() var documents = JsonConvert.DeserializeObject(body); var included = documents.Included; - Assert.Equal(4, included.Count); // 1 collection, 3 todos + Assert.Equal(4, included.Count); + + Assert.Equal(3, included.CountOfType("todo-items")); + Assert.Equal(1, included.CountOfType("todo-collections")); } [Fact] @@ -135,7 +138,11 @@ public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() var documents = JsonConvert.DeserializeObject(body); var included = documents.Included; - Assert.Equal(5, included.Count); // 1 collection, 3 todos, 1 owner + Assert.Equal(5, included.Count); + + Assert.Equal(3, included.CountOfType("todo-items")); + Assert.Equal(1, included.CountOfType("people")); + Assert.Equal(1, included.CountOfType("todo-collections")); } [Fact] @@ -174,7 +181,12 @@ public async Task Can_Include_Nested_Relationships_With_Multiple_Paths() var documents = JsonConvert.DeserializeObject(body); var included = documents.Included; - Assert.Equal(7, included.Count); // 1 collection, 3 todos, 2 owners, 1 role + Assert.Equal(7, included.Count); + + Assert.Equal(3, included.CountOfType("todo-items")); + Assert.Equal(2, included.CountOfType("people")); + Assert.Equal(1, included.CountOfType("person-roles")); + Assert.Equal(1, included.CountOfType("todo-collections")); } [Fact] From 1a367fa0f71a090519e5fe714d116e1fff187168 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 18:23:40 -0400 Subject: [PATCH 51/74] this is a big one --- .../Serialization/JsonApiDeSerializerTests.cs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs index 18d4b0ee6b..501576a1b2 100644 --- a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs +++ b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs @@ -532,17 +532,176 @@ public void Sets_Attribute_Values_On_Included_HasOne_Relationships() Assert.Equal(expectedName, result.Independent.Name); } + + [Fact] + public void Can_Deserialize_Nested_Included_HasMany_Relationships() + { + // arrange + var contextGraphBuilder = new ContextGraphBuilder(); + contextGraphBuilder.AddResource("independents"); + contextGraphBuilder.AddResource("dependents"); + contextGraphBuilder.AddResource("many-to-manys"); + + var deserializer = GetDeserializer(contextGraphBuilder); + + var contentString = + @"{ + ""data"": { + ""type"": ""independents"", + ""id"": ""1"", + ""attributes"": { }, + ""relationships"": { + ""many-to-manys"": { + ""data"": [{ + ""type"": ""many-to-manys"", + ""id"": ""2"" + }, { + ""type"": ""many-to-manys"", + ""id"": ""3"" + }] + } + } + }, + ""included"": [ + { + ""type"": ""many-to-manys"", + ""id"": ""2"", + ""attributes"": {}, + ""relationships"": { + ""dependent"": { + ""data"": { + ""type"": ""dependents"", + ""id"": ""4"" + } + }, + ""independent"": { + ""data"": { + ""type"": ""independents"", + ""id"": ""5"" + } + } + } + }, + { + ""type"": ""many-to-manys"", + ""id"": ""3"", + ""attributes"": {}, + ""relationships"": { + ""dependent"": { + ""data"": { + ""type"": ""dependents"", + ""id"": ""4"" + } + }, + ""independent"": { + ""data"": { + ""type"": ""independents"", + ""id"": ""6"" + } + } + } + }, + { + ""type"": ""dependents"", + ""id"": ""4"", + ""attributes"": {}, + ""relationships"": { + ""many-to-manys"": { + ""data"": [{ + ""type"": ""many-to-manys"", + ""id"": ""2"" + }, { + ""type"": ""many-to-manys"", + ""id"": ""3"" + }] + } + } + } + , + { + ""type"": ""independents"", + ""id"": ""5"", + ""attributes"": {}, + ""relationships"": { + ""many-to-manys"": { + ""data"": [{ + ""type"": ""many-to-manys"", + ""id"": ""2"" + }] + } + } + } + , + { + ""type"": ""independents"", + ""id"": ""6"", + ""attributes"": {}, + ""relationships"": { + ""many-to-manys"": { + ""data"": [{ + ""type"": ""many-to-manys"", + ""id"": ""3"" + }] + } + } + } + ] + }"; + + // act + var result = deserializer.Deserialize(contentString); + + // assert + Assert.NotNull(result); + Assert.Equal(1, result.Id); + Assert.NotNull(result.ManyToManys); + Assert.Equal(2, result.ManyToManys.Count); + } + + private JsonApiDeSerializer GetDeserializer(ContextGraphBuilder contextGraphBuilder) + { + var contextGraph = contextGraphBuilder.Build(); + + var jsonApiContextMock = new Mock(); + jsonApiContextMock.SetupAllProperties(); + jsonApiContextMock.Setup(m => m.ContextGraph).Returns(contextGraph); + jsonApiContextMock.Setup(m => m.AttributesToUpdate).Returns(new Dictionary()); + jsonApiContextMock.Setup(m => m.RelationshipsToUpdate).Returns(new Dictionary()); + jsonApiContextMock.Setup(m => m.HasManyRelationshipPointers).Returns(new HasManyRelationshipPointers()); + jsonApiContextMock.Setup(m => m.HasOneRelationshipPointers).Returns(new HasOneRelationshipPointers()); + + var jsonApiOptions = new JsonApiOptions(); + jsonApiContextMock.Setup(m => m.Options).Returns(jsonApiOptions); + + var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object); + + return deserializer; + } + + private class ManyToManyNested : Identifiable + { + [Attr("name")] public string Name { get; set; } + [HasOne("dependent")] public OneToManyDependent Dependents { get; set; } + public int DependentId { get; set; } + [HasOne("independent")] public OneToManyIndependent Independents { get; set; } + public int InependentId { get; set; } + } + private class OneToManyDependent : Identifiable { [Attr("name")] public string Name { get; set; } [HasOne("independent")] public OneToManyIndependent Independent { get; set; } public int IndependentId { get; set; } + + [HasMany("many-to-manys")] public List ManyToManys { get; set; } } private class OneToManyIndependent : Identifiable { [Attr("name")] public string Name { get; set; } [HasMany("dependents")] public List Dependents { get; set; } + + [HasMany("many-to-manys")] public List ManyToManys { get; set; } } } } From 82cfa719e2c3b5aef1b81c71a9c10f63ec1c0ba3 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 Aug 2018 18:36:01 -0400 Subject: [PATCH 52/74] more assertions... and then commented out, because I don't know what desired deserializer behavior is --- .../Serialization/JsonApiDeSerializerTests.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs index 501576a1b2..bf003bcecf 100644 --- a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs +++ b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs @@ -649,13 +649,23 @@ public void Can_Deserialize_Nested_Included_HasMany_Relationships() }"; // act - var result = deserializer.Deserialize(contentString); + var result = deserializer.Deserialize(contentString); // assert Assert.NotNull(result); Assert.Equal(1, result.Id); Assert.NotNull(result.ManyToManys); Assert.Equal(2, result.ManyToManys.Count); + + // TODO: not sure if this should be a thing that works? + // could this cause cycles in the graph? + // Assert.NotNull(result.ManyToManys[0].Dependent); + // Assert.NotNull(result.ManyToManys[0].Independent); + // Assert.NotNull(result.ManyToManys[1].Dependent); + // Assert.NotNull(result.ManyToManys[1].Independent); + + // Assert.Equal(result.ManyToManys[0].Dependent, result.ManyToManys[1].Dependent); + // Assert.NotEqual(result.ManyToManys[0].Independent, result.ManyToManys[1].Independent); } private JsonApiDeSerializer GetDeserializer(ContextGraphBuilder contextGraphBuilder) @@ -681,9 +691,9 @@ private JsonApiDeSerializer GetDeserializer(ContextGraphBuilder contextGraphBuil private class ManyToManyNested : Identifiable { [Attr("name")] public string Name { get; set; } - [HasOne("dependent")] public OneToManyDependent Dependents { get; set; } + [HasOne("dependent")] public OneToManyDependent Dependent { get; set; } public int DependentId { get; set; } - [HasOne("independent")] public OneToManyIndependent Independents { get; set; } + [HasOne("independent")] public OneToManyIndependent Independent { get; set; } public int InependentId { get; set; } } From a3b3af4c8590e2b70b4ed09c0551054e1dbbfec2 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Wed, 29 Aug 2018 21:46:38 -0700 Subject: [PATCH 53/74] update build config, bump package version and document process --- .github/CONTRIBUTING.MD | 26 ++++++++++++++++++++++++++ appveyor.yml | 13 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/.github/CONTRIBUTING.MD b/.github/CONTRIBUTING.MD index 8ce50e6c68..22e7f4d363 100644 --- a/.github/CONTRIBUTING.MD +++ b/.github/CONTRIBUTING.MD @@ -13,3 +13,29 @@ I have chosen to loosely follow the [Angular Commit Guidelines](https://github.c # Documentation If you'd like to help us improve our documentation, please checkout our [GitHub pages repository](https://github.com/json-api-dotnet/json-api-dotnet.github.io) where we host our documentation. + +# Backporting Features To Prior Releases + +To backport a feature that has been approved and merged into `master`: + +## Requester + +Open an issue requesting the feature you would like backported. The change will be reviewed based on: + +- compatibility +- difficulty in porting the change +- the added value the feature provides for users on the older versions +- how difficult it is for users to migrate from the older version to the newer version + +## Maintainer + +- Checkout the version you want to apply the feature on top of and create a new branch to release the new version: + ``` + git checkout tags/v2.5.1 -b release/2.5.2 + ``` +- Cherrypick the merge commit: `git cherry-pick {git commit SHA}` +- Bump the package version in the csproj +- Make any other compatibility, documentation or tooling related changes +- Push the branch to origin and verify the build +- Once the build is verified, create a GitHub release, tagging the release branch +- Open a PR back to master with any other additions diff --git a/appveyor.yml b/appveyor.yml index 78f4d3187e..68def2ef4c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -18,6 +18,7 @@ branches: - master - develop - unstable + - /release\/.+/ nuget: disable_publish_on_pr: true @@ -38,6 +39,7 @@ test: off artifacts: - path: .\**\artifacts\**\*.nupkg name: NuGet + deploy: - provider: NuGet server: https://www.myget.org/F/research-institute/api/v2/package @@ -47,6 +49,7 @@ deploy: symbol_server: https://www.myget.org/F/research-institute/symbols/api/v2/package on: branch: develop + - provider: NuGet server: https://www.myget.org/F/jadnc/api/v2/package api_key: @@ -54,10 +57,20 @@ deploy: skip_symbols: false on: branch: unstable + - provider: NuGet name: production + skip_symbols: false api_key: secure: /fsEOgG4EdtNd6DPmko9h3NxQwx1IGDcFreGTKd2KA56U2KEkpX/L/pCGpCIEf2s on: branch: master appveyor_repo_tag: true + +- provider: NuGet + skip_symbols: false + api_key: + secure: /fsEOgG4EdtNd6DPmko9h3NxQwx1IGDcFreGTKd2KA56U2KEkpX/L/pCGpCIEf2s + on: + branch: /release\/.+/ + appveyor_repo_tag: true From e0b3cefd0bccdbae39cdf0b339a6394160de73f2 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 30 Aug 2018 11:34:43 -0400 Subject: [PATCH 54/74] add additional assertions to test specific todoitem return --- .../Acceptance/TodoItemsControllerTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index bd29195193..22a887656c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -114,6 +114,9 @@ public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator() // Assert Assert.NotEmpty(deserializedBody); + + foreach (var todoItemResult in deserializedBody) + Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); } [Fact] @@ -139,6 +142,9 @@ public async Task Can_Filter_TodoItems_Using_IsNull_Operator() // Assert Assert.NotEmpty(deserializedBody); + + foreach (var todoItemResult in deserializedBody) + Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); } [Fact] From a0b60805f498b82b848c6e49dc3aac5c8355a379 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 30 Aug 2018 14:37:42 -0400 Subject: [PATCH 55/74] this one is tricky --- .../Serialization/JsonApiSerializerTests.cs | 169 ++++++++++++++++-- 1 file changed, 155 insertions(+), 14 deletions(-) diff --git a/test/UnitTests/Serialization/JsonApiSerializerTests.cs b/test/UnitTests/Serialization/JsonApiSerializerTests.cs index 8a67b80434..005400d870 100644 --- a/test/UnitTests/Serialization/JsonApiSerializerTests.cs +++ b/test/UnitTests/Serialization/JsonApiSerializerTests.cs @@ -1,7 +1,11 @@ -using JsonApiDotNetCore.Builders; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Request; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using Moq; @@ -17,19 +21,9 @@ public void Can_Serialize_Complex_Types() // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("test-resource"); - var contextGraph = contextGraphBuilder.Build(); - - var jsonApiContextMock = new Mock(); - jsonApiContextMock.SetupAllProperties(); - jsonApiContextMock.Setup(m => m.ContextGraph).Returns(contextGraph); - jsonApiContextMock.Setup(m => m.Options).Returns(new JsonApiOptions()); - jsonApiContextMock.Setup(m => m.RequestEntity) - .Returns(contextGraph.GetContextEntity("test-resource")); - jsonApiContextMock.Setup(m => m.MetaBuilder).Returns(new MetaBuilder()); - jsonApiContextMock.Setup(m => m.PageManager).Returns(new PageManager()); + + var serializer = GetSerializer(contextGraphBuilder); - var documentBuilder = new DocumentBuilder(jsonApiContextMock.Object); - var serializer = new JsonApiSerializer(jsonApiContextMock.Object, documentBuilder); var resource = new TestResource { ComplexMember = new ComplexType @@ -43,18 +37,165 @@ public void Can_Serialize_Complex_Types() // assert Assert.NotNull(result); - Assert.Equal("{\"data\":{\"attributes\":{\"complex-member\":{\"compound-name\":\"testname\"}},\"type\":\"test-resource\",\"id\":\"\"}}", result); + + var expectedFormatted = + @"{ + ""data"": { + ""attributes"": { + ""complex-member"": { + ""compound-name"": ""testname"" + } + }, + ""relationships"": { + ""children"": { + ""links"": { + ""self"": ""/test-resource//relationships/children"", + ""related"": ""/test-resource//children"" + } + } + }, + ""type"": ""test-resource"", + ""id"": """" + } + }"; + var expected = Regex.Replace(expectedFormatted, @"\s+", ""); + + Assert.Equal(expected, result); + } + + [Fact] + public void Can_Serialize_Deeply_Nested_Relationships() + { + // arrange + var contextGraphBuilder = new ContextGraphBuilder(); + contextGraphBuilder.AddResource("test-resources"); + contextGraphBuilder.AddResource("children"); + contextGraphBuilder.AddResource("infections"); + + var serializer = GetSerializer( + contextGraphBuilder, + included: new List { "children.infections" } + ); + + var resource = new TestResource + { + Children = new List { + new ChildResource { + Infections = new List { + new InfectionResource(), + new InfectionResource(), + } + }, + new ChildResource() + } + }; + + // act + var result = serializer.Serialize(resource); + + // assert + Assert.NotNull(result); + + var expectedFormatted = + @"{ + ""data"": { + ""attributes"": { + ""complex-member"": { + ""compound-name"": ""testname"" + } + }, + ""relationships"": { + ""children"": { + ""links"": { + ""self"": ""/test-resource//relationships/children"", + ""related"": ""/test-resource//children"" + } + } + }, + ""type"": ""test-resource"", + ""id"": """" + }, + ""included"": { + { + ""attributes"": {}, + ""relationships"": {}, + ""type"": ""children"" + }, + { + ""attributes"": {}, + ""relationships"": {}, + ""type"": ""children"" + }, + { + ""attributes"": {}, + ""relationships"": {}, + ""type"": ""infections"" + }, + { + ""attributes"": {}, + ""relationships"": {}, + ""type"": ""infections"" + } + } + }"; + var expected = Regex.Replace(expectedFormatted, @"\s+", ""); + + Assert.Equal(expected, result); + } + + private JsonApiSerializer GetSerializer( + ContextGraphBuilder contextGraphBuilder, + List included = null) + { + var contextGraph = contextGraphBuilder.Build(); + + var jsonApiContextMock = new Mock(); + jsonApiContextMock.SetupAllProperties(); + jsonApiContextMock.Setup(m => m.ContextGraph).Returns(contextGraph); + jsonApiContextMock.Setup(m => m.Options).Returns(new JsonApiOptions()); + jsonApiContextMock.Setup(m => m.RequestEntity).Returns(contextGraph.GetContextEntity("test-resource")); + // jsonApiContextMock.Setup(m => m.AttributesToUpdate).Returns(new Dictionary()); + // jsonApiContextMock.Setup(m => m.RelationshipsToUpdate).Returns(new Dictionary()); + // jsonApiContextMock.Setup(m => m.HasManyRelationshipPointers).Returns(new HasManyRelationshipPointers()); + // jsonApiContextMock.Setup(m => m.HasOneRelationshipPointers).Returns(new HasOneRelationshipPointers()); + jsonApiContextMock.Setup(m => m.MetaBuilder).Returns(new MetaBuilder()); + jsonApiContextMock.Setup(m => m.PageManager).Returns(new PageManager()); + + if (included != null) + jsonApiContextMock.Setup(m => m.IncludedRelationships).Returns(included); + + var jsonApiOptions = new JsonApiOptions(); + jsonApiContextMock.Setup(m => m.Options).Returns(jsonApiOptions); + + var documentBuilder = new DocumentBuilder(jsonApiContextMock.Object); + var serializer = new JsonApiSerializer(jsonApiContextMock.Object, documentBuilder); + + return serializer; } private class TestResource : Identifiable { [Attr("complex-member")] public ComplexType ComplexMember { get; set; } + + [HasMany("children")] public List Children { get; set; } } private class ComplexType { public string CompoundName { get; set; } } + + private class ChildResource : Identifiable + { + [HasMany("infections")] public List Infections { get; set;} + + [HasOne("parent")] public TestResource Parent { get; set; } + } + + private class InfectionResource : Identifiable + { + [HasOne("infected")] public ChildResource Infected { get; set; } + } } } From 909c4c6f093dea5aca6a83c2f999786162e11c0b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 30 Aug 2018 16:32:20 -0400 Subject: [PATCH 56/74] it passes! --- .../Serialization/JsonApiSerializerTests.cs | 116 ++++++++++++++---- 1 file changed, 94 insertions(+), 22 deletions(-) diff --git a/test/UnitTests/Serialization/JsonApiSerializerTests.cs b/test/UnitTests/Serialization/JsonApiSerializerTests.cs index 005400d870..c6b4a09128 100644 --- a/test/UnitTests/Serialization/JsonApiSerializerTests.cs +++ b/test/UnitTests/Serialization/JsonApiSerializerTests.cs @@ -3,11 +3,13 @@ using System.Text.RegularExpressions; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Request; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; +using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -68,7 +70,7 @@ public void Can_Serialize_Deeply_Nested_Relationships() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); - contextGraphBuilder.AddResource("test-resources"); + contextGraphBuilder.AddResource("test-resource"); contextGraphBuilder.AddResource("children"); contextGraphBuilder.AddResource("infections"); @@ -79,14 +81,18 @@ public void Can_Serialize_Deeply_Nested_Relationships() var resource = new TestResource { + Id = 1, Children = new List { new ChildResource { + Id = 2, Infections = new List { - new InfectionResource(), - new InfectionResource(), + new InfectionResource { Id = 4 }, + new InfectionResource { Id = 5 }, } }, - new ChildResource() + new ChildResource { + Id = 3 + } } }; @@ -100,43 +106,99 @@ public void Can_Serialize_Deeply_Nested_Relationships() @"{ ""data"": { ""attributes"": { - ""complex-member"": { - ""compound-name"": ""testname"" - } + ""complex-member"": null }, ""relationships"": { ""children"": { ""links"": { - ""self"": ""/test-resource//relationships/children"", - ""related"": ""/test-resource//children"" - } + ""self"": ""/test-resource/1/relationships/children"", + ""related"": ""/test-resource/1/children"" + }, + ""data"": [{ + ""type"": ""children"", + ""id"": ""2"" + }, { + ""type"": ""children"", + ""id"": ""3"" + }] } }, ""type"": ""test-resource"", - ""id"": """" + ""id"": ""1"" }, - ""included"": { + ""included"": [ { ""attributes"": {}, - ""relationships"": {}, - ""type"": ""children"" + ""relationships"": { + ""infections"": { + ""links"": { + ""self"": ""/children/2/relationships/infections"", + ""related"": ""/children/2/infections"" + }, + ""data"": [{ + ""type"": ""infections"", + ""id"": ""4"" + }, { + ""type"": ""infections"", + ""id"": ""5"" + }] + }, + ""parent"": { + ""links"": { + ""self"": ""/children/2/relationships/parent"", + ""related"": ""/children/2/parent"" + } + } + }, + ""type"": ""children"", + ""id"": ""2"" }, { ""attributes"": {}, - ""relationships"": {}, - ""type"": ""children"" + ""relationships"": { + ""infected"": { + ""links"": { + ""self"": ""/infections/4/relationships/infected"", + ""related"": ""/infections/4/infected"" + } + } + }, + ""type"": ""infections"", + ""id"": ""4"" }, { ""attributes"": {}, - ""relationships"": {}, - ""type"": ""infections"" + ""relationships"": { + ""infected"": { + ""links"": { + ""self"": ""/infections/5/relationships/infected"", + ""related"": ""/infections/5/infected"" + } + } + }, + ""type"": ""infections"", + ""id"": ""5"" }, { ""attributes"": {}, - ""relationships"": {}, - ""type"": ""infections"" + ""relationships"": { + ""infections"": { + ""links"": { + ""self"": ""/children/3/relationships/infections"", + ""related"": ""/children/3/infections"" + } + }, + ""parent"": { + ""links"": { + ""self"": ""/children/3/relationships/parent"", + ""related"": ""/children/3/parent"" + } + } + }, + ""type"": ""children"", + ""id"": ""3"" } - } + ] }"; var expected = Regex.Replace(expectedFormatted, @"\s+", ""); @@ -167,7 +229,17 @@ private JsonApiSerializer GetSerializer( var jsonApiOptions = new JsonApiOptions(); jsonApiContextMock.Setup(m => m.Options).Returns(jsonApiOptions); - var documentBuilder = new DocumentBuilder(jsonApiContextMock.Object); + var services = new ServiceCollection(); + + var mvcBuilder = services.AddMvcCore(); + + services + .AddJsonApiInternals(jsonApiOptions); + + var provider = services.BuildServiceProvider(); + var scoped = new TestScopedServiceProvider(provider); + + var documentBuilder = new DocumentBuilder(jsonApiContextMock.Object, scopedServiceProvider: scoped); var serializer = new JsonApiSerializer(jsonApiContextMock.Object, documentBuilder); return serializer; From edd0f225cf2a9a6273313dae3d74e9aa9edf425c Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 3 Sep 2018 20:28:07 -0700 Subject: [PATCH 57/74] fix test -- make assertions directly related to the feature under test --- .../Acceptance/TodoItemsControllerTests.cs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 22a887656c..ccc9a1e870 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -97,7 +97,11 @@ public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator() // Arrange var todoItem = _todoItemFaker.Generate(); todoItem.UpdatedDate = new DateTime(); - _context.TodoItems.Add(todoItem); + + var otherTodoItem = _todoItemFaker.Generate(); + otherTodoItem.UpdatedDate = null; + + _context.TodoItems.AddRange(new[] { todoItem, otherTodoItem }); _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); @@ -110,13 +114,11 @@ public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetService().DeserializeList(body); + var todoItems = _fixture.GetService().DeserializeList(body); // Assert - Assert.NotEmpty(deserializedBody); - - foreach (var todoItemResult in deserializedBody) - Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); + Assert.NotEmpty(todoItems); + Assert.All(todoItems, t => Assert.NotNull(t.UpdatedDate)); } [Fact] @@ -125,7 +127,11 @@ public async Task Can_Filter_TodoItems_Using_IsNull_Operator() // Arrange var todoItem = _todoItemFaker.Generate(); todoItem.UpdatedDate = null; - _context.TodoItems.Add(todoItem); + + var otherTodoItem = _todoItemFaker.Generate(); + otherTodoItem.UpdatedDate = new DateTime(); + + _context.TodoItems.AddRange(new[] { todoItem, otherTodoItem }); _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); @@ -138,13 +144,11 @@ public async Task Can_Filter_TodoItems_Using_IsNull_Operator() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetService().DeserializeList(body); + var todoItems = _fixture.GetService().DeserializeList(body); // Assert - Assert.NotEmpty(deserializedBody); - - foreach (var todoItemResult in deserializedBody) - Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); + Assert.NotEmpty(todoItems); + Assert.All(todoItems, t => Assert.Null(t.UpdatedDate)); } [Fact] From 5965fb368268c1d8a6d629e222062e00d79a25f3 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 3 Sep 2018 21:21:23 -0700 Subject: [PATCH 58/74] fix(Identifiable): handle null id values --- src/JsonApiDotNetCore/Models/Identifiable.cs | 3 +++ test/UnitTests/Models/IdentifiableTests.cs | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Models/Identifiable.cs b/src/JsonApiDotNetCore/Models/Identifiable.cs index 08dd43bf22..038544a89c 100644 --- a/src/JsonApiDotNetCore/Models/Identifiable.cs +++ b/src/JsonApiDotNetCore/Models/Identifiable.cs @@ -37,6 +37,9 @@ public string StringId /// protected virtual string GetStringId(object value) { + if(value == null) + return string.Empty; + var type = typeof(T); var stringValue = value.ToString(); diff --git a/test/UnitTests/Models/IdentifiableTests.cs b/test/UnitTests/Models/IdentifiableTests.cs index 778b1b485f..da4e30360a 100644 --- a/test/UnitTests/Models/IdentifiableTests.cs +++ b/test/UnitTests/Models/IdentifiableTests.cs @@ -21,6 +21,16 @@ public void Setting_StringId_To_Null_Sets_Id_As_Default() Assert.Equal(0, resource.Id); } - private class IntId : Identifiable { } + [Fact] + public void GetStringId_Returns_EmptyString_If_Object_Is_Null() + { + var resource = new IntId(); + var stringId = resource.ExposedGetStringId(null); + Assert.Equal(string.Empty, stringId); + } + + private class IntId : Identifiable { + public string ExposedGetStringId(object value) => GetStringId(value); + } } } From 9388683f38eed9579dc45c52d2bb9aac8bd771aa Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 3 Sep 2018 22:07:31 -0700 Subject: [PATCH 59/74] fix(#347): parallel tests were stomping each other --- test/UnitTests/UnitTests.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index f8dde36f41..b88e85582a 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -14,4 +14,9 @@ + + + PreserveNewest + + From e93e12e6705ef6c3a7e3d61b6005324c7b8f22ba Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Fri, 7 Sep 2018 20:30:38 -0700 Subject: [PATCH 60/74] fix(#394): register shorthand service types during discovery --- .../Graph/ServiceDiscoveryFacade.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index ca156718fe..b29df8bca1 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -107,12 +107,25 @@ private string FormatResourceName(Type resourceType, IResourceNameFormatter reso /// The assembly to search for resources in. public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly) { + RegisterServiceImplementations(assembly, typeof(IResourceService<>)); RegisterServiceImplementations(assembly, typeof(IResourceService<,>)); + + RegisterServiceImplementations(assembly, typeof(ICreateService<>)); RegisterServiceImplementations(assembly, typeof(ICreateService<,>)); + + RegisterServiceImplementations(assembly, typeof(IGetAllService<>)); RegisterServiceImplementations(assembly, typeof(IGetAllService<,>)); + + RegisterServiceImplementations(assembly, typeof(IGetByIdService<>)); RegisterServiceImplementations(assembly, typeof(IGetByIdService<,>)); + + RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<>)); RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<,>)); + + RegisterServiceImplementations(assembly, typeof(IUpdateService<>)); RegisterServiceImplementations(assembly, typeof(IUpdateService<,>)); + + RegisterServiceImplementations(assembly, typeof(IDeleteService<>)); RegisterServiceImplementations(assembly, typeof(IDeleteService<,>)); return this; @@ -122,8 +135,19 @@ public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly) /// Add implementations to container. /// /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddAssemblyRepositories(Assembly assembly) - => RegisterServiceImplementations(assembly, typeof(IEntityRepository<,>)); + public ServiceDiscoveryFacade AddAssemblyRepositories(Assembly assembly) + { + RegisterServiceImplementations(assembly, typeof(IEntityRepository<>)); + RegisterServiceImplementations(assembly, typeof(IEntityRepository<,>)); + + RegisterServiceImplementations(assembly, typeof(IEntityWriteRepository<>)); + RegisterServiceImplementations(assembly, typeof(IEntityWriteRepository<,>)); + + RegisterServiceImplementations(assembly, typeof(IEntityReadRepository<>)); + RegisterServiceImplementations(assembly, typeof(IEntityReadRepository<,>)); + + return this; + } private ServiceDiscoveryFacade RegisterServiceImplementations(Assembly assembly, Type interfaceType) { From 1a9fd65482e3e01dcdfdabdc66d83e361dc19e48 Mon Sep 17 00:00:00 2001 From: Bj Date: Tue, 11 Sep 2018 18:12:05 -0400 Subject: [PATCH 61/74] Remove `DocumentData`, instead use `ResourceObject` --- .../JsonApiDeserializer_Benchmarks.cs | 2 +- .../Controllers/TodoItemsCustomController.cs | 2 +- .../Services/TodoItemService.cs | 2 +- .../Builders/DocumentBuilder.cs | 33 +++++++++---------- .../Builders/IDocumentBuilder.cs | 4 +-- .../Controllers/BaseJsonApiController.cs | 2 +- .../Controllers/JsonApiCmdController.cs | 2 +- .../Controllers/JsonApiController.cs | 2 +- src/JsonApiDotNetCore/Models/Document.cs | 2 +- src/JsonApiDotNetCore/Models/DocumentBase.cs | 2 +- src/JsonApiDotNetCore/Models/DocumentData.cs | 5 --- src/JsonApiDotNetCore/Models/Documents.cs | 2 +- .../Models/Operations/Operation.cs | 12 +++---- .../Serialization/IJsonApiDeSerializer.cs | 2 +- .../Serialization/JsonApiDeSerializer.cs | 18 +++++----- .../Contract/IUpdateRelationshipService.cs | 2 +- .../Services/EntityResourceService.cs | 2 +- .../Operations/Processors/GetOpProcessor.cs | 8 ++--- .../Helpers/Extensions/DocumentExtensions.cs | 6 ++-- .../Serialization/JsonApiDeSerializerTests.cs | 21 ++++-------- .../Processors/CreateOpProcessorTests.cs | 4 +-- 21 files changed, 61 insertions(+), 74 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Models/DocumentData.cs diff --git a/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs b/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs index c490bee362..5487a38666 100644 --- a/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs @@ -17,7 +17,7 @@ namespace Benchmarks.Serialization { public class JsonApiDeserializer_Benchmarks { private const string TYPE_NAME = "simple-types"; private static readonly string Content = JsonConvert.SerializeObject(new Document { - Data = new DocumentData { + Data = new ResourceObject { Type = TYPE_NAME, Id = "1", Attributes = new Dictionary { diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index f5f3e111d9..ca2e860fa9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -126,7 +126,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) } [HttpPatch("{id}/relationships/{relationshipName}")] - public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) + public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) { await _resourceService.UpdateRelationshipsAsync(id, relationshipName, relationships); return Ok(); diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs index e07aeed3ab..d43007400c 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -84,7 +84,7 @@ public Task UpdateAsync(int id, TodoItem entity) throw new NotImplementedException(); } - public Task UpdateRelationshipsAsync(int id, string relationshipName, List relationships) + public Task UpdateRelationshipsAsync(int id, string relationshipName, List relationships) { throw new NotImplementedException(); } diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index 15e46ffd6f..6afa13e029 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -58,7 +58,7 @@ public Documents Build(IEnumerable entities) var enumeratedEntities = entities as IList ?? entities.ToList(); var documents = new Documents { - Data = new List(), + Data = new List(), Meta = GetMeta(enumeratedEntities.FirstOrDefault()) }; @@ -95,7 +95,7 @@ private Dictionary GetMeta(IIdentifiable entity) private bool ShouldIncludePageLinks(ContextEntity entity) => entity.Links.HasFlag(Link.Paging); - private List AppendIncludedObject(List includedObject, ContextEntity contextEntity, IIdentifiable entity) + private List AppendIncludedObject(List includedObject, ContextEntity contextEntity, IIdentifiable entity) { var includedEntities = GetIncludedEntities(includedObject, contextEntity, entity); if (includedEntities?.Count > 0) @@ -107,13 +107,12 @@ private List AppendIncludedObject(List includedObjec } [Obsolete("You should specify an IResourceDefinition implementation using the GetData/3 overload.")] - public DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity) + public ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity) => GetData(contextEntity, entity, resourceDefinition: null); - public DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity, IResourceDefinition resourceDefinition = null) + public ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity, IResourceDefinition resourceDefinition = null) { - var data = new DocumentData - { + var data = new ResourceObject { Type = contextEntity.EntityName, Id = entity.StringId }; @@ -151,7 +150,7 @@ private bool OmitNullValuedAttribute(AttrAttribute attr, object attributeValue) return attributeValue == null && _documentBuilderOptions.OmitNullValuedAttributes; } - private void AddRelationships(DocumentData data, ContextEntity contextEntity, IIdentifiable entity) + private void AddRelationships(ResourceObject data, ContextEntity contextEntity, IIdentifiable entity) { data.Relationships = new Dictionary(); contextEntity.Relationships.ForEach(r => @@ -192,9 +191,9 @@ private RelationshipData GetRelationshipData(RelationshipAttribute attr, Context return relationshipData; } - private List GetIncludedEntities(List included, ContextEntity rootContextEntity, IIdentifiable rootResource) + private List GetIncludedEntities(List included, ContextEntity rootContextEntity, IIdentifiable rootResource) { - if(_jsonApiContext.IncludedRelationships != null) + if (_jsonApiContext.IncludedRelationships != null) { foreach(var relationshipName in _jsonApiContext.IncludedRelationships) { @@ -209,8 +208,8 @@ private List GetIncludedEntities(List included, Cont return included; } - private List IncludeRelationshipChain( - List included, ContextEntity parentEntity, IIdentifiable parentResource, string[] relationshipChain, int relationshipChainIndex) + private List IncludeRelationshipChain( + List included, ContextEntity parentEntity, IIdentifiable parentResource, string[] relationshipChain, int relationshipChainIndex) { var requestedRelationship = relationshipChain[relationshipChainIndex]; var relationship = parentEntity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == requestedRelationship); @@ -232,10 +231,10 @@ private List IncludeRelationshipChain( return included; } - private List IncludeSingleResourceRelationships( - List included, IIdentifiable navigationEntity, RelationshipAttribute relationship, string[] relationshipChain, int relationshipChainIndex) + private List IncludeSingleResourceRelationships( + List included, IIdentifiable navigationEntity, RelationshipAttribute relationship, string[] relationshipChain, int relationshipChainIndex) { - if(relationshipChainIndex < relationshipChain.Length) + if (relationshipChainIndex < relationshipChain.Length) { var nextContextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); var resource = (IIdentifiable)navigationEntity; @@ -248,12 +247,12 @@ private List IncludeSingleResourceRelationships( } - private List AddIncludedEntity(List entities, IIdentifiable entity) + private List AddIncludedEntity(List entities, IIdentifiable entity) { var includedEntity = GetIncludedEntity(entity); if (entities == null) - entities = new List(); + entities = new List(); if (includedEntity != null && entities.Any(doc => string.Equals(doc.Id, includedEntity.Id) && string.Equals(doc.Type, includedEntity.Type)) == false) @@ -264,7 +263,7 @@ private List AddIncludedEntity(List entities, IIdent return entities; } - private DocumentData GetIncludedEntity(IIdentifiable entity) + private ResourceObject GetIncludedEntity(IIdentifiable entity) { if (entity == null) return null; diff --git a/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs index dccd6f753a..70f5746d2b 100644 --- a/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/IDocumentBuilder.cs @@ -11,7 +11,7 @@ public interface IDocumentBuilder Documents Build(IEnumerable entities); [Obsolete("You should specify an IResourceDefinition implementation using the GetData/3 overload.")] - DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity); - DocumentData GetData(ContextEntity contextEntity, IIdentifiable entity, IResourceDefinition resourceDefinition = null); + ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity); + ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity, IResourceDefinition resourceDefinition = null); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index aec2d16cfe..0423b76ac0 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -181,7 +181,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) return Ok(updatedEntity); } - public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) + public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) { if (_updateRelationships == null) throw Exceptions.UnSupportedRequestMethod; diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCmdController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCmdController.cs index 82c0fe40c4..16ab4aa74a 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCmdController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCmdController.cs @@ -35,7 +35,7 @@ public override async Task PatchAsync(TId id, [FromBody] T entity [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipsAsync( - TId id, string relationshipName, [FromBody] List relationships) + TId id, string relationshipName, [FromBody] List relationships) => await base.PatchRelationshipsAsync(id, relationshipName, relationships); [HttpDelete("{id}")] diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 929e76e5aa..a77c03da06 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -88,7 +88,7 @@ public override async Task PatchAsync(TId id, [FromBody] T entity [HttpPatch("{id}/relationships/{relationshipName}")] public override async Task PatchRelationshipsAsync( - TId id, string relationshipName, [FromBody] List relationships) + TId id, string relationshipName, [FromBody] List relationships) => await base.PatchRelationshipsAsync(id, relationshipName, relationships); [HttpDelete("{id}")] diff --git a/src/JsonApiDotNetCore/Models/Document.cs b/src/JsonApiDotNetCore/Models/Document.cs index 71922e5573..5d0d10d188 100644 --- a/src/JsonApiDotNetCore/Models/Document.cs +++ b/src/JsonApiDotNetCore/Models/Document.cs @@ -5,6 +5,6 @@ namespace JsonApiDotNetCore.Models public class Document : DocumentBase { [JsonProperty("data")] - public DocumentData Data { get; set; } + public ResourceObject Data { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/DocumentBase.cs b/src/JsonApiDotNetCore/Models/DocumentBase.cs index eb38f9582d..8812d301e5 100644 --- a/src/JsonApiDotNetCore/Models/DocumentBase.cs +++ b/src/JsonApiDotNetCore/Models/DocumentBase.cs @@ -9,7 +9,7 @@ public class DocumentBase public RootLinks Links { get; set; } [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore)] - public List Included { get; set; } + public List Included { get; set; } [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Meta { get; set; } diff --git a/src/JsonApiDotNetCore/Models/DocumentData.cs b/src/JsonApiDotNetCore/Models/DocumentData.cs deleted file mode 100644 index ba1ce646c0..0000000000 --- a/src/JsonApiDotNetCore/Models/DocumentData.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace JsonApiDotNetCore.Models -{ - // TODO: deprecate DocumentData in favor of ResourceObject - public class DocumentData : ResourceObject { } -} diff --git a/src/JsonApiDotNetCore/Models/Documents.cs b/src/JsonApiDotNetCore/Models/Documents.cs index 5ba8203bc4..8e1dcbb36e 100644 --- a/src/JsonApiDotNetCore/Models/Documents.cs +++ b/src/JsonApiDotNetCore/Models/Documents.cs @@ -6,6 +6,6 @@ namespace JsonApiDotNetCore.Models public class Documents : DocumentBase { [JsonProperty("data")] - public List Data { get; set; } + public List Data { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/Operations/Operation.cs b/src/JsonApiDotNetCore/Models/Operations/Operation.cs index 38c544eabc..604643d231 100644 --- a/src/JsonApiDotNetCore/Models/Operations/Operation.cs +++ b/src/JsonApiDotNetCore/Models/Operations/Operation.cs @@ -32,18 +32,18 @@ private void SetData(object data) if (data is JArray jArray) { DataIsList = true; - DataList = jArray.ToObject>(); + DataList = jArray.ToObject>(); } - else if (data is List dataList) + else if (data is List dataList) { DataIsList = true; DataList = dataList; } else if (data is JObject jObject) { - DataObject = jObject.ToObject(); + DataObject = jObject.ToObject(); } - else if (data is DocumentData dataObject) + else if (data is ResourceObject dataObject) { DataObject = dataObject; } @@ -53,10 +53,10 @@ private void SetData(object data) public bool DataIsList { get; private set; } [JsonIgnore] - public List DataList { get; private set; } + public List DataList { get; private set; } [JsonIgnore] - public DocumentData DataObject { get; private set; } + public ResourceObject DataObject { get; private set; } public string GetResourceTypeName() { diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs index 57b28c6087..6b6f41fbf7 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs @@ -9,6 +9,6 @@ public interface IJsonApiDeSerializer TEntity Deserialize(string requestBody); object DeserializeRelationship(string requestBody); List DeserializeList(string requestBody); - object DocumentToObject(DocumentData data, List included = null); + object DocumentToObject(ResourceObject data, List included = null); } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index bd2a25b0ad..28fc9c1ae2 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -80,9 +80,9 @@ public object DeserializeRelationship(string requestBody) var data = JToken.Parse(requestBody)["data"]; if (data is JArray) - return data.ToObject>(); + return data.ToObject>(); - return new List { data.ToObject() }; + return new List { data.ToObject() }; } catch (Exception e) { @@ -111,7 +111,7 @@ public List DeserializeList(string requestBody) } } - public object DocumentToObject(DocumentData data, List included = null) + public object DocumentToObject(ResourceObject data, List included = null) { if (data == null) throw new JsonApiException(422, "Failed to deserialize document as json:api."); @@ -175,7 +175,7 @@ private object SetRelationships( object entity, ContextEntity contextEntity, Dictionary relationships, - List included = null) + List included = null) { if (relationships == null || relationships.Count == 0) return entity; @@ -197,7 +197,7 @@ private object SetHasOneRelationship(object entity, HasOneAttribute attr, ContextEntity contextEntity, Dictionary relationships, - List included = null) + List included = null) { var relationshipName = attr.PublicRelationshipName; @@ -254,7 +254,7 @@ private void SetHasOneForeignKeyValue(object entity, HasOneAttribute hasOneAttr, /// If the resource has been included, all attributes will be set. /// If the resource has not been included, only the id will be set. /// - private void SetHasOneNavigationPropertyValue(object entity, HasOneAttribute hasOneAttr, ResourceIdentifierObject rio, List included) + private void SetHasOneNavigationPropertyValue(object entity, HasOneAttribute hasOneAttr, ResourceIdentifierObject rio, List included) { // if the resource identifier is null, there should be no reason to instantiate an instance if (rio != null && rio.Id != null) @@ -277,7 +277,7 @@ private object SetHasManyRelationship(object entity, RelationshipAttribute attr, ContextEntity contextEntity, Dictionary relationships, - List included = null) + List included = null) { var relationshipName = attr.PublicRelationshipName; @@ -303,7 +303,7 @@ private object SetHasManyRelationship(object entity, return entity; } - private IIdentifiable GetIncludedRelationship(ResourceIdentifierObject relatedResourceIdentifier, List includedResources, RelationshipAttribute relationshipAttr) + private IIdentifiable GetIncludedRelationship(ResourceIdentifierObject relatedResourceIdentifier, List includedResources, RelationshipAttribute relationshipAttr) { // at this point we can be sure the relationshipAttr.Type is IIdentifiable because we were able to successfully build the ContextGraph var relatedInstance = relationshipAttr.Type.New(); @@ -326,7 +326,7 @@ private IIdentifiable GetIncludedRelationship(ResourceIdentifierObject relatedRe return relatedInstance; } - private DocumentData GetLinkedResource(ResourceIdentifierObject relatedResourceIdentifier, List includedResources) + private ResourceObject GetLinkedResource(ResourceIdentifierObject relatedResourceIdentifier, List includedResources) { try { diff --git a/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs b/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs index c7b2fd77bf..a942cc0f74 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs @@ -11,6 +11,6 @@ public interface IUpdateRelationshipService : IUpdateRelationshipService where T : class, IIdentifiable { - Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships); + Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships); } } diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 1e8dc249a2..1c8dabb74b 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -157,7 +157,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource) return MapOut(entity); } - public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships) + public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships) { var entity = await _entities.GetAndIncludeAsync(id, relationshipName); if (entity == null) diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs index 954bfadd55..6e0ad06b51 100644 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs @@ -96,7 +96,7 @@ private async Task GetAllAsync(Operation operation) { var result = await _getAll.GetAsync(); - var operations = new List(); + var operations = new List(); foreach (var resource in result) { var doc = _documentBuilder.GetData( @@ -153,14 +153,14 @@ private async Task GetRelationshipAsync(Operation operation) detail: $"Type '{result.GetType()} does not implement {nameof(IIdentifiable)} nor {nameof(IEnumerable)}'"); } - private DocumentData GetData(ContextEntity contextEntity, IIdentifiable singleResource) + private ResourceObject GetData(ContextEntity contextEntity, IIdentifiable singleResource) { return _documentBuilder.GetData(contextEntity, singleResource); } - private List GetData(ContextEntity contextEntity, IEnumerable multipleResults) + private List GetData(ContextEntity contextEntity, IEnumerable multipleResults) { - var resources = new List(); + var resources = new List(); foreach (var singleResult in multipleResults) { if (singleResult is IIdentifiable resource) diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs index f467e17f5b..298b86812c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions { public static class DocumentExtensions { - public static DocumentData FindResource(this List included, string type, TId id) + public static ResourceObject FindResource(this List included, string type, TId id) { var document = included.Where(documentData => ( documentData.Type == type @@ -23,8 +23,8 @@ public static DocumentData FindResource(this List included, s return document; } - public static int CountOfType(this List included, string type) { + public static int CountOfType(this List included, string type) { return included.Where(documentData => documentData.Type == type).Count(); } } -} \ No newline at end of file +} diff --git a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs index bf003bcecf..9ea44cd6dd 100644 --- a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs +++ b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs @@ -36,8 +36,7 @@ public void Can_Deserialize_Complex_Types() var content = new Document { - Data = new DocumentData - { + Data = new ResourceObject { Type = "test-resource", Id = "1", Attributes = new Dictionary @@ -75,8 +74,7 @@ public void Can_Deserialize_Complex_List_Types() var content = new Document { - Data = new DocumentData - { + Data = new ResourceObject { Type = "test-resource", Id = "1", Attributes = new Dictionary @@ -116,8 +114,7 @@ public void Can_Deserialize_Complex_Types_With_Dasherized_Attrs() var content = new Document { - Data = new DocumentData - { + Data = new ResourceObject { Type = "test-resource", Id = "1", Attributes = new Dictionary @@ -160,8 +157,7 @@ public void Immutable_Attrs_Are_Not_Included_In_AttributesToUpdate() var content = new Document { - Data = new DocumentData - { + Data = new ResourceObject { Type = "test-resource", Id = "1", Attributes = new Dictionary @@ -209,8 +205,7 @@ public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship() var property = Guid.NewGuid().ToString(); var content = new Document { - Data = new DocumentData - { + Data = new ResourceObject { Type = "independents", Id = "1", Attributes = new Dictionary { { "property", property } } @@ -250,8 +245,7 @@ public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship_With_Rel var property = Guid.NewGuid().ToString(); var content = new Document { - Data = new DocumentData - { + Data = new ResourceObject { Type = "independents", Id = "1", Attributes = new Dictionary { { "property", property } }, @@ -304,8 +298,7 @@ public void Sets_The_DocumentMeta_Property_In_JsonApiContext() var content = new Document { Meta = new Dictionary() { { "foo", "bar" } }, - Data = new DocumentData - { + Data = new ResourceObject { Type = "independents", Id = "1", Attributes = new Dictionary { { "property", property } }, diff --git a/test/UnitTests/Services/Operations/Processors/CreateOpProcessorTests.cs b/test/UnitTests/Services/Operations/Processors/CreateOpProcessorTests.cs index aa76f2dc17..274fda556b 100644 --- a/test/UnitTests/Services/Operations/Processors/CreateOpProcessorTests.cs +++ b/test/UnitTests/Services/Operations/Processors/CreateOpProcessorTests.cs @@ -33,7 +33,7 @@ public async Task ProcessAsync_Deserializes_And_Creates() Name = "some-name" }; - var data = new DocumentData { + var data = new ResourceObject { Type = "test-resources", Attributes = new Dictionary { { "name", testResource.Name } @@ -48,7 +48,7 @@ public async Task ProcessAsync_Deserializes_And_Creates() .AddResource("test-resources") .Build(); - _deserializerMock.Setup(m => m.DocumentToObject(It.IsAny(), It.IsAny>())) + _deserializerMock.Setup(m => m.DocumentToObject(It.IsAny(), It.IsAny>())) .Returns(testResource); var opProcessor = new CreateOpProcessor( From d0a2c42558f90b322f418dc3c13663b40a623d2d Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 22 Sep 2018 20:06:07 -0700 Subject: [PATCH 62/74] fixing auto-discovery --- .../JsonApiDotNetCoreExample/Startup.cs | 5 +- src/Examples/ReportsExample/Startup.cs | 8 +- .../IServiceCollectionExtensions.cs | 54 ++++++++ .../Graph/ResourceDescriptor.cs | 2 + .../Graph/ServiceDiscoveryFacade.cs | 128 ++++++++++-------- src/JsonApiDotNetCore/Graph/TypeLocator.cs | 42 ++++-- .../Internal/JsonApiSetupException.cs | 13 ++ .../IServiceCollectionExtensionsTests.cs | 87 ++++++++++++ 8 files changed, 267 insertions(+), 72 deletions(-) create mode 100644 src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index 29dfc9e4b5..68ea93a7fc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -41,10 +41,9 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) options.IncludeTotalRecordCount = true; }, mvcBuilder, - discovery => discovery.AddCurrentAssemblyServices()); + discovery => discovery.AddCurrentAssembly()); - var provider = services.BuildServiceProvider(); - return provider; + return services.BuildServiceProvider(); } public virtual void Configure( diff --git a/src/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs index 43a910a0e6..b71b7fa74a 100644 --- a/src/Examples/ReportsExample/Startup.cs +++ b/src/Examples/ReportsExample/Startup.cs @@ -25,10 +25,10 @@ public Startup(IHostingEnvironment env) public virtual void ConfigureServices(IServiceCollection services) { var mvcBuilder = services.AddMvcCore(); - services.AddJsonApi( - opt => opt.Namespace = "api", - mvcBuilder, - discovery => discovery.AddCurrentAssemblyServices()); + services.AddJsonApi( + opt => opt.Namespace = "api", + mvcBuilder, + discovery => discovery.AddCurrentAssembly()); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 4c2d6c2f79..9c1df0626e 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; @@ -7,6 +9,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Generics; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using JsonApiDotNetCore.Services.Operations; @@ -182,5 +185,56 @@ public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions js options.Conventions.Insert(0, new DasherizedRoutingConvention(jsonApiOptions.Namespace)); } + + /// + /// + public static IServiceCollection AddResourceService(this IServiceCollection services) + { + var typeImplemenetsAnExpectedInterface = false; + + var serviceImplementationType = typeof(T); + + // it is _possible_ that a single concrete type could be used for multiple resources... + var resourceDescriptors = GetResourceTypesFromServiceImplementation(serviceImplementationType); + + foreach(var resourceDescriptor in resourceDescriptors) + { + foreach(var openGenericType in ServiceDiscoveryFacade.ServiceInterfaces) + { + var concreteGenericType = openGenericType.GetGenericArguments().Length == 1 + ? openGenericType.MakeGenericType(resourceDescriptor.ResourceType) + : openGenericType.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + + if(concreteGenericType.IsAssignableFrom(serviceImplementationType)) { + services.AddScoped(serviceImplementationType, serviceImplementationType); + typeImplemenetsAnExpectedInterface = true; + } + } + } + + if(typeImplemenetsAnExpectedInterface == false) + throw new JsonApiSetupException($"{typeImplemenetsAnExpectedInterface} does not implement any of the expected JsonApiDotNetCore interfaces."); + + return services; + } + + private static HashSet GetResourceTypesFromServiceImplementation(Type type) + { + var resourceDecriptors = new HashSet(); + var interfaces = type.GetInterfaces(); + foreach(var i in interfaces) + { + if(i.IsGenericType) + { + var firstGenericArgument = i.GetGenericTypeDefinition().GetGenericArguments().FirstOrDefault(); + if(TypeLocator.TryGetResourceDescriptor(firstGenericArgument, out var resourceDescriptor) == false) + { + resourceDecriptors.Add(resourceDescriptor); + } + } + } + + return resourceDecriptors; + } } } diff --git a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs index e90bcdc22c..2cb1a8b812 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs @@ -12,5 +12,7 @@ public ResourceDescriptor(Type resourceType, Type idType) public Type ResourceType { get; set; } public Type IdType { get; set; } + + internal static ResourceDescriptor Empty => new ResourceDescriptor(null, null); } } diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index b29df8bca1..f403f69124 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -13,10 +14,39 @@ namespace JsonApiDotNetCore.Graph { public class ServiceDiscoveryFacade { + internal static HashSet ServiceInterfaces = new HashSet { + typeof(IResourceService<>), + typeof(IResourceService<,>), + typeof(ICreateService<>), + typeof(ICreateService<,>), + typeof(IGetAllService<>), + typeof(IGetAllService<,>), + typeof(IGetByIdService<>), + typeof(IGetByIdService<,>), + typeof(IGetRelationshipService<>), + typeof(IGetRelationshipService<,>), + typeof(IUpdateService<>), + typeof(IUpdateService<,>), + typeof(IDeleteService<>), + typeof(IDeleteService<,>) + }; + + internal static HashSet RepositoryInterfaces = new HashSet { + typeof(IEntityRepository<>), + typeof(IEntityRepository<,>), + typeof(IEntityWriteRepository<>), + typeof(IEntityWriteRepository<,>), + typeof(IEntityReadRepository<>), + typeof(IEntityReadRepository<,>) + }; + private readonly IServiceCollection _services; private readonly IContextGraphBuilder _graphBuilder; + private readonly List _identifiables = new List(); - public ServiceDiscoveryFacade(IServiceCollection services, IContextGraphBuilder graphBuilder) + public ServiceDiscoveryFacade( + IServiceCollection services, + IContextGraphBuilder graphBuilder) { _services = services; _graphBuilder = graphBuilder; @@ -26,20 +56,25 @@ public ServiceDiscoveryFacade(IServiceCollection services, IContextGraphBuilder /// Add resources, services and repository implementations to the container. /// /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddCurrentAssemblyServices(IResourceNameFormatter resourceNameFormatter = null) - => AddAssemblyServices(Assembly.GetCallingAssembly(), resourceNameFormatter); + public ServiceDiscoveryFacade AddCurrentAssembly(IResourceNameFormatter resourceNameFormatter = null) + => AddAssembly(Assembly.GetCallingAssembly(), resourceNameFormatter); /// /// Add resources, services and repository implementations to the container. /// /// The assembly to search for resources in. /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + public ServiceDiscoveryFacade AddAssembly(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) { AddDbContextResolvers(assembly); - AddAssemblyResources(assembly, resourceNameFormatter); - AddAssemblyServices(assembly); - AddAssemblyRepositories(assembly); + + var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); + foreach (var resourceDescriptor in resourceDescriptors) + { + AddResource(assembly, resourceDescriptor, resourceNameFormatter); + AddServices(assembly, resourceDescriptor); + AddRepositories(assembly, resourceDescriptor); + } return this; } @@ -59,18 +94,21 @@ private void AddDbContextResolvers(Assembly assembly) /// /// The assembly to search for resources in. /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddAssemblyResources(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + public ServiceDiscoveryFacade AddResources(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) { var identifiables = TypeLocator.GetIdentifableTypes(assembly); foreach (var identifiable in identifiables) - { - RegisterResourceDefinition(assembly, identifiable); - AddResourceToGraph(identifiable, resourceNameFormatter); - } + AddResource(assembly, identifiable, resourceNameFormatter); return this; } + private void AddResource(Assembly assembly, ResourceDescriptor resourceDescriptor, IResourceNameFormatter resourceNameFormatter = null) + { + RegisterResourceDefinition(assembly, resourceDescriptor); + AddResourceToGraph(resourceDescriptor, resourceNameFormatter); + } + private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) { try @@ -83,9 +121,7 @@ private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor id } catch (InvalidOperationException e) { - // TODO: need a better way to communicate failure since this is unlikely to occur during a web request - throw new JsonApiException(500, - $"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); + throw new JsonApiSetupException($"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); } } @@ -105,61 +141,45 @@ private string FormatResourceName(Type resourceType, IResourceNameFormatter reso /// Add implementations to container. /// /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly) + public ServiceDiscoveryFacade AddServices(Assembly assembly) { - RegisterServiceImplementations(assembly, typeof(IResourceService<>)); - RegisterServiceImplementations(assembly, typeof(IResourceService<,>)); - - RegisterServiceImplementations(assembly, typeof(ICreateService<>)); - RegisterServiceImplementations(assembly, typeof(ICreateService<,>)); - - RegisterServiceImplementations(assembly, typeof(IGetAllService<>)); - RegisterServiceImplementations(assembly, typeof(IGetAllService<,>)); - - RegisterServiceImplementations(assembly, typeof(IGetByIdService<>)); - RegisterServiceImplementations(assembly, typeof(IGetByIdService<,>)); - - RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<>)); - RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<,>)); - - RegisterServiceImplementations(assembly, typeof(IUpdateService<>)); - RegisterServiceImplementations(assembly, typeof(IUpdateService<,>)); - - RegisterServiceImplementations(assembly, typeof(IDeleteService<>)); - RegisterServiceImplementations(assembly, typeof(IDeleteService<,>)); + var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); + foreach (var resourceDescriptor in resourceDescriptors) + AddServices(assembly, resourceDescriptor); return this; } + private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach(var serviceInterface in ServiceInterfaces) + RegisterServiceImplementations(assembly, serviceInterface, resourceDescriptor); + } + /// /// Add implementations to container. /// /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddAssemblyRepositories(Assembly assembly) + public ServiceDiscoveryFacade AddRepositories(Assembly assembly) { - RegisterServiceImplementations(assembly, typeof(IEntityRepository<>)); - RegisterServiceImplementations(assembly, typeof(IEntityRepository<,>)); - - RegisterServiceImplementations(assembly, typeof(IEntityWriteRepository<>)); - RegisterServiceImplementations(assembly, typeof(IEntityWriteRepository<,>)); - - RegisterServiceImplementations(assembly, typeof(IEntityReadRepository<>)); - RegisterServiceImplementations(assembly, typeof(IEntityReadRepository<,>)); + var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); + foreach (var resourceDescriptor in resourceDescriptors) + AddRepositories(assembly, resourceDescriptor); return this; } - private ServiceDiscoveryFacade RegisterServiceImplementations(Assembly assembly, Type interfaceType) + private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) { - var identifiables = TypeLocator.GetIdentifableTypes(assembly); - foreach (var identifiable in identifiables) - { - var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, identifiable.ResourceType, identifiable.IdType); - if (service.implementation != null) - _services.AddScoped(service.registrationInterface, service.implementation); - } + foreach(var serviceInterface in RepositoryInterfaces) + RegisterServiceImplementations(assembly, serviceInterface, resourceDescriptor); + } - return this; + private void RegisterServiceImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) + { + var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, resourceDescriptor.ResourceType, resourceDescriptor.IdType); + if (service.implementation != null) + _services.AddScoped(service.registrationInterface, service.implementation); } } } diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Graph/TypeLocator.cs index 223845859a..f96e17ffe0 100644 --- a/src/JsonApiDotNetCore/Graph/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Graph/TypeLocator.cs @@ -46,24 +46,44 @@ private static Type[] GetAssemblyTypes(Assembly assembly) } /// - /// Get all implementations of . in the assembly + /// Get all implementations of in the assembly /// - public static List GetIdentifableTypes(Assembly assembly) + public static IEnumerable GetIdentifableTypes(Assembly assembly) + => (_identifiableTypeCache.TryGetValue(assembly, out var descriptors) == false) + ? FindIdentifableTypes(assembly) + : _identifiableTypeCache[assembly]; + + private static IEnumerable FindIdentifableTypes(Assembly assembly) { - if (_identifiableTypeCache.TryGetValue(assembly, out var descriptors) == false) - { - descriptors = new List(); - _identifiableTypeCache[assembly] = descriptors; + var descriptors = new List(); + _identifiableTypeCache[assembly] = descriptors; - foreach (var type in assembly.GetTypes()) + foreach (var type in assembly.GetTypes()) + { + if (TryGetResourceDescriptor(type, out var descriptor)) { - var possible = GetIdType(type); - if (possible.isJsonApiResource) - descriptors.Add(new ResourceDescriptor(type, possible.idType)); + descriptors.Add(descriptor); + yield return descriptor; } } + } - return descriptors; + /// + /// Attempts to get a descriptor of the resource type. + /// + /// + /// True if the type is a valid json:api type (must implement ), false otherwise. + /// + internal static bool TryGetResourceDescriptor(Type type, out ResourceDescriptor descriptor) + { + var possible = GetIdType(type); + if (possible.isJsonApiResource) { + descriptor = new ResourceDescriptor(type, possible.idType); + return true; + } + + descriptor = ResourceDescriptor.Empty; + return false; } /// diff --git a/src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs b/src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs new file mode 100644 index 0000000000..34765066be --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCore.Internal +{ + public class JsonApiSetupException : Exception + { + public JsonApiSetupException(string message) + : base(message) { } + + public JsonApiSetupException(string message, Exception innerException) + : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 1b00c5aaa1..0dc7808b24 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -13,6 +13,10 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Models; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace UnitTests.Extensions { @@ -51,5 +55,88 @@ public void AddJsonApiInternals_Adds_All_Required_Services() Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService(typeof(GenericProcessor))); } + + [Fact] + public void AddResourceService_Registers_All_Shorthand_Service_Interfaces() + { + // arrange + var services = new ServiceCollection(); + + // act + services.AddResourceService(); + + // assert + var provider = services.BuildServiceProvider(); + Assert.IsType(typeof(IResourceService)); + Assert.IsType(typeof(IResourceCmdService)); + Assert.IsType(typeof(IResourceQueryService)); + Assert.IsType(typeof(IGetAllService)); + Assert.IsType(typeof(IGetByIdService)); + Assert.IsType(typeof(IGetRelationshipService)); + Assert.IsType(typeof(IGetRelationshipsService)); + Assert.IsType(typeof(ICreateService)); + Assert.IsType(typeof(IUpdateService)); + Assert.IsType(typeof(IDeleteService)); + } + + [Fact] + public void AddResourceService_Registers_All_LongForm_Service_Interfaces() + { + // arrange + var services = new ServiceCollection(); + + // act + services.AddResourceService(); + + // assert + var provider = services.BuildServiceProvider(); + Assert.IsType(typeof(IResourceService)); + Assert.IsType(typeof(IResourceCmdService)); + Assert.IsType(typeof(IResourceQueryService)); + Assert.IsType(typeof(IGetAllService)); + Assert.IsType(typeof(IGetByIdService)); + Assert.IsType(typeof(IGetRelationshipService)); + Assert.IsType(typeof(IGetRelationshipsService)); + Assert.IsType(typeof(ICreateService)); + Assert.IsType(typeof(IUpdateService)); + Assert.IsType(typeof(IDeleteService)); + } + + [Fact] + public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces() + { + // arrange + var services = new ServiceCollection(); + + // act, assert + Assert.Throws(() => services.AddResourceService()); + } + + private class IntResource : Identifiable { } + private 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> GetAsync() => throw new NotImplementedException(); + public Task GetAsync(int id) => throw new NotImplementedException(); + public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task GetRelationshipsAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task UpdateAsync(int id, IntResource entity) => throw new NotImplementedException(); + public Task UpdateRelationshipsAsync(int id, string relationshipName, List relationships) => throw new NotImplementedException(); + } + + private class GuidResourceService : IResourceService + { + public Task CreateAsync(GuidResource entity) => 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(); + public Task GetRelationshipsAsync(Guid id, string relationshipName) => throw new NotImplementedException(); + public Task UpdateAsync(Guid id, GuidResource entity) => throw new NotImplementedException(); + public Task UpdateRelationshipsAsync(Guid id, string relationshipName, List relationships) => throw new NotImplementedException(); + } } } From 550dcdd91a7ec36605f1933d91ba49e5273f1849 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 22 Sep 2018 20:21:24 -0700 Subject: [PATCH 63/74] add getting started project --- JsonApiDotnetCore.sln | 17 ++++++++- src/Examples/GettingStarted/.gitignore | 1 + .../Controllers/ArticlesController.cs | 17 +++++++++ .../Controllers/PeopleController.cs | 17 +++++++++ .../GettingStarted/Data/SampleDbContext.cs | 15 ++++++++ .../GettingStarted/GettingStarted.csproj | 21 ++++++++++ src/Examples/GettingStarted/Models/Article.cs | 14 +++++++ src/Examples/GettingStarted/Models/Person.cs | 14 +++++++ src/Examples/GettingStarted/Program.cs | 26 +++++++++++++ src/Examples/GettingStarted/README.md | 14 +++++++ src/Examples/GettingStarted/Startup.cs | 38 +++++++++++++++++++ .../IServiceCollectionExtensions.cs | 3 +- 12 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 src/Examples/GettingStarted/.gitignore create mode 100644 src/Examples/GettingStarted/Controllers/ArticlesController.cs create mode 100644 src/Examples/GettingStarted/Controllers/PeopleController.cs create mode 100644 src/Examples/GettingStarted/Data/SampleDbContext.cs create mode 100644 src/Examples/GettingStarted/GettingStarted.csproj create mode 100644 src/Examples/GettingStarted/Models/Article.cs create mode 100644 src/Examples/GettingStarted/Models/Person.cs create mode 100644 src/Examples/GettingStarted/Program.cs create mode 100644 src/Examples/GettingStarted/README.md create mode 100644 src/Examples/GettingStarted/Startup.cs diff --git a/JsonApiDotnetCore.sln b/JsonApiDotnetCore.sln index ce0219b1c8..bc0c4c5ec6 100644 --- a/JsonApiDotnetCore.sln +++ b/JsonApiDotnetCore.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27130.2010 MinimumVisualStudioVersion = 10.0.40219.1 @@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResourceEntitySeparationExa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResourceEntitySeparationExampleTests", "test\ResourceEntitySeparationExampleTests\ResourceEntitySeparationExampleTests.csproj", "{6DFA30D7-1679-4333-9779-6FB678E48EF5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -187,6 +189,18 @@ Global {6DFA30D7-1679-4333-9779-6FB678E48EF5}.Release|x64.Build.0 = Release|Any CPU {6DFA30D7-1679-4333-9779-6FB678E48EF5}.Release|x86.ActiveCfg = Release|Any CPU {6DFA30D7-1679-4333-9779-6FB678E48EF5}.Release|x86.Build.0 = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x64.Build.0 = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x86.Build.0 = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|Any CPU.Build.0 = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x64.ActiveCfg = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x64.Build.0 = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x86.ActiveCfg = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -205,6 +219,7 @@ Global {9CD2C116-D133-4FE4-97DA-A9FEAFF045F1} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {F4097194-9415-418A-AB4E-315C5D5466AF} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {6DFA30D7-1679-4333-9779-6FB678E48EF5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71} = {026FBC6C-AF76-4568-9B87-EC73457899FD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/GettingStarted/.gitignore b/src/Examples/GettingStarted/.gitignore new file mode 100644 index 0000000000..3997beadf8 --- /dev/null +++ b/src/Examples/GettingStarted/.gitignore @@ -0,0 +1 @@ +*.db \ No newline at end of file diff --git a/src/Examples/GettingStarted/Controllers/ArticlesController.cs b/src/Examples/GettingStarted/Controllers/ArticlesController.cs new file mode 100644 index 0000000000..248f5ffcb3 --- /dev/null +++ b/src/Examples/GettingStarted/Controllers/ArticlesController.cs @@ -0,0 +1,17 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace GettingStarted +{ + public class ArticlesController : JsonApiController
+ { + public ArticlesController( + IJsonApiContext jsonApiContext, + IResourceService
resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs new file mode 100644 index 0000000000..b33f89effb --- /dev/null +++ b/src/Examples/GettingStarted/Controllers/PeopleController.cs @@ -0,0 +1,17 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace GettingStarted +{ + public class PeopleController : JsonApiController + { + public PeopleController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs new file mode 100644 index 0000000000..770999c16a --- /dev/null +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -0,0 +1,15 @@ +using GettingStarted.Models; +using Microsoft.EntityFrameworkCore; + +namespace GettingStarted +{ + public class SampleDbContext : DbContext + { + public SampleDbContext(DbContextOptions options) + : base(options) + { } + + public DbSet
Articles { get; set; } + public DbSet People { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj new file mode 100644 index 0000000000..6e00daefae --- /dev/null +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.0 + + + + + + + + + + + + + + + + + diff --git a/src/Examples/GettingStarted/Models/Article.cs b/src/Examples/GettingStarted/Models/Article.cs new file mode 100644 index 0000000000..6835235be8 --- /dev/null +++ b/src/Examples/GettingStarted/Models/Article.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Models; + +namespace GettingStarted.Models +{ + public class Article : Identifiable + { + [Attr("title")] + public string Title { get; set; } + + [HasOne("author")] + public Person Author { get; set; } + public int AuthorId { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs new file mode 100644 index 0000000000..1d5c79de8e --- /dev/null +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace GettingStarted.Models +{ + public class Person : Identifiable + { + [Attr("name")] + public string Name { get; set; } + + [HasMany("articles")] + public List
Articles { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs new file mode 100644 index 0000000000..fdc5046542 --- /dev/null +++ b/src/Examples/GettingStarted/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace GettingStarted +{ + public class Program + { + public static void Main(string[] args) + { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup() + .UseUrls("http://localhost:5001") + .Build(); + } +} diff --git a/src/Examples/GettingStarted/README.md b/src/Examples/GettingStarted/README.md new file mode 100644 index 0000000000..d2c91c6d6a --- /dev/null +++ b/src/Examples/GettingStarted/README.md @@ -0,0 +1,14 @@ +## Sample project + +## Usage + +`dotnet run` to run the project + +You can verify the project is running by checking this endpoint: +`localhost:5001/api/sample-model` + +For further documentation and implementation of a JsonApiDotnetCore Application see the documentation or GitHub page: + +Repository: https://github.com/json-api-dotnet/JsonApiDotNetCore + +Documentation: https://json-api-dotnet.github.io/ \ No newline at end of file diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs new file mode 100644 index 0000000000..5d0fa8dc91 --- /dev/null +++ b/src/Examples/GettingStarted/Startup.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Extensions; + +namespace GettingStarted +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(options => + { + options.UseSqlite("Data Source=sample.db"); + }); + + var mvcCoreBuilder = services.AddMvcCore(); + services.AddJsonApi( + options => options.Namespace = "api", + mvcCoreBuilder, + discover => discover.AddCurrentAssembly()); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env, SampleDbContext context) + { + context.Database.EnsureDeleted(); // indicies need to be reset + context.Database.EnsureCreated(); + + app.UseJsonApi(); + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 9c1df0626e..33d91ffe96 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Builders; @@ -45,9 +46,7 @@ public static IServiceCollection AddJsonApi( IMvcCoreBuilder mvcBuilder) where TContext : DbContext { var config = new JsonApiOptions(); - options(config); - config.BuildContextGraph(builder => builder.AddDbContext()); mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, config)); From b0ab4dd159582f7197619de32b59241deccb0381 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 22 Sep 2018 21:07:41 -0700 Subject: [PATCH 64/74] optional attribute and relationship names --- .../Controllers/ArticlesController.cs | 6 ++-- .../Controllers/PeopleController.cs | 6 ++-- src/Examples/GettingStarted/Models/Article.cs | 4 +-- src/Examples/GettingStarted/Models/Person.cs | 4 +-- .../Builders/ContextGraphBuilder.cs | 7 +++-- .../Configuration/JsonApiOptions.cs | 6 ++++ .../Graph/IResourceNameFormatter.cs | 22 ++++++++++++++ .../Graph/ServiceDiscoveryFacade.cs | 30 ++++++++----------- src/JsonApiDotNetCore/Models/AttrAttribute.cs | 6 ++-- .../Models/HasManyAttribute.cs | 2 +- .../Models/HasOneAttribute.cs | 2 +- .../Models/RelationshipAttribute.cs | 2 +- 12 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/Examples/GettingStarted/Controllers/ArticlesController.cs b/src/Examples/GettingStarted/Controllers/ArticlesController.cs index 248f5ffcb3..53517540b1 100644 --- a/src/Examples/GettingStarted/Controllers/ArticlesController.cs +++ b/src/Examples/GettingStarted/Controllers/ArticlesController.cs @@ -1,7 +1,6 @@ using GettingStarted.Models; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; namespace GettingStarted { @@ -9,9 +8,8 @@ public class ArticlesController : JsonApiController
{ public ArticlesController( IJsonApiContext jsonApiContext, - IResourceService
resourceService, - ILoggerFactory loggerFactory) - : base(jsonApiContext, resourceService, loggerFactory) + IResourceService
resourceService) + : base(jsonApiContext, resourceService) { } } } \ No newline at end of file diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs index b33f89effb..f3c0c4b868 100644 --- a/src/Examples/GettingStarted/Controllers/PeopleController.cs +++ b/src/Examples/GettingStarted/Controllers/PeopleController.cs @@ -1,7 +1,6 @@ using GettingStarted.Models; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; namespace GettingStarted { @@ -9,9 +8,8 @@ public class PeopleController : JsonApiController { public PeopleController( IJsonApiContext jsonApiContext, - IResourceService resourceService, - ILoggerFactory loggerFactory) - : base(jsonApiContext, resourceService, loggerFactory) + IResourceService resourceService) + : base(jsonApiContext, resourceService) { } } } \ No newline at end of file diff --git a/src/Examples/GettingStarted/Models/Article.cs b/src/Examples/GettingStarted/Models/Article.cs index 6835235be8..68cecf060d 100644 --- a/src/Examples/GettingStarted/Models/Article.cs +++ b/src/Examples/GettingStarted/Models/Article.cs @@ -4,10 +4,10 @@ namespace GettingStarted.Models { public class Article : Identifiable { - [Attr("title")] + [Attr] public string Title { get; set; } - [HasOne("author")] + [HasOne] public Person Author { get; set; } public int AuthorId { get; set; } } diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 1d5c79de8e..625cf26ab6 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -5,10 +5,10 @@ namespace GettingStarted.Models { public class Person : Identifiable { - [Attr("name")] + [Attr] public string Name { get; set; } - [HasMany("articles")] + [HasMany] public List
Articles { get; set; } } } \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index ba71dbce06..7d2c6e0446 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; @@ -64,9 +65,8 @@ public class ContextGraphBuilder : IContextGraphBuilder { private List _entities = new List(); private List _validationResults = new List(); - private bool _usesDbContext; - private IResourceNameFormatter _resourceNameFormatter = new DefaultResourceNameFormatter(); + private IResourceNameFormatter _resourceNameFormatter = JsonApiOptions.ResourceNameFormatter; public Link DocumentLinks { get; set; } = Link.All; @@ -128,6 +128,7 @@ protected virtual List GetAttributes(Type entityType) if (attribute == null) continue; + attribute.PublicAttributeName = attribute.PublicAttributeName ?? JsonApiOptions.ResourceNameFormatter.FormatPropertyName(prop); attribute.InternalAttributeName = prop.Name; attribute.PropertyInfo = prop; @@ -146,6 +147,8 @@ protected virtual List GetRelationships(Type entityType) { var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); if (attribute == null) continue; + + attribute.PublicRelationshipName = attribute.PublicRelationshipName ?? JsonApiOptions.ResourceNameFormatter.FormatPropertyName(prop); attribute.InternalRelationshipName = prop.Name; attribute.Type = GetRelationshipType(attribute, prop); attributes.Add(attribute); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 54fcf5afaf..66386aac19 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; @@ -16,6 +17,11 @@ namespace JsonApiDotNetCore.Configuration /// public class JsonApiOptions { + /// + /// Provides an interface for formatting resource names by convention + /// + public static IResourceNameFormatter ResourceNameFormatter { get; set; } = new DefaultResourceNameFormatter(); + /// /// Whether or not stack traces should be serialized in Error objects /// diff --git a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs index 57baca0901..8f5bdf3bb2 100644 --- a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs @@ -17,6 +17,11 @@ public interface IResourceNameFormatter /// Get the publicly visible resource name from the internal type name /// string FormatResourceName(Type resourceType); + + /// + /// Get the publicly visible name for the given property + /// + string FormatPropertyName(PropertyInfo property); } public class DefaultResourceNameFormatter : IResourceNameFormatter @@ -47,5 +52,22 @@ public string FormatResourceName(Type type) throw new InvalidOperationException($"Cannot define multiple {nameof(ResourceAttribute)}s on type '{type}'.", e); } } + + /// + /// Uses the internal PropertyInfo to determine the external resource name. + /// By default the name will be formatted to kebab-case. + /// + /// + /// Given the following property: + /// + /// public string CompoundProperty { get; set; } + /// + /// The public attribute will be formatted like so: + /// + /// _default.FormatPropertyName(compoundProperty).Dump(); + /// // > "compound-property" + /// + /// + public string FormatPropertyName(PropertyInfo property) => str.Dasherize(property.Name); } } diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index f403f69124..52040bc563 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -55,23 +56,20 @@ public ServiceDiscoveryFacade( /// /// Add resources, services and repository implementations to the container. /// - /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddCurrentAssembly(IResourceNameFormatter resourceNameFormatter = null) - => AddAssembly(Assembly.GetCallingAssembly(), resourceNameFormatter); + public ServiceDiscoveryFacade AddCurrentAssembly() => AddAssembly(Assembly.GetCallingAssembly()); /// /// Add resources, services and repository implementations to the container. /// /// The assembly to search for resources in. - /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddAssembly(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + public ServiceDiscoveryFacade AddAssembly(Assembly assembly) { AddDbContextResolvers(assembly); var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); foreach (var resourceDescriptor in resourceDescriptors) { - AddResource(assembly, resourceDescriptor, resourceNameFormatter); + AddResource(assembly, resourceDescriptor); AddServices(assembly, resourceDescriptor); AddRepositories(assembly, resourceDescriptor); } @@ -93,20 +91,19 @@ private void AddDbContextResolvers(Assembly assembly) /// Adds resources to the graph and registers types on the container. /// /// The assembly to search for resources in. - /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddResources(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + public ServiceDiscoveryFacade AddResources(Assembly assembly) { var identifiables = TypeLocator.GetIdentifableTypes(assembly); foreach (var identifiable in identifiables) - AddResource(assembly, identifiable, resourceNameFormatter); + AddResource(assembly, identifiable); return this; } - private void AddResource(Assembly assembly, ResourceDescriptor resourceDescriptor, IResourceNameFormatter resourceNameFormatter = null) + private void AddResource(Assembly assembly, ResourceDescriptor resourceDescriptor) { RegisterResourceDefinition(assembly, resourceDescriptor); - AddResourceToGraph(resourceDescriptor, resourceNameFormatter); + AddResourceToGraph(resourceDescriptor); } private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) @@ -125,17 +122,14 @@ private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor id } } - private void AddResourceToGraph(ResourceDescriptor identifiable, IResourceNameFormatter resourceNameFormatter = null) + private void AddResourceToGraph(ResourceDescriptor identifiable) { - var resourceName = FormatResourceName(identifiable.ResourceType, resourceNameFormatter); + var resourceName = FormatResourceName(identifiable.ResourceType); _graphBuilder.AddResource(identifiable.ResourceType, identifiable.IdType, resourceName); } - private string FormatResourceName(Type resourceType, IResourceNameFormatter resourceNameFormatter) - { - resourceNameFormatter = resourceNameFormatter ?? new DefaultResourceNameFormatter(); - return resourceNameFormatter.FormatResourceName(resourceType); - } + private string FormatResourceName(Type resourceType) + => JsonApiOptions.ResourceNameFormatter.FormatResourceName(resourceType); /// /// Add implementations to container. diff --git a/src/JsonApiDotNetCore/Models/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/AttrAttribute.cs index d5a30221bb..a5e594ea0c 100644 --- a/src/JsonApiDotNetCore/Models/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/AttrAttribute.cs @@ -26,7 +26,7 @@ public class AttrAttribute : Attribute /// /// /// - public AttrAttribute(string publicName, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) + public AttrAttribute(string publicName = null, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) { PublicAttributeName = publicName; IsImmutable = isImmutable; @@ -34,7 +34,7 @@ public AttrAttribute(string publicName, bool isImmutable = false, bool isFiltera IsSortable = isSortable; } - public AttrAttribute(string publicName, string internalName, bool isImmutable = false) + internal AttrAttribute(string publicName, string internalName, bool isImmutable = false) { PublicAttributeName = publicName; InternalAttributeName = internalName; @@ -44,7 +44,7 @@ public AttrAttribute(string publicName, string internalName, bool isImmutable = /// /// How this attribute is exposed through the API /// - public string PublicAttributeName { get; } + public string PublicAttributeName { get; internal set;} /// /// The internal property name this attribute belongs to. diff --git a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs index 5bbea86783..11479819f4 100644 --- a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs @@ -23,7 +23,7 @@ public class HasManyAttribute : RelationshipAttribute /// /// /// - public HasManyAttribute(string publicName, Link documentLinks = Link.All, bool canInclude = true) + public HasManyAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true) : base(publicName, documentLinks, canInclude) { } diff --git a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs index a80734817d..2d83c3dd69 100644 --- a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs @@ -26,7 +26,7 @@ public class HasOneAttribute : RelationshipAttribute /// /// /// - public HasOneAttribute(string publicName, Link documentLinks = Link.All, bool canInclude = true, string withForeignKey = null) + public HasOneAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true, string withForeignKey = null) : base(publicName, documentLinks, canInclude) { _explicitIdentifiablePropertyName = withForeignKey; diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index 7408df0998..b479d3bb12 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -11,7 +11,7 @@ protected RelationshipAttribute(string publicName, Link documentLinks, bool canI CanInclude = canInclude; } - public string PublicRelationshipName { get; } + public string PublicRelationshipName { get; internal set; } public string InternalRelationshipName { get; internal set; } /// From f740f25d5bb3c15820ff84cfa4d523498f2afc7c Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 22 Sep 2018 21:09:43 -0700 Subject: [PATCH 65/74] build getting started in build.ps1 --- Build.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Build.ps1 b/Build.ps1 index 8ff62d6578..ee058a412d 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -23,6 +23,9 @@ $revision = "{0:D4}" -f [convert]::ToInt32($revision, 10) dotnet restore +dotnet build ./src/Examples/GettingStarted/GettingStarted.csproj +CheckLastExitCode + dotnet test ./test/UnitTests/UnitTests.csproj CheckLastExitCode @@ -35,7 +38,7 @@ CheckLastExitCode dotnet test ./test/OperationsExampleTests/OperationsExampleTests.csproj CheckLastExitCode -dotnet build .\src\JsonApiDotNetCore -c Release +dotnet build .src\JsonApiDotNetCore -c Release CheckLastExitCode Write-Output "APPVEYOR_REPO_TAG: $env:APPVEYOR_REPO_TAG" From 66470a94b4cb67a673a6d0d6edcfceb6e8b4e76d Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sat, 22 Sep 2018 21:12:30 -0700 Subject: [PATCH 66/74] fix build script --- Build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Build.ps1 b/Build.ps1 index ee058a412d..94140b05c2 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -38,7 +38,7 @@ CheckLastExitCode dotnet test ./test/OperationsExampleTests/OperationsExampleTests.csproj CheckLastExitCode -dotnet build .src\JsonApiDotNetCore -c Release +dotnet build ./src/JsonApiDotNetCore/JsonApiDotNetCore.csproj -c Release CheckLastExitCode Write-Output "APPVEYOR_REPO_TAG: $env:APPVEYOR_REPO_TAG" From 529a56286e10f946c7c8d0c47f46b72b6a9c82a7 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sun, 23 Sep 2018 14:36:14 -0700 Subject: [PATCH 67/74] add DiscoveryTests --- Build.ps1 | 3 + JsonApiDotnetCore.sln | 15 ++++ .../GettingStarted/Data/SampleDbContext.cs | 2 + .../ResourceDefinitionExample/Model.cs | 10 +++ .../ModelDefinition.cs | 16 ++++ .../ModelsController.cs | 15 ++++ .../Data/DefaultEntityRepository.cs | 18 +++- .../Graph/ServiceDiscoveryFacade.cs | 6 +- .../Models/ResourceDefinition.cs | 16 +++- .../Services/EntityResourceService.cs | 8 +- test/DiscoveryTests/DiscoveryTests.csproj | 19 ++++ .../ServiceDiscoveryFacadeTests.cs | 89 +++++++++++++++++++ 12 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs create mode 100644 src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs create mode 100644 src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs create mode 100644 test/DiscoveryTests/DiscoveryTests.csproj create mode 100644 test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs diff --git a/Build.ps1 b/Build.ps1 index 94140b05c2..9e1f94b2de 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -38,6 +38,9 @@ CheckLastExitCode dotnet test ./test/OperationsExampleTests/OperationsExampleTests.csproj CheckLastExitCode +dotnet test ./test/DiscoveryTests/DiscoveryTests.csproj +CheckLastExitCode + dotnet build ./src/JsonApiDotNetCore/JsonApiDotNetCore.csproj -c Release CheckLastExitCode diff --git a/JsonApiDotnetCore.sln b/JsonApiDotnetCore.sln index bc0c4c5ec6..d046b23819 100644 --- a/JsonApiDotnetCore.sln +++ b/JsonApiDotnetCore.sln @@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResourceEntitySeparationExa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscoveryTests", "test\DiscoveryTests\DiscoveryTests.csproj", "{09C0C8D8-B721-4955-8889-55CB149C3B5C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -201,6 +203,18 @@ Global {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x64.Build.0 = Release|Any CPU {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x86.ActiveCfg = Release|Any CPU {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x86.Build.0 = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x64.ActiveCfg = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x64.Build.0 = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x86.ActiveCfg = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x86.Build.0 = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|Any CPU.Build.0 = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x64.ActiveCfg = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x64.Build.0 = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x86.ActiveCfg = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -220,6 +234,7 @@ Global {F4097194-9415-418A-AB4E-315C5D5466AF} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {6DFA30D7-1679-4333-9779-6FB678E48EF5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {09C0C8D8-B721-4955-8889-55CB149C3B5C} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index 770999c16a..2f8fefb405 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -1,4 +1,5 @@ using GettingStarted.Models; +using GettingStarted.ResourceDefinitionExample; using Microsoft.EntityFrameworkCore; namespace GettingStarted @@ -11,5 +12,6 @@ public SampleDbContext(DbContextOptions options) public DbSet
Articles { get; set; } public DbSet People { get; set; } + public DbSet Models { get; set; } } } \ No newline at end of file diff --git a/src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs b/src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs new file mode 100644 index 0000000000..0bee86efe0 --- /dev/null +++ b/src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs @@ -0,0 +1,10 @@ +using JsonApiDotNetCore.Models; + +namespace GettingStarted.ResourceDefinitionExample +{ + public class Model : Identifiable + { + [Attr] + public string DontExpose { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs new file mode 100644 index 0000000000..fc41350664 --- /dev/null +++ b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace GettingStarted.ResourceDefinitionExample +{ + public class ModelDefinition : ResourceDefinition + { + // this allows POST / PATCH requests to set the value of a + // property, but we don't include this value in the response + // this might be used if the incoming value gets hashed or + // encrypted prior to being persisted and this value should + // never be sent back to the client + protected override List OutputAttrs() + => Remove(model => model.DontExpose); + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs new file mode 100644 index 0000000000..a14394e830 --- /dev/null +++ b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs @@ -0,0 +1,15 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; + +namespace GettingStarted.ResourceDefinitionExample +{ + public class ModelsController : JsonApiController + { + public ModelsController( + IJsonApiContext jsonApiContext, + IResourceService resourceService) + : base(jsonApiContext, resourceService) + { } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 512f45fe3c..68462ff4df 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -19,6 +19,12 @@ public class DefaultEntityRepository IEntityRepository where TEntity : class, IIdentifiable { + public DefaultEntityRepository( + IJsonApiContext jsonApiContext, + IDbContextResolver contextResolver) + : base(jsonApiContext, contextResolver) + { } + public DefaultEntityRepository( ILoggerFactory loggerFactory, IJsonApiContext jsonApiContext, @@ -42,6 +48,16 @@ public class DefaultEntityRepository private readonly IJsonApiContext _jsonApiContext; private readonly IGenericProcessorFactory _genericProcessorFactory; + public DefaultEntityRepository( + IJsonApiContext jsonApiContext, + IDbContextResolver contextResolver) + { + _context = contextResolver.GetContext(); + _dbSet = contextResolver.GetDbSet(); + _jsonApiContext = jsonApiContext; + _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; + } + public DefaultEntityRepository( ILoggerFactory loggerFactory, IJsonApiContext jsonApiContext, @@ -84,7 +100,7 @@ public virtual async Task GetAsync(TId id) /// public virtual async Task GetAndIncludeAsync(TId id, string relationshipName) { - _logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); + _logger?.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); var includedSet = Include(Get(), relationshipName); var result = await includedSet.SingleOrDefaultAsync(e => e.Id.Equals(id)); diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index 52040bc563..8c6ae19cf0 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -171,7 +171,11 @@ private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescr private void RegisterServiceImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) { - var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, resourceDescriptor.ResourceType, resourceDescriptor.IdType); + var genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 + ? new [] { resourceDescriptor.ResourceType, resourceDescriptor.IdType } + : new [] { resourceDescriptor.ResourceType }; + + var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); if (service.implementation != null) _services.AddScoped(service.registrationInterface, service.implementation); } diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 64ff918116..6033a7b856 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -64,7 +64,7 @@ protected List Remove(Expression> filter, List(); foreach (var attr in _contextEntity.Attributes) if (newExpression.Members.Any(m => m.Name == attr.InternalAttributeName) == false) - attributes.Add(attr); + attributes.Add(attr); return attributes; } @@ -76,12 +76,24 @@ protected List Remove(Expression> filter, List + /// Allows POST / PATCH requests to set the value of an + /// attribute, but exclude the attribute in the response + /// this might be used if the incoming value gets hashed or + /// encrypted prior to being persisted and this value should + /// never be sent back to the client. + /// /// Called once per filtered resource in request. ///
protected virtual List OutputAttrs() => _contextEntity.Attributes; /// - /// Called for every instance of a resource + /// Allows POST / PATCH requests to set the value of an + /// attribute, but exclude the attribute in the response + /// this might be used if the incoming value gets hashed or + /// encrypted prior to being persisted and this value should + /// never be sent back to the client. + /// + /// Called for every instance of a resource. /// protected virtual List OutputAttrs(T instance) => _contextEntity.Attributes; diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 1c8dabb74b..1e175bfeb0 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -16,7 +16,7 @@ public class EntityResourceService : EntityResourceService entityRepository, - ILoggerFactory loggerFactory) : + ILoggerFactory loggerFactory = null) : base(jsonApiContext, entityRepository, loggerFactory) { } } @@ -28,7 +28,7 @@ public class EntityResourceService : EntityResourceService entityRepository, - ILoggerFactory loggerFactory) : + ILoggerFactory loggerFactory = null) : base(jsonApiContext, entityRepository, loggerFactory) { } } @@ -46,7 +46,7 @@ public class EntityResourceService : public EntityResourceService( IJsonApiContext jsonApiContext, IEntityRepository entityRepository, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory = null) { // no mapper provided, TResource & TEntity must be the same type if (typeof(TResource) != typeof(TEntity)) @@ -56,7 +56,7 @@ public EntityResourceService( _jsonApiContext = jsonApiContext; _entities = entityRepository; - _logger = loggerFactory.CreateLogger>(); + _logger = loggerFactory?.CreateLogger>(); } public EntityResourceService( diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj new file mode 100644 index 0000000000..2846b365e5 --- /dev/null +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -0,0 +1,19 @@ + + + + $(NetCoreAppVersion) + false + + + + + + + + + + + + + + diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs new file mode 100644 index 0000000000..433d23557b --- /dev/null +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -0,0 +1,89 @@ +using System; +using GettingStarted.Models; +using GettingStarted.ResourceDefinitionExample; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace DiscoveryTests +{ + public class ServiceDiscoveryFacadeTests + { + private readonly IServiceCollection _services = new ServiceCollection(); + private readonly ContextGraphBuilder _graphBuilder = new ContextGraphBuilder(); + private ServiceDiscoveryFacade _facade => new ServiceDiscoveryFacade(_services, _graphBuilder); + + [Fact] + public void AddAssembly_Adds_All_Resources_To_Graph() + { + // arrange, act + _facade.AddAssembly(typeof(Person).Assembly); + + // assert + var graph = _graphBuilder.Build(); + var personResource = graph.GetContextEntity(typeof(Person)); + var articleResource = graph.GetContextEntity(typeof(Article)); + var modelResource = graph.GetContextEntity(typeof(Model)); + + Assert.NotNull(personResource); + Assert.NotNull(articleResource); + Assert.NotNull(modelResource); + } + + [Fact] + public void AddCurrentAssembly_Adds_Resources_To_Graph() + { + // arrange, act + _facade.AddCurrentAssembly(); + + // assert + var graph = _graphBuilder.Build(); + var testModelResource = graph.GetContextEntity(typeof(TestModel)); + Assert.NotNull(testModelResource); + } + + [Fact] + public void AddCurrentAssembly_Adds_Services_To_Container() + { + // arrange, act + _facade.AddCurrentAssembly(); + + // assert + var services = _services.BuildServiceProvider(); + Assert.IsType(services.GetService>()); + } + + [Fact] + public void AddCurrentAssembly_Adds_Repositories_To_Container() + { + // arrange, act + _facade.AddCurrentAssembly(); + + // assert + var services = _services.BuildServiceProvider(); + Assert.IsType(services.GetService>()); + } + + public class TestModel : Identifiable { } + + public class TestModelService : EntityResourceService + { + private static IEntityRepository _repo = new Mock>().Object; + private static IJsonApiContext _jsonApiContext = new Mock().Object; + public TestModelService() : base(_jsonApiContext, _repo) { } + } + + public class TestModelRepository : DefaultEntityRepository + { + private static IDbContextResolver _dbContextResolver = new Mock().Object; + private static IJsonApiContext _jsonApiContext = new Mock().Object; + public TestModelRepository() : base(_jsonApiContext, _dbContextResolver) { } + } + } +} From 453d4fc704b93c3843946ac7fba891cbf3a40709 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sun, 23 Sep 2018 15:25:17 -0700 Subject: [PATCH 68/74] fix tests --- .../IServiceCollectionExtensions.cs | 25 +++++++---- .../Graph/ServiceDiscoveryFacade.cs | 8 +++- .../IServiceCollectionExtensionsTests.cs | 42 +++++++++---------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 33d91ffe96..74168c8b0d 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; @@ -186,10 +187,12 @@ public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions js } /// + /// Adds all required registrations for the service to the container /// + /// public static IServiceCollection AddResourceService(this IServiceCollection services) { - var typeImplemenetsAnExpectedInterface = false; + var typeImplementsAnExpectedInterface = false; var serviceImplementationType = typeof(T); @@ -200,19 +203,25 @@ public static IServiceCollection AddResourceService(this IServiceCollection s { foreach(var openGenericType in ServiceDiscoveryFacade.ServiceInterfaces) { - var concreteGenericType = openGenericType.GetGenericArguments().Length == 1 + // A shorthand interface is one where the id type is ommitted + // e.g. IResourceService is the shorthand for IResourceService + var isShorthandInterface = (openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1); + if(isShorthandInterface && resourceDescriptor.IdType != typeof(int)) + continue; // we can't create a shorthand for id types other than int + + var concreteGenericType = isShorthandInterface ? openGenericType.MakeGenericType(resourceDescriptor.ResourceType) : openGenericType.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); if(concreteGenericType.IsAssignableFrom(serviceImplementationType)) { - services.AddScoped(serviceImplementationType, serviceImplementationType); - typeImplemenetsAnExpectedInterface = true; + services.AddScoped(concreteGenericType, serviceImplementationType); + typeImplementsAnExpectedInterface = true; } } } - if(typeImplemenetsAnExpectedInterface == false) - throw new JsonApiSetupException($"{typeImplemenetsAnExpectedInterface} does not implement any of the expected JsonApiDotNetCore interfaces."); + if(typeImplementsAnExpectedInterface == false) + throw new JsonApiSetupException($"{serviceImplementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); return services; } @@ -225,8 +234,8 @@ private static HashSet GetResourceTypesFromServiceImplementa { if(i.IsGenericType) { - var firstGenericArgument = i.GetGenericTypeDefinition().GetGenericArguments().FirstOrDefault(); - if(TypeLocator.TryGetResourceDescriptor(firstGenericArgument, out var resourceDescriptor) == false) + var firstGenericArgument = i.GenericTypeArguments.FirstOrDefault(); + if(TypeLocator.TryGetResourceDescriptor(firstGenericArgument, out var resourceDescriptor) == true) { resourceDecriptors.Add(resourceDescriptor); } diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index 8c6ae19cf0..f8a1300c7f 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -17,7 +17,11 @@ public class ServiceDiscoveryFacade { internal static HashSet ServiceInterfaces = new HashSet { typeof(IResourceService<>), - typeof(IResourceService<,>), + typeof(IResourceService<,>), + typeof(IResourceCmdService<>), + typeof(IResourceCmdService<,>), + typeof(IResourceQueryService<>), + typeof(IResourceQueryService<,>), typeof(ICreateService<>), typeof(ICreateService<,>), typeof(IGetAllService<>), @@ -26,6 +30,8 @@ public class ServiceDiscoveryFacade typeof(IGetByIdService<,>), typeof(IGetRelationshipService<>), typeof(IGetRelationshipService<,>), + typeof(IGetRelationshipsService<>), + typeof(IGetRelationshipsService<,>), typeof(IUpdateService<>), typeof(IUpdateService<,>), typeof(IDeleteService<>), diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 0dc7808b24..f48821e756 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -67,16 +67,16 @@ public void AddResourceService_Registers_All_Shorthand_Service_Interfaces() // assert var provider = services.BuildServiceProvider(); - Assert.IsType(typeof(IResourceService)); - Assert.IsType(typeof(IResourceCmdService)); - Assert.IsType(typeof(IResourceQueryService)); - Assert.IsType(typeof(IGetAllService)); - Assert.IsType(typeof(IGetByIdService)); - Assert.IsType(typeof(IGetRelationshipService)); - Assert.IsType(typeof(IGetRelationshipsService)); - Assert.IsType(typeof(ICreateService)); - Assert.IsType(typeof(IUpdateService)); - Assert.IsType(typeof(IDeleteService)); + Assert.IsType(provider.GetService(typeof(IResourceService))); + Assert.IsType(provider.GetService(typeof(IResourceCmdService))); + Assert.IsType(provider.GetService(typeof(IResourceQueryService))); + Assert.IsType(provider.GetService(typeof(IGetAllService))); + Assert.IsType(provider.GetService(typeof(IGetByIdService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipsService))); + Assert.IsType(provider.GetService(typeof(ICreateService))); + Assert.IsType(provider.GetService(typeof(IUpdateService))); + Assert.IsType(provider.GetService(typeof(IDeleteService))); } [Fact] @@ -90,16 +90,16 @@ public void AddResourceService_Registers_All_LongForm_Service_Interfaces() // assert var provider = services.BuildServiceProvider(); - Assert.IsType(typeof(IResourceService)); - Assert.IsType(typeof(IResourceCmdService)); - Assert.IsType(typeof(IResourceQueryService)); - Assert.IsType(typeof(IGetAllService)); - Assert.IsType(typeof(IGetByIdService)); - Assert.IsType(typeof(IGetRelationshipService)); - Assert.IsType(typeof(IGetRelationshipsService)); - Assert.IsType(typeof(ICreateService)); - Assert.IsType(typeof(IUpdateService)); - Assert.IsType(typeof(IDeleteService)); + Assert.IsType(provider.GetService(typeof(IResourceService))); + Assert.IsType(provider.GetService(typeof(IResourceCmdService))); + Assert.IsType(provider.GetService(typeof(IResourceQueryService))); + Assert.IsType(provider.GetService(typeof(IGetAllService))); + Assert.IsType(provider.GetService(typeof(IGetByIdService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipsService))); + Assert.IsType(provider.GetService(typeof(ICreateService))); + Assert.IsType(provider.GetService(typeof(IUpdateService))); + Assert.IsType(provider.GetService(typeof(IDeleteService))); } [Fact] @@ -109,7 +109,7 @@ public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces( var services = new ServiceCollection(); // act, assert - Assert.Throws(() => services.AddResourceService()); + Assert.Throws(() => services.AddResourceService()); } private class IntResource : Identifiable { } From 02f98c7017fd5870835a485882161eb41f960ad4 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sun, 23 Sep 2018 15:45:45 -0700 Subject: [PATCH 69/74] add ContextGraphBuilder tests --- .../Builders/ContextGraphBuilder.cs | 32 +++++++++++----- .../Builders/ContextGraphBuilder_Tests.cs | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index 7d2c6e0446..b343243463 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -23,24 +23,36 @@ public interface IContextGraphBuilder /// Add a json:api resource ///
/// The resource model type - /// The pluralized name that should be exposed by the API - IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable; + /// + /// The pluralized name that should be exposed by the API. + /// If nothing is specified, the configured name formatter will be used. + /// See . + /// + IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable; /// /// Add a json:api resource /// /// The resource model type /// The resource model identifier type - /// The pluralized name that should be exposed by the API - IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable; + /// + /// The pluralized name that should be exposed by the API. + /// If nothing is specified, the configured name formatter will be used. + /// See . + /// + IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable; /// /// Add a json:api resource /// /// The resource model type /// The resource model identifier type - /// The pluralized name that should be exposed by the API - IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName); + /// + /// The pluralized name that should be exposed by the API. + /// If nothing is specified, the configured name formatter will be used. + /// See . + /// + IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName = null); /// /// Add all the models that are part of the provided @@ -80,18 +92,20 @@ public IContextGraph Build() } /// - public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable + public IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable => AddResource(pluralizedTypeName); /// - public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable + public IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable => AddResource(typeof(TResource), typeof(TId), pluralizedTypeName); /// - public IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName) + public IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName = null) { AssertEntityIsNotAlreadyDefined(entityType); + pluralizedTypeName = pluralizedTypeName ?? _resourceNameFormatter.FormatResourceName(entityType); + _entities.Add(GetEntity(pluralizedTypeName, entityType, idType)); return this; diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index d5207fb6ef..a7bacd2dd6 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -1,3 +1,5 @@ +using System.Linq; +using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -37,5 +39,40 @@ public void Can_Build_ContextGraph_Using_Builder() Assert.Equal(typeof(NonDbResource), nonDbResource.EntityType); Assert.Equal(typeof(ResourceDefinition), nonDbResource.ResourceType); } + + [Fact] + public void Resources_Without_Names_Specified_Will_Use_Default_Formatter() + { + // arrange + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("test-resources", resource.EntityName); + } + + [Fact] + public void Attrs_Without_Names_Specified_Will_Use_Default_Formatter() + { + // arrange + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("attribute", resource.Attributes.Single().PublicAttributeName); + } + + public class TestResource : Identifiable + { + [Attr] public string Attribute { get; set; } + } } } From be0d5109bf325f0c09df1e8d2c1181cb51105e3f Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sun, 23 Sep 2018 20:49:58 -0700 Subject: [PATCH 70/74] add tests --- .../Builders/ContextGraphBuilder_Tests.cs | 77 ++++++++++++++++++- test/UnitTests/Graph/TypeLocator_Tests.cs | 56 ++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index a7bacd2dd6..ab4412de2c 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -1,6 +1,12 @@ +using System; +using System.Collections.Generic; using System.Linq; +using System.Reflection; +using Humanizer; using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; @@ -17,6 +23,11 @@ class TestContext : DbContext { public DbSet DbResources { get; set; } } + public ContextGraphBuilder_Tests() + { + JsonApiOptions.ResourceNameFormatter = new DefaultResourceNameFormatter(); + } + [Fact] public void Can_Build_ContextGraph_Using_Builder() { @@ -55,6 +66,22 @@ public void Resources_Without_Names_Specified_Will_Use_Default_Formatter() Assert.Equal("test-resources", resource.EntityName); } + [Fact] + public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() + { + // arrange + JsonApiOptions.ResourceNameFormatter = new CamelCaseNameFormatter(); + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("testResources", resource.EntityName); + } + [Fact] public void Attrs_Without_Names_Specified_Will_Use_Default_Formatter() { @@ -67,12 +94,56 @@ public void Attrs_Without_Names_Specified_Will_Use_Default_Formatter() // assert var resource = graph.GetContextEntity(typeof(TestResource)); - Assert.Equal("attribute", resource.Attributes.Single().PublicAttributeName); + Assert.Equal("compound-attribute", resource.Attributes.Single().PublicAttributeName); } - public class TestResource : Identifiable + [Fact] + public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() { - [Attr] public string Attribute { get; set; } + // arrange + JsonApiOptions.ResourceNameFormatter = new CamelCaseNameFormatter(); + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("compoundAttribute", resource.Attributes.Single().PublicAttributeName); + } + + [Fact] + public void Relationships_Without_Names_Specified_Will_Use_Default_Formatter() + { + // arrange + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("related-resource", resource.Relationships.Single(r => r.IsHasOne).PublicRelationshipName); + Assert.Equal("related-resources", resource.Relationships.Single(r => r.IsHasMany).PublicRelationshipName); + } + + public class TestResource : Identifiable { + [Attr] public string CompoundAttribute { get; set; } + [HasOne] public RelatedResource RelatedResource { get; set; } + [HasMany] public List RelatedResources { get; set; } + } + + public class RelatedResource : Identifiable { } + + public class CamelCaseNameFormatter : IResourceNameFormatter + { + 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/Graph/TypeLocator_Tests.cs b/test/UnitTests/Graph/TypeLocator_Tests.cs index f0d1ce04ad..890994c340 100644 --- a/test/UnitTests/Graph/TypeLocator_Tests.cs +++ b/test/UnitTests/Graph/TypeLocator_Tests.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; using Xunit; @@ -85,6 +86,61 @@ public void GetIdType_Correctly_Identifies_NonJsonApiResource() Assert.False(result.isJsonApiResource); Assert.Equal(exextedIdType, result.idType); } + + [Fact] + public void GetIdentifableTypes_Locates_Identifiable_Resource() + { + // arrange + var resourceType = typeof(Model); + + // act + var results = TypeLocator.GetIdentifableTypes(resourceType.Assembly); + + // assert + Assert.Contains(results, r => r.ResourceType == resourceType); + } + + [Fact] + public void GetIdentifableTypes__Only_Contains_IIdentifiable_Types() + { + // arrange + var resourceType = typeof(Model); + + // act + var resourceDescriptors = TypeLocator.GetIdentifableTypes(resourceType.Assembly); + + // assert + foreach(var resourceDescriptor in resourceDescriptors) + Assert.True(typeof(IIdentifiable).IsAssignableFrom(resourceDescriptor.ResourceType)); + } + + [Fact] + public void TryGetResourceDescriptor_Returns_True_If_Type_Is_IIdentfiable() + { + // arrange + var resourceType = typeof(Model); + + // act + var isJsonApiResource = TypeLocator.TryGetResourceDescriptor(resourceType, out var descriptor); + + // assert + Assert.True(isJsonApiResource); + Assert.Equal(resourceType, descriptor.ResourceType); + Assert.Equal(typeof(int), descriptor.IdType); + } + + [Fact] + public void TryGetResourceDescriptor_Returns_False_If_Type_Is_IIdentfiable() + { + // arrange + var resourceType = typeof(String); + + // act + var isJsonApiResource = TypeLocator.TryGetResourceDescriptor(resourceType, out var descriptor); + + // assert + Assert.False(isJsonApiResource); + } } From f8867e4bb255b730d4f6ed41d35e461c57fda596 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Sun, 23 Sep 2018 21:56:49 -0700 Subject: [PATCH 71/74] feat(402): adds filter and sort to ResourceDefinition --- .../Models/ResourceDefinition.cs | 76 ++++++++++++++++++- .../Models/ResourceDefinitionTests.cs | 13 ++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 6033a7b856..4705e4a2fa 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Query; using System; using System.Collections.Generic; using System.Linq; @@ -13,7 +14,10 @@ public interface IResourceDefinition } /// - /// A scoped service used to... + /// exposes developer friendly hooks into how their resources are exposed. + /// It is intended to improve the experience and reduce boilerplate for commonly required features. + /// The goal of this class is to reduce the frequency with which developers have to override the + /// service and repository layers. /// /// The resource type public class ResourceDefinition : IResourceDefinition where T : class, IIdentifiable @@ -47,8 +51,10 @@ private bool InstanceOutputAttrsAreSpecified() return declaringType == derivedType; } + public delegate dynamic FilterExpression(T type); + // TODO: need to investigate options for caching these - protected List Remove(Expression> filter, List from = null) + protected List Remove(Expression filter, List from = null) { from = from ?? _contextEntity.Attributes; @@ -115,5 +121,71 @@ private List GetOutputAttrs() return _requestCachedAttrs; } + + /// + /// Define a set of custom query expressions that can be applied + /// instead of the default query behavior. A common use-case for this + /// is including related resources and filtering on them. + /// + /// + /// A set of custom queries that will be applied instead of the default + /// queries for the given key. Null will be returned if default behavior + /// is desired. + /// + /// + /// + /// protected override QueryFilters GetQueryFilters() => { + /// { "facility", (t, value) => t.Include(t => t.Tenant) + /// .Where(t => t.Facility == value) } + /// } + /// + /// + /// If the logic is simply too complex for an in-line expression, you can + /// delegate to a private method: + /// + /// protected override QueryFilters GetQueryFilters() + /// => new QueryFilters { + /// { "is-active", FilterIsActive } + /// }; + /// + /// private IQueryable<Model> FilterIsActive(IQueryable<Model> query, string value) + /// { + /// // some complex logic goes here... + /// return query.Where(x => x.IsActive == computedValue); + /// } + /// + /// + protected virtual QueryFilters GetQueryFilters() => null; + + /// + /// This is an alias type intended to simplify the implementation's + /// method signature. + /// See for usage details. + /// + public class QueryFilters : Dictionary, string, IQueryable>> { } + + /// + /// Define a the default sort order if no sort key is provided. + /// + /// + /// A list of properties and the direction they should be sorted. + /// + /// + /// + /// protected override PropertySortOrder GetDefaultSortOrder() + /// => new PropertySortOrder { + /// (t => t.Prop1, SortDirection.Ascending), + /// (t => t.Prop2, SortDirection.Descending), + /// }; + /// + /// + protected virtual PropertySortOrder GetDefaultSortOrder() => null; + + /// + /// This is an alias type intended to simplify the implementation's + /// method signature. + /// See for usage details. + /// + public class PropertySortOrder : List<(Expression>, SortDirection)> { } } } diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs index 2112a49447..de606c5227 100644 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ b/test/UnitTests/Models/ResourceDefinitionTests.cs @@ -1,7 +1,9 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; using System.Collections.Generic; +using System.Linq; using Xunit; namespace UnitTests.Models @@ -98,6 +100,7 @@ public class Model : Identifiable { [Attr("name")] public string AlwaysExcluded { get; set; } [Attr("password")] public string Password { get; set; } + [Attr("prop")] public string Prop { get; set; } } public class RequestFilteredResource : ResourceDefinition @@ -116,6 +119,16 @@ protected override List OutputAttrs() => _isAdmin ? Remove(m => m.AlwaysExcluded) : Remove(m => new { m.AlwaysExcluded, m.Password }, from: base.OutputAttrs()); + + protected override QueryFilters GetQueryFilters() + => new QueryFilters { + { "is-active", (query, value) => query.Select(x => x) } + }; + + protected override PropertySortOrder GetDefaultSortOrder() + => new PropertySortOrder { + (t => t.Prop, SortDirection.Ascending) + }; } public class InstanceFilteredResource : ResourceDefinition From 38c39a0f8d715478f92cfdc6d3af46a88698e43f Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Tue, 25 Sep 2018 22:12:56 -0700 Subject: [PATCH 72/74] fix tests --- test/UnitTests/Models/ResourceDefinitionTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs index de606c5227..0a82d82d07 100644 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ b/test/UnitTests/Models/ResourceDefinitionTests.cs @@ -29,8 +29,7 @@ public void Request_Filter_Uses_Member_Expression() var attrs = resource.GetOutputAttrs(null); // assert - Assert.Single(attrs); - Assert.Equal(nameof(Model.Password), attrs[0].InternalAttributeName); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); } [Fact] @@ -43,7 +42,8 @@ public void Request_Filter_Uses_NewExpression() var attrs = resource.GetOutputAttrs(null); // assert - Assert.Empty(attrs); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.Password)); } [Fact] @@ -57,8 +57,7 @@ public void Instance_Filter_Uses_Member_Expression() var attrs = resource.GetOutputAttrs(model); // assert - Assert.Single(attrs); - Assert.Equal(nameof(Model.Password), attrs[0].InternalAttributeName); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); } [Fact] @@ -72,7 +71,8 @@ public void Instance_Filter_Uses_NewExpression() var attrs = resource.GetOutputAttrs(model); // assert - Assert.Empty(attrs); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.Password)); } [Fact] From 61372fffbf1195d812399ac5f9e213abc36c5c44 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Tue, 25 Sep 2018 22:55:11 -0700 Subject: [PATCH 73/74] implement new resource definition featurees --- .../Data/DefaultEntityRepository.cs | 46 ++++++++++++++++--- .../Models/ResourceDefinition.cs | 30 ++++++++++-- .../Services/EntityResourceService.cs | 3 +- .../Models/ResourceDefinitionTests.cs | 2 +- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 68462ff4df..808c1929a4 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -21,15 +21,17 @@ public class DefaultEntityRepository { public DefaultEntityRepository( IJsonApiContext jsonApiContext, - IDbContextResolver contextResolver) - : base(jsonApiContext, contextResolver) + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) + : base(jsonApiContext, contextResolver, resourceDefinition) { } public DefaultEntityRepository( ILoggerFactory loggerFactory, IJsonApiContext jsonApiContext, - IDbContextResolver contextResolver) - : base(loggerFactory, jsonApiContext, contextResolver) + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) + : base(loggerFactory, jsonApiContext, contextResolver, resourceDefinition) { } } @@ -47,27 +49,32 @@ public class DefaultEntityRepository private readonly ILogger _logger; private readonly IJsonApiContext _jsonApiContext; private readonly IGenericProcessorFactory _genericProcessorFactory; + private readonly ResourceDefinition _resourceDefinition; public DefaultEntityRepository( IJsonApiContext jsonApiContext, - IDbContextResolver contextResolver) + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) { _context = contextResolver.GetContext(); _dbSet = contextResolver.GetDbSet(); _jsonApiContext = jsonApiContext; _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; + _resourceDefinition = resourceDefinition; } public DefaultEntityRepository( ILoggerFactory loggerFactory, IJsonApiContext jsonApiContext, - IDbContextResolver contextResolver) + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) { _context = contextResolver.GetContext(); _dbSet = contextResolver.GetDbSet(); _jsonApiContext = jsonApiContext; _logger = loggerFactory.CreateLogger>(); _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; + _resourceDefinition = resourceDefinition; } /// @@ -82,13 +89,38 @@ public virtual IQueryable Get() /// public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { + if(_resourceDefinition != null) + { + var defaultQueryFilters = _resourceDefinition.GetQueryFilters(); + if(defaultQueryFilters != null && defaultQueryFilters.TryGetValue(filterQuery.Attribute, out var defaultQueryFilter) == true) + { + return defaultQueryFilter(entities, filterQuery.Value); + } + } + return entities.Filter(_jsonApiContext, filterQuery); } /// public virtual IQueryable Sort(IQueryable entities, List sortQueries) { - return entities.Sort(sortQueries); + if (sortQueries != null && sortQueries.Count > 0) + return entities.Sort(sortQueries); + + if(_resourceDefinition != null) + { + var defaultSortOrder = _resourceDefinition.DefaultSort(); + if(defaultSortOrder != null && defaultSortOrder.Count > 0) + { + foreach(var sortProp in defaultSortOrder) + { + // this is dumb...add an overload, don't allocate for no reason + entities.Sort(new SortQuery(sortProp.Item2, sortProp.Item1)); + } + } + } + + return entities; } /// diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 4705e4a2fa..c9cffb7062 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -49,12 +49,10 @@ private bool InstanceOutputAttrsAreSpecified() .FirstOrDefault(); var declaringType = instanceMethod?.DeclaringType; return declaringType == derivedType; - } - - public delegate dynamic FilterExpression(T type); + } // TODO: need to investigate options for caching these - protected List Remove(Expression filter, List from = null) + protected List Remove(Expression> filter, List from = null) { from = from ?? _contextEntity.Attributes; @@ -155,7 +153,7 @@ private List GetOutputAttrs() /// } /// /// - protected virtual QueryFilters GetQueryFilters() => null; + public virtual QueryFilters GetQueryFilters() => null; /// /// This is an alias type intended to simplify the implementation's @@ -181,6 +179,28 @@ public class QueryFilters : Dictionary, string, IQuer /// protected virtual PropertySortOrder GetDefaultSortOrder() => null; + internal List<(AttrAttribute, SortDirection)> DefaultSort() + { + var defaultSortOrder = GetDefaultSortOrder(); + if(defaultSortOrder != null && defaultSortOrder.Count > 0) + { + var order = new List<(AttrAttribute, SortDirection)>(); + foreach(var sortProp in defaultSortOrder) + { + // TODO: error handling, log or throw? + if (sortProp.Item1.Body is MemberExpression memberExpression) + order.Add( + (_contextEntity.Attributes.SingleOrDefault(a => a.InternalAttributeName != memberExpression.Member.Name), + sortProp.Item2) + ); + } + + return order; + } + + return null; + } + /// /// This is an alias type intended to simplify the implementation's /// method signature. diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 1e175bfeb0..2ce3b9147d 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -223,8 +223,7 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable 0) - entities = _entities.Sort(entities, query.SortParameters); + entities = _entities.Sort(entities, query.SortParameters); return entities; } diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs index 0a82d82d07..e7a1e75dcf 100644 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ b/test/UnitTests/Models/ResourceDefinitionTests.cs @@ -120,7 +120,7 @@ protected override List OutputAttrs() ? Remove(m => m.AlwaysExcluded) : Remove(m => new { m.AlwaysExcluded, m.Password }, from: base.OutputAttrs()); - protected override QueryFilters GetQueryFilters() + public override QueryFilters GetQueryFilters() => new QueryFilters { { "is-active", (query, value) => query.Select(x => x) } }; From a2053be12ed0739ef3e2c1898e7e3921bafc1b2a Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Mon, 1 Oct 2018 07:46:09 -0700 Subject: [PATCH 74/74] run ResourceEntitySeparationExampleTests on CI --- Build.ps1 | 5 ++++- build.sh | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Build.ps1 b/Build.ps1 index 8ff62d6578..6d4621d7e0 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -35,7 +35,10 @@ CheckLastExitCode dotnet test ./test/OperationsExampleTests/OperationsExampleTests.csproj CheckLastExitCode -dotnet build .\src\JsonApiDotNetCore -c Release +dotnet test ./test/ResourceEntitySeparationExampleTests/ResourceEntitySeparationExampleTests.csproj +CheckLastExitCode + +dotnet build ./src/JsonApiDotNetCore -c Release CheckLastExitCode Write-Output "APPVEYOR_REPO_TAG: $env:APPVEYOR_REPO_TAG" diff --git a/build.sh b/build.sh index 50f2ab9c99..1230bd6414 100755 --- a/build.sh +++ b/build.sh @@ -9,3 +9,4 @@ dotnet test ./test/UnitTests/UnitTests.csproj dotnet test ./test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj dotnet test ./test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj dotnet test ./test/OperationsExampleTests/OperationsExampleTests.csproj +dotnet test ./test/ResourceEntitySeparationExampleTests/ResourceEntitySeparationExampleTests.csproj