diff --git a/JsonApiDotNetCore.MongoDb.sln b/JsonApiDotNetCore.MongoDb.sln index f83b15b..1948dcf 100644 --- a/JsonApiDotNetCore.MongoDb.sln +++ b/JsonApiDotNetCore.MongoDb.sln @@ -1,11 +1,21 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 MinimumVisualStudioVersion = 15.0.26124.0 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DDAB9F03-9137-4BA5-9932-96C006C88583}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.MongoDb", "src\JsonApiDotNetCore.MongoDb\JsonApiDotNetCore.MongoDb.csproj", "{E8C38068-3E3E-477D-A09A-D536D662FC1C}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{19A533AA-E006-496D-A476-364DF2B637A1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{AA148569-62FF-4E1A-8E16-0E529FA38040}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreMongoDbExample", "src\Examples\JsonApiDotNetCoreMongoDbExample\JsonApiDotNetCoreMongoDbExample.csproj", "{11CC33C8-27D7-44D2-B402-76E3A33285A0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreMongoDbExampleTests", "test\JsonApiDotNetCoreMongoDbExampleTests\JsonApiDotNetCoreMongoDbExampleTests.csproj", "{24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.MongoDb", "src\JsonApiDotNetCore.MongoDb\JsonApiDotNetCore.MongoDb.csproj", "{FD312677-2A62-4B8F-A965-879B059F1755}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -16,24 +26,66 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Debug|x64.Build.0 = Debug|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Debug|x86.Build.0 = Debug|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Release|Any CPU.Build.0 = Release|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Release|x64.ActiveCfg = Release|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Release|x64.Build.0 = Release|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Release|x86.ActiveCfg = Release|Any CPU + {1AB13DD0-1E72-40C4-9EED-D4FF83701B4A}.Release|x86.Build.0 = Release|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Debug|x64.ActiveCfg = Debug|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Debug|x64.Build.0 = Debug|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Debug|x86.ActiveCfg = Debug|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Debug|x86.Build.0 = Debug|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Release|Any CPU.Build.0 = Release|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Release|x64.ActiveCfg = Release|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Release|x64.Build.0 = Release|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Release|x86.ActiveCfg = Release|Any CPU + {11CC33C8-27D7-44D2-B402-76E3A33285A0}.Release|x86.Build.0 = Release|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Debug|x64.Build.0 = Debug|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Debug|x86.Build.0 = Debug|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Release|Any CPU.Build.0 = Release|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Release|x64.ActiveCfg = Release|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Release|x64.Build.0 = Release|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Release|x86.ActiveCfg = Release|Any CPU + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}.Release|x86.Build.0 = Release|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Debug|x64.Build.0 = Debug|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Debug|x86.Build.0 = Debug|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Release|Any CPU.Build.0 = Release|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Release|x64.ActiveCfg = Release|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Release|x64.Build.0 = Release|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Release|x86.ActiveCfg = Release|Any CPU + {FD312677-2A62-4B8F-A965-879B059F1755}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Debug|x64.ActiveCfg = Debug|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Debug|x64.Build.0 = Debug|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Debug|x86.ActiveCfg = Debug|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Debug|x86.Build.0 = Debug|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Release|Any CPU.Build.0 = Release|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Release|x64.ActiveCfg = Release|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Release|x64.Build.0 = Release|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Release|x86.ActiveCfg = Release|Any CPU - {E8C38068-3E3E-477D-A09A-D536D662FC1C}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection GlobalSection(NestedProjects) = preSolution - {E8C38068-3E3E-477D-A09A-D536D662FC1C} = {DDAB9F03-9137-4BA5-9932-96C006C88583} + {AA148569-62FF-4E1A-8E16-0E529FA38040} = {7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784} + {11CC33C8-27D7-44D2-B402-76E3A33285A0} = {AA148569-62FF-4E1A-8E16-0E529FA38040} + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1} = {19A533AA-E006-496D-A476-364DF2B637A1} + {FD312677-2A62-4B8F-A965-879B059F1755} = {7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {83EBEC34-0CE5-4D16-93CF-EF5B5CCBC538} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index e0bb283..1546701 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,10 @@ dotnet add package JsonApiDotNetCore.MongoDb ### Models ```cs -public sealed class Book : IIdentifiable +public sealed class Book : MongoDbIdentifiable { - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - [Attr] - public string Id { get; set; } - [Attr] public string Name { get; set; } - - [BsonIgnore] - public string StringId { get => Id; set => Id = value; } } ``` @@ -54,12 +46,13 @@ public class Startup return client.GetDatabase(Configuration.GetSection("DatabaseSettings:Database").Value); }); - services.AddResourceRepository>(); - services.AddJsonApi(resources: builder => { builder.Add(); }); + services.AddJsonApiMongoDb(); + + services.AddResourceRepository>(); } public void Configure(IApplicationBuilder app) @@ -70,6 +63,55 @@ public class Startup } } ``` +Note: If your API project uses only MongoDB (not in combination with EF Core), then instead of +registering all MongoDB resources and repositories individually, you can use: +```cs +public class Startup +{ + public IServiceProvider ConfigureServices(IServiceCollection services) + { + // ... + + services.AddJsonApi(facade => facade.AddCurrentAssembly()); + services.AddJsonApiMongoDb(); + + services.AddScoped(typeof(IResourceReadRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceReadRepository<,>), typeof(MongoDbRepository<,>)); + services.AddScoped(typeof(IResourceWriteRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(MongoDbRepository<,>)); + services.AddScoped(typeof(IResourceRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceRepository<,>), typeof(MongoDbRepository<,>)); + } +} +``` + +## Development + +Restore all NuGet packages with: + +```bash +dotnet restore +``` + +### Testing + +You don't need to have a running instance of MongoDB on your machine. To run the tests just type the following command in your terminal: + +```bash +dotnet test +``` + +If you want to run the examples and explore them on your own **you are** going to need that running instance of MongoDB. If you have docker installed you can launch it like this: + +```bash +docker run -p 27017:27017 -d mongo:latest +``` + +And then to run the API: + +```bash +dotnet run +``` ## Limitations diff --git a/run-docker-mongodb.ps1 b/run-docker-mongodb.ps1 new file mode 100644 index 0000000..b1092b4 --- /dev/null +++ b/run-docker-mongodb.ps1 @@ -0,0 +1,9 @@ +#Requires -Version 7.0 + +# This script starts a docker container with MongoDB database, used for running tests. + +docker container stop jsonapi-dotnet-core-mongodb-testing + +docker run --rm --name jsonapi-dotnet-core-mongodb-testing ` + -p 27017:27017 ` + mongo:latest diff --git a/src/Examples/GettingStarted/Controllers/BooksController.cs b/src/Examples/GettingStarted/Controllers/BooksController.cs new file mode 100644 index 0000000..e9a0b28 --- /dev/null +++ b/src/Examples/GettingStarted/Controllers/BooksController.cs @@ -0,0 +1,16 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace GettingStarted.Controllers +{ + public sealed class BooksController : JsonApiController + { + public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj new file mode 100644 index 0000000..d570dd8 --- /dev/null +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -0,0 +1,14 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + diff --git a/src/Examples/GettingStarted/Models/Book.cs b/src/Examples/GettingStarted/Models/Book.cs new file mode 100644 index 0000000..5940dd4 --- /dev/null +++ b/src/Examples/GettingStarted/Models/Book.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace GettingStarted.Models +{ + public sealed class Book : MongoDbIdentifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public int PublishYear { get; set; } + + [Attr] + public string Author { get; set; } + } +} diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs new file mode 100644 index 0000000..30a58af --- /dev/null +++ b/src/Examples/GettingStarted/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace GettingStarted +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json new file mode 100644 index 0000000..6127651 --- /dev/null +++ b/src/Examples/GettingStarted/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52498", + "sslPort": 44343 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "launchUrl": "api/books", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "api/books", + "applicationUrl": "http://localhost:14141", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + diff --git a/src/Examples/GettingStarted/README.md b/src/Examples/GettingStarted/README.md new file mode 100644 index 0000000..833ce87 --- /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:14141/api/people` + +For further documentation and implementation of a JsonApiDotnetCore Application see the documentation or GitHub page: + +Repository: https://github.com/json-api-dotnet/JsonApiDotNetCore + +Documentation: http://www.jsonapi.net diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs new file mode 100644 index 0000000..774398d --- /dev/null +++ b/src/Examples/GettingStarted/Startup.cs @@ -0,0 +1,81 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Configuration; +using JsonApiDotNetCore.MongoDb.Repositories; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Newtonsoft.Json; + +namespace GettingStarted +{ + public sealed class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + private IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(sp => + { + var client = new MongoClient(Configuration.GetSection("DatabaseSettings:ConnectionString").Value); + return client.GetDatabase(Configuration.GetSection("DatabaseSettings:Database").Value); + }); + + services.AddJsonApi(ConfigureJsonApiOptions, resources: builder => + { + builder.Add(); + }); + services.AddJsonApiMongoDb(); + + services.AddResourceRepository>(); + } + + private void ConfigureJsonApiOptions(JsonApiOptions options) + { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerSettings.Formatting = Formatting.Indented; + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app) + { + CreateSampleData(app.ApplicationServices.GetService()); + + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + + private static void CreateSampleData(IMongoDatabase db) + { + db.GetCollection(nameof(Book)).InsertMany(new [] + { + new Book + { + Title = "Frankenstein", + PublishYear = 1818, + Author = "Mary Shelley" + }, new Book + { + Title = "Robinson Crusoe", + PublishYear = 1719, + Author = "Daniel Defoe" + }, new Book + { + Title = "Gulliver's Travels", + PublishYear = 1726, + Author = "Jonathan Swift" + } + }); + } + } +} diff --git a/src/Examples/GettingStarted/appsettings.json b/src/Examples/GettingStarted/appsettings.json new file mode 100644 index 0000000..8e493ac --- /dev/null +++ b/src/Examples/GettingStarted/appsettings.json @@ -0,0 +1,14 @@ +{ + "DatabaseSettings": { + "ConnectionString": "mongodb://localhost:27017", + "Database": "JsonApiDotNetCoreMongoDbGettingStarted" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/ArticlesController.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/ArticlesController.cs new file mode 100644 index 0000000..ef09e59 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/ArticlesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExample.Controllers +{ + public sealed class ArticlesController : JsonApiController + { + public ArticlesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/AuthorsController.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/AuthorsController.cs new file mode 100644 index 0000000..3e92a9d --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/AuthorsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExample.Controllers +{ + public sealed class AuthorsController : JsonApiController + { + public AuthorsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/BlogsController.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/BlogsController.cs new file mode 100644 index 0000000..9229eec --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/BlogsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExample.Controllers +{ + public sealed class BlogsController : JsonApiController + { + public BlogsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/PeopleController.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/PeopleController.cs new file mode 100644 index 0000000..6101d0f --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/PeopleController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExample.Controllers +{ + public sealed class PeopleController : JsonApiController + { + public PeopleController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/TodoItemsController.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/TodoItemsController.cs new file mode 100644 index 0000000..cc58714 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Controllers/TodoItemsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExample.Controllers +{ + public sealed class TodoItemsController : JsonApiController + { + public TodoItemsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000..403f874 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Definitions/TodoItemDefinition.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCoreMongoDbExample.Models; + +namespace JsonApiDotNetCoreMongoDbExample.Definitions +{ + public sealed class TodoItemDefinition : JsonApiResourceDefinition + { + public TodoItemDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + } + + public override IDictionary GetMeta(TodoItem resource) + { + if (resource.Description != null && resource.Description.StartsWith("Important:")) + { + return new Dictionary + { + ["hasHighPriority"] = true + }; + } + + return base.GetMeta(resource); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Dockerfile b/src/Examples/JsonApiDotNetCoreMongoDbExample/Dockerfile new file mode 100644 index 0000000..c5a5d90 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Dockerfile @@ -0,0 +1,13 @@ +FROM microsoft/dotnet:latest + +COPY . /app + +WORKDIR /app + +RUN ["dotnet", "restore"] + +RUN ["dotnet", "build"] + +EXPOSE 14140/tcp + +CMD ["dotnet", "run", "--server.urls", "http://*:14140"] diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/JsonApiDotNetCoreMongoDbExample.csproj b/src/Examples/JsonApiDotNetCoreMongoDbExample/JsonApiDotNetCoreMongoDbExample.csproj new file mode 100644 index 0000000..d570dd8 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/JsonApiDotNetCoreMongoDbExample.csproj @@ -0,0 +1,14 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Address.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Address.cs new file mode 100644 index 0000000..01b3687 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Address.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public sealed class Address : MongoDbIdentifiable + { + [Attr] + public string Street { get; set; } + + [Attr] + public string ZipCode { get; set; } + + [HasOne] + [BsonIgnore] + public Country Country { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Article.cs new file mode 100644 index 0000000..15a5449 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Article.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public sealed class Article : MongoDbIdentifiable + { + [Attr] + public string Caption { get; set; } + + [Attr] + public string Url { get; set; } + + [HasOne] + [BsonIgnore] + public Author Author { get; set; } + + [BsonIgnore] + [HasManyThrough(nameof(ArticleTags))] + public ISet Tags { get; set; } + + [BsonIgnore] + public ISet ArticleTags { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/ArticleTag.cs new file mode 100644 index 0000000..3281746 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/ArticleTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public sealed class ArticleTag + { + public string ArticleId { get; set; } + public Article Article { get; set; } + + public string TagId { get; set; } + public Tag Tag { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Author.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Author.cs new file mode 100644 index 0000000..0ad791f --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Author.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public sealed class Author : MongoDbIdentifiable + { + [Attr] + public string FirstName { get; set; } + + [Attr] + public string LastName { get; set; } + + [Attr] + public DateTime? DateOfBirth { get; set; } + + [Attr] + public string BusinessEmail { get; set; } + + [HasOne] + [BsonIgnore] + public Address LivingAddress { get; set; } + + [HasMany] + [BsonIgnore] + public IList
Articles { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Blog.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Blog.cs new file mode 100644 index 0000000..3133a82 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Blog.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public sealed class Blog : MongoDbIdentifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public string CompanyName { get; set; } + + [HasMany] + [BsonIgnore] + public IList
Articles { get; set; } + + [HasOne] + [BsonIgnore] + public Author Owner { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Country.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Country.cs new file mode 100644 index 0000000..e2b0e7a --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Country.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public class Country : MongoDbIdentifiable + { + [Attr] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Gender.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Gender.cs new file mode 100644 index 0000000..2b67553 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Gender.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public enum Gender + { + Unknown, + Male, + Female + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/IIsLockable.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/IIsLockable.cs new file mode 100644 index 0000000..be0bd32 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/IIsLockable.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public interface IIsLockable + { + bool IsLocked { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Person.cs new file mode 100644 index 0000000..169ae19 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Person.cs @@ -0,0 +1,42 @@ +using System.Linq; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public sealed class Person : MongoDbIdentifiable, IIsLockable + { + private string _firstName; + + public bool IsLocked { get; set; } + + [Attr] + public string FirstName + { + get => _firstName; + set + { + if (value != _firstName) + { + _firstName = value; + Initials = string.Concat(value.Split(' ').Select(x => char.ToUpperInvariant(x[0]))); + } + } + } + + [Attr] + public string Initials { get; set; } + + [Attr] + public string LastName { get; set; } + + [Attr(PublicName = "the-Age")] + public int Age { get; set; } + + [Attr] + public Gender Gender { get; set; } + + [Attr] + public string Category { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Tag.cs new file mode 100644 index 0000000..bfe8fcb --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/Tag.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public class Tag : MongoDbIdentifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public TagColor Color { get; set; } + + [HasManyThrough(nameof(ArticleTags))] + [BsonIgnore] + public ISet
Articles { get; set; } + public ISet ArticleTags { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/TagColor.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/TagColor.cs new file mode 100644 index 0000000..6d9a784 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/TagColor.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public enum TagColor + { + Red, + Green, + Blue + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/TodoItem.cs new file mode 100644 index 0000000..84f38ca --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Models/TodoItem.cs @@ -0,0 +1,39 @@ +using System; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExample.Models +{ + public class TodoItem : MongoDbIdentifiable, IIsLockable + { + public bool IsLocked { get; set; } + + [Attr] + public string Description { get; set; } + + [Attr] + public long Ordinal { get; set; } + + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] + public string AlwaysChangingValue + { + get => Guid.NewGuid().ToString(); + set { } + } + + [Attr] + public DateTime CreatedDate { get; set; } + + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] + public DateTime? AchievedDate { get; set; } + + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public string CalculatedValue => "calculated"; + + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] + public DateTimeOffset? OffsetDate { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Program.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Program.cs new file mode 100644 index 0000000..a363191 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace JsonApiDotNetCoreMongoDbExample +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreMongoDbExample/Properties/launchSettings.json new file mode 100644 index 0000000..1e3998e --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14140", + "sslPort": 44340 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "launchUrl": "api/v1/todoItems", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "api/v1/todoItems", + "applicationUrl": "https://localhost:44340;http://localhost:14140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Startups/EmptyStartup.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Startups/EmptyStartup.cs new file mode 100644 index 0000000..88a4474 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Startups/EmptyStartup.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreMongoDbExample +{ + /// + /// Empty startup class, required for integration tests. + /// Changes in ASP.NET Core 3 no longer allow Startup class to be defined in test projects. See https://github.com/aspnet/AspNetCore/issues/15373. + /// + public abstract class EmptyStartup + { + protected EmptyStartup(IConfiguration configuration) + { + } + + public virtual void ConfigureServices(IServiceCollection services) + { + } + + public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment environment) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreMongoDbExample/Startups/Startup.cs new file mode 100644 index 0000000..a00eb9f --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/Startups/Startup.cs @@ -0,0 +1,72 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Configuration; +using JsonApiDotNetCore.MongoDb.Repositories; +using JsonApiDotNetCore.Repositories; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MongoDB.Driver; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCoreMongoDbExample +{ + public class Startup : EmptyStartup + { + private IConfiguration Configuration { get; } + + public Startup(IConfiguration configuration) : base(configuration) + { + Configuration = configuration; + } + + public override void ConfigureServices(IServiceCollection services) + { + ConfigureClock(services); + + // TryAddSingleton will only register the IMongoDatabase if there is no + // previously registered instance - will make tests use individual dbs + services.TryAddSingleton(sp => + { + var client = new MongoClient(Configuration.GetSection("DatabaseSettings:ConnectionString").Value); + return client.GetDatabase(Configuration.GetSection("DatabaseSettings:Database").Value); + }); + + services.AddJsonApi(ConfigureJsonApiOptions, facade => facade.AddCurrentAssembly()); + services.AddJsonApiMongoDb(); + + services.AddScoped(typeof(IResourceReadRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceReadRepository<,>), typeof(MongoDbRepository<,>)); + services.AddScoped(typeof(IResourceWriteRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(MongoDbRepository<,>)); + services.AddScoped(typeof(IResourceRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceRepository<,>), typeof(MongoDbRepository<,>)); + } + + protected virtual void ConfigureClock(IServiceCollection services) + { + services.AddSingleton(); + } + + protected virtual void ConfigureJsonApiOptions(JsonApiOptions options) + { + options.IncludeExceptionStackTraceInErrors = true; + options.Namespace = "api/v1"; + options.DefaultPageSize = new PageSize(5); + options.IncludeTotalResourceCount = true; + options.ValidateModelState = true; + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.Converters.Add(new StringEnumConverter()); + } + + public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) + { + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreMongoDbExample/appsettings.json b/src/Examples/JsonApiDotNetCoreMongoDbExample/appsettings.json new file mode 100644 index 0000000..2491879 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreMongoDbExample/appsettings.json @@ -0,0 +1,14 @@ +{ + "DatabaseSettings": { + "ConnectionString": "mongodb://localhost:27017", + "Database": "JsonApiDotNetCoreMongoDbExample" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/JsonApiDotNetCore.MongoDb/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.MongoDb/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..72e4607 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.MongoDb.Serialization.Building; +using JsonApiDotNetCore.Serialization.Building; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.MongoDb.Configuration +{ + public static class ServiceCollectionExtensions + { + /// + /// Expands JsonApiDotNetCore configuration for usage with MongoDB. + /// + public static IServiceCollection AddJsonApiMongoDb(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb/Errors/AttributeComparisonInFilterNotSupportedException.cs b/src/JsonApiDotNetCore.MongoDb/Errors/AttributeComparisonInFilterNotSupportedException.cs new file mode 100644 index 0000000..edc2190 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Errors/AttributeComparisonInFilterNotSupportedException.cs @@ -0,0 +1,22 @@ +using System.Net; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Errors +{ + /// + /// The error that is thrown when a filter compares two attributes. + /// This is not supported by MongoDB.Driver. + /// https://jira.mongodb.org/browse/CSHARP-1592 + /// + public sealed class AttributeComparisonInFilterNotSupportedException : JsonApiException + { + public AttributeComparisonInFilterNotSupportedException() + : base(new Error(HttpStatusCode.BadRequest) + { + Title = "Comparing attributes against each other is not supported when using MongoDB." + }) + { + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/Errors/UnsupportedRelationshipException.cs b/src/JsonApiDotNetCore.MongoDb/Errors/UnsupportedRelationshipException.cs new file mode 100644 index 0000000..3f02a7a --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Errors/UnsupportedRelationshipException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Errors +{ + /// + /// The error that is thrown when the user attempts to fetch, create or update a relationship. + /// + public sealed class UnsupportedRelationshipException : JsonApiException + { + public UnsupportedRelationshipException() + : base(new Error(HttpStatusCode.BadRequest) + { + Title = "Relationships are not supported when using MongoDB." + }) + { + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoDbQueryableBuilder.cs b/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoDbQueryableBuilder.cs new file mode 100644 index 0000000..9fd1313 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoDbQueryableBuilder.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.MongoDb.Queries.Internal.QueryableBuilding +{ + /// + /// Drives conversion from into system trees. + /// + /// + /// This class was copied from JsonApiDotNetCore, so it can use instead. + /// + public sealed class MongoDbQueryableBuilder + { + private readonly Expression _source; + private readonly Type _elementType; + private readonly Type _extensionType; + private readonly LambdaParameterNameFactory _nameFactory; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IModel _entityModel; + private readonly LambdaScopeFactory _lambdaScopeFactory; + + public MongoDbQueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, + IResourceFactory resourceFactory, IResourceContextProvider resourceContextProvider, IModel entityModel, + LambdaScopeFactory lambdaScopeFactory = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _elementType = elementType ?? throw new ArgumentNullException(nameof(elementType)); + _extensionType = extensionType ?? throw new ArgumentNullException(nameof(extensionType)); + _nameFactory = nameFactory ?? throw new ArgumentNullException(nameof(nameFactory)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _entityModel = entityModel ?? throw new ArgumentNullException(nameof(entityModel)); + _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); + } + + public Expression ApplyQuery(QueryLayer layer) + { + if (layer == null) throw new ArgumentNullException(nameof(layer)); + + Expression expression = _source; + + if (layer.Include != null) + { + expression = ApplyInclude(expression, layer.Include, layer.ResourceContext); + } + + if (layer.Filter != null) + { + expression = ApplyFilter(expression, layer.Filter); + } + + if (layer.Sort != null) + { + expression = ApplySort(expression, layer.Sort); + } + + if (layer.Pagination != null) + { + expression = ApplyPagination(expression, layer.Pagination); + } + + if (layer.Projection != null && layer.Projection.Any()) + { + expression = ApplyProjection(expression, layer.Projection, layer.ResourceContext); + } + + return expression; + } + + private Expression ApplyInclude(Expression source, IncludeExpression include, ResourceContext resourceContext) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new IncludeClauseBuilder(source, lambdaScope, resourceContext, _resourceContextProvider); + return builder.ApplyInclude(include); + } + + private Expression ApplyFilter(Expression source, FilterExpression filter) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new MongoDbWhereClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplyWhere(filter); + } + + private Expression ApplySort(Expression source, SortExpression sort) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplyOrderBy(sort); + } + + private Expression ApplyPagination(Expression source, PaginationExpression pagination) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplySkipTake(pagination); + } + + private Expression ApplyProjection(Expression source, IDictionary projection, ResourceContext resourceContext) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory, _resourceContextProvider); + return builder.ApplySelect(projection, resourceContext); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoDbWhereClauseBuilder.cs b/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoDbWhereClauseBuilder.cs new file mode 100644 index 0000000..bfbb67b --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoDbWhereClauseBuilder.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +namespace JsonApiDotNetCore.MongoDb.Queries.Internal.QueryableBuilding +{ + /// + public class MongoDbWhereClauseBuilder : WhereClauseBuilder + { + public MongoDbWhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) : base(source, + lambdaScope, extensionType) + { + } + + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type expressionType) + { + if (expressionType == typeof(DateTime) || expressionType == typeof(DateTime?)) + { + DateTime? dateTime = TryParseDateTimeAsUtc(expression.Value, expressionType); + return Expression.Constant(dateTime); + } + + return base.VisitLiteralConstant(expression, expressionType); + } + + private static DateTime? TryParseDateTimeAsUtc(string value, Type expressionType) + { + var convertedValue = Convert.ChangeType(value, expressionType); + if (convertedValue is DateTime dateTime) + { + // DateTime values in MongoDB are always stored in UTC, so any ambiguous filter value passed + // must be interpreted as such for correct comparison. + if (dateTime.Kind == DateTimeKind.Unspecified) + { + return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + } + + return dateTime; + } + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbModel.cs b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbModel.cs new file mode 100644 index 0000000..00fb9bc --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbModel.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.MongoDb.Repositories +{ + internal sealed class MongoDbModel : IModel + { + private readonly IResourceContextProvider _resourceContextProvider; + + public object this[string name] => throw new NotImplementedException(); + + public MongoDbModel(IResourceContextProvider resourceContextProvider) + { + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + public IEnumerable GetEntityTypes() + { + var resourceContexts = _resourceContextProvider.GetResourceContexts(); + return resourceContexts.Select(resourceContext => new MongoEntityType(resourceContext, this)); + } + + public IAnnotation FindAnnotation(string name) => throw new NotImplementedException(); + public IEnumerable GetAnnotations() => throw new NotImplementedException(); + public IEntityType FindEntityType(string name) => throw new NotImplementedException(); + public IEntityType FindEntityType(string name, string definingNavigationName, IEntityType definingEntityType) => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbProperty.cs b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbProperty.cs new file mode 100644 index 0000000..4c2d98d --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbProperty.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.MongoDb.Repositories +{ + internal sealed class MongoDbProperty : IProperty + { + public IEntityType DeclaringEntityType { get; } + public PropertyInfo PropertyInfo { get; } + + public string Name => throw new NotImplementedException(); + public Type ClrType => throw new NotImplementedException(); + public FieldInfo FieldInfo => throw new NotImplementedException(); + public ITypeBase DeclaringType => throw new NotImplementedException(); + public bool IsNullable => throw new NotImplementedException(); + public ValueGenerated ValueGenerated => throw new NotImplementedException(); + public bool IsConcurrencyToken => throw new NotImplementedException(); + public object this[string name] => throw new NotImplementedException(); + + public MongoDbProperty(PropertyInfo propertyInfo, MongoEntityType owner) + { + DeclaringEntityType = owner ?? throw new ArgumentNullException(nameof(owner)); + PropertyInfo = propertyInfo ?? throw new ArgumentNullException(nameof(propertyInfo)); + } + + public IAnnotation FindAnnotation(string name) => throw new NotImplementedException(); + public IEnumerable GetAnnotations() => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbQueryExpressionValidator.cs b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbQueryExpressionValidator.cs new file mode 100644 index 0000000..a0fb635 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbQueryExpressionValidator.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.MongoDb.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.MongoDb.Repositories +{ + internal sealed class MongoDbQueryExpressionValidator : QueryExpressionRewriter + { + public void Validate(QueryLayer layer) + { + if (layer == null) throw new ArgumentNullException(nameof(layer)); + + bool hasIncludes = layer.Include?.Elements.Any() == true; + var hasSparseRelationshipSets = layer.Projection?.Any(pair => pair.Key is RelationshipAttribute) == true; + + if (hasIncludes || hasSparseRelationshipSets) + { + throw new UnsupportedRelationshipException(); + } + + ValidateExpression(layer.Filter); + ValidateExpression(layer.Sort); + ValidateExpression(layer.Pagination); + } + + private void ValidateExpression(QueryExpression expression) + { + if (expression != null) + { + Visit(expression, null); + } + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object argument) + { + if (expression != null) + { + if (expression.Fields.Count > 1 || expression.Fields.First() is RelationshipAttribute) + { + throw new UnsupportedRelationshipException(); + } + } + + return base.VisitResourceFieldChain(expression, argument); + } + + public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) + { + if (expression?.Left is ResourceFieldChainExpression && expression.Right is ResourceFieldChainExpression) + { + throw new AttributeComparisonInFilterNotSupportedException(); + } + + return base.VisitComparison(expression, argument); + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/MongoDbRepository.cs b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbRepository.cs similarity index 60% rename from src/JsonApiDotNetCore.MongoDb/MongoDbRepository.cs rename to src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbRepository.cs index bcbbfd2..a0e6244 100644 --- a/src/JsonApiDotNetCore.MongoDb/MongoDbRepository.cs +++ b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoDbRepository.cs @@ -4,40 +4,49 @@ using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.MongoDb.Errors; +using JsonApiDotNetCore.MongoDb.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using MongoDB.Driver; using MongoDB.Driver.Linq; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Infrastructure; -namespace JsonApiDotNetCore.MongoDb +namespace JsonApiDotNetCore.MongoDb.Repositories { /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses MongoDB. /// - public class MongoDbRepository - : IResourceRepository + public class MongoDbRepository : IResourceRepository where TResource : class, IIdentifiable { private readonly IMongoDatabase _mongoDatabase; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; private readonly IResourceFactory _resourceFactory; - + private readonly IEnumerable _constraintProviders; + public MongoDbRepository( IMongoDatabase mongoDatabase, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceFactory resourceFactory) + IResourceFactory resourceFactory, + IEnumerable constraintProviders) { _mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + + if (typeof(TId) != typeof(string)) + { + throw new InvalidConfigurationException("MongoDB can only be used for resources with an 'Id' property of type 'string'."); + } } protected virtual IMongoCollection Collection => _mongoDatabase.GetCollection(typeof(TResource).Name); @@ -47,8 +56,9 @@ public virtual async Task> GetAsync(QueryLayer la CancellationToken cancellationToken) { if (layer == null) throw new ArgumentNullException(nameof(layer)); - - var resources = await ApplyQueryLayer(layer).ToListAsync(cancellationToken); + + var query = ApplyQueryLayer(layer); + var resources = await query.ToListAsync(cancellationToken); return resources.AsReadOnly(); } @@ -68,18 +78,35 @@ public virtual Task CountAsync(FilterExpression topFilter, CancellationToke protected virtual IMongoQueryable ApplyQueryLayer(QueryLayer layer) { if (layer == null) throw new ArgumentNullException(nameof(layer)); + + var queryExpressionValidator = new MongoDbQueryExpressionValidator(); + queryExpressionValidator.Validate(layer); + + AssertNoRelationshipsInSparseFieldSets(); var source = GetAll(); + + var queryableHandlers = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Where(expressionInScope => expressionInScope.Scope == null) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .ToArray(); + + foreach (var queryableHandler in queryableHandlers) + { + source = queryableHandler.Apply(source); + } var nameFactory = new LambdaParameterNameFactory(); - var builder = new QueryableBuilder( + var builder = new MongoDbQueryableBuilder( source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _resourceContextProvider, - DummyModel.Instance); + new MongoDbModel(_resourceContextProvider)); var expression = builder.ApplyQuery(layer); return (IMongoQueryable)source.Provider.CreateQuery(expression); @@ -89,6 +116,24 @@ protected virtual IQueryable GetAll() { return Collection.AsQueryable(); } + + private void AssertNoRelationshipsInSparseFieldSets() + { + var resourceContext = _resourceContextProvider.GetResourceContext(); + + var hasRelationshipSelectors = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .Any(fieldTable => + fieldTable.Table.Keys.Any(targetResourceContext => targetResourceContext != resourceContext) || + fieldTable.Table.Values.Any(fieldSet => fieldSet.Fields.Any(field => field is RelationshipAttribute))); + + if (hasRelationshipSelectors) + { + throw new UnsupportedRelationshipException(); + } + } /// public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) @@ -100,18 +145,35 @@ public virtual Task GetForCreateAsync(TId id, CancellationToken cance } /// - public virtual Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, + public virtual async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) { if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (resourceForDatabase == null) throw new ArgumentNullException(nameof(resourceForDatabase)); + AssertNoRelationshipsAreTargeted(); + foreach (var attribute in _targetedFields.Attributes) { attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); } - return Collection.InsertOneAsync(resourceForDatabase, new InsertOneOptions(), cancellationToken); + try + { + await Collection.InsertOneAsync(resourceForDatabase, new InsertOneOptions(), cancellationToken); + } + catch (MongoWriteException ex) + { + throw new DataStoreUpdateException(ex); + } + } + + private void AssertNoRelationshipsAreTargeted() + { + if (_targetedFields.Relationships.Any()) + { + throw new UnsupportedRelationshipException(); + } } /// @@ -127,23 +189,39 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); if (resourceFromDatabase == null) throw new ArgumentNullException(nameof(resourceFromDatabase)); + AssertNoRelationshipsAreTargeted(); + foreach (var attr in _targetedFields.Attributes) + { attr.SetValue(resourceFromDatabase, attr.GetValue(resourceFromRequest)); + } - await Collection.ReplaceOneAsync( - Builders.Filter.Eq(e => e.Id, resourceFromDatabase.Id), - resourceFromDatabase, - new ReplaceOptions(), - cancellationToken); + var filter = Builders.Filter.Eq(e => e.Id, resourceFromDatabase.Id); + + try + { + await Collection.ReplaceOneAsync(filter, resourceFromDatabase, new ReplaceOptions(), cancellationToken); + } + catch (MongoWriteException ex) + { + throw new DataStoreUpdateException(ex); + } } /// public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) { - var result = await Collection.DeleteOneAsync( - Builders.Filter.Eq(e => e.Id, id), - new DeleteOptions(), - cancellationToken); + var filter = Builders.Filter.Eq(e => e.Id, id); + + DeleteResult result; + try + { + result = await Collection.DeleteOneAsync(filter, new DeleteOptions(), cancellationToken); + } + catch (MongoWriteException ex) + { + throw new DataStoreUpdateException(ex); + } if (!result.IsAcknowledged) { @@ -165,7 +243,7 @@ public virtual Task SetRelationshipAsync(TResource primaryResource, object secon /// public virtual Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) { - throw new NotImplementedException(); + throw new UnsupportedRelationshipException(); } /// @@ -180,52 +258,17 @@ public virtual Task RemoveFromToManyRelationshipAsync(TResource primaryResource, /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses MongoDB. /// - public class MongoDbRepository : MongoDbRepository - where TResource : class, IIdentifiable + public class MongoDbRepository : MongoDbRepository, IResourceRepository + where TResource : class, IIdentifiable { public MongoDbRepository( IMongoDatabase mongoDatabase, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IResourceFactory resourceFactory) - : base(mongoDatabase, targetedFields, resourceContextProvider, resourceFactory) - { - } - } - - internal sealed class DummyModel : IModel - { - public static IModel Instance { get; } = new DummyModel(); - - public object this[string name] => throw new NotImplementedException(); - - private DummyModel() - { - } - - public IAnnotation FindAnnotation(string name) - { - throw new NotImplementedException(); - } - - public IEnumerable GetAnnotations() - { - throw new NotImplementedException(); - } - - public IEnumerable GetEntityTypes() + IResourceFactory resourceFactory, + IEnumerable constraintProviders) + : base(mongoDatabase, targetedFields, resourceContextProvider, resourceFactory, constraintProviders) { - throw new NotImplementedException(); - } - - public IEntityType FindEntityType(string name) - { - throw new NotImplementedException(); - } - - public IEntityType FindEntityType(string name, string definingNavigationName, IEntityType definingEntityType) - { - throw new NotImplementedException(); } } } diff --git a/src/JsonApiDotNetCore.MongoDb/Repositories/MongoEntityType.cs b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoEntityType.cs new file mode 100644 index 0000000..2670e1c --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Repositories/MongoEntityType.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.MongoDb.Repositories +{ + internal sealed class MongoEntityType : IEntityType + { + private readonly ResourceContext _resourceContext; + + public IModel Model { get; } + public Type ClrType => _resourceContext.ResourceType; + + public string Name => throw new NotImplementedException(); + public IEntityType BaseType => throw new NotImplementedException(); + public string DefiningNavigationName => throw new NotImplementedException(); + public IEntityType DefiningEntityType => throw new NotImplementedException(); + public object this[string name] => throw new NotImplementedException(); + + public MongoEntityType(ResourceContext resourceContext, MongoDbModel owner) + { + _resourceContext = resourceContext ?? throw new ArgumentNullException(nameof(resourceContext)); + Model = owner ?? throw new ArgumentNullException(nameof(owner)); + } + + public IEnumerable GetProperties() + { + return _resourceContext.Attributes.Select(attr => new MongoDbProperty(attr.Property, this)); + } + + public IAnnotation FindAnnotation(string name) => throw new NotImplementedException(); + public IEnumerable GetAnnotations() => throw new NotImplementedException(); + public IKey FindPrimaryKey() => throw new NotImplementedException(); + public IKey FindKey(IReadOnlyList properties) => throw new NotImplementedException(); + public IEnumerable GetKeys() => throw new NotImplementedException(); + public IForeignKey FindForeignKey(IReadOnlyList properties, IKey principalKey, IEntityType principalEntityType) => throw new NotImplementedException(); + public IEnumerable GetForeignKeys() => throw new NotImplementedException(); + public IIndex FindIndex(IReadOnlyList properties) => throw new NotImplementedException(); + public IEnumerable GetIndexes() => throw new NotImplementedException(); + public IProperty FindProperty(string name) => throw new NotImplementedException(); + public IServiceProperty FindServiceProperty(string name) => throw new NotImplementedException(); + public IEnumerable GetServiceProperties() => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/Resources/MongoDbIdentifiable.cs b/src/JsonApiDotNetCore.MongoDb/Resources/MongoDbIdentifiable.cs new file mode 100644 index 0000000..f705ad9 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Resources/MongoDbIdentifiable.cs @@ -0,0 +1,23 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Resources +{ + /// + /// A convenient basic implementation of for use with MongoDB models. + /// + public abstract class MongoDbIdentifiable : IIdentifiable + { + /// + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public virtual string Id { get; set; } + + /// + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb/Serialization/Building/IgnoreRelationshipsResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore.MongoDb/Serialization/Building/IgnoreRelationshipsResponseResourceObjectBuilder.cs new file mode 100644 index 0000000..29d446a --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/Serialization/Building/IgnoreRelationshipsResponseResourceObjectBuilder.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Serialization.Building +{ + /// + public sealed class IgnoreRelationshipsResponseResourceObjectBuilder : ResponseResourceObjectBuilder + { + public IgnoreRelationshipsResponseResourceObjectBuilder(ILinkBuilder linkBuilder, + IIncludedResourceObjectBuilder includedBuilder, IEnumerable constraintProviders, + IResourceContextProvider resourceContextProvider, IResourceDefinitionAccessor resourceDefinitionAccessor, + IResourceObjectBuilderSettingsProvider settingsProvider) + : base(linkBuilder, includedBuilder, constraintProviders, resourceContextProvider, + resourceDefinitionAccessor, settingsProvider) + { + } + + /// + protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, + IIdentifiable resource) + { + if (resource is MongoDbIdentifiable) + { + return null; + } + + return base.GetRelationshipData(relationship, resource); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/HttpResponseMessageExtensions.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..6ef3b9b --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/HttpResponseMessageExtensions.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Primitives; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace JsonApiDotNetCoreMongoDbExampleTests +{ + public static class HttpResponseMessageExtensions + { + public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) + { + return new HttpResponseMessageAssertions(instance); + } + + public sealed class HttpResponseMessageAssertions + : ReferenceTypeAssertions + { + protected override string Identifier => "response"; + + public HttpResponseMessageAssertions(HttpResponseMessage instance) + { + Subject = instance; + } + + public AndConstraint HaveStatusCode(HttpStatusCode statusCode) + { + if (Subject.StatusCode != statusCode) + { + string responseText = GetFormattedContentAsync(Subject).Result; + Subject.StatusCode.Should().Be(statusCode, "response body returned was:\n" + responseText); + } + + return new AndConstraint(this); + } + + private static async Task GetFormattedContentAsync(HttpResponseMessage responseMessage) + { + string text = await responseMessage.Content.ReadAsStringAsync(); + + try + { + if (text.Length > 0) + { + return JsonConvert.DeserializeObject(text).ToString(); + } + } + catch + { + // ignored + } + + return text; + } + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTestContext.cs new file mode 100644 index 0000000..4c95edc --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTestContext.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.MongoDb.Configuration; +using JsonApiDotNetCore.MongoDb.Repositories; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCoreMongoDbExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Mongo2Go; +using MongoDB.Driver; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCoreMongoDbExampleTests +{ + /// + /// A test context that creates a new database and server instance before running tests and cleans up afterwards. + /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or + /// have your tests class inherit from it (init/cleanup runs once before/after each test). See + /// for details on shared context usage. + /// + /// The server Startup class, which can be defined in the test project. + public class IntegrationTestContext : IDisposable + where TStartup : class + { + private readonly Lazy> _lazyFactory; + + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + private readonly MongoDbRunner _runner; + + public WebApplicationFactory Factory => _lazyFactory.Value; + + public IntegrationTestContext() + { + _lazyFactory = new Lazy>(CreateFactory); + _runner = MongoDbRunner.Start(); + } + + private WebApplicationFactory CreateFactory() + { + var factory = new IntegrationTestWebApplicationFactory(); + + factory.ConfigureServicesBeforeStartup(services => + { + _beforeServicesConfiguration?.Invoke(services); + + services.AddSingleton(sp => + { + var client = new MongoClient(_runner.ConnectionString); + return client.GetDatabase($"JsonApiDotNetCore_MongoDb_{new Random().Next()}_Test"); + }); + + services.AddJsonApi(ConfigureJsonApiOptions, facade => facade.AddCurrentAssembly()); + services.AddJsonApiMongoDb(); + + services.AddScoped(typeof(IResourceReadRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceReadRepository<,>), typeof(MongoDbRepository<,>)); + services.AddScoped(typeof(IResourceWriteRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(MongoDbRepository<,>)); + services.AddScoped(typeof(IResourceRepository<>), typeof(MongoDbRepository<>)); + services.AddScoped(typeof(IResourceRepository<,>), typeof(MongoDbRepository<,>)); + }); + + factory.ConfigureServicesAfterStartup(_afterServicesConfiguration); + + return factory; + } + + private void ConfigureJsonApiOptions(JsonApiOptions options) + { + options.IncludeExceptionStackTraceInErrors = true; + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.Converters.Add(new StringEnumConverter()); + } + + public void Dispose() + { + _runner.Dispose(); + Factory.Dispose(); + } + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) => + _beforeServicesConfiguration = servicesConfiguration; + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) => + _afterServicesConfiguration = servicesConfiguration; + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + using var scope = Factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetService(); + + await asyncAction(db); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteGetAsync(string requestUrl, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePostAsync(string requestUrl, object requestBody, + string contentType = HeaderConstants.MediaType, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, + acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePatchAsync(string requestUrl, object requestBody, + string contentType = HeaderConstants.MediaType, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, + acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteDeleteAsync(string requestUrl, object requestBody = null, + string contentType = HeaderConstants.MediaType, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, + acceptHeaders); + } + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody, + string contentType, IEnumerable acceptHeaders) + { + var request = new HttpRequestMessage(method, requestUrl); + string requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + request.Content = new StringContent(requestText); + + if (contentType != null) + { + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + } + + using HttpClient client = Factory.CreateClient(); + + if (acceptHeaders != null) + { + foreach (var acceptHeader in acceptHeaders) + { + client.DefaultRequestHeaders.Accept.Add(acceptHeader); + } + } + + HttpResponseMessage responseMessage = await client.SendAsync(request); + + string responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument); + } + + private string SerializeRequest(object requestBody) + { + return requestBody == null + ? null + : requestBody is string stringRequestBody + ? stringRequestBody + : JsonConvert.SerializeObject(requestBody); + } + + private TResponseDocument DeserializeResponse(string responseText) + { + if (typeof(TResponseDocument) == typeof(string)) + { + return (TResponseDocument)(object)responseText; + } + + try + { + return JsonConvert.DeserializeObject(responseText); + } + catch (JsonException exception) + { + throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); + } + } + + private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory + { + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) + { + _beforeServicesConfiguration = servicesConfiguration; + } + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) + { + _afterServicesConfiguration = servicesConfiguration; + } + + protected override IHostBuilder CreateHostBuilder() + { + return Host.CreateDefaultBuilder(null) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.ConfigureServices(services => + { + _beforeServicesConfiguration?.Invoke(services); + }); + + webBuilder.UseStartup(); + + webBuilder.ConfigureServices(services => + { + _afterServicesConfiguration?.Invoke(services); + }); + }); + } + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/FakerContainer.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/FakerContainer.cs new file mode 100644 index 0000000..abc429f --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/FakerContainer.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests +{ + internal abstract class FakerContainer + { + protected static int GetFakerSeed() + { + // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. + + MethodBase testMethod = GetTestMethod(); + var testName = testMethod.DeclaringType?.FullName + "." + testMethod.Name; + + return GetDeterministicHashCode(testName); + } + + private static MethodBase GetTestMethod() + { + var stackTrace = new StackTrace(); + + var testMethod = stackTrace.GetFrames() + .Select(stackFrame => stackFrame?.GetMethod()) + .FirstOrDefault(IsTestMethod); + + if (testMethod == null) + { + // If called after the first await statement, the test method is no longer on the stack, + // but has been replaced with the compiler-generated async/wait state machine. + throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method."); + } + + return testMethod; + } + + private static bool IsTestMethod(MethodBase method) + { + if (method == null) + { + return false; + } + + return method.GetCustomAttribute(typeof(FactAttribute)) != null || method.GetCustomAttribute(typeof(TheoryAttribute)) != null; + } + + private static int GetDeterministicHashCode(string source) + { + // https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core/ + unchecked + { + int hash1 = (5381 << 16) + 5381; + int hash2 = hash1; + + for (int i = 0; i < source.Length; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ source[i]; + + if (i == source.Length - 1) + { + break; + } + + hash2 = ((hash2 << 5) + hash2) ^ source[i + 1]; + } + + return hash1 + hash2 * 1566083941; + } + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs new file mode 100644 index 0000000..b8fe7cc --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -0,0 +1,325 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterDataTypeTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterDataTypeTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Theory] + [InlineData(nameof(FilterableResource.SomeString), "text")] + [InlineData(nameof(FilterableResource.SomeBoolean), true)] + [InlineData(nameof(FilterableResource.SomeNullableBoolean), true)] + [InlineData(nameof(FilterableResource.SomeInt32), 1)] + [InlineData(nameof(FilterableResource.SomeNullableInt32), 1)] + [InlineData(nameof(FilterableResource.SomeUnsignedInt64), 1ul)] + [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64), 1ul)] + [InlineData(nameof(FilterableResource.SomeDouble), 0.5d)] + [InlineData(nameof(FilterableResource.SomeNullableDouble), 0.5d)] + [InlineData(nameof(FilterableResource.SomeEnum), DayOfWeek.Saturday)] + [InlineData(nameof(FilterableResource.SomeNullableEnum), DayOfWeek.Saturday)] + public async Task Can_filter_equality_on_type(string propertyName, object value) + { + // Arrange + var resource = new FilterableResource(); + var property = typeof(FilterableResource).GetProperty(propertyName); + property?.SetValue(resource, value); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var attributeName = propertyName.Camelize(); + var route = $"/filterableResources?filter=equals({attributeName},'{value}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes[attributeName].Should().Be(value is Enum ? value.ToString() : value); + } + + [Fact] + public async Task Can_filter_equality_on_type_Decimal() + { + // Arrange + var resource = new FilterableResource {SomeDecimal = 0.5m}; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDecimal"].Should().Be(resource.SomeDecimal); + } + + [Fact] + public async Task Can_filter_equality_on_type_Guid() + { + // Arrange + var resource = new FilterableResource {SomeGuid = Guid.NewGuid()}; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = $"/filterableResources?filter=equals(someGuid,'{resource.SomeGuid}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someGuid"].Should().Be(resource.SomeGuid.ToString()); + } + + [Fact] + public async Task Can_filter_equality_on_type_DateTime() + { + // Arrange + var resource = new FilterableResource {SomeDateTime = 27.January(2003).At(11, 22, 33, 44).AsUtc()}; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = $"/filterableResources?filter=equals(someDateTime,'{resource.SomeDateTime:O}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDateTime"].Should().Be(resource.SomeDateTime); + } + + [Fact] + public async Task Can_filter_equality_on_type_DateTimeOffset() + { + // Arrange + var resource = new FilterableResource + { + SomeDateTimeOffset = new DateTimeOffset(27.January(2003).At(11, 22, 33, 44), TimeSpan.FromHours(3)) + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = $"/filterableResources?filter=equals(someDateTimeOffset,'{WebUtility.UrlEncode(resource.SomeDateTimeOffset.ToString("O"))}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDateTimeOffset"].Should().Be(resource.SomeDateTimeOffset.LocalDateTime); + } + + [Fact] + public async Task Can_filter_equality_on_type_TimeSpan() + { + // Arrange + var resource = new FilterableResource {SomeTimeSpan = new TimeSpan(1, 2, 3, 4, 5)}; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someTimeSpan"].Should().Be(resource.SomeTimeSpan.ToString()); + } + + [Fact] + public async Task Cannot_filter_equality_on_incompatible_value() + { + // Arrange + var resource = new FilterableResource {SomeInt32 = 1}; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = "/filterableResources?filter=equals(someInt32,'ABC')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Query creation failed due to incompatible types."); + responseDocument.Errors[0].Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Theory] + [InlineData(nameof(FilterableResource.SomeString))] + [InlineData(nameof(FilterableResource.SomeNullableBoolean))] + [InlineData(nameof(FilterableResource.SomeNullableInt32))] + [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64))] + [InlineData(nameof(FilterableResource.SomeNullableDecimal))] + [InlineData(nameof(FilterableResource.SomeNullableDouble))] + [InlineData(nameof(FilterableResource.SomeNullableGuid))] + [InlineData(nameof(FilterableResource.SomeNullableDateTime))] + [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] + [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableEnum))] + public async Task Can_filter_is_null_on_type(string propertyName) + { + // Arrange + var resource = new FilterableResource(); + var property = typeof(FilterableResource).GetProperty(propertyName); + property?.SetValue(resource, null); + + var otherResource = new FilterableResource + { + SomeString = "X", + SomeNullableBoolean = true, + SomeNullableInt32 = 1, + SomeNullableUnsignedInt64 = 1, + SomeNullableDecimal = 1, + SomeNullableDouble = 1, + SomeNullableGuid = Guid.NewGuid(), + SomeNullableDateTime = 1.January(2001), + SomeNullableDateTimeOffset = 1.January(2001), + SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableEnum = DayOfWeek.Friday + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var attributeName = propertyName.Camelize(); + var route = $"/filterableResources?filter=equals({attributeName},null)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes[attributeName].Should().Be(null); + } + + [Theory] + [InlineData(nameof(FilterableResource.SomeString))] + [InlineData(nameof(FilterableResource.SomeNullableBoolean))] + [InlineData(nameof(FilterableResource.SomeNullableInt32))] + [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64))] + [InlineData(nameof(FilterableResource.SomeNullableDecimal))] + [InlineData(nameof(FilterableResource.SomeNullableDouble))] + [InlineData(nameof(FilterableResource.SomeNullableGuid))] + [InlineData(nameof(FilterableResource.SomeNullableDateTime))] + [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] + [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableEnum))] + public async Task Can_filter_is_not_null_on_type(string propertyName) + { + // Arrange + var resource = new FilterableResource + { + SomeString = "X", + SomeNullableBoolean = true, + SomeNullableInt32 = 1, + SomeNullableUnsignedInt64 = 1, + SomeNullableDecimal = 1, + SomeNullableDouble = 1, + SomeNullableGuid = Guid.NewGuid(), + SomeNullableDateTime = 1.January(2001), + SomeNullableDateTimeOffset = 1.January(2001), + SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableEnum = DayOfWeek.Friday + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var attributeName = propertyName.Camelize(); + var route = $"/filterableResources?filter=not(equals({attributeName},null))"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes[attributeName].Should().NotBe(null); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs new file mode 100644 index 0000000..1254d03 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs @@ -0,0 +1,219 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Tag = JsonApiDotNetCoreMongoDbExample.Models.Tag; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterDepthTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterDepthTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?filter=equals(caption,'Two')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(articles[1].StringId); + } + + [Fact] + public async Task Cannot_filter_in_single_primary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = $"/api/v1/articles/{article.StringId}?filter=equals(caption,'Two')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("The specified filter is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_on_HasOne_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X", + Author = new Author + { + LastName = "Conner" + } + }, + new Article + { + Caption = "X", + Author = new Author + { + LastName = "Smith" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?filter=equals(author.lastName,'Smith')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_filter_on_HasMany_relationship() + { + // Arrange + var blogs = new List + { + new Blog(), + new Blog + { + Articles = new List
+ { + new Article + { + Caption = "X" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(blogs); + }); + + var route = "/api/v1/blogs?filter=greaterThan(count(articles),'0')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_filter_on_HasManyThrough_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X" + }, + new Article + { + Caption = "X", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Hot" + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?filter=has(tags)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs new file mode 100644 index 0000000..5e1c41b --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using System.Web; +using FluentAssertions; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Repositories; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterOperatorTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterOperatorTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Fact] + public async Task Can_filter_equality_on_special_characters() + { + // Arrange + var resource = new FilterableResource + { + SomeString = "This, that & more" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = $"/filterableResources?filter=equals(someString,'{HttpUtility.UrlEncode(resource.SomeString)}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someString"].Should().Be(resource.SomeString); + } + + [Fact] + public async Task Cannot_filter_equality_on_two_attributes() + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = 5, + OtherInt32 = 5 + }; + + var otherResource = new FilterableResource + { + SomeInt32 = 5, + OtherInt32 = 10 + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var route = "/filterableResources?filter=equals(someInt32,otherInt32)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Comparing attributes against each other is not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Theory] + [InlineData(19, 21, ComparisonOperator.LessThan, 20)] + [InlineData(19, 21, ComparisonOperator.LessThan, 21)] + [InlineData(19, 21, ComparisonOperator.LessOrEqual, 20)] + [InlineData(19, 21, ComparisonOperator.LessOrEqual, 19)] + [InlineData(21, 19, ComparisonOperator.GreaterThan, 20)] + [InlineData(21, 19, ComparisonOperator.GreaterThan, 19)] + [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 20)] + [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 21)] + public async Task Can_filter_comparison_on_whole_number(int matchingValue, int nonMatchingValue, ComparisonOperator filterOperator, double filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = matchingValue + }; + + var otherResource = new FilterableResource + { + SomeInt32 = nonMatchingValue + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someInt32,'{filterValue}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + } + + [Theory] + [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.0)] + [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.1)] + [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 2.0)] + [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 1.9)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 2.0)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 1.9)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.0)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.1)] + public async Task Can_filter_comparison_on_fractional_number(double matchingValue, double nonMatchingValue, ComparisonOperator filterOperator, double filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeDouble = matchingValue + }; + + var otherResource = new FilterableResource + { + SomeDouble = nonMatchingValue + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDouble,'{filterValue}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDouble"].Should().Be(resource.SomeDouble); + } + + [Theory] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05")] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09")] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05")] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09")] + public async Task Can_filter_comparison_on_DateTime(string matchingDateTime, string nonMatchingDateTime, ComparisonOperator filterOperator, string filterDateTime) + { + // Arrange + var resource = new FilterableResource + { + SomeDateTime = DateTime.SpecifyKind(DateTime.ParseExact(matchingDateTime, "yyyy-MM-dd", null), DateTimeKind.Utc) + }; + + var otherResource = new FilterableResource + { + SomeDateTime = DateTime.SpecifyKind(DateTime.ParseExact(nonMatchingDateTime, "yyyy-MM-dd", null), DateTimeKind.Utc) + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTime,'{DateTime.ParseExact(filterDateTime, "yyyy-MM-dd", null)}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDateTime"].Should().Be(resource.SomeDateTime); + } + + [Theory] + [InlineData("The fox jumped over the lazy dog", "Other", TextMatchKind.Contains, "jumped")] + [InlineData("The fox jumped over the lazy dog", "the fox...", TextMatchKind.Contains, "The")] + [InlineData("The fox jumped over the lazy dog", "The fox jumped", TextMatchKind.Contains, "dog")] + [InlineData("The fox jumped over the lazy dog", "Yesterday The fox...", TextMatchKind.StartsWith, "The")] + [InlineData("The fox jumped over the lazy dog", "over the lazy dog earlier", TextMatchKind.EndsWith, "dog")] + public async Task Can_filter_text_match(string matchingText, string nonMatchingText, TextMatchKind matchKind, string filterText) + { + // Arrange + var resource = new FilterableResource + { + SomeString = matchingText + }; + + var otherResource = new FilterableResource + { + SomeString = nonMatchingText + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var route = $"/filterableResources?filter={matchKind.ToString().Camelize()}(someString,'{filterText}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someString"].Should().Be(resource.SomeString); + } + + [Theory] + [InlineData("two", "one two", "'one','two','three'")] + [InlineData("two", "nine", "'one','two','three','four','five'")] + public async Task Can_filter_in_set(string matchingText, string nonMatchingText, string filterText) + { + // Arrange + var resource = new FilterableResource + { + SomeString = matchingText + }; + + var otherResource = new FilterableResource + { + SomeString = nonMatchingText + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, otherResource}); + }); + + var route = $"/filterableResources?filter=any(someString,{filterText})"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someString"].Should().Be(resource.SomeString); + } + + [Fact] + public async Task Cannot_filter_on_has() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new FilterableResource() + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = "/filterableResources?filter=has(children)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_filter_on_count() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new FilterableResource(), + new FilterableResource() + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = "/filterableResources?filter=equals(count(children),'2')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Theory] + [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'))")] + [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'),equals(someEnum,'Tuesday'))")] + [InlineData("or(equals(someString,'---'),lessThan(someInt32,'33'))")] + [InlineData("not(equals(someEnum,'Saturday'))")] + public async Task Can_filter_on_logical_functions(string filterExpression) + { + // Arrange + var resource1 = new FilterableResource + { + SomeString = "ABC", + SomeInt32 = 11, + SomeEnum = DayOfWeek.Tuesday + }; + + var resource2 = new FilterableResource + { + SomeString = "XYZ", + SomeInt32 = 99, + SomeEnum = DayOfWeek.Saturday + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection() + .InsertManyAsync(new[] {resource1, resource2}); + }); + + var route = $"/filterableResources?filter={filterExpression}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(resource1.StringId); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterTests.cs new file mode 100644 index 0000000..0af0146 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Fact] + public async Task Can_filter_on_ID() + { + // Arrange + var person = new Person + { + FirstName = "Jane" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(new[] {person, new Person()}); + }); + + var route = $"/api/v1/people?filter=equals(id,'{person.StringId}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(person.StringId); + responseDocument.ManyData[0].Attributes["firstName"].Should().Be(person.FirstName); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterableResource.cs new file mode 100644 index 0000000..c499e51 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterableResource.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterableResource : MongoDbIdentifiable + { + [Attr] public string SomeString { get; set; } + + [Attr] public bool SomeBoolean { get; set; } + [Attr] public bool? SomeNullableBoolean { get; set; } + + [Attr] public int SomeInt32 { get; set; } + [Attr] public int? SomeNullableInt32 { get; set; } + + [Attr] public int OtherInt32 { get; set; } + [Attr] public int? OtherNullableInt32 { get; set; } + + [Attr] public ulong SomeUnsignedInt64 { get; set; } + [Attr] public ulong? SomeNullableUnsignedInt64 { get; set; } + + [Attr] public decimal SomeDecimal { get; set; } + [Attr] public decimal? SomeNullableDecimal { get; set; } + + [Attr] public double SomeDouble { get; set; } + [Attr] public double? SomeNullableDouble { get; set; } + + [Attr] public Guid SomeGuid { get; set; } + [Attr] public Guid? SomeNullableGuid { get; set; } + + [Attr] public DateTime SomeDateTime { get; set; } + [Attr] public DateTime? SomeNullableDateTime { get; set; } + + [Attr] public DateTimeOffset SomeDateTimeOffset { get; set; } + [Attr] public DateTimeOffset? SomeNullableDateTimeOffset { get; set; } + + [Attr] public TimeSpan SomeTimeSpan { get; set; } + [Attr] public TimeSpan? SomeNullableTimeSpan { get; set; } + + [Attr] public DayOfWeek SomeEnum { get; set; } + [Attr] public DayOfWeek? SomeNullableEnum { get; set; } + + [HasMany] + [BsonIgnore] + public ICollection Children { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs new file mode 100644 index 0000000..29663b4 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterableResourcesController : JsonApiController + { + public FilterableResourcesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Includes/IncludeTests.cs new file mode 100644 index 0000000..81ceb4b --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Repositories; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Includes +{ + public sealed class IncludeTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public IncludeTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_include_in_primary_resources() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(workItem.Assignee); + await db.GetCollection().InsertOneAsync(workItem); + }); + + var route = "/workItems?include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs new file mode 100644 index 0000000..cc72a0e --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Meta +{ + public sealed class ResourceMetaTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public ResourceMetaTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Resource_Meta() + { + // Arrange + var todoItems = new[] + { + new TodoItem {Description = "Important: Pay the bills"}, + new TodoItem {Description = "Plan my birthday party"}, + new TodoItem {Description = "Important: Call mom"} + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(todoItems); + }); + + var route = "/api/v1/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Meta.Should().ContainKey("hasHighPriority"); + responseDocument.ManyData[1].Meta.Should().BeNull(); + responseDocument.ManyData[2].Meta.Should().ContainKey("hasHighPriority"); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs new file mode 100644 index 0000000..f64fd99 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Meta +{ + public sealed class TopLevelCountTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public TopLevelCountTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Total_Resource_Count_Included_For_Collection() + { + // Arrange + var todoItem = new TodoItem(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertOneAsync(todoItem); + }); + + var route = "/api/v1/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().NotBeNull(); + responseDocument.Meta["totalResources"].Should().Be(1); + } + + [Fact] + public async Task Total_Resource_Count_Included_For_Empty_Collection() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + }); + + var route = "/api/v1/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().NotBeNull(); + responseDocument.Meta["totalResources"].Should().Be(0); + } + + [Fact] + public async Task Total_Resource_Count_Excluded_From_POST_Response() + { + // Arrange + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = "Something" + } + } + }; + + var route = "/api/v1/todoItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Meta.Should().BeNull(); + } + + [Fact] + public async Task Total_Resource_Count_Excluded_From_PATCH_Response() + { + // Arrange + var todoItem = new TodoItem(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(todoItem); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = todoItem.StringId, + attributes = new + { + description = "Something else" + } + } + }; + + var route = $"/api/v1/todoItems/{todoItem.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs new file mode 100644 index 0000000..39c9a3d --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -0,0 +1,28 @@ +using System; +using FluentAssertions; +using FluentAssertions.Primitives; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests +{ + public static class ObjectAssertionsExtensions + { + /// + /// Used to assert on a nullable column, whose value is returned as in JSON:API response body. + /// + public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expected, string because = "", + params object[] becauseArgs) + { + if (expected == null) + { + source.Subject.Should().BeNull(because, becauseArgs); + } + else + { + // We lose a little bit of precision (milliseconds) on roundtrip through PostgreSQL database. + + var value = new DateTimeOffset((DateTime) source.Subject); + value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); + } + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs new file mode 100644 index 0000000..7dcf5b7 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Pagination +{ + public sealed class PaginationWithTotalCountTests : IClassFixture> + { + private const int _defaultPageSize = 5; + private readonly IntegrationTestContext _testContext; + + public PaginationWithTotalCountTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + options.DefaultPageSize = new PageSize(_defaultPageSize); + options.MaximumPageSize = null; + options.MaximumPageNumber = null; + options.AllowUnknownQueryStringParameters = true; + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?page[number]=2&page[size]=1"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(articles[1].StringId); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost" + route); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/articles?page[size]=1"); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); + responseDocument.Links.Next.Should().BeNull(); + } + + [Fact] + public async Task Uses_default_page_number_and_size() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(2); + + var articles = new[] + { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + }, + new Article + { + Caption = "Three" + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(articles[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[1].StringId); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost" + route); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be("http://localhost/api/v1/articles?page[number]=2"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().Be("http://localhost/api/v1/articles?page[number]=2"); + } + + [Fact] + public async Task Returns_all_resources_when_paging_is_disabled() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = null; + + var articles = new List
(); + + for (int index = 0; index < 25; index++) + { + articles.Add(new Article + { + Caption = $"Item {index:D3}" + }); + } + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(25); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost" + route); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs new file mode 100644 index 0000000..58ccd99 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Pagination +{ + public sealed class RangeValidationTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly Faker _todoItemFaker = new Faker(); + + private const int _defaultPageSize = 5; + + public RangeValidationTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(_defaultPageSize); + options.MaximumPageSize = null; + options.MaximumPageNumber = null; + } + + [Fact] + public async Task When_page_number_is_too_high_it_must_return_empty_set_of_resources() + { + // Arrange + var todoItems = _todoItemFaker.Generate(3); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(todoItems); + }); + + var route = "/api/v1/todoItems?sort=id&page[size]=3&page[number]=2"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task When_page_size_is_zero_it_must_succeed() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=0"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_size_is_positive_it_must_succeed() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=50"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs new file mode 100644 index 0000000..9a9aea8 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -0,0 +1,239 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Creating +{ + public sealed class CreateResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public CreateResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_create_resource_with_string_ID() + { + // Arrange + var newWorkItem = _fakers.WorkItem.Generate(); + newWorkItem.DueAt = null; + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + description = newWorkItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().Be(newWorkItem.DueAt); + responseDocument.SingleData.Relationships.Should().BeNull(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await db.GetCollection().AsQueryable() + .Where(w => w.Id == newWorkItemId) + .FirstOrDefaultAsync(); + + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + workItemInDatabase.DueAt.Should().Be(newWorkItem.DueAt); + }); + + var property = typeof(WorkItem).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Cannot_create_resource_with_int_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "modelWithIntIds", + attributes = new + { + description = "Test" + } + } + }; + + var route = "/modelWithIntIds"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("MongoDB can only be used for resources with an 'Id' property of type 'string'."); + } + + [Fact] + public async Task Can_create_resource_without_attributes_or_relationships() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + }, + relationship = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().BeNull(); + responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == newWorkItemId) + .FirstOrDefaultAsync(); + + workItemInDatabase.Description.Should().BeNull(); + workItemInDatabase.DueAt.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_attribute() + { + // Arrange + var newWorkItem = _fakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + doesNotExist = "ignored", + description = newWorkItem.Description + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + responseDocument.SingleData.Relationships.Should().BeNull(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == newWorkItemId) + .FirstOrDefaultAsync(); + + workItemInDatabase.Description.Should().Be(newWorkItem.Description); + }); + } + + [Fact] + public async Task Can_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + doesNotExist = new + { + data = new + { + type = "doesNotExist", + id = "ffffffffffffffffffffffff" + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().BeNull(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == newWorkItemId) + .FirstOrDefaultAsync(); + + workItemInDatabase.Should().NotBeNull(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs new file mode 100644 index 0000000..bbc8016 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Creating +{ + public sealed class CreateResourceWithClientGeneratedIdTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + { + // Arrange + var newColor = _fakers.RgbColor.Generate(); + newColor.Id = "507f191e810c19729de860ea"; + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + attributes = new + { + displayName = newColor.DisplayName + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var colorInDatabase = await db.GetCollection().AsQueryable() + .Where(color => color.Id == newColor.Id) + .FirstOrDefaultAsync(); + + colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_side_effects_with_fieldset() + { + // Arrange + var newGroup = _fakers.WorkItemGroup.Generate(); + newGroup.Id = "5ffcc0d1d69a27c92b8c62dd"; + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = newGroup.StringId, + attributes = new + { + name = newGroup.Name + } + } + }; + + var route = "/workItemGroups?fields[workItemGroups]=name"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(newGroup.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + responseDocument.SingleData.Relationships.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var groupInDatabase = await db.GetCollection().AsQueryable() + .Where(group => group.Id == newGroup.Id) + .FirstOrDefaultAsync(); + + groupInDatabase.Name.Should().Be(newGroup.Name); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + + var colorToCreate = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingColor); + colorToCreate.Id = existingColor.Id; + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = colorToCreate.StringId, + attributes = new + { + displayName = colorToCreate.DisplayName + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Conflict); + responseDocument.Errors[0].Title.Should().Be("Another resource with the specified ID already exists."); + responseDocument.Errors[0].Detail.Should().Be($"Another resource of type 'rgbColors' with ID '{existingColor.StringId}' already exists."); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs new file mode 100644 index 0000000..6b1adf0 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Creating +{ + public sealed class CreateResourceWithToManyRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public CreateResourceWithToManyRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_create_HasMany_relationship() + { + // Arrange + var existingUserAccounts = _fakers.UserAccount.Generate(2); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertManyAsync(existingUserAccounts); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccounts[0].StringId + }, + new + { + type = "userAccounts", + id = existingUserAccounts[1].StringId + } + } + } + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs new file mode 100644 index 0000000..0aec789 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Creating +{ + public sealed class CreateResourceWithToOneRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public CreateResourceWithToOneRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_create_relationship() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingGroup.Color); + await db.GetCollection().InsertOneAsync(existingGroup); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingGroup.Color.StringId + } + } + } + } + }; + + var route = "/workItemGroups"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs new file mode 100644 index 0000000..9d98a5f --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Deleting +{ + public sealed class DeleteResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public DeleteResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_delete_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemsInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstOrDefaultAsync(); + + workItemsInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_missing_resource() + { + // Arrange + var route = "/workItems/ffffffffffffffffffffffff"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID 'ffffffffffffffffffffffff' does not exist."); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs new file mode 100644 index 0000000..5eedc95 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public FetchRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_get_HasOne_relationship() + { + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(workItem); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_HasMany_relationship() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(userAccount); + }); + + var route = $"/userAccounts/{userAccount.StringId}/relationships/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_HasManyThrough_relationship() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(workItem); + }); + + var route = $"/workItems/{workItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs new file mode 100644 index 0000000..db0e8da --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -0,0 +1,196 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public FetchResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var workItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(workItems); + }); + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItems[0].StringId); + item1.Type.Should().Be("workItems"); + item1.Attributes["description"].Should().Be(workItems[0].Description); + item1.Attributes["dueAt"].Should().BeCloseTo(workItems[0].DueAt); + item1.Attributes["priority"].Should().Be(workItems[0].Priority.ToString("G")); + item1.Relationships.Should().BeNull(); + + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItems[1].StringId); + item2.Type.Should().Be("workItems"); + item2.Attributes["description"].Should().Be(workItems[1].Description); + item2.Attributes["dueAt"].Should().BeCloseTo(workItems[1].DueAt); + item2.Attributes["priority"].Should().Be(workItems[1].Priority.ToString("G")); + item2.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(workItem); + }); + + var route = "/workItems/" + workItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(workItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); + responseDocument.SingleData.Attributes["dueAt"].Should().BeCloseTo(workItem.DueAt); + responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + var route = "/workItems/ffffffffffffffffffffffff"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID 'ffffffffffffffffffffffff' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_HasOne_resource() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.Assignee = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(workItem.Assignee); + await db.GetCollection().InsertOneAsync(workItem); + }); + + var route = $"/workItems/{workItem.StringId}/assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_secondary_HasMany_resources() + { + // Arrange + var userAccount = _fakers.UserAccount.Generate(); + userAccount.AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertManyAsync(userAccount.AssignedItems); + await db.GetCollection().InsertOneAsync(userAccount); + }); + + var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_secondary_HasManyThrough_resources() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + workItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection() + .InsertManyAsync(workItem.WorkItemTags.Select(workItemTag => workItemTag.Tag)); + await db.GetCollection().InsertOneAsync(workItem); + }); + + var route = $"/workItems/{workItem.StringId}/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ModelWithIntId.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ModelWithIntId.cs new file mode 100644 index 0000000..b784173 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ModelWithIntId.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class ModelWithIntId : Identifiable + { + [Attr] + public string Description { get; set; } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ModelWithIntIdsController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ModelWithIntIdsController.cs new file mode 100644 index 0000000..eaaccb2 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ModelWithIntIdsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class ModelWithIntIdsController : JsonApiController + { + public ModelWithIntIdsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs new file mode 100644 index 0000000..00a6a07 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs @@ -0,0 +1,45 @@ +using System; +using Bogus; +using MongoDB.Bson; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + internal sealed class ReadWriteFakers : FakerContainer + { + private readonly Lazy> _lazyWorkItemFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(workItem => workItem.Description, f => f.Lorem.Sentence()) + .RuleFor(workItem => workItem.DueAt, f => f.Date.Future()) + .RuleFor(workItem => workItem.Priority, f => f.PickRandom())); + + private readonly Lazy> _lazyWorkTagFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(workTag => workTag.Text, f => f.Lorem.Word()) + .RuleFor(workTag => workTag.IsBuiltIn, f => f.Random.Bool())); + + private readonly Lazy> _lazyUserAccountFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(userAccount => userAccount.FirstName, f => f.Name.FirstName()) + .RuleFor(userAccount => userAccount.LastName, f => f.Name.LastName())); + + private readonly Lazy> _lazyWorkItemGroupFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(group => group.Name, f => f.Lorem.Word()) + .RuleFor(group => group.IsPublic, f => f.Random.Bool())); + + private readonly Lazy> _lazyRgbColorFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(color => color.DisplayName, f => f.Lorem.Word())); + + public Faker WorkItem => _lazyWorkItemFaker.Value; + public Faker WorkTag => _lazyWorkTagFaker.Value; + public Faker UserAccount => _lazyUserAccountFaker.Value; + public Faker WorkItemGroup => _lazyWorkItemGroupFaker.Value; + public Faker RgbColor => _lazyRgbColorFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/RgbColor.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/RgbColor.cs new file mode 100644 index 0000000..dc7c015 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/RgbColor.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class RgbColor : MongoDbIdentifiable + { + [Attr] + public string DisplayName { get; set; } + + [HasOne] + [BsonIgnore] + public WorkItemGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs new file mode 100644 index 0000000..b056048 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/RgbColorsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class RgbColorsController : JsonApiController + { + public RgbColorsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000..9e46b45 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Relationships +{ + public sealed class AddToToManyRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public AddToToManyRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_add_to_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingSubscriber); + await db.GetCollection().InsertManyAsync(existingWorkItem.Subscribers); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_add_to_HasManyThrough_relationship() + { + // Arrange + var existingWorkItems = _fakers.WorkItem.Generate(2); + existingWorkItems[0].WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + } + }; + existingWorkItems[1].WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection() + .InsertManyAsync(existingWorkItems + .SelectMany(workItem => workItem.WorkItemTags.Select(workItemTag => workItemTag.Tag))); + await db.GetCollection().InsertManyAsync(existingWorkItems); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItems[0].WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingWorkItems[1].WorkItemTags.ElementAt(0).Tag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItems[0].StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000..316407a --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,123 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Relationships +{ + public sealed class RemoveFromToManyRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public RemoveFromToManyRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_remove_from_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingSubscriber); + await db.GetCollection().InsertManyAsync(existingWorkItem.Subscribers); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingSubscriber.StringId + }, + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(0).StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_remove_from_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + } + }; + var existingTag = _fakers.WorkTag.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingTag); + await db.GetCollection().InsertManyAsync(existingWorkItem.WorkItemTags.Select(workItemTag => workItemTag.Tag)); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(1).Tag.StringId + }, + new + { + type = "workTags", + id = existingTag.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000..cf78944 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,130 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Relationships +{ + public sealed class ReplaceToManyRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public ReplaceToManyRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_replace_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingSubscriber); + await db.GetCollection().InsertManyAsync(existingWorkItem.Subscribers); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + } + }; + + var existingTags = _fakers.WorkTag.Generate(2); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertManyAsync(existingTags); + await db.GetCollection().InsertManyAsync(existingWorkItem.WorkItemTags.Select(workItemTag => workItemTag.Tag)); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000..0ad08f1 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Relationships +{ + public sealed class UpdateToOneRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public UpdateToOneRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_replace_relationship() + { + // Arrange + var existingGroups = _fakers.WorkItemGroup.Generate(2); + existingGroups[0].Color = _fakers.RgbColor.Generate(); + existingGroups[1].Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection() + .InsertManyAsync(existingGroups.Select(workItemGroup => workItemGroup.Color)); + await db.GetCollection().InsertManyAsync(existingGroups); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingGroups[0].Color.StringId + } + }; + + var route = $"/workItemGroups/{existingGroups[1].StringId}/relationships/color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000..b82e059 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,152 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Resources +{ + public sealed class ReplaceToManyRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public ReplaceToManyRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_replace_HasMany_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + var existingSubscriber = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingSubscriber); + await db.GetCollection().InsertManyAsync(existingWorkItem.Subscribers); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + subscribers = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingWorkItem.Subscribers.ElementAt(1).StringId + }, + new + { + type = "userAccounts", + id = existingSubscriber.StringId + } + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_replace_HasManyThrough_relationship() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + existingWorkItem.WorkItemTags = new[] + { + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + }, + new WorkItemTag + { + Tag = _fakers.WorkTag.Generate() + } + }; + + var existingTags = _fakers.WorkTag.Generate(2); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertManyAsync(existingTags); + await db.GetCollection().InsertManyAsync(existingWorkItem.WorkItemTags.Select(workItemTag => workItemTag.Tag)); + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "workTags", + id = existingWorkItem.WorkItemTags.ElementAt(0).Tag.StringId + }, + new + { + type = "workTags", + id = existingTags[0].StringId + }, + new + { + type = "workTags", + id = existingTags[1].StringId + } + } + } + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000..f379db4 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -0,0 +1,395 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Resources +{ + public sealed class UpdateResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public UpdateResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingUserAccount); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + }, + relationships = new + { + } + } + }; + + var route = $"/userAccounts/{existingUserAccount.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_update_resource_with_unknown_attribute() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newFirstName = _fakers.UserAccount.Generate().FirstName; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingUserAccount); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newFirstName, + doesNotExist = "Ignored" + } + } + }; + + var route = $"/userAccounts/{existingUserAccount.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var userAccountInDatabase = await db.GetCollection().AsQueryable() + .Where(userAccount => userAccount.Id == existingUserAccount.Id) + .FirstOrDefaultAsync(); + + userAccountInDatabase.FirstName.Should().Be(newFirstName); + userAccountInDatabase.LastName.Should().Be(existingUserAccount.LastName); + }); + } + + [Fact] + public async Task Can_partially_update_resource_with_string_ID() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + var newName = _fakers.WorkItemGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingGroup); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId, + attributes = new + { + name = newName + } + } + }; + + var route = $"/workItemGroups/{existingGroup.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItemGroups"); + responseDocument.SingleData.Id.Should().Be(existingGroup.StringId); + responseDocument.SingleData.Attributes["name"].Should().Be(newName); + responseDocument.SingleData.Attributes["isPublic"].Should().Be(existingGroup.IsPublic); + responseDocument.SingleData.Relationships.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var groupInDatabase = await db.GetCollection().AsQueryable() + .Where(group => group.Id == existingGroup.Id) + .FirstOrDefaultAsync(); + + groupInDatabase.Name.Should().Be(newName); + groupInDatabase.IsPublic.Should().Be(existingGroup.IsPublic); + }); + + var property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Can_completely_update_resource_with_string_ID() + { + // Arrange + var existingColor = _fakers.RgbColor.Generate(); + var newDisplayName = _fakers.RgbColor.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingColor); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + attributes = new + { + displayName = newDisplayName + } + } + }; + + var route = $"/rgbColors/{existingColor.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var colorInDatabase = await db.GetCollection().AsQueryable() + .Where(color => color.Id == existingColor.Id) + .FirstOrDefaultAsync(); + + colorInDatabase.DisplayName.Should().Be(newDisplayName); + }); + + var property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + } + + [Fact] + public async Task Can_update_resource_without_side_effects() + { + // Arrange + var existingUserAccount = _fakers.UserAccount.Generate(); + var newUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingUserAccount); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newUserAccount.FirstName, + lastName = newUserAccount.LastName + } + } + }; + + var route = $"/userAccounts/{existingUserAccount.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var userAccountInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == existingUserAccount.Id) + .FirstOrDefaultAsync(); + + userAccountInDatabase.FirstName.Should().Be(newUserAccount.FirstName); + userAccountInDatabase.LastName.Should().Be(newUserAccount.LastName); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + responseDocument.SingleData.Attributes.Should().ContainKey("concurrencyToken"); + responseDocument.SingleData.Relationships.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstOrDefaultAsync(); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Can_update_resource_with_side_effects_with_primary_fieldset() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + var newDescription = _fakers.WorkItem.Generate().Description; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(existingWorkItem); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + description = newDescription, + dueAt = (DateTime?)null + } + } + }; + + var route = $"/workItems/{existingWorkItem.StringId}?fields[workItems]=description,priority"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); + responseDocument.SingleData.Relationships.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await db.GetCollection().AsQueryable() + .Where(workItem => workItem.Id == existingWorkItem.Id) + .FirstOrDefaultAsync(); + + workItemInDatabase.Description.Should().Be(newDescription); + workItemInDatabase.DueAt.Should().BeNull(); + workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + }); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_ID_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + id = "ffffffffffffffffffffffff" + } + }; + + var route = "/workItems/ffffffffffffffffffffffff"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' with ID 'ffffffffffffffffffffffff' does not exist."); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000..bad1200 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite.Updating.Resources +{ + public sealed class UpdateToOneRelationshipTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); + + public UpdateToOneRelationshipTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_create_relationship() + { + // Arrange + var existingGroup = _fakers.WorkItemGroup.Generate(); + existingGroup.Color = _fakers.RgbColor.Generate(); + + var existingColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertManyAsync(new[] {existingColor, existingGroup.Color}); + await db.GetCollection().InsertOneAsync(existingGroup); + }); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = existingGroup.StringId, + relationships = new + { + color = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId + } + } + } + } + }; + + var route = $"/workItemGroups/{existingGroup.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/UserAccount.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/UserAccount.cs new file mode 100644 index 0000000..02f5667 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/UserAccount.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class UserAccount : MongoDbIdentifiable + { + [Attr] + public string FirstName { get; set; } + + [Attr] + public string LastName { get; set; } + + [HasMany] + [BsonIgnore] + public ISet AssignedItems { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs new file mode 100644 index 0000000..944a701 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/UserAccountsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class UserAccountsController : JsonApiController + { + public UserAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItem.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItem.cs new file mode 100644 index 0000000..a52920e --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItem.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItem : MongoDbIdentifiable + { + [Attr] + public string Description { get; set; } + + [Attr] + public DateTimeOffset? DueAt { get; set; } + + [Attr] + public WorkItemPriority Priority { get; set; } + + [BsonIgnore] + [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public Guid ConcurrencyToken + { + get => Guid.NewGuid(); + set { } + } + + [BsonIgnore] + [HasOne] + public UserAccount Assignee { get; set; } + + [BsonIgnore] + [HasMany] + public ISet Subscribers { get; set; } + + [BsonIgnore] + [HasManyThrough(nameof(WorkItemTags))] + public ISet Tags { get; set; } + public ICollection WorkItemTags { get; set; } + + [BsonIgnore] + [HasOne] + public WorkItem Parent { get; set; } + + [BsonIgnore] + [HasMany] + public IList Children { get; set; } + + [BsonIgnore] + [HasManyThrough(nameof(RelatedFromItems), LeftPropertyName = nameof(WorkItemToWorkItem.ToItem), RightPropertyName = nameof(WorkItemToWorkItem.FromItem))] + public IList RelatedFrom { get; set; } + public IList RelatedFromItems { get; set; } + + [BsonIgnore] + [HasManyThrough(nameof(RelatedToItems), LeftPropertyName = nameof(WorkItemToWorkItem.FromItem), RightPropertyName = nameof(WorkItemToWorkItem.ToItem))] + public IList RelatedTo { get; set; } + public IList RelatedToItems { get; set; } + + [BsonIgnore] + [HasOne] + public WorkItemGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs new file mode 100644 index 0000000..cef0bd9 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemGroup.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public class WorkItemGroup : MongoDbIdentifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public bool IsPublic { get; set; } + + [BsonIgnore] + [Attr] + public Guid ConcurrencyToken => Guid.NewGuid(); + + [HasOne] + [BsonIgnore] + public RgbColor Color { get; set; } + + [HasMany] + [BsonIgnore] + public IList Items { get; set; } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs new file mode 100644 index 0000000..4976e09 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public class WorkItemGroupsController : JsonApiController + { + public WorkItemGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs new file mode 100644 index 0000000..ba20e8e --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemPriority.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public enum WorkItemPriority + { + Low, + Medium, + High + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs new file mode 100644 index 0000000..6500fa3 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemTag + { + public WorkItem Item { get; set; } + public string ItemId { get; set; } + + public WorkTag Tag { get; set; } + public string TagId { get; set; } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs new file mode 100644 index 0000000..3440955 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemToWorkItem + { + public WorkItem FromItem { get; set; } + public string FromItemId { get; set; } + + public WorkItem ToItem { get; set; } + public string ToItemId { get; set; } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs new file mode 100644 index 0000000..c84a485 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkItemsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemsController : JsonApiController + { + public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkTag.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkTag.cs new file mode 100644 index 0000000..98c964b --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ReadWrite/WorkTag.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ReadWrite +{ + public sealed class WorkTag : MongoDbIdentifiable + { + [Attr] + public string Text { get; set; } + + [Attr] + public bool IsBuiltIn { get; set; } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs new file mode 100644 index 0000000..4ab41c5 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.MongoDb.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ResourceDefinitions +{ + public class CallableResource : MongoDbIdentifiable + { + [Attr] + public string Label { get; set; } + + [Attr] + public int PercentageComplete { get; set; } + + [Attr] + public string Status => $"{PercentageComplete}% completed."; + + [Attr] + public int RiskLevel { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowSort)] + public DateTime CreatedAt { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowSort)] + public DateTime ModifiedAt { get; set; } + + [Attr(Capabilities = AttrCapabilities.None)] + public bool IsDeleted { get; set; } + + [HasMany] + [BsonIgnore] + public ICollection Children { get; set; } + + [HasOne] + [BsonIgnore] + public CallableResource Owner { get; set; } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs new file mode 100644 index 0000000..4bdc691 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ResourceDefinitions +{ + public interface IUserRolesService + { + bool AllowIncludeOwner { get; } + } + + public sealed class CallableResourceDefinition : JsonApiResourceDefinition + { + private readonly IUserRolesService _userRolesService; + private static readonly PageSize _maxPageSize = new PageSize(5); + + public CallableResourceDefinition(IResourceGraph resourceGraph, IUserRolesService userRolesService) : base(resourceGraph) + { + // This constructor will be resolved from the container, which means + // you can take on any dependency that is also defined in the container. + + _userRolesService = userRolesService; + } + + public override IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + // Use case: prevent including owner if user has insufficient permissions. + + if (!_userRolesService.AllowIncludeOwner && + existingIncludes.Any(x => x.Relationship.Property.Name == nameof(CallableResource.Owner))) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Including owner is not permitted." + }); + } + + return existingIncludes; + } + + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + // Use case: automatically exclude deleted resources for all requests. + + var resourceContext = ResourceGraph.GetResourceContext(); + var isDeletedAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(CallableResource.IsDeleted)); + + var isNotDeleted = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isDeletedAttribute), new LiteralConstantExpression(bool.FalseString)); + + return existingFilter == null + ? (FilterExpression) isNotDeleted + : new LogicalExpression(LogicalOperator.And, new[] {isNotDeleted, existingFilter}); + } + + public override SortExpression OnApplySort(SortExpression existingSort) + { + // Use case: set a default sort order when none was specified in query string. + + if (existingSort != null) + { + return existingSort; + } + + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (resource => resource.Label, ListSortDirection.Ascending), + (resource => resource.ModifiedAt, ListSortDirection.Descending) + }); + } + + public override PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + // Use case: enforce a page size of 5 or less for this resource type. + + if (existingPagination != null) + { + var pageSize = existingPagination.PageSize?.Value <= _maxPageSize.Value ? existingPagination.PageSize : _maxPageSize; + return new PaginationExpression(existingPagination.PageNumber, pageSize); + } + + return new PaginationExpression(PageNumber.ValueOne, _maxPageSize); + } + + public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + // Use case: always retrieve percentageComplete and never include riskLevel in responses. + + return existingSparseFieldSet + .Including(resource => resource.PercentageComplete, ResourceGraph) + .Excluding(resource => resource.RiskLevel, ResourceGraph); + } + + public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + // Use case: 'isHighRisk' query string parameter can be used to add extra filter on IQueryable. + + return new QueryStringParameterHandlers + { + ["isHighRisk"] = FilterByHighRisk + }; + } + + private static IQueryable FilterByHighRisk(IQueryable source, StringValues parameterValue) + { + bool isFilterOnHighRisk = bool.Parse(parameterValue); + return isFilterOnHighRisk ? source.Where(resource => resource.RiskLevel >= 5) : source.Where(resource => resource.RiskLevel < 5); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs new file mode 100644 index 0000000..004cc2a --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ResourceDefinitions +{ + public class CallableResourcesController : JsonApiController + { + public CallableResourcesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs new file mode 100644 index 0000000..c428bc0 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -0,0 +1,504 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.ResourceDefinitions +{ + public class ResourceDefinitionQueryCallbackTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public ResourceDefinitionQueryCallbackTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton(); + }); + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Filter_from_resource_definition_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + IsDeleted = true + }, + new CallableResource + { + Label = "A", + IsDeleted = false + }, + new CallableResource + { + Label = "B", + IsDeleted = true + }, + new CallableResource + { + Label = "B", + IsDeleted = false + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(resources[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[3].StringId); + + responseDocument.Meta["totalResources"].Should().Be(2); + } + + [Fact] + public async Task Filter_from_resource_definition_and_query_string_are_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + IsDeleted = true + }, + new CallableResource + { + Label = "A", + IsDeleted = false + }, + new CallableResource + { + Label = "B", + IsDeleted = true + }, + new CallableResource + { + Label = "B", + IsDeleted = false + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources?filter=equals(label,'B')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(resources[3].StringId); + + responseDocument.Meta["totalResources"].Should().Be(1); + } + + [Fact] + public async Task Sort_from_resource_definition_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.January(2001) + }, + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.December(2001) + }, + new CallableResource + { + Label = "B", + CreatedAt = 1.February(2001), + ModifiedAt = 15.January(2001) + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(resources[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(resources[2].StringId); + } + + [Fact] + public async Task Sort_from_query_string_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.January(2001) + }, + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.December(2001) + }, + new CallableResource + { + Label = "B", + CreatedAt = 1.February(2001), + ModifiedAt = 15.January(2001) + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources?sort=-createdAt,modifiedAt"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(resources[2].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(resources[1].StringId); + } + + [Fact] + public async Task Page_size_from_resource_definition_is_applied() + { + // Arrange + var resources = new List(); + + for (int index = 0; index < 10; index++) + { + resources.Add(new CallableResource()); + } + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources?page[size]=8"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(5); + } + + [Fact] + public async Task Attribute_inclusion_from_resource_definition_is_applied_for_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + PercentageComplete = 5 + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(resource); + }); + + var route = $"/callableResources/{resource.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes["percentageComplete"].Should().Be(resource.PercentageComplete); + } + + [Fact] + public async Task Attribute_inclusion_from_resource_definition_is_applied_for_non_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + PercentageComplete = 5 + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(resource); + }); + + var route = $"/callableResources/{resource.StringId}?fields[callableResources]=label,status"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes["status"].Should().Be("5% completed."); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Attribute_exclusion_from_resource_definition_is_applied_for_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + RiskLevel = 3 + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(resource); + }); + + var route = $"/callableResources/{resource.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes.Should().NotContainKey("riskLevel"); + } + + [Fact] + public async Task Attribute_exclusion_from_resource_definition_is_applied_for_non_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + RiskLevel = 3 + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(resource); + }); + + var route = $"/callableResources/{resource.StringId}?fields[callableResources]=label,riskLevel"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Queryable_parameter_handler_from_resource_definition_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + RiskLevel = 3 + }, + new CallableResource + { + Label = "A", + RiskLevel = 8 + }, + new CallableResource + { + Label = "B", + RiskLevel = 3 + }, + new CallableResource + { + Label = "B", + RiskLevel = 8 + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources?isHighRisk=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(resources[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[3].StringId); + } + + [Fact] + public async Task Queryable_parameter_handler_from_resource_definition_and_query_string_filter_are_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + RiskLevel = 3 + }, + new CallableResource + { + Label = "A", + RiskLevel = 8 + }, + new CallableResource + { + Label = "B", + RiskLevel = 3 + }, + new CallableResource + { + Label = "B", + RiskLevel = 8 + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(resources); + }); + + var route = "/callableResources?isHighRisk=false&filter=equals(label,'B')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(resources[2].StringId); + } + + [Fact] + public async Task Queryable_parameter_handler_from_resource_definition_is_not_applied_on_secondary_request() + { + // Arrange + var resource = new CallableResource + { + RiskLevel = 3, + Children = new List + { + new CallableResource + { + RiskLevel = 3 + }, + new CallableResource + { + RiskLevel = 8 + } + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(resource); + }); + + var route = $"/callableResources/{resource.StringId}/children?isHighRisk=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Custom query string parameters cannot be used on nested resource endpoints."); + responseDocument.Errors[0].Detail.Should().Be("Query string parameter 'isHighRisk' cannot be used on a nested resource endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("isHighRisk"); + } + + private sealed class FakeUserRolesService : IUserRolesService + { + public bool AllowIncludeOwner { get; set; } = true; + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Sorting/SortTests.cs new file mode 100644 index 0000000..883f997 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -0,0 +1,204 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Xunit; +using Person = JsonApiDotNetCoreMongoDbExample.Models.Person; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.Sorting +{ + public sealed class SortTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public SortTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_sort_in_primary_resources() + { + // Arrange + var articles = new List
+ { + new Article {Caption = "B"}, + new Article {Caption = "A"}, + new Article {Caption = "C"} + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?sort=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(articles[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(articles[2].StringId); + } + + [Fact] + public async Task Cannot_sort_on_HasMany_relationship() + { + // Arrange + var blog = new Blog(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(blog); + }); + + var route = "/api/v1/blogs?sort=count(articles)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_sort_on_HasManyThrough_relationship() + { + // Arrange + var article = new Article(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = "/api/v1/articles?sort=-count(tags)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_sort_on_HasOne_relationship() + { + // Arrange + var article = new Article(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = "/api/v1/articles?sort=-author.lastName"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_sort_descending_by_ID() + { + // Arrange + var people = new List + { + new Person + { + Id = "5ff752c4f7c9a9a8373991b2", + LastName = "B" + }, + new Person + { + Id = "5ff752c3f7c9a9a8373991b1", + LastName = "A" + }, + new Person + { + Id = "5ff752c2f7c9a9a8373991b0", + LastName = "A" + }, + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(people); + }); + + var route = "/api/v1/people?sort=lastName,-id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(people[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(people[2].StringId); + responseDocument.ManyData[2].Id.Should().Be(people[0].StringId); + } + + [Fact] + public async Task Sorts_by_ID_if_none_specified() + { + // Arrange + var persons = new List + { + new Person { Id = "5ff8a7bcb2a9b83724282718" }, + new Person { Id = "5ff8a7bcb2a9b83724282717" }, + new Person { Id = "5ff8a7bbb2a9b83724282716" }, + new Person { Id = "5ff8a7bdb2a9b83724282719" } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync(); + await db.GetCollection().InsertManyAsync(persons); + }); + + var route = "/api/v1/people"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(4); + responseDocument.ManyData[0].Id.Should().Be(persons[2].StringId); + responseDocument.ManyData[1].Id.Should().Be(persons[1].StringId); + responseDocument.ManyData[2].Id.Should().Be(persons[0].StringId); + responseDocument.ManyData[3].Id.Should().Be(persons[3].StringId); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs new file mode 100644 index 0000000..5036b58 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.SparseFieldSets +{ + public sealed class ResourceCaptureStore + { + public List Resources { get; } = new List(); + + public void Add(IEnumerable resources) + { + Resources.AddRange(resources); + } + + public void Clear() + { + Resources.Clear(); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs new file mode 100644 index 0000000..b530482 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Repositories; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using MongoDB.Driver; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.SparseFieldSets +{ + /// + /// Enables sparse fieldset tests to verify which fields were (not) retrieved from the database. + /// + public sealed class ResultCapturingRepository : MongoDbRepository + where TResource : class, IIdentifiable + { + private readonly ResourceCaptureStore _captureStore; + + public ResultCapturingRepository( + IMongoDatabase db, + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceFactory resourceFactory, + IEnumerable constraintProviders, + ResourceCaptureStore captureStore) + : base(db, targetedFields, resourceContextProvider, resourceFactory, constraintProviders) + { + _captureStore = captureStore; + } + + public override async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + { + var resources = await base.GetAsync(layer, cancellationToken); + + _captureStore.Add(resources); + + return resources; + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs new file mode 100644 index 0000000..c4177ee --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -0,0 +1,310 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreMongoDbExample; +using JsonApiDotNetCoreMongoDbExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests.SparseFieldSets +{ + public sealed class SparseFieldSetTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public SparseFieldSetTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton(); + + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + }); + } + + [Fact] + public async Task Cannot_select_fields_with_relationship_in_primary_resources() + { + // Arrange + var article = new Article(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = "/api/v1/articles?fields[articles]=caption,author"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_select_attribute_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = "/api/v1/articles?fields[articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + + var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + articleCaptured.Caption.Should().Be(article.Caption); + articleCaptured.Url.Should().BeNull(); + } + + [Fact] + public async Task Cannot_select_relationship_in_primary_resources() + { + // Arrange + var article = new Article(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = "/api/v1/articles?fields[articles]=author"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_select_attribute_in_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = $"/api/v1/articles/{article.StringId}?fields[articles]=url"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["url"].Should().Be(article.Url); + responseDocument.SingleData.Relationships.Should().BeNull(); + + var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + articleCaptured.Url.Should().Be(article.Url); + articleCaptured.Caption.Should().BeNull(); + } + + [Fact] + public async Task Cannot_select_fields_of_HasOne_relationship() + { + // Arrange + var article = new Article(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = $"/api/v1/articles/{article.StringId}?fields[authors]=lastName,businessEmail,livingAddress"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_select_fields_of_HasMany_relationship() + { + // Arrange + var author = new Author(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(author); + }); + + var route = $"/api/v1/authors/{author.StringId}?fields[articles]=caption,tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_select_fields_of_HasManyThrough_relationship() + { + // Arrange + var article = new Article(); + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = $"/api/v1/articles/{article.StringId}?fields[tags]=color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); + responseDocument.Errors[0].Title.Should().Be("Relationships are not supported when using MongoDB."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_select_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.ClearCollectionAsync
(); + await db.GetCollection
().InsertOneAsync(article); + }); + + var route = "/api/v1/articles?fields[articles]=id,caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + + var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + articleCaptured.Id.Should().Be(article.Id); + articleCaptured.Caption.Should().Be(article.Caption); + articleCaptured.Url.Should().BeNull(); + } + + [Fact] + public async Task Retrieves_all_properties_when_fieldset_contains_readonly_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var todoItem = new TodoItem + { + Description = "Pending work..." + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + await db.GetCollection().InsertOneAsync(todoItem); + }); + + var route = $"/api/v1/todoItems/{todoItem.StringId}?fields[todoItems]=calculatedValue"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["calculatedValue"].Should().Be(todoItem.CalculatedValue); + responseDocument.SingleData.Relationships.Should().BeNull(); + + var todoItemCaptured = (TodoItem) store.Resources.Should().ContainSingle(x => x is TodoItem).And.Subject.Single(); + todoItemCaptured.CalculatedValue.Should().Be(todoItem.CalculatedValue); + todoItemCaptured.Description.Should().Be(todoItem.Description); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/TestableStartup.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/TestableStartup.cs new file mode 100644 index 0000000..9c29654 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/IntegrationTests/TestableStartup.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreMongoDbExample; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreMongoDbExampleTests.IntegrationTests +{ + public class TestableStartup : EmptyStartup + { + public TestableStartup(IConfiguration configuration) : base(configuration) + { + } + + public override void ConfigureServices(IServiceCollection services) + { + } + + public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) + { + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/JsonApiDotNetCoreMongoDbExampleTests.csproj b/test/JsonApiDotNetCoreMongoDbExampleTests/JsonApiDotNetCoreMongoDbExampleTests.csproj new file mode 100644 index 0000000..d5987d5 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/JsonApiDotNetCoreMongoDbExampleTests.csproj @@ -0,0 +1,35 @@ + + + + $(NetCoreAppVersion) + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + PreserveNewest + + + + + + + + diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/MongoDatabaseExtensions.cs b/test/JsonApiDotNetCoreMongoDbExampleTests/MongoDatabaseExtensions.cs new file mode 100644 index 0000000..bf9f1d2 --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/MongoDatabaseExtensions.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using MongoDB.Driver; + +namespace JsonApiDotNetCoreMongoDbExampleTests +{ + public static class MongoDatabaseExtensions + { + public static IMongoCollection GetCollection(this IMongoDatabase db) + { + return db.GetCollection(typeof(TResource).Name); + } + + public static async Task ClearCollectionAsync(this IMongoDatabase db) + { + var collection = GetCollection(db); + await collection.DeleteManyAsync(Builders.Filter.Empty); + } + } +} diff --git a/test/JsonApiDotNetCoreMongoDbExampleTests/xunit.runner.json b/test/JsonApiDotNetCoreMongoDbExampleTests/xunit.runner.json new file mode 100644 index 0000000..9db029b --- /dev/null +++ b/test/JsonApiDotNetCoreMongoDbExampleTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +}