diff --git a/.travis.yml b/.travis.yml index d24277b532..274f9e6228 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,5 +10,6 @@ dotnet: 1.0.0-preview2-1-003177 branches: only: - master + - staging script: - - ./build.sh \ No newline at end of file + - ./build.sh diff --git a/README.md b/README.md index e12a416cb7..9a17a3375a 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,24 @@ Your models should inherit `Identifiable` where `TId` is the type of the pr ```csharp public class Person : Identifiable -{ - public override Guid Id { get; set; } +{ } +``` + +You can use the non-generic `Identifiable` if your primary key is an integer: + +```csharp +public class Person : Identifiable +{ } +``` + +If you need to hang annotations or attributes on the `Id` property, you can override the virtual member: + +```csharp +public class Person : Identifiable +{ + [Key] + [Column("person_id")] + public override int Id { get; set; } } ``` @@ -89,8 +105,6 @@ add the `AttrAttribute` and provide the outbound name. ```csharp public class Person : Identifiable { - public override int Id { get; set; } - [Attr("first-name")] public string FirstName { get; set; } } @@ -99,16 +113,15 @@ public class Person : Identifiable #### Relationships In order for navigation properties to be identified in the model, -they should be labeled as virtual. +they should be labeled with the appropriate attribute (either `HasOne` or `HasMany`). ```csharp public class Person : Identifiable { - public override int Id { get; set; } - [Attr("first-name")] public string FirstName { get; set; } + [HasMany("todo-items")] public virtual List TodoItems { get; set; } } ``` @@ -119,12 +132,12 @@ For example, a `TodoItem` may have an `Owner` and so the Id attribute should be ```csharp public class TodoItem : Identifiable { - public override int Id { get; set; } - [Attr("description")] public string Description { get; set; } public int OwnerId { get; set; } + + [HasOne("owner")] public virtual Person Owner { get; set; } } ``` @@ -224,6 +237,9 @@ public class MyAuthorizedEntityRepository : DefaultEntityRepository } ``` +For more examples, take a look at the customization tests +in `./test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility`. + ### Pagination Resources can be paginated. @@ -272,6 +288,7 @@ identifier): ?filter[attribute]=gt:value ?filter[attribute]=le:value ?filter[attribute]=ge:value +?filter[attribute]=like:value ``` ### Sorting diff --git a/appveyor.yml b/appveyor.yml index 45405958fe..65646e6b38 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,6 +4,7 @@ pull_requests: branches: only: - master + - staging nuget: disable_publish_on_pr: true build_script: @@ -19,7 +20,7 @@ deploy: secure: 6CeYcZ4Ze+57gxfeuHzqP6ldbUkPtF6pfpVM1Gw/K2jExFrAz763gNAQ++tiacq3 skip_symbols: true on: - branch: master + branch: staging - provider: NuGet name: production api_key: diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index eda58f66a3..284b2184c5 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -26,7 +26,8 @@ public Document Build(IIdentifiable entity) var document = new Document { Data = _getData(contextEntity, entity), - Meta = _getMeta(entity) + Meta = _getMeta(entity), + Links = _jsonApiContext.PageManager.GetPageLinks(new LinkBuilder(_jsonApiContext)) }; document.Included = _appendIncludedObject(document.Included, contextEntity, entity); @@ -45,7 +46,8 @@ public Documents Build(IEnumerable entities) var documents = new Documents { Data = new List(), - Meta = _getMeta(entities.FirstOrDefault()) + Meta = _getMeta(entities.FirstOrDefault()), + Links = _jsonApiContext.PageManager.GetPageLinks(new LinkBuilder(_jsonApiContext)) }; foreach (var entity in entities) @@ -68,7 +70,7 @@ private Dictionary _getMeta(IIdentifiable entity) meta = metaEntity.GetMeta(_jsonApiContext); if(_jsonApiContext.Options.IncludeTotalRecordCount) - meta["total-records"] = _jsonApiContext.TotalRecords; + meta["total-records"] = _jsonApiContext.PageManager.TotalRecords; if(meta.Count > 0) return meta; return null; @@ -122,23 +124,25 @@ private void _addRelationships(DocumentData data, ContextEntity contextEntity, I { Links = new Links { - Self = linkBuilder.GetSelfRelationLink(contextEntity.EntityName, entity.Id.ToString(), r.RelationshipName), - Related = linkBuilder.GetRelatedRelationLink(contextEntity.EntityName, entity.Id.ToString(), r.RelationshipName) + Self = linkBuilder.GetSelfRelationLink(contextEntity.EntityName, entity.Id.ToString(), r.InternalRelationshipName), + Related = linkBuilder.GetRelatedRelationLink(contextEntity.EntityName, entity.Id.ToString(), r.InternalRelationshipName) } }; - if (_relationshipIsIncluded(r.RelationshipName)) + if (_relationshipIsIncluded(r.InternalRelationshipName)) { var navigationEntity = _jsonApiContext.ContextGraph - .GetRelationship(entity, r.RelationshipName); + .GetRelationship(entity, r.InternalRelationshipName); - if (navigationEntity is IEnumerable) - relationshipData.ManyData = _getRelationships((IEnumerable)navigationEntity, r.RelationshipName); + if(navigationEntity == null) + relationshipData.SingleData = null; + else if (navigationEntity is IEnumerable) + relationshipData.ManyData = _getRelationships((IEnumerable)navigationEntity, r.InternalRelationshipName); else - relationshipData.SingleData = _getRelationship(navigationEntity, r.RelationshipName); + relationshipData.SingleData = _getRelationship(navigationEntity, r.InternalRelationshipName); } - data.Relationships.Add(r.RelationshipName.Dasherize(), relationshipData); + data.Relationships.Add(r.InternalRelationshipName.Dasherize(), relationshipData); }); } @@ -148,9 +152,9 @@ private List _getIncludedEntities(ContextEntity contextEntity, IId contextEntity.Relationships.ForEach(r => { - if (!_relationshipIsIncluded(r.RelationshipName)) return; + if (!_relationshipIsIncluded(r.InternalRelationshipName)) return; - var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, r.RelationshipName); + var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, r.InternalRelationshipName); if (navigationEntity is IEnumerable) foreach (var includedEntity in (IEnumerable)navigationEntity) @@ -164,6 +168,8 @@ private List _getIncludedEntities(ContextEntity contextEntity, IId private DocumentData _getIncludedEntity(IIdentifiable entity) { + if(entity == null) return null; + var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(entity.GetType()); var data = new DocumentData diff --git a/src/JsonApiDotNetCore/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Builders/LinkBuilder.cs index d819aab253..5fd25793d7 100644 --- a/src/JsonApiDotNetCore/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/LinkBuilder.cs @@ -1,4 +1,3 @@ - using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; @@ -45,5 +44,10 @@ public string GetRelatedRelationLink(string parent, string parentId, string chil { return $"{_context.BasePath}/{parent.Dasherize()}/{parentId}/{child.Dasherize()}"; } + + public string GetPageLink(int pageOffset, int pageSize) + { + return $"{_context.BasePath}/{_context.RequestEntity.EntityName.Dasherize()}?page[size]={pageSize}&page[number]={pageOffset}"; + } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 1378a373c5..ab6eedbf9d 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -65,7 +65,7 @@ public virtual async Task GetAsync() entities = IncludeRelationships(entities, _jsonApiContext.QuerySet.IncludedRelationships); if (_jsonApiContext.Options.IncludeTotalRecordCount) - _jsonApiContext.TotalRecords = await entities.CountAsync(); + _jsonApiContext.PageManager.TotalRecords = await entities.CountAsync(); // pagination should be done last since it will execute the query var pagedEntities = await ApplyPageQueryAsync(entities); @@ -126,9 +126,6 @@ public virtual async Task GetRelationshipAsync(TId id, string rel var relationship = _jsonApiContext.ContextGraph .GetRelationship(entity, relationshipName); - if (relationship == null) - return NotFound(); - return Ok(relationship); } @@ -141,9 +138,13 @@ public virtual async Task PostAsync([FromBody] T entity) return UnprocessableEntity(); } + var stringId = entity.Id.ToString(); + if(stringId.Length > 0 && stringId != "0") + return Forbidden(); + await _entities.CreateAsync(entity); - return Created(HttpContext.Request.Path, entity); + return Created($"{HttpContext.Request.Path}/{entity.Id}", entity); } [HttpPatch("{id}")] @@ -157,9 +158,41 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) var updatedEntity = await _entities.UpdateAsync(id, entity); + if(updatedEntity == null) return NotFound(); + return Ok(updatedEntity); } + [HttpPatch("{id}/relationships/{relationshipName}")] + public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) + { + relationshipName = _jsonApiContext.ContextGraph + .GetRelationshipName(relationshipName.ToProperCase()); + + if (relationshipName == null) + { + _logger?.LogInformation($"Relationship name not specified returning 422"); + return UnprocessableEntity(); + } + + var entity = await _entities.GetAndIncludeAsync(id, relationshipName); + + if (entity == null) + return NotFound(); + + var relationship = _jsonApiContext.ContextGraph + .GetContextEntity(typeof(T)) + .Relationships + .FirstOrDefault(r => r.InternalRelationshipName == relationshipName); + + var relationshipIds = relationships.Select(r=>r.Id); + + await _entities.UpdateRelationshipsAsync(entity, relationship, relationshipIds); + + return Ok(); + + } + [HttpDelete("{id}")] public virtual async Task DeleteAsync(TId id) { @@ -190,17 +223,15 @@ private IQueryable ApplySortAndFilterQuery(IQueryable entities) private async Task> ApplyPageQueryAsync(IQueryable entities) { - if(_jsonApiContext.Options.DefaultPageSize == 0 && (_jsonApiContext.QuerySet == null || _jsonApiContext.QuerySet.PageQuery.PageSize == 0)) + var pageManager = _jsonApiContext.PageManager; + if(!pageManager.IsPaginated) return entities; var query = _jsonApiContext.QuerySet?.PageQuery ?? new PageQuery(); - - var pageNumber = query.PageOffset > 0 ? query.PageOffset : 1; - var pageSize = query.PageSize > 0 ? query.PageSize : _jsonApiContext.Options.DefaultPageSize; - _logger?.LogInformation($"Applying paging query. Fetching page {pageNumber} with {pageSize} entities"); + _logger?.LogInformation($"Applying paging query. Fetching page {pageManager.CurrentPage} with {pageManager.PageSize} entities"); - return await _entities.PageAsync(entities, pageSize, pageNumber); + return await _entities.PageAsync(entities, pageManager.PageSize, pageManager.CurrentPage); } private IQueryable IncludeRelationships(IQueryable entities, List relationships) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs index eedd1444fa..b97b4e135a 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs @@ -11,5 +11,10 @@ protected IActionResult UnprocessableEntity() { return new StatusCodeResult(422); } + + protected IActionResult Forbidden() + { + return new StatusCodeResult(403); + } } } diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index f95b6ad8a8..5a44bb076c 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -105,7 +105,13 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) await _context.SaveChangesAsync(); - return oldEntity; + return oldEntity; + } + + public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + { + var genericProcessor = GenericProcessorFactory.GetProcessor(relationship.Type, _context); + await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } public virtual async Task DeleteAsync(TId id) @@ -125,7 +131,7 @@ public virtual async Task DeleteAsync(TId id) public virtual IQueryable Include(IQueryable entities, string relationshipName) { var entity = _jsonApiContext.RequestEntity; - if(entity.Relationships.Any(r => r.RelationshipName == relationshipName)) + if(entity.Relationships.Any(r => r.InternalRelationshipName == relationshipName)) return entities.Include(relationshipName); throw new JsonApiException("400", "Invalid relationship", diff --git a/src/JsonApiDotNetCore/Data/IEntityRepository.cs b/src/JsonApiDotNetCore/Data/IEntityRepository.cs index a8b670e3d4..8df7019f13 100644 --- a/src/JsonApiDotNetCore/Data/IEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; @@ -33,6 +34,8 @@ public interface IEntityRepository Task UpdateAsync(TId id, TEntity entity); + Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); + Task DeleteAsync(TId id); } } diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 904d2c9fe2..2e2551603c 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -86,7 +86,7 @@ public static IQueryable Filter(this IQueryable sourc // {1} var right = Expression.Constant(convertedValue, property.PropertyType); - var body = Expression.Equal(left, right); + Expression body; switch (filterQuery.FilterOperation) { case FilterOperations.eq: @@ -109,6 +109,12 @@ public static IQueryable Filter(this IQueryable sourc // {model.Id <= 1} body = Expression.GreaterThanOrEqual(left, right); break; + case FilterOperations.like: + // {model.Id <= 1} + body = Expression.Call(left, "Contains", null, right); + break; + default: + throw new JsonApiException("500", $"Unknown filter operation {filterQuery.FilterOperation}"); } var lambda = Expression.Lambda>(body, parameter); diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index 114f01cf3c..856ef4211d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -41,7 +41,9 @@ public Task ReadAsync(InputFormatterContext context) { var body = GetRequestBody(context.HttpContext.Request.Body); var jsonApiContext = GetService(context); - var model = JsonApiDeSerializer.Deserialize(body, jsonApiContext); + var model = jsonApiContext.IsRelationshipPath ? + JsonApiDeSerializer.DeserializeRelationship(body, jsonApiContext) : + JsonApiDeSerializer.Deserialize(body, jsonApiContext); if(model == null) logger?.LogError("An error occurred while de-serializing the payload"); diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index 05f9c57c4a..95e607fca5 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -2,6 +2,7 @@ using System.Text; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc.Formatters; @@ -65,23 +66,35 @@ private T GetService(OutputFormatterWriteContext context) private string GetResponseBody(object responseObject, IJsonApiContext jsonApiContext, ILogger logger) { + if (responseObject == null) + return GetNullDataResponse(); + if (responseObject.GetType() == typeof(Error) || jsonApiContext.RequestEntity == null) + return GetErrorJson(responseObject, logger); + + return JsonApiSerializer.Serialize(responseObject, jsonApiContext); + } + + private string GetNullDataResponse() + { + return JsonConvert.SerializeObject(new Document { - if (responseObject.GetType() == typeof(Error)) - { - var errors = new ErrorCollection(); - errors.Add((Error)responseObject); - return errors.GetJson(); - } - else - { - logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); - return JsonConvert.SerializeObject(responseObject); - } + Data = null + }); + } + + private string GetErrorJson(object responseObject, ILogger logger) + { + if (responseObject.GetType() == typeof(Error)) + { + var errors = new ErrorCollection(); + errors.Add((Error)responseObject); + return errors.GetJson(); } else { - return JsonApiSerializer.Serialize(responseObject, jsonApiContext); + logger?.LogInformation("Response was not a JSONAPI entity. Serializing as plain JSON."); + return JsonConvert.SerializeObject(responseObject); } } } diff --git a/src/JsonApiDotNetCore/Internal/ContextEntity.cs b/src/JsonApiDotNetCore/Internal/ContextEntity.cs index a1afeeefed..e43d85ab0f 100644 --- a/src/JsonApiDotNetCore/Internal/ContextEntity.cs +++ b/src/JsonApiDotNetCore/Internal/ContextEntity.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal { @@ -8,6 +9,6 @@ public class ContextEntity public string EntityName { get; set; } public Type EntityType { get; set; } public List Attributes { get; set; } - public List Relationships { get; set; } + public List Relationships { get; set; } } } diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index 07f4629df4..f59ce6f497 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -33,7 +33,7 @@ public object GetRelationship(TParent entity, string relationshipName) .FirstOrDefault(p => p.Name.ToLower() == relationshipName.ToLower()); if(navigationProperty == null) - return null; + throw new JsonApiException("400", $"{parentEntityType} does not contain a relationship named {relationshipName}"); return navigationProperty.GetValue(entity); } @@ -46,8 +46,8 @@ public string GetRelationshipName(string relationshipName) e.EntityType == entityType) .Relationships .FirstOrDefault(r => - r.RelationshipName.ToLower() == relationshipName.ToLower()) - ?.RelationshipName; + r.InternalRelationshipName.ToLower() == relationshipName.ToLower()) + ?.InternalRelationshipName; } } } diff --git a/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs index db01fabc6c..e856714b40 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal { @@ -14,7 +15,6 @@ public class ContextGraphBuilder where T : DbContext public ContextGraph Build() { _getFirstLevelEntities(); - _loadRelationships(); var graph = new ContextGraph { @@ -41,7 +41,8 @@ private void _getFirstLevelEntities() entities.Add(new ContextEntity { EntityName = property.Name, EntityType = entityType, - Attributes = _getAttributes(entityType) + Attributes = _getAttributes(entityType), + Relationships = _getRelationships(entityType) }); } } @@ -65,38 +66,29 @@ private List _getAttributes(Type entityType) return attributes; } - private void _loadRelationships() - { - _entities.ForEach(entity => { - - var relationships = new List(); - var properties = entity.EntityType.GetProperties(); - - foreach(var entityProperty in properties) - { - var propertyType = entityProperty.PropertyType; - - if(_isValidEntity(propertyType) - || (propertyType.GetTypeInfo().IsGenericType && _isValidEntity(propertyType.GetGenericArguments()[0]))) - relationships.Add(_getRelationshipFromPropertyInfo(entityProperty)); - } - - entity.Relationships = relationships; - }); - } - - private bool _isValidEntity(Type type) + private List _getRelationships(Type entityType) { - var validEntityRelationshipTypes = _entities.Select(e => e.EntityType); - return validEntityRelationshipTypes.Contains(type); + var attributes = new List(); + + var properties = entityType.GetProperties(); + + foreach(var prop in properties) + { + var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); + if(attribute == null) continue; + attribute.InternalRelationshipName = prop.Name; + attribute.Type = _getRelationshipType(attribute, prop); + attributes.Add(attribute); + } + return attributes; } - private Relationship _getRelationshipFromPropertyInfo(PropertyInfo propertyInfo) + private Type _getRelationshipType(RelationshipAttribute relation, PropertyInfo prop) { - return new Relationship { - Type = propertyInfo.PropertyType, - RelationshipName = propertyInfo.Name - }; + if(relation.IsHasMany) + return prop.PropertyType.GetGenericArguments()[0]; + else + return prop.PropertyType; } } } diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs new file mode 100644 index 0000000000..5fa1857b94 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs @@ -0,0 +1,38 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Models; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Internal +{ + public class GenericProcessor : IGenericProcessor where T : class, IIdentifiable + { + private readonly DbContext _context; + public GenericProcessor(DbContext context) + { + _context = context; + } + + public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + { + var relationshipType = relationship.Type; + + if(relationship.IsHasMany) + { + var entities = _context.GetDbSet().Where(x => relationshipIds.Contains(x.Id.ToString())).ToList(); + relationship.SetValue(parent, entities); + } + else + { + var entity = _context.GetDbSet().SingleOrDefault(x => relationshipIds.First() == x.Id.ToString()); + relationship.SetValue(parent, entity); + } + + await _context.SaveChangesAsync(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs new file mode 100644 index 0000000000..24a963599a --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessorFactory.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Internal +{ + /// + /// Used to generate a generic operations processor when the types + /// are not know until runtime. The typical use case would be for + /// accessing relationship data. + /// + public static class GenericProcessorFactory + { + public static IGenericProcessor GetProcessor(Type type, DbContext dbContext) + { + var repositoryType = typeof(GenericProcessor<>).MakeGenericType(type); + return (IGenericProcessor)Activator.CreateInstance(repositoryType, dbContext); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessor.cs b/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessor.cs new file mode 100644 index 0000000000..313db15dc1 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Generics/IGenericProcessor.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Internal +{ + public interface IGenericProcessor + { + Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); + } +} diff --git a/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs new file mode 100644 index 0000000000..41d676f5da --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs @@ -0,0 +1,22 @@ +using System; +using System.Linq; + +namespace JsonApiDotNetCore.Internal +{ + public static class JsonApiExceptionFactory + { + public static JsonApiException GetException(Exception exception) + { + var exceptionType = exception.GetType().ToString().Split('.').Last(); + switch(exceptionType) + { + case "JsonApiException": + return (JsonApiException)exception; + case "InvalidCastException": + return new JsonApiException("409", exception.Message); + default: + return new JsonApiException("500", exception.Message); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/PageManager.cs b/src/JsonApiDotNetCore/Internal/PageManager.cs new file mode 100644 index 0000000000..c85d81b1e9 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/PageManager.cs @@ -0,0 +1,42 @@ +using System; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Internal +{ + public class PageManager + { + public int TotalRecords { get; set; } + public int PageSize { get; set; } + public int DefaultPageSize { get; set; } + public int CurrentPage { get; set; } + public bool IsPaginated { get { return PageSize > 0; } } + public int TotalPages { + get { return (TotalRecords == 0) ? -1: (int)Math.Ceiling(decimal.Divide(TotalRecords, PageSize)); } + } + + public RootLinks GetPageLinks(LinkBuilder linkBuilder) + { + if(!IsPaginated || (CurrentPage == 1 && TotalPages <= 0)) + return null; + + var rootLinks = new RootLinks(); + + var includePageSize = DefaultPageSize != PageSize; + + if(CurrentPage > 1) + rootLinks.First = linkBuilder.GetPageLink(1, PageSize); + + if(CurrentPage > 1) + rootLinks.Prev = linkBuilder.GetPageLink(CurrentPage - 1, PageSize); + + if(CurrentPage < TotalPages) + rootLinks.Next = linkBuilder.GetPageLink(CurrentPage + 1, PageSize); + + if(TotalPages > 0) + rootLinks.Last = linkBuilder.GetPageLink(TotalPages, PageSize); + + return rootLinks; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs index cad391073f..cc6166e422 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs @@ -6,6 +6,7 @@ public enum FilterOperations lt = 1, gt = 2, le = 3, - ge = 4 + ge = 4, + like = 5 } } \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs index 45811e88bd..364d0342c6 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs @@ -1,3 +1,5 @@ +using JsonApiDotNetCore.Models; + namespace JsonApiDotNetCore.Internal.Query { public class FilterQuery diff --git a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs index 25bb9f1417..864251e4a4 100644 --- a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs +++ b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal.Query { @@ -82,26 +83,29 @@ private FilterQuery ParseFilterOperation(AttrAttribute attribute, string value) if(value.Length < 3) return new FilterQuery(attribute, value, FilterOperations.eq); - var prefix = value.Substring(0, 3); + var operation = value.Split(':'); - if(prefix[2] != ':') + if(operation.Length == 1) return new FilterQuery(attribute, value, FilterOperations.eq); // remove prefix from value - value = value.Substring(3, value.Length - 3); + var prefix = operation[0]; + value = operation[1]; switch(prefix) { - case "eq:": + case "eq": return new FilterQuery(attribute, value, FilterOperations.eq); - case "lt:": + case "lt": return new FilterQuery(attribute, value, FilterOperations.lt); - case "gt:": + case "gt": return new FilterQuery(attribute, value, FilterOperations.gt); - case "le:": + case "le": return new FilterQuery(attribute, value, FilterOperations.le); - case "ge:": + case "ge": return new FilterQuery(attribute, value, FilterOperations.ge); + case "like": + return new FilterQuery(attribute, value, FilterOperations.like); } throw new JsonApiException("400", $"Invalid filter prefix '{prefix}'"); @@ -147,6 +151,9 @@ private List ParseSortParameters(string value) private List ParseIncludedRelationships(string value) { + if(value.Contains(".")) + throw new JsonApiException("400", "Deeply nested relationships are not supported"); + return value .Split(',') .Select(s => s.ToProperCase()) diff --git a/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs b/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs index 827ac3523b..7ef6682cc6 100644 --- a/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs @@ -1,3 +1,5 @@ +using JsonApiDotNetCore.Models; + namespace JsonApiDotNetCore.Internal.Query { public class SortQuery diff --git a/src/JsonApiDotNetCore/Internal/Relationship.cs b/src/JsonApiDotNetCore/Internal/Relationship.cs deleted file mode 100644 index 4ef736e719..0000000000 --- a/src/JsonApiDotNetCore/Internal/Relationship.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Internal -{ - public class Relationship - { - public Type Type { get; set; } - public string RelationshipName { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs index 0a3153b36c..479a947e5e 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs @@ -19,11 +19,8 @@ public void OnException(ExceptionContext context) { _logger?.LogError(new EventId(), context.Exception, "An unhandled exception occurred during the request"); - var jsonApiException = context.Exception as JsonApiException; + var jsonApiException = JsonApiExceptionFactory.GetException(context.Exception); - if(jsonApiException == null) - jsonApiException = new JsonApiException("500", context.Exception.Message); - var error = jsonApiException.GetError(); var result = new ObjectResult(error); result.StatusCode = Convert.ToInt16(error.Status); diff --git a/src/JsonApiDotNetCore/Internal/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/AttrAttribute.cs similarity index 96% rename from src/JsonApiDotNetCore/Internal/AttrAttribute.cs rename to src/JsonApiDotNetCore/Models/AttrAttribute.cs index 743412e3f2..3ce9a2196a 100644 --- a/src/JsonApiDotNetCore/Internal/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/AttrAttribute.cs @@ -1,7 +1,7 @@ using System; using System.Reflection; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Models { public class AttrAttribute : Attribute { diff --git a/src/JsonApiDotNetCore/Models/DocumentBase.cs b/src/JsonApiDotNetCore/Models/DocumentBase.cs index 7864c72d13..1cb31595ec 100644 --- a/src/JsonApiDotNetCore/Models/DocumentBase.cs +++ b/src/JsonApiDotNetCore/Models/DocumentBase.cs @@ -4,9 +4,30 @@ namespace JsonApiDotNetCore.Models { public class DocumentBase - { + { + [JsonProperty("links")] + public RootLinks Links { get; set; } + [JsonProperty("included")] public List Included { get; set; } + + [JsonProperty("meta")] public Dictionary Meta { get; set; } + + // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm + public bool ShouldSerializeIncluded() + { + return (Included != null); + } + + public bool ShouldSerializeMeta() + { + return (Meta != null); + } + + public bool ShouldSerializeLinks() + { + return (Links != null); + } } } diff --git a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs new file mode 100644 index 0000000000..13e4a9efad --- /dev/null +++ b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCore.Models +{ + public class HasManyAttribute : RelationshipAttribute + { + public HasManyAttribute(string publicName) + : base(publicName) + { + PublicRelationshipName = publicName; + } + } +} diff --git a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs new file mode 100644 index 0000000000..e5670eae29 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.Models +{ + public class HasOneAttribute : RelationshipAttribute + { + public HasOneAttribute(string publicName) + : base(publicName) + { + PublicRelationshipName = publicName; + } + } +} diff --git a/src/JsonApiDotNetCore/Models/Identifiable.cs b/src/JsonApiDotNetCore/Models/Identifiable.cs index 9912c7ad4d..ead65bef28 100644 --- a/src/JsonApiDotNetCore/Models/Identifiable.cs +++ b/src/JsonApiDotNetCore/Models/Identifiable.cs @@ -1,8 +1,11 @@ namespace JsonApiDotNetCore.Models { - public abstract class Identifiable : IIdentifiable, IIdentifiable + public class Identifiable : Identifiable + {} + + public class Identifiable : IIdentifiable, IIdentifiable { - public abstract T Id { get; set; } + public virtual T Id { get; set; } object IIdentifiable.Id { diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs new file mode 100644 index 0000000000..45b3565592 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -0,0 +1,28 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Models +{ + public class RelationshipAttribute : Attribute + { + protected RelationshipAttribute(string publicName) + { + PublicRelationshipName = publicName; + } + + public string PublicRelationshipName { get; set; } + public string InternalRelationshipName { get; set; } + public Type Type { get; set; } + public bool IsHasMany { get { return this.GetType() == typeof(HasManyAttribute); } } + public bool IsHasOne { get { return this.GetType() == typeof(HasOneAttribute); } } + + public void SetValue(object entity, object newValue) + { + var propertyInfo = entity + .GetType() + .GetProperty(InternalRelationshipName); + + propertyInfo.SetValue(entity, newValue); + } + } +} diff --git a/src/JsonApiDotNetCore/Models/RootLinks.cs b/src/JsonApiDotNetCore/Models/RootLinks.cs new file mode 100644 index 0000000000..42b0a7863f --- /dev/null +++ b/src/JsonApiDotNetCore/Models/RootLinks.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models +{ + public class RootLinks + { + [JsonProperty("self")] + public string Self { get; set; } + + [JsonProperty("next")] + public string Next { get; set; } + + [JsonProperty("prev")] + public string Prev { get; set; } + + [JsonProperty("first")] + public string First { get; set; } + + [JsonProperty("last")] + public string Last { get; set; } + + // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm + public bool ShouldSerializeSelf() + { + return (!string.IsNullOrEmpty(Self)); + } + + public bool ShouldSerializeFirst() + { + return (!string.IsNullOrEmpty(First)); + } + + public bool ShouldSerializeNext() + { + return (!string.IsNullOrEmpty(Next)); + } + + public bool ShouldSerializePrev() + { + return (!string.IsNullOrEmpty(Prev)); + } + + public bool ShouldSerializeLast() + { + return (!string.IsNullOrEmpty(Last)); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 9a3fd6436c..ad7e7cba18 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JsonApiDotNetCore.Serialization { @@ -15,12 +16,21 @@ public static class JsonApiDeSerializer public static object Deserialize(string requestBody, IJsonApiContext context) { var document = JsonConvert.DeserializeObject(requestBody); - var entity = DataToObject(document.Data, context); - return entity; } + public static object DeserializeRelationship(string requestBody, IJsonApiContext context) + { + var data = JToken.Parse(requestBody)["data"]; + + if(data is JArray) + return data.ToObject>(); + + return new List { data.ToObject() }; + } + + public static List DeserializeList(string requestBody, IJsonApiContext context) { var documents = JsonConvert.DeserializeObject(requestBody); @@ -41,16 +51,16 @@ private static object DataToObject(DocumentData data, IJsonApiContext context) var contextEntity = context.ContextGraph.GetContextEntity(entityTypeName); context.RequestEntity = contextEntity; - + var entity = Activator.CreateInstance(contextEntity.EntityType); - + entity = _setEntityAttributes(entity, contextEntity, data.Attributes); entity = _setRelationships(entity, contextEntity, data.Relationships); var identifiableEntity = (IIdentifiable)entity; - if(data.Id != null) - identifiableEntity.Id = Convert.ChangeType(data.Id, identifiableEntity.Id.GetType()); + if (data.Id != null) + identifiableEntity.Id = ChangeType(data.Id, identifiableEntity.Id.GetType()); return identifiableEntity; } @@ -70,7 +80,7 @@ private static object _setEntityAttributes( object newValue; if (attributeValues.TryGetValue(attr.PublicAttributeName.Dasherize(), out newValue)) { - var convertedValue = Convert.ChangeType(newValue, entityProperty.PropertyType); + var convertedValue = ChangeType(newValue, entityProperty.PropertyType); entityProperty.SetValue(entity, convertedValue); } } @@ -88,26 +98,41 @@ private static object _setRelationships( foreach (var attr in contextEntity.Relationships) { - var entityProperty = entityProperties.FirstOrDefault(p => p.Name == $"{attr.RelationshipName}Id"); + var entityProperty = entityProperties.FirstOrDefault(p => p.Name == $"{attr.InternalRelationshipName}Id"); if (entityProperty == null) - throw new JsonApiException("400", $"{contextEntity.EntityType.Name} does not contain an relationsip named {attr.RelationshipName}"); - - var relationshipName = attr.RelationshipName.Dasherize(); + throw new JsonApiException("400", $"{contextEntity.EntityType.Name} does not contain an relationsip named {attr.InternalRelationshipName}"); + + var relationshipName = attr.InternalRelationshipName.Dasherize(); RelationshipData relationshipData; if (relationships.TryGetValue(relationshipName, out relationshipData)) { var data = (Dictionary)relationshipData.ExposedData; - - if(data == null) continue; - + + if (data == null) continue; + var newValue = data["id"]; - var convertedValue = Convert.ChangeType(newValue, entityProperty.PropertyType); + var convertedValue = ChangeType(newValue, entityProperty.PropertyType); entityProperty.SetValue(entity, convertedValue); } } return entity; } + + private static object ChangeType(object value, Type conversion) + { + var t = conversion; + + if (t.GetTypeInfo().IsGenericType && t.GetGenericTypeDefinition().Equals(typeof(Nullable<>))) + { + if (value == null) + return null; + + t = Nullable.GetUnderlyingType(t); + } + + return Convert.ChangeType(value, t); + } } } diff --git a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs index 30faf32fea..1757109b71 100644 --- a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs @@ -15,6 +15,8 @@ public interface IJsonApiContext QuerySet QuerySet { get; set; } bool IsRelationshipData { get; set; } List IncludedRelationships { get; set; } - int TotalRecords { get; set; } + bool IsRelationshipPath { get; } + PageManager PageManager { get; set; } + } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiContext.cs b/src/JsonApiDotNetCore/Services/JsonApiContext.cs index 8423eb4450..5ecd72872d 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiContext.cs @@ -27,12 +27,14 @@ public JsonApiContext( public string BasePath { get; set; } public QuerySet QuerySet { get; set; } public bool IsRelationshipData { get; set; } + public bool IsRelationshipPath { get; private set; } public List IncludedRelationships { get; set; } - public int TotalRecords { get; set; } + public PageManager PageManager { get; set; } public IJsonApiContext ApplyContext() { var context = _httpContextAccessor.HttpContext; + var path = context.Request.Path.Value.Split('/'); RequestEntity = ContextGraph.GetContextEntity(typeof(T)); @@ -40,12 +42,27 @@ public IJsonApiContext ApplyContext() { QuerySet = new QuerySet(this, context.Request.Query); IncludedRelationships = QuerySet.IncludedRelationships; - } + } var linkBuilder = new LinkBuilder(this); BasePath = linkBuilder.GetBasePath(context, RequestEntity.EntityName); - + PageManager = GetPageManager(); + IsRelationshipPath = path[path.Length - 2] == "relationships"; return this; } + + private PageManager GetPageManager() + { + if(Options.DefaultPageSize == 0 && (QuerySet == null || QuerySet.PageQuery.PageSize == 0)) + return new PageManager(); + + var query = QuerySet?.PageQuery ?? new PageQuery(); + + return new PageManager { + DefaultPageSize = Options.DefaultPageSize, + CurrentPage = query.PageOffset > 0 ? query.PageOffset : 1, + PageSize = query.PageSize > 0 ? query.PageSize : Options.DefaultPageSize + }; + } } } diff --git a/src/JsonApiDotNetCore/project.json b/src/JsonApiDotNetCore/project.json index d56b00e58f..79b4e420d0 100644 --- a/src/JsonApiDotNetCore/project.json +++ b/src/JsonApiDotNetCore/project.json @@ -1,5 +1,5 @@ { - "version": "0.2.12", + "version": "1.0.0", "dependencies": { "Microsoft.NETCore.App": { diff --git a/src/JsonApiDotNetCoreExample/Controllers/TodoItemCollectionsController.cs b/src/JsonApiDotNetCoreExample/Controllers/TodoItemCollectionsController.cs new file mode 100644 index 0000000000..c2dbb48a51 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Controllers/TodoItemCollectionsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public class TodoItemCollectionsController : JsonApiController + { + public TodoItemCollectionsController( + IJsonApiContext jsonApiContext, + IEntityRepository entityRepository, + ILoggerFactory loggerFactory) + : base(jsonApiContext, entityRepository, loggerFactory) + { } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 1a1090f617..46b29924f8 100644 --- a/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -11,5 +11,6 @@ public AppDbContext(DbContextOptions options) public DbSet TodoItems { get; set; } public DbSet People { get; set; } + public DbSet TodoItemCollection { get; set; } } } diff --git a/src/JsonApiDotNetCoreExample/Migrations/20170228175630_MakeOwnerOptionalOnTodoItems.Designer.cs b/src/JsonApiDotNetCoreExample/Migrations/20170228175630_MakeOwnerOptionalOnTodoItems.Designer.cs new file mode 100755 index 0000000000..504ec8f1d3 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Migrations/20170228175630_MakeOwnerOptionalOnTodoItems.Designer.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using JsonApiDotNetCoreExample.Data; + +namespace JsonApiDotNetCoreExample.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20170228175630_MakeOwnerOptionalOnTodoItems")] + partial class MakeOwnerOptionalOnTodoItems + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752"); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("FirstName"); + + b.Property("LastName"); + + b.HasKey("Id"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Description"); + + b.Property("Ordinal"); + + b.Property("OwnerId"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => + { + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") + .WithMany("TodoItems") + .HasForeignKey("OwnerId"); + }); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Migrations/20170228175630_MakeOwnerOptionalOnTodoItems.cs b/src/JsonApiDotNetCoreExample/Migrations/20170228175630_MakeOwnerOptionalOnTodoItems.cs new file mode 100755 index 0000000000..96f199d56b --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Migrations/20170228175630_MakeOwnerOptionalOnTodoItems.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace JsonApiDotNetCoreExample.Migrations +{ + public partial class MakeOwnerOptionalOnTodoItems : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TodoItems_People_OwnerId", + table: "TodoItems"); + + migrationBuilder.AlterColumn( + name: "OwnerId", + table: "TodoItems", + nullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_TodoItems_People_OwnerId", + table: "TodoItems", + column: "OwnerId", + principalTable: "People", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TodoItems_People_OwnerId", + table: "TodoItems"); + + migrationBuilder.AlterColumn( + name: "OwnerId", + table: "TodoItems", + nullable: false); + + migrationBuilder.AddForeignKey( + name: "FK_TodoItems_People_OwnerId", + table: "TodoItems", + column: "OwnerId", + principalTable: "People", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Migrations/20170228193414_AddTodoItemCollection.Designer.cs b/src/JsonApiDotNetCoreExample/Migrations/20170228193414_AddTodoItemCollection.Designer.cs new file mode 100755 index 0000000000..eb84318106 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Migrations/20170228193414_AddTodoItemCollection.Designer.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using JsonApiDotNetCoreExample.Data; + +namespace JsonApiDotNetCoreExample.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20170228193414_AddTodoItemCollection")] + partial class AddTodoItemCollection + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752"); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("FirstName"); + + b.Property("LastName"); + + b.HasKey("Id"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("CollectionId"); + + b.Property("Description"); + + b.Property("Ordinal"); + + b.Property("OwnerId"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItemCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Name"); + + b.Property("OwnerId"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoItemCollection"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => + { + b.HasOne("JsonApiDotNetCoreExample.Models.TodoItemCollection", "Collection") + .WithMany("TodoItems") + .HasForeignKey("CollectionId"); + + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") + .WithMany("TodoItems") + .HasForeignKey("OwnerId"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItemCollection", b => + { + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") + .WithMany("TodoItemCollections") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + }); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Migrations/20170228193414_AddTodoItemCollection.cs b/src/JsonApiDotNetCoreExample/Migrations/20170228193414_AddTodoItemCollection.cs new file mode 100755 index 0000000000..3262a40f28 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Migrations/20170228193414_AddTodoItemCollection.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCoreExample.Migrations +{ + public partial class AddTodoItemCollection : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CollectionId", + table: "TodoItems", + nullable: true); + + migrationBuilder.CreateTable( + name: "TodoItemCollection", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn), + Name = table.Column(nullable: true), + OwnerId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoItemCollection", x => x.Id); + table.ForeignKey( + name: "FK_TodoItemCollection_People_OwnerId", + column: x => x.OwnerId, + principalTable: "People", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TodoItems_CollectionId", + table: "TodoItems", + column: "CollectionId"); + + migrationBuilder.CreateIndex( + name: "IX_TodoItemCollection_OwnerId", + table: "TodoItemCollection", + column: "OwnerId"); + + migrationBuilder.AddForeignKey( + name: "FK_TodoItems_TodoItemCollection_CollectionId", + table: "TodoItems", + column: "CollectionId", + principalTable: "TodoItemCollection", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TodoItems_TodoItemCollection_CollectionId", + table: "TodoItems"); + + migrationBuilder.DropTable( + name: "TodoItemCollection"); + + migrationBuilder.DropIndex( + name: "IX_TodoItems_CollectionId", + table: "TodoItems"); + + migrationBuilder.DropColumn( + name: "CollectionId", + table: "TodoItems"); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs b/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs index 181ffc1fa8..9fe2c5e9c2 100755 --- a/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs @@ -35,23 +35,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd(); + b.Property("CollectionId"); + b.Property("Description"); b.Property("Ordinal"); - b.Property("OwnerId"); + b.Property("OwnerId"); b.HasKey("Id"); + b.HasIndex("CollectionId"); + b.HasIndex("OwnerId"); b.ToTable("TodoItems"); }); + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItemCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Name"); + + b.Property("OwnerId"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoItemCollection"); + }); + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItem", b => { + b.HasOne("JsonApiDotNetCoreExample.Models.TodoItemCollection", "Collection") + .WithMany("TodoItems") + .HasForeignKey("CollectionId"); + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") .WithMany("TodoItems") + .HasForeignKey("OwnerId"); + }); + + modelBuilder.Entity("JsonApiDotNetCoreExample.Models.TodoItemCollection", b => + { + b.HasOne("JsonApiDotNetCoreExample.Models.Person", "Owner") + .WithMany("TodoItemCollections") .HasForeignKey("OwnerId") .OnDelete(DeleteBehavior.Cascade); }); diff --git a/src/JsonApiDotNetCoreExample/Models/Person.cs b/src/JsonApiDotNetCoreExample/Models/Person.cs index 4c09bd01f5..3689f39537 100644 --- a/src/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/JsonApiDotNetCoreExample/Models/Person.cs @@ -5,17 +5,19 @@ namespace JsonApiDotNetCoreExample.Models { - public class Person : Identifiable, IHasMeta + public class Person : Identifiable, IHasMeta { - public override int Id { get; set; } - [Attr("first-name")] public string FirstName { get; set; } [Attr("last-name")] public string LastName { get; set; } + [HasMany("todo-items")] public virtual List TodoItems { get; set; } + + [HasMany("todo-item-collections")] + public virtual List TodoItemCollections { get; set; } public Dictionary GetMeta(IJsonApiContext context) { diff --git a/src/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/JsonApiDotNetCoreExample/Models/TodoItem.cs index cba9e45c5d..5d706da5db 100644 --- a/src/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -3,17 +3,21 @@ namespace JsonApiDotNetCoreExample.Models { - public class TodoItem : Identifiable + public class TodoItem : Identifiable { - public override int Id { get; set; } - [Attr("description")] public string Description { get; set; } [Attr("ordinal")] public long Ordinal { get; set; } - public int OwnerId { get; set; } + public int? OwnerId { get; set; } + public int? CollectionId { get; set; } + + [HasOne("owner")] public virtual Person Owner { get; set; } + + [HasOne("collection")] + public virtual TodoItemCollection Collection { get; set; } } } diff --git a/src/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs new file mode 100644 index 0000000000..c5b1dda453 --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCoreExample.Models +{ + public class TodoItemCollection : Identifiable + { + [Attr("name")] + public string Name { get; set; } + public int OwnerId { get; set; } + + [HasMany("todo-items")] + public virtual List TodoItems { get; set; } + + [HasOne("owner")] + public virtual Person Owner { get; set; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCoreExample/Startup.cs b/src/JsonApiDotNetCoreExample/Startup.cs index 6ae05f93d3..2abea4baad 100644 --- a/src/JsonApiDotNetCoreExample/Startup.cs +++ b/src/JsonApiDotNetCoreExample/Startup.cs @@ -44,6 +44,7 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) { opt.Namespace = "api/v1"; opt.DefaultPageSize = 5; + opt.IncludeTotalRecordCount = true; }); services.AddDocumentationConfiguration(Config); @@ -52,6 +53,7 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) var appContext = provider.GetRequiredService(); if(appContext == null) throw new ArgumentException(); + return provider; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs new file mode 100644 index 0000000000..3409e1824c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RepositoryOverrideTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Xunit; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExampleTests.Startups; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Services; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility +{ + [Collection("WebHostCollection")] + public class RepositoryOverrideTests + { + public RepositoryOverrideTests() + { } + + [Fact] + public async Task Total_Record_Count_Included() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var server = new TestServer(builder); + var client = server.CreateClient(); + var context = (AppDbContext)server.Host.Services.GetService(typeof(AppDbContext)); + var jsonApiContext = (IJsonApiContext)server.Host.Services.GetService(typeof(IJsonApiContext)); + + var person = new Person(); + context.People.Add(person); + var ownedTodoItem = new TodoItem(); + var unOwnedTodoItem = new TodoItem(); + ownedTodoItem.Owner = person; + context.TodoItems.Add(ownedTodoItem); + context.TodoItems.Add(unOwnedTodoItem); + context.SaveChanges(); + + var authService = (IAuthorizationService)server.Host.Services.GetService(typeof(IAuthorizationService)); + authService.CurrentUserId = person.Id; + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?include=owner"; + + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonApiDeSerializer.DeserializeList(responseBody, jsonApiContext); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + foreach(var item in deserializedBody) + Assert.Equal(person.Id, item.OwnerId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs new file mode 100644 index 0000000000..027e0e29e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class CreatingDataTests + { + private DocsFixture _fixture; + private IJsonApiContext _jsonApiContext; + private Faker _todoItemFaker; + + public CreatingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _jsonApiContext = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Request_With_ClientGeneratedId_Returns_403() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var route = "/api/v1/todo-items"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + var todoItem = _todoItemFaker.Generate(); + var content = new + { + data = new + { + type = "todo-items", + id = "9999", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + 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.Forbidden, response.StatusCode); + } + + [Fact] + public async Task ShouldReceiveLocationHeader_InResponse() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var route = "/api/v1/todo-items"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + var todoItem = _todoItemFaker.Generate(); + var content = new + { + data = new + { + type = "todo-items", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + 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(); + var deserializedBody = (TodoItem)JsonApiDeSerializer.Deserialize(body, _jsonApiContext); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"/api/v1/todo-items/{deserializedBody.Id}", response.Headers.Location.ToString()); + } + + [Fact] + public async Task Respond_409_ToIncorrectEntityType() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var route = "/api/v1/todo-items"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + var todoItem = _todoItemFaker.Generate(); + var content = new + { + data = new + { + type = "people", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + 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.Conflict, response.StatusCode); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs new file mode 100644 index 0000000000..888f1a262c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class DeletingDataTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _todoItemFaker; + + public DeletingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Respond_404_If_EntityDoesNotExist() + { + // arrange + var maxPersonId = _context.TodoItems.LastOrDefault()?.Id ?? 0; + var todoItem = _todoItemFaker.Generate(); + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var httpMethod = new HttpMethod("DELETE"); + var route = $"/api/v1/todo-items/{maxPersonId + 100}"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs index 5b8096577f..9ddf5519cf 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs @@ -13,7 +13,7 @@ using JsonApiDotNetCoreExample.Data; using Bogus; using JsonApiDotNetCoreExample.Models; -using System; +using System.Linq; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { @@ -24,6 +24,7 @@ public class Included private AppDbContext _context; private Faker _personFaker; private Faker _todoItemFaker; + private Faker _todoItemCollectionFaker; public Included(DocsFixture fixture) { @@ -36,6 +37,9 @@ public Included(DocsFixture fixture) _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()); + + _todoItemCollectionFaker = new Faker() + .RuleFor(t => t.Name, f => f.Company.CatchPhrase()); } [Fact] @@ -102,6 +106,7 @@ public async Task GET_Included_Contains_SideloadedData_OneToMany() { // arrange _context.People.RemoveRange(_context.People); // ensure all people have todo-items + _context.TodoItems.RemoveRange(_context.TodoItems); var person = _personFaker.Generate(); var todoItem = _todoItemFaker.Generate(); todoItem.Owner = person; @@ -164,5 +169,93 @@ public async Task GET_ById_Included_Contains_SideloadedData_ForOneToMany() Assert.NotEmpty(document.Included); Assert.Equal(numberOfTodoItems, document.Included.Count); } + + [Fact] + public async Task Can_Include_MultipleRelationships() + { + // arrange + var person = _personFaker.Generate(); + var todoItemCollection = _todoItemCollectionFaker.Generate(); + todoItemCollection.Owner = person; + + const int numberOfTodoItems = 5; + for (var i = 0; i < numberOfTodoItems; i++) + { + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + todoItem.Collection = todoItemCollection; + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + } + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + + var route = $"/api/v1/people/{person.Id}?include=todo-items,todo-item-collections"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var responseString = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(responseString); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(document.Included); + Assert.Equal(numberOfTodoItems + 1, document.Included.Count); + } + + [Fact] + public async Task Request_ToIncludeUnknownRelationship_Returns_400() + { + // arrange + var person = _context.People.First(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + + var route = $"/api/v1/people/{person.Id}?include=non-existent-relationship"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Request_ToIncludeDeeplyNestedRelationships_Returns_400() + { + // arrange + var person = _context.People.First(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + + var route = $"/api/v1/people/{person.Id}?include=owner.name"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs index 0be777a4f7..e024786252 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs @@ -14,6 +14,7 @@ using JsonApiDotNetCoreExampleTests.Startups; using JsonApiDotNetCoreExample.Models; using System.Collections; +using System; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { @@ -32,7 +33,10 @@ public Meta(DocsFixture fixture) public async Task Total_Record_Count_Included() { // arrange - var expectedCount = _context.TodoItems.Count(); + _context.TodoItems.RemoveRange(_context.TodoItems); + _context.TodoItems.Add(new TodoItem()); + _context.SaveChanges(); + var expectedCount = 1; var builder = new WebHostBuilder() .UseStartup(); @@ -45,7 +49,8 @@ public async Task Total_Record_Count_Included() // act var response = await client.SendAsync(request); - var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var responseBody = await response.Content.ReadAsStringAsync(); + var documents = JsonConvert.DeserializeObject(responseBody); // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs new file mode 100644 index 0000000000..0ea3b5a0d2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; +using Bogus; +using JsonApiDotNetCoreExample.Models; +using System; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests +{ + [Collection("WebHostCollection")] + public class PagingTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _personFaker; + private Faker _todoItemFaker; + private Faker _todoItemCollectionFaker; + + public PagingTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); + + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + + _todoItemCollectionFaker = new Faker() + .RuleFor(t => t.Name, f => f.Company.CatchPhrase()); + } + + [Fact] + public async Task Server_IncludesPagination_Links() + { + // arrange + var pageSize = 5; + const int minimumNumberOfRecords = 11; + _context.TodoItems.RemoveRange(_context.TodoItems); + + for(var i=0; i < minimumNumberOfRecords; i++) + _context.TodoItems.Add(_todoItemFaker.Generate()); + + await _context.SaveChangesAsync(); + + var numberOfPages = (int)Math.Ceiling(decimal.Divide(minimumNumberOfRecords, pageSize)); + var startPageNumber = 2; + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?page[number]=2"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var links = documents.Links; + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(links.First); + Assert.NotEmpty(links.Next); + Assert.NotEmpty(links.Last); + + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]={startPageNumber+1}", links.Next); + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]={startPageNumber-1}", links.Prev); + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]={numberOfPages}", links.Last); + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]=1", links.First); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index 32637a64e2..744a395ce5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -11,6 +11,8 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; using System.Linq; +using Bogus; +using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { @@ -19,10 +21,15 @@ public class Relationships { private DocsFixture _fixture; private AppDbContext _context; + private Faker _todoItemFaker; + public Relationships(DocsFixture fixture) { _fixture = fixture; _context = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); } [Fact] @@ -31,9 +38,13 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() // arrange var builder = new WebHostBuilder() .UseStartup(); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items"; + var route = $"/api/v1/todo-items/{todoItem.Id}"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -41,8 +52,8 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() // act var response = await client.SendAsync(request); - var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - var data = documents.Data[0]; + var document = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var data = document.Data; var expectedOwnerSelfLink = $"http://localhost/api/v1/todo-items/{data.Id}/relationships/owner"; var expectedOwnerRelatedLink = $"http://localhost/api/v1/todo-items/{data.Id}/owner"; @@ -56,13 +67,15 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() { // arrange - var todoItemId = _context.TodoItems.Last().Id; - var builder = new WebHostBuilder() .UseStartup(); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items/{todoItemId}"; + var route = $"/api/v1/todo-items/{todoItem.Id}"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -72,8 +85,8 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() var response = await client.SendAsync(request); var responseString = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject(responseString).Data; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todo-items/{todoItemId}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todo-items/{todoItemId}/owner"; + var expectedOwnerSelfLink = $"http://localhost/api/v1/todo-items/{todoItem.Id}/relationships/owner"; + var expectedOwnerRelatedLink = $"http://localhost/api/v1/todo-items/{todoItem.Id}/owner"; // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs new file mode 100644 index 0000000000..6a11cb459d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class FetchingDataTests + { + private DocsFixture _fixture; + private IJsonApiContext _jsonApiContext; + + public FetchingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _jsonApiContext = fixture.GetService(); + } + + [Fact] + public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() + { + // arrange + var context = _fixture.GetService(); + context.TodoItems.RemoveRange(context.TodoItems); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = "/api/v1/todo-items"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + var expectedBody = JsonConvert.SerializeObject(new { + data = new List() + }); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); + Assert.Empty(deserializedBody); + Assert.Equal(expectedBody, body); + + context.Dispose(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs new file mode 100644 index 0000000000..6522e79b62 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class FetchingRelationshipsTests + { + private DocsFixture _fixture; + private IJsonApiContext _jsonApiContext; + private Faker _todoItemFaker; + + public FetchingRelationshipsTests(DocsFixture fixture) + { + _fixture = fixture; + _jsonApiContext = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Request_UnsetRelationship_Returns_Null_DataObject() + { + // arrange + var context = _fixture.GetService(); + var todoItem = _todoItemFaker.Generate(); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items/{todoItem.Id}/owner"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + var expectedBody = "{\"data\":null}"; + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/vnd.api+json", response.Content.Headers.ContentType.ToString()); + Assert.Equal(expectedBody, body); + + context.Dispose(); + } + + [Fact] + public async Task Request_ForRelationshipLink_ThatDoesNotExist_Returns_404() + { + // arrange + var context = _fixture.GetService(); + var todoItem = context.TodoItems.First(); + var todoItemId = todoItem.Id; + context.TodoItems.Remove(todoItem); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items/{todoItemId}/owner"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + context.Dispose(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs new file mode 100644 index 0000000000..c87683fa2f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class UpdatingDataTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _todoItemFaker; + + public UpdatingDataTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Respond_404_If_EntityDoesNotExist() + { + // arrange + var maxPersonId = _context.TodoItems.LastOrDefault()?.Id ?? 0; + var todoItem = _todoItemFaker.Generate(); + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new + { + type = "todo-items", + attributes = new + { + description = todoItem.Description, + ordinal = todoItem.Ordinal + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todo-items/{maxPersonId + 100}"; + var request = new HttpRequestMessage(httpMethod, route); + + 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.NotFound, response.StatusCode); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs new file mode 100644 index 0000000000..dfbd862232 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + [Collection("WebHostCollection")] + public class UpdatingRelationshipsTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _personFaker; + private Faker _todoItemFaker; + + public UpdatingRelationshipsTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _personFaker = new Faker() + .RuleFor(t => t.FirstName, f => f.Name.FirstName()) + .RuleFor(t => t.LastName, f => f.Name.LastName()); + + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + } + + [Fact] + public async Task Can_Update_ToMany_Relationship_ThroughLink() + { + // arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new List + { + new { + type = "todo-items", + id = $"{todoItem.Id}" + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/people/{person.Id}/relationships/todo-items"; + var request = new HttpRequestMessage(httpMethod, route); + + 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 personsTodoItems = _context.People.Include(p => p.TodoItems).Single(p => p.Id == person.Id).TodoItems; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(personsTodoItems); + } + + [Fact] + public async Task Can_Update_ToOne_Relationship_ThroughLink() + { + // arrange + var person = _personFaker.Generate(); + _context.People.Add(person); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + + _context.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var content = new + { + data = new + { + type = "person", + id = $"{person.Id}" + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todo-items/{todoItem.Id}/relationships/owner"; + var request = new HttpRequestMessage(httpMethod, route); + + 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 todoItemsOwner = _context.TodoItems.Include(t => t.Owner).Single(t => t.Id == todoItem.Id); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(todoItemsOwner); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 0c137a87bc..a6d234f3cd 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -124,6 +124,36 @@ public async Task Can_Filter_TodoItems() Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); } + [Fact] + public async Task Can_Filter_TodoItems_Using_Like_Operator() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.Ordinal = 999999; + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + var substring = todoItem.Description.Substring(1, todoItem.Description.Length - 2); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?filter[description]=like:{substring}"; + + var description = new RequestProperties("Filter TodoItems Where Attribute Like", new Dictionary { + { "?filter[...]=", "Filter on attribute" } + }); + + // Act + var response = await _fixture.MakeRequest(description, httpMethod, route); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonApiDeSerializer.DeserializeList(body, _jsonApiContext); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(deserializedBody); + + foreach (var todoItemResult in deserializedBody) + Assert.Contains(substring, todoItem.Description); + } + [Fact] public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() { diff --git a/test/JsonApiDotNetCoreExampleTests/Repositories/AuthorizedTodoItemsRepository.cs b/test/JsonApiDotNetCoreExampleTests/Repositories/AuthorizedTodoItemsRepository.cs new file mode 100644 index 0000000000..b443bb605b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Repositories/AuthorizedTodoItemsRepository.cs @@ -0,0 +1,33 @@ +using System.Linq; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.Repositories +{ + public class AuthorizedTodoItemsRepository : DefaultEntityRepository + { + private readonly ILogger _logger; + private readonly AppDbContext _context; + private readonly IAuthorizationService _authService; + + public AuthorizedTodoItemsRepository(AppDbContext context, + ILoggerFactory loggerFactory, + IJsonApiContext jsonApiContext, + IAuthorizationService authService) + : base(context, loggerFactory, jsonApiContext) + { + _context = context; + _logger = loggerFactory.CreateLogger(); + _authService = authService; + } + + public override IQueryable Get() + { + return base.Get().Where(todoItem => todoItem.OwnerId == _authService.CurrentUserId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Services/IAuthorizationService.cs b/test/JsonApiDotNetCoreExampleTests/Services/IAuthorizationService.cs new file mode 100644 index 0000000000..b994c7f8bd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Services/IAuthorizationService.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCoreExampleTests.Services +{ + public interface IAuthorizationService + { + int CurrentUserId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/AuthorizedStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/AuthorizedStartup.cs new file mode 100644 index 0000000000..b9272e2de7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/AuthorizedStartup.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Extensions; +using DotNetCoreDocs.Configuration; +using System; +using JsonApiDotNetCoreExample; +using Moq; +using JsonApiDotNetCoreExampleTests.Services; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Repositories; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public class AuthorizedStartup : Startup + { + public AuthorizedStartup(IHostingEnvironment env) + : base (env) + { } + + public override IServiceProvider ConfigureServices(IServiceCollection services) + { + var loggerFactory = new LoggerFactory(); + + loggerFactory + .AddConsole(LogLevel.Trace); + + services.AddSingleton(loggerFactory); + + services.AddDbContext(options => + { + options.UseNpgsql(GetDbConnectionString()); + }, ServiceLifetime.Transient); + + services.AddJsonApi(opt => + { + opt.Namespace = "api/v1"; + opt.DefaultPageSize = 5; + opt.IncludeTotalRecordCount = true; + }); + + // custom authorization implementation + var authServicMock = new Mock(); + authServicMock.SetupAllProperties(); + services.AddSingleton(authServicMock.Object); + services.AddScoped, AuthorizedTodoItemsRepository>(); + + services.AddDocumentationConfiguration(Config); + + return services.BuildServiceProvider(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/project.json b/test/JsonApiDotNetCoreExampleTests/project.json index 8f7e5247d8..f7cd3ced06 100644 --- a/test/JsonApiDotNetCoreExampleTests/project.json +++ b/test/JsonApiDotNetCoreExampleTests/project.json @@ -11,7 +11,8 @@ "dotnet-test-xunit": "2.2.0-preview2-build1029", "xunit": "2.2.0-beta5-build3474", "Bogus": "8.0.1-beta-1", - "Microsoft.DotNet.InternalAbstractions": "1.0.0" + "Microsoft.DotNet.InternalAbstractions": "1.0.0", + "Moq": "4.7.1" }, "tools": {