diff --git a/.gitignore b/.gitignore index cd9f35a..74e917a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,369 @@ -\.vs/ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# User-specific files +*.rsuser +*.suo *.user -.couscous/ -docs/Template-Dark/ -.idea/ \ No newline at end of file +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# MacOS file systems +**/.DS_STORE + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JetBrains Rider +.idea/ + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*[.json, .xml, .info] + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# Sqlite example databases +*.db diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6ae2058 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: csharp +mono: none +dotnet: 3.1.100 +solution: JsonApiDotNetCore.MongoDb.sln +script: + - dotnet restore + - dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov-coverage /p:Include="[JsonApiDotNetCore.MongoDb]*" +after_success: + - bash <(curl -s https://codecov.io/bash) +before_deploy: + - dotnet build -c Release + - dotnet pack -c Release +deploy: + skip_cleanup: true + provider: script + script: dotnet nuget push ./src/JsonApiDotNetCore.MongoDb/bin/Release/JsonApiDotNetCore.MongoDb.*.nupkg -k $NUGET_API -s https://api.nuget.org/v3/index.json + on: + branch: master diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c835917 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (webapi)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/src/Example/bin/Debug/netcoreapp3.1/Example.dll", + "args": [], + "cwd": "${workspaceFolder}/src/Example", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false, + "requireExactSource": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..0463b1e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/test/JsonApiDotNetCore.MongoDb.UnitTests/JsonApiDotNetCore.MongoDb.UnitTests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/test/JsonApiDotNetCore.MongoDb.UnitTests/JsonApiDotNetCore.MongoDb.UnitTests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "${workspaceFolder}/test/JsonApiDotNetCore.MongoDb.UnitTests/JsonApiDotNetCore.MongoDb.UnitTests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7f73136 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,22 @@ + + + netcoreapp3.1 + 3.1.* + 4.0.* + 2.11.* + + + + $(NoWarn);1591 + true + true + + + + + 2.4.1 + 5.10.3 + 31.0.3 + 4.14.6 + + diff --git a/JsonApiDotNetCore.MongoDb.sln b/JsonApiDotNetCore.MongoDb.sln index 5dbe7c9..5994f4a 100644 --- a/JsonApiDotNetCore.MongoDb.sln +++ b/JsonApiDotNetCore.MongoDb.sln @@ -1,44 +1,86 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27130.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.MongoDb", "src\JsonApiDotNetCore.MongoDb\JsonApiDotNetCore.MongoDb.csproj", "{7F964050-3BB1-4E5C-8458-A974B956908F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{262566DF-A206-4026-8018-69C66936EFCF}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EAC8B3B-BD8B-4FFF-8807-37FCA45BD8BD}" - ProjectSection(SolutionItems) = preProject - .gitignore = .gitignore - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "test\UnitTests\UnitTests.csproj", "{0AB9C87F-0A30-4875-B9BF-38F606E79734}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AEA5DF2E-26B5-4D81-AA6A-ADA690E3E0D8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7F964050-3BB1-4E5C-8458-A974B956908F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7F964050-3BB1-4E5C-8458-A974B956908F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7F964050-3BB1-4E5C-8458-A974B956908F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7F964050-3BB1-4E5C-8458-A974B956908F}.Release|Any CPU.Build.0 = Release|Any CPU - {0AB9C87F-0A30-4875-B9BF-38F606E79734}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0AB9C87F-0A30-4875-B9BF-38F606E79734}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0AB9C87F-0A30-4875-B9BF-38F606E79734}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0AB9C87F-0A30-4875-B9BF-38F606E79734}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7F964050-3BB1-4E5C-8458-A974B956908F} = {262566DF-A206-4026-8018-69C66936EFCF} - {0AB9C87F-0A30-4875-B9BF-38F606E79734} = {AEA5DF2E-26B5-4D81-AA6A-ADA690E3E0D8} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {F7741F43-2CB1-4C5E-9886-FDCB0B973E30} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.MongoDb.GettingStarted", "src\JsonApiDotNetCore.MongoDb.GettingStarted\JsonApiDotNetCore.MongoDb.GettingStarted.csproj", "{600A3E66-E63F-427D-A991-4CD2067041F9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.MongoDb", "src\JsonApiDotNetCore.MongoDb\JsonApiDotNetCore.MongoDb.csproj", "{FD312677-2A62-4B8F-A965-879B059F1755}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{19A533AA-E006-496D-A476-364DF2B637A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.MongoDb.Example.Tests", "test\JsonApiDotNetCore.MongoDb.Example.Tests\JsonApiDotNetCore.MongoDb.Example.Tests.csproj", "{24CE53FA-9C49-4E20-A060-4A43DFB8C8F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonApiDotNetCore.MongoDb.Example", "src\JsonApiDotNetCore.MongoDb.Example\JsonApiDotNetCore.MongoDb.Example.csproj", "{743C32A5-2584-4FA0-987B-B4E97CDAADE8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {600A3E66-E63F-427D-A991-4CD2067041F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Debug|x64.Build.0 = Debug|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Debug|x86.Build.0 = Debug|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Release|Any CPU.Build.0 = Release|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Release|x64.ActiveCfg = Release|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Release|x64.Build.0 = Release|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.Release|x86.ActiveCfg = Release|Any CPU + {600A3E66-E63F-427D-A991-4CD2067041F9}.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 + {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 + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Debug|x64.ActiveCfg = Debug|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Debug|x64.Build.0 = Debug|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Debug|x86.ActiveCfg = Debug|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Debug|x86.Build.0 = Debug|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Release|Any CPU.Build.0 = Release|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Release|x64.ActiveCfg = Release|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Release|x64.Build.0 = Release|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Release|x86.ActiveCfg = Release|Any CPU + {743C32A5-2584-4FA0-987B-B4E97CDAADE8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {600A3E66-E63F-427D-A991-4CD2067041F9} = {7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784} + {FD312677-2A62-4B8F-A965-879B059F1755} = {7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784} + {24CE53FA-9C49-4E20-A060-4A43DFB8C8F1} = {19A533AA-E006-496D-A476-364DF2B637A1} + {743C32A5-2584-4FA0-987B-B4E97CDAADE8} = {7E29AA10-F938-4CF8-9CAB-7ACD2D6DC784} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..509975f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) +Copyright (c) 2020 Alvaro Nicoli + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7b5ac1 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# JsonApiDotNetCore MongoDB Repository + +[![NuGet version][nuget-image]][nuget-url] +[![Downloads][downloads-image]][nuget-url] +[![Build Status](https://travis-ci.com/mrnkr/JsonApiDotNetCore.MongoDb.svg?branch=master)](https://travis-ci.com/mrnkr/JsonApiDotNetCore.MongoDb) +[![codecov](https://codecov.io/gh/mrnkr/JsonApiDotNetCore.MongoDb/branch/master/graph/badge.svg)](https://codecov.io/gh/mrnkr/JsonApiDotNetCore.MongoDb) +[![license][license]](https://github.com/mrnkr/JsonApiDotNetCore.MongoDb/blob/master/LICENSE) + +[nuget-image]:https://img.shields.io/nuget/v/JsonApiDotNetCore.MongoDb +[nuget-url]:https://www.nuget.org/packages/JsonApiDotNetCore.MongoDb +[downloads-image]:https://img.shields.io/nuget/dt/JsonApiDotNetCore.MongoDb +[license]:https://img.shields.io/github/license/mrnkr/JsonApiDotNetCore.MongoDb + +Plug-n-play implementation of `IResourceRepository` allowing you to use MongoDb with your `JsonApiDotNetCore` APIs. + +## Installation and Usage + +### Models + +```cs +public sealed class Book : IIdentifiable +{ + [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; } +} +``` + +### Controllers + +```cs +public sealed class BooksController : JsonApiController +{ + public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } +} +``` + +### Middleware + +```cs +public class Startup +{ + public IServiceProvider ConfigureServices(IServiceCollection services) + { + services.AddSingleton(sp => + { + var client = new MongoClient(Configuration.GetSection("DatabaseSettings:ConnectionString").Value); + return client.GetDatabase(Configuration.GetSection("DatabaseSettings:Database").Value); + }); + + services.AddScoped, MongoEntityRepository>(); + services.AddJsonApi(options => + { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerSettings.Formatting = Formatting.Indented; + }, resources: builder => + { + builder.Add(); + }); + // ... + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + // ... + } +} +``` + +## Running tests and examples + +Integration tests use the [`Mongo2Go`](https://github.com/Mongo2Go/Mongo2Go) package so they don't require a running instance of MongoDb on your machine. + +Just run the following command to run all tests: + +```bash +dotnet test +``` + +To run the examples you are indeed going to want to have a running instance of MongoDb on your device. Fastest way to get one running is using docker: + +```bash +docker run -p 27017:27017 -d mongo:latest +dotnet run +``` + +## Limitations + +- Relations are not supported (yet) diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/ArticlesController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/ArticlesController.cs new file mode 100644 index 0000000..0aa0aa1 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/ArticlesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class ArticlesController : JsonApiController + { + public ArticlesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/AuthorsController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/AuthorsController.cs new file mode 100644 index 0000000..73b9962 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/AuthorsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class AuthorsController : JsonApiController + { + public AuthorsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/BlogsController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/BlogsController.cs new file mode 100644 index 0000000..6805d16 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/BlogsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class BlogsController : JsonApiController + { + public BlogsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/CountriesController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/CountriesController.cs new file mode 100644 index 0000000..78ef4af --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/CountriesController.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + [DisableQueryString(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] + public sealed class CountriesController : JsonApiController + { + public CountriesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PassportsController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PassportsController.cs new file mode 100644 index 0000000..b61ee4b --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PassportsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class PassportsController : JsonApiController + { + public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PeopleController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PeopleController.cs new file mode 100644 index 0000000..8710d52 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PeopleController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class PeopleController : JsonApiController + { + public PeopleController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PersonRolesController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PersonRolesController.cs new file mode 100644 index 0000000..9bf10f1 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/PersonRolesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class PersonRolesController : JsonApiController + { + public PersonRolesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/Restricted/ReadOnlyController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/Restricted/ReadOnlyController.cs new file mode 100644 index 0000000..31c120f --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/Restricted/ReadOnlyController.cs @@ -0,0 +1,106 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers.Restricted +{ + [DisableRoutingConvention, Route("[controller]")] + [HttpReadOnly] + public class ReadOnlyController : BaseJsonApiController + { + public ReadOnlyController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + + [HttpGet] + public IActionResult Get() => Ok(); + + [HttpPost] + public IActionResult Post() => Ok(); + + [HttpPatch] + public IActionResult Patch() => Ok(); + + [HttpDelete] + public IActionResult Delete() => Ok(); + } + + [DisableRoutingConvention, Route("[controller]")] + [NoHttpPost] + public class NoHttpPostController : BaseJsonApiController + { + public NoHttpPostController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + + [HttpGet] + public IActionResult Get() => Ok(); + + [HttpPost] + public IActionResult Post() => Ok(); + + [HttpPatch] + public IActionResult Patch() => Ok(); + + [HttpDelete] + public IActionResult Delete() => Ok(); + } + + [DisableRoutingConvention, Route("[controller]")] + [NoHttpPatch] + public class NoHttpPatchController : BaseJsonApiController + { + public NoHttpPatchController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + + [HttpGet] + public IActionResult Get() => Ok(); + + [HttpPost] + public IActionResult Post() => Ok(); + + [HttpPatch] + public IActionResult Patch() => Ok(); + + [HttpDelete] + public IActionResult Delete() => Ok(); + } + + [DisableRoutingConvention, Route("[controller]")] + [NoHttpDelete] + public class NoHttpDeleteController : BaseJsonApiController + { + public NoHttpDeleteController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + + [HttpGet] + public IActionResult Get() => Ok(); + + [HttpPost] + public IActionResult Post() => Ok(); + + [HttpPatch] + public IActionResult Patch() => Ok(); + + [HttpDelete] + public IActionResult Delete() => Ok(); + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TagsController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TagsController.cs new file mode 100644 index 0000000..16c958d --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TagsController.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + [DisableQueryString("skipCache")] + public sealed class TagsController : JsonApiController + { + public TagsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TestValuesController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TestValuesController.cs new file mode 100644 index 0000000..9bd4cec --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TestValuesController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + [Route("[controller]")] + public class TestValuesController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + var result = new[] { "value" }; + return Ok(result); + } + + [HttpPost] + public IActionResult Post(string name) + { + var result = "Hello, " + name; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + var result = "Hello, " + name; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Deleted"); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/ThrowingResourcesController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/ThrowingResourcesController.cs new file mode 100644 index 0000000..2fcda52 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/ThrowingResourcesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class ThrowingResourcesController : JsonApiController + { + public ThrowingResourcesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoCollectionsController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoCollectionsController.cs new file mode 100644 index 0000000..13804a9 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoCollectionsController.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class TodoCollectionsController : JsonApiController + { + + public TodoCollectionsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpPatch("{id}")] + public override async Task PatchAsync(string id, [FromBody] TodoItemCollection resource, CancellationToken cancellationToken) + { + // if (resource.Name == "PRE-ATTACH-TEST") + // { + // var targetTodoId = resource.TodoItems.First().Id; + // var todoItemContext = _dbResolver.GetContext().Set(); + // await todoItemContext.Where(ti => ti.Id == targetTodoId).FirstOrDefaultAsync(cancellationToken); + // } + + return await base.PatchAsync(id, resource, cancellationToken); + } + + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsController.cs new file mode 100644 index 0000000..a147a20 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class TodoItemsController : JsonApiController + { + public TodoItemsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsCustomController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsCustomController.cs new file mode 100644 index 0000000..36ad9c5 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsCustomController.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + [ApiController] + [DisableRoutingConvention, Route("custom/route/todoItems")] + public class TodoItemsCustomController : CustomJsonApiController + { + public TodoItemsCustomController( + IJsonApiOptions options, + IResourceService resourceService) + : base(options, resourceService) + { } + } + + public class CustomJsonApiController + : CustomJsonApiController where T : class, IIdentifiable + { + public CustomJsonApiController( + IJsonApiOptions options, + IResourceService resourceService) + : base(options, resourceService) + { + } + } + + public class CustomJsonApiController + : ControllerBase where T : class, IIdentifiable + { + private readonly IJsonApiOptions _options; + private readonly IResourceService _resourceService; + + private IActionResult Forbidden() + { + return new StatusCodeResult((int)HttpStatusCode.Forbidden); + } + + public CustomJsonApiController( + IJsonApiOptions options, + IResourceService resourceService) + { + _options = options; + _resourceService = resourceService; + } + + public CustomJsonApiController( + IResourceService resourceService) + { + _resourceService = resourceService; + } + + [HttpGet] + public async Task GetAsync(CancellationToken cancellationToken) + { + var resources = await _resourceService.GetAsync(cancellationToken); + return Ok(resources); + } + + [HttpGet("{id}")] + public async Task GetAsync(TId id, CancellationToken cancellationToken) + { + try + { + var resource = await _resourceService.GetAsync(id, cancellationToken); + return Ok(resource); + } + catch (ResourceNotFoundException) + { + return NotFound(); + } + } + + [HttpGet("{id}/relationships/{relationshipName}")] + public async Task GetRelationshipsAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + try + { + var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName, cancellationToken); + return Ok(relationship); + } + catch (ResourceNotFoundException) + { + return NotFound(); + } + } + + [HttpGet("{id}/{relationshipName}")] + public async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + var relationship = await _resourceService.GetSecondaryAsync(id, relationshipName, cancellationToken); + return Ok(relationship); + } + + [HttpPost] + public async Task PostAsync([FromBody] T resource, CancellationToken cancellationToken) + { + if (resource == null) + return UnprocessableEntity(); + + if (_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) + return Forbidden(); + + resource = await _resourceService.CreateAsync(resource, cancellationToken); + + return Created($"{HttpContext.Request.Path}/{resource.Id}", resource); + } + + [HttpPatch("{id}")] + public async Task PatchAsync(TId id, [FromBody] T resource, CancellationToken cancellationToken) + { + if (resource == null) + return UnprocessableEntity(); + + try + { + var updated = await _resourceService.UpdateAsync(id, resource, cancellationToken); + return Ok(updated); + } + catch (ResourceNotFoundException) + { + return NotFound(); + } + } + + [HttpPatch("{id}/relationships/{relationshipName}")] + public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds, CancellationToken cancellationToken) + { + await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + + return Ok(); + } + + [HttpDelete("{id}")] + public async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + await _resourceService.DeleteAsync(id, cancellationToken); + return NoContent(); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsTestController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsTestController.cs new file mode 100644 index 0000000..518b34f --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/TodoItemsTestController.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public abstract class AbstractTodoItemsController + : BaseJsonApiController where T : class, IIdentifiable + { + protected AbstractTodoItemsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService service) + : base(options, loggerFactory, service) + { } + } + + [DisableRoutingConvention] + [Route("/abstract")] + public class TodoItemsTestController : AbstractTodoItemsController + { + public TodoItemsTestController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService service) + : base(options, loggerFactory, service) + { } + + [HttpGet] + public override async Task GetAsync(CancellationToken cancellationToken) + { + return await base.GetAsync(cancellationToken); + } + + [HttpGet("{id}")] + public override async Task GetAsync(string id, CancellationToken cancellationToken) + { + return await base.GetAsync(id, cancellationToken); + } + + [HttpGet("{id}/{relationshipName}")] + public override async Task GetSecondaryAsync(string id, string relationshipName, CancellationToken cancellationToken) + { + return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); + } + + [HttpGet("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(string id, string relationshipName, CancellationToken cancellationToken) + { + return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); + } + + [HttpPost] + public override async Task PostAsync([FromBody] TodoItem resource, CancellationToken cancellationToken) + { + await Task.Yield(); + + return NotFound(new Error(HttpStatusCode.NotFound) + { + Title = "NotFound ActionResult with explicit error object." + }); + } + + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync( + string id, string relationshipName, [FromBody] ISet secondaryResourceIds, CancellationToken cancellationToken) + { + return await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + } + + [HttpPatch("{id}")] + public override async Task PatchAsync(string id, [FromBody] TodoItem resource, CancellationToken cancellationToken) + { + await Task.Yield(); + + return Conflict("Something went wrong"); + } + + [HttpPatch("{id}/relationships/{relationshipName}")] + public override async Task PatchRelationshipAsync( + string id, string relationshipName, [FromBody] object secondaryResourceIds, CancellationToken cancellationToken) + { + return await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + } + + [HttpDelete("{id}")] + public override async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await Task.Yield(); + + return NotFound(); + } + + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(string id, string relationshipName, [FromBody] ISet secondaryResourceIds, CancellationToken cancellationToken) + { + return await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Controllers/UsersController.cs b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/UsersController.cs new file mode 100644 index 0000000..78b962b --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Controllers/UsersController.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Controllers +{ + public sealed class UsersController : JsonApiController + { + public UsersController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } + + public sealed class SuperUsersController : JsonApiController + { + public SuperUsersController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/ArticleHooksDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/ArticleHooksDefinition.cs new file mode 100644 index 0000000..76b2a18 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/ArticleHooksDefinition.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Example.Definitions +{ + public class ArticleHooksDefinition : ResourceHooksDefinition
+ { + public ArticleHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + + public override IEnumerable
OnReturn(HashSet
resources, ResourcePipeline pipeline) + { + if (pipeline == ResourcePipeline.GetSingle && resources.Any(r => r.Caption == "Classified")) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to see this article." + }); + } + + return resources.Where(t => t.Caption != "This should not be included"); + } + } +} + diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/LockableHooksDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/LockableHooksDefinition.cs new file mode 100644 index 0000000..662cf82 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/LockableHooksDefinition.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Example.Definitions +{ + public abstract class LockableHooksDefinition : ResourceHooksDefinition where T : class, IIsLockable, IIdentifiable + { + protected LockableHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + + protected void DisallowLocked(IEnumerable resources) + { + foreach (var e in resources ?? Enumerable.Empty()) + { + if (e.IsLocked) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update fields or relationships of locked todo items." + }); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/PassportHooksDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/PassportHooksDefinition.cs new file mode 100644 index 0000000..6318fbc --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/PassportHooksDefinition.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Example.Definitions +{ + public class PassportHooksDefinition : ResourceHooksDefinition + { + public PassportHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + } + + public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) + { + if (pipeline == ResourcePipeline.GetSingle && isIncluded) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to include passports on individual persons." + }); + } + } + + public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) + { + resourcesByRelationship.GetByRelationship().ToList().ForEach(kvp => DoesNotTouchLockedPassports(kvp.Value)); + } + + private void DoesNotTouchLockedPassports(IEnumerable resources) + { + foreach (var passport in resources ?? Enumerable.Empty()) + { + if (passport.IsLocked) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update fields or relationships of locked persons." + }); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/PersonHooksDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/PersonHooksDefinition.cs new file mode 100644 index 0000000..c2b618b --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/PersonHooksDefinition.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.MongoDb.Example.Models; + +namespace JsonApiDotNetCore.MongoDb.Example.Definitions +{ + public class PersonHooksDefinition : LockableHooksDefinition + { + public PersonHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + + public override IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) + { + BeforeImplicitUpdateRelationship(resourcesByRelationship, pipeline); + return ids; + } + + public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) + { + resourcesByRelationship.GetByRelationship().ToList().ForEach(kvp => DisallowLocked(kvp.Value)); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TagHooksDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TagHooksDefinition.cs new file mode 100644 index 0000000..3742c70 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TagHooksDefinition.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.MongoDb.Example.Definitions +{ + public class TagHooksDefinition : ResourceHooksDefinition + { + public TagHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + + public override IEnumerable BeforeCreate(IResourceHashSet affected, ResourcePipeline pipeline) + { + return base.BeforeCreate(affected, pipeline); + } + + public override IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline) + { + return resources.Where(t => t.Name != "This should not be included"); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TodoHooksDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TodoHooksDefinition.cs new file mode 100644 index 0000000..b1ccad4 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TodoHooksDefinition.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.MongoDb.Example.Definitions +{ + public class TodoHooksDefinition : LockableHooksDefinition + { + public TodoHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + + public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) + { + if (stringId == "1337") + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "You are not allowed to update the author of todo items." + }); + } + } + + public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) + { + List todos = resourcesByRelationship.GetByRelationship().SelectMany(kvp => kvp.Value).ToList(); + DisallowLocked(todos); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TodoItemDefinition.cs b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000..c5b9706 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Definitions/TodoItemDefinition.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.MongoDb.Example.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/JsonApiDotNetCore.MongoDb.Example/Dockerfile b/src/JsonApiDotNetCore.MongoDb.Example/Dockerfile new file mode 100644 index 0000000..c5a5d90 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/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/JsonApiDotNetCore.MongoDb.Example/JsonApiDotNetCore.MongoDb.Example.csproj b/src/JsonApiDotNetCore.MongoDb.Example/JsonApiDotNetCore.MongoDb.Example.csproj new file mode 100644 index 0000000..68f4699 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/JsonApiDotNetCore.MongoDb.Example.csproj @@ -0,0 +1,14 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Address.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Address.cs new file mode 100644 index 0000000..686e488 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Address.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class Address : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Street { get; set; } + + [Attr] + public string ZipCode { get; set; } + + [HasOne] + public Country Country { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Article.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Article.cs new file mode 100644 index 0000000..525ccad --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Article.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class Article : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Caption { get; set; } + + [Attr] + public string Url { get; set; } + + // [HasOne] + // public Author Author { get; set; } + // + // [BsonIgnore] + // [HasManyThrough(nameof(ArticleTags))] + // public ISet Tags { get; set; } + // public ISet ArticleTags { get; set; } + // + // [BsonIgnore] + // [HasManyThrough(nameof(IdentifiableArticleTags))] + // public ICollection IdentifiableTags { get; set; } + // public ICollection IdentifiableArticleTags { get; set; } + // + // [HasMany] + // public ICollection Revisions { get; set; } + // + // [HasOne] + // public Blog Blog { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/ArticleTag.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/ArticleTag.cs new file mode 100644 index 0000000..6593c3e --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/ArticleTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class ArticleTag + { + public int ArticleId { get; set; } + public Article Article { get; set; } + + public int TagId { get; set; } + public Tag Tag { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Author.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Author.cs new file mode 100644 index 0000000..651dd2e --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Author.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class Author : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [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] + // public Address LivingAddress { get; set; } + // + // [HasMany] + // public IList
Articles { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Blog.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Blog.cs new file mode 100644 index 0000000..9f80e75 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Blog.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class Blog : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Title { get; set; } + + [Attr] + public string CompanyName { get; set; } + + // [HasMany] + // public IList
Articles { get; set; } + + // [HasOne] + // public Author Owner { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Country.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Country.cs new file mode 100644 index 0000000..28e83d7 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Country.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class Country : IIdentifiable + { + [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; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Gender.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Gender.cs new file mode 100644 index 0000000..c8545b9 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Gender.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public enum Gender + { + Unknown, + Male, + Female + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/IIsLockable.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/IIsLockable.cs new file mode 100644 index 0000000..35f8c4e --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/IIsLockable.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public interface IIsLockable + { + bool IsLocked { get; set; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/IdentifiableArticleTag.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/IdentifiableArticleTag.cs new file mode 100644 index 0000000..d4304d0 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/IdentifiableArticleTag.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class IdentifiableArticleTag : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + public int ArticleId { get; set; } + [HasOne] + public Article Article { get; set; } + + public int TagId { get; set; } + [HasOne] + public Tag Tag { get; set; } + + public string SomeMetaData { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/KebabCasedModel.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/KebabCasedModel.cs new file mode 100644 index 0000000..a72c7b1 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/KebabCasedModel.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class KebabCasedModel : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string CompoundAttr { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Passport.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Passport.cs new file mode 100644 index 0000000..e16fe40 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Passport.cs @@ -0,0 +1,65 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class Passport : IIdentifiable + { + // private readonly ISystemClock _systemClock; + private int? _socialSecurityNumber; + + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public int? SocialSecurityNumber + { + get => _socialSecurityNumber; + set + { + if (value != _socialSecurityNumber) + { + LastSocialSecurityNumberChange = DateTime.UtcNow.ToLocalTime(); + _socialSecurityNumber = value; + } + } + } + + [Attr] + public DateTime LastSocialSecurityNumberChange { get; set; } + + [Attr] + public bool IsLocked { get; set; } + + // [HasOne] + // public Person Person { get; set; } + + // [Attr] + // [NotMapped] + // public string BirthCountryName + // { + // get => BirthCountry?.Name; + // set + // { + // BirthCountry ??= new Country(); + // BirthCountry.Name = value; + // } + // } + + // [EagerLoad] + // public Country BirthCountry { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + + // public Passport(AppDbContext appDbContext) + // { + // _systemClock = appDbContext.SystemClock; + // } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Person.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Person.cs new file mode 100644 index 0000000..ae66ab6 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Person.cs @@ -0,0 +1,90 @@ +using System.Linq; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class PersonRole : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [HasOne] + public Person Person { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } + + public sealed class Person : IIdentifiable, IIsLockable + { + private string _firstName; + + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + 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; } + + // [HasMany] + // public ISet TodoItems { get; set; } + // + // [HasMany] + // public ISet AssignedTodoItems { get; set; } + // + // [HasMany] + // public HashSet TodoCollections { get; set; } + // + // [HasOne] + // public PersonRole Role { get; set; } + // + // [HasOne] + // public TodoItem OneToOneTodoItem { get; set; } + // + // [HasOne] + // public TodoItem StakeHolderTodoItem { get; set; } + // + // [HasOne(Links = LinkTypes.All, CanInclude = false)] + // public TodoItem UnIncludeableItem { get; set; } + // + // [HasOne] + // public Passport Passport { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Revision.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Revision.cs new file mode 100644 index 0000000..ad5d763 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Revision.cs @@ -0,0 +1,28 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class Revision : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public DateTime PublishTime { get; set; } + + [HasOne] + public Author Author { get; set; } + + [HasOne] + public Article Article { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/Tag.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/Tag.cs new file mode 100644 index 0000000..be3c99b --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/Tag.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class Tag : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Name { get; set; } + + [Attr] + public TagColor Color { get; set; } + + // [NotMapped] + // [HasManyThrough(nameof(ArticleTags))] + // public ISet
Articles { get; set; } + // public ISet ArticleTags { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/TagColor.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/TagColor.cs new file mode 100644 index 0000000..bab8ade --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/TagColor.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public enum TagColor + { + Red, + Green, + Blue + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/ThrowingResource.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/ThrowingResource.cs new file mode 100644 index 0000000..1af8d68 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/ThrowingResource.cs @@ -0,0 +1,40 @@ +using System; +using System.Diagnostics; +using System.Linq; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public sealed class ThrowingResource : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string FailsOnSerialize + { + get + { + var isSerializingResponse = new StackTrace().GetFrames() + .Any(frame => frame.GetMethod().DeclaringType == typeof(JsonApiWriter)); + + if (isSerializingResponse) + { + throw new InvalidOperationException($"The value for the '{nameof(FailsOnSerialize)}' property is currently unavailable."); + } + + return string.Empty; + } + set { } + } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/TodoItem.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/TodoItem.cs new file mode 100644 index 0000000..5ed4ba6 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/TodoItem.cs @@ -0,0 +1,72 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class TodoItem : IIdentifiable, IIsLockable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + 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; } + + // [HasOne] + // public Person Owner { get; set; } + // + // [HasOne] + // public Person Assignee { get; set; } + // + // [HasOne] + // public Person OneToOnePerson { get; set; } + // + // [HasMany] + // public ISet StakeHolders { get; set; } + // + // [HasOne] + // public TodoItemCollection Collection { get; set; } + // + // // cyclical to-one structure + // [HasOne] + // public TodoItem DependentOnTodo { get; set; } + // + // // cyclical to-many structure + // [HasOne] + // public TodoItem ParentTodo { get; set; } + // + // [HasMany] + // public IList ChildrenTodos { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/TodoItemCollection.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/TodoItemCollection.cs new file mode 100644 index 0000000..9300816 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/TodoItemCollection.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + [Resource("todoCollections")] + public sealed class TodoItemCollection : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Name { get; set; } + + // [HasMany] + // public ISet TodoItems { get; set; } + // + // [HasOne] + // public Person Owner { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Models/User.cs b/src/JsonApiDotNetCore.MongoDb.Example/Models/User.cs new file mode 100644 index 0000000..412d415 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Models/User.cs @@ -0,0 +1,54 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Models +{ + public class User : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + // private readonly ISystemClock _systemClock; + private string _password; + + [Attr] public string UserName { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] + public string Password + { + get => _password; + set + { + if (value != _password) + { + _password = value; + LastPasswordChange = DateTime.UtcNow.ToLocalTime(); + } + } + } + + [Attr] public DateTime LastPasswordChange { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + + // public User(AppDbContext appDbContext) + // { + // _systemClock = appDbContext.SystemClock; + // } + } + + public sealed class SuperUser : User + { + [Attr] public int SecurityLevel { get; set; } + + // public SuperUser(AppDbContext appDbContext) : base(appDbContext) + // { + // } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Program.cs b/src/JsonApiDotNetCore.MongoDb.Example/Program.cs new file mode 100644 index 0000000..d718b50 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace JsonApiDotNetCore.MongoDb.Example +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Properties/launchSettings.json b/src/JsonApiDotNetCore.MongoDb.Example/Properties/launchSettings.json new file mode 100644 index 0000000..1e3998e --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/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/JsonApiDotNetCore.MongoDb.Example/Services/CustomArticleService.cs b/src/JsonApiDotNetCore.MongoDb.Example/Services/CustomArticleService.cs new file mode 100644 index 0000000..4f1eed2 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Services/CustomArticleService.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Services +{ + public class CustomArticleService : JsonApiResourceService + { + public CustomArticleService( + IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IJsonApiRequest request, + IResourceChangeTracker
resourceChangeTracker, + IResourceHookExecutorFacade hookExecutor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, hookExecutor) + { } + + public override async Task
GetAsync(string id, CancellationToken cancellationToken) + { + var resource = await base.GetAsync(id, cancellationToken); + resource.Caption = "None for you Glen Coco"; + return resource; + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Services/SkipCacheQueryStringParameterReader.cs b/src/JsonApiDotNetCore.MongoDb.Example/Services/SkipCacheQueryStringParameterReader.cs new file mode 100644 index 0000000..8af3c32 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Services/SkipCacheQueryStringParameterReader.cs @@ -0,0 +1,36 @@ +using System.Linq; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.MongoDb.Example.Services +{ + public class SkipCacheQueryStringParameterReader : IQueryStringParameterReader + { + private const string _skipCacheParameterName = "skipCache"; + + public bool SkipCache { get; private set; } + + public bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + return !disableQueryStringAttribute.ParameterNames.Contains(_skipCacheParameterName); + } + + public bool CanRead(string parameterName) + { + return parameterName == _skipCacheParameterName; + } + + public void Read(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out bool skipCache)) + { + throw new InvalidQueryStringParameterException(parameterName, "Boolean value required.", + $"The value {parameterValue} is not a valid boolean."); + } + + SkipCache = skipCache; + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/Startups/EmptyStartup.cs b/src/JsonApiDotNetCore.MongoDb.Example/Startups/EmptyStartup.cs new file mode 100644 index 0000000..2ff1251 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Startups/EmptyStartup.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.MongoDb.Example +{ + /// + /// 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/JsonApiDotNetCore.MongoDb.Example/Startups/Startup.cs b/src/JsonApiDotNetCore.MongoDb.Example/Startups/Startup.cs new file mode 100644 index 0000000..a2d7854 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Startups/Startup.cs @@ -0,0 +1,107 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb; +using JsonApiDotNetCore.MongoDb.Example.Models; +using JsonApiDotNetCore.MongoDb.Example.Services; +using JsonApiDotNetCore.QueryStrings; +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 JsonApiDotNetCore.MongoDb.Example +{ + public class Startup : EmptyStartup + { + private IConfiguration Configuration { get; } + + public Startup(IConfiguration configuration) : base(configuration) + { + Configuration = configuration; + } + + public override void ConfigureServices(IServiceCollection services) + { + ConfigureClock(services); + + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + // 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.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + + + services.AddJsonApi( + ConfigureJsonApiOptions, + resources: builder => + { + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + builder.Add(); + }); + + // once all tests have been moved to WebApplicationFactory format we can get rid of this line below + services.AddClientSerialization(); + } + + private 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/JsonApiDotNetCore.MongoDb.Example/Startups/TestStartup.cs b/src/JsonApiDotNetCore.MongoDb.Example/Startups/TestStartup.cs new file mode 100644 index 0000000..cf99c40 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/Startups/TestStartup.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.MongoDb.Example +{ + public class TestStartup : EmptyStartup + { + public TestStartup(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()); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.Example/appsettings.json b/src/JsonApiDotNetCore.MongoDb.Example/appsettings.json new file mode 100644 index 0000000..ff9ab4c --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.Example/appsettings.json @@ -0,0 +1,14 @@ +{ + "DatabaseSettings": { + "ConnectionString": "mongodb://localhost:27017", + "Database": "JsonApiDotNetCoreExample" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/JsonApiDotNetCore.MongoDb.GettingStarted/Controllers/BooksController.cs b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Controllers/BooksController.cs new file mode 100644 index 0000000..e80e46a --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Controllers/BooksController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.MongoDb.GettingStarted.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.GettingStarted.Controllers +{ + public sealed class BooksController : JsonApiController + { + public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.GettingStarted/JsonApiDotNetCore.MongoDb.GettingStarted.csproj b/src/JsonApiDotNetCore.MongoDb.GettingStarted/JsonApiDotNetCore.MongoDb.GettingStarted.csproj new file mode 100644 index 0000000..68f4699 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/JsonApiDotNetCore.MongoDb.GettingStarted.csproj @@ -0,0 +1,14 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.MongoDb.GettingStarted/Models/Book.cs b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Models/Book.cs new file mode 100644 index 0000000..89d7a74 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Models/Book.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.GettingStarted.Models +{ + public sealed class Book : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Name { get; set; } + + [Attr] + [BsonRepresentation(BsonType.Decimal128)] + public decimal Price { get; set; } + + [Attr] + public string Category { get; set; } + + [Attr] + public string Author { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.GettingStarted/Program.cs b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Program.cs new file mode 100644 index 0000000..87d567e --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace JsonApiDotNetCore.MongoDb.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/JsonApiDotNetCore.MongoDb.GettingStarted/Properties/launchSettings.json b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Properties/launchSettings.json new file mode 100644 index 0000000..e360e7a --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52498", + "sslPort": 44343 + } + }, + "profiles": { + "Kestrel": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "api/books", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + diff --git a/src/JsonApiDotNetCore.MongoDb.GettingStarted/README.md b/src/JsonApiDotNetCore.MongoDb.GettingStarted/README.md new file mode 100644 index 0000000..833ce87 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.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/JsonApiDotNetCore.MongoDb.GettingStarted/Startup.cs b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Startup.cs new file mode 100644 index 0000000..908bd90 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/Startup.cs @@ -0,0 +1,52 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb; +using JsonApiDotNetCore.MongoDb.GettingStarted.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.MongoDb.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.AddResourceRepository>(); + services.AddJsonApi(options => + { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerSettings.Formatting = Formatting.Indented; + }, resources: builder => + { + builder.Add(); + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app) + { + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + } +} diff --git a/src/JsonApiDotNetCore.MongoDb.GettingStarted/appsettings.json b/src/JsonApiDotNetCore.MongoDb.GettingStarted/appsettings.json new file mode 100644 index 0000000..7eecbe7 --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb.GettingStarted/appsettings.json @@ -0,0 +1,14 @@ +{ + "DatabaseSettings": { + "ConnectionString": "mongodb://localhost:27017", + "Database": "JsonApiDotNetCoreExample" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/JsonApiDotNetCore.MongoDb/Data/MongoEntityRepository.cs b/src/JsonApiDotNetCore.MongoDb/Data/MongoEntityRepository.cs deleted file mode 100644 index c926da0..0000000 --- a/src/JsonApiDotNetCore.MongoDb/Data/MongoEntityRepository.cs +++ /dev/null @@ -1,145 +0,0 @@ -namespace JsonApiDotNetCore.MongoDb.Data -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using JsonApiDotNetCore.Data; - using JsonApiDotNetCore.Extensions; - using JsonApiDotNetCore.Internal.Query; - using JsonApiDotNetCore.Models; - using JsonApiDotNetCore.MongoDb.Extensions; - using JsonApiDotNetCore.Services; - using MongoDB.Bson; - using MongoDB.Driver; - - public class MongoEntityRepository - : IEntityRepository - where TEntity : class, IIdentifiable - { - private readonly IMongoDatabase db; - - private readonly string collectionName; - - private readonly IJsonApiContext jsonApiContext; - - public MongoEntityRepository(IMongoDatabase db, string collectionName, IJsonApiContext jsonApiContext) - { - this.db = db; - this.collectionName = collectionName; - this.jsonApiContext = jsonApiContext; - } - - private IMongoCollection Collection => this.db.GetCollection(this.collectionName); - - private IQueryable Entities => this.Collection.AsQueryable(); - - public async Task CountAsync(IQueryable entities) - { - return (int)await this.Collection.CountAsync(Builders.Filter.Empty); - } - - public async Task CreateAsync(TEntity entity) - { - await this.Collection.InsertOneAsync(entity); - - return entity; - } - - public async Task DeleteAsync(TId id) - { - var result = await this.Collection.DeleteOneAsync(Builders.Filter.Eq(e => e.Id, id)); - - return result.IsAcknowledged && result.DeletedCount > 0; - } - - public IQueryable Filter(IQueryable entities, FilterQuery filterQuery) - { - return entities.Filter(this.jsonApiContext, filterQuery); - } - - public Task FirstOrDefaultAsync(IQueryable entities) - { - return entities.FirstOrDefaultAsync(); - } - - public IQueryable Get() - { - List fields = this.jsonApiContext.QuerySet?.Fields; - if (fields?.Any() ?? false) - { - return this.Entities.Select(fields); - } - - return this.Entities; - } - - public Task GetAndIncludeAsync(TId id, string relationshipName) - { - // this is a document DB, no relations! - return this.GetAsync(id); - } - - public Task GetAsync(TId id) - { - return this.Collection.Find(Builders.Filter.Eq(e => e.Id, id)).SingleOrDefaultAsync(); - } - - public IQueryable Include(IQueryable entities, string relationshipName) - { - // this is a document DB, no relations! - return entities; - } - - public async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) - { - return await entities.PageForward(pageSize, pageNumber).ToListAsync(); - } - - public Task SingleOrDefaultAsync(IQueryable queryable) - { - return queryable.SingleOrDefaultAsync(); - } - - public IQueryable Sort(IQueryable entities, List sortQueries) - { - return entities.Sort(sortQueries); - } - - public Task> ToListAsync(IQueryable entities) - { - return entities.ToListAsync(); - } - - public async Task UpdateAsync(TId id, TEntity entity) - { - var existingEntity = await this.GetAsync(id); - - if (existingEntity == null) - { - return null; - } - - foreach (var attr in this.jsonApiContext.AttributesToUpdate) - { - attr.Key.SetValue(existingEntity, attr.Value); - } - - foreach (var relationship in this.jsonApiContext.RelationshipsToUpdate) - { - relationship.Key.SetValue(existingEntity, relationship.Value); - } - - await this.Collection.ReplaceOneAsync(Builders.Filter.Eq(e => e.Id, id), existingEntity); - - return existingEntity; - } - - public Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) - { - throw new NotImplementedException(); - } - - internal IEnumerable GetAllDocuments() => this.Collection.Find(new BsonDocument()).ToEnumerable(); - } -} diff --git a/src/JsonApiDotNetCore.MongoDb/Extensions/MongoIQueryableExtensions.cs b/src/JsonApiDotNetCore.MongoDb/Extensions/MongoIQueryableExtensions.cs index f245c35..f163bcc 100644 --- a/src/JsonApiDotNetCore.MongoDb/Extensions/MongoIQueryableExtensions.cs +++ b/src/JsonApiDotNetCore.MongoDb/Extensions/MongoIQueryableExtensions.cs @@ -1,37 +1,20 @@ -namespace JsonApiDotNetCore.MongoDb.Extensions -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using MongoDB.Driver; - using MongoDB.Driver.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +namespace JsonApiDotNetCore.MongoDb.Extensions +{ public static class MongoIQueryableExtensions { - public static async Task> ToListAsync(this IQueryable queryable) - { - return await IAsyncCursorSourceExtensions.ToListAsync(ToMongoQueryable(queryable)); - } - - public static async Task SingleOrDefaultAsync(this IQueryable queryable) - { - return await IAsyncCursorSourceExtensions.SingleOrDefaultAsync(ToMongoQueryable(queryable)); - } + public static async Task> ToListAsync(this IQueryable queryable) => + await IAsyncCursorSourceExtensions.ToListAsync(ToMongoQueryable(queryable)); - public static async Task FirstOrDefaultAsync(this IQueryable queryable) - { - return await IAsyncCursorSourceExtensions.FirstOrDefaultAsync(ToMongoQueryable(queryable)); - } - - private static IMongoQueryable ToMongoQueryable(IQueryable queryable) - { - if (!(queryable is IMongoQueryable mongoQueryable)) - { + private static IMongoQueryable ToMongoQueryable(IQueryable queryable) => + (queryable is IMongoQueryable mongoQueryable) ? + mongoQueryable : throw new ArgumentException($"This MongoDB-specific extension method expects a {nameof(IMongoQueryable)} and cannot work with {nameof(IQueryable)} of type {queryable.GetType().Name}."); - } - - return mongoQueryable; - } } } diff --git a/src/JsonApiDotNetCore.MongoDb/JsonApiDotNetCore.MongoDb.csproj b/src/JsonApiDotNetCore.MongoDb/JsonApiDotNetCore.MongoDb.csproj index dac47a6..21a35bc 100644 --- a/src/JsonApiDotNetCore.MongoDb/JsonApiDotNetCore.MongoDb.csproj +++ b/src/JsonApiDotNetCore.MongoDb/JsonApiDotNetCore.MongoDb.csproj @@ -1,12 +1,29 @@ - netcoreapp2.0 + 4.0.0-rc + $(NetCoreAppVersion) + true + + jsonapi;json:api;dotnet;core;MongoDB + Persistence layer implementation for use of mongodb in applications using JsonApiDotNetCore + https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb + MIT + false + true + true + embedded + + + + + + - - + + diff --git a/src/JsonApiDotNetCore.MongoDb/MongoEntityRepository.cs b/src/JsonApiDotNetCore.MongoDb/MongoEntityRepository.cs new file mode 100644 index 0000000..f49f2ad --- /dev/null +++ b/src/JsonApiDotNetCore.MongoDb/MongoEntityRepository.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.MongoDb.Extensions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +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 +{ + public class MongoEntityRepository + : IResourceRepository + where TResource : class, IIdentifiable + { + private readonly IMongoDatabase _db; + private readonly ITargetedFields _targetedFields; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IResourceFactory _resourceFactory; + + public MongoEntityRepository( + IMongoDatabase db, + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceFactory resourceFactory) + { + _db = db; + _targetedFields = targetedFields; + _resourceContextProvider = resourceContextProvider; + _resourceFactory = resourceFactory; + } + + private IMongoCollection Collection => _db.GetCollection(typeof(TResource).Name); + private IMongoQueryable Entities => Collection.AsQueryable(); + + public virtual async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) => + await ApplyQueryLayer(layer).ToListAsync(); + + public virtual Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + { + var resourceContext = _resourceContextProvider.GetResourceContext(); + var layer = new QueryLayer(resourceContext) + { + Filter = topFilter + }; + + var query = ApplyQueryLayer(layer); + return query.CountAsync(cancellationToken); + } + + public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + return Task.FromResult(resource); + } + + public virtual Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, + CancellationToken cancellationToken) + { + if (resourceFromRequest == null) throw new ArgumentNullException(nameof(resourceFromRequest)); + if (resourceForDatabase == null) throw new ArgumentNullException(nameof(resourceForDatabase)); + + foreach (var attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); + } + + return Collection.InsertOneAsync(resourceForDatabase, new InsertOneOptions(), cancellationToken); + } + + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + var resources = await GetAsync(queryLayer, cancellationToken); + return resources.FirstOrDefault(); + } + + public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource, CancellationToken cancellationToken) + { + foreach (var attr in _targetedFields.Attributes) + attr.SetValue(databaseResource, attr.GetValue(requestResource)); + + await Collection.ReplaceOneAsync( + Builders.Filter.Eq(e => e.Id, databaseResource.Id), + databaseResource, + new ReplaceOptions(), + cancellationToken); + } + + public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + var result = await Collection.DeleteOneAsync( + Builders.Filter.Eq(e => e.Id, id), + new DeleteOptions(), + cancellationToken); + + if (!result.IsAcknowledged || result.DeletedCount == 0) + { + throw new DataStoreUpdateException(new Exception()); + } + } + + public virtual Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public virtual Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public virtual Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + protected virtual IMongoQueryable ApplyQueryLayer(QueryLayer layer) + { + var source = Entities; + + var nameFactory = new LambdaParameterNameFactory(); + var builder = new QueryableBuilder( + source.Expression, + source.ElementType, + typeof(Queryable), + nameFactory, + _resourceFactory, + _resourceContextProvider, + DummyModel.Instance); + + var expression = builder.ApplyQuery(layer); + return (IMongoQueryable)source.Provider.CreateQuery(expression); + } + } + + internal sealed class DummyModel : IModel + { + public static IModel Instance { get; } = new DummyModel(); + + private DummyModel() + { + } + + public IAnnotation FindAnnotation(string name) + { + throw new NotImplementedException(); + } + + public IEnumerable GetAnnotations() + { + throw new NotImplementedException(); + } + + public object this[string name] => throw new NotImplementedException(); + + public IEnumerable GetEntityTypes() + { + 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/test/JsonApiDotNetCore.MongoDb.Example.Tests/Helpers/Extensions/StringExtensions.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/Helpers/Extensions/StringExtensions.cs new file mode 100644 index 0000000..6ff22de --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/Helpers/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Tests.Helpers.Extensions +{ + public static class StringExtensions + { + public static string NormalizeLineEndings(this string text) + { + return text.Replace("\r\n", "\n").Replace("\r", "\n"); + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/Helpers/Models/TodoItemClient.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/Helpers/Models/TodoItemClient.cs new file mode 100644 index 0000000..b7e5123 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/Helpers/Models/TodoItemClient.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.MongoDb.Example.Models; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.Helpers.Models +{ + /// + /// this "client" version of the is required because the + /// base property that is overridden here does not have a setter. For a model + /// defined on a JSON:API client, it would not make sense to have an exposed attribute + /// without a setter. + /// + public class TodoItemClient : TodoItem + { + [Attr] + public new string CalculatedValue { get; set; } + } + + [Resource("todoCollections")] + public sealed class TodoItemCollectionClient : Identifiable + { + [Attr] + public string Name { get; set; } + public int OwnerId { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } + + [HasOne] + public Person Owner { get; set; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/HttpResponseMessageExtensions.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..14aa16d --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/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 JsonApiDotNetCore.MongoDb.Example.Tests +{ + 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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTestContext.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTestContext.cs new file mode 100644 index 0000000..3841a4c --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTestContext.cs @@ -0,0 +1,177 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Mongo2Go; +using MongoDB.Driver; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests +{ + public sealed class IntegrationTestContext : IDisposable + where TStartup : class + { + private readonly Lazy> _lazyFactory; + + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + private Action _registerResources; + 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( + options => + { + options.IncludeExceptionStackTraceInErrors = true; + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.Converters.Add(new StringEnumConverter()); + }, resources: _registerResources); + }); + + factory.ConfigureServicesAfterStartup(_afterServicesConfiguration); + + return factory; + } + + public void Dispose() + { + _runner.Dispose(); + Factory.Dispose(); + } + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) => + _beforeServicesConfiguration = servicesConfiguration; + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) => + _afterServicesConfiguration = servicesConfiguration; + + public void RegisterResources(Action resources) => + _registerResources = resources; + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + using var scope = Factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetService(); + + await asyncAction(db); + } + + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl) => + ExecuteRequestAsync(HttpMethod.Get, requestUrl); + + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, object requestBody) => + ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody); + + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, object requestBody) => + ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody); + + public Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl) => + ExecuteRequestAsync(HttpMethod.Delete, requestUrl); + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody = null) + { + var request = new HttpRequestMessage(method, requestUrl); + var requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + } + + using var client = Factory.CreateClient(); + var responseMessage = await client.SendAsync(request); + + var responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument); + } + + private string SerializeRequest(object requestBody) => + 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.ConfigureTestServices(services => + { + _beforeServicesConfiguration?.Invoke(services); + }); + + webBuilder.UseStartup(); + + webBuilder.ConfigureTestServices(services => + { + _afterServicesConfiguration?.Invoke(services); + }); + }); + } + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/FakerContainer.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/FakerContainer.cs new file mode 100644 index 0000000..049bac2 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/FakerContainer.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterDataTypeTests.cs new file mode 100644 index 0000000..b244796 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -0,0 +1,338 @@ +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 MongoDB.Driver; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Filtering +{ + public sealed class FilterDataTypeTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterDataTypeTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.RegisterResources(builder => + { + builder.Add(); + }); + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + }); + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc; + } + + [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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterDepthTests.cs new file mode 100644 index 0000000..cd718d7 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterDepthTests.cs @@ -0,0 +1,651 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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; + + // options.DisableTopPagination = false; + // options.DisableChildrenPagination = 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 => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.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
(nameof(Article)).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 Can_filter_in_secondary_resources() + // { + // // Arrange + // var blog = new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}/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(blog.Articles[1].StringId); + // } + + // [Fact] + // public async Task Cannot_filter_in_single_secondary_resource() + // { + // // Arrange + // var article = new Article + // { + // Caption = "X" + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}/author?filter=equals(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("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 Can_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 dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/articles?include=author&filter=equals(author.lastName,'Smith')"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(1); + // responseDocument.Included.Should().HaveCount(1); + // + // responseDocument.Included[0].Id.Should().Be(articles[1].Author.StringId); + // } + + // [Fact] + // public async Task Can_filter_on_HasMany_relationship() + // { + // // Arrange + // var blogs = new List + // { + // new Blog(), + // new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "X" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?filter=greaterThan(count(articles),'0')"; + // + // // 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(blogs[1].StringId); + // } + // + // [Fact] + // public async Task Can_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 dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/articles?filter=has(tags)"; + // + // // 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 Can_filter_in_scope_of_HasMany_relationship() + // { + // // Arrange + // var blog = new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?include=articles&filter[articles]=equals(caption,'Two')"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(1); + // responseDocument.Included.Should().HaveCount(1); + // + // responseDocument.Included[0].Id.Should().Be(blog.Articles[1].StringId); + // } + // + // [Fact] + // public async Task Can_filter_in_scope_of_HasMany_relationship_on_secondary_resource() + // { + // // Arrange + // var blog = new Blog + // { + // Owner = new Author + // { + // LastName = "X", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&filter[articles]=equals(caption,'Two')"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.Included.Should().HaveCount(1); + // + // responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[1].StringId); + // } + // + // [Fact] + // public async Task Can_filter_in_scope_of_HasManyThrough_relationship() + // { + // // Arrange + // var articles = new List
+ // { + // new Article + // { + // Caption = "X", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Cold" + // } + // } + // } + // }, + // new Article + // { + // Caption = "X", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Hot" + // } + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // // Workaround for https://github.com/dotnet/efcore/issues/21026 + // var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + // options.DisableTopPagination = false; + // options.DisableChildrenPagination = true; + // + // var route = "/api/v1/articles?include=tags&filter[tags]=equals(name,'Hot')"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(2); + // responseDocument.Included.Should().HaveCount(1); + // + // responseDocument.Included[0].Id.Should().Be(articles[1].ArticleTags.First().Tag.StringId); + // } + // + // [Fact] + // public async Task Can_filter_in_scope_of_relationship_chain() + // { + // // Arrange + // var blog = new Blog + // { + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?include=owner.articles&filter[owner.articles]=equals(caption,'Two')"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(1); + // responseDocument.Included.Should().HaveCount(2); + // + // responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + // responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[1].StringId); + // } + // + // [Fact] + // public async Task Can_filter_in_same_scope_multiple_times() + // { + // // Arrange + // var articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // }, + // new Article + // { + // Caption = "Three" + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/articles?filter=equals(caption,'One')&filter=equals(caption,'Three')"; + // + // // 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[2].StringId); + // } + // + // [Fact] + // public async Task Can_filter_in_same_scope_multiple_times_using_legacy_notation() + // { + // // Arrange + // var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + // options.EnableLegacyFilterNotation = true; + // + // var articles = new List
+ // { + // new Article + // { + // Caption = "One", + // Author = new Author + // { + // FirstName = "Joe", + // LastName = "Smith" + // } + // }, + // new Article + // { + // Caption = "Two", + // Author = new Author + // { + // FirstName = "John", + // LastName = "Doe" + // } + // }, + // new Article + // { + // Caption = "Three", + // Author = new Author + // { + // FirstName = "Jack", + // LastName = "Miller" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/articles?filter[author.firstName]=John&filter[author.lastName]=Smith"; + // + // // 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); + // } + // + // [Fact] + // public async Task Can_filter_in_multiple_scopes() + // { + // // Arrange + // var blogs = new List + // { + // new Blog(), + // new Blog + // { + // Title = "Technology", + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two", + // Revisions = new List + // { + // new Revision + // { + // PublishTime = 1.January(2000) + // }, + // new Revision + // { + // PublishTime = 10.January(2010) + // } + // } + // } + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?include=owner.articles.revisions&" + + // "filter=and(equals(title,'Technology'),has(owner.articles),equals(owner.lastName,'Smith'))&" + + // "filter[owner.articles]=equals(caption,'Two')&" + + // "filter[owner.articles.revisions]=greaterThan(publishTime,'2005-05-05')"; + // + // // 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(blogs[1].StringId); + // + // responseDocument.Included.Should().HaveCount(3); + // responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + // responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Articles[1].StringId); + // responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(1).First().StringId); + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterOperatorTests.cs new file mode 100644 index 0000000..c1107c0 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -0,0 +1,567 @@ +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.Queries.Expressions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Filtering +{ + public sealed class FilterOperatorTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterOperatorTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.RegisterResources(builder => + { + builder.Add(); + }); + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + }); + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + options.SerializerSettings.DateFormatString = "yyyy-MM-dd"; + } + + [Fact] + public async Task Can_filter_equality_on_special_characters() + { + // Arrange + var resource = new FilterableResource + { + SomeString = "This, that & more" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 Can_filter_equality_on_two_attributes_of_same_type() + // { + // // Arrange + // var resource = new FilterableResource + // { + // SomeInt32 = 5, + // OtherInt32 = 5 + // }; + // + // var otherResource = new FilterableResource + // { + // SomeInt32 = 5, + // OtherInt32 = 10 + // }; + // + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var collection = db.GetCollection(nameof(FilterableResource)); + // await collection.DeleteManyAsync(Builders.Filter.Empty); + // await collection.InsertManyAsync(new[] {resource, otherResource}); + // }); + // + // var route = "/filterableResources?filter=equals(someInt32,otherInt32)"; + // + // // 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); + // responseDocument.ManyData[0].Attributes["otherInt32"].Should().Be(resource.OtherInt32); + // } + + // [Fact] + // public async Task Can_filter_equality_on_two_attributes_of_same_nullable_type() + // { + // // Arrange + // var resource = new FilterableResource + // { + // SomeNullableInt32 = 5, + // OtherNullableInt32 = 5 + // }; + // + // var otherResource = new FilterableResource + // { + // SomeNullableInt32 = 5, + // OtherNullableInt32 = 10 + // }; + // + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var collection = db.GetCollection(nameof(FilterableResource)); + // await collection.DeleteManyAsync(Builders.Filter.Empty); + // await collection.InsertManyAsync(new[] {resource, otherResource}); + // }); + // + // var route = "/filterableResources?filter=equals(someNullableInt32,otherNullableInt32)"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(1); + // responseDocument.ManyData[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + // responseDocument.ManyData[0].Attributes["otherNullableInt32"].Should().Be(resource.OtherNullableInt32); + // } + + // [Fact] + // public async Task Can_filter_equality_on_two_attributes_with_nullable_at_start() + // { + // // Arrange + // var resource = new FilterableResource + // { + // SomeInt32 = 5, + // SomeNullableInt32 = 5 + // }; + // + // var otherResource = new FilterableResource + // { + // SomeInt32 = 5, + // SomeNullableInt32 = 10 + // }; + // + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var collection = db.GetCollection(nameof(FilterableResource)); + // await collection.DeleteManyAsync(Builders.Filter.Empty); + // await collection.InsertManyAsync(new[] {resource, otherResource}); + // }); + // + // var route = "/filterableResources?filter=equals(someNullableInt32,someInt32)"; + // + // // 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); + // responseDocument.ManyData[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + // } + + // [Fact] + // public async Task Can_filter_equality_on_two_attributes_with_nullable_at_end() + // { + // // Arrange + // var resource = new FilterableResource + // { + // SomeInt32 = 5, + // SomeNullableInt32 = 5 + // }; + // + // var otherResource = new FilterableResource + // { + // SomeInt32 = 5, + // SomeNullableInt32 = 10 + // }; + // + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var collection = db.GetCollection(nameof(FilterableResource)); + // await collection.DeleteManyAsync(Builders.Filter.Empty); + // await collection.InsertManyAsync(new[] {resource, otherResource}); + // }); + // + // var route = "/filterableResources?filter=equals(someInt32,someNullableInt32)"; + // + // // 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); + // responseDocument.ManyData[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + // } + + // [Fact] + // public async Task Can_filter_equality_on_two_attributes_of_compatible_types() + // { + // // Arrange + // var resource = new FilterableResource + // { + // SomeInt32 = 5, + // SomeUnsignedInt64 = 5 + // }; + // + // var otherResource = new FilterableResource + // { + // SomeInt32 = 5, + // SomeUnsignedInt64 = 10 + // }; + // + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var collection = db.GetCollection(nameof(FilterableResource)); + // await collection.DeleteManyAsync(Builders.Filter.Empty); + // await collection.InsertManyAsync(new[] {resource, otherResource}); + // }); + // + // var route = "/filterableResources?filter=equals(someInt32,someUnsignedInt64)"; + // + // // 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); + // responseDocument.ManyData[0].Attributes["someUnsignedInt64"].Should().Be(resource.SomeUnsignedInt64); + // } + + [Fact] + public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types() + { + // Arrange + var route = "/filterableResources?filter=equals(someDouble,someTimeSpan)"; + + // 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("No coercion operator is defined between types 'System.TimeSpan' and 'System.Double'."); + responseDocument.Errors[0].Source.Parameter.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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.ParseExact(matchingDateTime, "yyyy-MM-dd", null) + }; + + var otherResource = new FilterableResource + { + SomeDateTime = DateTime.ParseExact(nonMatchingDateTime, "yyyy-MM-dd", null) + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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.ToString("yyyy-MM-dd")); + } + + [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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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 Can_filter_on_has() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new FilterableResource() + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.InsertManyAsync(new[] {resource, new FilterableResource()}); + }); + + var route = "/filterableResources?filter=has(children)"; + + // 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(resource.StringId); + } + + [Fact] + public async Task Can_filter_on_count() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new FilterableResource(), + new FilterableResource() + } + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(resource.StringId); + } + + [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 => + { + var collection = db.GetCollection(nameof(FilterableResource)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterTests.cs new file mode 100644 index 0000000..bb68358 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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 Cannot_filter_in_unknown_scope() + { + // Arrange + var route = "/api/v1/people?filter[doesNotExist]=equals(title,null)"; + + // 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("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter[doesNotExist]"); + } + + [Fact] + public async Task Cannot_filter_in_unknown_nested_scope() + { + // Arrange + var route = "/api/v1/people?filter[todoItems.doesNotExist]=equals(title,null)"; + + // 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("Relationship 'todoItems' in 'todoItems.doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter[todoItems.doesNotExist]"); + } + + [Fact] + public async Task Cannot_filter_on_attribute_with_blocked_capability() + { + // Arrange + var route = "/api/v1/todoItems?filter=equals(achievedDate,null)"; + + // 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("Filtering on the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Filtering on attribute 'achievedDate' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_on_ID() + { + // Arrange + var person = new Person + { + FirstName = "Jane" + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(Person)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterableResource.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterableResource.cs new file mode 100644 index 0000000..182a312 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterableResource.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Filtering +{ + public sealed class FilterableResource : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [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] public ICollection Children { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterableResourcesController.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterableResourcesController.cs new file mode 100644 index 0000000..42eb764 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Filtering/FilterableResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Filtering +{ + public sealed class FilterableResourcesController : JsonApiController + { + public FilterableResourcesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Meta/TopLevelCountTests.cs new file mode 100644 index 0000000..fa5ca00 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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 => + { + var collection = db.GetCollection(nameof(TodoItem)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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.GetCollection(nameof(TodoItem)).DeleteManyAsync(Builders.Filter.Empty)); + + 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(nameof(TodoItem)).InsertOneAsync(todoItem)); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = todoItem.Id, + attributes = new + { + description = "Something else" + } + } + }; + + var route = $"/api/v1/todoItems/{todoItem.Id}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ObjectAssertionsExtensions.cs new file mode 100644 index 0000000..3463b56 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -0,0 +1,28 @@ +using System; +using FluentAssertions; +using FluentAssertions.Primitives; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs new file mode 100644 index 0000000..4b3c2df --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -0,0 +1,776 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Pagination +{ + public sealed class PaginationWithTotalCountTests : IClassFixture> + { + private const int _defaultPageSize = 5; + private readonly IntegrationTestContext _testContext; + private readonly Faker _todoItemFaker = new Faker(); + + 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 => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.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 Cannot_paginate_in_single_primary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection
(nameof(Article)).InsertOneAsync(article)); + + var route = $"/api/v1/articles/{article.StringId}?page[number]=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("The specified paging 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("page[number]"); + } + + // [Fact] + // public async Task Can_paginate_in_secondary_resources() + // { + // // Arrange + // var blog = new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/blogs/{blog.StringId}/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(blog.Articles[1].StringId); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be("http://localhost" + route); + // responseDocument.Links.First.Should().Be($"http://localhost/blogs/{blog.StringId}/api/v1/articles?page[size]=1"); + // responseDocument.Links.Last.Should().BeNull(); + // responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); + // responseDocument.Links.Next.Should().Be($"http://localhost/blogs/{blog.StringId}/api/v1/articles?page[number]=3&page[size]=1"); + // } + + // [Fact] + // public async Task Cannot_paginate_in_single_secondary_resource() + // { + // // Arrange + // var article = new Article + // { + // Caption = "X" + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}/author?page[size]=5"; + // + // // 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 paging 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("page[size]"); + // } + + // [Fact] + // public async Task Can_paginate_in_scope_of_HasMany_relationship() + // { + // // Arrange + // var blogs = new List + // { + // new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // }, + // new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "First" + // }, + // new Article + // { + // Caption = "Second" + // } + // } + // }, + // new Blog() + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/blogs?include=articles&page[number]=articles:2&page[size]=2,articles:1"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(2); + // responseDocument.Included.Should().HaveCount(2); + // + // responseDocument.Included[0].Id.Should().Be(blogs[0].Articles[1].StringId); + // responseDocument.Included[1].Id.Should().Be(blogs[1].Articles[1].StringId); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be("http://localhost" + route); + // responseDocument.Links.First.Should().Be("http://localhost/blogs?include=articles&page[size]=2,articles:1"); + // responseDocument.Links.Last.Should().Be("http://localhost/blogs?include=articles&page[number]=2&page[size]=2,articles:1"); + // responseDocument.Links.Prev.Should().BeNull(); + // responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); + // } + // + // [Fact] + // public async Task Can_paginate_in_scope_of_HasMany_relationship_on_secondary_resource() + // { + // // Arrange + // var blog = new Blog + // { + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/blogs/{blog.StringId}/owner?include=articles&page[number]=articles:2&page[size]=articles:1"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.Included.Should().HaveCount(1); + // responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[1].StringId); + // + // 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(); + // } + // + // [Fact] + // public async Task Can_paginate_HasMany_relationship_on_relationship_endpoint() + // { + // // Arrange + // var blog = new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/blogs/{blog.StringId}/relationships/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(blog.Articles[1].StringId); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be("http://localhost" + route); + // responseDocument.Links.First.Should().Be($"http://localhost/blogs/{blog.StringId}/relationships/api/v1/articles?page[size]=1"); + // responseDocument.Links.Last.Should().BeNull(); + // responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); + // responseDocument.Links.Next.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_paginate_in_scope_of_HasManyThrough_relationship() + // { + // // Arrange + // var articles = new List
+ // { + // new Article + // { + // Caption = "X", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Cold" + // } + // }, + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Hot" + // } + // } + // } + // }, + // new Article + // { + // Caption = "X", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Wet" + // } + // }, + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Dry" + // } + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // // Workaround for https://github.com/dotnet/efcore/issues/21026 + // var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + // options.DisableTopPagination = true; + // options.DisableChildrenPagination = false; + // + // var route = "/api/v1/articles?include=tags&page[number]=tags:2&page[size]=tags:1"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(2); + // responseDocument.Included.Should().HaveCount(2); + // + // responseDocument.Included[0].Id.Should().Be(articles[0].ArticleTags.Skip(1).First().Tag.StringId); + // responseDocument.Included[1].Id.Should().Be(articles[1].ArticleTags.Skip(1).First().Tag.StringId); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be("http://localhost" + route); + // responseDocument.Links.First.Should().Be("http://localhost/api/v1/articles?include=tags&page[size]=tags:1"); + // responseDocument.Links.Last.Should().Be(responseDocument.Links.First); + // responseDocument.Links.Prev.Should().BeNull(); + // responseDocument.Links.Next.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_paginate_HasManyThrough_relationship_on_relationship_endpoint() + // { + // // Arrange + // var article = new Article + // { + // Caption = "X", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Cold" + // } + // }, + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Hot" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}/relationships/tags?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(article.ArticleTags.ElementAt(1).TagId.ToString()); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be("http://localhost" + route); + // responseDocument.Links.First.Should().Be($"http://localhost/api/v1/articles/{article.StringId}/relationships/tags?page[size]=1"); + // responseDocument.Links.Last.Should().BeNull(); + // responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); + // responseDocument.Links.Next.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_paginate_in_multiple_scopes() + // { + // // Arrange + // var blogs = new List + // { + // new Blog + // { + // Title = "Cooking" + // }, + // new Blog + // { + // Title = "Technology", + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two", + // Revisions = new List + // { + // new Revision + // { + // PublishTime = 1.January(2000) + // }, + // new Revision + // { + // PublishTime = 10.January(2010) + // } + // } + // } + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/blogs?include=owner.articles.revisions&" + + // "page[size]=1,owner.articles:1,owner.articles.revisions:1&" + + // "page[number]=2,owner.articles:2,owner.articles.revisions:2"; + // + // // 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(blogs[1].StringId); + // + // responseDocument.Included.Should().HaveCount(3); + // responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + // responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Articles[1].StringId); + // responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(1).First().StringId); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be("http://localhost" + route); + // responseDocument.Links.First.Should().Be("http://localhost/blogs?include=owner.articles.revisions&page[size]=1,owner.articles:1,owner.articles.revisions:1"); + // responseDocument.Links.Last.Should().Be("http://localhost/blogs?include=owner.articles.revisions&page[size]=1,owner.articles:1,owner.articles.revisions:1&page[number]=2"); + // responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); + // responseDocument.Links.Next.Should().BeNull(); + // } + + // [Fact] + // public async Task Cannot_paginate_in_unknown_scope() + // { + // // Arrange + // var route = "/people?page[number]=doesNotExist:1"; + // + // // 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 paging is invalid."); + // responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + // responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + // } + // + // [Fact] + // public async Task Cannot_paginate_in_unknown_nested_scope() + // { + // // Arrange + // var route = "/people?page[size]=todoItems.doesNotExist:1"; + // + // // 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 paging is invalid."); + // responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + // responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + // } + + // [Fact] + // public async Task Uses_default_page_number_and_size() + // { + // // Arrange + // var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + // options.DefaultPageSize = new PageSize(2); + // + // var blog = new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two" + // }, + // new Article + // { + // Caption = "Three" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/blogs/{blog.StringId}/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(blog.Articles[0].StringId); + // responseDocument.ManyData[1].Id.Should().Be(blog.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().BeNull(); + // responseDocument.Links.Prev.Should().BeNull(); + // responseDocument.Links.Next.Should().Be($"http://localhost/blogs/{blog.StringId}/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 blog = new Blog + // { + // Articles = new List
() + // }; + // + // for (int index = 0; index < 25; index++) + // { + // blog.Articles.Add(new Article + // { + // Caption = $"Item {index:D3}" + // }); + // } + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/blogs/{blog.StringId}/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(); + // } + + // [Theory] + // [InlineData(1, 1, 4, null, 2)] + // [InlineData(2, 1, 4, 1, 3)] + // [InlineData(3, 1, 4, 2, 4)] + // [InlineData(4, 1, 4, 3, null)] + // public async Task Renders_correct_top_level_links_for_page_number(int pageNumber, int? firstLink, int? lastLink, int? prevLink, int? nextLink) + // { + // // Arrange + // var person = new Person + // { + // LastName = "&Ampersand" + // }; + // + // const int totalCount = 3 * _defaultPageSize + 3; + // var todoItems = _todoItemFaker.Generate(totalCount); + // + // foreach (var todoItem in todoItems) + // { + // todoItem.Owner = person; + // } + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.TodoItems.AddRange(todoItems); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var routePrefix = "/todoItems?filter=equals(owner.lastName,'" + WebUtility.UrlEncode(person.LastName) + "')" + + // "&fields[people]=firstName&include=owner&sort=ordinal&foo=bar,baz"; + // var route = routePrefix + $"&page[number]={pageNumber}"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // Assert.Equal("http://localhost" + route, responseDocument.Links.Self); + // + // if (firstLink != null) + // { + // var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, firstLink.Value); + // Assert.Equal(expected, responseDocument.Links.First); + // } + // else + // { + // Assert.Null(responseDocument.Links.First); + // } + // + // if (prevLink != null) + // { + // var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, prevLink.Value); + // Assert.Equal(expected, responseDocument.Links.Prev); + // } + // else + // { + // Assert.Null(responseDocument.Links.Prev); + // } + // + // if (nextLink != null) + // { + // var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, nextLink.Value); + // Assert.Equal(expected, responseDocument.Links.Next); + // } + // else + // { + // Assert.Null(responseDocument.Links.Next); + // } + // + // if (lastLink != null) + // { + // var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, lastLink.Value); + // Assert.Equal(expected, responseDocument.Links.Last); + // } + // else + // { + // Assert.Null(responseDocument.Links.Last); + // } + // + // static string SetPageNumberInUrl(string url, int pageNumber) + // { + // return pageNumber != 1 ? url + "&page[number]=" + pageNumber : url; + // } + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs new file mode 100644 index 0000000..fdb4d6c --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Pagination +{ + public sealed class PaginationWithoutTotalCountTests : IClassFixture> + { + private const int _defaultPageSize = 5; + private readonly IntegrationTestContext _testContext; + private readonly Faker
_articleFaker = new Faker
(); + + public PaginationWithoutTotalCountTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + + options.IncludeTotalResourceCount = false; + options.DefaultPageSize = new PageSize(_defaultPageSize); + options.AllowUnknownQueryStringParameters = true; + } + + [Fact] + public async Task When_page_size_is_unconstrained_it_should_not_render_pagination_links() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = null; + + var route = "/api/v1/articles?foo=bar"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost/api/v1/articles?foo=bar"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + } + + [Fact] + public async Task When_page_size_is_specified_in_query_string_with_no_data_it_should_render_pagination_links() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = null; + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection
(nameof(Article)).DeleteManyAsync(Builders
.Filter.Empty)); + + var route = "/api/v1/articles?page[size]=8&foo=bar"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost/api/v1/articles?page[size]=8&foo=bar"); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/articles?page[size]=8&foo=bar"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + } + + [Fact] + public async Task When_page_number_is_specified_in_query_string_with_no_data_it_should_render_pagination_links() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection
(nameof(Article)).DeleteManyAsync(Builders
.Filter.Empty)); + + var route = "/api/v1/articles?page[number]=2&foo=bar"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost/api/v1/articles?page[number]=2&foo=bar"); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/articles?foo=bar"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().Be("http://localhost/api/v1/articles?foo=bar"); + responseDocument.Links.Next.Should().BeNull(); + } + + [Fact] + public async Task When_page_number_is_specified_in_query_string_with_partially_filled_page_it_should_render_pagination_links() + { + // Arrange + var articles = _articleFaker.Generate(12); + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?foo=bar&page[number]=3"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Count.Should().BeLessThan(_defaultPageSize); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost/api/v1/articles?foo=bar&page[number]=3"); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/articles?foo=bar"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().Be("http://localhost/api/v1/articles?foo=bar&page[number]=2"); + responseDocument.Links.Next.Should().BeNull(); + } + + [Fact] + public async Task When_page_number_is_specified_in_query_string_with_full_page_it_should_render_pagination_links() + { + // Arrange + var articles = _articleFaker.Generate(_defaultPageSize * 3); + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.InsertManyAsync(articles); + }); + + var route = "/api/v1/articles?page[number]=3&foo=bar"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(_defaultPageSize); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost/api/v1/articles?page[number]=3&foo=bar"); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/articles?foo=bar"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().Be("http://localhost/api/v1/articles?page[number]=2&foo=bar"); + responseDocument.Links.Next.Should().Be("http://localhost/api/v1/articles?page[number]=4&foo=bar"); + } + + // [Fact] + // public async Task When_page_number_is_specified_in_query_string_with_full_page_on_secondary_endpoint_it_should_render_pagination_links() + // { + // // Arrange + // var author = new Author + // { + // Articles = _articleFaker.Generate(_defaultPageSize * 3) + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.AuthorDifferentDbContextName.Add(author); + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/authors/{author.StringId}/articles?page[number]=3&foo=bar"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.ManyData.Should().HaveCount(_defaultPageSize); + // + // responseDocument.Links.Should().NotBeNull(); + // responseDocument.Links.Self.Should().Be($"http://localhost/authors/{author.StringId}/articles?page[number]=3&foo=bar"); + // responseDocument.Links.First.Should().Be($"http://localhost/authors/{author.StringId}/articles?foo=bar"); + // responseDocument.Links.Last.Should().BeNull(); + // responseDocument.Links.Prev.Should().Be($"http://localhost/authors/{author.StringId}/articles?page[number]=2&foo=bar"); + // responseDocument.Links.Next.Should().Be($"http://localhost/authors/{author.StringId}/articles?page[number]=4&foo=bar"); + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/RangeValidationTests.cs new file mode 100644 index 0000000..f524d6b --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/RangeValidationTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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_negative_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[number]=-1"; + + // 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 paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page number cannot be negative or zero."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task When_page_number_is_zero_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[number]=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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page number cannot be negative or zero."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task When_page_number_is_positive_it_must_succeed() + { + // Arrange + var route = "/api/v1/todoItems?page[number]=20"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [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 => + { + var collection = db.GetCollection(nameof(TodoItem)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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_negative_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=-1"; + + // 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 paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page size cannot be negative."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs new file mode 100644 index 0000000..dd782ac --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Pagination +{ + public sealed class RangeValidationWithMaximumTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + private const int _maximumPageSize = 15; + private const int _maximumPageNumber = 20; + + public RangeValidationWithMaximumTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(5); + options.MaximumPageSize = new PageSize(_maximumPageSize); + options.MaximumPageNumber = new PageNumber(_maximumPageNumber); + } + + [Fact] + public async Task When_page_number_is_below_maximum_it_must_succeed() + { + // Arrange + const int pageNumber = _maximumPageNumber - 1; + var route = "/api/v1/todoItems?page[number]=" + pageNumber; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_number_equals_maximum_it_must_succeed() + { + // Arrange + const int pageNumber = _maximumPageNumber; + var route = "/api/v1/todoItems?page[number]=" + pageNumber; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_number_is_over_maximum_it_must_fail() + { + // Arrange + const int pageNumber = _maximumPageNumber + 1; + var route = "/api/v1/todoItems?page[number]=" + pageNumber; + + // 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 paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be($"Page number cannot be higher than {_maximumPageNumber}."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task When_page_size_equals_zero_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page size cannot be unconstrained."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [Fact] + public async Task When_page_size_is_below_maximum_it_must_succeed() + { + // Arrange + const int pageSize = _maximumPageSize - 1; + var route = "/api/v1/todoItems?page[size]=" + pageSize; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_size_equals_maximum_it_must_succeed() + { + // Arrange + const int pageSize = _maximumPageSize; + var route = "/api/v1/todoItems?page[size]=" + pageSize; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_size_is_over_maximum_it_must_fail() + { + // Arrange + const int pageSize = _maximumPageSize + 1; + var route = "/api/v1/todoItems?page[size]=" + pageSize; + + // 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 paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be($"Page size cannot be higher than {_maximumPageSize}."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/QueryStrings/QueryStringTests.cs new file mode 100644 index 0000000..7fe1859 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.QueryStrings +{ + public sealed class QueryStringTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public QueryStringTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_use_unknown_query_string_parameter() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = false; + + var route = "/api/v1/articles?foo=bar"; + + // 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("Unknown query string parameter."); + responseDocument.Errors[0].Detail.Should().Be("Query string parameter 'foo' is unknown. Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + responseDocument.Errors[0].Source.Parameter.Should().Be("foo"); + } + + [Fact] + public async Task Can_use_unknown_query_string_parameter() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = true; + + var route = "/api/v1/articles?foo=bar"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Theory] + [InlineData("include")] + [InlineData("filter")] + [InlineData("sort")] + [InlineData("page")] + [InlineData("fields")] + [InlineData("defaults")] + [InlineData("nulls")] + public async Task Cannot_use_empty_query_string_parameter_value(string parameterName) + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = false; + + var route = "/api/v1/articles?" + parameterName + "="; + + // 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("Missing query string parameter value."); + responseDocument.Errors[0].Detail.Should().Be($"Missing value for '{parameterName}' query string parameter."); + responseDocument.Errors[0].Source.Parameter.Should().Be(parameterName); + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs new file mode 100644 index 0000000..62eaf4d --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -0,0 +1,628 @@ +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 Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite.Creating +{ + public sealed class CreateResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public CreateResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.RegisterResources(builder => + { + builder.Add(); + builder.Add(); + }); + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + services.AddResourceRepository>(); + }); + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + options.AllowClientGeneratedIds = false; + } + + [Fact] + public async Task Sets_location_header_for_created_resource() + { + // Arrange + var newWorkItem = _fakers.WorkItem.Generate(); + + 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); + + var newWorkItemId = responseDocument.SingleData.Id; + httpResponse.Headers.Location.Should().Be("/workItems/" + newWorkItemId); + + responseDocument.Links.Self.Should().Be("http://localhost/workItems"); + responseDocument.Links.First.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be("http://localhost" + httpResponse.Headers.Location); + } + + [Fact] + public async Task Can_create_resource_with_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().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await (await db.GetCollection(nameof(WorkItem)) + .FindAsync(Builders.Filter.Eq(w => w.Id, newWorkItemId))) + .FirstAsync(); + + 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 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().NotBeEmpty(); + // + // var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // var workItemInDatabase = await dbContext.WorkItems + // .FirstAsync(workItem => workItem.Id == newWorkItemId); + // + // 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().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await (await db.GetCollection(nameof(WorkItem)) + .FindAsync(Builders.Filter.Eq(workItem => workItem.Id, newWorkItemId))) + .FirstAsync(); + + 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 = 12345678 + } + } + } + } + }; + + 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().NotBeEmpty(); + + var newWorkItemId = responseDocument.SingleData.Id; + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await (await db.GetCollection(nameof(WorkItem)) + .FindAsync(Builders.Filter.Eq(workItem => workItem.Id, newWorkItemId))) + .FirstOrDefaultAsync(); + + workItemInDatabase.Should().NotBeNull(); + }); + } + + [Fact] + public async Task Cannot_create_resource_with_client_generated_ID() + { + // Arrange + var requestBody = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C", + attributes = new + { + name = "Black" + } + } + }; + + var route = "/rgbColors"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); + responseDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/id"); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_request_body() + { + // Arrange + var requestBody = string.Empty; + + 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("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_resource_for_missing_type() + { + // Arrange + var requestBody = new + { + data = new + { + attributes = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_for_unknown_type() + { + // Arrange + var requestBody = new + { + data = new + { + type = "doesNotExist", + attributes = new + { + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_create_resource_on_unknown_resource_type_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + } + } + }; + + var route = "/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_create_on_resource_type_mismatch_between_url_and_body() + { + // Arrange + var requestBody = new + { + data = new + { + type = "rgbColors", + id = "0A0B0C" + } + }; + var route = "/workItems"; + + // 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("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be("Expected resource of type 'workItems' in POST request body at endpoint '/workItems', instead of 'rgbColors'."); + } + + [Fact] + public async Task Cannot_create_resource_attribute_with_blocked_capability() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Setting the initial value of 'concurrencyToken' is not allowed. - Request body:"); + } + + // [Fact] + // public async Task Cannot_create_resource_with_readonly_attribute() + // { + // // Arrange + // var requestBody = new + // { + // data = new + // { + // type = "workItemGroups", + // attributes = new + // { + // concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + // } + // } + // }; + // + // var route = "/workItemGroups"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // + // responseDocument.Errors.Should().HaveCount(1); + // responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + // responseDocument.Errors[0].Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); + // } + + [Fact] + public async Task Cannot_create_resource_for_broken_JSON_request_body() + { + // Arrange + var requestBody = "{ \"data\" {"; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); + } + + [Fact] + public async Task Cannot_create_resource_with_incompatible_attribute_value() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + dueAt = "not-a-valid-time" + } + } + }; + + var route = "/workItems"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); + } + + //[Fact] + //public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() + //{ + //// Arrange + //var existingUserAccounts = _fakers.UserAccount.Generate(2); + //var existingTag = _fakers.WorkTag.Generate(); + + //var newDescription = _fakers.WorkItem.Generate().Description; + + //await _testContext.RunOnDatabaseAsync(async db => + //{ + //await db.GetCollection(nameof(UserAccount)).InsertManyAsync(existingUserAccounts); + //await db.GetCollection(nameof(WorkTag)).InsertOneAsync(existingTag); + //}); + + //var requestBody = new + //{ + //data = new + //{ + //type = "workItems", + //attributes = new + //{ + //description = newDescription + //}, + //relationships = new + //{ + //assignee = new + //{ + //data = new + //{ + //type = "userAccounts", + //id = existingUserAccounts[0].StringId + //} + //}, + //subscribers = new + //{ + //data = new[] + //{ + //new + //{ + //type = "userAccounts", + //id = existingUserAccounts[1].StringId + //} + //} + //}, + //tags = new + //{ + //data = new[] + //{ + //new + //{ + //type = "workTags", + //id = existingTag.StringId + //} + //} + //} + //} + //} + //}; + + //var route = "/workItems"; + + //// Act + //var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + //// Assert + //httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + //responseDocument.SingleData.Should().NotBeNull(); + //responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); + //responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + //var newWorkItemId = int.Parse(responseDocument.SingleData.Id); + + //await _testContext.RunOnDatabaseAsync(async dbContext => + //{ + //var workItemInDatabase = await dbContext.WorkItems + //.Include(workItem => workItem.Assignee) + //.Include(workItem => workItem.Subscribers) + //.Include(workItem => workItem.WorkItemTags) + //.ThenInclude(workItemTag => workItemTag.Tag) + //.FirstAsync(workItem => workItem.Id == newWorkItemId); + + //workItemInDatabase.Description.Should().Be(newDescription); + + //workItemInDatabase.Assignee.Should().NotBeNull(); + //workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + + //workItemInDatabase.Subscribers.Should().HaveCount(1); + //workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + + //workItemInDatabase.WorkItemTags.Should().HaveCount(1); + //workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + //}); + //} + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs new file mode 100644 index 0000000..ae10855 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -0,0 +1,228 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite.Deleting +{ + public sealed class DeleteResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public DeleteResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.RegisterResources(builder => + { + builder.Add(); + }); + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + }); + } + + [Fact] + public async Task Can_delete_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).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 (await db.GetCollection(nameof(WorkItem)) + .FindAsync(Builders.Filter.Eq(workItem => workItem.Id, existingWorkItem.Id))) + .FirstOrDefaultAsync(); + + workItemsInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_missing_resource() + { + // Arrange + var route = "/workItems/5f88857c4aa60defec6a4999"; + + // 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 '5f88857c4aa60defec6a4999' does not exist."); + } + + // [Fact] + // public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() + // { + // // Arrange + // var existingColor = _fakers.RgbColor.Generate(); + // existingColor.Group = _fakers.WorkItemGroup.Generate(); + + // await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(RgbColor)).InsertOneAsync(existingColor)); + + // var route = "/rgbColors/" + existingColor.StringId; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + // responseDocument.Should().BeEmpty(); + + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var colorsInDatabase = await (await db.GetCollection(nameof(RgbColor)) + // .FindAsync(Builders.Filter.Eq(color => color.Id, existingColor.Id))) + // .FirstOrDefaultAsync(); + + // colorsInDatabase.Should().BeNull(); + + // var groupInDatabase = await (await db.GetCollection(nameof(WorkItemGroup)) + // .FindAsync(Builders.Filter.Eq(group => group.Id, existingColor.Group.Id))) + // .FirstAsync(); + + // groupInDatabase.Color.Should().BeNull(); + // }); + // } + + // [Fact] + // public async Task Can_delete_existing_resource_with_OneToOne_relationship_from_principal_side() + // { + // // Arrange + // var existingGroup = _fakers.WorkItemGroup.Generate(); + // existingGroup.Color = _fakers.RgbColor.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Groups.Add(existingGroup); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = "/workItemGroups/" + existingGroup.StringId; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + // responseDocument.Should().BeEmpty(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // var groupsInDatabase = await dbContext.Groups + // .FirstOrDefaultAsync(group => group.Id == existingGroup.Id); + + // groupsInDatabase.Should().BeNull(); + + // var colorInDatabase = await dbContext.RgbColors + // .FirstOrDefaultAsync(color => color.Id == existingGroup.Color.Id); + + // colorInDatabase.Should().NotBeNull(); + // colorInDatabase.Group.Should().BeNull(); + // }); + // } + + // [Fact] + // public async Task Can_delete_existing_resource_with_HasMany_relationship() + // { + // // Arrange + // var existingWorkItem = _fakers.WorkItem.Generate(); + // existingWorkItem.Subscribers = _fakers.UserAccount.Generate(2).ToHashSet(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(existingWorkItem); + // await dbContext.SaveChangesAsync(); + // }); + + // 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 dbContext => + // { + // var workItemInDatabase = await dbContext.WorkItems + // .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItem.Id); + + // workItemInDatabase.Should().BeNull(); + + // var userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); + + // userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(0).Id); + // userAccountsInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); + // }); + // } + + // [Fact] + // public async Task Can_delete_resource_with_HasManyThrough_relationship() + // { + // // Arrange + // var existingWorkItemTag = new WorkItemTag + // { + // Item = _fakers.WorkItem.Generate(), + // Tag = _fakers.WorkTag.Generate() + // }; + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItemTags.Add(existingWorkItemTag); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = "/workItems/" + existingWorkItemTag.Item.StringId; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + // responseDocument.Should().BeEmpty(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // var workItemsInDatabase = await dbContext.WorkItems + // .FirstOrDefaultAsync(workItem => workItem.Id == existingWorkItemTag.Item.Id); + + // workItemsInDatabase.Should().BeNull(); + + // var workItemTagsInDatabase = await dbContext.WorkItemTags + // .FirstOrDefaultAsync(workItemTag => workItemTag.Item.Id == existingWorkItemTag.Item.Id); + + // workItemTagsInDatabase.Should().BeNull(); + // }); + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs new file mode 100644 index 0000000..031f44e --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -0,0 +1,386 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite.Fetching +{ + public sealed class FetchResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public FetchResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.RegisterResources(builder => + { + builder.Add(); + }); + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + }); + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var workItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(WorkItem)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.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().NotBeEmpty(); + + 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().NotBeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resources_for_unknown_type() + { + // Arrange + var route = "/doesNotExist"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var workItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).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().NotBeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_type() + { + // Arrange + var route = "/doesNotExist/5f88857c4aa60defec6a4999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + var route = "/workItems/5f88857c4aa60defec6a4999"; + + // 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 '5f88857c4aa60defec6a4999' does not exist."); + } + + // [Fact] + // public async Task Can_get_secondary_HasOne_resource() + // { + // // Arrange + // var workItem = _fakers.WorkItem.Generate(); + // workItem.Assignee = _fakers.UserAccount.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(workItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/workItems/{workItem.StringId}/assignee"; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Type.Should().Be("userAccounts"); + // responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); + // responseDocument.SingleData.Attributes["firstName"].Should().Be(workItem.Assignee.FirstName); + // responseDocument.SingleData.Attributes["lastName"].Should().Be(workItem.Assignee.LastName); + // responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + // } + + // [Fact] + // public async Task Can_get_unknown_secondary_HasOne_resource() + // { + // // Arrange + // var workItem = _fakers.WorkItem.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(workItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/workItems/{workItem.StringId}/assignee"; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // responseDocument.Data.Should().BeNull(); + // } + + // [Fact] + // public async Task Can_get_secondary_HasMany_resources() + // { + // // Arrange + // var userAccount = _fakers.UserAccount.Generate(); + // userAccount.AssignedItems = _fakers.WorkItem.Generate(2).ToHashSet(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.UserAccounts.Add(userAccount); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // // 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 == userAccount.AssignedItems.ElementAt(0).StringId); + // item1.Type.Should().Be("workItems"); + // item1.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(0).Description); + // item1.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(0).DueAt); + // item1.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(0).Priority.ToString("G")); + // item1.Relationships.Should().NotBeEmpty(); + + // var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); + // item2.Type.Should().Be("workItems"); + // item2.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(1).Description); + // item2.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(1).DueAt); + // item2.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(1).Priority.ToString("G")); + // item2.Relationships.Should().NotBeEmpty(); + // } + + // [Fact] + // public async Task Can_get_unknown_secondary_HasMany_resource() + // { + // // Arrange + // var userAccount = _fakers.UserAccount.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.UserAccounts.Add(userAccount); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/userAccounts/{userAccount.StringId}/assignedItems"; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // responseDocument.ManyData.Should().BeEmpty(); + // } + + // [Fact] + // public async Task Can_get_secondary_HasManyThrough_resources() + // { + // // Arrange + // var workItem = _fakers.WorkItem.Generate(); + // workItem.WorkItemTags = new List + // { + // new WorkItemTag + // { + // Tag = _fakers.WorkTag.Generate() + // }, + // new WorkItemTag + // { + // Tag = _fakers.WorkTag.Generate() + // } + // }; + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(workItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/workItems/{workItem.StringId}/tags"; + + // // 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 == workItem.WorkItemTags.ElementAt(0).Tag.StringId); + // item1.Type.Should().Be("workTags"); + // item1.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.Text); + // item1.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.IsBuiltIn); + // item1.Relationships.Should().BeNull(); + + // var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); + // item2.Type.Should().Be("workTags"); + // item2.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.Text); + // item2.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.IsBuiltIn); + // item2.Relationships.Should().BeNull(); + // } + + // [Fact] + // public async Task Can_get_unknown_secondary_HasManyThrough_resources() + // { + // // Arrange + // var workItem = _fakers.WorkItem.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(workItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/workItems/{workItem.StringId}/tags"; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // responseDocument.ManyData.Should().BeEmpty(); + // } + + // [Fact] + // public async Task Cannot_get_secondary_resource_for_unknown_primary_type() + // { + // // Arrange + // var route = "/doesNotExist/99999999/assignee"; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + // responseDocument.Should().BeEmpty(); + // } + + // [Fact] + // public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() + // { + // // Arrange + // var route = "/workItems/99999999/assignee"; + + // // 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 '99999999' does not exist."); + // } + + // [Fact] + // public async Task Cannot_get_secondary_resource_for_unknown_secondary_type() + // { + // // Arrange + // var workItem = _fakers.WorkItem.Generate(); + // workItem.Assignee = _fakers.UserAccount.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(workItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var route = $"/workItems/{workItem.StringId}/doesNotExist"; + + // // 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 relationship does not exist."); + // responseDocument.Errors[0].Detail.Should().Be("Resource of type 'workItems' does not contain a relationship named 'doesNotExist'."); + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/RgbColor.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/RgbColor.cs new file mode 100644 index 0000000..57a8f69 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/RgbColor.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class RgbColor : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string DisplayName { get; set; } + + [HasOne] + public WorkItemGroup Group { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/RgbColorsController.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/RgbColorsController.cs new file mode 100644 index 0000000..f32f75b --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/RgbColorsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class RgbColorsController : JsonApiController + { + public RgbColorsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000..52b7473 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -0,0 +1,1111 @@ +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 Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite.Updating.Resources +{ + public sealed class UpdateResourceTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly WriteFakers _fakers = new WriteFakers(); + + public UpdateResourceTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _testContext.RegisterResources(builder => + { + builder.Add(); + builder.Add(); + builder.Add(); + }); + + _testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + }); + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + options.AllowClientGeneratedIds = false; + } + + [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(nameof(UserAccount)).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(nameof(UserAccount)).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 (await db.GetCollection(nameof(UserAccount)) + .FindAsync(Builders.Filter.Eq(userAccount => userAccount.Id, existingUserAccount.Id))) + .FirstAsync(); + + userAccountInDatabase.FirstName.Should().Be(newFirstName); + userAccountInDatabase.LastName.Should().Be(existingUserAccount.LastName); + }); + } + + // [Fact] + // public async Task Can_update_resource_with_unknown_relationship() + // { + // // Arrange + // var existingUserAccount = _fakers.UserAccount.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.UserAccounts.Add(existingUserAccount); + // await dbContext.SaveChangesAsync(); + // }); + + // var requestBody = new + // { + // data = new + // { + // type = "userAccounts", + // id = existingUserAccount.StringId, + // relationships = new + // { + // doesNotExist = new + // { + // data = new + // { + // type = "doesNotExist", + // id = 12345678 + // } + // } + // } + // } + // }; + + // 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_partially_update_resource_with_guid_ID() + // { + // // Arrange + // var existingGroup = _fakers.WorkItemGroup.Generate(); + // var newName = _fakers.WorkItemGroup.Generate().Name; + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Groups.Add(existingGroup); + // await dbContext.SaveChangesAsync(); + // }); + + // 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().NotBeEmpty(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // var groupInDatabase = await dbContext.Groups + // .FirstAsync(group => group.Id == existingGroup.Id); + + // 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(Guid)); + // } + + [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(nameof(RgbColor)).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 (await db.GetCollection(nameof(RgbColor)) + .FindAsync(Builders.Filter.Eq(color => color.Id, existingColor.Id))) + .FirstAsync(); + + 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(nameof(UserAccount)).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 (await db.GetCollection(nameof(UserAccount)) + .FindAsync(Builders.Filter.Eq(userAccount => userAccount.Id, existingUserAccount.Id))) + .FirstAsync(); + + 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(nameof(WorkItem)).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().NotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async db => + { + var workItemInDatabase = await (await db.GetCollection(nameof(WorkItem)) + .FindAsync(Builders.Filter.Eq(workItem => workItem.Id, existingWorkItem.Id))) + .FirstAsync(); + + 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(nameof(WorkItem)).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 (await db.GetCollection(nameof(WorkItem)) + .FindAsync(Builders.Filter.Eq(workItem => workItem.Id, existingWorkItem.Id))) + .FirstAsync(); + + 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_include_and_fieldsets() + // { + // // Arrange + // var existingWorkItem = _fakers.WorkItem.Generate(); + // existingWorkItem.WorkItemTags = new[] + // { + // new WorkItemTag + // { + // Tag = _fakers.WorkTag.Generate() + // } + // }; + + // var newDescription = _fakers.WorkItem.Generate().Description; + + // await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).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,tags&include=tags&fields[workTags]=text"; + + // // 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().HaveCount(1); + // responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); + // responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); + + // responseDocument.Included.Should().HaveCount(1); + // responseDocument.Included[0].Type.Should().Be("workTags"); + // responseDocument.Included[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); + // responseDocument.Included[0].Attributes.Should().HaveCount(1); + // responseDocument.Included[0].Attributes["text"].Should().Be(existingWorkItem.WorkItemTags.Single().Tag.Text); + // responseDocument.Included[0].Relationships.Should().BeNull(); + + // await _testContext.RunOnDatabaseAsync(async db => + // { + // var workItemInDatabase = await (await db.GetCollection(nameof(WorkItem)) + // .FindAsync(Builders.Filter.Eq(workItem => workItem.Id, existingWorkItem.Id))) + // .FirstAsync(); + + // workItemInDatabase.Description.Should().Be(newDescription); + // workItemInDatabase.DueAt.Should().BeNull(); + // workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); + // }); + // } + + // [Fact] + // public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() + // { + // // Arrange + // var existingWorkItem = _fakers.WorkItem.Generate(); + // existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(existingWorkItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var requestBody = new + // { + // data = new + // { + // type = "workItems", + // id = existingWorkItem.StringId + // } + // }; + + // 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.Relationships.Should().NotBeEmpty(); + // responseDocument.SingleData.Relationships.Values.Should().OnlyContain(relationshipEntry => relationshipEntry.Data == null); + + // responseDocument.Included.Should().BeNull(); + // } + + [Fact] + public async Task Cannot_update_resource_for_missing_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = string.Empty; + + 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("Missing request body."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_for_unknown_type() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "doesNotExist", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource type 'doesNotExist' does not exist. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_for_missing_ID() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "workItems" + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element."); + responseDocument.Errors[0].Detail.Should().StartWith("Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_type_in_url() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId + } + }; + + var route = "/doesNotExist/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Cannot_update_resource_on_unknown_resource_ID_in_url() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + id = "5f88857c4aa60defec6a4999" + } + }; + + var route = "/workItems/5f88857c4aa60defec6a4999"; + + // 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 '5f88857c4aa60defec6a4999' does not exist."); + } + + [Fact] + public async Task Cannot_update_on_resource_type_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingWorkItem.StringId + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(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("Resource type mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource of type 'workItems' in PATCH request body at endpoint '/workItems/{existingWorkItem.StringId}', instead of 'rgbColors'."); + } + + [Fact] + public async Task Cannot_update_on_resource_ID_mismatch_between_url_and_body() + { + // Arrange + var existingWorkItems = _fakers.WorkItem.Generate(2); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertManyAsync(existingWorkItems)); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItems[0].StringId + } + }; + + var route = "/workItems/" + existingWorkItems[1].StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(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("Resource ID mismatch between request body and endpoint URL."); + responseDocument.Errors[0].Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint '/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); + } + + [Fact] + public async Task Cannot_update_resource_attribute_with_blocked_capability() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().StartWith("Changing the value of 'concurrencyToken' is not allowed. - Request body:"); + } + + // [Fact] + // public async Task Cannot_update_resource_with_readonly_attribute() + // { + // // Arrange + // var existingWorkItem = _fakers.WorkItem.Generate(); + // + // await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + // + // var requestBody = new + // { + // data = new + // { + // type = "workItemGroups", + // id = existingWorkItem.StringId, + // attributes = new + // { + // concurrencyToken = "274E1D9A-91BE-4A42-B648-CA75E8B2945E" + // } + // } + // }; + // + // var route = "/workItemGroups/" + existingWorkItem.StringId; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // + // responseDocument.Errors.Should().HaveCount(1); + // responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + // responseDocument.Errors[0].Detail.Should().StartWith("Attribute 'concurrencyToken' is read-only. - Request body:"); + // } + + [Fact] + public async Task Cannot_update_resource_for_broken_JSON_request_body() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = "{ \"data\" {"; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Invalid character after parsing"); + } + + [Fact] + public async Task Cannot_change_ID_of_existing_resource() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + id = existingWorkItem.Id + 123456 + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); + responseDocument.Errors[0].Detail.Should().StartWith("Resource ID is read-only. - Request body: <<"); + } + + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + var existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection(nameof(WorkItem)).InsertOneAsync(existingWorkItem)); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + attributes = new + { + dueAt = "not-a-valid-time" + } + } + }; + + var route = "/workItems/" + existingWorkItem.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'Nullable`1'. - Request body: <<"); + } + + // [Fact] + // public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() + // { + // // Arrange + // var existingWorkItem = _fakers.WorkItem.Generate(); + // existingWorkItem.Assignee = _fakers.UserAccount.Generate(); + // existingWorkItem.Subscribers = _fakers.UserAccount.Generate(1).ToHashSet(); + // existingWorkItem.WorkItemTags = new List + // { + // new WorkItemTag + // { + // Tag = _fakers.WorkTag.Generate() + // } + // }; + + // var existingUserAccounts = _fakers.UserAccount.Generate(2); + // var existingTag = _fakers.WorkTag.Generate(); + + // var newDescription = _fakers.WorkItem.Generate().Description; + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.AddRange(existingWorkItem, existingTag); + // dbContext.UserAccounts.AddRange(existingUserAccounts); + // await dbContext.SaveChangesAsync(); + // }); + + // var requestBody = new + // { + // data = new + // { + // type = "workItems", + // id = existingWorkItem.StringId, + // attributes = new + // { + // description = newDescription + // }, + // relationships = new + // { + // assignee = new + // { + // data = new + // { + // type = "userAccounts", + // id = existingUserAccounts[0].StringId + // } + // }, + // subscribers = new + // { + // data = new[] + // { + // new + // { + // type = "userAccounts", + // id = existingUserAccounts[1].StringId + // } + // } + // }, + // tags = new + // { + // data = new[] + // { + // new + // { + // type = "workTags", + // id = existingTag.StringId + // } + // } + // } + // } + // } + // }; + + // 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.Attributes["description"].Should().Be(newDescription); + // responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // var workItemInDatabase = await dbContext.WorkItems + // .Include(workItem => workItem.Assignee) + // .Include(workItem => workItem.Subscribers) + // .Include(workItem => workItem.WorkItemTags) + // .ThenInclude(workItemTag => workItemTag.Tag) + // .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + // workItemInDatabase.Description.Should().Be(newDescription); + + // workItemInDatabase.Assignee.Should().NotBeNull(); + // workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); + + // workItemInDatabase.Subscribers.Should().HaveCount(1); + // workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); + + // workItemInDatabase.WorkItemTags.Should().HaveCount(1); + // workItemInDatabase.WorkItemTags.Single().Tag.Id.Should().Be(existingTag.Id); + // }); + // } + + // [Fact] + // public async Task Can_update_resource_with_multiple_cyclic_relationship_types() + // { + // // Arrange + // var existingWorkItem = _fakers.WorkItem.Generate(); + // existingWorkItem.Parent = _fakers.WorkItem.Generate(); + // existingWorkItem.Children = _fakers.WorkItem.Generate(1); + // existingWorkItem.RelatedToItems = new List + // { + // new WorkItemToWorkItem + // { + // ToItem = _fakers.WorkItem.Generate() + // } + // }; + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.WorkItems.Add(existingWorkItem); + // await dbContext.SaveChangesAsync(); + // }); + + // var requestBody = new + // { + // data = new + // { + // type = "workItems", + // id = existingWorkItem.StringId, + // relationships = new + // { + // parent = new + // { + // data = new + // { + // type = "workItems", + // id = existingWorkItem.StringId + // } + // }, + // children = new + // { + // data = new[] + // { + // new + // { + // type = "workItems", + // id = existingWorkItem.StringId + // } + // } + // }, + // relatedTo = new + // { + // data = new[] + // { + // new + // { + // type = "workItems", + // id = existingWorkItem.StringId + // } + // } + // } + // } + // } + // }; + + // var route = "/workItems/" + existingWorkItem.StringId; + + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // responseDocument.SingleData.Should().NotBeNull(); + + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // var workItemInDatabase = await dbContext.WorkItems + // .Include(workItem => workItem.Parent) + // .Include(workItem => workItem.Children) + // .Include(workItem => workItem.RelatedToItems) + // .ThenInclude(workItemToWorkItem => workItemToWorkItem.ToItem) + // .FirstAsync(workItem => workItem.Id == existingWorkItem.Id); + + // workItemInDatabase.Parent.Should().NotBeNull(); + // workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); + + // workItemInDatabase.Children.Should().HaveCount(1); + // workItemInDatabase.Children.Single().Id.Should().Be(existingWorkItem.Id); + + // workItemInDatabase.RelatedToItems.Should().HaveCount(1); + // workItemInDatabase.RelatedToItems.Single().ToItem.Id.Should().Be(existingWorkItem.Id); + // }); + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/UserAccount.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/UserAccount.cs new file mode 100644 index 0000000..4b72a9c --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/UserAccount.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class UserAccount : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string FirstName { get; set; } + + [Attr] + public string LastName { get; set; } + + // [HasMany] + // public ISet AssignedItems { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/UserAccountsController.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/UserAccountsController.cs new file mode 100644 index 0000000..cce8bf3 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/UserAccountsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class UserAccountsController : JsonApiController + { + public UserAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItem.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItem.cs new file mode 100644 index 0000000..1a3ea9a --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItem.cs @@ -0,0 +1,66 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkItem : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [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 { } + } + + // [HasOne] + // public UserAccount Assignee { get; set; } + + // [HasMany] + // public ISet Subscribers { get; set; } + + // [NotMapped] + // [HasManyThrough(nameof(WorkItemTags))] + // public ISet Tags { get; set; } + // public ICollection WorkItemTags { get; set; } + + // [HasOne] + // public WorkItem Parent { get; set; } + + // [HasMany] + // public IList Children { get; set; } + + // [NotMapped] + // [HasManyThrough(nameof(RelatedFromItems), LeftPropertyName = nameof(WorkItemToWorkItem.ToItem), RightPropertyName = nameof(WorkItemToWorkItem.FromItem))] + // public IList RelatedFrom { get; set; } + // public IList RelatedFromItems { get; set; } + + // [NotMapped] + // [HasManyThrough(nameof(RelatedToItems), LeftPropertyName = nameof(WorkItemToWorkItem.FromItem), RightPropertyName = nameof(WorkItemToWorkItem.ToItem))] + // public IList RelatedTo { get; set; } + // public IList RelatedToItems { get; set; } + + // [HasOne] + // public WorkItemGroup Group { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemGroup.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemGroup.cs new file mode 100644 index 0000000..a8c06c0 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemGroup.cs @@ -0,0 +1,34 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemGroup : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Name { get; set; } + + [Attr] + public bool IsPublic { get; set; } + + // [NotMapped] + // [Attr] + // public Guid ConcurrencyToken => Guid.NewGuid(); + + // [HasOne] + // public RgbColor Color { get; set; } + + // [HasMany] + // public IList Items { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs new file mode 100644 index 0000000..6466ff9 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemGroupsController : JsonApiController + { + public WorkItemGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemPriority.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemPriority.cs new file mode 100644 index 0000000..cfce1a8 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemPriority.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public enum WorkItemPriority + { + Low, + Medium, + High + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemTag.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemTag.cs new file mode 100644 index 0000000..3e35242 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemTag.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemTag + { + public WorkItem Item { get; set; } + public int ItemId { get; set; } + + public WorkTag Tag { get; set; } + public int TagId { get; set; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs new file mode 100644 index 0000000..2470694 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemToWorkItem + { + public WorkItem FromItem { get; set; } + public int FromItemId { get; set; } + + public WorkItem ToItem { get; set; } + public int ToItemId { get; set; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemsController.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemsController.cs new file mode 100644 index 0000000..4032785 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkItemsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkItemsController : JsonApiController + { + public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkTag.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkTag.cs new file mode 100644 index 0000000..f296de7 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WorkTag.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + public sealed class WorkTag : IIdentifiable + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [Attr] + public string Id { get; set; } + + [Attr] + public string Text { get; set; } + + [Attr] + public bool IsBuiltIn { get; set; } + + [BsonIgnore] + public string StringId { get => Id; set => Id = value; } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WriteFakers.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WriteFakers.cs new file mode 100644 index 0000000..835f8d2 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/ReadWrite/WriteFakers.cs @@ -0,0 +1,44 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.ReadWrite +{ + internal sealed class WriteFakers : 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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Sorting/SortTests.cs new file mode 100644 index 0000000..1a66bad --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/Sorting/SortTests.cs @@ -0,0 +1,739 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.MongoDb.Example.Models; +using MongoDB.Driver; +using Xunit; +using Person = JsonApiDotNetCore.MongoDb.Example.Models.Person; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.Sorting +{ + public sealed class SortTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + // private readonly Faker
_articleFaker; + // private readonly Faker _authorFaker; + + public SortTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + // _articleFaker = new Faker
() + // .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)); + // + // _authorFaker = new Faker() + // .RuleFor(a => a.LastName, f => f.Random.Words(2)); + } + + [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 => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.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_in_single_primary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection
(nameof(Article)).InsertOneAsync(article)); + + var route = $"/api/v1/articles/{article.StringId}?sort=id"; + + // 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 sort 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("sort"); + } + + // [Fact] + // public async Task Can_sort_in_secondary_resources() + // { + // // Arrange + // var blog = new Blog + // { + // Articles = new List
+ // { + // new Article {Caption = "B"}, + // new Article {Caption = "A"}, + // new Article {Caption = "C"} + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}/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(blog.Articles[1].StringId); + // responseDocument.ManyData[1].Id.Should().Be(blog.Articles[0].StringId); + // responseDocument.ManyData[2].Id.Should().Be(blog.Articles[2].StringId); + // } + + [Fact] + public async Task Cannot_sort_in_single_secondary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async db => await db.GetCollection
(nameof(Article)).InsertOneAsync(article)); + + var route = $"/api/v1/articles/{article.StringId}/author?sort=id"; + + // 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 sort 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("sort"); + } + + // [Fact] + // public async Task Can_sort_on_HasMany_relationship() + // { + // // Arrange + // var blogs = new List + // { + // new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "A" + // }, + // new Article + // { + // Caption = "B" + // } + // } + // }, + // new Blog + // { + // Articles = new List
+ // { + // new Article + // { + // Caption = "C" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?sort=count(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(blogs[1].StringId); + // responseDocument.ManyData[1].Id.Should().Be(blogs[0].StringId); + // } + + // [Fact] + // public async Task Can_sort_on_HasManyThrough_relationship() + // { + // // Arrange + // var articles = new List
+ // { + // new Article + // { + // Caption = "First", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "A" + // } + // } + // } + // }, + // new Article + // { + // Caption = "Second", + // ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "B" + // } + // }, + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "C" + // } + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/articles?sort=-count(tags)"; + // + // // 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[1].StringId); + // responseDocument.ManyData[1].Id.Should().Be(articles[0].StringId); + // } + + // [Fact] + // public async Task Can_sort_in_scope_of_HasMany_relationship() + // { + // // Arrange + // var author = _authorFaker.Generate(); + // author.Articles = new List
+ // { + // new Article {Caption = "B"}, + // new Article {Caption = "A"}, + // new Article {Caption = "C"} + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.AuthorDifferentDbContextName.Add(author); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/authors/{author.StringId}?include=articles&sort[articles]=caption"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Id.Should().Be(author.StringId); + // + // responseDocument.Included.Should().HaveCount(3); + // responseDocument.Included[0].Id.Should().Be(author.Articles[1].StringId); + // responseDocument.Included[1].Id.Should().Be(author.Articles[0].StringId); + // responseDocument.Included[2].Id.Should().Be(author.Articles[2].StringId); + // } + + // [Fact] + // public async Task Can_sort_in_scope_of_HasMany_relationship_on_secondary_resource() + // { + // // Arrange + // var blog = new Blog + // { + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article {Caption = "B"}, + // new Article {Caption = "A"}, + // new Article {Caption = "C"} + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&sort[articles]=caption"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); + // + // responseDocument.Included.Should().HaveCount(3); + // responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[1].StringId); + // responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + // responseDocument.Included[2].Id.Should().Be(blog.Owner.Articles[2].StringId); + // } + + // [Fact] + // public async Task Can_sort_in_scope_of_HasManyThrough_relationship() + // { + // // Arrange + // var article = _articleFaker.Generate(); + // article.ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "B" + // } + // }, + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "A" + // } + // }, + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "C" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}?include=tags&sort[tags]=name"; + // + // // 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.Included.Should().HaveCount(3); + // responseDocument.Included[0].Id.Should().Be(article.ArticleTags.Skip(1).First().Tag.StringId); + // responseDocument.Included[1].Id.Should().Be(article.ArticleTags.Skip(0).First().Tag.StringId); + // responseDocument.Included[2].Id.Should().Be(article.ArticleTags.Skip(2).First().Tag.StringId); + // } + + // [Fact] + // public async Task Can_sort_on_multiple_fields_in_multiple_scopes() + // { + // // Arrange + // var blogs = new List + // { + // new Blog + // { + // Title = "Z", + // Articles = new List
+ // { + // new Article + // { + // Caption = "B", + // Revisions = new List + // { + // new Revision + // { + // PublishTime = 1.January(2015) + // }, + // new Revision + // { + // PublishTime = 1.January(2014) + // }, + // new Revision + // { + // PublishTime = 1.January(2016) + // } + // } + // }, + // new Article + // { + // Caption = "A", + // Url = "www.some2.com" + // }, + // new Article + // { + // Caption = "A", + // Url = "www.some1.com" + // }, + // new Article + // { + // Caption = "C" + // } + // } + // }, + // new Blog + // { + // Title = "Y" + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?include=articles.revisions&sort=title&sort[articles]=caption,url&sort[articles.revisions]=-publishTime"; + // + // // 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(blogs[1].StringId); + // responseDocument.ManyData[1].Id.Should().Be(blogs[0].StringId); + // + // responseDocument.Included.Should().HaveCount(7); + // + // responseDocument.Included[0].Type.Should().Be("articles"); + // responseDocument.Included[0].Id.Should().Be(blogs[0].Articles[2].StringId); + // + // responseDocument.Included[1].Type.Should().Be("articles"); + // responseDocument.Included[1].Id.Should().Be(blogs[0].Articles[1].StringId); + // + // responseDocument.Included[2].Type.Should().Be("articles"); + // responseDocument.Included[2].Id.Should().Be(blogs[0].Articles[0].StringId); + // + // responseDocument.Included[3].Type.Should().Be("revisions"); + // responseDocument.Included[3].Id.Should().Be(blogs[0].Articles[0].Revisions.Skip(2).First().StringId); + // + // responseDocument.Included[4].Type.Should().Be("revisions"); + // responseDocument.Included[4].Id.Should().Be(blogs[0].Articles[0].Revisions.Skip(0).First().StringId); + // + // responseDocument.Included[5].Type.Should().Be("revisions"); + // responseDocument.Included[5].Id.Should().Be(blogs[0].Articles[0].Revisions.Skip(1).First().StringId); + // + // responseDocument.Included[6].Type.Should().Be("articles"); + // responseDocument.Included[6].Id.Should().Be(blogs[0].Articles[3].StringId); + // } + + // [Fact] + // public async Task Can_sort_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 dbContext => + // { + // await dbContext.ClearTableAsync
(); + // dbContext.Articles.AddRange(articles); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/articles?sort=-author.lastName"; + // + // // 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[1].StringId); + // responseDocument.ManyData[1].Id.Should().Be(articles[0].StringId); + // } + // + // [Fact] + // public async Task Can_sort_in_multiple_scopes() + // { + // // Arrange + // var blogs = new List + // { + // new Blog + // { + // Title = "Cooking" + // }, + // new Blog + // { + // Title = "Technology", + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One" + // }, + // new Article + // { + // Caption = "Two", + // Revisions = new List + // { + // new Revision + // { + // PublishTime = 1.January(2000) + // }, + // new Revision + // { + // PublishTime = 10.January(2010) + // } + // } + // } + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // await dbContext.ClearTableAsync(); + // dbContext.Blogs.AddRange(blogs); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = "/api/v1/blogs?include=owner.articles.revisions&" + + // "sort=-title&" + + // "sort[owner.articles]=-caption&" + + // "sort[owner.articles.revisions]=-publishTime"; + // + // // 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(blogs[1].StringId); + // responseDocument.ManyData[1].Id.Should().Be(blogs[0].StringId); + // + // responseDocument.Included.Should().HaveCount(5); + // responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + // responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Articles[1].StringId); + // responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(1).First().StringId); + // responseDocument.Included[3].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(0).First().StringId); + // responseDocument.Included[4].Id.Should().Be(blogs[1].Owner.Articles[0].StringId); + // } + // + // [Fact] + // public async Task Cannot_sort_in_unknown_scope() + // { + // // Arrange + // var route = "/api/v1/people?sort[doesNotExist]=id"; + // + // // 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 sort is invalid."); + // responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + // responseDocument.Errors[0].Source.Parameter.Should().Be("sort[doesNotExist]"); + // } + // + // [Fact] + // public async Task Cannot_sort_in_unknown_nested_scope() + // { + // // Arrange + // var route = "/api/v1/people?sort[todoItems.doesNotExist]=id"; + // + // // 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 sort is invalid."); + // responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + // responseDocument.Errors[0].Source.Parameter.Should().Be("sort[todoItems.doesNotExist]"); + // } + + [Fact] + public async Task Cannot_sort_on_attribute_with_blocked_capability() + { + // Arrange + var route = "/api/v1/todoItems?sort=achievedDate"; + + // 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("Sorting on the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Sorting on attribute 'achievedDate' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort"); + } + + [Fact] + public async Task Can_sort_descending_by_ID() + { + // Arrange + var persons = new List + { + new Person {LastName = "B"}, + new Person {LastName = "A"}, + new Person {LastName = "A"} + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(Person)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.InsertManyAsync(persons); + }); + + var route = "/api/v1/people?sort=lastName,-id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + persons.Sort((a, b) => a.LastName.CompareTo(b.LastName) + b.Id.CompareTo(a.Id)); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(persons[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(persons[1].StringId); + responseDocument.ManyData[2].Id.Should().Be(persons[2].StringId); + } + + [Fact] + public async Task Sorts_by_ID_if_none_specified() + { + // Arrange + var persons = new List + { + new Person {}, + new Person {}, + new Person {}, + new Person {} + }; + + await _testContext.RunOnDatabaseAsync(async db => + { + var collection = db.GetCollection(nameof(Person)); + await collection.DeleteManyAsync(Builders.Filter.Empty); + await collection.InsertManyAsync(persons); + }); + + var route = "/api/v1/people"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + persons.Sort((a, b) => a.Id.CompareTo(b.Id)); + + responseDocument.ManyData.Should().HaveCount(4); + responseDocument.ManyData[0].Id.Should().Be(persons[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(persons[1].StringId); + responseDocument.ManyData[2].Id.Should().Be(persons[2].StringId); + responseDocument.ManyData[3].Id.Should().Be(persons[3].StringId); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs new file mode 100644 index 0000000..12093c0 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs new file mode 100644 index 0000000..1c5d6a2 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using MongoDB.Driver; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.SparseFieldSets +{ + /// + /// Enables sparse fieldset tests to verify which fields were (not) retrieved from the database. + /// + public sealed class ResultCapturingRepository : MongoEntityRepository + where TResource : class, IIdentifiable + { + private readonly ResourceCaptureStore _captureStore; + + public ResultCapturingRepository( + IMongoDatabase db, + ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, + IResourceFactory resourceFactory, + ResourceCaptureStore captureStore) + : base(db, targetedFields, resourceContextProvider, resourceFactory) + { + _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/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs new file mode 100644 index 0000000..90709ef --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -0,0 +1,808 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.MongoDb.Example.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.IntegrationTests.SparseFieldSets +{ + public sealed class SparseFieldSetTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + // private readonly Faker
_articleFaker; + // private readonly Faker _authorFaker; + private readonly Faker _userFaker; + + public SparseFieldSetTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton(); + + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); + + services.AddScoped, JsonApiResourceService>(); + }); + + // _articleFaker = new Faker
() + // .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)); + // + // _authorFaker = new Faker() + // .RuleFor(a => a.LastName, f => f.Random.Words(2)); + + _userFaker = new Faker() + .RuleFor(u => u.UserName, f => f.Internet.UserName()) + .RuleFor(u => u.Password, f => f.Internet.Password()); + } + + [Fact] + public async Task Can_select_fields_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 => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.InsertOneAsync(article); + }); + + var route = "/api/v1/articles?fields[articles]=caption"; // TODO: once relationships are implemented select author field too + + // 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().HaveCount(1); + // responseDocument.ManyData[0].Relationships["author"].Data.Should().BeNull(); + // responseDocument.ManyData[0].Relationships["author"].Links.Self.Should().NotBeNull(); + // responseDocument.ManyData[0].Relationships["author"].Links.Related.Should().NotBeNull(); + + 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 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 => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.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 Can_select_relationship_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 => + // { + // var collection = db.GetCollection
(nameof(Article)); + // await collection.DeleteManyAsync(Builders
.Filter.Empty); + // await collection.InsertOneAsync(article); + // }); + // + // var route = "/api/v1/articles?fields[articles]=author"; + // + // // 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().BeNull(); + // responseDocument.ManyData[0].Relationships.Should().HaveCount(1); + // responseDocument.ManyData[0].Relationships["author"].Data.Should().BeNull(); + // responseDocument.ManyData[0].Relationships["author"].Links.Self.Should().NotBeNull(); + // responseDocument.ManyData[0].Relationships["author"].Links.Related.Should().NotBeNull(); + // + // var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + // articleCaptured.Caption.Should().BeNull(); + // articleCaptured.Url.Should().BeNull(); + // } + + [Fact] + public async Task Can_select_fields_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
(nameof(Article)).InsertOneAsync(article)); + + var route = $"/api/v1/articles/{article.StringId}?fields[articles]=url"; // TODO: once relationships are implemented select author field too + + // 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().HaveCount(1); + // responseDocument.SingleData.Relationships["author"].Data.Should().BeNull(); + // responseDocument.SingleData.Relationships["author"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["author"].Links.Related.Should().NotBeNull(); + + 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 Can_select_fields_in_secondary_resources() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var blog = new Blog + // { + // Title = "Some", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One", + // Url = "https://one.domain.com" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}/articles?fields[articles]=caption,tags"; + // + // // 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(blog.Articles[0].StringId); + // responseDocument.ManyData[0].Attributes.Should().HaveCount(1); + // responseDocument.ManyData[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); + // responseDocument.ManyData[0].Relationships.Should().HaveCount(1); + // responseDocument.ManyData[0].Relationships["tags"].Data.Should().BeNull(); + // responseDocument.ManyData[0].Relationships["tags"].Links.Self.Should().NotBeNull(); + // responseDocument.ManyData[0].Relationships["tags"].Links.Related.Should().NotBeNull(); + // + // var blogCaptured = (Blog)store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + // blogCaptured.Id.Should().Be(blog.Id); + // blogCaptured.Title.Should().BeNull(); + // + // blogCaptured.Articles.Should().HaveCount(1); + // blogCaptured.Articles[0].Caption.Should().Be(blog.Articles[0].Caption); + // blogCaptured.Articles[0].Url.Should().BeNull(); + // } + + // [Fact] + // public async Task Can_select_fields_of_HasOne_relationship() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var article = _articleFaker.Generate(); + // article.Caption = "Some"; + // article.Author = new Author + // { + // FirstName = "Joe", + // LastName = "Smith", + // BusinessEmail = "nospam@email.com" + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}?include=author&fields[authors]=lastName,businessEmail,livingAddress"; + // + // // 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["caption"].Should().Be(article.Caption); + // responseDocument.SingleData.Relationships["author"].SingleData.Id.Should().Be(article.Author.StringId); + // responseDocument.SingleData.Relationships["author"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["author"].Links.Related.Should().NotBeNull(); + // + // responseDocument.Included.Should().HaveCount(1); + // responseDocument.Included[0].Attributes.Should().HaveCount(2); + // responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); + // responseDocument.Included[0].Attributes["businessEmail"].Should().Be(article.Author.BusinessEmail); + // responseDocument.Included[0].Relationships.Should().HaveCount(1); + // responseDocument.Included[0].Relationships["livingAddress"].Data.Should().BeNull(); + // responseDocument.Included[0].Relationships["livingAddress"].Links.Self.Should().NotBeNull(); + // responseDocument.Included[0].Relationships["livingAddress"].Links.Related.Should().NotBeNull(); + // + // 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.Author.LastName.Should().Be(article.Author.LastName); + // articleCaptured.Author.BusinessEmail.Should().Be(article.Author.BusinessEmail); + // articleCaptured.Author.FirstName.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_select_fields_of_HasMany_relationship() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var author = _authorFaker.Generate(); + // author.LastName = "Smith"; + // author.Articles = new List
+ // { + // new Article + // { + // Caption = "One", + // Url = "https://one.domain.com" + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.AuthorDifferentDbContextName.Add(author); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/authors/{author.StringId}?include=articles&fields[articles]=caption,tags"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Id.Should().Be(author.StringId); + // responseDocument.SingleData.Attributes["lastName"].Should().Be(author.LastName); + // responseDocument.SingleData.Relationships["articles"].ManyData.Should().HaveCount(1); + // responseDocument.SingleData.Relationships["articles"].ManyData[0].Id.Should().Be(author.Articles[0].StringId); + // responseDocument.SingleData.Relationships["articles"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["articles"].Links.Related.Should().NotBeNull(); + // + // responseDocument.Included.Should().HaveCount(1); + // responseDocument.Included[0].Attributes.Should().HaveCount(1); + // responseDocument.Included[0].Attributes["caption"].Should().Be(author.Articles[0].Caption); + // responseDocument.Included[0].Relationships.Should().HaveCount(1); + // responseDocument.Included[0].Relationships["tags"].Data.Should().BeNull(); + // responseDocument.Included[0].Relationships["tags"].Links.Self.Should().NotBeNull(); + // responseDocument.Included[0].Relationships["tags"].Links.Related.Should().NotBeNull(); + // + // var authorCaptured = (Author) store.Resources.Should().ContainSingle(x => x is Author).And.Subject.Single(); + // authorCaptured.Id.Should().Be(author.Id); + // authorCaptured.LastName.Should().Be(author.LastName); + // + // authorCaptured.Articles.Should().HaveCount(1); + // authorCaptured.Articles[0].Caption.Should().Be(author.Articles[0].Caption); + // authorCaptured.Articles[0].Url.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_select_fields_of_HasMany_relationship_on_secondary_resource() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var blog = new Blog + // { + // Owner = new Author + // { + // LastName = "Smith", + // Articles = new List
+ // { + // new Article + // { + // Caption = "One", + // Url = "https://one.domain.com" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&fields[articles]=caption,revisions"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); + // responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); + // responseDocument.SingleData.Relationships["articles"].ManyData.Should().HaveCount(1); + // responseDocument.SingleData.Relationships["articles"].ManyData[0].Id.Should().Be(blog.Owner.Articles[0].StringId); + // responseDocument.SingleData.Relationships["articles"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["articles"].Links.Related.Should().NotBeNull(); + // + // responseDocument.Included.Should().HaveCount(1); + // responseDocument.Included[0].Attributes.Should().HaveCount(1); + // responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + // responseDocument.Included[0].Relationships.Should().HaveCount(1); + // responseDocument.Included[0].Relationships["revisions"].Data.Should().BeNull(); + // responseDocument.Included[0].Relationships["revisions"].Links.Self.Should().NotBeNull(); + // responseDocument.Included[0].Relationships["revisions"].Links.Related.Should().NotBeNull(); + // + // var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + // blogCaptured.Id.Should().Be(blog.Id); + // blogCaptured.Owner.Should().NotBeNull(); + // blogCaptured.Owner.LastName.Should().Be(blog.Owner.LastName); + // + // blogCaptured.Owner.Articles.Should().HaveCount(1); + // blogCaptured.Owner.Articles[0].Caption.Should().Be(blog.Owner.Articles[0].Caption); + // blogCaptured.Owner.Articles[0].Url.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_select_fields_of_HasManyThrough_relationship() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var article = _articleFaker.Generate(); + // article.Caption = "Some"; + // article.ArticleTags = new HashSet + // { + // new ArticleTag + // { + // Tag = new Tag + // { + // Name = "Hot" + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}?include=tags&fields[tags]=color"; + // + // // 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["caption"].Should().Be(article.Caption); + // responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); + // responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(article.ArticleTags.ElementAt(0).Tag.StringId); + // responseDocument.SingleData.Relationships["tags"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["tags"].Links.Related.Should().NotBeNull(); + // + // responseDocument.Included.Should().HaveCount(1); + // responseDocument.Included[0].Attributes.Should().HaveCount(1); + // responseDocument.Included[0].Attributes["color"].Should().Be(article.ArticleTags.Single().Tag.Color.ToString("G")); + // responseDocument.Included[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.ArticleTags.Should().HaveCount(1); + // articleCaptured.ArticleTags.Single().Tag.Color.Should().Be(article.ArticleTags.Single().Tag.Color); + // articleCaptured.ArticleTags.Single().Tag.Name.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_select_attributes_in_multiple_resource_types() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var blog = new Blog + // { + // Title = "Technology", + // CompanyName = "Contoso", + // Owner = new Author + // { + // FirstName = "Jason", + // LastName = "Smith", + // DateOfBirth = 21.November(1999), + // Articles = new List
+ // { + // new Article + // { + // Caption = "One", + // Url = "www.one.com" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields[blogs]=title&fields[authors]=firstName,lastName&fields[articles]=caption"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Id.Should().Be(blog.StringId); + // responseDocument.SingleData.Attributes.Should().HaveCount(1); + // responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); + // responseDocument.SingleData.Relationships.Should().BeNull(); + // + // responseDocument.Included.Should().HaveCount(2); + // + // responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + // responseDocument.Included[0].Attributes.Should().HaveCount(2); + // responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); + // responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); + // responseDocument.Included[0].Relationships.Should().BeNull(); + // + // responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + // responseDocument.Included[1].Attributes.Should().HaveCount(1); + // responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + // responseDocument.Included[1].Relationships.Should().BeNull(); + // + // var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + // blogCaptured.Id.Should().Be(blog.Id); + // blogCaptured.Title.Should().Be(blog.Title); + // blogCaptured.CompanyName.Should().BeNull(); + // + // blogCaptured.Owner.FirstName.Should().Be(blog.Owner.FirstName); + // blogCaptured.Owner.LastName.Should().Be(blog.Owner.LastName); + // blogCaptured.Owner.DateOfBirth.Should().BeNull(); + // + // blogCaptured.Owner.Articles.Should().HaveCount(1); + // blogCaptured.Owner.Articles[0].Caption.Should().Be(blog.Owner.Articles[0].Caption); + // blogCaptured.Owner.Articles[0].Url.Should().BeNull(); + // } + // + // [Fact] + // public async Task Can_select_only_top_level_fields_with_multiple_includes() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var blog = new Blog + // { + // Title = "Technology", + // CompanyName = "Contoso", + // Owner = new Author + // { + // FirstName = "Jason", + // LastName = "Smith", + // DateOfBirth = 21.November(1999), + // Articles = new List
+ // { + // new Article + // { + // Caption = "One", + // Url = "www.one.com" + // } + // } + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Blogs.Add(blog); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields[blogs]=title,owner"; + // + // // Act + // var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + // + // // Assert + // httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // + // responseDocument.SingleData.Should().NotBeNull(); + // responseDocument.SingleData.Id.Should().Be(blog.StringId); + // responseDocument.SingleData.Attributes.Should().HaveCount(1); + // responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); + // responseDocument.SingleData.Relationships.Should().HaveCount(1); + // responseDocument.SingleData.Relationships["owner"].SingleData.Id.Should().Be(blog.Owner.StringId); + // responseDocument.SingleData.Relationships["owner"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["owner"].Links.Related.Should().NotBeNull(); + // + // responseDocument.Included.Should().HaveCount(2); + // + // responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + // responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); + // responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); + // responseDocument.Included[0].Attributes["dateOfBirth"].Should().Be(blog.Owner.DateOfBirth); + // responseDocument.Included[0].Relationships["articles"].ManyData.Should().HaveCount(1); + // responseDocument.Included[0].Relationships["articles"].ManyData[0].Id.Should().Be(blog.Owner.Articles[0].StringId); + // responseDocument.Included[0].Relationships["articles"].Links.Self.Should().NotBeNull(); + // responseDocument.Included[0].Relationships["articles"].Links.Related.Should().NotBeNull(); + // + // responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + // responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + // responseDocument.Included[1].Attributes["url"].Should().Be(blog.Owner.Articles[0].Url); + // responseDocument.Included[1].Relationships["tags"].Data.Should().BeNull(); + // responseDocument.Included[1].Relationships["tags"].Links.Self.Should().NotBeNull(); + // responseDocument.Included[1].Relationships["tags"].Links.Related.Should().NotBeNull(); + // + // var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + // blogCaptured.Id.Should().Be(blog.Id); + // blogCaptured.Title.Should().Be(blog.Title); + // blogCaptured.CompanyName.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 => + { + var collection = db.GetCollection
(nameof(Article)); + await collection.DeleteManyAsync(Builders
.Filter.Empty); + await collection.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 Cannot_select_on_unknown_resource_type() + { + // Arrange + var route = "/api/v1/people?fields[doesNotExist]=id"; + + // 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 fieldset is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields[doesNotExist]"); + } + + [Fact] + public async Task Cannot_select_attribute_with_blocked_capability() + { + // Arrange + var user = _userFaker.Generate(); + + var route = $"/api/v1/users/{user.Id}?fields[users]=password"; + + // 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("Retrieving the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Retrieving the attribute 'password' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields[users]"); + } + + // [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(nameof(TodoItem)).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); + // } + + // [Fact] + // public async Task Can_select_fields_on_resource_type_multiple_times() + // { + // // Arrange + // var store = _testContext.Factory.Services.GetRequiredService(); + // store.Clear(); + // + // var article = new Article + // { + // Caption = "One", + // Url = "https://one.domain.com", + // Author = new Author + // { + // LastName = "Smith" + // } + // }; + // + // await _testContext.RunOnDatabaseAsync(async dbContext => + // { + // dbContext.Articles.Add(article); + // + // await dbContext.SaveChangesAsync(); + // }); + // + // var route = $"/api/v1/articles/{article.StringId}?fields[articles]=url&fields[articles]=caption,url&fields[articles]=caption,author"; + // + // // 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(2); + // responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + // responseDocument.SingleData.Attributes["url"].Should().Be(article.Url); + // responseDocument.SingleData.Relationships.Should().HaveCount(1); + // responseDocument.SingleData.Relationships["author"].Data.Should().BeNull(); + // responseDocument.SingleData.Relationships["author"].Links.Self.Should().NotBeNull(); + // responseDocument.SingleData.Relationships["author"].Links.Related.Should().NotBeNull(); + // + // 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().Be(articleCaptured.Url); + // } + } +} diff --git a/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/TestableStartup.cs b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/TestableStartup.cs new file mode 100644 index 0000000..4a4b586 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/IntegrationTests/TestableStartup.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.MongoDb.Example.Tests.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/JsonApiDotNetCore.MongoDb.Example.Tests/JsonApiDotNetCore.MongoDb.Example.Tests.csproj b/test/JsonApiDotNetCore.MongoDb.Example.Tests/JsonApiDotNetCore.MongoDb.Example.Tests.csproj new file mode 100644 index 0000000..8dcfdd4 --- /dev/null +++ b/test/JsonApiDotNetCore.MongoDb.Example.Tests/JsonApiDotNetCore.MongoDb.Example.Tests.csproj @@ -0,0 +1,29 @@ + + + + $(NetCoreAppVersion) + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/UnitTests/.gitignore b/test/UnitTests/.gitignore deleted file mode 100644 index 0ca27f0..0000000 --- a/test/UnitTests/.gitignore +++ /dev/null @@ -1,234 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -build/ -bld/ -[Bb]in/ -[Oo]bj/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Microsoft Azure ApplicationInsights config file -ApplicationInsights.config - -# Windows Store app package directory -AppPackages/ -BundleArtifacts/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe - -# FAKE - F# Make -.fake/ diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj deleted file mode 100644 index e5ff8f6..0000000 --- a/test/UnitTests/UnitTests.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - netcoreapp2.0 - - false - - - - - - - - - - - - - -