From 0097c7d1b7fc7d6939918cfaf630ebc1efae618b Mon Sep 17 00:00:00 2001 From: Steven Rothwell <45801433+steven-rothwell@users.noreply.github.com> Date: Sat, 21 Oct 2023 10:45:27 -0400 Subject: [PATCH] Version 1.0.0 --- .gitignore | 398 +++ .vscode/launch.json | 36 + .vscode/tasks.json | 41 + CODEOWNERS | 3 + Crud.Api/Attributes/PreventCrudAttribute.cs | 53 + Crud.Api/Attributes/PreventQueryAttribute.cs | 31 + Crud.Api/Constants/Delimiter.cs | 8 + Crud.Api/Constants/ErrorMessage.cs | 17 + Crud.Api/Constants/JsonSerializerOption.cs | 12 + Crud.Api/Constants/Namespace.cs | 7 + Crud.Api/Constants/RegexOption.cs | 7 + Crud.Api/Controllers/BaseApiController.cs | 39 + Crud.Api/Controllers/CrudController.cs | 556 ++++ Crud.Api/Crud.Api.csproj | 14 + Crud.Api/Enums/CrudOperation.cs | 29 + Crud.Api/Extensions/BsonDocumentExtensions.cs | 13 + Crud.Api/Extensions/GenericExtensions.cs | 28 + Crud.Api/Extensions/ObjectExtensions.cs | 54 + Crud.Api/Extensions/PropertyInfoExtensions.cs | 83 + Crud.Api/Extensions/StringExtensions.cs | 93 + Crud.Api/Extensions/TypeExtensions.cs | 52 + Crud.Api/Helpers/ReflectionHelper.cs | 16 + Crud.Api/Models/Address.cs | 13 + Crud.Api/Models/ExternalEntity.cs | 11 + Crud.Api/Models/IExternalEntity.cs | 7 + Crud.Api/Models/User.cs | 21 + Crud.Api/Options/ApplicationOptions.cs | 11 + Crud.Api/Options/MongoDbOptions.cs | 8 + Crud.Api/Preservers/IPreserver.cs | 20 + Crud.Api/Preservers/MongoDb/Preserver.cs | 218 ++ Crud.Api/Program.cs | 68 + Crud.Api/Properties/launchSettings.json | 31 + Crud.Api/QueryModels/Condition.cs | 34 + Crud.Api/QueryModels/GroupedCondition.cs | 17 + Crud.Api/QueryModels/Operator.cs | 75 + Crud.Api/QueryModels/Query.cs | 35 + Crud.Api/QueryModels/Sort.cs | 17 + Crud.Api/Results/MessageResult.cs | 21 + Crud.Api/Results/ValidationResult.cs | 21 + Crud.Api/Services/IMongoDbService.cs | 23 + Crud.Api/Services/IPostprocessingService.cs | 21 + Crud.Api/Services/IPreprocessingService.cs | 21 + Crud.Api/Services/IQueryCollectionService.cs | 7 + Crud.Api/Services/IStreamService.cs | 9 + Crud.Api/Services/ITypeService.cs | 7 + Crud.Api/Services/MongoDbService.cs | 320 ++ Crud.Api/Services/PostprocessingService.cs | 69 + Crud.Api/Services/PreprocessingService.cs | 69 + Crud.Api/Services/QueryCollectionService.cs | 12 + Crud.Api/Services/StreamService.cs | 17 + Crud.Api/Services/TypeService.cs | 26 + Crud.Api/Validators/IValidator.cs | 23 + Crud.Api/Validators/Validator.cs | 356 ++ Crud.Api/appsettings.Development.json | 19 + Crud.Api/appsettings.json | 20 + .../Attributes/PreventCrudAttributeTests.cs | 115 + .../Attributes/PreventQueryAttributeTests.cs | 41 + .../Controllers/BaseApiControllerTests.cs | 57 + .../Controllers/CrudControllerTests.cs | 2026 ++++++++++++ .../Crud.Api.Tests/Crud.Api.Tests.csproj | 31 + .../Extensions/BsonDocumentExtensionsTests.cs | 29 + .../Extensions/GenericExtensionsTests.cs | 80 + .../Extensions/ObjectExtensionsTests.cs | 229 ++ .../Extensions/PropertyInfoExtensionsTests.cs | 313 ++ .../Extensions/StringExtensionsTests.cs | 202 ++ .../Extensions/TypeExtensionsTests.cs | 134 + .../Helpers/RelectionHelperTests.cs | 54 + .../Services/MongoDbServiceTests.cs | 1437 +++++++++ .../Services/TypeServiceTests.cs | 54 + .../Crud.Api.Tests/TestingModels/Model.cs | 13 + Crud.Tests/Crud.Api.Tests/Usings.cs | 1 + .../Validators/ValidatorTests.cs | 961 ++++++ Crud.sln | 33 + LICENSE | 21 + MetricsInstructions.txt | 3 + MetricsResults.txt | 14 + Postman/Crud.postman_collection.json | 2852 +++++++++++++++++ Postman/Crud_Local.postman_environment.json | 74 + README.md | 530 +++ docs/CONTRIBUTING.md | 34 + docs/POSTPROCESSING.md | 17 + docs/PREPROCESSING.md | 17 + docs/PREVENTCRUDATTRIBUTE.md | 17 + docs/PREVENTQUERYATTRIBUTE.md | 23 + docs/release-notes/RELEASE-1.0.0.md | 25 + 85 files changed, 12674 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 CODEOWNERS create mode 100644 Crud.Api/Attributes/PreventCrudAttribute.cs create mode 100644 Crud.Api/Attributes/PreventQueryAttribute.cs create mode 100644 Crud.Api/Constants/Delimiter.cs create mode 100644 Crud.Api/Constants/ErrorMessage.cs create mode 100644 Crud.Api/Constants/JsonSerializerOption.cs create mode 100644 Crud.Api/Constants/Namespace.cs create mode 100644 Crud.Api/Constants/RegexOption.cs create mode 100644 Crud.Api/Controllers/BaseApiController.cs create mode 100644 Crud.Api/Controllers/CrudController.cs create mode 100644 Crud.Api/Crud.Api.csproj create mode 100644 Crud.Api/Enums/CrudOperation.cs create mode 100644 Crud.Api/Extensions/BsonDocumentExtensions.cs create mode 100644 Crud.Api/Extensions/GenericExtensions.cs create mode 100644 Crud.Api/Extensions/ObjectExtensions.cs create mode 100644 Crud.Api/Extensions/PropertyInfoExtensions.cs create mode 100644 Crud.Api/Extensions/StringExtensions.cs create mode 100644 Crud.Api/Extensions/TypeExtensions.cs create mode 100644 Crud.Api/Helpers/ReflectionHelper.cs create mode 100644 Crud.Api/Models/Address.cs create mode 100644 Crud.Api/Models/ExternalEntity.cs create mode 100644 Crud.Api/Models/IExternalEntity.cs create mode 100644 Crud.Api/Models/User.cs create mode 100644 Crud.Api/Options/ApplicationOptions.cs create mode 100644 Crud.Api/Options/MongoDbOptions.cs create mode 100644 Crud.Api/Preservers/IPreserver.cs create mode 100644 Crud.Api/Preservers/MongoDb/Preserver.cs create mode 100644 Crud.Api/Program.cs create mode 100644 Crud.Api/Properties/launchSettings.json create mode 100644 Crud.Api/QueryModels/Condition.cs create mode 100644 Crud.Api/QueryModels/GroupedCondition.cs create mode 100644 Crud.Api/QueryModels/Operator.cs create mode 100644 Crud.Api/QueryModels/Query.cs create mode 100644 Crud.Api/QueryModels/Sort.cs create mode 100644 Crud.Api/Results/MessageResult.cs create mode 100644 Crud.Api/Results/ValidationResult.cs create mode 100644 Crud.Api/Services/IMongoDbService.cs create mode 100644 Crud.Api/Services/IPostprocessingService.cs create mode 100644 Crud.Api/Services/IPreprocessingService.cs create mode 100644 Crud.Api/Services/IQueryCollectionService.cs create mode 100644 Crud.Api/Services/IStreamService.cs create mode 100644 Crud.Api/Services/ITypeService.cs create mode 100644 Crud.Api/Services/MongoDbService.cs create mode 100644 Crud.Api/Services/PostprocessingService.cs create mode 100644 Crud.Api/Services/PreprocessingService.cs create mode 100644 Crud.Api/Services/QueryCollectionService.cs create mode 100644 Crud.Api/Services/StreamService.cs create mode 100644 Crud.Api/Services/TypeService.cs create mode 100644 Crud.Api/Validators/IValidator.cs create mode 100644 Crud.Api/Validators/Validator.cs create mode 100644 Crud.Api/appsettings.Development.json create mode 100644 Crud.Api/appsettings.json create mode 100644 Crud.Tests/Crud.Api.Tests/Attributes/PreventCrudAttributeTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Attributes/PreventQueryAttributeTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Controllers/BaseApiControllerTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Controllers/CrudControllerTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Crud.Api.Tests.csproj create mode 100644 Crud.Tests/Crud.Api.Tests/Extensions/BsonDocumentExtensionsTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Extensions/GenericExtensionsTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Extensions/ObjectExtensionsTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Extensions/PropertyInfoExtensionsTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Extensions/StringExtensionsTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Extensions/TypeExtensionsTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Helpers/RelectionHelperTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Services/MongoDbServiceTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Services/TypeServiceTests.cs create mode 100644 Crud.Tests/Crud.Api.Tests/TestingModels/Model.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Usings.cs create mode 100644 Crud.Tests/Crud.Api.Tests/Validators/ValidatorTests.cs create mode 100644 Crud.sln create mode 100644 LICENSE create mode 100644 MetricsInstructions.txt create mode 100644 MetricsResults.txt create mode 100644 Postman/Crud.postman_collection.json create mode 100644 Postman/Crud_Local.postman_environment.json create mode 100644 README.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/POSTPROCESSING.md create mode 100644 docs/PREPROCESSING.md create mode 100644 docs/PREVENTCRUDATTRIBUTE.md create mode 100644 docs/PREVENTQUERYATTRIBUTE.md create mode 100644 docs/release-notes/RELEASE-1.0.0.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dd4607 --- /dev/null +++ b/.gitignore @@ -0,0 +1,398 @@ +## 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/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.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/ + +# MSTest 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 +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# 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 + +# 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 +coverage*.xml +coverage*.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 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# 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/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# 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 + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c106c8f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // 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 + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/Crud.Api/bin/Debug/net7.0/Crud.Api.dll", + "args": [], + "cwd": "${workspaceFolder}/Crud.Api", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + }, + "launchSettingsProfile": "Crud.Api" + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..593e547 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Crud.Api/Crud.Api.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/Crud.Api/Crud.Api.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/Crud.Api/Crud.Api.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..30832d0 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,3 @@ +# Global ownership + +* @steven-rothwell \ No newline at end of file diff --git a/Crud.Api/Attributes/PreventCrudAttribute.cs b/Crud.Api/Attributes/PreventCrudAttribute.cs new file mode 100644 index 0000000..98a38eb --- /dev/null +++ b/Crud.Api/Attributes/PreventCrudAttribute.cs @@ -0,0 +1,53 @@ +using Crud.Api.Enums; + +namespace Crud.Api.Attributes +{ + /// + /// Decorate a class to prevent CRUD operations. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class PreventCrudAttribute : Attribute + { + private HashSet _preventedCrudOperations; + + private static IReadOnlyDictionary _encompassingCrudOperationLookup = new Dictionary + { + { CrudOperation.ReadWithId, CrudOperation.Read }, + { CrudOperation.ReadWithQueryParams, CrudOperation.Read }, + { CrudOperation.ReadWithQuery, CrudOperation.Read }, + { CrudOperation.ReadCount, CrudOperation.Read }, + { CrudOperation.PartialUpdateWithId, CrudOperation.PartialUpdate }, + { CrudOperation.PartialUpdateWithQueryParams, CrudOperation.PartialUpdate }, + { CrudOperation.DeleteWithId, CrudOperation.Delete }, + { CrudOperation.DeleteWithQueryParams, CrudOperation.Delete }, + { CrudOperation.DeleteWithQuery, CrudOperation.Delete } + }; + + /// + /// Specific CRUD operations to prevent may be specified. If no operations are specified, all CRUD operations are prevented. + /// + /// CRUD operations to be prevented. + public PreventCrudAttribute(params CrudOperation[] crudOperations) + { + _preventedCrudOperations = new HashSet(crudOperations); + } + + public Boolean AllowsCrudOperation(CrudOperation crudOperation) + { + if (_preventedCrudOperations.Count == 0) + return false; + + if (_preventedCrudOperations.Contains(crudOperation)) + return false; + + if (_encompassingCrudOperationLookup.ContainsKey(crudOperation)) + { + var encompassingCrudOperation = _encompassingCrudOperationLookup[crudOperation]; + + return !_preventedCrudOperations.Contains(encompassingCrudOperation); + } + + return true; + } + } +} diff --git a/Crud.Api/Attributes/PreventQueryAttribute.cs b/Crud.Api/Attributes/PreventQueryAttribute.cs new file mode 100644 index 0000000..7bd50d2 --- /dev/null +++ b/Crud.Api/Attributes/PreventQueryAttribute.cs @@ -0,0 +1,31 @@ +namespace Crud.Api.Attributes +{ + /// + /// Decorate a property to prevent Query operators. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public class PreventQueryAttribute : Attribute + { + private HashSet _preventedOperators; + + /// + /// Specific Query operators to prevent may be specified. If no operators are specified, all Query operators are prevented. Suggest using constants. + /// + /// Query operators to be prevented. + public PreventQueryAttribute(params String[] operators) + { + _preventedOperators = new HashSet(operators); + } + + public Boolean AllowsOperator(String @operator) + { + if (_preventedOperators.Count == 0) + return false; + + if (_preventedOperators.Contains(@operator)) + return false; + + return true; + } + } +} diff --git a/Crud.Api/Constants/Delimiter.cs b/Crud.Api/Constants/Delimiter.cs new file mode 100644 index 0000000..05bf34a --- /dev/null +++ b/Crud.Api/Constants/Delimiter.cs @@ -0,0 +1,8 @@ +namespace Crud.Api.Constants +{ + public static class Delimiter + { + public const Char QueryParamChildProperty = '_'; + public const Char MongoDbChildProperty = '.'; + } +} diff --git a/Crud.Api/Constants/ErrorMessage.cs b/Crud.Api/Constants/ErrorMessage.cs new file mode 100644 index 0000000..a1e9145 --- /dev/null +++ b/Crud.Api/Constants/ErrorMessage.cs @@ -0,0 +1,17 @@ +using Crud.Api.QueryModels; + +namespace Crud.Api.Constants +{ + public static class ErrorMessage + { + public const String NotFoundRead = "No matching {0} found."; + public const String NotFoundUpdate = "No matching {0} found to update."; + public const String NotFoundDelete = "No matching {0} found to delete."; + + public const String BadRequestModelType = "No model type found."; + public const String BadRequestBody = "Request body cannot be null or whitespace."; + public const String BadRequestQuery = $"A {nameof(Query)} object could not be created from the request body. Reason: {{0}}"; + + public const String MethodNotAllowedType = "{0} is not allowed on type {1}."; + } +} diff --git a/Crud.Api/Constants/JsonSerializerOption.cs b/Crud.Api/Constants/JsonSerializerOption.cs new file mode 100644 index 0000000..8735226 --- /dev/null +++ b/Crud.Api/Constants/JsonSerializerOption.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace Crud.Api.Constants +{ + public static class JsonSerializerOption + { + public static readonly JsonSerializerOptions Default = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + } +} diff --git a/Crud.Api/Constants/Namespace.cs b/Crud.Api/Constants/Namespace.cs new file mode 100644 index 0000000..f2dd392 --- /dev/null +++ b/Crud.Api/Constants/Namespace.cs @@ -0,0 +1,7 @@ +namespace Crud.Api.Constants +{ + public static class Namespace + { + public const String Models = "Crud.Api.Models"; + } +} diff --git a/Crud.Api/Constants/RegexOption.cs b/Crud.Api/Constants/RegexOption.cs new file mode 100644 index 0000000..8762c9b --- /dev/null +++ b/Crud.Api/Constants/RegexOption.cs @@ -0,0 +1,7 @@ +namespace Crud.Api.Constants +{ + public static class RegexOption + { + public const String CaseInsensitive = "i"; + } +} diff --git a/Crud.Api/Controllers/BaseApiController.cs b/Crud.Api/Controllers/BaseApiController.cs new file mode 100644 index 0000000..5eeb707 --- /dev/null +++ b/Crud.Api/Controllers/BaseApiController.cs @@ -0,0 +1,39 @@ +using Crud.Api.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Crud.Api.Controllers +{ + public abstract class BaseApiController : ControllerBase + { + protected readonly ApplicationOptions _applicationOptions; + + public BaseApiController(IOptions applicationOptions) + { + _applicationOptions = applicationOptions.Value; + } + + protected virtual IActionResult InternalServerError() + { + return StatusCode(StatusCodes.Status500InternalServerError); + } + + protected virtual IActionResult InternalServerError(Exception exception) + { + if (_applicationOptions.ShowExceptions) + return StatusCode(StatusCodes.Status500InternalServerError, exception.ToString()); + + return InternalServerError(); + } + + protected virtual IActionResult InternalServerError(String? message) + { + return StatusCode(StatusCodes.Status500InternalServerError, message); + } + + protected virtual IActionResult MethodNotAllowed(String? message) + { + return StatusCode(StatusCodes.Status405MethodNotAllowed, message); + } + } +} diff --git a/Crud.Api/Controllers/CrudController.cs b/Crud.Api/Controllers/CrudController.cs new file mode 100644 index 0000000..397fc00 --- /dev/null +++ b/Crud.Api/Controllers/CrudController.cs @@ -0,0 +1,556 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Crud.Api.Constants; +using Crud.Api.Enums; +using Crud.Api.Helpers; +using Crud.Api.Options; +using Crud.Api.Preservers; +using Crud.Api.QueryModels; +using Crud.Api.Results; +using Crud.Api.Services; +using Crud.Api.Validators; +using Humanizer; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Crud.Api.Controllers; + +[ApiController] +[Route("api")] +public class CrudController : BaseApiController +{ + private readonly ILogger _logger; + private readonly IValidator _validator; + private readonly IPreserver _preserver; + private readonly IStreamService _streamService; + private readonly ITypeService _typeService; + private readonly IQueryCollectionService _queryCollectionService; + private readonly IPreprocessingService _preprocessingService; + private readonly IPostprocessingService _postprocessingService; + + public CrudController(IOptions applicationOptions, ILogger logger, IValidator validator, IPreserver preserver, IStreamService streamService, ITypeService typeService, IQueryCollectionService queryCollectionService, + IPreprocessingService preprocessingService, IPostprocessingService postprocessingService) + : base(applicationOptions) + { + _logger = logger; + _validator = validator; + _preserver = preserver; + _streamService = streamService; + _typeService = typeService; + _queryCollectionService = queryCollectionService; + _preprocessingService = preprocessingService; + _postprocessingService = postprocessingService; + } + + [Route("{typeName}"), HttpPost] + public async Task CreateAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.Create; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + dynamic? model = JsonSerializer.Deserialize(json, type, JsonSerializerOption.Default); + + var validationResult = (ValidationResult)await _validator.ValidateCreateAsync(model); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessCreateAsync(model); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var createdModel = await _preserver.CreateAsync(model); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessCreateAsync(createdModel); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(createdModel); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error creating with typeName: {typeName}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}/{id:guid}"), HttpGet] + public async Task ReadAsync(String typeName, Guid id) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.ReadWithId; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessReadAsync(model, id); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var readAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.ReadAsync), new Type[] { typeof(Guid) }); + model = await (dynamic)readAsync.Invoke(_preserver, new object[] { id }); + + if (model is null) + return NotFound(String.Format(ErrorMessage.NotFoundRead, typeName)); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessReadAsync(model, id); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(model); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error reading with typeName: {typeName}, id: {id}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}"), HttpGet] + public async Task ReadAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.ReadWithQueryParams; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + var queryParams = _queryCollectionService.ConvertToDictionary(Request.Query); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + var validationResult = (ValidationResult)await _validator.ValidateReadAsync(model!, queryParams); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessReadAsync(model!, queryParams); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var readAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.ReadAsync), new Type[] { typeof(IDictionary) }); + var models = await (dynamic)readAsync.Invoke(_preserver, new object[] { queryParams }); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessReadAsync(models, queryParams); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(models); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error reading with typeName: {typeName}."); + return InternalServerError(ex); + } + } + + [Route("query/{typeName}"), HttpPost] + public async Task QueryReadAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.ReadWithQuery; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + Query? query = null; + string jsonExMessage = $"{nameof(Query)} is null."; + try { query = JsonSerializer.Deserialize(json, typeof(Query), JsonSerializerOption.Default) as Query; } + catch (Exception jsonEx) + { + jsonExMessage = jsonEx.Message; + } + if (query is null) + return BadRequest(String.Format(ErrorMessage.BadRequestQuery, jsonExMessage)); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + if (_applicationOptions.ValidateQuery) + { + var validationResult = (ValidationResult)_validator.ValidateQuery(model!, query); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + } + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessReadAsync(model!, query); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var queryReadAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.QueryReadAsync), new Type[] { typeof(Query) }); + var models = await (dynamic)queryReadAsync.Invoke(_preserver, new object[] { query }); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessReadAsync(models, query); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + if ((query.Includes is not null && query.Includes.Count > 0) || (query.Excludes is not null && query.Excludes.Count > 0)) + { + var modelsWithLessProperties = JsonSerializer.Deserialize(JsonSerializer.Serialize(models, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + + return Ok(modelsWithLessProperties); + } + + return Ok(models); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error query reading with typeName: {typeName}."); + return InternalServerError(ex); + } + } + + [Route("query/{typeName}/count"), HttpPost] + public async Task QueryReadCountAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.ReadCount; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + Query? query = null; + string jsonExMessage = $"{nameof(Query)} is null."; + try { query = JsonSerializer.Deserialize(json, typeof(Query), JsonSerializerOption.Default) as Query; } + catch (Exception jsonEx) + { + jsonExMessage = jsonEx.Message; + } + if (query is null) + return BadRequest(String.Format(ErrorMessage.BadRequestQuery, jsonExMessage)); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + if (_applicationOptions.ValidateQuery) + { + var validationResult = (ValidationResult)_validator.ValidateQuery(model!, query); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + } + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessReadCountAsync(model!, query); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + long count = await _preserver.QueryReadCountAsync(type, query); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessReadCountAsync(model!, query, count); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(count); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error query reading count with typeName: {typeName}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}/{id:guid}"), HttpPut] + public async Task UpdateAsync(String typeName, Guid id) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.Update; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + dynamic? model = JsonSerializer.Deserialize(json, type, JsonSerializerOption.Default); + + var validationResult = (ValidationResult)await _validator.ValidateUpdateAsync(model, id); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessUpdateAsync(model, id); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var updatedModel = await _preserver.UpdateAsync(model, id); + + if (updatedModel is null) + return NotFound(String.Format(ErrorMessage.NotFoundUpdate, typeName)); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessUpdateAsync(updatedModel, id); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(updatedModel); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error updating with typeName: {typeName}, id: {id}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}/{id:guid}"), HttpPatch] + public async Task PartialUpdateAsync(String typeName, Guid id) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.PartialUpdateWithId; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + dynamic? model = JsonSerializer.Deserialize(json, type, JsonSerializerOption.Default); + var propertyValues = JsonSerializer.Deserialize>(json, JsonSerializerOption.Default); + + var validationResult = (ValidationResult)await _validator.ValidatePartialUpdateAsync(model, id, propertyValues?.Keys); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessPartialUpdateAsync(model, id, propertyValues); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var partialUpdateAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.PartialUpdateAsync), new Type[] { typeof(Guid), typeof(IDictionary) }); + var updatedModel = await (dynamic)partialUpdateAsync.Invoke(_preserver, new object[] { id, propertyValues }); + + if (updatedModel is null) + return NotFound(String.Format(ErrorMessage.NotFoundUpdate, typeName)); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessPartialUpdateAsync(updatedModel, id, propertyValues); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(updatedModel); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error partially updating with typeName: {typeName}, id {id}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}"), HttpPatch] + public async Task PartialUpdateAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.PartialUpdateWithQueryParams; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + var queryParams = _queryCollectionService.ConvertToDictionary(Request.Query); + + dynamic? model = JsonSerializer.Deserialize(json, type, JsonSerializerOption.Default); + var propertyValues = JsonSerializer.Deserialize>(json, JsonSerializerOption.Default); + + var validationResult = (ValidationResult)await _validator.ValidatePartialUpdateAsync(model, queryParams, propertyValues?.Keys); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessPartialUpdateAsync(model, queryParams, propertyValues); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var partialUpdateAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.PartialUpdateAsync), new Type[] { typeof(IDictionary), typeof(IDictionary) }); + var updatedCount = await (dynamic)partialUpdateAsync.Invoke(_preserver, new object[] { queryParams, propertyValues }); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessPartialUpdateAsync(model, queryParams, propertyValues, updatedCount); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(updatedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error partially updating with typeName: {typeName}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}/{id:guid}"), HttpDelete] + public async Task DeleteAsync(String typeName, Guid id) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.DeleteWithId; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessDeleteAsync(model, id); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var deleteAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.DeleteAsync), new Type[] { typeof(Guid) }); + var deletedCount = await (dynamic)deleteAsync.Invoke(_preserver, new object[] { id }); + + if (deletedCount == 0) + return NotFound(String.Format(ErrorMessage.NotFoundDelete, typeName)); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessDeleteAsync(model, id, deletedCount); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(deletedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error deleting with typeName: {typeName}, id: {id}."); + return InternalServerError(ex); + } + } + + [Route("{typeName}"), HttpDelete] + public async Task DeleteAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.DeleteWithQueryParams; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + var queryParams = _queryCollectionService.ConvertToDictionary(Request.Query); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + var validationResult = (ValidationResult)await _validator.ValidateDeleteAsync(model!, queryParams); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessDeleteAsync(model, queryParams); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var deleteAsync = ReflectionHelper.GetGenericMethod(type, typeof(IPreserver), nameof(IPreserver.DeleteAsync), new Type[] { typeof(IDictionary) }); + var deletedCount = await (dynamic)deleteAsync.Invoke(_preserver, new object[] { queryParams }); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessDeleteAsync(model, queryParams, deletedCount); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(deletedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error deleting with typeName: {typeName}."); + return InternalServerError(ex); + } + } + + [Route("query/{typeName}"), HttpDelete] + public async Task QueryDeleteAsync(String typeName) + { + try + { + var type = _typeService.GetModelType(typeName); + if (type is null) + return BadRequest(ErrorMessage.BadRequestModelType); + + var crudOperation = CrudOperation.DeleteWithQuery; + if (!type.AllowsCrudOperation(crudOperation)) + return MethodNotAllowed(String.Format(ErrorMessage.MethodNotAllowedType, crudOperation.ToString().Humanize(), type.Name)); + + string json = await _streamService.ReadToEndThenDisposeAsync(Request.Body, Encoding.UTF8); + if (String.IsNullOrWhiteSpace(json)) + return BadRequest(ErrorMessage.BadRequestBody); + + Query? query = null; + string jsonExMessage = $"{nameof(Query)} is null."; + try { query = JsonSerializer.Deserialize(json, typeof(Query), JsonSerializerOption.Default) as Query; } + catch (Exception jsonEx) + { + jsonExMessage = jsonEx.Message; + } + if (query is null) + return BadRequest(String.Format(ErrorMessage.BadRequestQuery, jsonExMessage)); + + dynamic model = Convert.ChangeType(Activator.CreateInstance(type, null), type)!; + + if (_applicationOptions.ValidateQuery) + { + var validationResult = (ValidationResult)_validator.ValidateQuery(model!, query); + if (!validationResult.IsValid) + return BadRequest(validationResult.Message); + } + + var preprocessingMessageResult = (MessageResult)await _preprocessingService.PreprocessDeleteAsync(model, query); + if (!preprocessingMessageResult.IsSuccessful) + return InternalServerError(preprocessingMessageResult.Message); + + var deletedCount = await _preserver.QueryDeleteAsync(type, query); + + var postprocessingMessageResult = (MessageResult)await _postprocessingService.PostprocessDeleteAsync(model, query, deletedCount); + if (!postprocessingMessageResult.IsSuccessful) + return InternalServerError(postprocessingMessageResult.Message); + + return Ok(deletedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error deleting with typeName: {typeName}."); + return InternalServerError(ex); + } + } +} diff --git a/Crud.Api/Crud.Api.csproj b/Crud.Api/Crud.Api.csproj new file mode 100644 index 0000000..3ae791c --- /dev/null +++ b/Crud.Api/Crud.Api.csproj @@ -0,0 +1,14 @@ + + + net7.0 + enable + enable + + + + + + + + + \ No newline at end of file diff --git a/Crud.Api/Enums/CrudOperation.cs b/Crud.Api/Enums/CrudOperation.cs new file mode 100644 index 0000000..0d63979 --- /dev/null +++ b/Crud.Api/Enums/CrudOperation.cs @@ -0,0 +1,29 @@ +namespace Crud.Api.Enums +{ + public enum CrudOperation + { + Create = 0, + /// + /// Encompasses all Read operations. + /// + Read, + ReadWithId, + ReadWithQueryParams, + ReadWithQuery, + ReadCount, + Update, + /// + /// Encompasses all Partial Update operations. + /// + PartialUpdate, + PartialUpdateWithId, + PartialUpdateWithQueryParams, + /// + /// Encompasses all Delete operations. + /// + Delete, + DeleteWithId, + DeleteWithQueryParams, + DeleteWithQuery + } +} diff --git a/Crud.Api/Extensions/BsonDocumentExtensions.cs b/Crud.Api/Extensions/BsonDocumentExtensions.cs new file mode 100644 index 0000000..9a93518 --- /dev/null +++ b/Crud.Api/Extensions/BsonDocumentExtensions.cs @@ -0,0 +1,13 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; + +namespace Crud.Api +{ + public static class BsonDocumentExtensions + { + public static T? FromBsonDocument(this BsonDocument? bsonDocument) + { + return bsonDocument is null ? default : BsonSerializer.Deserialize(bsonDocument); + } + } +} diff --git a/Crud.Api/Extensions/GenericExtensions.cs b/Crud.Api/Extensions/GenericExtensions.cs new file mode 100644 index 0000000..9b0fce7 --- /dev/null +++ b/Crud.Api/Extensions/GenericExtensions.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Crud.Api +{ + public static class GenericExtensions + { + public static TableAttribute? GetTableAttribute(this T t) + { + if (t is null) + return null; + + return Attribute.GetCustomAttribute(t.GetType(), typeof(TableAttribute)) as TableAttribute; + } + + public static String? GetTableName(this T t) + { + if (t is null) + return null; + + var tableAttribute = t.GetTableAttribute(); + + if (tableAttribute is not null) + return tableAttribute.Name; + + return t.GetType().GetPluralizedName(); + } + } +} diff --git a/Crud.Api/Extensions/ObjectExtensions.cs b/Crud.Api/Extensions/ObjectExtensions.cs new file mode 100644 index 0000000..7a43fc4 --- /dev/null +++ b/Crud.Api/Extensions/ObjectExtensions.cs @@ -0,0 +1,54 @@ +using Crud.Api.Results; +using DataAnnotations = System.ComponentModel.DataAnnotations; + +namespace Crud.Api +{ + public static class ObjectExtensions + { + public static ValidationResult ValidateDataAnnotations(this Object? model, Boolean validateChildModels = true, IReadOnlyCollection? propertiesToValidate = null, Char childPropertyNameDelimiter = default) + { + if (model is null) + throw new ArgumentNullException(nameof(model)); + + var results = new List(); + var validationContext = new DataAnnotations.ValidationContext(model); + if (!DataAnnotations.Validator.TryValidateObject(model, validationContext, results)) + { + if (propertiesToValidate is null) + { + return new ValidationResult(false, results.First().ErrorMessage); + } + else + { + var firstResult = results.Where(result => result.MemberNames.Any(memberName => propertiesToValidate.Contains(memberName))).FirstOrDefault(); + if (firstResult is not null) + return new ValidationResult(false, firstResult.ErrorMessage); + } + } + + if (validateChildModels) + { + var classPropertyValues = model.GetClassPropertyValues(); + if (classPropertyValues is not null) + { + foreach (var classPropertyValue in classPropertyValues) + { + if (classPropertyValue is null) + continue; + + var validationResult = classPropertyValue.ValidateDataAnnotations(validateChildModels, propertiesToValidate?.Select(propertyToValidate => propertyToValidate.GetValueAfterFirstDelimiter(childPropertyNameDelimiter)).ToList()); + if (!validationResult.IsValid) + return validationResult; + } + } + } + + return new ValidationResult(true); + } + + public static IEnumerable? GetClassPropertyValues(this Object model) + { + return model.GetType().GetProperties().Where(property => property.PropertyType.IsClass && property.PropertyType != typeof(string))?.Select(property => property.GetValue(model, null)); + } + } +} diff --git a/Crud.Api/Extensions/PropertyInfoExtensions.cs b/Crud.Api/Extensions/PropertyInfoExtensions.cs new file mode 100644 index 0000000..69ff638 --- /dev/null +++ b/Crud.Api/Extensions/PropertyInfoExtensions.cs @@ -0,0 +1,83 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Crud.Api +{ + public static class PropertyInfoExtensions + { + public static PropertyInfo? GetProperty(this PropertyInfo[]? properties, String? propertyName, Char childPropertyDelimiter = default, StringComparison stringComparison = StringComparison.OrdinalIgnoreCase) + { + if (properties is null) + return null; + + if (String.IsNullOrWhiteSpace(propertyName)) + return null; + + int childPropertyDelimiterIndex = -1; + if (childPropertyDelimiter != default) + { + childPropertyDelimiterIndex = propertyName.IndexOf(childPropertyDelimiter, stringComparison); + + if (childPropertyDelimiterIndex == 0) + throw new ArgumentException($"{nameof(propertyName)} cannot begin with {childPropertyDelimiter}."); + } + + if (childPropertyDelimiterIndex == -1) + { + return properties.FirstOrDefault(property => property.Name.Equals(propertyName, stringComparison) || property.MatchesAlias(propertyName, stringComparison)); + } + else + { + string parentPropertyName = propertyName.Substring(0, childPropertyDelimiterIndex); + + var childPropertyInfo = properties.FirstOrDefault(property => property.Name.Equals(parentPropertyName, stringComparison) || property.MatchesAlias(propertyName, stringComparison)); + if (childPropertyInfo is null) + return null; + + var childPropertyType = childPropertyInfo.PropertyType; + string nextPropertyName = propertyName.Substring(childPropertyDelimiterIndex + 1); + if (typeof(IEnumerable).IsAssignableFrom(childPropertyType) && childPropertyType.IsGenericType) + { + var tType = childPropertyType.GenericTypeArguments.First(); + return tType.GetProperties().GetProperty(nextPropertyName, childPropertyDelimiter); + } + else if (childPropertyType.IsClass && childPropertyType != typeof(string)) + { + return childPropertyType.GetProperties().GetProperty(nextPropertyName, childPropertyDelimiter); + } + + throw new NotSupportedException($"Retrieving child property info from {parentPropertyName} of type {childPropertyType} is unsupported."); + } + } + + public static Boolean HasAllPropertyNames(this PropertyInfo[] properties, IEnumerable propertyNames, Char childPropertyDelimiter = default) + { + return propertyNames.All(propertyName => properties.GetProperty(propertyName, childPropertyDelimiter) is not null); + } + + public static Boolean HasPropertyName(this PropertyInfo[] properties, String propertyName, Char childPropertyDelimiter = default) + { + return properties.GetProperty(propertyName, childPropertyDelimiter) is not null; + } + + public static Boolean MatchesAlias(this PropertyInfo? propertyInfo, String? propertyName, StringComparison stringComparison = StringComparison.OrdinalIgnoreCase) + { + if (propertyInfo is null) + return false; + + if (String.IsNullOrWhiteSpace(propertyName)) + return false; + + if (Attribute.IsDefined(propertyInfo, typeof(JsonPropertyNameAttribute))) + { + var attribute = propertyInfo.GetCustomAttribute(); + + if (attribute!.Name.Equals(propertyName, stringComparison)) + return true; + } + + return false; + } + } +} diff --git a/Crud.Api/Extensions/StringExtensions.cs b/Crud.Api/Extensions/StringExtensions.cs new file mode 100644 index 0000000..b27acf5 --- /dev/null +++ b/Crud.Api/Extensions/StringExtensions.cs @@ -0,0 +1,93 @@ +using System.Text; +using Humanizer; + +namespace Crud.Api +{ + public static class StringExtensions + { + public static dynamic? ChangeType(this String? value, Type? type) + { + if (value is null) + return null; + + if (type is null) + throw new ArgumentNullException(nameof(type)); + + if (type.IsGenericType && type.GetGenericTypeDefinition().Equals(typeof(Nullable<>))) + { + type = Nullable.GetUnderlyingType(type); + } + + if (type == typeof(Guid)) return Guid.Parse(value); + else return Convert.ChangeType(value, type!); + } + + public static String Pascalize(this String? value, Char delimiter) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + + var subValues = value.Split(delimiter); + + if (subValues.Length == 1) + return value.Pascalize(); + + var valueBuilder = new StringBuilder(); + foreach (var subValue in subValues) + { + valueBuilder.Append(String.Concat(delimiter, subValue.Pascalize())); + } + + return valueBuilder.ToString(1, valueBuilder.Length - 1); + } + + public static String Camelize(this String? value, Char delimiter) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + + var subValues = value.Split(delimiter); + + if (subValues.Length == 1) + return value.Camelize(); + + var valueBuilder = new StringBuilder(); + foreach (var subValue in subValues) + { + valueBuilder.Append(String.Concat(delimiter, subValue.Camelize())); + } + + return valueBuilder.ToString(1, valueBuilder.Length - 1); + } + + public static String GetValueAfterFirstDelimiter(this String? value, Char delimiter) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + + var indexOfFirstDelimiter = value.IndexOf(delimiter); + + if (indexOfFirstDelimiter > -1) + { + return value.Substring(indexOfFirstDelimiter + 1); + } + + return value; + } + + public static String GetValueAfterLastDelimiter(this String? value, Char delimiter) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + + var indexOfLastDelimiter = value.LastIndexOf(delimiter); + + if (indexOfLastDelimiter > -1) + { + return value.Substring(indexOfLastDelimiter + 1); + } + + return value; + } + } +} diff --git a/Crud.Api/Extensions/TypeExtensions.cs b/Crud.Api/Extensions/TypeExtensions.cs new file mode 100644 index 0000000..e6d1664 --- /dev/null +++ b/Crud.Api/Extensions/TypeExtensions.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; +using Crud.Api.Attributes; +using Crud.Api.Enums; +using Humanizer; + +namespace Crud.Api +{ + public static class TypeExtensions + { + public static TableAttribute? GetTableAttribute(this Type? type) + { + if (type is null) + return null; + + return Attribute.GetCustomAttribute(type, typeof(TableAttribute)) as TableAttribute; + } + + public static String? GetTableName(this Type? type) + { + if (type is null) + return null; + + var tableAttribute = type.GetTableAttribute(); + + if (tableAttribute is not null) + return tableAttribute.Name; + + return type.GetPluralizedName(); + } + + public static String? GetPluralizedName(this Type? type) + { + if (type is null) + return null; + + return type.Name.Pluralize(); + } + + public static Boolean AllowsCrudOperation(this Type type, CrudOperation crudOperation) + { + var attribute = type.GetCustomAttribute(); + + if (attribute is not null) + { + return attribute.AllowsCrudOperation(crudOperation); + } + + return true; + } + } +} diff --git a/Crud.Api/Helpers/ReflectionHelper.cs b/Crud.Api/Helpers/ReflectionHelper.cs new file mode 100644 index 0000000..b94d5e3 --- /dev/null +++ b/Crud.Api/Helpers/ReflectionHelper.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Crud.Api.Helpers +{ + public static class ReflectionHelper + { + public static MethodInfo GetGenericMethod(Type t, Type classOfMethod, String methodName, Type[] parameterTypes) + { + var method = classOfMethod.GetMethod(methodName, parameterTypes); + if (method is null) + throw new Exception($"Unable to get method. {methodName} does not exist."); + + return method.MakeGenericMethod(t); + } + } +} diff --git a/Crud.Api/Models/Address.cs b/Crud.Api/Models/Address.cs new file mode 100644 index 0000000..bb19fd4 --- /dev/null +++ b/Crud.Api/Models/Address.cs @@ -0,0 +1,13 @@ +using Crud.Api.Attributes; + +namespace Crud.Api.Models +{ + + [PreventCrud] + public class Address + { + public String? Street { get; set; } + public String? City { get; set; } + public String? State { get; set; } + } +} diff --git a/Crud.Api/Models/ExternalEntity.cs b/Crud.Api/Models/ExternalEntity.cs new file mode 100644 index 0000000..bdcae98 --- /dev/null +++ b/Crud.Api/Models/ExternalEntity.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Crud.Api.Models +{ + public abstract class ExternalEntity : IExternalEntity + { + [JsonPropertyOrder(order: -1)] // Properties without this attribute default to order 0. Must be negative to come before them. + [JsonPropertyName("id")] + public Guid? ExternalId { get; set; } + } +} diff --git a/Crud.Api/Models/IExternalEntity.cs b/Crud.Api/Models/IExternalEntity.cs new file mode 100644 index 0000000..c684302 --- /dev/null +++ b/Crud.Api/Models/IExternalEntity.cs @@ -0,0 +1,7 @@ +namespace Crud.Api.Models +{ + public interface IExternalEntity + { + Guid? ExternalId { get; set; } + } +} diff --git a/Crud.Api/Models/User.cs b/Crud.Api/Models/User.cs new file mode 100644 index 0000000..6a64add --- /dev/null +++ b/Crud.Api/Models/User.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Crud.Api.Attributes; +using Crud.Api.QueryModels; + +namespace Crud.Api.Models +{ + [Table("users")] + public class User : ExternalEntity + { + [Required] + public String? Name { get; set; } + public Address? Address { get; set; } + [Range(0, Int32.MaxValue)] + public Int32? Age { get; set; } + [PreventQuery(Operator.Contains)] + public String? HairColor { get; set; } + public ICollection? FavoriteThings { get; set; } + public ICollection
? FormerAddresses { get; set; } + } +} diff --git a/Crud.Api/Options/ApplicationOptions.cs b/Crud.Api/Options/ApplicationOptions.cs new file mode 100644 index 0000000..1b483a9 --- /dev/null +++ b/Crud.Api/Options/ApplicationOptions.cs @@ -0,0 +1,11 @@ +namespace Crud.Api.Options +{ + public class ApplicationOptions + { + public Boolean ShowExceptions { get; set; } + public Boolean ValidateQuery { get; set; } + public Boolean PreventAllQueryContains { get; set; } + public Boolean PreventAllQueryStartsWith { get; set; } + public Boolean PreventAllQueryEndsWith { get; set; } + } +} diff --git a/Crud.Api/Options/MongoDbOptions.cs b/Crud.Api/Options/MongoDbOptions.cs new file mode 100644 index 0000000..cc28715 --- /dev/null +++ b/Crud.Api/Options/MongoDbOptions.cs @@ -0,0 +1,8 @@ +namespace Crud.Api.Options +{ + public class MongoDbOptions + { + public String ConnectionString { get; set; } = String.Empty; + public String DatabaseName { get; set; } = String.Empty; + } +} diff --git a/Crud.Api/Preservers/IPreserver.cs b/Crud.Api/Preservers/IPreserver.cs new file mode 100644 index 0000000..114b4da --- /dev/null +++ b/Crud.Api/Preservers/IPreserver.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using Crud.Api.QueryModels; + +namespace Crud.Api.Preservers +{ + public interface IPreserver + { + Task CreateAsync(T model); + Task ReadAsync(Guid id); + Task> ReadAsync(IDictionary? queryParams); + Task> QueryReadAsync(Query query); + Task QueryReadCountAsync(Type type, Query query); + Task UpdateAsync(T model, Guid id); + Task PartialUpdateAsync(Guid id, IDictionary propertyValues); + Task PartialUpdateAsync(IDictionary? queryParams, IDictionary propertyValues); + Task DeleteAsync(Guid id); + Task DeleteAsync(IDictionary? queryParams); + Task QueryDeleteAsync(Type type, Query query); + } +} diff --git a/Crud.Api/Preservers/MongoDb/Preserver.cs b/Crud.Api/Preservers/MongoDb/Preserver.cs new file mode 100644 index 0000000..c9a4630 --- /dev/null +++ b/Crud.Api/Preservers/MongoDb/Preserver.cs @@ -0,0 +1,218 @@ +using System.Text.Json; +using Crud.Api.Models; +using Crud.Api.Options; +using Crud.Api.QueryModels; +using Crud.Api.Services; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Crud.Api.Preservers.MongoDb +{ + public class Preserver : IPreserver + { + private readonly MongoDbOptions _mongoDbOptions; + private readonly MongoCollectionSettings _mongoCollectionSettings; + private readonly IMongoDbService _mongoDbService; + + public Preserver(IOptions mongoDbOptions, IMongoDbService mongoDbService) + { + _mongoDbOptions = mongoDbOptions.Value; + _mongoCollectionSettings = new MongoCollectionSettings + { + AssignIdOnInsert = true + }; + _mongoDbService = mongoDbService; + } + + public async Task CreateAsync(T model) + { + if (model is null) + throw new Exception("Cannot create because model is null."); + + if (model is IExternalEntity entity && !entity.ExternalId.HasValue) + { + entity.ExternalId = Guid.NewGuid(); + } + + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + string? tableName = model.GetTableName(); + if (tableName is null) + throw new Exception($"No table name found on {model.GetType().Name}."); + + var collection = database.GetCollection(tableName, _mongoCollectionSettings); + + var bsonDocument = model.ToBsonDocument(); + await collection.InsertOneAsync(bsonDocument); + return bsonDocument.FromBsonDocument()!; + } + + public async Task ReadAsync(Guid id) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetIdFilter(tType, id); + + var bsonDocument = await collection.Find(filter).FirstOrDefaultAsync(); + return bsonDocument.FromBsonDocument(); + } + + public async Task> ReadAsync(IDictionary? queryParams) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetQueryParamFilter(tType, queryParams); + + var models = await collection.FindAsync(filter); + return await models.ToListAsync(); + } + + public async Task> QueryReadAsync(Query query) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetConditionFilter(tType, query.Where); + var sort = _mongoDbService.GetSort(query.OrderBy); + var projections = _mongoDbService.GetProjections(query); + + var models = await collection.FindAsync(filter, new FindOptions + { + Sort = sort, + Limit = query.Limit, + Skip = query.Skip, + Projection = projections + }); + return await models.ToListAsync(); + } + + public async Task QueryReadCountAsync(Type type, Query query) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + string tableName = _mongoDbService.GetTableName(type); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetConditionFilter(type, query.Where); + var sort = _mongoDbService.GetSort(query.OrderBy); + var projections = _mongoDbService.GetProjections(query); + + return await collection.CountDocumentsAsync(filter, new CountOptions + { + Limit = query.Limit, + Skip = query.Skip + }); + } + + public async Task UpdateAsync(T model, Guid id) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName, _mongoCollectionSettings); + var filter = _mongoDbService.GetIdFilter(tType, id); + + var bsonDocument = model.ToBsonDocument(); + return await collection.FindOneAndReplaceAsync(filter, bsonDocument, new FindOneAndReplaceOptions + { + ReturnDocument = ReturnDocument.After + }); + } + + public async Task PartialUpdateAsync(Guid id, IDictionary propertyValues) + { + if (propertyValues is null) + throw new ArgumentNullException(nameof(propertyValues)); + + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName, _mongoCollectionSettings); + var filter = _mongoDbService.GetIdFilter(tType, id); + var updates = _mongoDbService.GetShallowUpdates(propertyValues, tType); // Can utilize GetDeepUpdates instead, if all child objects are guaranteed to be instantiated. + var update = Builders.Update.Combine(updates); + + return await collection.FindOneAndUpdateAsync(filter, update, new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + } + + public async Task PartialUpdateAsync(IDictionary? queryParams, IDictionary propertyValues) + { + if (propertyValues is null) + throw new ArgumentNullException(nameof(propertyValues)); + + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName, _mongoCollectionSettings); + var filter = _mongoDbService.GetQueryParamFilter(tType, queryParams); + var updates = _mongoDbService.GetShallowUpdates(propertyValues, tType); // Can utilize GetDeepUpdates instead, if all child objects are guaranteed to be instantiated. + var update = Builders.Update.Combine(updates); + + var updateResult = await collection.UpdateManyAsync(filter, update); + return updateResult.ModifiedCount; + } + + public async Task DeleteAsync(Guid id) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetIdFilter(tType, id); + + var deleteResult = await collection.DeleteOneAsync(filter); + return deleteResult.DeletedCount; + } + + public async Task DeleteAsync(IDictionary? queryParams) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + var tType = typeof(T); + string tableName = _mongoDbService.GetTableName(tType); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetQueryParamFilter(tType, queryParams); + + var deleteResult = await collection.DeleteManyAsync(filter); + return deleteResult.DeletedCount; + } + + public async Task QueryDeleteAsync(Type type, Query query) + { + var dbClient = new MongoClient(_mongoDbOptions.ConnectionString); + var database = dbClient.GetDatabase(_mongoDbOptions.DatabaseName); + + string tableName = _mongoDbService.GetTableName(type); + var collection = database.GetCollection(tableName); + var filter = _mongoDbService.GetConditionFilter(type, query.Where); + + var deleteResult = await collection.DeleteManyAsync(filter); + return deleteResult.DeletedCount; + } + } +} diff --git a/Crud.Api/Program.cs b/Crud.Api/Program.cs new file mode 100644 index 0000000..5b8d544 --- /dev/null +++ b/Crud.Api/Program.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using Crud.Api.Options; +using Crud.Api.Preservers; +using Crud.Api.Services; +using Crud.Api.Validators; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.Serializers; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var conventionPack = new ConventionPack +{ + new CamelCaseElementNameConvention(), + new IgnoreExtraElementsConvention(true) +}; +ConventionRegistry.Register("conventionPack", conventionPack, t => true); + +// This is necessary for IEnumerable to properly choose the correct GuidRepresentation when dynamic is Guid. +// This is currently a bug in the MongoDB C# driver. This may be removed when fixed. +// https://jira.mongodb.org/browse/CSHARP-4784 +var discriminatorConvention = BsonSerializer.LookupDiscriminatorConvention(typeof(object)); +var objectSerializer = new ObjectSerializer(discriminatorConvention, GuidRepresentation.Standard); +BsonSerializer.RegisterSerializer(objectSerializer); + +BsonDefaults.GuidRepresentationMode = GuidRepresentationMode.V3; +BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + +builder.Services.Configure(builder.Configuration.GetSection(nameof(MongoDbOptions))); +builder.Services.Configure(builder.Configuration.GetSection(nameof(ApplicationOptions))); + +builder.Services.AddControllers().AddJsonOptions(options => +{ + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; +}); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/Crud.Api/Properties/launchSettings.json b/Crud.Api/Properties/launchSettings.json new file mode 100644 index 0000000..0577bec --- /dev/null +++ b/Crud.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:40864", + "sslPort": 44357 + } + }, + "profiles": { + "Crud.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7289;http://localhost:5265", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Crud.Api/QueryModels/Condition.cs b/Crud.Api/QueryModels/Condition.cs new file mode 100644 index 0000000..c735947 --- /dev/null +++ b/Crud.Api/QueryModels/Condition.cs @@ -0,0 +1,34 @@ +namespace Crud.Api.QueryModels +{ + /// + /// Constrains what documents/rows are filtered on in the data store. + /// + public class Condition + { + /// + /// Name of the field/column side being evaluated. + /// Should be null if is populated. + /// + public String? Field { get; set; } + /// + /// The operator used in the evaluation. + /// Should be null if is populated. + /// + public String? ComparisonOperator { get; set; } + /// + /// Value that the will compare the against in the evaluation. + /// Should be null if or is populated. + /// + public String? Value { get; set; } + /// + /// Values that the will compare the against in the evaluation. + /// Should be null if or is populated. + /// + /// + public IReadOnlyCollection? Values { get; set; } + /// + /// Groups of conditions used for complex logic to constrain what documents/rows are filtered on in the data store. + /// + public IReadOnlyCollection? GroupedConditions { get; set; } + } +} diff --git a/Crud.Api/QueryModels/GroupedCondition.cs b/Crud.Api/QueryModels/GroupedCondition.cs new file mode 100644 index 0000000..8a4f0b8 --- /dev/null +++ b/Crud.Api/QueryModels/GroupedCondition.cs @@ -0,0 +1,17 @@ +namespace Crud.Api.QueryModels +{ + /// + /// Groups of conditions used for complex logic to constrain what documents/rows are filtered on in the data store. + /// + public class GroupedCondition + { + /// + /// The operator applied between each condition in . + /// + public String? LogicalOperator { get; set; } + /// + /// All conditions have the same applied between each condition. + /// + public IReadOnlyCollection? Conditions { get; set; } + } +} diff --git a/Crud.Api/QueryModels/Operator.cs b/Crud.Api/QueryModels/Operator.cs new file mode 100644 index 0000000..441aa0a --- /dev/null +++ b/Crud.Api/QueryModels/Operator.cs @@ -0,0 +1,75 @@ +namespace Crud.Api.QueryModels +{ + public static class Operator + { + public const String And = "&&"; + public const String Or = "||"; + public const String Equality = "=="; + public const String Inequality = "!="; + public const String GreaterThan = ">"; + public const String GreaterThanOrEquals = ">="; + public const String LessThan = "<"; + public const String LessThanOrEquals = "<="; + /// + /// If any value in matches any value in . + /// + public const String In = "IN"; + /// + /// If all values in do not match any value in . + /// + public const String NotIn = "NIN"; + /// + /// If all values in match any value in . + /// + public const String All = "ALL"; + /// + /// For use with properties of type . If value in contains the value in . + /// + public const String Contains = "CONTAINS"; + /// + /// For use with properties of type . If value in starts with the value in . + /// + public const String StartsWith = "STARTSWITH"; + /// + /// For use with properties of type . If value in ends with the value in . + /// + public const String EndsWith = "ENDSWITH"; + + public static IReadOnlyDictionary LogicalAliasLookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { And, And }, + { "AND", And }, + { Or, Or }, + { "OR", Or } + }; + + public static IReadOnlyDictionary ComparisonAliasLookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { Equality, Equality }, + { "Equals", Equality }, + { "EQ", Equality }, + { Inequality, Inequality }, + { "NotEquals", Inequality }, + { "NE", Inequality }, + { GreaterThan, GreaterThan }, + { "GreaterThan", GreaterThan }, + { "GT", GreaterThan }, + { GreaterThanOrEquals, GreaterThanOrEquals }, + { "GreaterThanOrEquals", GreaterThanOrEquals }, + { "GTE", GreaterThanOrEquals }, + { LessThan, LessThan }, + { "LessThan", LessThan }, + { "LT", LessThan }, + { LessThanOrEquals, LessThanOrEquals }, + { "LessThanOrEquals", LessThanOrEquals }, + { "LTE", LessThanOrEquals }, + { In, In }, + { NotIn, NotIn }, + { "NotIn", NotIn }, + { All, All }, + { Contains, Contains }, + { StartsWith, StartsWith }, + { EndsWith, EndsWith } + }; + } +} diff --git a/Crud.Api/QueryModels/Query.cs b/Crud.Api/QueryModels/Query.cs new file mode 100644 index 0000000..8992f07 --- /dev/null +++ b/Crud.Api/QueryModels/Query.cs @@ -0,0 +1,35 @@ +namespace Crud.Api.QueryModels +{ + /// + /// Determines what is returned from the data store. + /// + public class Query + { + /// + /// Fields/columns that will be returned from the data store. + /// If this and are null, all fields/columns are returned. + /// + public HashSet? Includes { get; set; } + /// + /// Fields/columns that will not be returned from the data store. + /// If this and are null, all fields/columns are returned. + /// + public HashSet? Excludes { get; set; } + /// + /// Documents/rows that will be returned from the data store. + /// + public Condition? Where { get; set; } + /// + /// In what order the documents/rows will be returned from the data store. + /// + public IReadOnlyCollection? OrderBy { get; set; } + /// + /// Sets the max number of documents/rows that will be returned from the data store. + /// + public Int32? Limit { get; set; } + /// + /// Sets how many documents/rows to skip over. + /// + public Int32? Skip { get; set; } + } +} diff --git a/Crud.Api/QueryModels/Sort.cs b/Crud.Api/QueryModels/Sort.cs new file mode 100644 index 0000000..dab5b7c --- /dev/null +++ b/Crud.Api/QueryModels/Sort.cs @@ -0,0 +1,17 @@ +namespace Crud.Api.QueryModels +{ + /// + /// Describes what and how something will be sorted. + /// + public class Sort + { + /// + /// Name of the field/column being sorted. + /// + public String? Field { get; set; } + /// + /// If the will be in descending order. + /// + public Boolean? IsDescending { get; set; } + } +} diff --git a/Crud.Api/Results/MessageResult.cs b/Crud.Api/Results/MessageResult.cs new file mode 100644 index 0000000..03aad5c --- /dev/null +++ b/Crud.Api/Results/MessageResult.cs @@ -0,0 +1,21 @@ +namespace Crud.Api.Results +{ + public class MessageResult + { + public MessageResult() { } + + public MessageResult(Boolean isSuccessful) + { + IsSuccessful = isSuccessful; + } + + public MessageResult(Boolean isSuccessful, String? message) + { + IsSuccessful = isSuccessful; + Message = message; + } + + public Boolean IsSuccessful { get; set; } + public String? Message { get; set; } + } +} diff --git a/Crud.Api/Results/ValidationResult.cs b/Crud.Api/Results/ValidationResult.cs new file mode 100644 index 0000000..5e28b2a --- /dev/null +++ b/Crud.Api/Results/ValidationResult.cs @@ -0,0 +1,21 @@ +namespace Crud.Api.Results +{ + public class ValidationResult + { + public ValidationResult() { } + + public ValidationResult(Boolean isValid) + { + IsValid = isValid; + } + + public ValidationResult(Boolean isValid, String? message) + { + IsValid = isValid; + Message = message; + } + + public Boolean IsValid { get; set; } + public String? Message { get; set; } + } +} diff --git a/Crud.Api/Services/IMongoDbService.cs b/Crud.Api/Services/IMongoDbService.cs new file mode 100644 index 0000000..da305c9 --- /dev/null +++ b/Crud.Api/Services/IMongoDbService.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using Crud.Api.QueryModels; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Crud.Api.Services +{ + public interface IMongoDbService + { + String GetTableName(Type type); + FilterDefinition GetIdFilter(Type type, Guid id); + FilterDefinition GetQueryParamFilter(Type type, IDictionary? queryParams); + IEnumerable> GetShallowUpdates(IDictionary propertyValues, Type type); + IEnumerable> GetDeepUpdates(IDictionary propertyValues, Type type); + IEnumerable> GetAllPropertiesToUpdate(String propertyName, Type type, JsonElement jsonElement); + FilterDefinition GetConditionFilter(Type type, Condition? condition, String? rootLogicalOperator = Operator.And); + IEnumerable> GetConditionsFilters(Type type, IReadOnlyCollection? groupedConditions); + FilterDefinition GetLogicalOperatorFilter(String logicalOperator, IEnumerable> filters); + FilterDefinition GetComparisonOperatorFilter(String field, String comparisonOperator, dynamic value); + SortDefinition GetSort(IReadOnlyCollection? orderBy); + ProjectionDefinition GetProjections(Query? query); + } +} diff --git a/Crud.Api/Services/IPostprocessingService.cs b/Crud.Api/Services/IPostprocessingService.cs new file mode 100644 index 0000000..fb20b26 --- /dev/null +++ b/Crud.Api/Services/IPostprocessingService.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Crud.Api.QueryModels; +using Crud.Api.Results; + +namespace Crud.Api.Services +{ + public interface IPostprocessingService + { + Task PostprocessCreateAsync(Object createdModel); + Task PostprocessReadAsync(Object model, Guid id); + Task PostprocessReadAsync(IEnumerable models, IDictionary? queryParams); + Task PostprocessReadAsync(IEnumerable models, Query query); + Task PostprocessReadCountAsync(Object model, Query query, Int64 count); + Task PostprocessUpdateAsync(Object updatedModel, Guid id); + Task PostprocessPartialUpdateAsync(Object updatedModel, Guid id, IDictionary propertyValues); + Task PostprocessPartialUpdateAsync(Object model, IDictionary? queryParams, IDictionary propertyValues, Int64 updatedCount); + Task PostprocessDeleteAsync(Object model, Guid id, Int64 deletedCount); + Task PostprocessDeleteAsync(Object model, IDictionary? queryParams, Int64 deletedCount); + Task PostprocessDeleteAsync(Object model, Query query, Int64 deletedCount); + } +} diff --git a/Crud.Api/Services/IPreprocessingService.cs b/Crud.Api/Services/IPreprocessingService.cs new file mode 100644 index 0000000..077dd61 --- /dev/null +++ b/Crud.Api/Services/IPreprocessingService.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Crud.Api.QueryModels; +using Crud.Api.Results; + +namespace Crud.Api.Services +{ + public interface IPreprocessingService + { + Task PreprocessCreateAsync(Object model); + Task PreprocessReadAsync(Object model, Guid id); + Task PreprocessReadAsync(Object model, IDictionary? queryParams); + Task PreprocessReadAsync(Object model, Query query); + Task PreprocessReadCountAsync(Object model, Query query); + Task PreprocessUpdateAsync(Object model, Guid id); + Task PreprocessPartialUpdateAsync(Object model, Guid id, IDictionary propertyValues); + Task PreprocessPartialUpdateAsync(Object model, IDictionary? queryParams, IDictionary propertyValues); + Task PreprocessDeleteAsync(Object model, Guid id); + Task PreprocessDeleteAsync(Object model, IDictionary? queryParams); + Task PreprocessDeleteAsync(Object model, Query query); + } +} diff --git a/Crud.Api/Services/IQueryCollectionService.cs b/Crud.Api/Services/IQueryCollectionService.cs new file mode 100644 index 0000000..d1bef2a --- /dev/null +++ b/Crud.Api/Services/IQueryCollectionService.cs @@ -0,0 +1,7 @@ +namespace Crud.Api.Services +{ + public interface IQueryCollectionService + { + Dictionary ConvertToDictionary(IQueryCollection queryCollection); + } +} diff --git a/Crud.Api/Services/IStreamService.cs b/Crud.Api/Services/IStreamService.cs new file mode 100644 index 0000000..b1f01a5 --- /dev/null +++ b/Crud.Api/Services/IStreamService.cs @@ -0,0 +1,9 @@ +using System.Text; + +namespace Crud.Api.Services +{ + public interface IStreamService + { + Task ReadToEndThenDisposeAsync(Stream stream, Encoding encoding); + } +} diff --git a/Crud.Api/Services/ITypeService.cs b/Crud.Api/Services/ITypeService.cs new file mode 100644 index 0000000..c0fdef9 --- /dev/null +++ b/Crud.Api/Services/ITypeService.cs @@ -0,0 +1,7 @@ +namespace Crud.Api.Services +{ + public interface ITypeService + { + Type? GetModelType(String typeName); + } +} diff --git a/Crud.Api/Services/MongoDbService.cs b/Crud.Api/Services/MongoDbService.cs new file mode 100644 index 0000000..b7ec077 --- /dev/null +++ b/Crud.Api/Services/MongoDbService.cs @@ -0,0 +1,320 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; +using Crud.Api.Constants; +using Crud.Api.Models; +using Crud.Api.QueryModels; +using Humanizer; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Crud.Api.Services +{ + public class MongoDbService : IMongoDbService + { + public String GetTableName(Type? type) + { + string? tableName = type.GetTableName(); + if (tableName is null) + throw new Exception($"No table name found on type {type?.Name}."); + + return tableName; + } + + public FilterDefinition GetIdFilter(Type type, Guid id) + { + FilterDefinition filter; + if (typeof(IExternalEntity).IsAssignableFrom(type)) + filter = Builders.Filter.Eq(nameof(IExternalEntity.ExternalId).Camelize(), id); + else + filter = Builders.Filter.Eq("id", id); + + return filter; + } + + public FilterDefinition GetQueryParamFilter(Type type, IDictionary? queryParams) + { + FilterDefinition filter = new BsonDocument(); + if (queryParams is not null) + { + foreach (var queryParam in queryParams) + { + var propertyInfo = type.GetProperties().GetProperty(queryParam.Key, Delimiter.MongoDbChildProperty); + string key = propertyInfo!.Name.Replace(Delimiter.QueryParamChildProperty, Delimiter.MongoDbChildProperty); + dynamic? value = queryParam.Value.ChangeType(propertyInfo!.PropertyType); + filter &= Builders.Filter.Eq(key.Camelize(Delimiter.MongoDbChildProperty), value); + } + } + + return filter; + } + + public IEnumerable> GetShallowUpdates(IDictionary propertyValues, Type type) + { + var updates = new List>(); + + foreach (var propertyValue in propertyValues) + { + string key = propertyValue.Key.Camelize(); + dynamic? value = JsonSerializer.Deserialize(propertyValue.Value, type.GetProperty(key.Pascalize())!.PropertyType, JsonSerializerOption.Default); + + if (value is null) + { + updates.Add(Builders.Update.Set(key, BsonNull.Value)); + } + else + { + updates.Add(Builders.Update.Set(key, value)); + } + } + + return updates; + } + + public IEnumerable> GetDeepUpdates(IDictionary propertyValues, Type type) + { + var updates = new List>(); + + foreach (var propertyValue in propertyValues) + { + string key = propertyValue.Key.Camelize(); + updates.AddRange(GetAllPropertiesToUpdate(key, type, propertyValue.Value)); + } + + return updates; + } + + public IEnumerable> GetAllPropertiesToUpdate(String propertyName, Type type, JsonElement jsonElement) + { + var updates = new List>(); + string currentPropertyName = propertyName.GetValueAfterLastDelimiter(Delimiter.MongoDbChildProperty).Pascalize(); + + if (jsonElement.ValueKind == JsonValueKind.Object) + { + var propertyValues = jsonElement.Deserialize>(); + foreach (var propertyValue in propertyValues!) + { + updates.AddRange(GetAllPropertiesToUpdate($"{propertyName}{Delimiter.MongoDbChildProperty}{propertyValue.Key.Camelize()}", type.GetProperty(currentPropertyName)!.PropertyType, propertyValue.Value)); + } + } + else + { + dynamic? value = jsonElement.Deserialize(type.GetProperty(currentPropertyName)!.PropertyType, JsonSerializerOption.Default); + + if (value is null) + { + updates.Add(Builders.Update.Set(propertyName, BsonNull.Value)); + } + else + { + updates.Add(Builders.Update.Set(propertyName, value)); + } + } + + return updates; + } + + public FilterDefinition GetConditionFilter(Type type, Condition? condition, String? rootLogicalOperator = Operator.And) + { + FilterDefinition filter = new BsonDocument(); + + if (condition is null) + return filter; + + if (condition.GroupedConditions is null) + { + if (condition.Field is null || condition.ComparisonOperator is null) + return filter; + + var fieldPropertyInfo = type.GetProperties().GetProperty(condition.Field, Delimiter.MongoDbChildProperty); + string field = fieldPropertyInfo!.Name.Camelize(Delimiter.MongoDbChildProperty); + Type fieldType = fieldPropertyInfo!.PropertyType; + + if (typeof(IEnumerable).IsAssignableFrom(fieldType) && fieldType.IsGenericType) + { + fieldType = fieldType.GenericTypeArguments.First(); + } + + if (condition.Values is not null) + { + IEnumerable values = condition.Values!.Select(value => ChangeType(field, fieldType, value)); + filter = GetComparisonOperatorFilter(field, condition.ComparisonOperator, values); + } + else + { + dynamic value = ChangeType(field, fieldType, condition.Value!); + filter = GetComparisonOperatorFilter(field, condition.ComparisonOperator, value); + } + } + else + { + var filters = GetConditionsFilters(type, condition.GroupedConditions); + filter = GetLogicalOperatorFilter(rootLogicalOperator ?? Operator.And, filters); + } + + return filter; + } + + public IEnumerable> GetConditionsFilters(Type type, IReadOnlyCollection? groupedConditions) + { + if (groupedConditions is null) + return new List> { new BsonDocument() }; + + var groupedConditionFilters = new List>(); + foreach (var groupedCondition in groupedConditions) + { + if (groupedCondition is null || groupedCondition.LogicalOperator is null || groupedCondition.Conditions is null) + continue; + + var conditionFilters = new List>(); + foreach (var condition in groupedCondition.Conditions) + { + if (condition.GroupedConditions is null) + { + conditionFilters.Add(GetConditionFilter(type, condition)); + } + else + { + conditionFilters.AddRange(GetConditionsFilters(type, condition.GroupedConditions)); + } + } + + groupedConditionFilters.Add(GetLogicalOperatorFilter(groupedCondition.LogicalOperator, conditionFilters)); + } + + return groupedConditionFilters; + } + + public FilterDefinition GetLogicalOperatorFilter(String logicalOperator, IEnumerable> filters) + { + if (!Operator.LogicalAliasLookup.ContainsKey(logicalOperator)) + throw new KeyNotFoundException($"{nameof(GroupedCondition.LogicalOperator)} '{logicalOperator}' was not found in {Operator.LogicalAliasLookup}."); + + return Operator.LogicalAliasLookup[logicalOperator] switch + { + Operator.And => Builders.Filter.And(filters), + Operator.Or => Builders.Filter.Or(filters), + _ => throw new NotImplementedException($"{nameof(GroupedCondition.LogicalOperator)} '{logicalOperator}' is not implemented.") + }; + } + + public FilterDefinition GetComparisonOperatorFilter(String field, String comparisonOperator, dynamic value) + { + if (!Operator.ComparisonAliasLookup.ContainsKey(comparisonOperator)) + throw new KeyNotFoundException($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' was not found in {Operator.ComparisonAliasLookup}."); + + return Operator.ComparisonAliasLookup[comparisonOperator] switch + { + Operator.Equality => Builders.Filter.Eq(field, value), + Operator.Inequality => Builders.Filter.Ne(field, value), + Operator.GreaterThan => Builders.Filter.Gt(field, value), + Operator.GreaterThanOrEquals => Builders.Filter.Gte(field, value), + Operator.LessThan => Builders.Filter.Lt(field, value), + Operator.LessThanOrEquals => Builders.Filter.Lte(field, value), + Operator.Contains => Builders.Filter.Regex(field, new BsonRegularExpression(value, "i")), + Operator.StartsWith => Builders.Filter.Regex(field, new BsonRegularExpression($"^{value}", "i")), + Operator.EndsWith => Builders.Filter.Regex(field, new BsonRegularExpression($"{value}$", "i")), + _ => throw new NotImplementedException($"Unable to compare {field} to {value}. {nameof(Condition.ComparisonOperator)} '{comparisonOperator}' is not implemented.") + }; + } + + public FilterDefinition GetComparisonOperatorFilter(String field, String comparisonOperator, IEnumerable values) + { + if (!Operator.ComparisonAliasLookup.ContainsKey(comparisonOperator)) + throw new KeyNotFoundException($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' was not found in {Operator.ComparisonAliasLookup}."); + + return Operator.ComparisonAliasLookup[comparisonOperator] switch + { + Operator.In => Builders.Filter.In(field, values), + Operator.NotIn => Builders.Filter.Nin(field, values), + Operator.All => Builders.Filter.All(field, values), + _ => throw new NotImplementedException($"Unable to compare {field} to {values}. {nameof(Condition.ComparisonOperator)} '{comparisonOperator}' is not implemented.") + }; + } + + public SortDefinition GetSort(IReadOnlyCollection? orderBy) + { + var sortBuilder = Builders.Sort; + + if (orderBy is null) + return sortBuilder.ToBsonDocument(); + + var sortDefinitions = new List>(); + foreach (var sort in orderBy) + { + var field = sort.Field!.Camelize(Delimiter.MongoDbChildProperty); + SortDefinition sortDefinition; + if (sort.IsDescending.HasValue && sort.IsDescending.Value) + { + sortDefinition = sortBuilder.Descending(field); + } + else + { + sortDefinition = sortBuilder.Ascending(field); + } + + sortDefinitions.Add(sortDefinition); + } + + return sortBuilder.Combine(sortDefinitions); + } + + public ProjectionDefinition GetProjections(Query? query) + { + var projectionBuilder = Builders.Projection; + + if (query is null) + return projectionBuilder.ToBsonDocument(); + + return projectionBuilder.Combine(GetIncludesProjections(query.Includes), GetExcludesProjections(query.Excludes)); + } + + public ProjectionDefinition GetIncludesProjections(HashSet? includes) + { + var projectionBuilder = Builders.Projection; + + if (includes is null) + return projectionBuilder.ToBsonDocument(); + + var projectionDefinitions = new List>(); + foreach (var include in includes) + { + var field = include.Camelize(Delimiter.MongoDbChildProperty); + + projectionDefinitions.Add(projectionBuilder.Include(field)); + } + + return projectionBuilder.Combine(projectionDefinitions); + } + + public ProjectionDefinition GetExcludesProjections(HashSet? excludes) + { + var projectionBuilder = Builders.Projection; + + if (excludes is null) + return projectionBuilder.ToBsonDocument(); + + var projectionDefinitions = new List>(); + foreach (var exclude in excludes) + { + var field = exclude.Camelize(Delimiter.MongoDbChildProperty); + + projectionDefinitions.Add(projectionBuilder.Exclude(field)); + } + + return projectionBuilder.Combine(projectionDefinitions); + } + + public dynamic ChangeType(String field, Type type, String value) + { + try + { + return value.ChangeType(type); + } + catch (Exception) + { + throw new InvalidCastException($"Unable to convert value: {value} to field: {field}'s type: {type}."); + } + } + } +} diff --git a/Crud.Api/Services/PostprocessingService.cs b/Crud.Api/Services/PostprocessingService.cs new file mode 100644 index 0000000..7ff2baa --- /dev/null +++ b/Crud.Api/Services/PostprocessingService.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Crud.Api.QueryModels; +using Crud.Api.Results; + +namespace Crud.Api.Services +{ + public class PostprocessingService : IPostprocessingService + { + public PostprocessingService() + { + + } + + public Task PostprocessCreateAsync(Object createdModel) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessReadAsync(Object model, Guid id) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessReadAsync(IEnumerable models, IDictionary? queryParams) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessReadAsync(IEnumerable models, Query query) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessReadCountAsync(Object model, Query query, Int64 count) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessUpdateAsync(Object updatedModel, Guid id) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessPartialUpdateAsync(Object updatedModel, Guid id, IDictionary propertyValues) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessPartialUpdateAsync(Object model, IDictionary? queryParams, IDictionary propertyValues, Int64 updatedCount) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessDeleteAsync(Object model, Guid id, Int64 deletedCount) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessDeleteAsync(Object model, IDictionary? queryParams, Int64 deletedCount) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PostprocessDeleteAsync(Object model, Query query, Int64 deletedCount) + { + return Task.FromResult(new MessageResult(true)); + } + } +} diff --git a/Crud.Api/Services/PreprocessingService.cs b/Crud.Api/Services/PreprocessingService.cs new file mode 100644 index 0000000..3bf355c --- /dev/null +++ b/Crud.Api/Services/PreprocessingService.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Crud.Api.QueryModels; +using Crud.Api.Results; + +namespace Crud.Api.Services +{ + public class PreprocessingService : IPreprocessingService + { + public PreprocessingService() + { + + } + + public Task PreprocessCreateAsync(Object model) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessReadAsync(Object model, Guid id) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessReadAsync(Object model, IDictionary? queryParams) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessReadAsync(Object model, Query query) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessReadCountAsync(Object model, Query query) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessUpdateAsync(Object model, Guid id) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessPartialUpdateAsync(Object model, Guid id, IDictionary propertyValues) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessPartialUpdateAsync(Object model, IDictionary? queryParams, IDictionary propertyValues) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessDeleteAsync(Object model, Guid id) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessDeleteAsync(Object model, IDictionary? queryParams) + { + return Task.FromResult(new MessageResult(true)); + } + + public Task PreprocessDeleteAsync(Object model, Query query) + { + return Task.FromResult(new MessageResult(true)); + } + } +} diff --git a/Crud.Api/Services/QueryCollectionService.cs b/Crud.Api/Services/QueryCollectionService.cs new file mode 100644 index 0000000..50f923e --- /dev/null +++ b/Crud.Api/Services/QueryCollectionService.cs @@ -0,0 +1,12 @@ +namespace Crud.Api.Services +{ + public class QueryCollectionService : IQueryCollectionService + { + public QueryCollectionService() { } + + public Dictionary ConvertToDictionary(IQueryCollection queryCollection) + { + return queryCollection.ToDictionary(query => query.Key, query => query.Value.ToString()); + } + } +} diff --git a/Crud.Api/Services/StreamService.cs b/Crud.Api/Services/StreamService.cs new file mode 100644 index 0000000..2c7da8b --- /dev/null +++ b/Crud.Api/Services/StreamService.cs @@ -0,0 +1,17 @@ +using System.Text; + +namespace Crud.Api.Services +{ + public class StreamService : IStreamService + { + public StreamService() { } + + public async Task ReadToEndThenDisposeAsync(Stream stream, Encoding encoding) + { + using (StreamReader reader = new StreamReader(stream, encoding)) + { + return await reader.ReadToEndAsync(); + } + } + } +} diff --git a/Crud.Api/Services/TypeService.cs b/Crud.Api/Services/TypeService.cs new file mode 100644 index 0000000..d7ecc34 --- /dev/null +++ b/Crud.Api/Services/TypeService.cs @@ -0,0 +1,26 @@ +using Crud.Api.Constants; +using Humanizer; + +namespace Crud.Api.Services +{ + public class TypeService : ITypeService + { + public TypeService() { } + + public Type? GetType(String @namespace, String typeName) + { + if (String.IsNullOrWhiteSpace(@namespace)) + throw new ArgumentException($"{nameof(@namespace)} cannot be null or whitespace."); + + if (String.IsNullOrWhiteSpace(typeName)) + throw new ArgumentException($"{nameof(typeName)} cannot be null or whitespace."); + + return Type.GetType($"{@namespace}.{typeName.Singularize().Pascalize()}"); + } + + public Type? GetModelType(String typeName) + { + return GetType(Namespace.Models, typeName); + } + } +} diff --git a/Crud.Api/Validators/IValidator.cs b/Crud.Api/Validators/IValidator.cs new file mode 100644 index 0000000..563c4f0 --- /dev/null +++ b/Crud.Api/Validators/IValidator.cs @@ -0,0 +1,23 @@ +using Crud.Api.Models; +using Crud.Api.QueryModels; +using Crud.Api.Results; + +namespace Crud.Api.Validators +{ + public interface IValidator + { + Task ValidateCreateAsync(Object model); + Task ValidateCreateAsync(User user); + Task ValidateReadAsync(Object model, IDictionary? queryParams); + Task ValidateReadAsync(User user, IDictionary? queryParams); + Task ValidateUpdateAsync(Object model, Guid id); + Task ValidateUpdateAsync(User user, Guid id); + Task ValidatePartialUpdateAsync(Object model, Guid id, IReadOnlyCollection? propertiesToBeUpdated); + Task ValidatePartialUpdateAsync(User user, Guid id, IReadOnlyCollection? propertiesToBeUpdated); + Task ValidatePartialUpdateAsync(Object model, IDictionary? queryParams, IReadOnlyCollection? propertiesToBeUpdated); + Task ValidatePartialUpdateAsync(User user, IDictionary? queryParams, IReadOnlyCollection? propertiesToBeUpdated); + Task ValidateDeleteAsync(Object model, IDictionary? queryParams); + Task ValidateDeleteAsync(User user, IDictionary? queryParams); + ValidationResult ValidateQuery(Object model, Query query); + } +} \ No newline at end of file diff --git a/Crud.Api/Validators/Validator.cs b/Crud.Api/Validators/Validator.cs new file mode 100644 index 0000000..53317b9 --- /dev/null +++ b/Crud.Api/Validators/Validator.cs @@ -0,0 +1,356 @@ +using System.Reflection; +using Crud.Api.Attributes; +using Crud.Api.Constants; +using Crud.Api.Models; +using Crud.Api.Options; +using Crud.Api.Preservers; +using Crud.Api.QueryModels; +using Crud.Api.Results; +using Microsoft.Extensions.Options; + +namespace Crud.Api.Validators +{ + public class Validator : IValidator + { + private readonly IPreserver _preserver; + private readonly ApplicationOptions _applicationOptions; + + public Validator(IPreserver preserver, IOptions applicationOptions) + { + _preserver = preserver; + _applicationOptions = applicationOptions.Value; + } + + public Task ValidateCreateAsync(Object model) + { + var validationResult = model.ValidateDataAnnotations(); + if (!validationResult.IsValid) + return Task.FromResult(validationResult); + + return Task.FromResult(new ValidationResult(true)); + } + + public async Task ValidateCreateAsync(User user) + { + if (user is null) + return new ValidationResult(false, $"{nameof(User)} cannot be null."); + + var objectValidationResult = await ValidateCreateAsync((object)user); + if (!objectValidationResult.IsValid) + return objectValidationResult; + + if (user.ExternalId is not null) + return new ValidationResult(false, $"{nameof(user.ExternalId)} cannot be set on create."); + + if (String.IsNullOrWhiteSpace(user.Name)) + return new ValidationResult(false, $"{nameof(user.Name)} cannot be empty."); + + var existingUsers = await _preserver.ReadAsync(new Dictionary { { nameof(user.Name), user.Name } }); + if (existingUsers is not null && existingUsers.Any()) + return new ValidationResult(false, $"A {nameof(User)} with the {nameof(user.Name)}: '{user.Name}' already exists."); + + if (user.Age < 0) + return new ValidationResult(false, $"{nameof(user.Age)} cannot be less than 0."); + + return new ValidationResult(true); + } + + public Task ValidateReadAsync(Object model, IDictionary? queryParams) + { + if (queryParams is null || queryParams.Count == 0) // Remove to allow returning all. + return Task.FromResult(new ValidationResult(false, "Filter cannot be empty.")); + + if (!model.GetType().GetProperties().HasAllPropertyNames(queryParams.Select(queryParam => queryParam.Key), Delimiter.QueryParamChildProperty)) + return Task.FromResult(new ValidationResult(false, "Filter cannot contain properties that the model does not have.")); + + return Task.FromResult(new ValidationResult(true)); + } + + public Task ValidateReadAsync(User user, IDictionary? queryParams) + { + // The user version of this method and call to object version is not necessary. + // This is only here to show how to override in case more user validation was necessary. + return ValidateReadAsync((object)user, queryParams); + } + + public Task ValidateUpdateAsync(Object model, Guid id) + { + var validationResult = model.ValidateDataAnnotations(); + if (!validationResult.IsValid) + return Task.FromResult(validationResult); + + return Task.FromResult(new ValidationResult(true)); + } + + public async Task ValidateUpdateAsync(User user, Guid id) + { + var objectValidationResult = await ValidateUpdateAsync((object)user, id); + if (!objectValidationResult.IsValid) + return objectValidationResult; + + if (id == Guid.Empty) + return new ValidationResult(false, "Id cannot be empty."); + + if (user is null) + return new ValidationResult(false, $"{nameof(User)} cannot be null."); + + if (id != user.ExternalId) + return new ValidationResult(false, $"{nameof(user.ExternalId)} cannot be altered."); + + if (String.IsNullOrWhiteSpace(user.Name)) + return new ValidationResult(false, $"{nameof(user.Name)} cannot be empty."); + + if (user.Age < 0) + return new ValidationResult(false, $"{nameof(user.Age)} cannot be less than zero."); + + return new ValidationResult(true); + } + + public Task ValidatePartialUpdateAsync(Object model, Guid id, IReadOnlyCollection? propertiesToBeUpdated) + { + if (propertiesToBeUpdated is null || propertiesToBeUpdated.Count == 0) + return Task.FromResult(new ValidationResult(false, "Updated properties cannot be empty.")); + + if (!model.GetType().GetProperties().HasAllPropertyNames(propertiesToBeUpdated)) + return Task.FromResult(new ValidationResult(false, "Updated properties cannot contain properties that the model does not have.")); + + var validationResult = model.ValidateDataAnnotations(true, propertiesToBeUpdated); + if (!validationResult.IsValid) + return Task.FromResult(validationResult); + + return Task.FromResult(new ValidationResult(true)); + } + + public async Task ValidatePartialUpdateAsync(User user, Guid id, IReadOnlyCollection? propertiesToBeUpdated) + { + var objectValidationResult = await ValidatePartialUpdateAsync((object)user, id, propertiesToBeUpdated); + if (!objectValidationResult.IsValid) + return objectValidationResult; + + if (WillBeUpdated(nameof(user.ExternalId), propertiesToBeUpdated)) // Prevents updating this property. + return new ValidationResult(false, $"{nameof(user.ExternalId)} cannot be altered."); + + if (WillBeUpdated(nameof(user.Name), propertiesToBeUpdated) && String.IsNullOrWhiteSpace(user.Name)) + return new ValidationResult(false, $"{nameof(user.Name)} cannot be empty."); + + if (WillBeUpdated(nameof(user.Age), propertiesToBeUpdated) && user.Age < 0) + return new ValidationResult(false, $"{nameof(user.Age)} cannot be less than zero."); + + return new ValidationResult(true); + } + + public Task ValidatePartialUpdateAsync(Object model, IDictionary? queryParams, IReadOnlyCollection? propertiesToBeUpdated) + { + if (queryParams is null || queryParams.Count == 0) // Remove to allow returning all. + return Task.FromResult(new ValidationResult(false, "Filter cannot be empty.")); + + if (!model.GetType().GetProperties().HasAllPropertyNames(queryParams.Select(queryParam => queryParam.Key), Delimiter.QueryParamChildProperty)) + return Task.FromResult(new ValidationResult(false, "Filter cannot contain properties that the model does not have.")); + + if (propertiesToBeUpdated is null || propertiesToBeUpdated.Count == 0) + return Task.FromResult(new ValidationResult(false, "Updated properties cannot be empty.")); + + if (!model.GetType().GetProperties().HasAllPropertyNames(propertiesToBeUpdated)) + return Task.FromResult(new ValidationResult(false, "Updated properties cannot contain properties that the model does not have.")); + + var validationResult = model.ValidateDataAnnotations(true, propertiesToBeUpdated); + if (!validationResult.IsValid) + return Task.FromResult(validationResult); + + return Task.FromResult(new ValidationResult(true)); + } + + public async Task ValidatePartialUpdateAsync(User user, IDictionary? queryParams, IReadOnlyCollection? propertiesToBeUpdated) + { + var objectValidationResult = await ValidatePartialUpdateAsync((object)user, queryParams, propertiesToBeUpdated); + if (!objectValidationResult.IsValid) + return objectValidationResult; + + if (user is null) + return new ValidationResult(false, $"{nameof(User)} cannot be null."); + + if (WillBeUpdated(nameof(user.ExternalId), propertiesToBeUpdated)) // Prevents updating this property. + return new ValidationResult(false, $"{nameof(user.ExternalId)} cannot be altered."); + + if (WillBeUpdated(nameof(user.Name), propertiesToBeUpdated) && String.IsNullOrWhiteSpace(user.Name)) + return new ValidationResult(false, $"{nameof(user.Name)} cannot be empty."); + + if (WillBeUpdated(nameof(user.Age), propertiesToBeUpdated) && user.Age < 0) + return new ValidationResult(false, $"{nameof(user.Age)} cannot be less than zero."); + + return new ValidationResult(true); + } + + public Task ValidateDeleteAsync(Object model, IDictionary? queryParams) + { + if (queryParams is null || queryParams.Count == 0) // Remove to allow returning all. + return Task.FromResult(new ValidationResult(false, "Filter cannot be empty.")); + + if (!model.GetType().GetProperties().HasAllPropertyNames(queryParams.Select(queryParam => queryParam.Key), Delimiter.QueryParamChildProperty)) + return Task.FromResult(new ValidationResult(false, "Filter cannot contain properties that the model does not have.")); + + return Task.FromResult(new ValidationResult(true)); + } + + public Task ValidateDeleteAsync(User user, IDictionary? queryParams) + { + // The user version of this method and call to object version is not necessary. + // This is only here to show how to override in case more user validation was necessary. + return ValidateDeleteAsync((object)user, queryParams); + } + + public ValidationResult ValidateQuery(Object model, Query query) + { + var includesIsPopulated = (query.Includes is not null && query.Includes.Count > 0); + var excludesIsPopulated = (query.Excludes is not null && query.Excludes.Count > 0); + var modelProperties = model.GetType().GetProperties(); + + if (includesIsPopulated && excludesIsPopulated) + return new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Includes)} and {nameof(Query.Excludes)} cannot both be populated."); + + if (includesIsPopulated && !modelProperties.HasAllPropertyNames(query.Includes!, Delimiter.MongoDbChildProperty)) + return new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Includes)} cannot contain properties that the model does not have."); + + if (excludesIsPopulated && !modelProperties.HasAllPropertyNames(query.Excludes!, Delimiter.MongoDbChildProperty)) + return new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Excludes)} cannot contain properties that the model does not have."); + + if (query.Where is not null) + { + var conditionValidationResult = ValidateCondition(modelProperties, query.Where); + if (!conditionValidationResult.IsValid) + return conditionValidationResult; + } + + if (query.OrderBy is not null) + { + var orderByValidationResult = ValidateSorts(modelProperties, query.OrderBy); + if (!orderByValidationResult.IsValid) + return orderByValidationResult; + } + + if (query.Limit < 0) + return new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + + if (query.Skip < 0) + return new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Skip)} cannot be less than zero."); + + return new ValidationResult(true); + } + + public ValidationResult ValidateCondition(PropertyInfo[] modelProperties, Condition condition) + { + if (condition.Field is null && condition.GroupedConditions is null) + return new ValidationResult(false, $"A {nameof(Condition)} must contain either a {nameof(Condition.Field)} or {nameof(Condition.GroupedConditions)}."); + + if (condition.Field is not null) + { + var propertyInfo = modelProperties.GetProperty(condition.Field, Delimiter.MongoDbChildProperty); + if (propertyInfo is null) + return new ValidationResult(false, $"A {nameof(Condition)} {nameof(Condition.Field)} contains a property {condition.Field} that the model does not have."); + + if (condition.ComparisonOperator is null) + return new ValidationResult(false, $"A {nameof(Condition)} cannot have a populated {nameof(Condition.Field)} and a null {nameof(Condition.ComparisonOperator)}."); + + if (!Operator.ComparisonAliasLookup.ContainsKey(condition.ComparisonOperator)) + return new ValidationResult(false, $"{nameof(Condition.ComparisonOperator)} '{condition.ComparisonOperator}' must be found in {Operator.ComparisonAliasLookup}."); + + var comparisonOperator = Operator.ComparisonAliasLookup[condition.ComparisonOperator]; + + var validationResult = ValidateQueryApplicationOptions(comparisonOperator); + if (!validationResult.IsValid) + return validationResult; + + validationResult = ValidatePropertyQueryAttributes(propertyInfo, comparisonOperator); + if (!validationResult.IsValid) + return validationResult; + + if (condition.Value is not null) + { + if ((comparisonOperator == Operator.Contains + || comparisonOperator == Operator.StartsWith + || comparisonOperator == Operator.EndsWith) + && (condition.Value.Length == 0 + || !condition.Value.All(Char.IsLetterOrDigit))) + return new ValidationResult(false, $"{nameof(Condition.ComparisonOperator)} '{condition.ComparisonOperator}' can only contain letters and numbers."); + } + } + + if (condition.GroupedConditions is not null) + { + foreach (var groupedCondition in condition.GroupedConditions) + { + var validationResult = ValidateGroupedCondition(modelProperties, groupedCondition); + if (!validationResult.IsValid) + return validationResult; + } + } + + return new ValidationResult(true); + } + + public ValidationResult ValidateQueryApplicationOptions(String? comparisonOperator) + { + if (comparisonOperator is not null) + { + if ((comparisonOperator == Operator.Contains && _applicationOptions.PreventAllQueryContains) + || (comparisonOperator == Operator.StartsWith && _applicationOptions.PreventAllQueryStartsWith) + || (comparisonOperator == Operator.EndsWith && _applicationOptions.PreventAllQueryEndsWith)) + return new ValidationResult(false, $"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' may not be used."); + } + + return new ValidationResult(true); + } + + public ValidationResult ValidatePropertyQueryAttributes(PropertyInfo propertyInfo, String? comparisonOperator) + { + if (comparisonOperator is not null) + { + var attribute = propertyInfo.GetCustomAttribute(); + if (attribute is not null) + { + if (!attribute.AllowsOperator(comparisonOperator)) + return new ValidationResult(false, $"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' may not be used on the {propertyInfo.Name} property."); + } + } + + return new ValidationResult(true); + } + + public ValidationResult ValidateGroupedCondition(PropertyInfo[] modelProperties, GroupedCondition groupedCondition) + { + if (groupedCondition.LogicalOperator is not null && !Operator.LogicalAliasLookup.ContainsKey(groupedCondition.LogicalOperator)) + return new ValidationResult(false, $"{nameof(GroupedCondition.LogicalOperator)} '{groupedCondition.LogicalOperator}' must be found in {Operator.LogicalAliasLookup}."); + + if (groupedCondition.Conditions is null || groupedCondition.Conditions.Count == 0) + return new ValidationResult(false, $"{nameof(GroupedCondition.Conditions)} cannot be empty."); + + foreach (var condition in groupedCondition.Conditions) + { + var validationResult = ValidateCondition(modelProperties, condition); + if (!validationResult.IsValid) + return validationResult; + } + + return new ValidationResult(true); + } + + public ValidationResult ValidateSorts(PropertyInfo[] modelProperties, IReadOnlyCollection sorts) + { + foreach (var sort in sorts) + { + if (sort.Field is null) + return new ValidationResult(false, $"{nameof(Query.OrderBy)} cannot contain a {nameof(Sort)} with a null {nameof(Sort.Field)}."); + + if (!modelProperties.HasPropertyName(sort.Field, Delimiter.MongoDbChildProperty)) + return new ValidationResult(false, $"A {nameof(Sort)} {nameof(Sort.Field)} contains a property {sort.Field} that the model does not have."); + } + + return new ValidationResult(true); + } + + private Boolean WillBeUpdated(String propertyName, IEnumerable? propertiesToBeUpdated) + { + return propertiesToBeUpdated?.Contains(propertyName, StringComparer.OrdinalIgnoreCase) ?? false; + } + } +} diff --git a/Crud.Api/appsettings.Development.json b/Crud.Api/appsettings.Development.json new file mode 100644 index 0000000..b7d4f7e --- /dev/null +++ b/Crud.Api/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "MongoDbOptions": { + "ConnectionString": "mongodb://localhost", + "DatabaseName": "testDb" + }, + "ApplicationOptions": { + "ShowExceptions": true, + "ValidateQuery": true, + "PreventAllQueryContains": false, + "PreventAllQueryStartsWith": false, + "PreventAllQueryEndsWith": false + } +} diff --git a/Crud.Api/appsettings.json b/Crud.Api/appsettings.json new file mode 100644 index 0000000..653cfc6 --- /dev/null +++ b/Crud.Api/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "MongoDbOptions": { + "ConnectionString": "mongodb://localhost", + "DatabaseName": "testDb" + }, + "AllowedHosts": "*", + "SettingOptions": { + "ShowExceptions": false, + "ValidateQuery": true, + "PreventAllQueryContains": false, + "PreventAllQueryStartsWith": false, + "PreventAllQueryEndsWith": false + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Attributes/PreventCrudAttributeTests.cs b/Crud.Tests/Crud.Api.Tests/Attributes/PreventCrudAttributeTests.cs new file mode 100644 index 0000000..fc619b3 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Attributes/PreventCrudAttributeTests.cs @@ -0,0 +1,115 @@ +using Crud.Api.Attributes; +using Crud.Api.Enums; + +namespace Crud.Api.Tests.Extensions +{ + public class PreventCrudAttributeTests + { + [Fact] + public void AllowsCrudOperation_PreventedCrudOperationsCountIsZero_ReturnsFalse() + { + var preventCrudAttribute = new PreventCrudAttribute(); + var crudOperation = CrudOperation.Create; + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + [Fact] + public void AllowsCrudOperation_PreventedCrudOperationsContainsCrudOperation_ReturnsFalse() + { + var preventCrudAttribute = new PreventCrudAttribute(CrudOperation.Create); + var crudOperation = CrudOperation.Create; + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + [Fact] + public void AllowsCrudOperation_EncompassingCrudOperationLookupContainsKeyCrudOperation_ReturnsPreventedCrudOperationsContainsEncompassingCrudOperationResult() + { + var preventCrudAttribute = new PreventCrudAttribute(CrudOperation.Read); + var crudOperation = CrudOperation.ReadWithId; + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + [Fact] + public void AllowsCrudOperation_EncompassingCrudOperationLookupDoesNotContainKeyCrudOperation_ReturnsTrue() + { + var preventCrudAttribute = new PreventCrudAttribute(CrudOperation.Read); + var crudOperation = CrudOperation.Create; + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.True(result); + } + + [Theory] + [ClassData(typeof(EncompassedReadOperations))] + public void EncompassingCrudOperationLookup_ReadPrevented_AllEncompassedReadOperationsReturnFalse(CrudOperation crudOperation) + { + var preventCrudAttribute = new PreventCrudAttribute(CrudOperation.Read); + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + [Theory] + [ClassData(typeof(EncompassedPartialUpdateOperations))] + public void EncompassingCrudOperationLookup_PartialUpdatePrevented_AllEncompassedPartialUpdateOperationsReturnFalse(CrudOperation crudOperation) + { + var preventCrudAttribute = new PreventCrudAttribute(CrudOperation.PartialUpdate); + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + [Theory] + [ClassData(typeof(EncompassedDeleteOperations))] + public void EncompassingCrudOperationLookup_DeletePrevented_AllEncompassedDeleteOperationsReturnFalse(CrudOperation crudOperation) + { + var preventCrudAttribute = new PreventCrudAttribute(CrudOperation.Delete); + + var result = preventCrudAttribute.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + private class EncompassedReadOperations : TheoryData + { + public EncompassedReadOperations() + { + Add(CrudOperation.ReadWithId); + Add(CrudOperation.ReadWithQueryParams); + Add(CrudOperation.ReadWithQuery); + Add(CrudOperation.ReadCount); + } + } + + private class EncompassedPartialUpdateOperations : TheoryData + { + public EncompassedPartialUpdateOperations() + { + Add(CrudOperation.PartialUpdateWithId); + Add(CrudOperation.PartialUpdateWithQueryParams); + } + } + + private class EncompassedDeleteOperations : TheoryData + { + public EncompassedDeleteOperations() + { + Add(CrudOperation.DeleteWithId); + Add(CrudOperation.DeleteWithQueryParams); + Add(CrudOperation.DeleteWithQuery); + } + } + } +} \ No newline at end of file diff --git a/Crud.Tests/Crud.Api.Tests/Attributes/PreventQueryAttributeTests.cs b/Crud.Tests/Crud.Api.Tests/Attributes/PreventQueryAttributeTests.cs new file mode 100644 index 0000000..95788f2 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Attributes/PreventQueryAttributeTests.cs @@ -0,0 +1,41 @@ +using Crud.Api.Attributes; +using Crud.Api.QueryModels; + +namespace Crud.Api.Tests.Extensions +{ + public class PreventQueryAttributeTests + { + [Fact] + public void AllowsOperator_PreventedOperatorsCountIsZero_ReturnsFalse() + { + var preventQueryAttribute = new PreventQueryAttribute(); + var @operator = Operator.Contains; + + var result = preventQueryAttribute.AllowsOperator(@operator); + + Assert.False(result); + } + + [Fact] + public void AllowsOperator_PreventedOperatorsContainsOperator_ReturnsFalse() + { + var preventQueryAttribute = new PreventQueryAttribute(Operator.Contains); + var @operator = Operator.Contains; + + var result = preventQueryAttribute.AllowsOperator(@operator); + + Assert.False(result); + } + + [Fact] + public void AllowsOperator_PreventedOperatorsDoesNotContainOperator_ReturnsTrue() + { + var preventQueryAttribute = new PreventQueryAttribute(Operator.All); + var @operator = Operator.Contains; + + var result = preventQueryAttribute.AllowsOperator(@operator); + + Assert.True(result); + } + } +} \ No newline at end of file diff --git a/Crud.Tests/Crud.Api.Tests/Controllers/BaseApiControllerTests.cs b/Crud.Tests/Crud.Api.Tests/Controllers/BaseApiControllerTests.cs new file mode 100644 index 0000000..4bb95b6 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Controllers/BaseApiControllerTests.cs @@ -0,0 +1,57 @@ +using Crud.Api.Controllers; +using Crud.Api.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Crud.Api.Tests.Controllers +{ + public class BaseApiControllerTests + { + private IOptions _applicationOptions; + private DerivedController _controller; + + public BaseApiControllerTests() + { + _applicationOptions = Microsoft.Extensions.Options.Options.Create(new ApplicationOptions()); + _controller = new DerivedController(_applicationOptions); + } + + [Fact] + public void InternalServerError_ShowExceptionsIsTrue_Returns500AndException() + { + var exception = new Exception("an-error-occurred"); + + _applicationOptions.Value.ShowExceptions = true; + + var result = _controller.CallInternalServerError(exception) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(exception.ToString(), result.Value); + } + + [Fact] + public void InternalServerError_ShowExceptionsIsFalse_Returns500() + { + var exception = new Exception("an-error-occurred"); + + _applicationOptions.Value.ShowExceptions = false; + + var result = _controller.CallInternalServerError(exception) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + + private class DerivedController : BaseApiController + { + public DerivedController(IOptions applicationOptions) : base(applicationOptions) { } + + public IActionResult CallInternalServerError(Exception exception) + { + return InternalServerError(exception); + } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Controllers/CrudControllerTests.cs b/Crud.Tests/Crud.Api.Tests/Controllers/CrudControllerTests.cs new file mode 100644 index 0000000..0d07aea --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Controllers/CrudControllerTests.cs @@ -0,0 +1,2026 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Crud.Api.Attributes; +using Crud.Api.Constants; +using Crud.Api.Controllers; +using Crud.Api.Enums; +using Crud.Api.Options; +using Crud.Api.Preservers; +using Crud.Api.QueryModels; +using Crud.Api.Results; +using Crud.Api.Services; +using Crud.Api.Tests.TestingModels; +using Crud.Api.Validators; +using Humanizer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Crud.Api.Tests.Controllers +{ + public class CrudControllerTests : IDisposable + { + private IOptions _applicationOptions; + private Mock> _logger; + private Mock _validator; + private Mock _preserver; + private Mock _streamService; + private Mock _typeService; + private Mock _queryCollectionService; + private Mock _preprocessingService; + private Mock _postprocessingService; + private CrudController _controller; + private Stream _stream; + + public CrudControllerTests() + { + _applicationOptions = Microsoft.Extensions.Options.Options.Create(new ApplicationOptions { ShowExceptions = false, ValidateQuery = false }); + _logger = new Mock>(); + _validator = new Mock(); + _preserver = new Mock(); + _streamService = new Mock(); + _typeService = new Mock(); + _queryCollectionService = new Mock(); + _preprocessingService = new Mock(); + _postprocessingService = new Mock(); + _stream = new MemoryStream(Encoding.UTF8.GetBytes("this-does-not-matter")); + var httpContext = new DefaultHttpContext() { Request = { Body = _stream, ContentLength = _stream.Length } }; + var controllerContext = new ControllerContext { HttpContext = httpContext }; + + _controller = new CrudController(_applicationOptions, _logger.Object, _validator.Object, _preserver.Object, _streamService.Object, _typeService.Object, _queryCollectionService.Object, _preprocessingService.Object, _postprocessingService.Object) { ControllerContext = controllerContext }; + } + + public void Dispose() + { + _stream.Dispose(); + } + + #region CreateAsync + [Fact] + public async Task CreateAsync_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.CreateAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task CreateAsync_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.CreateAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.Create.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task CreateAsync_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.CreateAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task CreateAsync_ValidationResultIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult + { + IsValid = false, + Message = "some-message" + }; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(model.GetType()); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateCreateAsync(It.IsAny())).ReturnsAsync(validationResult); + + var result = await _controller.CreateAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(validationResult.Message, result.Value); + } + + [Fact] + public async Task CreateAsync_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(model.GetType()); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateCreateAsync(It.IsAny())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessCreateAsync(It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.CreateAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task CreateAsync_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(model.GetType()); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateCreateAsync(It.IsAny())).ReturnsAsync(validationResult); + _preserver.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(model); + _preprocessingService.Setup(m => m.PreprocessCreateAsync(It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessCreateAsync(It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.CreateAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task CreateAsync_ModelCreated_ReturnsOkCreatedModel() + { + var typeName = "some-type-name"; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(model.GetType()); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateCreateAsync(It.IsAny())).ReturnsAsync(validationResult); + _preserver.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(model); + _preprocessingService.Setup(m => m.PreprocessCreateAsync(It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessCreateAsync(It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.CreateAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + + var typedResult = result.Value as Model; + + Assert.NotNull(typedResult); + Assert.Equal(model.Id, typedResult.Id); + } + + [Fact] + public async Task CreateAsync_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.CreateAsync(typeName) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region ReadAsync_WithStringGuid + [Fact] + public async Task ReadAsync_WithStringGuid_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.ReadAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task ReadAsync_WithStringGuid_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.ReadAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.ReadWithId.ToString().Humanize(), type.Name), result.Value); + } + + [Fact] + public async Task ReadAsync_WithStringGuid_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.ReadAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task ReadAsync_WithStringGuid_ModelIsNull_ReturnsNotFound() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + Model? model = null; + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.ReadAsync(It.IsAny())).ReturnsAsync(model); + + var result = await _controller.ReadAsync(typeName, id) as NotFoundObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.NotFoundRead, typeName), result.Value); + } + + [Fact] + public async Task ReadAsync_WithStringGuid_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.ReadAsync(It.IsAny())).ReturnsAsync(model); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.ReadAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task ReadAsync_WithStringGuid_ModelIsFound_ReturnsFoundModel() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.ReadAsync(It.IsAny())).ReturnsAsync(model); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.ReadAsync(typeName, id) as OkObjectResult; + + Assert.NotNull(result); + + var typedResult = result.Value as Model; + + Assert.NotNull(typedResult); + Assert.Equal(model.Id, typedResult.Id); + } + + [Fact] + public async Task ReadAsync_WithStringGuid_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Guid id = Guid.Empty; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.ReadAsync(typeName, id) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region ReadAsync_WithString + [Fact] + public async Task ReadAsync_WithString_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.ReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task ReadAsync_WithString_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.ReadAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.ReadWithQueryParams.ToString().Humanize(), type.Name), result.Value); + } + + [Fact] + public async Task ReadAsync_WithString_ValidationResultIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var validationResult = new ValidationResult + { + IsValid = false, + Message = "some-message" + }; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _validator.Setup(m => m.ValidateReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + + var result = await _controller.ReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(validationResult.Message, result.Value); + } + + [Fact] + public async Task ReadAsync_WithString_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _validator.Setup(m => m.ValidateReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.ReadAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task ReadAsync_WithString_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _validator.Setup(m => m.ValidateReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny>(), It.IsAny>())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.ReadAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task ReadAsync_WithString_ModelsAreFound_ReturnsFoundModels() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var models = new List { new Model { Id = 1 } }; + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _validator.Setup(m => m.ValidateReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.ReadAsync(It.IsAny>())).ReturnsAsync(models); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny>(), It.IsAny>())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.ReadAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + + var typedResult = result.Value as IEnumerable; + + Assert.NotNull(typedResult); + Assert.Equal(models.FirstOrDefault()!.Id, typedResult.FirstOrDefault()?.Id); + } + + [Fact] + public async Task ReadAsync_WithString_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.ReadAsync(typeName) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region QueryReadAsync + + [Fact] + public async Task QueryReadAsync_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.QueryReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task QueryReadAsync_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.QueryReadAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.ReadWithQuery.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task QueryReadAsync_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task QueryReadAsync_QueryIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = null; + var json = JsonSerializer.Serialize(query); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.BadRequestQuery, $"{nameof(Query)} is null."), result.Value); + } + + [Fact] + public async Task QueryReadAsync_DeserializeThrowsExceptionQueryIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var json = @"{ ""OrderBy"": ""1""}"; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + Assert.Contains(String.Format(ErrorMessage.BadRequestQuery, String.Empty), result.Value.ToString()); + Assert.Contains("OrderBy", result.Value.ToString()); + } + + [Fact] + public async Task QueryReadAsync_ValidateQueryIsTrueQueryIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = true; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + + var result = await _controller.QueryReadAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal($"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero.", result.Value); + } + + [Fact] + public async Task QueryReadAsync_ValidateQueryIsFalseQueryIsInvalid_ValidateQueryNotCalled() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.QueryReadAsync(typeName); + + _validator.Verify(m => m.ValidateQuery(It.IsAny(), It.IsAny()), Times.Never); + _preserver.Verify(m => m.QueryReadAsync(It.Is(thisQuery => thisQuery.Limit == query.Limit)), Times.Once); + } + + [Fact] + public async Task QueryReadAsync_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.QueryReadAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task QueryReadAsync_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var model = new Model { Id = 1 }; + var models = new List { model }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.QueryReadAsync(It.IsAny())).ReturnsAsync(models); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryReadAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task QueryReadAsync_QueryHasIncludes_ReturnObjectWithOnlyPropertiesRequestedInIncludes() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Includes = new HashSet { nameof(Model.Id) } }; + var json = JsonSerializer.Serialize(query); + var model = new Model { Id = 1 }; + var models = new List { model }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.QueryReadAsync(It.IsAny())).ReturnsAsync(models); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryReadAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + + var typedResult = JsonSerializer.Deserialize(result.Value!.ToString()!, typeof(IList)) as IList; + + Assert.NotNull(typedResult); + Assert.Single(typedResult); + + var firstResult = typedResult[0].ToString(); + + Assert.Contains(nameof(Model.Id).Camelize(), firstResult); + Assert.DoesNotContain(nameof(Model.Name).Camelize(), firstResult); + Assert.DoesNotContain(nameof(Model.Description).Camelize(), firstResult); + } + + [Fact] + public async Task QueryReadAsync_QueryHasExcludes_ReturnObjectWithOnlyPropertiesNotRequestedInExcludes() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Excludes = new HashSet { nameof(Model.Name), nameof(Model.Description) } }; + var json = JsonSerializer.Serialize(query); + var model = new Model { Id = 1 }; + var models = new List { model }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.QueryReadAsync(It.IsAny())).ReturnsAsync(models); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryReadAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + + var typedResult = JsonSerializer.Deserialize(result.Value!.ToString()!, typeof(IList)) as IList; + + Assert.NotNull(typedResult); + Assert.Single(typedResult); + + var firstResult = typedResult[0].ToString(); + + Assert.Contains(nameof(Model.Id).Camelize(), firstResult); + Assert.DoesNotContain(nameof(Model.Name).Camelize(), firstResult); + Assert.DoesNotContain(nameof(Model.Description).Camelize(), firstResult); + } + + [Fact] + public async Task QueryReadAsync_QueryHasNoIncludesOrExcludes_ReturnFoundModels() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Includes = null, Excludes = null }; + var json = JsonSerializer.Serialize(query); + var model = new Model { Id = 1 }; + var models = new List { model }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.QueryReadAsync(It.IsAny())).ReturnsAsync(models); + _postprocessingService.Setup(m => m.PostprocessReadAsync(It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryReadAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + + var typedResult = result.Value as IList; + + Assert.NotNull(typedResult); + Assert.Single(typedResult); + + var firstModel = typedResult[0]; + + Assert.Equal(model.Id, firstModel.Id); + } + + [Fact] + public async Task QueryReadAsync_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.QueryReadAsync(typeName) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + + #endregion + + #region QueryReadCountAsync + + [Fact] + public async Task QueryReadCountAsync_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.QueryReadCountAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task QueryReadCountAsync_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.QueryReadCountAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.ReadCount.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task QueryReadCountAsync_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryReadCountAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task QueryReadCountAsync_QueryIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = null; + var json = JsonSerializer.Serialize(query); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryReadCountAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.BadRequestQuery, $"{nameof(Query)} is null."), result.Value); + } + + [Fact] + public async Task QueryReadCountAsync_DeserializeThrowsExceptionQueryIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var json = @"{ ""OrderBy"": ""1""}"; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryReadCountAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + Assert.Contains(String.Format(ErrorMessage.BadRequestQuery, String.Empty), result.Value.ToString()); + Assert.Contains("OrderBy", result.Value.ToString()); + } + + [Fact] + public async Task QueryReadCountAsync_ValidateQueryIsTrueQueryIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = true; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + + var result = await _controller.QueryReadCountAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal($"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero.", result.Value); + } + + [Fact] + public async Task QueryReadCountAsync_ValidateQueryIsFalseQueryIsInvalid_ValidateQueryNotCalled() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + _preprocessingService.Setup(m => m.PreprocessReadCountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.QueryReadCountAsync(typeName); + + _validator.Verify(m => m.ValidateQuery(It.IsAny(), It.IsAny()), Times.Never); + _preserver.Verify(m => m.QueryReadCountAsync(type, It.Is(thisQuery => thisQuery.Limit == query.Limit)), Times.Once); + } + + [Fact] + public async Task QueryReadCountAsync_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadCountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.QueryReadCountAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task QueryReadCountAsync_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadCountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessReadCountAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryReadCountAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task QueryReadCountAsync_CountRead_ReturnsCount() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Includes = null, Excludes = null }; + var json = JsonSerializer.Serialize(query); + var model = new Model { Id = 1 }; + var models = new List { model }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preprocessingService.Setup(m => m.PreprocessReadCountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.QueryReadCountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(models.Count); + _postprocessingService.Setup(m => m.PostprocessReadCountAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryReadCountAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + + var typedResult = (long)result.Value; + + Assert.Equal(models.Count, typedResult); + } + + [Fact] + public async Task QueryReadCountAsync_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.QueryReadCountAsync(typeName) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + + #endregion + + #region UpdateAsync + [Fact] + public async Task UpdateAsync_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.UpdateAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task UpdateAsync_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.UpdateAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.Update.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task UpdateAsync_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.UpdateAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task UpdateAsync_ValidationResultIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult + { + IsValid = false, + Message = "some-message" + }; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(validationResult); + + var result = await _controller.UpdateAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(validationResult.Message, result.Value); + } + + [Fact] + public async Task UpdateAsync_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.UpdateAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task UpdateAsync_UpdatedModelIsNull_ReturnsNotFound() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + Model? updatedModel = null; + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.UpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(updatedModel); + + var result = await _controller.UpdateAsync(typeName, id) as NotFoundObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.NotFoundUpdate, typeName), result.Value); + } + + [Fact] + public async Task UpdateAsync_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + Model updatedModel = model; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.UpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(updatedModel); + _postprocessingService.Setup(m => m.PostprocessUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.UpdateAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task UpdateAsync_UpdatedModelIsFound_ReturnsUpdatedModel() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + Model updatedModel = model; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.UpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(updatedModel); + _postprocessingService.Setup(m => m.PostprocessUpdateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.UpdateAsync(typeName, id) as OkObjectResult; + + Assert.NotNull(result); + + var typedResult = result.Value as Model; + + Assert.NotNull(typedResult); + Assert.Equal(model.Id, typedResult.Id); + } + + [Fact] + public async Task UpdateAsync_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Guid id = Guid.Empty; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.UpdateAsync(typeName, id) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region PartialUpdateAsync_WithStringGuid + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.PartialUpdateAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.PartialUpdateAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.PartialUpdateWithId.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task PartialUpdateAsync_WithStringGuid_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.PartialUpdateAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_ValidationResultIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult + { + IsValid = false, + Message = "some-message" + }; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + + var result = await _controller.PartialUpdateAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(validationResult.Message, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.PartialUpdateAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_UpdatedModelIsNull_ReturnsNotFound() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + Model? updatedModel = null; + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.PartialUpdateAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(updatedModel); + + var result = await _controller.PartialUpdateAsync(typeName, id) as NotFoundObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.NotFoundUpdate, typeName), result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + Model updatedModel = model; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.PartialUpdateAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(updatedModel); + _postprocessingService.Setup(m => m.PostprocessPartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.PartialUpdateAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_UpdatedModelIsFound_ReturnsUpdatedModel() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + Model updatedModel = model; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.PartialUpdateAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(updatedModel); + _postprocessingService.Setup(m => m.PostprocessPartialUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.PartialUpdateAsync(typeName, id) as OkObjectResult; + + Assert.NotNull(result); + + var typedResult = result.Value as Model; + + Assert.NotNull(typedResult); + Assert.Equal(model.Id, typedResult.Id); + } + + [Fact] + public async Task PartialUpdateAsync_WithStringGuid_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Guid id = Guid.Empty; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.PartialUpdateAsync(typeName, id) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region PartialUpdateAsync_WithString + [Fact] + public async Task PartialUpdateAsync_WithString_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.PartialUpdateAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithString_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.PartialUpdateAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.PartialUpdateWithQueryParams.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task PartialUpdateAsync_WithString_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.PartialUpdateAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithString_ValidationResultIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult + { + IsValid = false, + Message = "some-message" + }; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(validationResult); + + var result = await _controller.PartialUpdateAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(validationResult.Message, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithString_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.PartialUpdateAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithString_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessPartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.PartialUpdateAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithString_UpdatedCountReturned_ReturnsUpdatedCount() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var updatedCount = 1; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidatePartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessPartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.PartialUpdateAsync(It.IsAny>(), It.IsAny>())).ReturnsAsync(updatedCount); + _postprocessingService.Setup(m => m.PostprocessPartialUpdateAsync(It.IsAny(), It.IsAny?>(), It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.PartialUpdateAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + Assert.True(result.Value is long); + Assert.Equal(updatedCount, (long)result.Value); + } + + [Fact] + public async Task PartialUpdateAsync_WithString_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.PartialUpdateAsync(typeName) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region DeleteAsync_WithStringGuid + [Fact] + public async Task DeleteAsync_WithStringGuid_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.DeleteAsync(typeName, id) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithStringGuid_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + Guid id = Guid.Empty; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.DeleteAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.DeleteWithId.ToString().Humanize(), type.Name), result.Value); + } + + [Fact] + public async Task DeleteAsync_WithStringGuid_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.DeleteAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithStringGuid_DeletedCountIsZero_ReturnsNotFound() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var deletedCount = 0; + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.DeleteAsync(It.IsAny())).ReturnsAsync(deletedCount); + + var result = await _controller.DeleteAsync(typeName, id) as NotFoundObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.NotFoundDelete, typeName), result.Value); + } + + [Fact] + public async Task DeleteAsync_WithStringGuid_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var deletedCount = 1; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.DeleteAsync(It.IsAny())).ReturnsAsync(deletedCount); + _postprocessingService.Setup(m => m.PostprocessDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.DeleteAsync(typeName, id) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithStringGuid_DeletedCountIsNotZero_ReturnsDeletedCount() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Guid id = Guid.Empty; + var deletedCount = 1; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.DeleteAsync(It.IsAny())).ReturnsAsync(deletedCount); + _postprocessingService.Setup(m => m.PostprocessDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.DeleteAsync(typeName, id) as OkObjectResult; + + Assert.NotNull(result); + Assert.True(result.Value is long); + Assert.Equal(deletedCount, (long)result.Value); + } + + [Fact] + public async Task DeleteAsync_WithStringGuid_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Guid id = Guid.Empty; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.DeleteAsync(typeName, id) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region DeleteAsync_WithString + [Fact] + public async Task DeleteAsync_WithString_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.DeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithString_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.DeleteAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.DeleteWithQueryParams.ToString().Humanize(), type.Name), result.Value); + } + + [Fact] + public async Task DeleteAsync_WithString_ValidationResultIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult + { + IsValid = false, + Message = "some-message" + }; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateDeleteAsync(It.IsAny(), It.IsAny?>())).ReturnsAsync(validationResult); + + var result = await _controller.DeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(validationResult.Message, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithString_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateDeleteAsync(It.IsAny(), It.IsAny?>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.DeleteAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithString_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateDeleteAsync(It.IsAny(), It.IsAny?>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessDeleteAsync(It.IsAny(), It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.DeleteAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task DeleteAsync_WithString_DeletedCountReturned_ReturnsDeletedCount() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var model = new Model { Id = 1 }; + var json = JsonSerializer.Serialize(model); + var validationResult = new ValidationResult { IsValid = true }; + var deletedCount = 1; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _validator.Setup(m => m.ValidateDeleteAsync(It.IsAny(), It.IsAny?>())).ReturnsAsync(validationResult); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny>())).ReturnsAsync(preprocessingMessageResult); + _preserver.Setup(m => m.DeleteAsync(It.IsAny>())).ReturnsAsync(deletedCount); + _postprocessingService.Setup(m => m.PostprocessDeleteAsync(It.IsAny(), It.IsAny>(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.DeleteAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + Assert.True(result.Value is long); + Assert.Equal(deletedCount, (long)result.Value); + } + + [Fact] + public async Task DeleteAsync_WithString_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Guid id = Guid.Empty; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.DeleteAsync(typeName, id) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + #endregion + + #region QueryDeleteAsync + + [Fact] + public async Task QueryDeleteAsync_TypeIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = null; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.QueryDeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestModelType, result.Value); + } + + [Fact] + public async Task QueryDeleteAsync_TypeDoesNotAllowCrudOperation_ReturnsMethodNotAllowed() + { + var typeName = "some-type-name"; + Type? type = typeof(ModelWithPreventCrudAttribute); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + + var result = await _controller.QueryDeleteAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); + Assert.Equal(String.Format(ErrorMessage.MethodNotAllowedType, CrudOperation.DeleteWithQuery.ToString().Humanize(), type.Name), result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task QueryDeleteAsync_JsonIsNullOrEmpty_ReturnsBadRequest(String json) + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryDeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(ErrorMessage.BadRequestBody, result.Value); + } + + [Fact] + public async Task QueryDeleteAsync_QueryIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = null; + var json = JsonSerializer.Serialize(query); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryDeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal(String.Format(ErrorMessage.BadRequestQuery, $"{nameof(Query)} is null."), result.Value); + } + + [Fact] + public async Task QueryDeleteAsync_DeserializeThrowsExceptionQueryIsNull_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + var json = @"{ ""OrderBy"": ""1""}"; + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + + var result = await _controller.QueryDeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + Assert.Contains(String.Format(ErrorMessage.BadRequestQuery, String.Empty), result.Value.ToString()); + Assert.Contains("OrderBy", result.Value.ToString()); + } + + [Fact] + public async Task QueryDeleteAsync_ValidateQueryIsTrueQueryIsInvalid_ReturnsBadRequest() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = true; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + + var result = await _controller.QueryDeleteAsync(typeName) as BadRequestObjectResult; + + Assert.NotNull(result); + Assert.Equal($"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero.", result.Value); + } + + [Fact] + public async Task QueryDeleteAsync_ValidateQueryIsFalseQueryIsInvalid_ValidateQueryNotCalled() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + var preprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.QueryDeleteAsync(typeName); + + _validator.Verify(m => m.ValidateQuery(It.IsAny(), It.IsAny()), Times.Never); + _preserver.Verify(m => m.QueryDeleteAsync(type, It.Is(thisQuery => thisQuery.Limit == query.Limit)), Times.Once); + } + + [Fact] + public async Task QueryDeleteAsync_PreprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + var preprocessingMessageResult = new MessageResult(false, "preprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + + var result = await _controller.QueryDeleteAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(preprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task QueryDeleteAsync_PostprocessingIsNotSuccessful_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Limit = -1 }; + var json = JsonSerializer.Serialize(query); + var validationResult = new ValidationResult(false, $"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero."); + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(false, "postprocessing-failed"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _validator.Setup(m => m.ValidateQuery(It.IsAny(), It.IsAny())).Returns(validationResult); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryDeleteAsync(typeName) as ObjectResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal(postprocessingMessageResult.Message, result.Value); + } + + [Fact] + public async Task QueryDeleteAsync_ModelsAreDeleted_ReturnsDeletedCount() + { + var typeName = "some-type-name"; + Type? type = typeof(Model); + Query? query = new Query { Includes = null, Excludes = null }; + var json = JsonSerializer.Serialize(query); + var model = new Model { Id = 1 }; + var models = new List { model }; + var preprocessingMessageResult = new MessageResult(true); + var postprocessingMessageResult = new MessageResult(true); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Returns(type); + _streamService.Setup(m => m.ReadToEndThenDisposeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(json); + _applicationOptions.Value.ValidateQuery = false; + _preserver.Setup(m => m.QueryDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(models.Count); + _preprocessingService.Setup(m => m.PreprocessDeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(preprocessingMessageResult); + _postprocessingService.Setup(m => m.PostprocessDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(postprocessingMessageResult); + + var result = await _controller.QueryDeleteAsync(typeName) as OkObjectResult; + + Assert.NotNull(result); + Assert.NotNull(result.Value); + + var typedResult = (long)result.Value; + + Assert.Equal(models.Count, typedResult); + } + + [Fact] + public async Task QueryDeleteAsync_ExceptionThrown_ReturnsInternalServerError() + { + var typeName = "some-type-name"; + var exception = new Exception("an-error-occurred"); + + _typeService.Setup(m => m.GetModelType(It.IsAny())).Throws(exception); + + var result = await _controller.QueryDeleteAsync(typeName) as StatusCodeResult; + + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + } + + #endregion + + [PreventCrud] + private class ModelWithPreventCrudAttribute + { + public Int32 Id { get; set; } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Crud.Api.Tests.csproj b/Crud.Tests/Crud.Api.Tests/Crud.Api.Tests.csproj new file mode 100644 index 0000000..fbfe329 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Crud.Api.Tests.csproj @@ -0,0 +1,31 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Crud.Tests/Crud.Api.Tests/Extensions/BsonDocumentExtensionsTests.cs b/Crud.Tests/Crud.Api.Tests/Extensions/BsonDocumentExtensionsTests.cs new file mode 100644 index 0000000..0d4e606 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Extensions/BsonDocumentExtensionsTests.cs @@ -0,0 +1,29 @@ +using Crud.Api.Tests.TestingModels; +using MongoDB.Bson; + +namespace Crud.Api.Tests.Extensions +{ + public class BsonDocumentExtensionsTests + { + [Fact] + public void FromBsonDocument_BsonDocumentIsNull_ReturnsDefault() + { + BsonDocument? bsonDocument = null; + + var result = bsonDocument.FromBsonDocument(); + + Assert.Equal((Model?)default, result); + } + + [Fact] + public void FromBsonDocument_BsonDocumentIsNotNull_ReturnsDefault() + { + var model = new Model { Id = 1 }; + BsonDocument bsonDocument = model.ToBsonDocument(); + + var result = bsonDocument.FromBsonDocument(); + + Assert.Equal(model.Id, result!.Id); + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Extensions/GenericExtensionsTests.cs b/Crud.Tests/Crud.Api.Tests/Extensions/GenericExtensionsTests.cs new file mode 100644 index 0000000..e314d25 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Extensions/GenericExtensionsTests.cs @@ -0,0 +1,80 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Crud.Api.Tests.TestingModels; + +namespace Crud.Api.Tests.Extensions +{ + public class GenericExtensionsTests + { + [Fact] + public void GetTableAttribute_TIsNull_ReturnsNull() + { + Model? model = null; + + var result = model.GetTableAttribute(); + + Assert.Null(result); + } + + [Fact] + public void GetTableAttribute_ModelWithoutTableAttribute_ReturnsNull() + { + var model = new ModelWithoutTableAttribute { Id = 1 }; + + var result = model.GetTableAttribute(); + + Assert.Null(result); + } + + [Fact] + public void GetTableAttribute_ModelWithTableAttribute_ReturnsNameInTableAttribute() + { + var model = new ModelWithTableAttribute { Id = 1 }; + + var result = model.GetTableAttribute(); + + Assert.NotNull(result); + Assert.Equal(nameof(ModelWithTableAttribute), result.Name); + } + + [Fact] + public void GetTableName_ModelIsNull_ReturnsNull() + { + Model? model = null; + + var result = model.GetTableName(); + + Assert.Null(result); + } + + [Fact] + public void GetTableName_TableAttributeIsNotNull_ReturnsNameInTableAttribute() + { + var model = new ModelWithTableAttribute { Id = 1 }; + + var result = model.GetTableName(); + + Assert.Equal(nameof(ModelWithTableAttribute), result); + } + + [Fact] + public void GetTableName_TableAttributeIsNull_ReturnsPluralizedNameOfClass() + { + var model = new ModelWithoutTableAttribute { Id = 1 }; + + var result = model.GetTableName(); + + Assert.Equal("ModelWithoutTableAttributes", result); + } + + [Table(nameof(ModelWithTableAttribute))] + private class ModelWithTableAttribute + { + public Int32 Id { get; set; } + } + + private class ModelWithoutTableAttribute + { + public Int32 Id { get; set; } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Extensions/ObjectExtensionsTests.cs b/Crud.Tests/Crud.Api.Tests/Extensions/ObjectExtensionsTests.cs new file mode 100644 index 0000000..d6b804a --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Extensions/ObjectExtensionsTests.cs @@ -0,0 +1,229 @@ +using System.ComponentModel.DataAnnotations; +using Crud.Api.Constants; + +namespace Crud.Api.Tests.Extensions +{ + public class ObjectExtensionsTests + { + private const Char _delimiter = Delimiter.QueryParamChildProperty; + + [Fact] + public void ValidateDataAnnotations_ModelIsNull_ThrowsArgumentNullException() + { + ModelWithNoDataAnnotations? model = null; + + var action = () => model.ValidateDataAnnotations(); + + var exception = Assert.Throws(action); + Assert.Equal("model", exception.ParamName); + } + + [Fact] + public void ValidateDataAnnotations_NoDataAnnotations_ReturnsTrueValidationResult() + { + var model = new ModelWithNoDataAnnotations(); + + var result = model.ValidateDataAnnotations(); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateDataAnnotations_ModelHasInvalidDataAnnotations_ReturnsFalseValidationResultWithFirstFailedMessage() + { + var model = new ModelWithDataAnnotations(); + var validateChildModels = false; + + var result = model.ValidateDataAnnotations(validateChildModels); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ModelWithDataAnnotations.FirstName)} field is required.", result.Message); + } + + [Fact] + public void ValidateDataAnnotations_ModelHasValidDataAnnotations_ReturnsTrueValidationResult() + { + var model = new ModelWithDataAnnotations + { + FirstName = "FirstName", + LastName = "LastName" + }; + var validateChildModels = false; + + var result = model.ValidateDataAnnotations(validateChildModels); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateDataAnnotations_ChildHasInvalidDataAnnotationsValidateChildModelsIsFalse_ReturnsTrueValidationResult() + { + var model = new ModelWithDataAnnotations + { + FirstName = "FirstName", + LastName = "LastName", + Child = new ChildModelWithDataAnnotations() + }; + var validateChildModels = false; + + var result = model.ValidateDataAnnotations(validateChildModels); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateDataAnnotations_ChildHasInvalidDataAnnotationsValidateChildModelsIsTrue_ReturnsFalseValidationResultWithFirstFailedMessage() + { + var model = new ModelWithDataAnnotations + { + FirstName = "FirstName", + LastName = "LastName", + Child = new ChildModelWithDataAnnotations() + }; + + var result = model.ValidateDataAnnotations(); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ChildModelWithDataAnnotations.Description)} field is required.", result.Message); + } + + [Fact] + public void ValidateDataAnnotations_DerivedHasInvalidDataAnnotations_ReturnsFalseValidationResultWithFirstFailedMessage() + { + var model = new DerivedModelWithDataAnnotations + { + FirstName = "FirstName", + LastName = "LastName" + }; + + var result = model.ValidateDataAnnotations(); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(DerivedModelWithDataAnnotations.DerivedName)} field is required.", result.Message); + } + + [Theory] + [ClassData(typeof(ModelPropertiesToValidateContainsNoInvalidMemberNames))] + public void ValidateDataAnnotations_ModelPropertiesToValidateContainsNoInvalidMemberNames_ReturnsTrueValidationResult(IReadOnlyCollection propertiesToValidate) + { + var model = new ModelWithDataAnnotations(); + var validateChildModels = false; + + var result = model.ValidateDataAnnotations(validateChildModels, propertiesToValidate); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateDataAnnotations_ModelPropertiesToValidateContainsInvalidMemberNames_ReturnsFalseValidationResultWithFirstFailedMessage() + { + var model = new ModelWithDataAnnotations(); + var validateChildModels = false; + IReadOnlyCollection propertiesToValidate = new List + { + nameof(ModelWithDataAnnotations.FirstName) + }; + + var result = model.ValidateDataAnnotations(validateChildModels, propertiesToValidate); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ModelWithDataAnnotations.FirstName)} field is required.", result.Message); + } + + [Theory] + [ClassData(typeof(ChildPropertiesToValidateContainsNoInvalidMemberNames))] + public void ValidateDataAnnotations_ChildPropertiesToValidateContainsNoInvalidMemberNames_ReturnsTrueValidationResult(IReadOnlyCollection propertiesToValidate) + { + var model = new ModelWithDataAnnotations + { + FirstName = "FirstName", + LastName = "LastName", + Child = new ChildModelWithDataAnnotations() + }; + var validateChildModels = true; + + var result = model.ValidateDataAnnotations(validateChildModels, propertiesToValidate, _delimiter); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateDataAnnotations_ChildPropertiesToValidateContainsInvalidMemberNames_ReturnsFalseValidationResultWithFirstFailedMessage() + { + var model = new ModelWithDataAnnotations + { + FirstName = "FirstName", + LastName = "LastName", + Child = new ChildModelWithDataAnnotations() + }; + var validateChildModels = true; + IReadOnlyCollection propertiesToValidate = new List + { + $"{nameof(ModelWithDataAnnotations.Child)}{_delimiter}{nameof(ChildModelWithDataAnnotations.Description)}" + }; + + var result = model.ValidateDataAnnotations(validateChildModels, propertiesToValidate, _delimiter); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ChildModelWithDataAnnotations.Description)} field is required.", result.Message); + } + + private class ModelWithDataAnnotations + { + [Required] + [StringLength(10)] + public String? FirstName { get; set; } + [Required] + public String? LastName { get; set; } + public ChildModelWithDataAnnotations? Child { get; set; } + } + + private class ChildModelWithDataAnnotations + { + [Required] + [StringLength(10)] + public String? Description { get; set; } + } + + private class DerivedModelWithDataAnnotations : ModelWithDataAnnotations + { + [Required] + [StringLength(10)] + public String? DerivedName { get; set; } + } + + private class ModelWithNoDataAnnotations + { + public String? FirstName { get; set; } + public String? LastName { get; set; } + } + + private class ModelPropertiesToValidateContainsNoInvalidMemberNames : TheoryData> + { + public ModelPropertiesToValidateContainsNoInvalidMemberNames() + { + Add(new List()); + Add(new List { "PropertyDoesNotExist" }); + } + } + + private class ChildPropertiesToValidateContainsNoInvalidMemberNames : TheoryData> + { + public ChildPropertiesToValidateContainsNoInvalidMemberNames() + { + Add(new List()); + Add(new List { $"{nameof(ModelWithDataAnnotations.Child)}{_delimiter}PropertyDoesNotExist" }); + } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Extensions/PropertyInfoExtensionsTests.cs b/Crud.Tests/Crud.Api.Tests/Extensions/PropertyInfoExtensionsTests.cs new file mode 100644 index 0000000..0b150ca --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Extensions/PropertyInfoExtensionsTests.cs @@ -0,0 +1,313 @@ +using System.Reflection; +using System.Text.Json.Serialization; +using Crud.Api.Constants; + +namespace Crud.Api.Tests.Extensions +{ + public class PropertyInfoExtensionsTests + { + private const String _parentName = "parentName"; + private const String _childName = "childName"; + + [Fact] + public void GetProperty_PropertiesAreNull_ReturnsNull() + { + PropertyInfo[]? properties = null; + var propertyName = "propertyName"; + + var result = properties.GetProperty(propertyName); + + Assert.Null(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetProperty_PropertyNameIsNullOrWhiteSpace_ReturnsNull(String? propertyName) + { + var properties = typeof(Parent).GetProperties(); + + var result = properties.GetProperty(propertyName); + + Assert.Null(result); + } + + [Fact] + public void GetProperty_PropertyNameStartsWithNotDefaultChildPropertyDelimiter_ThrowsArgumentException() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{childPropertyDelimiter}propertyName"; + + var action = () => properties.GetProperty(propertyName, childPropertyDelimiter); + + var exception = Assert.Throws(action); + Assert.Equal($"{nameof(propertyName)} cannot begin with {childPropertyDelimiter}.", exception.Message); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterNotInPropertyNamePropertyExists_ReturnsPropertyInfo() + { + var properties = typeof(Parent).GetProperties(); + var propertyName = nameof(Parent.Id); + + var result = properties.GetProperty(propertyName); + + Assert.NotNull(result); + Assert.Equal(typeof(Parent), result.ReflectedType); + Assert.Equal(typeof(int), result.PropertyType); + Assert.Equal(nameof(Parent.Id), result.Name); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterNotInPropertyNamePropertyMatchesAlias_ReturnsPropertyInfo() + { + var properties = typeof(Parent).GetProperties(); + var propertyName = _parentName; + + var result = properties.GetProperty(propertyName); + + Assert.NotNull(result); + Assert.Equal(typeof(Parent), result.ReflectedType); + Assert.Equal(typeof(string), result.PropertyType); + Assert.Equal(nameof(Parent.Name), result.Name); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterNotInPropertyNamePropertyDoesNotExist_ReturnsNull() + { + var properties = typeof(Parent).GetProperties(); + var propertyName = "PropertyDoesNotExist"; + + var result = properties.GetProperty(propertyName); + + Assert.Null(result); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNamePropertyNameDoesNotExistInParent_ReturnsNull() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"ParentPropertyDoesNotExist{childPropertyDelimiter}{nameof(Child.Id)}"; + + var result = properties.GetProperty(propertyName, childPropertyDelimiter); + + Assert.Null(result); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNameParentPropertyIsClassPropertyNameExistsInChild_ReturnsPropertyInfo() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{nameof(Parent.Child)}{childPropertyDelimiter}{nameof(Child.Id)}"; + + var result = properties.GetProperty(propertyName, childPropertyDelimiter); + + Assert.NotNull(result); + Assert.Equal(typeof(Child), result.ReflectedType); + Assert.Equal(typeof(int), result.PropertyType); + Assert.Equal(nameof(Child.Id), result.Name); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNameParentPropertyIsClassPropertyNameMatchesAliasInChild_ReturnsPropertyInfo() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{nameof(Parent.Child)}{childPropertyDelimiter}{_childName}"; + + var result = properties.GetProperty(propertyName, childPropertyDelimiter); + + Assert.NotNull(result); + Assert.Equal(typeof(Child), result.ReflectedType); + Assert.Equal(typeof(string), result.PropertyType); + Assert.Equal(nameof(Child.Name), result.Name); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNameParentPropertyImplementsIEnumerable_ReturnsPropertyInfo() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{nameof(Parent.GrandChildren)}{childPropertyDelimiter}{nameof(Child.Id)}"; + + var result = properties.GetProperty(propertyName, childPropertyDelimiter); + + Assert.NotNull(result); + Assert.Equal(typeof(Child), result.ReflectedType); + Assert.Equal(typeof(int), result.PropertyType); + Assert.Equal(nameof(Child.Id), result.Name); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNameParentPropertyIsClassPropertyNameDoesNotExistsInChild_ReturnsNull() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{nameof(Parent.Child)}{childPropertyDelimiter}ChildPropertyDoesNotExist"; + + var result = properties.GetProperty(propertyName, childPropertyDelimiter); + + Assert.Null(result); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNameParentPropertyIsNotClass_ThrowsNotSupportedException() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{nameof(Parent.Id)}{childPropertyDelimiter}propertyName"; + + var action = () => properties.GetProperty(propertyName, childPropertyDelimiter); + + var exception = Assert.Throws(action); + Assert.Equal($"Retrieving child property info from {nameof(Parent.Id)} of type {typeof(int)} is unsupported.", exception.Message); + } + + [Fact] + public void GetProperty_ChildPropertyDelimiterInPropertyNameParentPropertyIsString_ThrowsNotSupportedException() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = $"{nameof(Parent.Name)}{childPropertyDelimiter}propertyName"; + + var action = () => properties.GetProperty(propertyName, childPropertyDelimiter); + + var exception = Assert.Throws(action); + Assert.Equal($"Retrieving child property info from {nameof(Parent.Name)} of type {typeof(string)} is unsupported.", exception.Message); + } + + [Fact] + public void HasAllPropertyNames_GetPropertyNeverReturnsNull_ReturnsTrue() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyNames = new List + { + nameof(Parent.Id), + $"{nameof(Parent.Child)}{childPropertyDelimiter}{nameof(Child.Id)}" + }; + + var result = properties.HasAllPropertyNames(propertyNames, childPropertyDelimiter); + + Assert.True(result); + } + + [Fact] + public void HasAllPropertyNames_GetPropertyReturnsAtLeastOneNull_ReturnsFalse() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyNames = new List + { + nameof(Parent.Id), + $"{nameof(Parent.Child)}{childPropertyDelimiter}{nameof(Child.Id)}", + "ParentPropertyDoesNotExist" + }; + + var result = properties.HasAllPropertyNames(propertyNames, childPropertyDelimiter); + + Assert.False(result); + } + + [Fact] + public void HasPropertyName_GetPropertyReturnsNull_ReturnsFalse() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = "ParentPropertyDoesNotExist"; + + var result = properties.HasPropertyName(propertyName, childPropertyDelimiter); + + Assert.False(result); + } + + [Fact] + public void HasPropertyName_GetPropertyReturnsNotNull_ReturnsTrue() + { + var properties = typeof(Parent).GetProperties(); + var childPropertyDelimiter = Delimiter.QueryParamChildProperty; + var propertyName = nameof(Parent.Id); + + var result = properties.HasPropertyName(propertyName, childPropertyDelimiter); + + Assert.True(result); + } + + [Fact] + void MatchesAlias_PropertyInfoIsNull_ReturnsFalse() + { + PropertyInfo? propertyInfo = null; + String? propertyName = "propertyName"; + + var result = propertyInfo.MatchesAlias(propertyName); + + Assert.False(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + void MatchesAlias_PropertyNameIsNullOrWhiteSpace_ReturnsFalse(String? propertyName) + { + PropertyInfo? propertyInfo = typeof(Parent).GetProperty(nameof(Parent.Name)); + + var result = propertyInfo.MatchesAlias(propertyName); + + Assert.False(result); + } + + [Fact] + void MatchesAlias_JsonPropertyNameAttributeIsNotDefined_ReturnsFalse() + { + PropertyInfo? propertyInfo = typeof(Parent).GetProperty(nameof(Parent.Id)); + String? propertyName = "propertyName"; + + var result = propertyInfo.MatchesAlias(propertyName); + + Assert.False(result); + } + + [Fact] + void MatchesAlias_JsonPropertyNameAttributeIsDefinedNameDoesNotMatch_ReturnsFalse() + { + PropertyInfo? propertyInfo = typeof(Parent).GetProperty(nameof(Parent.Name)); + String? propertyName = "propertyName"; + + var result = propertyInfo.MatchesAlias(propertyName); + + Assert.False(result); + } + + [Fact] + void MatchesAlias_JsonPropertyNameAttributeIsDefinedNameDoesMatch_ReturnsTrue() + { + PropertyInfo? propertyInfo = typeof(Parent).GetProperty(nameof(Parent.Name)); + String? propertyName = _parentName; + + var result = propertyInfo.MatchesAlias(propertyName); + + Assert.True(result); + } + + private class Parent + { + public Int32 Id { get; set; } + [JsonPropertyName(_parentName)] + public String? Name { get; set; } + public Child? Child { get; set; } + public List? GrandChildren { get; set; } + } + + private class Child + { + public Int32 Id { get; set; } + [JsonPropertyName(_childName)] + public String? Name { get; set; } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Extensions/StringExtensionsTests.cs b/Crud.Tests/Crud.Api.Tests/Extensions/StringExtensionsTests.cs new file mode 100644 index 0000000..14bf917 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,202 @@ +using Crud.Api.Constants; + +namespace Crud.Api.Tests.Extensions +{ + public class StringExtensionsTests + { + [Fact] + public void ChangeType_ValueIsNull_ReturnsNull() + { + string? value = null; + Type type = typeof(Nullable); + + var result = value.ChangeType(type); + + Assert.Null(result); + } + + [Fact] + public void ChangeType_TypeIsNull_ThrowsArgumentNullException() + { + string value = "1"; + Type? type = null; + + var action = () => value.ChangeType(type); + + var exception = Assert.Throws(action); + Assert.Equal("type", exception.ParamName); + } + + [Fact] + public void ChangeType_TypeIsNullable_ReturnsUnderlyingType() + { + string value = "1"; + Type type = typeof(Nullable); + + var result = value.ChangeType(type); + + Assert.IsType(typeof(int), result); + Assert.Equal(1, result); + } + + [Fact] + public void ChangeType_TypeIsGuid_ReturnsGuid() + { + string value = "00000000-0000-0000-0000-000000000000"; + Type type = typeof(Guid); + + var result = value.ChangeType(type); + + Assert.IsType(typeof(Guid), result); + Assert.Equal(Guid.Empty, result); + } + + [Fact] + public void ChangeType_TypeIsNotSpecial_ReturnsConvertedValue() + { + string value = "1"; + Type type = typeof(int); + + var result = value.ChangeType(type); + + Assert.IsType(typeof(int), result); + Assert.Equal(1, result); + } + + [Fact] + public void Pascalize_ValueIsNull_ThrowsArgumentNullException() + { + string? value = null; + char delimiter = Delimiter.MongoDbChildProperty; + + var action = () => value.Pascalize(delimiter); + + var exception = Assert.Throws(action); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void Pascalize_NoDelimiterInValue_ReturnsPascalizedValue() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = "thisDoesNotContainADelimiter"; + + var result = value.Pascalize(delimiter); + + Assert.Equal("ThisDoesNotContainADelimiter", result); + } + + [Fact] + public void Pascalize_DelimiterInValue_ReturnsPascalizedValue() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = $"this{delimiter}does{delimiter}contain{delimiter}a{delimiter}delimiter"; + + var result = value.Pascalize(delimiter); + + Assert.Equal($"This{delimiter}Does{delimiter}Contain{delimiter}A{delimiter}Delimiter", result); + } + + [Fact] + public void Camelize_ValueIsNull_ThrowsArgumentNullException() + { + string? value = null; + char delimiter = Delimiter.MongoDbChildProperty; + + var action = () => value.Camelize(delimiter); + + var exception = Assert.Throws(action); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void Camelize_NoDelimiterInValue_ReturnsCamelizedValue() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = "ThisDoesNotContainADelimiter"; + + var result = value.Camelize(delimiter); + + Assert.Equal("thisDoesNotContainADelimiter", result); + } + + [Fact] + public void Camelize_DelimiterInValue_ReturnsCamelizedValue() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = $"This{delimiter}Does{delimiter}Contain{delimiter}A{delimiter}Delimiter"; + + var result = value.Camelize(delimiter); + + Assert.Equal($"this{delimiter}does{delimiter}contain{delimiter}a{delimiter}delimiter", result); + } + + [Fact] + public void GetValueAfterFirstDelimiter_ValueIsNull_ThrowsArgumentNullException() + { + string? value = null; + char delimiter = Delimiter.MongoDbChildProperty; + + var action = () => value.GetValueAfterFirstDelimiter(delimiter); + + var exception = Assert.Throws(action); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void GetValueAfterFirstDelimiter_DelimiterInValue_ReturnsValueAfterLastDelimiter() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = $"this{delimiter}does{delimiter}contain{delimiter}a{delimiter}delimiter"; + + var result = value.GetValueAfterFirstDelimiter(delimiter); + + Assert.Equal($"does{delimiter}contain{delimiter}a{delimiter}delimiter", result); + } + + [Fact] + public void GetValueAfterFirstDelimiter_NoDelimiterInValue_ReturnsValue() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = "thisDoesNotContainADelimiter"; + + var result = value.GetValueAfterFirstDelimiter(delimiter); + + Assert.Equal(value, result); + } + + [Fact] + public void GetValueAfterLastDelimiter_ValueIsNull_ThrowsArgumentNullException() + { + string? value = null; + char delimiter = Delimiter.MongoDbChildProperty; + + var action = () => value.GetValueAfterLastDelimiter(delimiter); + + var exception = Assert.Throws(action); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void GetValueAfterLastDelimiter_DelimiterInValue_ReturnsValueAfterLastDelimiter() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = $"this{delimiter}does{delimiter}contain{delimiter}a{delimiter}delimiter"; + + var result = value.GetValueAfterLastDelimiter(delimiter); + + Assert.Equal("delimiter", result); + } + + [Fact] + public void GetValueAfterLastDelimiter_NoDelimiterInValue_ReturnsValue() + { + char delimiter = Delimiter.MongoDbChildProperty; + string value = "thisDoesNotContainADelimiter"; + + var result = value.GetValueAfterLastDelimiter(delimiter); + + Assert.Equal(value, result); + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Extensions/TypeExtensionsTests.cs b/Crud.Tests/Crud.Api.Tests/Extensions/TypeExtensionsTests.cs new file mode 100644 index 0000000..88a72fa --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Extensions/TypeExtensionsTests.cs @@ -0,0 +1,134 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Crud.Api.Attributes; +using Crud.Api.Enums; + +namespace Crud.Api.Tests.Extensions +{ + public class TypeExtensionsTests + { + [Fact] + public void GetTableAttribute_TypeIsNull_ReturnsNull() + { + Type? type = null; + + var result = type.GetTableAttribute(); + + Assert.Null(result); + } + + [Fact] + public void GetTableAttribute_TypeOfModelWithoutTableAttribute_ReturnsNull() + { + var type = typeof(ModelWithoutTableAttribute); + + var result = type.GetTableAttribute(); + + Assert.Null(result); + } + + [Fact] + public void GetTableAttribute_TypeOfModelWithTableAttribute_ReturnsNameInTableAttribute() + { + var type = typeof(ModelWithTableAttribute); + + var result = type.GetTableAttribute(); + + Assert.NotNull(result); + Assert.Equal(nameof(ModelWithTableAttribute), result.Name); + } + + [Fact] + public void GetTableName_TypeIsNull_ReturnsNull() + { + Type? type = null; + + var result = type.GetTableName(); + + Assert.Null(result); + } + + [Fact] + public void GetTableName_TableAttributeIsNotNull_ReturnsNameInTableAttribute() + { + var type = typeof(ModelWithTableAttribute); + + var result = type.GetTableName(); + + Assert.Equal(nameof(ModelWithTableAttribute), result); + } + + [Fact] + public void GetTableName_TableAttributeIsNull_ReturnsPluralizedNameOfClass() + { + var type = typeof(ModelWithoutTableAttribute); + + var result = type.GetTableName(); + + Assert.Equal("ModelWithoutTableAttributes", result); + } + + [Fact] + public void GetPluralizedName_TypeIsNull_ReturnsNull() + { + Type? type = null; + + var result = type.GetPluralizedName(); + + Assert.Null(result); + } + + [Fact] + public void GetPluralizedName_TypeIsNotNull_ReturnsPluralizedNameOfClass() + { + Type? type = typeof(ModelWithoutTableAttribute); + + var result = type.GetPluralizedName(); + + Assert.Equal("ModelWithoutTableAttributes", result); + } + + [Fact] + public void AllowsCrudOperation_AttributeIsNull_ReturnsTrue() + { + Type type = typeof(ModelWithoutPreventCrudAttribute); + var crudOperation = CrudOperation.Create; + + var result = type.AllowsCrudOperation(crudOperation); + + Assert.True(result); + } + + [Fact] + public void AllowsCrudOperation_AttributeIsNotNull_ReturnsAllowsCrudOperationResult() + { + Type type = typeof(ModelWithPreventCrudAttribute); + var crudOperation = CrudOperation.Create; + + var result = type.AllowsCrudOperation(crudOperation); + + Assert.False(result); + } + + [Table(nameof(ModelWithTableAttribute))] + private class ModelWithTableAttribute + { + public Int32 Id { get; set; } + } + + private class ModelWithoutTableAttribute + { + public Int32 Id { get; set; } + } + + [PreventCrud] + private class ModelWithPreventCrudAttribute + { + public Int32 Id { get; set; } + } + + private class ModelWithoutPreventCrudAttribute + { + public Int32 Id { get; set; } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Helpers/RelectionHelperTests.cs b/Crud.Tests/Crud.Api.Tests/Helpers/RelectionHelperTests.cs new file mode 100644 index 0000000..838836b --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Helpers/RelectionHelperTests.cs @@ -0,0 +1,54 @@ +using Crud.Api.Helpers; + +namespace Crud.Api.Tests.Helpers +{ + public class RelectionHelperTests + { + [Fact] + public void GetGenericMethod_MethodIsNull_ThrowsException() + { + var t = typeof(SomeOtherClass); + var classOfMethod = typeof(ClassWithGenericMethod); + var methodName = "ThisMethodDoesNotExist"; + var parameterTypes = new Type[] { typeof(string), typeof(Guid) }; + + var action = () => ReflectionHelper.GetGenericMethod(t, classOfMethod, methodName, parameterTypes); + + var exception = Assert.Throws(action); + Assert.Equal($"Unable to get method. {methodName} does not exist.", exception.Message); + } + + [Fact] + public void GetGenericMethod_MethodIsNotNull_ReturnsMethodInfo() + { + var t = typeof(SomeOtherClass); + var classOfMethod = typeof(ClassWithGenericMethod); + var methodName = nameof(ClassWithGenericMethod.SomeGenericMethod); + var parameterTypes = new Type[] { typeof(string), typeof(Guid) }; + + var result = ReflectionHelper.GetGenericMethod(t, classOfMethod, methodName, parameterTypes); + + Assert.NotNull(result); + Assert.Equal(t, result.ReturnType); + Assert.Equal(parameterTypes.Length, result.GetParameters().Length); + } + + private class ClassWithGenericMethod + { + public T SomeGenericMethod() where T : new() + { + return new T(); + } + + public T SomeGenericMethod(String someStringParameter, Guid someGuidParameter) where T : new() + { + return new T(); + } + } + + private class SomeOtherClass + { + public Int32 Id { get; set; } + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Services/MongoDbServiceTests.cs b/Crud.Tests/Crud.Api.Tests/Services/MongoDbServiceTests.cs new file mode 100644 index 0000000..8ed238f --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Services/MongoDbServiceTests.cs @@ -0,0 +1,1437 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Crud.Api.Constants; +using Crud.Api.Models; +using Crud.Api.QueryModels; +using Crud.Api.Services; +using Crud.Api.Tests.TestingModels; +using Humanizer; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Crud.Api.Tests.Services +{ + public class MongoDbServiceTests + { + private MongoDbService _mongoDbService; + + public MongoDbServiceTests() + { + _mongoDbService = new MongoDbService(); + } + + [Fact] + public void GetTableName_TableNameIsNull_ThrowsException() + { + Type? type = null; + + var action = () => _mongoDbService.GetTableName(type); + + var exception = Assert.Throws(action); + Assert.Equal($"No table name found on type {type?.Name}.", exception.Message); + } + + [Fact] + public void GetTableName_TableNameIsNotNull_ReturnsTableName() + { + Type type = typeof(ModelWithTableAttribute); + + var result = _mongoDbService.GetTableName(type); + + Assert.Equal(nameof(ModelWithTableAttribute), result); + } + + [Fact] + public void GetIdFilter_ModelImplementsIExternalEntity_ReturnsFilterOnExternalId() + { + Type type = typeof(ModelImplementsIExternalEntity); + Guid id = Guid.Empty; + + var result = _mongoDbService.GetIdFilter(type, id); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Eq(nameof(IExternalEntity.ExternalId).Camelize(), id); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetIdFilter_ModelDoesNotImplementIExternalEntity_ReturnsFilterOnId() + { + Type type = typeof(ModelDoesNotImplementIExternalEntity); + Guid id = Guid.Empty; + + var result = _mongoDbService.GetIdFilter(type, id); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Eq("id", id); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(QueryParamsIsNullOrEmpty))] + public void GetQueryParamFilter_QueryParamsIsNullOrEmpty_ReturnsEmptyFilter(IDictionary? queryParams) + { + Type type = typeof(ModelDoesNotImplementIExternalEntity); + + var result = _mongoDbService.GetQueryParamFilter(type, queryParams); + + Assert.NotNull(result); + + var expectedFilter = new BsonDocument(); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetQueryParamFilter_QueryParamsIsNotNullOrEmpty_ReturnsFilterPerQueryParam() + { + Type type = typeof(ModelDoesNotImplementIExternalEntity); + var externalId = Guid.Empty; + var name = "Name"; + var total = 1; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelDoesNotImplementIExternalEntity.ExternalId), externalId.ToString() }, + { nameof(ModelDoesNotImplementIExternalEntity.Name), name }, + { nameof(ModelDoesNotImplementIExternalEntity.Total), total.ToString() } + }; + + var result = _mongoDbService.GetQueryParamFilter(type, queryParams); + + Assert.NotNull(result); + + FilterDefinition expectedFilter = new BsonDocument(); + expectedFilter &= Builders.Filter.Eq(nameof(ModelDoesNotImplementIExternalEntity.ExternalId).Camelize(), externalId); + expectedFilter &= Builders.Filter.Eq(nameof(ModelDoesNotImplementIExternalEntity.Name).Camelize(), name); + expectedFilter &= Builders.Filter.Eq(nameof(ModelDoesNotImplementIExternalEntity.Total).Camelize(), total); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetShallowUpdates_JsonElementIsNull_ReturnsUpdateDefinitionsWithOneUpdateOfPropertyToNull() + { + var propertyName = nameof(ModelDoesNotImplementIExternalEntity.ExternalId).Camelize(); + JsonElement jsonElement = JsonSerializer.SerializeToElement(null, typeof(Guid?))!; + IDictionary propertyValues = new Dictionary + { + { propertyName, jsonElement } + }; + Type type = typeof(ModelDoesNotImplementIExternalEntity); + + var result = _mongoDbService.GetShallowUpdates(propertyValues, type); + + Assert.NotNull(result); + + var expectedUpdates = new List>(); + expectedUpdates.Add(Builders.Update.Set(propertyName, BsonNull.Value)); + var expectedJson = ConvertUpdatesToJson(expectedUpdates); + var resultJson = ConvertUpdatesToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetShallowUpdates_JsonElementIsNotNull_ReturnsUpdateDefinitionsWithOneUpdateOfPropertyToValue() + { + var propertyName = nameof(ModelDoesNotImplementIExternalEntity.ExternalId).Camelize(); + var value = Guid.Empty; + JsonElement jsonElement = JsonSerializer.SerializeToElement(value, typeof(Guid?))!; + IDictionary propertyValues = new Dictionary + { + { propertyName, jsonElement } + }; + Type type = typeof(ModelDoesNotImplementIExternalEntity); + + var result = _mongoDbService.GetShallowUpdates(propertyValues, type); + + Assert.NotNull(result); + + var expectedUpdates = new List>(); + expectedUpdates.Add(Builders.Update.Set(propertyName, value)); + var expectedJson = ConvertUpdatesToJson(expectedUpdates); + var resultJson = ConvertUpdatesToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetAllPropertiesToUpdate_JsonElementIsNotJsonObjectAndIsNull_ReturnsUpdateDefinitionsWithOneUpdateOfPropertyNameAndValue() + { + var propertyName = nameof(ModelDoesNotImplementIExternalEntity.ExternalId); + Type type = typeof(ModelDoesNotImplementIExternalEntity); + JsonElement jsonElement = JsonSerializer.SerializeToElement(null, typeof(Guid?))!; + + var result = _mongoDbService.GetAllPropertiesToUpdate(propertyName, type, jsonElement); + + Assert.NotNull(result); + + var expectedUpdates = new List>(); + expectedUpdates.Add(Builders.Update.Set(propertyName, BsonNull.Value)); + var expectedJson = ConvertUpdatesToJson(expectedUpdates); + var resultJson = ConvertUpdatesToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetAllPropertiesToUpdate_JsonElementIsNotJsonObjectAndIsNotNull_ReturnsUpdateDefinitionsWithOneUpdateOfPropertyNameAndValue() + { + var propertyName = nameof(ModelDoesNotImplementIExternalEntity.ExternalId); + Type type = typeof(ModelDoesNotImplementIExternalEntity); + var value = Guid.Empty; + JsonElement jsonElement = JsonSerializer.SerializeToElement(value, typeof(Guid?))!; + + var result = _mongoDbService.GetAllPropertiesToUpdate(propertyName, type, jsonElement); + + Assert.NotNull(result); + + var expectedUpdates = new List>(); + expectedUpdates.Add(Builders.Update.Set(propertyName, value)); + var expectedJson = ConvertUpdatesToJson(expectedUpdates); + var resultJson = ConvertUpdatesToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetAllPropertiesToUpdate_JsonElementIsJsonObject_ReturnsUpdateDefinitionsWithOneUpdateOfPropertyNameAndValue() + { + var propertyName = nameof(ModelDoesNotImplementIExternalEntity.ChildModel).Camelize(); + Type type = typeof(ModelDoesNotImplementIExternalEntity); + var name = "ChildName"; + var value = new ChildModel { Name = name }; + JsonElement jsonElement = JsonSerializer.SerializeToElement(value, typeof(ChildModel))!; + + var result = _mongoDbService.GetAllPropertiesToUpdate(propertyName, type, jsonElement); + + Assert.NotNull(result); + + var expectedUpdates = new List>(); + expectedUpdates.Add(Builders.Update.Set($"{nameof(ModelDoesNotImplementIExternalEntity.ChildModel).Camelize()}{Delimiter.MongoDbChildProperty}{nameof(ChildModel.Name).Camelize()}", name)); + var expectedJson = ConvertUpdatesToJson(expectedUpdates); + var resultJson = ConvertUpdatesToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_ConditionIsNull_ReturnsEmptyFilterDefinition() + { + var type = typeof(Model); + Condition? condition = null; + + var result = _mongoDbService.GetConditionFilter(type, condition); + + Assert.NotNull(result); + + FilterDefinition expectedFilter = new BsonDocument(); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_SingleConditionFieldTypeNotIEnumerableValuesIsNull_ReturnsSimpleFilterDefinition() + { + var type = typeof(Model); + var field = nameof(Model.Id).Camelize(); + var value = 1; + Condition? condition = new Condition + { + Field = field, + ComparisonOperator = Operator.Equality, + Value = value.ToString() + }; + + var result = _mongoDbService.GetConditionFilter(type, condition); + + Assert.NotNull(result); + + FilterDefinition expectedFilter = Builders.Filter.Eq(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_SingleConditionFieldTypeIsIEnumerableValuesIsNull_ReturnsSimpleFilterDefinition() + { + var type = typeof(ModelDoesNotImplementIExternalEntity); + var field = nameof(ModelDoesNotImplementIExternalEntity.Ints).Camelize(); + var value = 1; + Condition? condition = new Condition + { + Field = field, + ComparisonOperator = Operator.Equality, + Value = value.ToString() + }; + + var result = _mongoDbService.GetConditionFilter(type, condition); + + Assert.NotNull(result); + + FilterDefinition expectedFilter = Builders.Filter.Eq(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_SingleConditionFieldTypeNotIEnumerableValuesIsNotNull_ReturnsSimpleFilterDefinition() + { + var type = typeof(Model); + var field = nameof(Model.Id).Camelize(); + var values = new List { 1, 2, 3 }; + Condition? condition = new Condition + { + Field = field, + ComparisonOperator = Operator.In, + Values = values.Select(value => value.ToString()).ToList() + }; + + var result = _mongoDbService.GetConditionFilter(type, condition); + + Assert.NotNull(result); + + FilterDefinition expectedFilter = Builders.Filter.In(field, values); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_SingleConditionFieldTypeIsIEnumerableValuesIsNotNull_ReturnsSimpleFilterDefinition() + { + var type = typeof(ModelDoesNotImplementIExternalEntity); + var field = nameof(ModelDoesNotImplementIExternalEntity.Ints).Camelize(); + var values = new List { 1, 2, 3 }; + Condition? condition = new Condition + { + Field = field, + ComparisonOperator = Operator.In, + Values = values.Select(value => value.ToString()).ToList() + }; + + var result = _mongoDbService.GetConditionFilter(type, condition); + + Assert.NotNull(result); + + FilterDefinition expectedFilter = Builders.Filter.In(field, values); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_GroupedConditionsRootLogicalOperatorIsNull_ReturnsAndOfGroupedConditionsFilterDefinition() + { + var type = typeof(Model); + var field1 = nameof(Model.Id).Camelize(); + var value1 = 1; + var field2 = nameof(Model.Id).Camelize(); + var value2 = 2; + var field3 = nameof(Model.Id).Camelize(); + var value3 = 3; + Condition? condition = new Condition + { + GroupedConditions = new List + { + new GroupedCondition + { + LogicalOperator = Operator.Or, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + } + } + }; + + var result = _mongoDbService.GetConditionFilter(type, condition); + + Assert.NotNull(result); + + var groupedConditionFilters = new List> + { + Builders.Filter.Eq(field1, value1), + Builders.Filter.Eq(field2, value2), + Builders.Filter.Eq(field3, value3) + }; + var filter = Builders.Filter.Or(groupedConditionFilters); + FilterDefinition expectedFilter = Builders.Filter.And(filter); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionFilter_GroupedConditionsRootLogicalOperatorIsNotNull_ReturnsRootLogicalOperatorOfGroupedConditionsFilterDefinition() + { + var type = typeof(Model); + var field1 = nameof(Model.Id).Camelize(); + var value1 = 1; + var field2 = nameof(Model.Id).Camelize(); + var value2 = 2; + var field3 = nameof(Model.Id).Camelize(); + var value3 = 3; + var rootLogicalOperator = Operator.Or; + Condition? condition = new Condition + { + GroupedConditions = new List + { + new GroupedCondition + { + LogicalOperator = Operator.Or, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + } + } + }; + + var result = _mongoDbService.GetConditionFilter(type, condition, rootLogicalOperator); + + Assert.NotNull(result); + + var groupedConditionFilters = new List> + { + Builders.Filter.Eq(field1, value1), + Builders.Filter.Eq(field2, value2), + Builders.Filter.Eq(field3, value3) + }; + var filter = Builders.Filter.Or(groupedConditionFilters); + FilterDefinition expectedFilter = Builders.Filter.Or(filter); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionsFilters_GroupedConditionsIsNull_ReturnsListOfEmptyFilterDefinition() + { + var type = typeof(Model); + IReadOnlyCollection? groupedConditions = null; + + var result = _mongoDbService.GetConditionsFilters(type, groupedConditions); + + Assert.NotNull(result); + + var expectedFilters = new List> { new BsonDocument() }; + var expectedJson = ConvertFiltersToJson(expectedFilters); + var resultJson = ConvertFiltersToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionsFilters_GroupedConditionIsNull_ReturnsListOfOtherFilterDefinitions() + { + var type = typeof(Model); + var field1 = nameof(Model.Id).Camelize(); + var value1 = 1; + var field2 = nameof(Model.Id).Camelize(); + var value2 = 2; + var field3 = nameof(Model.Id).Camelize(); + var value3 = 3; + IReadOnlyCollection? groupedConditions = new List + { + (GroupedCondition)null, + new GroupedCondition + { + LogicalOperator = Operator.Or, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + } + }; + + var result = _mongoDbService.GetConditionsFilters(type, groupedConditions); + + Assert.NotNull(result); + + var groupedConditionFilters = new List> + { + Builders.Filter.Eq(field1, value1), + Builders.Filter.Eq(field2, value2), + Builders.Filter.Eq(field3, value3) + }; + var filter = Builders.Filter.Or(groupedConditionFilters); + var expectedFilters = new List> { filter }; + var expectedJson = ConvertFiltersToJson(expectedFilters); + var resultJson = ConvertFiltersToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionsFilters_GroupedConditionLogicalOperatorIsNull_ReturnsListOfOtherFilterDefinitions() + { + var type = typeof(Model); + var field1 = nameof(Model.Id).Camelize(); + var value1 = 1; + var field2 = nameof(Model.Id).Camelize(); + var value2 = 2; + var field3 = nameof(Model.Id).Camelize(); + var value3 = 3; + IReadOnlyCollection? groupedConditions = new List + { + new GroupedCondition { LogicalOperator = null }, + new GroupedCondition + { + LogicalOperator = Operator.Or, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + } + }; + + var result = _mongoDbService.GetConditionsFilters(type, groupedConditions); + + Assert.NotNull(result); + + var groupedConditionFilters = new List> + { + Builders.Filter.Eq(field1, value1), + Builders.Filter.Eq(field2, value2), + Builders.Filter.Eq(field3, value3) + }; + var filter = Builders.Filter.Or(groupedConditionFilters); + var expectedFilters = new List> { filter }; + var expectedJson = ConvertFiltersToJson(expectedFilters); + var resultJson = ConvertFiltersToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionsFilters_GroupedConditionConditionsIsNull_ReturnsListOfOtherFilterDefinitions() + { + var type = typeof(Model); + var field1 = nameof(Model.Id).Camelize(); + var value1 = 1; + var field2 = nameof(Model.Id).Camelize(); + var value2 = 2; + var field3 = nameof(Model.Id).Camelize(); + var value3 = 3; + IReadOnlyCollection? groupedConditions = new List + { + new GroupedCondition + { + LogicalOperator = Operator.And, + Conditions = null + }, + new GroupedCondition + { + LogicalOperator = Operator.Or, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + } + }; + + var result = _mongoDbService.GetConditionsFilters(type, groupedConditions); + + Assert.NotNull(result); + + var groupedConditionFilters = new List> + { + Builders.Filter.Eq(field1, value1), + Builders.Filter.Eq(field2, value2), + Builders.Filter.Eq(field3, value3) + }; + var filter = Builders.Filter.Or(groupedConditionFilters); + var expectedFilters = new List> { filter }; + var expectedJson = ConvertFiltersToJson(expectedFilters); + var resultJson = ConvertFiltersToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetConditionsFilters_NoGroupedConditionsSkipped_ReturnsListOfFilterDefinitions() + { + var type = typeof(Model); + var field1 = nameof(Model.Id).Camelize(); + var value1 = 1; + var field2 = nameof(Model.Id).Camelize(); + var value2 = 2; + var field3 = nameof(Model.Id).Camelize(); + var value3 = 3; + IReadOnlyCollection? groupedConditions = new List + { + new GroupedCondition + { + LogicalOperator = Operator.And, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + }, + new GroupedCondition + { + LogicalOperator = Operator.Or, + Conditions = new List + { + new Condition + { + Field = field1, + ComparisonOperator = Operator.Equality, + Value = value1.ToString() + }, + new Condition + { + Field = field2, + ComparisonOperator = Operator.Equality, + Value = value2.ToString() + }, + new Condition + { + Field = field3, + ComparisonOperator = Operator.Equality, + Value = value3.ToString() + } + } + } + }; + + var result = _mongoDbService.GetConditionsFilters(type, groupedConditions); + + Assert.NotNull(result); + + var groupedConditionFilters = new List> + { + Builders.Filter.Eq(field1, value1), + Builders.Filter.Eq(field2, value2), + Builders.Filter.Eq(field3, value3) + }; + var filter1 = Builders.Filter.And(groupedConditionFilters); + var filter2 = Builders.Filter.Or(groupedConditionFilters); + var expectedFilters = new List> { filter1, filter2 }; + var expectedJson = ConvertFiltersToJson(expectedFilters); + var resultJson = ConvertFiltersToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetLogicalOperatorFilter_LogicalAliasLookupDoesNotContainLogicalOperator_ThrowsKeyNotFoundException() + { + var logicalOperator = "NotInAliasLookup"; + var filters = new List>(); + + var action = () => _mongoDbService.GetLogicalOperatorFilter(logicalOperator, filters); + + var exception = Assert.Throws(action); + Assert.Equal($"{nameof(GroupedCondition.LogicalOperator)} '{logicalOperator}' was not found in {Operator.LogicalAliasLookup}.", exception.Message); + } + + [Theory] + [ClassData(typeof(AndAliasFound))] + public void GetLogicalOperatorFilter_AndAliasFound_ReturnsAndFilterDefinition(String logicalOperator) + { + var filters = new List>(); + + var result = _mongoDbService.GetLogicalOperatorFilter(logicalOperator, filters); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.And(filters); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(OrAliasFound))] + public void GetLogicalOperatorFilter_OrAliasFound_ReturnsOrFilterDefinition(String logicalOperator) + { + var filters = new List>(); + + var result = _mongoDbService.GetLogicalOperatorFilter(logicalOperator, filters); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Or(filters); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetComparisonOperatorFilter_WithStringStringDynamic_ComparisonAliasLookupDoesNotContainComparisonOperator_ThrowsKeyNotFoundException() + { + var field = "Field"; + var comparisonOperator = "NotInAliasLookup"; + var value = "Value"; + + var action = () => _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + var exception = Assert.Throws(action); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' was not found in {Operator.ComparisonAliasLookup}.", exception.Message); + } + + [Theory] + [ClassData(typeof(EqualityAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_EqualityOperatorFound_ReturnsEqFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Eq(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(InequalityAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_InequalityOperatorFound_ReturnsNeFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Ne(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(GreaterThanAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_GreaterThanOperatorFound_ReturnsGtFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Gt(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(GreaterThanOrEqualsAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_GreaterThanOrEqualsOperatorFound_ReturnsGteFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Gte(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(LessThanAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_LessThanOperatorFound_ReturnsLtFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Lt(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(LessThanOrEqualsAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_LessThanOrEqualsOperatorFound_ReturnsLteFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Lte(field, value); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(ContainsAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_ContainsOperatorFound_ReturnsRegexFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Regex(field, new BsonRegularExpression(value, "i")); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(StartsWithAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_StartsWithOperatorFound_ReturnsRegexFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Regex(field, new BsonRegularExpression($"^{value}", "i")); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(EndsWithAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringDynamic_EndsWithOperatorFound_ReturnsRegexFilter(String comparisonOperator) + { + var field = "Field"; + var value = "Value"; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, value); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Regex(field, new BsonRegularExpression($"{value}$", "i")); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetComparisonOperatorFilter_WithStringStringIEnumerableOfObject_ComparisonAliasLookupDoesNotContainComparisonOperator_ThrowsKeyNotFoundException() + { + var field = "Field"; + var comparisonOperator = "NotInAliasLookup"; + var values = new List { "Value1", "Value2" }; + + var action = () => _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, values); + + var exception = Assert.Throws(action); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' was not found in {Operator.ComparisonAliasLookup}.", exception.Message); + } + + [Theory] + [ClassData(typeof(InAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringIEnumerableOfObject_InOperatorFound_ReturnsInFilter(String comparisonOperator) + { + var field = "Field"; + var values = new List { "Value1", "Value2" }; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, values); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.In(field, values); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(NotInAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringIEnumerableOfObject_NotInOperatorFound_ReturnsNinFilter(String comparisonOperator) + { + var field = "Field"; + var values = new List { "Value1", "Value2" }; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, values); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.Nin(field, values); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(AllAliasFound))] + public void GetComparisonOperatorFilter_WithStringStringIEnumerableOfObject_AllOperatorFound_ReturnsAllFilter(String comparisonOperator) + { + var field = "Field"; + var values = new List { "Value1", "Value2" }; + + var result = _mongoDbService.GetComparisonOperatorFilter(field, comparisonOperator, values); + + Assert.NotNull(result); + + var expectedFilter = Builders.Filter.All(field, values); + var expectedJson = ConvertFilterToJson(expectedFilter); + var resultJson = ConvertFilterToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(OrderByIsNullOrEmpty))] + public void GetSort_OrderByIsNullOrEmpty_ReturnsEmptySortDefinition(IReadOnlyCollection? orderBy) + { + var result = _mongoDbService.GetSort(orderBy); + + Assert.NotNull(result); + + var expectedSort = Builders.Sort.ToBsonDocument(); + var expectedJson = ConvertSortToJson(expectedSort); + var resultJson = ConvertSortToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(IsDescendingHasNoValueOrIsFalse))] + public void GetSort_IsDescendingHasNoValueOrIsFalse_ReturnsAscendingSortDefinition(IReadOnlyCollection? orderBy) + { + var result = _mongoDbService.GetSort(orderBy); + + Assert.NotNull(result); + + var expectedSort = Builders.Sort.Ascending("field"); + var expectedJson = ConvertSortToJson(expectedSort); + var resultJson = ConvertSortToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetSort_IsDescendingHasValueAndIsTrue_ReturnsDescendingSortDefinition() + { + var field = "field"; + IReadOnlyCollection? orderBy = new List { new Sort { Field = field, IsDescending = true } }; + + var result = _mongoDbService.GetSort(orderBy); + + Assert.NotNull(result); + + var expectedSort = Builders.Sort.Descending(field); + var expectedJson = ConvertSortToJson(expectedSort); + var resultJson = ConvertSortToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetProjections_QueryIsNull_ReturnsEmptyProjectionDefinition() + { + Query? query = null; + + var result = _mongoDbService.GetProjections(query); + + Assert.NotNull(result); + + var expectedProjection = Builders.Projection.ToBsonDocument(); + var expectedJson = ConvertProjectionToJson(expectedProjection); + var resultJson = ConvertProjectionToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(IncludesIsNullOrEmpty))] + public void GetIncludesProjections_IncludesIsNullOrEmpty_ReturnsEmptyProjectionDefinition(HashSet? includes) + { + var result = _mongoDbService.GetIncludesProjections(includes); + + Assert.NotNull(result); + + var expectedProjection = Builders.Projection.ToBsonDocument(); + var expectedJson = ConvertProjectionToJson(expectedProjection); + var resultJson = ConvertProjectionToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetIncludesProjections_IncludesIsNotNullOrEmpty_ReturnsProjectionDefinition() + { + var field1 = "field1"; + var field2 = "field2"; + HashSet? includes = new HashSet { field1, field2 }; + + var result = _mongoDbService.GetIncludesProjections(includes); + + Assert.NotNull(result); + + var expectedProjection = Builders.Projection.Include(field1).Include(field2); + var expectedJson = ConvertProjectionToJson(expectedProjection); + var resultJson = ConvertProjectionToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Theory] + [ClassData(typeof(ExcludesIsNullOrEmpty))] + public void GetExcludesProjections_ExcludesIsNullOrEmpty_ReturnsEmptyProjectionDefinition(HashSet? excludes) + { + var result = _mongoDbService.GetExcludesProjections(excludes); + + Assert.NotNull(result); + + var expectedProjection = Builders.Projection.ToBsonDocument(); + var expectedJson = ConvertProjectionToJson(expectedProjection); + var resultJson = ConvertProjectionToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void GetExcludesProjections_ExcludesIsNotNullOrEmpty_ReturnsProjectionDefinition() + { + var field1 = "field1"; + var field2 = "field2"; + HashSet? excludes = new HashSet { field1, field2 }; + + var result = _mongoDbService.GetExcludesProjections(excludes); + + Assert.NotNull(result); + + var expectedProjection = Builders.Projection.Exclude(field1).Exclude(field2); + var expectedJson = ConvertProjectionToJson(expectedProjection); + var resultJson = ConvertProjectionToJson(result); + + Assert.Equal(expectedJson, resultJson); + } + + [Fact] + public void ChangeType_ChangeTypeThrowsException_ThrowsInvalidCastException() + { + string field = nameof(Model.Id); + Type? type = null; + string? value = "1"; + + var action = () => _mongoDbService.ChangeType(field, type, value); + + var exception = Assert.Throws(action); + Assert.Equal($"Unable to convert value: {value} to field: {field}'s type: {type}.", exception.Message); + } + + [Fact] + public void ChangeType_ChangeTypeThrowsNoException_ReturnsValueAsType() + { + string field = nameof(Model.Id); + Type? type = typeof(int); + string? value = "1"; + + var result = _mongoDbService.ChangeType(field, type, value); + + Assert.NotNull(result); + Assert.IsType(typeof(int), result); + Assert.Equal(1, result); + } + + [Table(nameof(ModelWithTableAttribute))] + private class ModelWithTableAttribute + { + public Int32 Id { get; set; } + } + + private class ModelImplementsIExternalEntity : IExternalEntity + { + public Guid? ExternalId { get; set; } + } + + private class ModelDoesNotImplementIExternalEntity + { + public Guid? ExternalId { get; set; } + public String? Name { get; set; } + public Int32 Total { get; set; } + public ChildModel? ChildModel { get; set; } + public List? Ints { get; set; } + } + + private class ChildModel + { + public String? Name { get; set; } + } + + private class QueryParamsIsNullOrEmpty : TheoryData?> + { + public QueryParamsIsNullOrEmpty() + { + Add(null); + Add(new Dictionary()); + } + } + + private class AndAliasFound : TheoryData + { + public AndAliasFound() + { + Add(Operator.And); + Add("AND"); + Add("and"); + } + } + + private class OrAliasFound : TheoryData + { + public OrAliasFound() + { + Add(Operator.Or); + Add("OR"); + Add("or"); + } + } + + private class EqualityAliasFound : TheoryData + { + public EqualityAliasFound() + { + Add(Operator.Equality); + Add("EQUALS"); + Add("equals"); + } + } + + private class InequalityAliasFound : TheoryData + { + public InequalityAliasFound() + { + Add(Operator.Inequality); + Add("NotEquals"); + Add("NE"); + } + } + + private class GreaterThanAliasFound : TheoryData + { + public GreaterThanAliasFound() + { + Add(Operator.GreaterThan); + Add("GreaterThan"); + Add("GT"); + } + } + + private class GreaterThanOrEqualsAliasFound : TheoryData + { + public GreaterThanOrEqualsAliasFound() + { + Add(Operator.GreaterThanOrEquals); + Add("GreaterThanOrEquals"); + Add("GTE"); + } + } + + private class LessThanAliasFound : TheoryData + { + public LessThanAliasFound() + { + Add(Operator.LessThan); + Add("LessThan"); + Add("LT"); + } + } + + private class LessThanOrEqualsAliasFound : TheoryData + { + public LessThanOrEqualsAliasFound() + { + Add(Operator.LessThanOrEquals); + Add("LessThanOrEquals"); + Add("LTE"); + } + } + + private class ContainsAliasFound : TheoryData + { + public ContainsAliasFound() + { + Add(Operator.Contains); + } + } + + private class StartsWithAliasFound : TheoryData + { + public StartsWithAliasFound() + { + Add(Operator.StartsWith); + } + } + + private class EndsWithAliasFound : TheoryData + { + public EndsWithAliasFound() + { + Add(Operator.EndsWith); + } + } + + private class InAliasFound : TheoryData + { + public InAliasFound() + { + Add(Operator.In); + } + } + + private class NotInAliasFound : TheoryData + { + public NotInAliasFound() + { + Add(Operator.NotIn); + Add("NotIn"); + } + } + + private class AllAliasFound : TheoryData + { + public AllAliasFound() + { + Add(Operator.All); + } + } + + private class OrderByIsNullOrEmpty : TheoryData?> + { + public OrderByIsNullOrEmpty() + { + Add(null); + Add(new List()); + } + } + + private class IncludesIsNullOrEmpty : TheoryData?> + { + public IncludesIsNullOrEmpty() + { + Add(null); + Add(new HashSet()); + } + } + + private class ExcludesIsNullOrEmpty : TheoryData?> + { + public ExcludesIsNullOrEmpty() + { + Add(null); + Add(new HashSet()); + } + } + + private class IsDescendingHasNoValueOrIsFalse : TheoryData?> + { + public IsDescendingHasNoValueOrIsFalse() + { + Add(new List { new Sort { Field = "field", IsDescending = null } }); + Add(new List { new Sort { Field = "field", IsDescending = false } }); + } + } + + private String ConvertFilterToJson(FilterDefinition filter) + { + var serializerRegistry = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry; + var documentSerializer = serializerRegistry.GetSerializer(); + return filter.Render(documentSerializer, serializerRegistry).ToJson(); + } + + private String ConvertFiltersToJson(IEnumerable> filters) + { + var serializerRegistry = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry; + var documentSerializer = serializerRegistry.GetSerializer(); + + var jsonBuilder = new StringBuilder(); + + foreach (var filter in filters) + { + jsonBuilder.Append(filter.Render(documentSerializer, serializerRegistry).ToJson()); + } + + return jsonBuilder.ToString(); + } + + private String ConvertUpdatesToJson(IEnumerable> updates) + { + var serializerRegistry = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry; + var documentSerializer = serializerRegistry.GetSerializer(); + var jsonBuilder = new StringBuilder(); + + foreach (var update in updates) + { + jsonBuilder.Append(update.Render(documentSerializer, serializerRegistry).ToJson()); + } + + return jsonBuilder.ToString(); + } + + private String ConvertSortToJson(SortDefinition sort) + { + var serializerRegistry = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry; + var documentSerializer = serializerRegistry.GetSerializer(); + return sort.Render(documentSerializer, serializerRegistry).ToJson(); + } + + private String ConvertProjectionToJson(ProjectionDefinition projection) + { + var serializerRegistry = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry; + var documentSerializer = serializerRegistry.GetSerializer(); + return projection.Render(documentSerializer, serializerRegistry).ToJson(); + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Services/TypeServiceTests.cs b/Crud.Tests/Crud.Api.Tests/Services/TypeServiceTests.cs new file mode 100644 index 0000000..924cda0 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Services/TypeServiceTests.cs @@ -0,0 +1,54 @@ +using Crud.Api.Services; +using Crud.Api.Tests.TestingModels; + +namespace Crud.Api.Tests.Services +{ + public class TypeServiceTests + { + private TypeService _typeService; + + public TypeServiceTests() + { + _typeService = new TypeService(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetType_NamespaceIsNullOrWhitespace_ThrowsArgumentException(String @namespace) + { + var typeName = nameof(Model); + + var action = () => _typeService.GetType(@namespace, typeName); + + var exception = Assert.Throws(action); + Assert.Equal($"{nameof(@namespace)} cannot be null or whitespace.", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetType_TypeNameIsNullOrWhitespace_ThrowsArgumentException(String typeName) + { + var @namespace = "Crud.Api.Tests.TestingModels"; + + var action = () => _typeService.GetType(@namespace, typeName); + + var exception = Assert.Throws(action); + Assert.Equal($"{nameof(typeName)} cannot be null or whitespace.", exception.Message); + } + + [Fact] + public void GetType_TypeExists_ReturnsType() + { + var @namespace = "Crud.Api.Tests.TestingModels"; + var typeName = nameof(Model); + + var result = _typeService.GetType(@namespace, typeName); + + Assert.Null(result); // This is null because Type.GetType does not have access to Crud.Api.Tests.TestingModels.Models. + } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/TestingModels/Model.cs b/Crud.Tests/Crud.Api.Tests/TestingModels/Model.cs new file mode 100644 index 0000000..9cf539c --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/TestingModels/Model.cs @@ -0,0 +1,13 @@ +namespace Crud.Api.Tests.TestingModels +{ + /// + /// This is to be used inside this test project only. + /// Was made public because using private caused _preserver.Setup(m => m.CreateAsync(It.IsAny())).ReturnsAsync(model); to return null. + /// + public class Model + { + public Int32 Id { get; set; } + public String? Name { get; set; } + public String? Description { get; set; } + } +} diff --git a/Crud.Tests/Crud.Api.Tests/Usings.cs b/Crud.Tests/Crud.Api.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/Crud.Tests/Crud.Api.Tests/Validators/ValidatorTests.cs b/Crud.Tests/Crud.Api.Tests/Validators/ValidatorTests.cs new file mode 100644 index 0000000..f000fd2 --- /dev/null +++ b/Crud.Tests/Crud.Api.Tests/Validators/ValidatorTests.cs @@ -0,0 +1,961 @@ +using Crud.Api.Attributes; +using Crud.Api.Options; +using Crud.Api.Preservers; +using Crud.Api.QueryModels; +using Crud.Api.Validators; +using Microsoft.Extensions.Options; +using Moq; +using DataAnnotations = System.ComponentModel.DataAnnotations; + +namespace Crud.Api.Tests.Validators +{ + public class ValidatorTests + { + private Mock _preserver; + private IOptions _applicationOptions; + private Validator _validator; + + public ValidatorTests() + { + _preserver = new Mock(); + _applicationOptions = Microsoft.Extensions.Options.Options.Create(new ApplicationOptions { PreventAllQueryContains = false, PreventAllQueryStartsWith = false, PreventAllQueryEndsWith = false }); + + _validator = new Validator(_preserver.Object, _applicationOptions); + } + + [Fact] + public async Task ValidateCreateAsync_WithObject_DataAnnotationsValidationIsInvalid_ReturnsFalseValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + + var result = await _validator.ValidateCreateAsync(model); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ModelForValidation.Name)} field is required.", result.Message); + } + + [Fact] + public async Task ValidateCreateAsync_WithObject_DataAnnotationsValidationIsValid_ReturnsTrueValidationResult() + { + object model = new ModelForValidation { Id = 1, Name = "Test" }; + + var result = await _validator.ValidateCreateAsync(model); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Theory] + [ClassData(typeof(QueryParamsIsNullOrEmpty))] + public async Task ValidateReadAsync_WithObjectIDictionaryOfStringString_QueryParamsIsNullOrEmpty_ReturnsFalseValidationResult(IDictionary? queryParams) + { + object model = new Object(); + + var result = await _validator.ValidateReadAsync(model, queryParams); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Filter cannot be empty.", result.Message); + } + + [Fact] + public async Task ValidateReadAsync_WithObjectIDictionaryOfStringString_ModelDoesNotHaveAllPropertiesInQueryParams_ReturnsFalseValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { "PropertyDoesNotExist", "value" } + }; + + var result = await _validator.ValidateReadAsync(model, queryParams); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Filter cannot contain properties that the model does not have.", result.Message); + } + + [Fact] + public async Task ValidateReadAsync_WithObjectIDictionaryOfStringString_ModelHasAllPropertiesInQueryParams_ReturnsTrueValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + + var result = await _validator.ValidateReadAsync(model, queryParams); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateUpdateAsync_WithGuidObject_DataAnnotationsValidationIsInvalid_ReturnsFalseValidationResult() + { + var id = Guid.Empty; + object model = new ModelForValidation { Id = 1 }; + + var result = await _validator.ValidateUpdateAsync(model, id); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ModelForValidation.Name)} field is required.", result.Message); + } + + [Fact] + public async Task ValidateUpdateAsync_WithGuidObject_DataAnnotationsValidationIsValid_ReturnsTrueValidationResult() + { + var id = Guid.Empty; + object model = new ModelForValidation { Id = 1, Name = "Test" }; + + var result = await _validator.ValidateUpdateAsync(model, id); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Theory] + [ClassData(typeof(PropertiesToBeUpdatedIsNullOrEmpty))] + public async Task ValidatePartialUpdateAsync_WithGuidObjectIReadOnlyCollectionOfString_PropertiesToBeUpdatedIsNullOrEmpty_ReturnsFalseValidationResult(IReadOnlyCollection? propertiesToBeUpdated) + { + var id = Guid.Empty; + object model = new Object(); + + var result = await _validator.ValidatePartialUpdateAsync(model, id, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Updated properties cannot be empty.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithGuidObjectIReadOnlyCollectionOfString_ModelDoesNotHaveAllPropertiesInPropertiesToBeUpdated_ReturnsFalseValidationResult() + { + var id = Guid.Empty; + object model = new ModelForValidation { Id = 1 }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + "PropertyDoesNotExist" + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, id, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Updated properties cannot contain properties that the model does not have.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithGuidObjectIReadOnlyCollectionOfString_ModelHasAllPropertiesInPropertiesToBeUpdated_ReturnsTrueValidationResult() + { + var id = Guid.Empty; + object model = new ModelForValidation { Id = 1 }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + nameof(ModelForValidation.Id) + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, id, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithGuidObjectIReadOnlyCollectionOfString_DataAnnotationsValidationIsInvalid_ReturnsFalseValidationResult() + { + var id = Guid.Empty; + object model = new ModelForValidation { Id = 1 }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + nameof(ModelForValidation.Id), + nameof(ModelForValidation.Name) + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, id, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ModelForValidation.Name)} field is required.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithGuidObjectIReadOnlyCollectionOfString_DataAnnotationsValidationIsValid_ReturnsTrueValidationResult() + { + var id = Guid.Empty; + object model = new ModelForValidation { Id = 1, Name = "Test" }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + nameof(ModelForValidation.Id), + nameof(ModelForValidation.Name) + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, id, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Theory] + [ClassData(typeof(QueryParamsIsNullOrEmpty))] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_QueryParamsIsNullOrEmpty_ReturnsFalseValidationResult(IDictionary? queryParams) + { + object model = new Object(); + IReadOnlyCollection? propertiesToBeUpdated = new List(); + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Filter cannot be empty.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_ModelDoesNotHaveAllPropertiesInQueryParams_ReturnsFalseValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { "PropertyDoesNotExist", "value" } + }; + IReadOnlyCollection? propertiesToBeUpdated = new List(); + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Filter cannot contain properties that the model does not have.", result.Message); + } + + [Theory] + [ClassData(typeof(PropertiesToBeUpdatedIsNullOrEmpty))] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_PropertiesToBeUpdatedIsNullOrEmpty_ReturnsFalseValidationResult(IReadOnlyCollection? propertiesToBeUpdated) + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Updated properties cannot be empty.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_ModelDoesNotHaveAllPropertiesInPropertiesToBeUpdated_ReturnsFalseValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + "PropertyDoesNotExist" + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Updated properties cannot contain properties that the model does not have.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_DataAnnotationsValidationIsInvalid_ReturnsFalseValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + nameof(ModelForValidation.Id), + nameof(ModelForValidation.Name) + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"The {nameof(ModelForValidation.Name)} field is required.", result.Message); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_DataAnnotationsValidationIsValid_ReturnsTrueValidationResult() + { + object model = new ModelForValidation { Id = 1, Name = "Test" }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + nameof(ModelForValidation.Id), + nameof(ModelForValidation.Name) + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidatePartialUpdateAsync_WithObjectIDictionaryOfStringStringIReadOnlyCollectionOfString_ModelHasAllPropertiesInPropertiesToBeUpdated_ReturnsTrueValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + IReadOnlyCollection? propertiesToBeUpdated = new List + { + nameof(ModelForValidation.Id) + }; + + var result = await _validator.ValidatePartialUpdateAsync(model, queryParams, propertiesToBeUpdated); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Theory] + [ClassData(typeof(QueryParamsIsNullOrEmpty))] + public async Task ValidateDeleteAsync_WithObjectIDictionaryOfStringString_QueryParamsIsNullOrEmpty_ReturnsFalseValidationResult(IDictionary? queryParams) + { + object model = new Object(); + + var result = await _validator.ValidateDeleteAsync(model, queryParams); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Filter cannot be empty.", result.Message); + } + + [Fact] + public async Task ValidateDeleteAsync_WithObjectIDictionaryOfStringString_ModelDoesNotHaveAllPropertiesInQueryParams_ReturnsFalseValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { "PropertyDoesNotExist", "value" } + }; + + var result = await _validator.ValidateDeleteAsync(model, queryParams); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal("Filter cannot contain properties that the model does not have.", result.Message); + } + + [Fact] + public async Task ValidateDeleteAsync_WithObjectIDictionaryOfStringString_ModelHasAllPropertiesInQueryParams_ReturnsTrueValidationResult() + { + object model = new ModelForValidation { Id = 1 }; + IDictionary? queryParams = new Dictionary + { + { nameof(ModelForValidation.Id), "value" } + }; + + var result = await _validator.ValidateDeleteAsync(model, queryParams); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateQuery_IncludesIsPopulatedAndExcludesIsPopulated_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + Includes = new HashSet { "IncludedFieldName" }, + Excludes = new HashSet { "ExcludedFieldName" } + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query)} {nameof(Query.Includes)} and {nameof(Query.Excludes)} cannot both be populated.", result.Message); + } + + [Fact] + public void ValidateQuery_IncludesIsPopulatedAndModelDoesNotHaveAllPropertiesInIncludes_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + Includes = new HashSet { nameof(ModelForValidation.Id), "PropertyDoesNotExist" } + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query)} {nameof(Query.Includes)} cannot contain properties that the model does not have.", result.Message); + } + + [Fact] + public void ValidateQuery_ExcludesIsPopulatedAndModelDoesNotHaveAllPropertiesInExcludes_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + Excludes = new HashSet { nameof(ModelForValidation.Id), "PropertyDoesNotExist" } + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query)} {nameof(Query.Excludes)} cannot contain properties that the model does not have.", result.Message); + } + + [Fact] + public void ValidateQuery_WhereIsNotNullAndConditionValidationResultIsInvalid_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var field = "PropertyDoesNotExist"; + var query = new Query + { + Where = new Condition + { + Field = field + } + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"A {nameof(Condition)} {nameof(Condition.Field)} contains a property {field} that the model does not have.", result.Message); + } + + [Fact] + public void ValidateQuery_OrderByIsNotNullAndOrderByValidationResultIsInvalid_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + OrderBy = new List + { + new Sort { Field = null } + } + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query.OrderBy)} cannot contain a {nameof(Sort)} with a null {nameof(Sort.Field)}.", result.Message); + } + + [Fact] + public void ValidateQuery_LimitIsLessThanZero_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + Limit = -1 + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query)} {nameof(Query.Limit)} cannot be less than zero.", result.Message); + } + + [Fact] + public void ValidateQuery_SkipIsLessThanZero_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + Skip = -1 + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query)} {nameof(Query.Skip)} cannot be less than zero.", result.Message); + } + + [Fact] + public void ValidateQuery_QueryIsValid_ReturnsTrueValidationResult() + { + object model = new ModelForValidation(); + var query = new Query + { + Skip = 1 + }; + + var result = _validator.ValidateQuery(model, query); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateCondition_FieldIsNullAndGroupedConditionsIsNull_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = null, + GroupedConditions = null + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"A {nameof(Condition)} must contain either a {nameof(Condition.Field)} or {nameof(Condition.GroupedConditions)}.", result.Message); + } + + [Fact] + public void ValidateCondition_ModelDoesNotHaveFieldPropertyName_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = "PropertyDoesNotExist" + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"A {nameof(Condition)} {nameof(Condition.Field)} contains a property {condition.Field} that the model does not have.", result.Message); + } + + [Fact] + public void ValidateCondition_ComparisonOperatorIsNull_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = nameof(ModelForValidation.Id), + ComparisonOperator = null + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"A {nameof(Condition)} cannot have a populated {nameof(Condition.Field)} and a null {nameof(Condition.ComparisonOperator)}.", result.Message); + } + + [Fact] + public void ValidateCondition_ComparisonOperatorNotInComparisonAliasLookup_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = nameof(ModelForValidation.Id), + ComparisonOperator = "DoesNotExist" + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{condition.ComparisonOperator}' must be found in {Operator.ComparisonAliasLookup}.", result.Message); + } + + [Fact] + public void ValidateCondition_OperatorNotAllowedByApplicationOptions_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = nameof(ModelForValidation.Id), + ComparisonOperator = Operator.Contains + }; + _applicationOptions.Value.PreventAllQueryContains = true; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{condition.ComparisonOperator}' may not be used.", result.Message); + } + + [Fact] + public void ValidateCondition_OperatorNotAllowedByPropertyQueryAttribute_ReturnsFalseValidationResult() + { + object model = new PreventQueryModel(); + var condition = new Condition + { + Field = nameof(PreventQueryModel.PropertyWithPreventQueryContainsAttribute), + ComparisonOperator = Operator.Contains + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{condition.ComparisonOperator}' may not be used on the {condition.Field} property.", result.Message); + } + + [Theory] + [ClassData(typeof(ValueIsNotAllLettersOrNumbers))] + public void ValidateCondition_ContainsOperatorAndValueIsNotAllLettersOrNumbers_ReturnsFalseValidationResult(String comparisonOperator, String value) + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = nameof(ModelForValidation.Name), + ComparisonOperator = comparisonOperator, + Value = value + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{condition.ComparisonOperator}' can only contain letters and numbers.", result.Message); + } + + [Theory] + [ClassData(typeof(ValueIsAllLettersOrNumbers))] + public void ValidateCondition_ContainsOperatorAndValueIsAllLettersOrNumbers_ReturnsTrueValidationResult(String comparisonOperator, String value) + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = nameof(ModelForValidation.Name), + ComparisonOperator = comparisonOperator, + Value = value + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateQueryApplicationOptions_ComparisonOperatorIsNull_ReturnsTrueValidationResult() + { + string? comparisonOperator = null; + + var result = _validator.ValidateQueryApplicationOptions(comparisonOperator); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateQueryApplicationOptions_ComparisonOperatorContainsAndPreventAllQueryContains_ReturnsFalseValidationResult() + { + string? comparisonOperator = Operator.Contains; + _applicationOptions.Value.PreventAllQueryContains = true; + + var result = _validator.ValidateQueryApplicationOptions(comparisonOperator); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' may not be used.", result.Message); + } + + [Fact] + public void ValidateQueryApplicationOptions_ComparisonOperatorStartsWithAndPreventAllQueryStartsWith_ReturnsFalseValidationResult() + { + string? comparisonOperator = Operator.StartsWith; + _applicationOptions.Value.PreventAllQueryStartsWith = true; + + var result = _validator.ValidateQueryApplicationOptions(comparisonOperator); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' may not be used.", result.Message); + } + + [Fact] + public void ValidateQueryApplicationOptions_ComparisonOperatorEndsWithAndPreventAllQueryEndsWith_ReturnsFalseValidationResult() + { + string? comparisonOperator = Operator.EndsWith; + _applicationOptions.Value.PreventAllQueryEndsWith = true; + + var result = _validator.ValidateQueryApplicationOptions(comparisonOperator); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' may not be used.", result.Message); + } + + [Fact] + public void ValidatePropertyQueryAttributes_ComparisonOperatorIsNull_ReturnsTrueValidationsResult() + { + var propertyInfo = typeof(PreventQueryModel).GetProperty(nameof(PreventQueryModel.PropertyWithPreventQueryContainsAttribute)); + string? comparisonOperator = null; + + var result = _validator.ValidatePropertyQueryAttributes(propertyInfo!, comparisonOperator); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidatePropertyQueryAttributes_AttributeIsNull_ReturnsTrueValidationsResult() + { + var propertyInfo = typeof(PreventQueryModel).GetProperty(nameof(PreventQueryModel.PropertyWithoutPreventQueryAttribute))!; + string? comparisonOperator = Operator.Contains; + + var result = _validator.ValidatePropertyQueryAttributes(propertyInfo, comparisonOperator); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidatePropertyQueryAttributes_AttributeAllowsOperator_ReturnsTrueValidationsResult() + { + var propertyInfo = typeof(PreventQueryModel).GetProperty(nameof(PreventQueryModel.PropertyWithPreventQueryContainsAttribute))!; + string? comparisonOperator = Operator.StartsWith; + + var result = _validator.ValidatePropertyQueryAttributes(propertyInfo, comparisonOperator); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidatePropertyQueryAttributes_AttributeDoesNotAllowOperator_ReturnsFalseValidationsResult() + { + var propertyInfo = typeof(PreventQueryModel).GetProperty(nameof(PreventQueryModel.PropertyWithPreventQueryContainsAttribute))!; + string? comparisonOperator = Operator.Contains; + + var result = _validator.ValidatePropertyQueryAttributes(propertyInfo, comparisonOperator); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Condition.ComparisonOperator)} '{comparisonOperator}' may not be used on the {propertyInfo.Name} property.", result.Message); + } + + [Fact] + public void ValidateCondition_GroupedConditionsContainsAtLeastOneInvalidGroupedCondition_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var logicalOperator = "DoesNotExist"; + var condition = new Condition + { + GroupedConditions = new List + { + new GroupedCondition { LogicalOperator = logicalOperator } + } + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(GroupedCondition.LogicalOperator)} '{logicalOperator}' must be found in {Operator.LogicalAliasLookup}.", result.Message); + } + + [Fact] + public void ValidateCondition_ConditionIsValid_ReturnsTrueValidationResult() + { + object model = new ModelForValidation(); + var condition = new Condition + { + Field = nameof(ModelForValidation.Id), + ComparisonOperator = Operator.Equality, + Value = "1" + }; + + var result = _validator.ValidateCondition(model.GetType().GetProperties(), condition); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateGroupedCondition_LogicalOperatorIsNotNullAndNotInLogicalAliasLookup_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var logicalOperator = "DoesNotExist"; + var groupedCondition = new GroupedCondition { LogicalOperator = logicalOperator }; + + var result = _validator.ValidateGroupedCondition(model.GetType().GetProperties(), groupedCondition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(GroupedCondition.LogicalOperator)} '{groupedCondition.LogicalOperator}' must be found in {Operator.LogicalAliasLookup}.", result.Message); + } + + [Theory] + [ClassData(typeof(ConditionsIsNullOrEmpty))] + public void ValidateGroupedCondition_ConditionsIsNullOrEmpty_ReturnsFalseValidationResult(IReadOnlyCollection conditions) + { + object model = new ModelForValidation(); + var groupedCondition = new GroupedCondition { Conditions = conditions }; + + var result = _validator.ValidateGroupedCondition(model.GetType().GetProperties(), groupedCondition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(GroupedCondition.Conditions)} cannot be empty.", result.Message); + } + + [Fact] + public void ValidateGroupedCondition_ConditionsContainsAtLeastOneInvalidCondition_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var groupedCondition = new GroupedCondition + { + Conditions = new List + { + new Condition() + } + }; + + var result = _validator.ValidateGroupedCondition(model.GetType().GetProperties(), groupedCondition); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"A {nameof(Condition)} must contain either a {nameof(Condition.Field)} or {nameof(Condition.GroupedConditions)}.", result.Message); + } + + [Fact] + public void ValidateGroupedCondition_GroupedConditionIsValid_ReturnsTrueValidationResult() + { + object model = new ModelForValidation(); + var groupedCondition = new GroupedCondition + { + Conditions = new List + { + new Condition + { + Field = nameof(ModelForValidation.Id), + ComparisonOperator = Operator.Equality, + Value = "1" + } + } + }; + + var result = _validator.ValidateGroupedCondition(model.GetType().GetProperties(), groupedCondition); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateSorts_FieldIsNull_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var sorts = new List + { + new Sort { Field = null } + }; + + var result = _validator.ValidateSorts(model.GetType().GetProperties(), sorts); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"{nameof(Query.OrderBy)} cannot contain a {nameof(Sort)} with a null {nameof(Sort.Field)}.", result.Message); + } + + [Fact] + public void ValidateSorts_ModelDoesNotHaveFieldPropertyName_ReturnsFalseValidationResult() + { + object model = new ModelForValidation(); + var field = "DoesNotExist"; + var sorts = new List + { + new Sort { Field = field } + }; + + var result = _validator.ValidateSorts(model.GetType().GetProperties(), sorts); + + Assert.NotNull(result); + Assert.False(result.IsValid); + Assert.Equal($"A {nameof(Sort)} {nameof(Sort.Field)} contains a property {field} that the model does not have.", result.Message); + } + + [Fact] + public void ValidateSorts_SortsIsValid_ReturnsTrueValidationResult() + { + object model = new ModelForValidation(); + var sorts = new List + { + new Sort { Field = nameof(ModelForValidation.Id) } + }; + + var result = _validator.ValidateSorts(model.GetType().GetProperties(), sorts); + + Assert.NotNull(result); + Assert.True(result.IsValid); + } + + private class ModelForValidation + { + public Int32 Id { get; set; } + [DataAnnotations.Required] + public String? Name { get; set; } + } + + private class PreventQueryModel + { + [PreventQuery(Operator.Contains)] + public String? PropertyWithPreventQueryContainsAttribute { get; set; } + public String? PropertyWithoutPreventQueryAttribute { get; set; } + } + + private class QueryParamsIsNullOrEmpty : TheoryData?> + { + public QueryParamsIsNullOrEmpty() + { + Add(null); + Add(new Dictionary()); + } + } + + private class PropertiesToBeUpdatedIsNullOrEmpty : TheoryData?> + { + public PropertiesToBeUpdatedIsNullOrEmpty() + { + Add(null); + Add(new List()); + } + } + + private class ConditionsIsNullOrEmpty : TheoryData?> + { + public ConditionsIsNullOrEmpty() + { + Add(null); + Add(new List()); + } + } + + private class ValueIsAllLettersOrNumbers : TheoryData + { + public ValueIsAllLettersOrNumbers() + { + Add(Operator.Contains, "letters"); + Add(Operator.StartsWith, "12345"); + Add(Operator.EndsWith, "L3tt3r5"); + } + } + + private class ValueIsNotAllLettersOrNumbers : TheoryData + { + public ValueIsNotAllLettersOrNumbers() + { + Add(Operator.Contains, "letter$"); + Add(Operator.StartsWith, ""); + Add(Operator.EndsWith, " "); + } + } + } +} diff --git a/Crud.sln b/Crud.sln new file mode 100644 index 0000000..a2475fb --- /dev/null +++ b/Crud.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crud.Api", "Crud.Api\Crud.Api.csproj", "{F1E61854-BD30-4C4D-B415-085351DD22ED}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Crud.Tests", "Crud.Tests", "{D9F69C7E-B52F-4740-A266-938B8983A061}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crud.Api.Tests", "Crud.Tests\Crud.Api.Tests\Crud.Api.Tests.csproj", "{9F5E84F1-9845-48A1-9410-E687DA711969}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F1E61854-BD30-4C4D-B415-085351DD22ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1E61854-BD30-4C4D-B415-085351DD22ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1E61854-BD30-4C4D-B415-085351DD22ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1E61854-BD30-4C4D-B415-085351DD22ED}.Release|Any CPU.Build.0 = Release|Any CPU + {9F5E84F1-9845-48A1-9410-E687DA711969}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F5E84F1-9845-48A1-9410-E687DA711969}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F5E84F1-9845-48A1-9410-E687DA711969}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F5E84F1-9845-48A1-9410-E687DA711969}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9F5E84F1-9845-48A1-9410-E687DA711969} = {D9F69C7E-B52F-4740-A266-938B8983A061} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2a266af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 steven-rothwell + +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/MetricsInstructions.txt b/MetricsInstructions.txt new file mode 100644 index 0000000..fc098e1 --- /dev/null +++ b/MetricsInstructions.txt @@ -0,0 +1,3 @@ +- dotnet publish -c Release -o ./bin/Publish +- cd D:\GitLocal/Crud/bin/Publish +- dotnet Crud.Api.dll --urls "https://localhost:7289" \ No newline at end of file diff --git a/MetricsResults.txt b/MetricsResults.txt new file mode 100644 index 0000000..6407c5c --- /dev/null +++ b/MetricsResults.txt @@ -0,0 +1,14 @@ +100 Iterations (no indexes) +--------------------------- +All requests use the User model. +------------------------------------------------- +Request Avg Response Time +-------------- ----------------- +Create 4 ms +Read_Id 3 ms +Read_Name 3 ms +Update_Id 3 ms +PartialUpdate_Id 4 ms +PartialUpdate_Name 3 ms +Delete_Id 3 ms +Delete_Name 3 ms \ No newline at end of file diff --git a/Postman/Crud.postman_collection.json b/Postman/Crud.postman_collection.json new file mode 100644 index 0000000..1838541 --- /dev/null +++ b/Postman/Crud.postman_collection.json @@ -0,0 +1,2852 @@ +{ + "info": { + "_postman_id": "66397f53-ed09-4e30-acb9-4a9383a26372", + "name": "Crud", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "1338704" + }, + "item": [ + { + "name": "BadRequests", + "item": [ + { + "name": "Create_Name_AlreadyExists", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"NewUser_0\",\r\n \"address\": {\r\n \"street\": \"44 Maple Street\",\r\n \"city\": \"Pittsburgh\",\r\n \"state\": \"PA\"\r\n },\r\n \"age\": 25,\r\n \"hairColor\": \"brown\",\r\n \"favoriteThings\": [\"Steelers\", \"Pirates\", \"Penguins\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ] + } + }, + "response": [] + }, + { + "name": "Read_Params_Filter_IsEmpty", + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ] + } + }, + "response": [] + }, + { + "name": "Update_Id_IsEmpty", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"externalId\": \"aabf9d27-c180-46c4-a7a5-46bd7ddb7c9e\",\r\n \"name\": \"NewUser_0\",\r\n \"address\": {\r\n \"street\": \"45 Maple Street\",\r\n \"city\": \"Pittsburgh\",\r\n \"state\": \"PA\"\r\n },\r\n \"age\": 3,\r\n \"hairColor\": \"brown\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users/00000000-0000-0000-0000-000000000000", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users", + "00000000-0000-0000-0000-000000000000" + ] + } + }, + "response": [] + }, + { + "name": "PartialUpdate_Id_ExternalId_DoNotMatch", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"externalId\": \"8dc544bd-ce6c-44e5-9f8c-6ad32bb581ab\",\r\n \"age\": 27,\r\n \"hairColor\": \"blonde\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users/b5a4cb77-a153-41f3-bb9c-821255ca7e1f", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users", + "b5a4cb77-a153-41f3-bb9c-821255ca7e1f" + ] + } + }, + "response": [] + }, + { + "name": "PartialUpdate_Params_ExternalId_IsNotNull", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"externalId\": \"8dc544bd-ce6c-44e5-9f8c-6ad32bb581ab\",\r\n \"age\": 27,\r\n \"hairColor\": \"blonde\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users?name=NewUser_0&hairColor=brown", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ], + "query": [ + { + "key": "name", + "value": "NewUser_0" + }, + { + "key": "hairColor", + "value": "brown" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete_Params_Filter_ContainsPropertiesNotInModel", + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/users?height=8&address_city=Pittsburgh", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ], + "query": [ + { + "key": "height", + "value": "8" + }, + { + "key": "address_city", + "value": "Pittsburgh" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Create", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Test Id\", function () {\r", + " var jsonData = pm.response.json();\r", + " console.log(\"Testing Id: \" + jsonData.id);\r", + " pm.expect(jsonData.id).not.null;\r", + "\r", + " let externalIds = JSON.parse(pm.environment.get(\"external_ids\"));\r", + " externalIds.push(jsonData.id);\r", + " pm.environment.set(\"external_ids\", JSON.stringify(externalIds));\r", + "});" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let iterations = JSON.parse(pm.environment.get(\"iterations\"));\r", + "let totalIterations = iterations.length;\r", + "pm.environment.set(\"current_iteration\", totalIterations);\r", + "iterations.push(totalIterations);\r", + "pm.environment.set(\"iterations\", JSON.stringify(iterations));" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"Id\": null,\r\n \"name\": \"NewUser_{{current_iteration}}\",\r\n \"address\": {\r\n \"street\": \"44 Maple Street\",\r\n \"city\": \"Pittsburgh\",\r\n \"state\": \"PA\"\r\n },\r\n \"age\": 25,\r\n \"hairColor\": \"brown\",\r\n \"favoriteThings\": [\r\n \"Steelers\",\r\n \"Pirates\",\r\n \"Penguins\"\r\n ],\r\n \"formerAddresses\": [\r\n {\r\n \"street\": \"100 Elm Street\",\r\n \"city\": \"Kittanning\",\r\n \"state\": \"PA\"\r\n },\r\n {\r\n \"street\": \"202 Oak Street\",\r\n \"city\": \"Ford City\",\r\n \"state\": \"PA\"\r\n },\r\n {\r\n \"street\": \"66 Robin Road\",\r\n \"city\": \"Butler\",\r\n \"state\": \"PA\"\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_OrderBy", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"orderby\": [{\r\n \"field\": \"address.city\"\r\n },\r\n {\r\n \"field\": \"age\",\r\n \"isDescending\": true\r\n },\r\n {\r\n \"field\": \"name\"\r\n }]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_OrderBy_Null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"orderby\": null\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Limit", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"limit\": 3\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Limit_Null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"limit\": null\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Skip", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"skip\": 3\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Skip_Null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"skip\": null\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Includes", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"includes\": [\"age\", \"name\", \"address.city\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Includes_Null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"includes\": null\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Excludes", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"excludes\": [\"hairColor\", \"address.state\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Excludes_Null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"excludes\": null\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Conditions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"hairColor\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"brown\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_NestedConditions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"groupedConditions\": [{\r\n \"logicalOperator\": \"||\",\r\n \"conditions\": [{\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"NewUser_0\"\r\n },\r\n {\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"Test3\"\r\n }]\r\n },\r\n {\r\n \"logicalOperator\": \"&&\",\r\n \"conditions\": [{\r\n \"field\": \"hairColor\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"brown\"\r\n }]\r\n },\r\n {\r\n \"logicalOperator\": \"&&\",\r\n \"conditions\": [{\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"25\"\r\n }]\r\n }]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_NestedNestedConditions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"groupedConditions\": [{\r\n \"logicalOperator\": \"&&\",\r\n \"conditions\": [{\r\n \"groupedConditions\": [{\r\n \"logicalOperator\": \"||\",\r\n \"conditions\": [{\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"NewUser_0\"\r\n },\r\n {\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"Test3\"\r\n }]\r\n },\r\n {\r\n \"logicalOperator\": \"&&\",\r\n \"conditions\": [{\r\n \"field\": \"Address.City\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"Pittsburgh\"\r\n },\r\n {\r\n \"field\": \"hairColor\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"brown\"\r\n }]\r\n }] \r\n }]\r\n }]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_NestedNestedNestedConditions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"groupedConditions\": [{\r\n \"logicalOperator\": \"&&\",\r\n \"conditions\": [{\r\n \"groupedConditions\": [{\r\n \"logicalOperator\": \"&&\",\r\n \"conditions\": [{\r\n \"groupedConditions\": [{\r\n \"logicalOperator\": \"||\",\r\n \"conditions\": [{\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"NewUser_0\"\r\n },\r\n {\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"Test3\"\r\n }]\r\n }]\r\n },\r\n {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"25\"\r\n }]\r\n },\r\n {\r\n \"logicalOperator\": \"||\",\r\n \"conditions\": [{\r\n \"field\": \"Address.City\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"Pittsburgh\"\r\n },\r\n {\r\n \"field\": \"hairColor\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"blonde\"\r\n }]\r\n }] \r\n }]\r\n }]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_EQ", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"25\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_NE", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"!=\",\r\n \"value\": \"23\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_GT", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \">\",\r\n \"value\": \"23\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_GTE", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \">=\",\r\n \"value\": \"25\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_LT", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"<\",\r\n \"value\": \"30\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_LTE", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"<=\",\r\n \"value\": \"30\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_CONTAINS", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"contains\",\r\n \"value\": \"user\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_STARTSWITH", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"startswith\",\r\n \"value\": \"new\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_ENDSWITH", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"name\",\r\n \"comparisonOperator\": \"endswith\",\r\n \"value\": \"0\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_Object_IN", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"in\",\r\n \"values\": [\r\n \"25\",\r\n \"26\",\r\n \"27\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_Object_NIN", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"age\",\r\n \"comparisonOperator\": \"nin\",\r\n \"values\": [\r\n \"20\",\r\n \"30\",\r\n \"40\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_Array_IN", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"favoriteThings\",\r\n \"comparisonOperator\": \"in\",\r\n \"values\": [\r\n \"Steelers\",\r\n \"Browns\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_Array_NIN", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"favoriteThings\",\r\n \"comparisonOperator\": \"nin\",\r\n \"values\": [\r\n \"Bengals\",\r\n \"Browns\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_Array_ALL", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"favoriteThings\",\r\n \"comparisonOperator\": \"all\",\r\n \"values\": [\r\n \"Pirates\",\r\n \"Penguins\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_ArrayObjects_IN", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"formerAddresses.city\",\r\n \"comparisonOperator\": \"in\",\r\n \"values\": [\r\n \"Kittanning\",\r\n \"Johnstown\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_ArrayObjects_NIN", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"formerAddresses.city\",\r\n \"comparisonOperator\": \"nin\",\r\n \"values\": [\r\n \"Beaver\",\r\n \"Latrobe\",\r\n \"Washington\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryRead_Operators_ArrayObjects_ALL", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"formerAddresses.city\",\r\n \"comparisonOperator\": \"all\",\r\n \"values\": [\r\n \"Kittanning\",\r\n \"Ford City\"\r\n ]\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "QueryReadCount_Conditions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"hairColor\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"brown\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users/count", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users", + "count" + ] + } + }, + "response": [] + }, + { + "name": "Read_Id", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "let externalIds = JSON.parse(pm.environment.get(\"external_ids\"));\r", + "let index = pm.environment.get(\"current_read_external_id_index\");\r", + "if (index >= externalIds.length) index = 0;\r", + "let currentExternalId = externalIds[index];\r", + "pm.environment.set(\"current_external_id\", currentExternalId);\r", + "pm.environment.set(\"current_read_external_id_index\", ++index);" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/users/{{current_external_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users", + "{{current_external_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Read_Params", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "let iterations = JSON.parse(pm.environment.get(\"iterations\"));\r", + "let index = pm.environment.get(\"current_read_iteration_index\");\r", + "if (index >= iterations.length) index = 0;\r", + "let currentIternation = iterations[index];\r", + "pm.environment.set(\"current_iteration\", currentIternation);\r", + "pm.environment.set(\"current_read_iteration_index\", ++index);" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/users?name=NewUser_{{current_iteration}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ], + "query": [ + { + "key": "name", + "value": "NewUser_{{current_iteration}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Read_All", + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ] + } + }, + "response": [] + }, + { + "name": "Update", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let iterations = JSON.parse(pm.environment.get(\"iterations\"));\r", + "let iterationIndex = pm.environment.get(\"current_update_iteration_index\");\r", + "if (iterationIndex >= iterations.length) iterationIndex = 0;\r", + "let currentIternation = iterations[iterationIndex];\r", + "pm.environment.set(\"current_iteration\", currentIternation);\r", + "pm.environment.set(\"current_update_iteration_index\", ++iterationIndex);\r", + "\r", + "let externalIds = JSON.parse(pm.environment.get(\"external_ids\"));\r", + "let externalIdsIndex = pm.environment.get(\"current_update_external_id_index\");\r", + "if (externalIdsIndex >= externalIds.length) externalIdsIndex = 0;\r", + "let currentExternalId = externalIds[externalIdsIndex];\r", + "pm.environment.set(\"current_external_id\", currentExternalId);\r", + "pm.environment.set(\"current_update_external_id_index\", ++externalIdsIndex);" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"id\": \"{{current_external_id}}\",\r\n \"name\": \"NewUser_{{current_iteration}}\",\r\n \"address\": {\r\n \"street\": \"46 Maple Street\",\r\n \"city\": \"Pittsburgh\",\r\n \"state\": \"PA\"\r\n },\r\n \"age\": 26,\r\n \"hairColor\": \"brown\",\r\n \"favoriteThings\": [\"Steelers\", \"Pirates\", \"Penguins\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users/{{current_external_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users", + "{{current_external_id}}" + ] + } + }, + "response": [] + }, + { + "name": "PartialUpdate_Id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let externalIds = JSON.parse(pm.environment.get(\"external_ids\"));\r", + "let index = pm.environment.get(\"current_partial_external_id_index\");\r", + "if (index >= externalIds.length) index = 0;\r", + "let currentExternalId = externalIds[index];\r", + "pm.environment.set(\"current_external_id\", currentExternalId);\r", + "pm.environment.set(\"current_partial_external_id_index\", ++index);" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"age\": 27,\r\n \"hairColor\": \"blonde\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users/{{current_external_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users", + "{{current_external_id}}" + ] + } + }, + "response": [] + }, + { + "name": "PartialUpdate_Params", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let iterations = JSON.parse(pm.environment.get(\"iterations\"));\r", + "let index = pm.environment.get(\"current_partial_iteration_index\");\r", + "if (index >= iterations.length) index = 0;\r", + "let currentIternation = iterations[index];\r", + "pm.environment.set(\"current_iteration\", currentIternation);\r", + "pm.environment.set(\"current_partial_iteration_index\", ++index);" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"age\": 28,\r\n \"hairColor\": \"red\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users?name=NewUser_{{current_iteration}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ], + "query": [ + { + "key": "name", + "value": "NewUser_{{current_iteration}}" + } + ] + } + }, + "response": [] + }, + { + "name": "PartialUpdate_All", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"age\": 29\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ] + } + }, + "response": [] + }, + { + "name": "Delete_Id", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "function decrementAndSave(variableName) {\r", + " let value = pm.environment.get(variableName);\r", + " let integer = parseInt(value);\r", + " if (integer !== 0) integer--;\r", + " pm.environment.set(variableName, integer);\r", + "}\r", + "\r", + "let externalIds = JSON.parse(pm.environment.get(\"external_ids\"));\r", + "let currentExternalId = externalIds.shift();\r", + "pm.environment.set(\"current_external_id\", currentExternalId);\r", + "pm.environment.set(\"external_ids\", JSON.stringify(externalIds));\r", + "\r", + "let iterations = JSON.parse(pm.environment.get(\"iterations\"));\r", + "iterations.shift();\r", + "pm.environment.set(\"iterations\", JSON.stringify(iterations));\r", + "\r", + "decrementAndSave(\"current_read_iteration_index\");\r", + "decrementAndSave(\"current_update_iteration_index\");\r", + "decrementAndSave(\"current_partial_iteration_index\");\r", + "decrementAndSave(\"current_read_external_id_index\");\r", + "decrementAndSave(\"current_update_external_id_index\");\r", + "decrementAndSave(\"current_partial_external_id_index\");" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/users/{{current_external_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users", + "{{current_external_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete_Params", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "function decrementAndSave(variableName) {\r", + " let value = pm.environment.get(variableName);\r", + " let integer = parseInt(value);\r", + " if (integer !== 0) integer--;\r", + " pm.environment.set(variableName, integer);\r", + "}\r", + "\r", + "let iterations = JSON.parse(pm.environment.get(\"iterations\"));\r", + "let currentIteration = iterations.shift();\r", + "pm.environment.set(\"current_iteration\", currentIteration);\r", + "pm.environment.set(\"iterations\", JSON.stringify(iterations));\r", + "\r", + "let externalIds = JSON.parse(pm.environment.get(\"external_ids\"));\r", + "externalIds.shift();\r", + "pm.environment.set(\"external_ids\", JSON.stringify(externalIds));\r", + "\r", + "decrementAndSave(\"current_read_iteration_index\");\r", + "decrementAndSave(\"current_update_iteration_index\");\r", + "decrementAndSave(\"current_partial_iteration_index\");\r", + "decrementAndSave(\"current_read_external_id_index\");\r", + "decrementAndSave(\"current_update_external_id_index\");\r", + "decrementAndSave(\"current_partial_external_id_index\");" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/users?name=NewUser_{{current_iteration}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ], + "query": [ + { + "key": "name", + "value": "NewUser_{{current_iteration}}" + } + ] + } + }, + "response": [] + }, + { + "name": "QueryDelete_Conditions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "/*function standardDeviation(values, avg) {\r", + " var squareDiffs = values.map(value => Math.pow(value - avg, 2));\r", + " return Math.sqrt(average(squareDiffs));\r", + "}\r", + "\r", + "function average(data) {\r", + " return data.reduce((sum, value)=>sum + value) / data.length;\r", + "}\r", + "\r", + "if (responseCode.code === 200 || responseCode.code === 201) {\r", + " response_array = globals['response_times'] ? JSON.parse(globals['response_times']) : []\r", + " response_array.push(responseTime)\r", + " postman.setGlobalVariable(\"response_times\", JSON.stringify(response_array))\r", + "\r", + " response_average = average(response_array);\r", + " postman.setGlobalVariable('response_average', response_average)\r", + "\r", + " response_std = standardDeviation(response_array, response_average)\r", + " postman.setGlobalVariable('response_std', response_std)\r", + "}*/" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"where\": {\r\n \"field\": \"address.city\",\r\n \"comparisonOperator\": \"==\",\r\n \"value\": \"Pittsburgh\"\r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/api/query/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "query", + "users" + ] + } + }, + "response": [] + }, + { + "name": "Delete_All", + "protocolProfileBehavior": { + "strictSSL": false + }, + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "users" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/Postman/Crud_Local.postman_environment.json b/Postman/Crud_Local.postman_environment.json new file mode 100644 index 0000000..ea0699c --- /dev/null +++ b/Postman/Crud_Local.postman_environment.json @@ -0,0 +1,74 @@ +{ + "id": "274ce1bf-bd24-4bba-b350-a6c9dadbdf86", + "name": "Crud_Local", + "values": [ + { + "key": "base_url", + "value": "https://localhost:7289", + "enabled": true + }, + { + "key": "iterations", + "value": "[]", + "type": "default", + "enabled": true + }, + { + "key": "current_read_iteration_index", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "current_update_iteration_index", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "current_partial_iteration_index", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "current_iteration", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "external_ids", + "value": "[]", + "type": "default", + "enabled": true + }, + { + "key": "current_read_external_id_index", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "current_update_external_id_index", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "current_partial_external_id_index", + "value": "0", + "type": "default", + "enabled": true + }, + { + "key": "current_external_id", + "value": "", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2023-08-16T02:36:28.946Z", + "_postman_exported_using": "Postman/10.17.0" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6cb8cc --- /dev/null +++ b/README.md @@ -0,0 +1,530 @@ +# Summary + +The goal of this application is to solve the problem of needing to write boilerplate code when creating a microservice. Code necessary to be written should be as simple as possible while still allowing flexibilty for complex use cases. In addition to this, unlike a project template, microservices created from this application should be able to pull in versioned enhancements and fixes as desired. + +# Quick Start + +1. Start off by [forking](https://docs.github.com/en/get-started/quickstart/fork-a-repo#forking-a-repository) this repository. Check out [versions](#versions) and [branching strategy](#branching-strategy) to decide what branch to start from. +2. Clone the new repo locally. +3. Create a new branch. +4. Open the [solution](/Crud.sln) in an IDE. +5. Add any [models](#models) if desired or use existing example models. +6. Ensure data store is running. +7. Add connection information to [appsettings](/Crud.Api/appsettings.Development.json). +8. Start the application. +9. Use Postman or similar application to start calling the [C](#create)[R](#read)[U](#update)[D](#delete) routes. (See example [Postman requests](/Postman/Crud.postman_collection.json).) + +# Models + +Models are POCOs located in the [Models](/Crud.Api/Models/) folder. These map directly to a collection/table in the data store. + +Examples in the following documentation use the [User](/Crud.Api/Models/User.cs) and [Address](/Crud.Api/Models/Address.cs) models that come defaultly in this project. These are used soley for examples and may be removed. Do not remove IExternalEntity or [ExternalEntity](#externalentity). + +## Attributes + +### Table Data Annotation + +The [Table](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.schema.tableattribute?view=net-7.0) data annotation is optional. It may be used to specify the name of the collection/table that the model will be stored in. Otherwise, the name that will be used as the collection/table will default to the pluralized version of the class name. + +The following example will specify that the name of the collection/table to store the model in should be "users". + +```c# +[Table("users")] +public class User : ExternalEntity +``` + +### Validator Data Annotations + +Standard and custom [data annotation](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations?view=net-7.0) validators may be added to properties in the model. These will automatically be used to validate the model without adding any additional code to the [Validator](/Crud.Api/Validators/Validator.cs). + +### JSON Attributes + +Standard System.Text.Json attributes, like [JsonPropertyNameAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonpropertynameattribute?view=net-7.0), can be added to the properties in the model to customize the JSON serialization and deserialization. + +### PreventCrud Attribute + +The [PreventCrud](/Crud.Api/Attributes/PreventCrudAttribute.cs) attribute is optional. This is used to prevent some or all [CRUD operations](/Crud.Api/Enums/CrudOperation.cs) on a model. See details [here](/docs/PREVENTCRUDATTRIBUTE.md). + +### PreventQuery Attribute + +The [PreventQuery](/Crud.Api/Attributes/PreventQueryAttribute.cs) attribute is optional. This is used to prevent some or all [Query operators](/Crud.Api/QueryModels/Operator.cs) on a model's property. See details [here](/docs/PREVENTQUERYATTRIBUTE.md). + +## ExternalEntity + +This class and IExternalEntity interface should not be removed from the application. Although not necessary, it is highly suggested to inherit from for [models](#models) that map directly to a collection/table. Example: [User](/Crud.Api/Models/User.cs) maps to the `Users` collection while [Address](/Crud.Api/Models/Address.cs) is stored within a document in that collection. The purpose of this class is to give each document/row a unique "random" identifier so that it may be safely referenced by external applications. Sequential identifiers are not as safe to use as they can be easily manipulated and without the proper checks, allow access to other data. They do make for better clustered indexes, so they should continue to be used within the data store. + +# Routing + +This application uses a RESTful naming convention for the routes. In the examples below, replace `{typeName}` with the pluralized name of the model the action will be on. For example, when acting on [User](/Crud.Api/Models/User.cs), replace `{typeName}` with "users". + +# Create + +## api/{typeName} - HttpPost + +Add the JSON describing the model to be created in the request body. + +# Read + +## api/{typeName}/{id:guid} - HttpGet + +Replace `{id:guid}` with the `Id` of the model to be retrieved. + +## api/{typeName}{?prop1=val1...&propN=valN} - HttpGet + +Replace `{?prop1=val1...&propN=valN}` with [query parameter filtering](#query-parameter-filtering). By default, at least one query parameter is required. To allow returning all, the [validator](/Crud.Api/Validators/Validator.cs) check for this will need to be removed. All documents/rows that match the filter will be retrieved. + +## api/query/{typeName} - HttpPost + +Add the JSON [query filtering](#body-query-filtering) to the body of the request. All documents/rows that match the filter will be retrieved. + +## api/query/{typeName}/count - HttpPost + +This returns the `number` of documents/rows that the [query filtering](#body-query-filtering) filtered. The forseen utility of this route is for pagination. + +# Update + +## api/{typeName}/{id:guid} - HttpPut + +Replace `{id:guid}` with the `Id` of the model to be updated. The document/row that this `Id` matches will be replaced by the JSON object in the body of the request. + +# Partial Update + +## api/{typeName}/{id:guid} - HttpPatch + +Replace `{id:guid}` with the `Id` of the model to be updated. The document/row that this `Id` matches will have only the fields/columns updated that are in the JSON object in the body of the request. + +## api/{typeName}{?prop1=val1...&propN=valN} - HttpPatch + +Replace `{?prop1=val1...&propN=valN}` with [query parameter filtering](#query-parameter-filtering). By default, at least one query parameter is required. To allow updating all, the [validator](/Crud.Api/Validators/Validator.cs) check for this will need to be removed. All documents/rows that match the filter will have only the fields/columns updated that are in the JSON object in the body of the request. + +*Note: Unable to do [query filtering](#body-query-filtering) and partial update as both require JSON in the body of the request.* + +# Delete + +## api/{typeName}/{id:guid} - HttpDelete + +Replace `{id:guid}` with the `Id` of the model to be deleted. + +## api/{typeName}{?prop1=val1...&propN=valN} - HttpDelete + +Replace `{?prop1=val1...&propN=valN}` with [query parameter filtering](#query-parameter-filtering). By default, at least one query parameter is required. To allow deleting all, the [validator](/Crud.Api/Validators/Validator.cs) check for this will need to be removed. All documents/rows that match the filter will be deleted. + +## api/query/{typeName} - HttpDelete + +Add the JSON [query filtering](#body-query-filtering) to the body of the request. All documents/rows that match the filter will be deleted. + +# Query Parameter Filtering + +Properties of the model may be added as a query parameter to filter the documents/rows acted on in the data store. The operator is limited to equality for filtering. The underscore delimiter `parent_child` may be used to refer to child properties. + +The following example will filter on [Users](/Crud.Api/Models/User.cs) with `age` equal to 42 and `city` equal to "Tampa". + +``` +api/users?age=42&address_city=Tampa +``` + +# Body Query Filtering + +Queries can be added to the body of a request in JSON format. This will then be used to filter the documents/rows acted on in the data store. The dot delimiter `parent.child` may be used to refer to child properties. + +## Includes + +Fields/columns that will be returned from the data store. If this and [Excludes](#excludes) are null, all fields/columns are returned. + +The following example will only return the `age`, `name`, and `city` for all [Users](/Crud.Api/Models/User.cs) retrieved. + +```json +{ + "includes": ["age", "name", "address.city"] +} +``` + +Example returned JSON: + +```json +[ + { + "name": "Bill Johnson", + "address": { + "city": "Pittsburgh" + }, + "age": 25 + }, + { + "name": "John Billson", + "address": { + "city": "Dallas" + }, + "age": 31 + }, + { + "name": "Johnny Bill", + "address": { + "city": "Tampa" + }, + "age": 42 + } +] +``` + +## Excludes + +Fields/columns that will not be returned from the data store. If this and [Includes](#includes) are null, all fields/columns are returned. + +The following example will return all properties except `hairColor`, `age`, `formerAddresses`, and `state` for all [Users](/Crud.Api/Models/User.cs) retrieved. + +```json +{ + "excludes": ["hairColor", "age", "formerAddresses", "address.state"] +} +``` + +Example returned JSON: + +```json +[ + { + "id": "6cd6f392-8271-49bb-8564-e584ddf48890", + "name": "Bill Johnson", + "address": { + "street": "44 Maple Street", + "city": "Pittsburgh" + }, + "favoriteThings": ["Steelers", "Pirates", "Penguins"] + }, + { + "id": "c7b1ebaf-4ac1-4fe0-b066-1282e072585a", + "name": "John Billson", + "address": { + "street": "101 Elm Street", + "city": "Dallas" + }, + "favoriteThings": ["Cowboys", "Stars", "Mavericks"] + }, + { + "id": "f4064c6b-e41a-4c34-a0b2-9e7a233b8310", + "name": "Johnny Bill", + "address": { + "street": "75 Oak Street", + "city": "Tampa" + }, + "favoriteThings": ["Buccaneers", "Lightning"] + } +] +``` + +## Where + +### Condition + +Constrains what documents/rows are filtered on in the data store. + +| JSON Type | Name | Description | +| --------- | ---- | ----------- | +| `String?` | Field | Name of the field/column side being evaluated.
Should be null if [GroupedConditions](#grouped-conditions) is populated. | +| `String?` | ComparisonOperator | The operator used in the evaluation.
Should be null if [GroupedConditions](#grouped-conditions) is populated. | +| `String?` | Value | Value that the [ComparisonOperator](#comparison-operators) will compare the `Field` against in the evaluation.
Should be null if `Values` or [GroupedConditions](#grouped-conditions) is populated. | +| `Array[String]?` | Values | Values that the [ComparisonOperator](#comparison-operators) will compare the `Field` against in the evaluation.
Should be null if `Value` or [GroupedConditions](#grouped-conditions) is populated. | +| `Array[GroupedCondition]?` | GroupedConditions | Groups of conditions used for complex logic to constrain what documents/rows are filtered on in the data store. For more details, see the [GroupedConditions](#grouped-conditions) section. | + +The following example will filter on [Users](/Crud.Api/Models/User.cs) with an age less than 30. + +```json +{ + "where": { + "field": "age", + "comparisonOperator": "<", + "value": "30" + } +} +``` + +### Grouped Conditions + +Groups of conditions used for complex logic to constrain what documents/rows are filtered on in the data store.
+*Note: Top level Grouped Conditions default to an AND [LogicalOperator](#logical-operators).* + +| JSON Type | Name | Description | +| --------- | ---- | ----------- | +| `String?` | LogicalOperator | The operator applied between each condition in `Conditions`. | +| `Array[Condition]` | Conditions | All conditions have the same [LogicalOperator](#logical-operators) applied between each condition. | + +The following example will filter on [Users](/Crud.Api/Models/User.cs) with `city` equal to "Dallas" or an `age` equal to 25. + +```json +{ + "where": { + "groupedConditions": [{ + "logicalOperator": "||", + "conditions": [{ + "field": "address.city", + "comparisonOperator": "==", + "value": "Dallas" + }, + { + "field": "age", + "comparisonOperator": "==", + "value": "25" + }] + }] + } +} +``` + +### Comparison Operators + +The aliases are put in a [Condition](#condition)'s `ComparisonOperator`. Aliases are not case sensitive. Some operators have multiple aliases for the same operator. These may be mixed and matched to fit any style. + +| Name | Aliases | Description | +| ---- | ------- | ----------- | +| Equality | `==`
`Equals`
`EQ` | | +| Inequality | `!=`
`NotEquals`
`NE` | | +| GreaterThan | `>`
`GreaterThan`
`GT` | | +| GreaterThanOrEquals | `>=`
`GreaterThanOrEquals`
`GTE` | | +| LessThan | `<`
`LessThan`
`LT` | | +| LessThanOrEquals | `<=`
`LessThanOrEquals`
`LTE` | | +| In | `IN` | If any value in `Field` matches any value in `Values`. | +| NotIn | `NotIn`
`NIN` | If all values in `Field` do not match any value in `Values`. | +| All | `All` | If all values in `Values` match any value in `Field`. | +| Contains | `Contains` | For use with `Field` properties of type `String`. If value in `Field` contains the value in `Value`. There may be hits to performance when using this operator. All [queries](#body-query-filtering) may be prevented from using this operator by setting `PreventAllQueryContains` to `true` in the [appsettings.json](/Crud.Api/appsettings.json). Instead of preventing all, individual properties may be prevented from being being [queried](#body-query-filtering) on using this operator by decorating it with the [PreventQuery](/Crud.Api/Attributes/PreventQueryAttribute.cs)([Operator](/Crud.Api/QueryModels/Operator.cs).Contains). | +| StartsWith | `StartsWith` | For use with `Field` properties of type `String`. If value in `Field` starts with the value in `Value`. There may be hits to performance when using this operator. All [queries](#body-query-filtering) may be prevented from using this operator by setting `PreventAllQueryStartsWith` to `true` in the [appsettings.json](/Crud.Api/appsettings.json). Instead of preventing all, individual properties may be prevented from being being [queried](#body-query-filtering) on using this operator by decorating it with the [PreventQuery](/Crud.Api/Attributes/PreventQueryAttribute.cs)([Operator](/Crud.Api/QueryModels/Operator.cs).StartsWith). | +| EndsWith | `EndsWith` | For use with `Field` properties of type `String`. If value in `Field` ends with the value in `Value`. There may be hits to performance when using this operator. All [queries](#body-query-filtering) may be prevented from using this operator by setting `PreventAllQueryEndsWith` to `true` in the [appsettings.json](/Crud.Api/appsettings.json). Instead of preventing all, individual properties may be prevented from being being [queried](#body-query-filtering) on using this operator by decorating it with the [PreventQuery](/Crud.Api/Attributes/PreventQueryAttribute.cs)([Operator](/Crud.Api/QueryModels/Operator.cs).EndsWith). | + +### Logical Operators + +The aliases are put in a [GroupedCondition](#grouped-conditions)'s `LogicalOperator`. This `LogicalOperator` is applied between each condition in `Conditions`. Aliases are not case sensitive. Some operators have multiple aliases for the same operator. These may be mixed at matched to fit any style. + +| Name | Aliases | +| ---- | ------- | +| And | `&&`
`AND` | +| Or | `\|\|`
`OR` | + +## Order By + +In what order the documents/rows will be returned from the data store. + +| JSON Type | Name | Description | +| --------- | ---- | ----------- | +| `String?` | Field | Name of the field/column being sorted. | +| `Boolean?` | IsDescending | If the `Field` will be in descending order.
*Default: false* | + +The following example will return all [Users](/Crud.Api/Models/User.cs) ordered first by their `city` ascending, then `age` descending, then by `name` ascending. + +```json +{ + "orderby": [ + { + "field": "address.city" + }, + { + "field": "age", + "isDescending": true + }, + { + "field": "name" + } + ] +} +``` + +## Limit + +Sets the max number of documents/rows that will be returned from the data store. + +The following example limits the max number of [Users](/Crud.Api/Models/User.cs) returned to 2. + +```json +{ + "limit": 2 +} +``` + +## Skip + +Sets how many documents/rows to skip over. + +The following example skips over the first 3 [Users](/Crud.Api/Models/User.cs) that would have been returned and returns the rest. + +```json +{ + "skip": 3 +} +``` + +## Complex Query Example + +The following example will only return `name`, `age`, and `favoriteThings` of [Users](/Crud.Api/Models/User.cs) with a `name` that ends with "Johnson" or `favoriteThings` that are in ["Steelers", "Lightning"] and a `city` equal to "Pittsburgh" and `age` less than or equal to 42. The result will be ordered by `name` in ascending order, then `age` in descending order. The first two that would have returned are skipped over. The max number of [Users](/Crud.Api/Models/User.cs) returned is ten. + +```json +{ + "includes": ["name", "age", "favoriteThings"], + "where": { + "groupedConditions": [ + { + "logicalOperator": "&&", + "conditions": [ + { + "groupedConditions": [ + { + "logicalOperator": "||", + "conditions": [ + { + "field": "name", + "comparisonOperator": "ENDSWITH", + "value": "Johnson" + }, + { + "field": "favoriteThings", + "comparisonOperator": "IN", + "values": ["Steelers", "Lightning"] + } + ] + }, + { + "logicalOperator": "&&", + "conditions": [ + { + "field": "address.city", + "comparisonOperator": "==", + "value": "Pittsburgh" + }, + { + "field": "age", + "comparisonOperator": "<=", + "value": "42" + } + ] + } + ] + } + ] + } + ] + }, + "orderby": [ + { + "field": "name" + }, + { + "field": "age", + "isDescending": true + } + ], + "limit": 10, + "skip": 2 +} +``` + +To help get a better understanding, the following is an equivalent C# logical statement of the [where](#where) condition in the JSON above. + +```c# +if ( + (user.Name.EndsWith("Johnson", StringComparison.OrdinalIgnoreCase) + || user.FavoriteThings.Any(favoriteThing => new List { "Steelers", "Lightning" }.Any(x => x == favoriteThing))) + && + (user.Address.City == "Pittsburgh" + && user.Age <= 42) + ) +``` + +# Validation + +These methods may be used to prevent a CRUD operation and optionally return a message stating why the operation was invalid. + +| Signature | Description | +| --------- | ----------- | +| `Task ValidateCreateAsync(Object model)` | Validates the model when creating. By default, data annotations on the model are validated. | +| `Task ValidateReadAsync(Object model, IDictionary? queryParams)` | Validates the model when reading with [query parameter filtering](#query-parameter-filtering). By default, all query parameters are ensured to be properties of the model. | +| `Task ValidateUpdateAsync(Object model, Guid id)` | Validates the model when replacement updating with an Id. By default, data annotations on the model are validated. | +| `Task ValidatePartialUpdateAsync(Object model, Guid id, IReadOnlyCollection? propertiesToBeUpdated)` | Validates the model when partially updating with an Id. By default, all properties to be updated are ensured to be properties of the model and data annotations on the model are validated. | +| `Task ValidatePartialUpdateAsync(Object model, IDictionary? queryParams, IReadOnlyCollection? propertiesToBeUpdated)` | Validates the model when partially updating with [query parameter filtering](#query-parameter-filtering). By default, all query parameters are ensured to be properties of the model, all properties to be updated are ensured to be properties of the model, and data annotations on the model are validated. | +| `Task ValidateDeleteAsync(Object model, IDictionary? queryParams)` | Validates the model when deleting with [query parameter filtering](#query-parameter-filtering). By default, all query parameters are ensured to be properties of the model. | +| `ValidationResult ValidateQuery(Object model, Query query)` | Validates the model when using [body query filtering](#body-query-filtering). | + +Each signature above may be overloaded by replacing the `Object model` parameter with a specific model type. There are many examples using the [User](/Crud.Api/Models/User.cs) model to override the validating method in the [Validator](/Crud.Api/Validators/Validator.cs) class. These may be removed as they are solely there as examples. + +The following example overrides the `Task ValidateCreateAsync(Object model)` validating method and also calls the `Object model` version of the method to reuse the logic. + +```c# +public async Task ValidateCreateAsync(User user) +{ + if (user is null) + return new ValidationResult(false, $"{nameof(User)} cannot be null."); + + var objectValidationResult = await ValidateCreateAsync((object)user); + if (!objectValidationResult.IsValid) + return objectValidationResult; + + return new ValidationResult(true); +} +``` + +# Preprocessing + +[Preprocessing](/Crud.Api/Services/PreprocessingService.cs) is optional. These methods may be used to do any sort of preprocessing actions. See details [here](/docs/PREPROCESSING.md). + +# Postprocessing + +[Postprocessing](/Crud.Api/Services/PostprocessingService.cs) is optional. These methods may be used to do any sort of postprocessing actions. See details [here](/docs/POSTPROCESSING.md). + +# Metrics + +CRUD operations on models has been simplified. But at what cost? The following metrics were obtained by running the exact same [Postman requests](/Postman/Crud.postman_collection.json) against this application versus running them against an application that does the same operations, but without the dynamic model capabilities, called [CrudMetrics](https://github.com/steven-rothwell/CrudMetrics). + +The following is the average of each request which was run with 100 iterations and no indexes on the collections. + +| Request | CrudMetrics (baseline) | Crud (dynamic models) | +| ------- | ---------------------: | --------------------: | +| CreateUser | 4 ms | 4 ms | +| ReadUser_Id | 3 ms | 3 ms | +| ReadUser_Name | 3 ms | 3 ms | +| UpdateUser_Id | 3 ms | 3 ms | +| PartialUpdateUser_Id | 3 ms | 4 ms | +| PartialUpdateUser_Name | 3 ms | 3 ms | +| DeleteUser_Id | 3 ms | 3 ms | +| DeleteUser_Name | 3 ms | 3 ms | + +# Mutable Code + +The following are files and folders that may be altered when using this code to create a microservice. All other files and folders should only be modified by [contributors](#contributing). + +- [Models](/Crud.Api/Models/) - See details [here](#models). +- [Validator](/Crud.Api/Validators/Validator.cs) - See details [here](#validation). +- [PreprocessingService](/Crud.Api/Services/PreprocessingService.cs) - See details [here](#preprocessing). +- [PostprocessingService](/Crud.Api/Services/PostprocessingService.cs) - See details [here](#postprocessing). + +# Versions + +Pattern: #.#.# - *breaking-change*.*new-features*.*maintenance* +
Incrementing version zeroes-out version numbers to the right. +
Example: Current version is 1.0.3, new features are added, new version is 1.1.0. + +# Updating Versions + +If a new version is released and these updates would be useful in a forked application: + +1. At minimum, read all [release notes](#release-notes) for each breaking change since the last fetch. (Example: Last forked from v1.0.3. Desired updated version is v4.2.6. At least read release notes of v2.0.0, v3.0.0, and v4.0.0 as code changes may be required.) +1. Fetch the desired v#.#.# branch from this repository into the forked repository. +2. Create a new branch. +3. Merge existing forked application code and v#.#.# branch. +3. Fix any merge conflicts. +4. Test. + +# Branching Strategy + +| Name | Description | +| ---- | ----------- | +| v#.#.# | Standard branches to create a forked application from. | +| v#.#.#-beta | Used when the next version requires burn in testing. This may be forked from to test out new features, but should not be used in a production environment. | + +# Release Notes + +| Number | Available Preservers | Framework | Notes | +| ------ | -------------------- | --------- | ----- | +| 1.0.0 | MongoDB | .NET 7 | See details [here](/docs/release-notes/RELEASE-1.0.0.md). | + +# Contributing + +Please see detailed documentation on how to contribute to this project [here](/docs/CONTRIBUTING.md). + +^ [Back to top](#summary) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..c458591 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing Guide + +Hello and welcome! Thank you for showing a modicum of interest in contributing to the CRUD application by at least opening this file! + +## Goals + +The main goal as stated in the [README](/README.md) is to give developers the ability to quickly create a microservice with nil boilerplate code. Yet also, allow for fine tuning and customization when desired. + +## Branching + +| Name | Description | +| ------------ | ----------------------------------------------- | +| v#.#.#-alpha | Used to integrate changes for the next release. | +| feature/\* | Used to contribute new feature code. | +| bugfix/\* | Used to contribute code to fix a bug. | +| test/\* | Used to experiment with the code. | + +Beyond the initial directory style naming convention, feel free to name branches to your style. You could even have subdirectories like `feature/steven-rothwell/my-sweet-new-feature` to help identify which branches are yours. + +### Features and Bugs + +Prior to starting work on any new features or bug fixes, please raise an issue and it agreed upon. I would not want your hard work and time wasted on something I do not think belongs in the project. + +Please ensure any new projects or newly added NuGet packages are agreed upon. + +### New Branches + +New branches should be created from `master`. + +### Pull Requests + +PRs should target the next `v#.#.#-alpha`. All PRs will be squashed and merged. + +Fair warning, I am very particular with PRs. Please follow best practices, coding style, and naming conventions. diff --git a/docs/POSTPROCESSING.md b/docs/POSTPROCESSING.md new file mode 100644 index 0000000..a8ab8ed --- /dev/null +++ b/docs/POSTPROCESSING.md @@ -0,0 +1,17 @@ +# Postprocessing + +[Postprocessing](/Crud.Api/Services/PostprocessingService.cs) is optional. These methods may be used to do any sort of postprocessing actions. + +| Signature | Description | +| --------- | ----------- | +| `Task PostprocessCreateAsync(Object createdModel)` | Postprocessing when creating. | +| `Task PostprocessReadAsync(Object model, Guid id)` | Postprocessing when reading with an Id. | +| `Task PostprocessReadAsync(IEnumerable models, IDictionary? queryParams)` | Postprocessing when reading with [query parameter filtering](/README.md#query-parameter-filtering). | +| `Task PostprocessReadAsync(IEnumerable models, Query query)` | Postprocessing when reading with [body query filtering](/README.md#body-query-filtering). | +| `Task PostprocessReadCountAsync(Object model, Query query, Int64 count)` | Postprocessing when reading the count with [body query filtering](/README.md#body-query-filtering). | +| `Task PostprocessUpdateAsync(Object updatedModel, Guid id)` | Postprocessing when updating with an Id. | +| `Task PostprocessPartialUpdateAsync(Object updatedModel, Guid id, IDictionary propertyValues)` | Postprocessing when partially updating with an Id. | +| `Task PostprocessPartialUpdateAsync(Object model, IDictionary? queryParams, IDictionary propertyValues, Int64 updatedCount)` | Postprocessing when partially updating with [query parameter filtering](/README.md#query-parameter-filtering). | +| `Task PostprocessDeleteAsync(Object model, Guid id, Int64 deletedCount)` | Postprocessing when deleting with an Id. | +| `Task PostprocessDeleteAsync(Object model, IDictionary? queryParams, Int64 deletedCount)` | Postprocessing when deleting with [query parameter filtering](/README.md#query-parameter-filtering). | +| `Task PostprocessDeleteAsync(Object model, Query query, Int64 deletedCount)` | Postprocessing when deleting with [body query filtering](/README.md#body-query-filtering). | \ No newline at end of file diff --git a/docs/PREPROCESSING.md b/docs/PREPROCESSING.md new file mode 100644 index 0000000..96ac821 --- /dev/null +++ b/docs/PREPROCESSING.md @@ -0,0 +1,17 @@ +# Preprocessing + +[Preprocessing](/Crud.Api/Services/PreprocessingService.cs) is optional. These methods may be used to do any sort of preprocessing actions. + +| Signature | Description | +| --------- | ----------- | +| `Task PreprocessCreateAsync(Object model)` | Preprocessing when creating. | +| `Task PreprocessReadAsync(Object model, Guid id)` | Preprocessing when reading with an Id. | +| `Task PreprocessReadAsync(Object model, IDictionary? queryParams)` | Preprocessing when reading with [query parameter filtering](/README.md#query-parameter-filtering). | +| `Task PreprocessReadAsync(Object model, Query query)` | Preprocessing when reading with [body query filtering](/README.md#body-query-filtering). | +| `Task PreprocessReadCountAsync(Object model, Query query)` | Preprocessing when reading the count with [body query filtering](/README.md#body-query-filtering). | +| `Task PreprocessUpdateAsync(Object model, Guid id)` | Preprocessing when updating with an Id. | +| `Task PreprocessPartialUpdateAsync(Object model, Guid id, IDictionary propertyValues)` | Preprocessing when partially updating with an Id. | +| `Task PreprocessPartialUpdateAsync(Object model, IDictionary? queryParams, IDictionary propertyValues)` | Preprocessing when partially updating with [query parameter filtering](/README.md#query-parameter-filtering). | +| `Task PreprocessDeleteAsync(Object model, Guid id)` | Preprocessing when deleting with an Id. | +| `Task PreprocessDeleteAsync(Object model, IDictionary? queryParams)` | Preprocessing when deleting with [query parameter filtering](/README.md#query-parameter-filtering). | +| `Task PreprocessDeleteAsync(Object model, Query query)` | Preprocessing when deleting with [body query filtering](/README.md#body-query-filtering). | \ No newline at end of file diff --git a/docs/PREVENTCRUDATTRIBUTE.md b/docs/PREVENTCRUDATTRIBUTE.md new file mode 100644 index 0000000..16cb306 --- /dev/null +++ b/docs/PREVENTCRUDATTRIBUTE.md @@ -0,0 +1,17 @@ +# PreventCrud Attribute + +The [PreventCrud](/Crud.Api/Attributes/PreventCrudAttribute.cs) attribute is optional. This is used to prevent some or all [CRUD operations](/Crud.Api/Enums/CrudOperation.cs) on a model. + +The following example will prevent all CRUD operations on the [Address](/Crud.Api/Models/Address.cs) model. This is useful for models that will always be stored within another model. + +```c# +[PreventCrud] +public class Address +``` + +The following example will prevent [Updating](#update), all forms of [Partial Updating](#partial-update), and all forms of [Deleting](#delete) the `CreationMetadata`. This is useful when limiting CRUD operations is required. In this example `CreationMetadata` is required to be a readonly model. + +```c# +[PreventCrud(CrudOperation.Update, CrudOperation.PartialUpdate, CrudOperation.Delete)] +public class CreationMetadata +``` \ No newline at end of file diff --git a/docs/PREVENTQUERYATTRIBUTE.md b/docs/PREVENTQUERYATTRIBUTE.md new file mode 100644 index 0000000..eda1b05 --- /dev/null +++ b/docs/PREVENTQUERYATTRIBUTE.md @@ -0,0 +1,23 @@ +# PreventQuery Attribute + +The [PreventQuery](/Crud.Api/Attributes/PreventQueryAttribute.cs) attribute is optional. This is used to prevent some or all [Query operators](/Crud.Api/QueryModels/Operator.cs) on a model's property. + +The following example will prevent all [Query operators](/Crud.Api/QueryModels/Operator.cs) on the [User](/Crud.Api/Models/User.cs) `FormerAddress` property. + +```c# +public class User +{ + [PreventQuery] + public ICollection
? FormerAddresses { get; set; } +} +``` + +The following example will prevent [Operator](/Crud.Api/QueryModels/Operator.cs).Contains on the [User](/Crud.Api/Models/User.cs) `Name` property. This is useful for operators that may cause performance hits or properties that are not indexed and may also lead to poor performance when fetching the data. + +```c# +public class User +{ + [PreventQuery(Operator.Contains)] + public String? Name { get; set; } +} +``` \ No newline at end of file diff --git a/docs/release-notes/RELEASE-1.0.0.md b/docs/release-notes/RELEASE-1.0.0.md new file mode 100644 index 0000000..7d85110 --- /dev/null +++ b/docs/release-notes/RELEASE-1.0.0.md @@ -0,0 +1,25 @@ +# Release v1.0.0 + +## Notes + +Initial release with basic CRUD operations, filtering by query parameters, and body queries. + +### Breaking Changes + +- None + +### New Features + +- None + +### Maintenance + +- None + +## Available Preservers + +- MongoDB + +## Framework + +- .NET 7