diff --git a/JsonApiDotnetCore.sln b/JsonApiDotnetCore.sln index a9640c1142..7a4f950746 100644 --- a/JsonApiDotnetCore.sln +++ b/JsonApiDotnetCore.sln @@ -1,7 +1,6 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26228.4 +VisualStudioVersion = 15.0.26228.9 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore", "src\JsonApiDotNetCore\JsonApiDotNetCore.csproj", "{C0EC9E70-EB2E-436F-9D94-FA16FA774123}" EndProject @@ -18,24 +17,68 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitignore = .gitignore EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoEntityFrameworkExample", "src\NoEntityFrameworkExample\NoEntityFrameworkExample.csproj", "{570165EC-62B5-4684-A139-8D2A30DD4475}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoEntityFrameworkTests", "test\NoEntityFrameworkTests\NoEntityFrameworkTests.csproj", "{73DA578D-A63F-4956-83ED-6D7102E09140}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Debug|x86.ActiveCfg = Debug|Any CPU {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Release|Any CPU.ActiveCfg = Release|Any CPU {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Release|Any CPU.Build.0 = Release|Any CPU + {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Release|x64.ActiveCfg = Release|Any CPU + {C0EC9E70-EB2E-436F-9D94-FA16FA774123}.Release|x86.ActiveCfg = Release|Any CPU {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Debug|x64.ActiveCfg = Debug|Any CPU + {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Debug|x86.ActiveCfg = Debug|Any CPU {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Release|Any CPU.ActiveCfg = Release|Any CPU {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Release|Any CPU.Build.0 = Release|Any CPU + {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Release|x64.ActiveCfg = Release|Any CPU + {97EE048B-16C0-43F6-BDA9-4E762B2F579F}.Release|x86.ActiveCfg = Release|Any CPU {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Debug|x64.ActiveCfg = Debug|Any CPU + {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Debug|x86.ActiveCfg = Debug|Any CPU {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Release|Any CPU.ActiveCfg = Release|Any CPU {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Release|Any CPU.Build.0 = Release|Any CPU + {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Release|x64.ActiveCfg = Release|Any CPU + {0B959765-40D2-43B5-87EE-FE2FEF9DBED5}.Release|x86.ActiveCfg = Release|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Debug|Any CPU.Build.0 = Debug|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Debug|x64.ActiveCfg = Debug|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Debug|x64.Build.0 = Debug|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Debug|x86.ActiveCfg = Debug|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Debug|x86.Build.0 = Debug|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Release|Any CPU.ActiveCfg = Release|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Release|Any CPU.Build.0 = Release|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Release|x64.ActiveCfg = Release|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Release|x64.Build.0 = Release|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Release|x86.ActiveCfg = Release|Any CPU + {570165EC-62B5-4684-A139-8D2A30DD4475}.Release|x86.Build.0 = Release|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Debug|x64.ActiveCfg = Debug|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Debug|x64.Build.0 = Debug|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Debug|x86.ActiveCfg = Debug|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Debug|x86.Build.0 = Debug|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Release|Any CPU.Build.0 = Release|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Release|x64.ActiveCfg = Release|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Release|x64.Build.0 = Release|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Release|x86.ActiveCfg = Release|Any CPU + {73DA578D-A63F-4956-83ED-6D7102E09140}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -44,5 +87,7 @@ Global {C0EC9E70-EB2E-436F-9D94-FA16FA774123} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {97EE048B-16C0-43F6-BDA9-4E762B2F579F} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {0B959765-40D2-43B5-87EE-FE2FEF9DBED5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {570165EC-62B5-4684-A139-8D2A30DD4475} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {73DA578D-A63F-4956-83ED-6D7102E09140} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 362225c453..d1d41e9849 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ JsonApiDotnetCore provides a framework for building [json:api](http://jsonapi.or - [Defining Models](#defining-models) - [Specifying Public Attributes](#specifying-public-attributes) - [Relationships](#relationships) + - [Resource Names](#resource-names) - [Defining Controllers](#defining-controllers) - [Non-Integer Type Keys](#non-integer-type-keys) - [Routing](#routing) @@ -169,6 +170,23 @@ public class TodoItem : Identifiable } ``` +#### Resource Names + +If a DbContext is specified when adding the services, the context will be used to define the resources and their names. + +```csharp +public DbSet SomeModels { get; set; } // this will be translated into "some-models" +``` + +However, you can specify a custom name like so: + +```csharp +[Resource("some-models")] +public DbSet MyModels { get; set; } // this will be translated into "some-models" +``` + +For further resource customizations, please see the section on [Defining Custom Data Access Methods](#defining-custom-data-access-methods). + ### Defining Controllers You need to create controllers that inherit from `JsonApiController` or `JsonApiController` @@ -180,9 +198,9 @@ public class ThingsController : JsonApiController { public ThingsController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) { } } ``` @@ -199,9 +217,9 @@ public class ThingsController : JsonApiController { public ThingsController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) { } } ``` @@ -228,7 +246,67 @@ services.AddJsonApi( ### Defining Custom Data Access Methods -You can implement custom methods for accessing the data by creating an implementation of +By default, data retrieval is distributed across 3 layers: + +1. `JsonApiController` +2. `EntityResourceService` +3. `DefaultEntityRepository` + +Customization can be done at any of these layers. However, it is recommended that you make your customizations at the service or the repository layer when possible to keep the controllers free of unnecessary logic. + +#### Not Using Entity Framework? + +Out of the box, the library uses your `DbContext` to create a "ContextGraph" or map of all your models and their relationships. If, however, you have models that are not members of a `DbContext`, you can manually create this graph like so: + +```csharp +// Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Add framework services. + var mvcBuilder = services.AddMvc(); + + services.AddJsonApi(options => { + options.Namespace = "api/v1"; + options.BuildContextGraph((builder) => { + builder.AddResource("my-models"); + }); + }, mvcBuilder); + // ... +} +``` + +#### Custom Resource Service Implementation + +By default, this library uses Entity Framework. If you'd like to use another ORM that does not implement `IQueryable`, you can inject a custom service like so: + +```csharp +// Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + services.AddScoped, MyModelService>(); + // ... +} +``` + +```csharp +// MyModelService.cs +public class MyModelService : IResourceService +{ + private readonly IMyModelDAL _dal; + public MyModelService(IMyModelDAL dal) + { + _dal = dal; + } + public Task> GetAsync() + { + return await _dal.GetModelAsync(); + } +} +``` + +#### Custom Entity Repository Implementation + +If you want to use EF, but need additional data access logic (such as authorization), you can implement custom methods for accessing the data by creating an implementation of `IEntityRepository`. If you only need minor changes you can override the methods defined in `DefaultEntityRepository`. The repository should then be add to the service collection in `Startup.ConfigureServices` like so: diff --git a/build.sh b/build.sh index 6efa9b472f..d4d4fbe65a 100755 --- a/build.sh +++ b/build.sh @@ -6,5 +6,7 @@ set -e dotnet restore ./src/JsonApiDotNetCore/JsonApiDotNetCore.csproj dotnet restore ./src/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj dotnet restore ./test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +dotnet restore ./test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj -dotnet test ./test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj \ No newline at end of file +dotnet test ./test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +dotnet test ./test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs new file mode 100644 index 0000000000..e90f1ca7ff --- /dev/null +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Extensions; + +namespace JsonApiDotNetCore.Builders +{ + public class ContextGraphBuilder : IContextGraphBuilder + { + private List Entities; + private bool _usesDbContext; + public ContextGraphBuilder() + { + Entities = new List(); + } + + public IContextGraph Build() + { + var graph = new ContextGraph() + { + Entities = Entities, + UsesDbContext = _usesDbContext + }; + return graph; + } + + public void AddResource(string pluralizedTypeName) where TResource : class + { + var entityType = typeof(TResource); + Entities.Add(new ContextEntity + { + EntityName = pluralizedTypeName, + EntityType = entityType, + Attributes = GetAttributes(entityType), + Relationships = GetRelationships(entityType) + }); + } + + protected virtual List GetAttributes(Type entityType) + { + var attributes = new List(); + + var properties = entityType.GetProperties(); + + foreach (var prop in properties) + { + var attribute = (AttrAttribute)prop.GetCustomAttribute(typeof(AttrAttribute)); + if (attribute == null) continue; + attribute.InternalAttributeName = prop.Name; + attributes.Add(attribute); + } + return attributes; + } + + protected virtual List GetRelationships(Type entityType) + { + 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; + } + + protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) + { + if (relation.IsHasMany) + return prop.PropertyType.GetGenericArguments()[0]; + else + return prop.PropertyType; + } + + public void AddDbContext() where T : DbContext + { + _usesDbContext = true; + + var contextType = typeof(T); + + var entities = new List(); + + var contextProperties = contextType.GetProperties(); + + foreach (var property in contextProperties) + { + var dbSetType = property.PropertyType; + + if (dbSetType.GetTypeInfo().IsGenericType + && dbSetType.GetGenericTypeDefinition() == typeof(DbSet<>)) + { + var entityType = dbSetType.GetGenericArguments()[0]; + entities.Add(new ContextEntity + { + EntityName = GetResourceName(property), + EntityType = entityType, + Attributes = GetAttributes(entityType), + Relationships = GetRelationships(entityType) + }); + } + } + + Entities = entities; + } + + private string GetResourceName(PropertyInfo property) + { + var resourceAttribute = property.GetCustomAttribute(typeof(ResourceAttribute)); + if(resourceAttribute == null) + return property.Name.Dasherize(); + + return ((ResourceAttribute)resourceAttribute).ResourceName; + } + } +} diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index 971fd2af10..fd9b29a0d5 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -1,7 +1,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -143,12 +142,12 @@ private void AddRelationships(DocumentData data, ContextEntity contextEntity, II { Links = new Links { - Self = linkBuilder.GetSelfRelationLink(contextEntity.EntityName, entity.StringId, r.InternalRelationshipName), - Related = linkBuilder.GetRelatedRelationLink(contextEntity.EntityName, entity.StringId, r.InternalRelationshipName) + Self = linkBuilder.GetSelfRelationLink(contextEntity.EntityName, entity.StringId, r.PublicRelationshipName), + Related = linkBuilder.GetRelatedRelationLink(contextEntity.EntityName, entity.StringId, r.PublicRelationshipName) } }; - if (RelationshipIsIncluded(r.InternalRelationshipName)) + if (RelationshipIsIncluded(r.PublicRelationshipName)) { var navigationEntity = _jsonApiContext.ContextGraph .GetRelationship(entity, r.InternalRelationshipName); @@ -161,7 +160,7 @@ private void AddRelationships(DocumentData data, ContextEntity contextEntity, II relationshipData.SingleData = GetRelationship(navigationEntity, r.InternalRelationshipName); } - data.Relationships.Add(r.InternalRelationshipName.Dasherize(), relationshipData); + data.Relationships.Add(r.PublicRelationshipName, relationshipData); }); } @@ -171,7 +170,7 @@ private List GetIncludedEntities(ContextEntity contextEntity, IIde contextEntity.Relationships.ForEach(r => { - if (!RelationshipIsIncluded(r.InternalRelationshipName)) return; + if (!RelationshipIsIncluded(r.PublicRelationshipName)) return; var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, r.InternalRelationshipName); @@ -214,7 +213,7 @@ private DocumentData GetIncludedEntity(IIdentifiable entity) private bool RelationshipIsIncluded(string relationshipName) { return _jsonApiContext.IncludedRelationships != null && - _jsonApiContext.IncludedRelationships.Contains(relationshipName.ToProperCase()); + _jsonApiContext.IncludedRelationships.Contains(relationshipName); } private List> GetRelationships(IEnumerable entities, string relationshipName) @@ -227,7 +226,7 @@ private List> GetRelationships(IEnumerable en foreach (var entity in entities) { relationships.Add(new Dictionary { - {"type", typeName.EntityName.Dasherize() }, + {"type", typeName.EntityName }, {"id", ((IIdentifiable)entity).StringId } }); } @@ -240,7 +239,7 @@ private Dictionary GetRelationship(object entity, string relatio var typeName = _jsonApiContext.ContextGraph.GetContextEntity(objType); return new Dictionary { - {"type", typeName.EntityName.Dasherize() }, + {"type", typeName.EntityName }, {"id", ((IIdentifiable)entity).StringId } }; } diff --git a/src/JsonApiDotNetCore/Builders/IContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/IContextGraphBuilder.cs new file mode 100644 index 0000000000..9249615a40 --- /dev/null +++ b/src/JsonApiDotNetCore/Builders/IContextGraphBuilder.cs @@ -0,0 +1,12 @@ +using JsonApiDotNetCore.Internal; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Builders +{ + public interface IContextGraphBuilder + { + IContextGraph Build(); + void AddResource(string pluralizedTypeName) where TResource : class; + void AddDbContext() where T : DbContext; + } +} diff --git a/src/JsonApiDotNetCore/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Builders/LinkBuilder.cs index 5fd25793d7..241646a850 100644 --- a/src/JsonApiDotNetCore/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/LinkBuilder.cs @@ -26,10 +26,10 @@ private string GetNamespaceFromPath(string path, string entityName) for(var i = 1; i < segments.Length; i++) { - if(segments[i].ToLower() == entityName.Dasherize()) + if(segments[i].ToLower() == entityName) break; - nSpace += $"/{segments[i].Dasherize()}"; + nSpace += $"/{segments[i]}"; } return nSpace; @@ -37,17 +37,17 @@ private string GetNamespaceFromPath(string path, string entityName) public string GetSelfRelationLink(string parent, string parentId, string child) { - return $"{_context.BasePath}/{parent.Dasherize()}/{parentId}/relationships/{child.Dasherize()}"; + return $"{_context.BasePath}/{parent}/{parentId}/relationships/{child}"; } public string GetRelatedRelationLink(string parent, string parentId, string child) { - return $"{_context.BasePath}/{parent.Dasherize()}/{parentId}/{child.Dasherize()}"; + return $"{_context.BasePath}/{parent}/{parentId}/{child}"; } public string GetPageLink(int pageOffset, int pageSize) { - return $"{_context.BasePath}/{_context.RequestEntity.EntityName.Dasherize()}?page[size]={pageSize}&page[number]={pageOffset}"; + return $"{_context.BasePath}/{_context.RequestEntity.EntityName}?page[size]={pageSize}&page[number]={pageOffset}"; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 3993823d98..cacb08ea40 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,3 +1,8 @@ +using System; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; +using Microsoft.EntityFrameworkCore; + namespace JsonApiDotNetCore.Configuration { public class JsonApiOptions @@ -6,5 +11,31 @@ public class JsonApiOptions public int DefaultPageSize { get; set; } public bool IncludeTotalRecordCount { get; set; } public bool AllowClientGeneratedIds { get; set; } + public IContextGraph ContextGraph { get; set; } + + public void BuildContextGraph(Action builder) + where TContext : DbContext + { + var contextGraphBuilder = new ContextGraphBuilder(); + + contextGraphBuilder.AddDbContext(); + + if(builder != null) + builder(contextGraphBuilder); + + ContextGraph = contextGraphBuilder.Build(); + } + + public void BuildContextGraph(Action builder) + { + if(builder == null) + throw new ArgumentException("Cannot build non-EF context graph without an IContextGraphBuilder action", nameof(builder)); + + var contextGraphBuilder = new ContextGraphBuilder(); + + builder(contextGraphBuilder); + + ContextGraph = contextGraphBuilder.Build(); + } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 044338f36c..d6ab13ae3e 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,15 +1,9 @@ using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace JsonApiDotNetCore.Controllers { @@ -18,69 +12,48 @@ public class JsonApiController { public JsonApiController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) { } } public class JsonApiController : JsonApiControllerMixin where T : class, IIdentifiable { - private readonly IEntityRepository _entities; - private readonly IJsonApiContext _jsonApiContext; private readonly ILogger _logger; + private readonly IResourceService _resourceService; + private readonly IJsonApiContext _jsonApiContext; public JsonApiController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) { _jsonApiContext = jsonApiContext.ApplyContext(); - - _entities = entityRepository; - + _resourceService = resourceService; _logger = loggerFactory.CreateLogger>(); - _logger.LogTrace($@"JsonApiController activated with ContextGraph: - {JsonConvert.SerializeObject(jsonApiContext.ContextGraph)}"); } public JsonApiController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository) + IResourceService resourceService) { _jsonApiContext = jsonApiContext.ApplyContext(); - _jsonApiContext = jsonApiContext; - _entities = entityRepository; + _resourceService = resourceService; } [HttpGet] public virtual async Task GetAsync() { - var entities = _entities.Get(); - - entities = ApplySortAndFilterQuery(entities); - - if (_jsonApiContext.QuerySet != null && _jsonApiContext.QuerySet.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0) - entities = IncludeRelationships(entities, _jsonApiContext.QuerySet.IncludedRelationships); - - if (_jsonApiContext.Options.IncludeTotalRecordCount) - _jsonApiContext.PageManager.TotalRecords = await entities.CountAsync(); - - // pagination should be done last since it will execute the query - var pagedEntities = await ApplyPageQueryAsync(entities); - - return Ok(pagedEntities); + var entities = await _resourceService.GetAsync(); + return Ok(entities); } [HttpGet("{id}")] public virtual async Task GetAsync(TId id) { - T entity; - if (_jsonApiContext.QuerySet?.IncludedRelationships != null) - entity = await _getWithRelationshipsAsync(id); - else - entity = await _entities.GetAsync(id); + var entity = await _resourceService.GetAsync(id); if (entity == null) return NotFound(); @@ -88,44 +61,20 @@ public virtual async Task GetAsync(TId id) return Ok(entity); } - private async Task _getWithRelationshipsAsync(TId id) - { - var query = _entities.Get(); - _jsonApiContext.QuerySet.IncludedRelationships.ForEach(r => - { - query = _entities.Include(query, r.ToProperCase()); - }); - return await query.FirstOrDefaultAsync(e => e.Id.Equals(id)); - } - [HttpGet("{id}/relationships/{relationshipName}")] public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) { - _jsonApiContext.IsRelationshipData = true; - + var relationship = _resourceService.GetRelationshipAsync(id, relationshipName); + if(relationship == null) + return NotFound(); + return await GetRelationshipAsync(id, relationshipName); } [HttpGet("{id}/{relationshipName}")] public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - 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 - .GetRelationship(entity, relationshipName); - + var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName); return Ok(relationship); } @@ -133,15 +82,12 @@ public virtual async Task GetRelationshipAsync(TId id, string rel public virtual async Task PostAsync([FromBody] T entity) { if (entity == null) - { - _logger?.LogInformation($"Entity cannot be null returning 422"); return UnprocessableEntity(); - } if (!_jsonApiContext.Options.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) return Forbidden(); - await _entities.CreateAsync(entity); + entity = await _resourceService.CreateAsync(entity); return Created($"{HttpContext.Request.Path}/{entity.Id}", entity); } @@ -150,14 +96,12 @@ public virtual async Task PostAsync([FromBody] T entity) public virtual async Task PatchAsync(TId id, [FromBody] T entity) { if (entity == null) - { - _logger?.LogInformation($"Entity cannot be null returning 422"); return UnprocessableEntity(); - } - - var updatedEntity = await _entities.UpdateAsync(id, entity); - - if (updatedEntity == null) return NotFound(); + + var updatedEntity = await _resourceService.UpdateAsync(id, entity); + + if(updatedEntity == null) + return NotFound(); return Ok(updatedEntity); } @@ -165,82 +109,19 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) [HttpPatch("{id}/relationships/{relationshipName}")] public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) { - 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); - + await _resourceService.UpdateRelationshipsAsync(id, relationshipName, relationships); return Ok(); - } [HttpDelete("{id}")] public virtual async Task DeleteAsync(TId id) { - var wasDeleted = await _entities.DeleteAsync(id); + var wasDeleted = await _resourceService.DeleteAsync(id); if (!wasDeleted) return NotFound(); return NoContent(); } - - private IQueryable ApplySortAndFilterQuery(IQueryable entities) - { - var query = _jsonApiContext.QuerySet; - - if (_jsonApiContext.QuerySet == null) - return entities; - - if (query.Filters.Count > 0) - foreach (var filter in query.Filters) - entities = _entities.Filter(entities, filter); - - if (query.SortParameters != null && query.SortParameters.Count > 0) - entities = _entities.Sort(entities, query.SortParameters); - - return entities; - } - - private async Task> ApplyPageQueryAsync(IQueryable entities) - { - var pageManager = _jsonApiContext.PageManager; - if (!pageManager.IsPaginated) - return entities; - - var query = _jsonApiContext.QuerySet?.PageQuery ?? new PageQuery(); - - _logger?.LogInformation($"Applying paging query. Fetching page {pageManager.CurrentPage} with {pageManager.PageSize} entities"); - - return await _entities.PageAsync(entities, pageManager.PageSize, pageManager.CurrentPage); - } - - private IQueryable IncludeRelationships(IQueryable entities, List relationships) - { - _jsonApiContext.IncludedRelationships = relationships; - - foreach (var r in relationships) - entities = _entities.Include(entities, r.ToProperCase()); - - return entities; - } } } diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index f7b0a5e960..5b5125ef91 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -138,10 +138,11 @@ 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.InternalRelationshipName == relationshipName)) - return entities.Include(relationshipName); + var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); + if(relationship != null) + return entities.Include(relationship.InternalRelationshipName); - throw new JsonApiException("400", "Invalid relationship", + throw new JsonApiException("400", $"Invalid relationship {relationshipName} on {entity.EntityName}", $"{entity.EntityName} does not have a relationship named {relationshipName}"); } diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 22653f0e8f..93590cf30b 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -15,64 +15,86 @@ namespace JsonApiDotNetCore.Extensions { public static class IServiceCollectionExtensions { - public static void AddJsonApi(this IServiceCollection services) + public static void AddJsonApi(this IServiceCollection services) where TContext : DbContext { var mvcBuilder = services.AddMvc(); - AddInternals(services, new JsonApiOptions(), mvcBuilder); + AddJsonApi(services, (opt) => { }, mvcBuilder); } - public static void AddJsonApi(this IServiceCollection services, Action options) + public static void AddJsonApi(this IServiceCollection services, Action options) where TContext : DbContext { - var config = new JsonApiOptions(); - - options(config); - var mvcBuilder = services.AddMvc(); - AddInternals(services, config, mvcBuilder); + AddJsonApi(services, options, mvcBuilder); } - public static void AddJsonApi(this IServiceCollection services, - Action options, - IMvcBuilder mvcBuilder) where TContext : DbContext + public static void AddJsonApi(this IServiceCollection services, + Action options, + IMvcBuilder mvcBuilder) where TContext : DbContext { var config = new JsonApiOptions(); - + options(config); - AddInternals(services, config, mvcBuilder); + mvcBuilder + .AddMvcOptions(opt => + { + opt.Filters.Add(typeof(JsonApiExceptionFilter)); + opt.SerializeAsJsonApi(config); + }); + + AddJsonApiInternals(services, config); } - private static void AddInternals(IServiceCollection services, - JsonApiOptions jsonApiOptions, - IMvcBuilder mvcBuilder) where TContext : DbContext + public static void AddJsonApi(this IServiceCollection services, + Action options, + IMvcBuilder mvcBuilder) { - services.AddJsonApiInternals(jsonApiOptions); + var config = new JsonApiOptions(); + + options(config); + mvcBuilder - .AddMvcOptions(opt => { + .AddMvcOptions(opt => + { opt.Filters.Add(typeof(JsonApiExceptionFilter)); - opt.SerializeAsJsonApi(jsonApiOptions); + opt.SerializeAsJsonApi(config); }); + + AddJsonApiInternals(services, config); } - public static void AddJsonApiInternals(this IServiceCollection services, JsonApiOptions jsonApiOptions) - where TContext : DbContext + public static void AddJsonApiInternals( + this IServiceCollection services, + JsonApiOptions jsonApiOptions) where TContext : DbContext { - var contextGraphBuilder = new ContextGraphBuilder(); - var contextGraph = contextGraphBuilder.Build(); + if (jsonApiOptions.ContextGraph == null) + jsonApiOptions.BuildContextGraph(null); services.AddScoped(typeof(DbContext), typeof(TContext)); + AddJsonApiInternals(services, jsonApiOptions); + } + + public static void AddJsonApiInternals( + this IServiceCollection services, + JsonApiOptions jsonApiOptions) + { + if(!jsonApiOptions.ContextGraph.UsesDbContext) + { + services.AddScoped(); + services.AddSingleton(new DbContextOptionsBuilder().Options); + } + services.AddScoped(typeof(IEntityRepository<>), typeof(DefaultEntityRepository<>)); services.AddScoped(typeof(IEntityRepository<,>), typeof(DefaultEntityRepository<,>)); - + services.AddScoped(typeof(IResourceService<>), typeof(EntityResourceService<>)); + services.AddScoped(typeof(IResourceService<,>), typeof(EntityResourceService<,>)); services.AddSingleton(jsonApiOptions); - services.AddSingleton(contextGraph); - services.AddScoped(); + services.AddSingleton(jsonApiOptions.ContextGraph); + services.AddScoped(); services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -86,7 +108,7 @@ public static void AddJsonApiInternals(this IServiceCollection service public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions jsonApiOptions) { options.InputFormatters.Insert(0, new JsonApiInputFormatter()); - + options.OutputFormatters.Insert(0, new JsonApiOutputFormatter()); options.Conventions.Insert(0, new DasherizedRoutingConvention(jsonApiOptions.Namespace)); diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index f59ce6f497..fbf97cfc00 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -1,20 +1,20 @@ using System.Reflection; using System.Collections.Generic; using System.Linq; -using Microsoft.EntityFrameworkCore; using System; namespace JsonApiDotNetCore.Internal { - public class ContextGraph : IContextGraph where T : DbContext + public class ContextGraph : IContextGraph { public List Entities { get; set; } + public bool UsesDbContext { get; set; } - public ContextEntity GetContextEntity(string dbSetName) + public ContextEntity GetContextEntity(string entityName) { return Entities .FirstOrDefault(e => - e.EntityName.ToLower() == dbSetName.ToLower()); + e.EntityName.ToLower() == entityName.ToLower()); } public ContextEntity GetContextEntity(Type entityType) diff --git a/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs deleted file mode 100644 index e856714b40..0000000000 --- a/src/JsonApiDotNetCore/Internal/ContextGraphBuilder.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Microsoft.EntityFrameworkCore; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Internal -{ - public class ContextGraphBuilder where T : DbContext - { - private readonly Type _contextType = typeof(T); - private List _entities; - - public ContextGraph Build() - { - _getFirstLevelEntities(); - - var graph = new ContextGraph - { - Entities = _entities - }; - - return graph; - } - - private void _getFirstLevelEntities() - { - var entities = new List(); - - var contextProperties = _contextType.GetProperties(); - - foreach(var property in contextProperties) - { - var dbSetType = property.PropertyType; - - if (dbSetType.GetTypeInfo().IsGenericType - && dbSetType.GetGenericTypeDefinition() == typeof(DbSet<>)) - { - var entityType = dbSetType.GetGenericArguments()[0]; - entities.Add(new ContextEntity { - EntityName = property.Name, - EntityType = entityType, - Attributes = _getAttributes(entityType), - Relationships = _getRelationships(entityType) - }); - } - } - - _entities = entities; - } - - private List _getAttributes(Type entityType) - { - var attributes = new List(); - - var properties = entityType.GetProperties(); - - foreach(var prop in properties) - { - var attribute = (AttrAttribute)prop.GetCustomAttribute(typeof(AttrAttribute)); - if(attribute == null) continue; - attribute.InternalAttributeName = prop.Name; - attributes.Add(attribute); - } - return attributes; - } - - private List _getRelationships(Type entityType) - { - 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 Type _getRelationshipType(RelationshipAttribute relation, PropertyInfo prop) - { - if(relation.IsHasMany) - return prop.PropertyType.GetGenericArguments()[0]; - else - return prop.PropertyType; - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/IContextGraph.cs b/src/JsonApiDotNetCore/Internal/IContextGraph.cs index 31529a2f13..707d6fd32d 100644 --- a/src/JsonApiDotNetCore/Internal/IContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/IContextGraph.cs @@ -8,5 +8,6 @@ public interface IContextGraph string GetRelationshipName(string relationshipName); ContextEntity GetContextEntity(string dbSetName); ContextEntity GetContextEntity(Type entityType); + bool UsesDbContext { get; set; } } } diff --git a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs index 80b1870a94..6ebc7179a8 100644 --- a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs +++ b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs @@ -147,7 +147,6 @@ private List ParseIncludedRelationships(string value) return value .Split(',') - .Select(s => s.ToProperCase()) .ToList(); } @@ -158,7 +157,7 @@ private List ParseFieldsQuery(string key, string value) var includedFields = new List { "Id" }; - if(typeName != _jsonApiContext.RequestEntity.EntityName.Dasherize()) + if(typeName != _jsonApiContext.RequestEntity.EntityName) return includedFields; var fields = value.Split(','); diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index b2d2bd4a6c..2c05690e10 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,12 +1,20 @@  - 1.3.1 + 2.0.0 netcoreapp1.0 JsonApiDotNetCore JsonApiDotNetCore 1.1.1 $(PackageTargetFallback);dnxcore50;portable-net45+win8 + + jsonapi;dotnet core;emberjs;ember + https://github.com/Research-Institute/json-api-dotnet-core + https://raw.githubusercontent.com/Research-Institute/json-api-dotnet-core/master/LICENSE + false + git + https://github.com/Research-Institute/json-api-dotnet-core + diff --git a/src/JsonApiDotNetCore/Models/DocumentData.cs b/src/JsonApiDotNetCore/Models/DocumentData.cs index b2e97dee6c..32ca6f3f51 100644 --- a/src/JsonApiDotNetCore/Models/DocumentData.cs +++ b/src/JsonApiDotNetCore/Models/DocumentData.cs @@ -1,19 +1,12 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Extensions; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models { public class DocumentData { - private string _type; - [JsonProperty("type")] - public string Type - { - get { return _type.Dasherize(); } - set { _type = value; } - } + public string Type { get; set; } [JsonProperty("id")] public string Id { get; set; } diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index 6e5356c343..208e002504 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace JsonApiDotNetCore.Models { diff --git a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs new file mode 100644 index 0000000000..9f17883662 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace JsonApiDotNetCore.Models +{ + public class ResourceAttribute : Attribute + { + public ResourceAttribute(string resourceName) + { + ResourceName = resourceName; + } + + public string ResourceName { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 367904f8dd..b6ad9273c3 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -61,9 +61,7 @@ public List DeserializeList(string requestBody) private object DataToObject(DocumentData data) { - var entityTypeName = data.Type.ToProperCase(); - - var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(entityTypeName); + var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(data.Type); _jsonApiContext.RequestEntity = contextEntity; var entity = Activator.CreateInstance(contextEntity.EntityType); @@ -95,7 +93,7 @@ private object _setEntityAttributes( throw new ArgumentException($"{contextEntity.EntityType.Name} does not contain an attribute named {attr.InternalAttributeName}", nameof(entity)); object newValue; - if (attributeValues.TryGetValue(attr.PublicAttributeName.Dasherize(), out newValue)) + if (attributeValues.TryGetValue(attr.PublicAttributeName, out newValue)) { var convertedValue = TypeHelper.ConvertType(newValue, entityProperty.PropertyType); entityProperty.SetValue(entity, convertedValue); @@ -137,7 +135,7 @@ private object _setHasOneRelationship(object entity, if (entityProperty == null) throw new JsonApiException("400", $"{contextEntity.EntityType.Name} does not contain an relationsip named {attr.InternalRelationshipName}"); - var relationshipName = attr.InternalRelationshipName.Dasherize(); + var relationshipName = attr.PublicRelationshipName; if (relationships.TryGetValue(relationshipName, out RelationshipData relationshipData)) { @@ -170,7 +168,7 @@ private object _setHasManyRelationship(object entity, if (entityProperty == null) throw new JsonApiException("400", $"{contextEntity.EntityType.Name} does not contain an relationsip named {attr.InternalRelationshipName}"); - var relationshipName = attr.InternalRelationshipName.Dasherize(); + var relationshipName = attr.PublicRelationshipName; if (relationships.TryGetValue(relationshipName, out RelationshipData relationshipData)) { diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs new file mode 100644 index 0000000000..ca49b7bf70 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -0,0 +1,184 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Services +{ + public class EntityResourceService + : EntityResourceService, + IResourceService + where T : class, IIdentifiable + { + public EntityResourceService( + IJsonApiContext jsonApiContext, + IEntityRepository entityRepository, + ILoggerFactory loggerFactory) + : base(jsonApiContext, entityRepository, loggerFactory) + { } + } + + public class EntityResourceService : IResourceService + where T : class, IIdentifiable + { + private readonly IJsonApiContext _jsonApiContext; + private readonly IEntityRepository _entities; + private readonly ILogger _logger; + + public EntityResourceService( + IJsonApiContext jsonApiContext, + IEntityRepository entityRepository, + ILoggerFactory loggerFactory) + { + _jsonApiContext = jsonApiContext; + _entities = entityRepository; + _logger = loggerFactory.CreateLogger>(); + } + + public async Task> GetAsync() + { + var entities = _entities.Get(); + + entities = ApplySortAndFilterQuery(entities); + + if (_jsonApiContext.QuerySet != null && _jsonApiContext.QuerySet.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0) + entities = IncludeRelationships(entities, _jsonApiContext.QuerySet.IncludedRelationships); + + if (_jsonApiContext.Options.IncludeTotalRecordCount) + _jsonApiContext.PageManager.TotalRecords = await entities.CountAsync(); + + // pagination should be done last since it will execute the query + var pagedEntities = await ApplyPageQueryAsync(entities); + return pagedEntities; + } + + public async Task GetAsync(TId id) + { + T entity; + if (_jsonApiContext.QuerySet?.IncludedRelationships != null) + entity = await GetWithRelationshipsAsync(id); + else + entity = await _entities.GetAsync(id); + return entity; + } + + private async Task GetWithRelationshipsAsync(TId id) + { + var query = _entities.Get(); + _jsonApiContext.QuerySet.IncludedRelationships.ForEach(r => + { + query = _entities.Include(query, r); + }); + return await query.FirstOrDefaultAsync(e => e.Id.Equals(id)); + } + + public async Task GetRelationshipsAsync(TId id, string relationshipName) + { + _jsonApiContext.IsRelationshipData = true; + return await GetRelationshipAsync(id, relationshipName); + } + + public async Task GetRelationshipAsync(TId id, string relationshipName) + { + relationshipName = _jsonApiContext.ContextGraph + .GetRelationshipName(relationshipName.ToProperCase()); + + if (relationshipName == null) + throw new JsonApiException("422", "Relationship name not specified."); + + var entity = await _entities.GetAndIncludeAsync(id, relationshipName); + if (entity == null) + throw new JsonApiException("404", $"Relationship {relationshipName} not found."); + + var relationship = _jsonApiContext.ContextGraph + .GetRelationship(entity, relationshipName); + + return relationship; + } + + public async Task CreateAsync(T entity) + { + return await _entities.CreateAsync(entity); + } + + public async Task UpdateAsync(TId id, T entity) + { + var updatedEntity = await _entities.UpdateAsync(id, entity); + return updatedEntity; + } + + public async Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships) + { + relationshipName = _jsonApiContext.ContextGraph + .GetRelationshipName(relationshipName.ToProperCase()); + + if (relationshipName == null) + throw new JsonApiException("422", "Relationship name not specified."); + + var entity = await _entities.GetAndIncludeAsync(id, relationshipName); + + if (entity == null) + throw new JsonApiException("404", $"Entity with id {id} could not be found."); + + 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); + } + + public async Task DeleteAsync(TId id) + { + return await _entities.DeleteAsync(id); + } + + private IQueryable ApplySortAndFilterQuery(IQueryable entities) + { + var query = _jsonApiContext.QuerySet; + + if (_jsonApiContext.QuerySet == null) + return entities; + + if (query.Filters.Count > 0) + foreach (var filter in query.Filters) + entities = _entities.Filter(entities, filter); + + if (query.SortParameters != null && query.SortParameters.Count > 0) + entities = _entities.Sort(entities, query.SortParameters); + + return entities; + } + + private async Task> ApplyPageQueryAsync(IQueryable entities) + { + var pageManager = _jsonApiContext.PageManager; + if (!pageManager.IsPaginated) + return entities; + + var query = _jsonApiContext.QuerySet?.PageQuery ?? new PageQuery(); + + _logger?.LogInformation($"Applying paging query. Fetching page {pageManager.CurrentPage} with {pageManager.PageSize} entities"); + + return await _entities.PageAsync(entities, pageManager.PageSize, pageManager.CurrentPage); + } + + private IQueryable IncludeRelationships(IQueryable entities, List relationships) + { + _jsonApiContext.IncludedRelationships = relationships; + + foreach (var r in relationships) + entities = _entities.Include(entities, r); + + return entities; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceService.cs b/src/JsonApiDotNetCore/Services/IResourceService.cs new file mode 100644 index 0000000000..c2cc0ceab3 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Services +{ + public interface IResourceService : IResourceService + where T : class, IIdentifiable + { } + + public interface IResourceService + where T : class, IIdentifiable + { + Task> GetAsync(); + Task GetAsync(TId id); + Task GetRelationshipsAsync(TId id, string relationshipName); + Task GetRelationshipAsync(TId id, string relationshipName); + Task CreateAsync(T entity); + Task UpdateAsync(TId id, T entity); + Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships); + Task DeleteAsync(TId id); + } +} diff --git a/src/JsonApiDotNetCoreExample/Controllers/PeopleController.cs b/src/JsonApiDotNetCoreExample/Controllers/PeopleController.cs index 4e344011c6..e249e2af53 100644 --- a/src/JsonApiDotNetCoreExample/Controllers/PeopleController.cs +++ b/src/JsonApiDotNetCoreExample/Controllers/PeopleController.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; @@ -10,9 +9,9 @@ public class PeopleController : JsonApiController { public PeopleController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCoreExample/Controllers/TodoItemCollectionsController.cs b/src/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs similarity index 50% rename from src/JsonApiDotNetCoreExample/Controllers/TodoItemCollectionsController.cs rename to src/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs index e596e125c3..be86208b72 100644 --- a/src/JsonApiDotNetCoreExample/Controllers/TodoItemCollectionsController.cs +++ b/src/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs @@ -1,19 +1,18 @@ using System; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - public class TodoItemCollectionsController : JsonApiController + public class TodoCollectionsController : JsonApiController { - public TodoItemCollectionsController( + public TodoCollectionsController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) { } } } \ No newline at end of file diff --git a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs index 8aabf3c8cf..768dd1c37c 100644 --- a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs +++ b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; @@ -10,9 +9,9 @@ public class TodoItemsController : JsonApiController { public TodoItemsController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService resourceService, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs new file mode 100644 index 0000000000..ab427445ee --- /dev/null +++ b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [Route("custom/route/todo-items")] + public class TodoItemsCustomController : CustomJsonApiController + { + public TodoItemsCustomController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } + } + + public class CustomJsonApiController + : CustomJsonApiController where T : class, IIdentifiable + { + public CustomJsonApiController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } + } + + public class CustomJsonApiController + : Controller where T : class, IIdentifiable + { + private readonly ILogger _logger; + private readonly IResourceService _resourceService; + private readonly IJsonApiContext _jsonApiContext; + + protected IActionResult UnprocessableEntity() + { + return new StatusCodeResult(422); + } + + protected IActionResult Forbidden() + { + return new StatusCodeResult(403); + } + + public CustomJsonApiController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + { + _jsonApiContext = jsonApiContext.ApplyContext(); + _resourceService = resourceService; + _logger = loggerFactory.CreateLogger>(); + } + + public CustomJsonApiController( + IJsonApiContext jsonApiContext, + IResourceService resourceService) + { + _jsonApiContext = jsonApiContext.ApplyContext(); + _resourceService = resourceService; + } + + [HttpGet] + public virtual async Task GetAsync() + { + var entities = await _resourceService.GetAsync(); + return Ok(entities); + } + + [HttpGet("{id}")] + public virtual async Task GetAsync(TId id) + { + var entity = await _resourceService.GetAsync(id); + + if (entity == null) + return NotFound(); + + return Ok(entity); + } + + [HttpGet("{id}/relationships/{relationshipName}")] + public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) + { + var relationship = _resourceService.GetRelationshipAsync(id, relationshipName); + if (relationship == null) + return NotFound(); + + return await GetRelationshipAsync(id, relationshipName); + } + + [HttpGet("{id}/{relationshipName}")] + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + { + var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName); + return Ok(relationship); + } + + [HttpPost] + public virtual async Task PostAsync([FromBody] T entity) + { + if (entity == null) + return UnprocessableEntity(); + + if (!_jsonApiContext.Options.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) + return Forbidden(); + + entity = await _resourceService.CreateAsync(entity); + + return Created($"{HttpContext.Request.Path}/{entity.Id}", entity); + } + + [HttpPatch("{id}")] + public virtual async Task PatchAsync(TId id, [FromBody] T entity) + { + if (entity == null) + return UnprocessableEntity(); + + var updatedEntity = await _resourceService.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) + { + await _resourceService.UpdateRelationshipsAsync(id, relationshipName, relationships); + return Ok(); + } + + [HttpDelete("{id}")] + public virtual async Task DeleteAsync(TId id) + { + var wasDeleted = await _resourceService.DeleteAsync(id); + + if (!wasDeleted) + return NotFound(); + + return NoContent(); + } + } +} diff --git a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index 52a6513f24..9bab3cf544 100644 --- a/src/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -1,29 +1,31 @@ using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - public abstract class AbstractTodoItemsController : JsonApiController where T : class, IIdentifiable + public abstract class AbstractTodoItemsController + : JsonApiController where T : class, IIdentifiable { protected AbstractTodoItemsController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService service, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) - { - } -} + : base(jsonApiContext, service, loggerFactory) + { } + } + + [Route("/abstract")] public class TodoItemsTestController : AbstractTodoItemsController { public TodoItemsTestController( IJsonApiContext jsonApiContext, - IEntityRepository entityRepository, + IResourceService service, ILoggerFactory loggerFactory) - : base(jsonApiContext, entityRepository, loggerFactory) + : base(jsonApiContext, service, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 592513b94d..9d691c8268 100644 --- a/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,3 +1,4 @@ +using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; @@ -24,6 +25,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet TodoItems { get; set; } public DbSet People { get; set; } + + [Resource("todo-collections")] public DbSet TodoItemCollections { get; set; } } } diff --git a/src/JsonApiDotNetCoreExample/Models/Person.cs b/src/JsonApiDotNetCoreExample/Models/Person.cs index 04992a40a6..8acf87c405 100644 --- a/src/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/JsonApiDotNetCoreExample/Models/Person.cs @@ -18,7 +18,7 @@ public class Person : Identifiable, IHasMeta [HasMany("assigned-todo-items")] public virtual List AssignedTodoItems { get; set; } - [HasMany("todo-item-collections")] + [HasMany("todo-collections")] public virtual List TodoItemCollections { get; set; } public Dictionary GetMeta(IJsonApiContext context) diff --git a/src/JsonApiDotNetCoreExample/Startup.cs b/src/JsonApiDotNetCoreExample/Startup.cs index 2abea4baad..0f8c5403c6 100644 --- a/src/JsonApiDotNetCoreExample/Startup.cs +++ b/src/JsonApiDotNetCoreExample/Startup.cs @@ -63,7 +63,7 @@ public virtual void Configure( ILoggerFactory loggerFactory, AppDbContext context) { - context.Database.Migrate(); + context.Database.EnsureCreated(); loggerFactory.AddConsole(Config.GetSection("Logging")); loggerFactory.AddDebug(); diff --git a/src/NoEntityFrameworkExample/.gitignore b/src/NoEntityFrameworkExample/.gitignore new file mode 100644 index 0000000000..0ca27f04e1 --- /dev/null +++ b/src/NoEntityFrameworkExample/.gitignore @@ -0,0 +1,234 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/src/NoEntityFrameworkExample/Controllers/CustomTodoItemsController.cs b/src/NoEntityFrameworkExample/Controllers/CustomTodoItemsController.cs new file mode 100644 index 0000000000..a6ded9749f --- /dev/null +++ b/src/NoEntityFrameworkExample/Controllers/CustomTodoItemsController.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace NoEntityFrameworkExample.Controllers +{ + public class CustomTodoItemsController : JsonApiController + { + public CustomTodoItemsController( + IJsonApiContext jsonApiContext, + IResourceService resourceService, + ILoggerFactory loggerFactory) + : base(jsonApiContext, resourceService, loggerFactory) + { } + } +} diff --git a/src/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj new file mode 100755 index 0000000000..510bc74533 --- /dev/null +++ b/src/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -0,0 +1,27 @@ + + + + netcoreapp1.0 + 1.1.1 + $(PackageTargetFallback);dotnet5.6;portable-net45+win8 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NoEntityFrameworkExample/Program.cs b/src/NoEntityFrameworkExample/Program.cs new file mode 100755 index 0000000000..33c8f78dcb --- /dev/null +++ b/src/NoEntityFrameworkExample/Program.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace NoEntityFrameworkExample +{ + public class Program + { + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseStartup() + .Build(); + + host.Run(); + } + } +} diff --git a/src/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/NoEntityFrameworkExample/Services/TodoItemService.cs new file mode 100644 index 0000000000..e07aeed3ab --- /dev/null +++ b/src/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Configuration; +using Npgsql; +using Dapper; +using System.Data; +using JsonApiDotNetCoreExample.Models; +using System.Linq; + +namespace NoEntityFrameworkExample.Services +{ + public class TodoItemService : IResourceService + { + private readonly string _connectionString; + + public TodoItemService(IConfiguration config) + { + _connectionString = config.GetValue("Data:DefaultConnection"); + } + + private IDbConnection Connection + { + get + { + return new NpgsqlConnection(_connectionString); + } + } + + private async Task> QueryAsync(Func>> query) + { + using (IDbConnection dbConnection = Connection) + { + dbConnection.Open(); + return await query(dbConnection); + } + } + + public async Task> GetAsync() + { + return await QueryAsync(async connection => + { + return await connection.QueryAsync("select * from \"TodoItems\""); + }); + } + + public async Task GetAsync(int id) + { + return (await QueryAsync(async connection => + { + return await connection.QueryAsync("select * from \"TodoItems\" where \"Id\"= @id", new { id }); + })).SingleOrDefault(); + } + + public Task GetRelationshipAsync(int id, string relationshipName) + { + throw new NotImplementedException(); + } + + public Task GetRelationshipsAsync(int id, string relationshipName) + { + throw new NotImplementedException(); + } + + public async Task CreateAsync(TodoItem entity) + { + return (await QueryAsync(async connection => + { + var query = "insert into \"TodoItems\" (\"Description\", \"Ordinal\", \"GuidProperty\") values (@description, @ordinal, @guidProperty) returning \"Id\",\"Description\",\"Ordinal\", \"GuidProperty\""; + var result = await connection.QueryAsync(query, new { description = entity.Description, ordinal = entity.Ordinal, guidProperty = entity.GuidProperty}); + return result; + })).SingleOrDefault(); + } + + public Task DeleteAsync(int id) + { + throw new NotImplementedException(); + } + + public Task UpdateAsync(int id, TodoItem entity) + { + throw new NotImplementedException(); + } + + public Task UpdateRelationshipsAsync(int id, string relationshipName, List relationships) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NoEntityFrameworkExample/Startup.cs b/src/NoEntityFrameworkExample/Startup.cs new file mode 100755 index 0000000000..fdea4fc582 --- /dev/null +++ b/src/NoEntityFrameworkExample/Startup.cs @@ -0,0 +1,62 @@ +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NoEntityFrameworkExample.Services; +using Microsoft.EntityFrameworkCore; + +namespace NoEntityFrameworkExample +{ + public class Startup + { + public Startup(IHostingEnvironment env) + { + var builder = new ConfigurationBuilder() + .SetBasePath(env.ContentRootPath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables(); + Configuration = builder.Build(); + } + + public IConfigurationRoot Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + // Add framework services. + var mvcBuilder = services.AddMvc(); + + services.AddJsonApi(options => { + options.Namespace = "api/v1"; + options.BuildContextGraph((builder) => { + builder.AddResource("custom-todo-items"); + }); + }, mvcBuilder); + + services.AddScoped, TodoItemService>(); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(Configuration.GetValue("Data:DefaultConnection")); + services.AddSingleton(Configuration); + services.AddSingleton>(optionsBuilder.Options); + services.AddScoped(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, AppDbContext context) + { + loggerFactory.AddConsole(Configuration.GetSection("Logging")); + loggerFactory.AddDebug(); + + context.Database.EnsureCreated(); + + app.UseMvc(); + } + } +} diff --git a/src/NoEntityFrameworkExample/appsettings.json b/src/NoEntityFrameworkExample/appsettings.json new file mode 100755 index 0000000000..42da2105cc --- /dev/null +++ b/src/NoEntityFrameworkExample/appsettings.json @@ -0,0 +1,11 @@ +{ + "Data": { + "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Warning" + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index 97043cc8e9..2d1c5623e3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -1,19 +1,39 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using DotNetCoreDocs.Models; -using JsonApiDotNetCore.Serialization; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Xunit; using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility { [Collection("WebHostCollection")] public class CustomControllerTests { + private DocsFixture _fixture; + private Faker _todoItemFaker; + private Faker _personFaker; + + public CustomControllerTests(DocsFixture fixture) + { + _fixture = fixture; + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); + } + [Fact] public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() { @@ -35,13 +55,13 @@ public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() } [Fact] - public async Task InheritedJsonApiControllers_Uses_Dasherized_Routes() + public async Task CustomRouteControllers_Uses_Dasherized_Collection_Route() { // Arrange var builder = new WebHostBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todo-items-test"; + var route = $"/custom/route/todo-items"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -53,5 +73,63 @@ public async Task InheritedJsonApiControllers_Uses_Dasherized_Routes() // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task CustomRouteControllers_Uses_Dasherized_Item_Route() + { + // Arrange + var context = _fixture.GetService(); + var todoItem = _todoItemFaker.Generate(); + var person = _personFaker.Generate(); + todoItem.Owner = person; + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/custom/route/todo-items/{todoItem.Id}"; + + 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.OK, response.StatusCode); + } + + [Fact] + public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() + { + // Arrange + var context = _fixture.GetService(); + var todoItem = _todoItemFaker.Generate(); + var person = _personFaker.Generate(); + todoItem.Owner = person; + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/custom/route/todo-items/{todoItem.Id}"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act & assert + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = JsonConvert.DeserializeObject(body); + + var result = deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString(); + Assert.EndsWith($"{route}/owner", deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString()); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index 52b399191f..866b9a331b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -54,13 +54,13 @@ public async Task Can_Create_Guid_Identifiable_Entity() context.People.Add(owner); await context.SaveChangesAsync(); - var route = "/api/v1/todo-item-collections"; + var route = "/api/v1/todo-collections"; var request = new HttpRequestMessage(httpMethod, route); var content = new { data = new { - type = "todo-item-collections", + type = "todo-collections", relationships = new { owner = new @@ -180,14 +180,14 @@ public async Task Can_Create_Guid_Identifiable_Entity_With_Client_Defined_Id_If_ context.People.Add(owner); await context.SaveChangesAsync(); - var route = "/api/v1/todo-item-collections"; + var route = "/api/v1/todo-collections"; var request = new HttpRequestMessage(httpMethod, route); var clientDefinedId = Guid.NewGuid(); var content = new { data = new { - type = "todo-item-collections", + type = "todo-collections", id = $"{clientDefinedId}", relationships = new { @@ -234,13 +234,13 @@ public async Task Can_Create_And_Set_HasMany_Relationships() context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var route = "/api/v1/todo-item-collections"; + var route = "/api/v1/todo-collections"; var request = new HttpRequestMessage(httpMethod, route); var content = new { data = new { - type = "todo-item-collections", + type = "todo-collections", relationships = new Dictionary { { "owner", new { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs index 39747fd68e..16971d8b06 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs @@ -227,7 +227,7 @@ public async Task Can_Include_MultipleRelationships() var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/people/{person.Id}?include=todo-items,todo-item-collections"; + var route = $"/api/v1/people/{person.Id}?include=todo-items,todo-collections"; var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/TestServerExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/TestServerExtensions.cs new file mode 100644 index 0000000000..a7363e7b32 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/TestServerExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.TestHost; + +namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions +{ + public static class TestServerExtensions + { + public static T GetService(this TestServer server) + { + return (T)server.Host.Services.GetService(typeof(T)); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj index b79d496c65..3b0e140c03 100755 --- a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +++ b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj @@ -20,6 +20,7 @@ + diff --git a/test/NoEntityFrameworkTests/.gitignore b/test/NoEntityFrameworkTests/.gitignore new file mode 100644 index 0000000000..0ca27f04e1 --- /dev/null +++ b/test/NoEntityFrameworkTests/.gitignore @@ -0,0 +1,234 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs b/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs new file mode 100644 index 0000000000..3a2166a3d3 --- /dev/null +++ b/test/NoEntityFrameworkTests/Acceptance/Extensibility/NoEntityFrameworkTests.cs @@ -0,0 +1,122 @@ +using JsonApiDotNetCore.Serialization; +using Xunit; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using NoEntityFrameworkExample; +using System.Net.Http; +using JsonApiDotNetCoreExampleTests.Helpers.Extensions; +using System.Threading.Tasks; +using System.Net; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExample.Data; +using System; +using Newtonsoft.Json; +using System.Net.Http.Headers; + +namespace NoEntityFrameworkTests.Acceptance.Extensibility +{ + public class NoEntityFrameworkTests + { + private readonly TestServer _server; + private readonly AppDbContext _context; + + public NoEntityFrameworkTests() + { + var builder = new WebHostBuilder() + .UseStartup(); + _server = new TestServer(builder); + _context = _server.GetService(); + _context.Database.EnsureCreated(); + } + + [Fact] + public async Task Can_Get_TodoItems() + { + // arrange + _context.TodoItems.Add(new TodoItem()); + _context.SaveChanges(); + + var client = _server.CreateClient(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/custom-todo-items"; + + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + var deserializedBody = _server.GetService() + .DeserializeList(responseBody); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(deserializedBody); + Assert.NotEmpty(deserializedBody); + } + + [Fact] + public async Task Can_Get_TodoItems_By_Id() + { + // arrange + var todoItem = new TodoItem(); + _context.TodoItems.Add(todoItem); + _context.SaveChanges(); + + var client = _server.CreateClient(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/custom-todo-items/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + var deserializedBody = (TodoItem)_server.GetService() + .Deserialize(responseBody); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(deserializedBody); + Assert.Equal(todoItem.Id, deserializedBody.Id); + } + + [Fact] + public async Task Can_Create_TodoItems() + { + // arrange + var description = Guid.NewGuid().ToString(); + var client = _server.CreateClient(); + var httpMethod = new HttpMethod("POST"); + var route = $"/api/v1/custom-todo-items/"; + var content = new + { + data = new + { + type = "custom-todo-items", + attributes = new + { + description = description, + ordinal = 1 + } + } + }; + + 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 responseBody = await response.Content.ReadAsStringAsync(); + var deserializedBody = (TodoItem)_server.GetService() + .Deserialize(responseBody); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(deserializedBody); + Assert.Equal(description, deserializedBody.Description); + } + } +} diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj new file mode 100644 index 0000000000..7f429caac3 --- /dev/null +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -0,0 +1,36 @@ + + + + netcoreapp1.0 + true + NoEntityFrameworkTests + Exe + NoEntityFrameworkTests + true + true + 1.1.1 + $(PackageTargetFallback);dotnet5.6;portable-net45+win8 + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + diff --git a/test/NoEntityFrameworkTests/WebHostCollection.cs b/test/NoEntityFrameworkTests/WebHostCollection.cs new file mode 100644 index 0000000000..071b1fcf81 --- /dev/null +++ b/test/NoEntityFrameworkTests/WebHostCollection.cs @@ -0,0 +1,12 @@ +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using Xunit; + +namespace NoEntityFrameworkTests +{ + [CollectionDefinition("WebHostCollection")] + public class WebHostCollection + : ICollectionFixture> + { } +} diff --git a/test/NoEntityFrameworkTests/appsettings.json b/test/NoEntityFrameworkTests/appsettings.json new file mode 100644 index 0000000000..898a1ad601 --- /dev/null +++ b/test/NoEntityFrameworkTests/appsettings.json @@ -0,0 +1,18 @@ +{ + "Data": { + "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "DocsConfiguration": { + "BaseAddress": "http://localhost:5000", + "RequestsDirectory": "../../src/JsonApiDotNetCoreExample/_data", + "DocumentationRoute": "/docs" + } +} \ No newline at end of file