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