diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..134066baff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +charset = utf-8 + +[*.{csproj,props}] +indent_size = 2 diff --git a/JsonApiDotnetCore.sln b/JsonApiDotnetCore.sln index a144223671..385fa4d6ad 100644 --- a/JsonApiDotnetCore.sln +++ b/JsonApiDotnetCore.sln @@ -28,6 +28,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{02 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReportsExample", "src\Examples\ReportsExample\ReportsExample.csproj", "{FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{076E1AE4-FD25-4684-B826-CAAE37FEA0AA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "benchmarks\Benchmarks.csproj", "{1F604666-BB0F-413E-922D-9D37C6073285}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -106,10 +110,22 @@ Global {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Debug|x86.Build.0 = Debug|Any CPU {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|Any CPU.ActiveCfg = Release|Any CPU {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|Any CPU.Build.0 = Release|Any CPU - {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.ActiveCfg = Release|Any CPU - {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.Build.0 = Release|Any CPU - {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.ActiveCfg = Release|Any CPU - {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.Build.0 = Release|Any CPU + {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.ActiveCfg = Release|x64 + {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x64.Build.0 = Release|x64 + {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.ActiveCfg = Release|x86 + {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D}.Release|x86.Build.0 = Release|x86 + {1F604666-BB0F-413E-922D-9D37C6073285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F604666-BB0F-413E-922D-9D37C6073285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x64.ActiveCfg = Debug|x64 + {1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x64.Build.0 = Debug|x64 + {1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x86.ActiveCfg = Debug|x86 + {1F604666-BB0F-413E-922D-9D37C6073285}.Debug|x86.Build.0 = Debug|x86 + {1F604666-BB0F-413E-922D-9D37C6073285}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F604666-BB0F-413E-922D-9D37C6073285}.Release|Any CPU.Build.0 = Release|Any CPU + {1F604666-BB0F-413E-922D-9D37C6073285}.Release|x64.ActiveCfg = Release|x64 + {1F604666-BB0F-413E-922D-9D37C6073285}.Release|x64.Build.0 = Release|x64 + {1F604666-BB0F-413E-922D-9D37C6073285}.Release|x86.ActiveCfg = Release|x86 + {1F604666-BB0F-413E-922D-9D37C6073285}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -123,6 +139,7 @@ Global {6D4BD85A-A262-44C6-8572-FE3A30410BF3} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {026FBC6C-AF76-4568-9B87-EC73457899FD} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {FBFB0B0B-EA86-4B41-AB2A-E0249F70C86D} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {1F604666-BB0F-413E-922D-9D37C6073285} = {076E1AE4-FD25-4684-B826-CAAE37FEA0AA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000000..5a3c72cbbb --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,239 @@ +_data/ +*-report-default.md +*-report.csv +*-report.html + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Query.QueryParser_Benchmarks-report-github.md b/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Query.QueryParser_Benchmarks-report-github.md new file mode 100755 index 0000000000..b3ebd8a29c --- /dev/null +++ b/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Query.QueryParser_Benchmarks-report-github.md @@ -0,0 +1,16 @@ +``` ini + +BenchmarkDotNet=v0.10.10, OS=Mac OS X 10.12 +Processor=Intel Core i5-5257U CPU 2.70GHz (Broadwell), ProcessorCount=4 +.NET Core SDK=2.0.0 + [Host] : .NET Core 1.1.4 (Framework 4.6.25714.03), 64bit RyuJIT + Job-WKDOLS : .NET Core 1.1.4 (Framework 4.6.25714.03), 64bit RyuJIT + +LaunchCount=3 TargetCount=20 WarmupCount=10 + +``` +| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Allocated | +|--------------- |-------------:|-----------:|-----------:|---------:|--------:|----------:| +| AscendingSort | 4.316 us | 1.3773 us | 3.0232 us | 0.5066 | 0.1303 | 1.08 KB | +| DescendingSort | 3.300 us | 0.0314 us | 0.0682 us | 0.5123 | 0.1318 | 1.13 KB | +| ComplexQuery | 2,041.642 us | 41.5631 us | 92.1010 us | 312.5000 | 80.2734 | 648.99 KB | diff --git a/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Serialization.JsonApiDeserializer_Benchmarks-report-github.md b/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Serialization.JsonApiDeserializer_Benchmarks-report-github.md new file mode 100755 index 0000000000..6c8c3f2905 --- /dev/null +++ b/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Serialization.JsonApiDeserializer_Benchmarks-report-github.md @@ -0,0 +1,13 @@ +``` ini + +BenchmarkDotNet=v0.10.10, OS=Mac OS X 10.12 +Processor=Intel Core i5-5257U CPU 2.70GHz (Broadwell), ProcessorCount=4 +.NET Core SDK=2.0.0 + [Host] : .NET Core 1.1.4 (Framework 4.6.25714.03), 64bit RyuJIT + DefaultJob : .NET Core 1.1.4 (Framework 4.6.25714.03), 64bit RyuJIT + + +``` +| Method | Mean | Error | StdDev | +|------------------------ |---------:|----------:|----------:| +| DeserializeSimpleObject | 27.05 us | 0.5353 us | 0.5950 us | diff --git a/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Serialization.JsonApiSerializer_Benchmarks-report-github.md b/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Serialization.JsonApiSerializer_Benchmarks-report-github.md new file mode 100755 index 0000000000..f86bf0faa9 --- /dev/null +++ b/benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.Serialization.JsonApiSerializer_Benchmarks-report-github.md @@ -0,0 +1,13 @@ +``` ini + +BenchmarkDotNet=v0.10.10, OS=Mac OS X 10.12 +Processor=Intel Core i5-5257U CPU 2.70GHz (Broadwell), ProcessorCount=4 +.NET Core SDK=2.0.0 + [Host] : .NET Core 1.1.4 (Framework 4.6.25714.03), 64bit RyuJIT + DefaultJob : .NET Core 1.1.4 (Framework 4.6.25714.03), 64bit RyuJIT + + +``` +| Method | Mean | Error | StdDev | +|---------------------- |---------:|----------:|----------:| +| SerializeSimpleObject | 7.195 us | 0.1436 us | 0.1816 us | diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj new file mode 100644 index 0000000000..b5ff121826 --- /dev/null +++ b/benchmarks/Benchmarks.csproj @@ -0,0 +1,16 @@ + + + + Exe + $(NetCoreAppVersion) + Benchmarks + + + + + + + + + + diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs new file mode 100644 index 0000000000..7665d5fb97 --- /dev/null +++ b/benchmarks/Program.cs @@ -0,0 +1,16 @@ +using BenchmarkDotNet.Running; +using Benchmarks.Query; +using Benchmarks.Serialization; + +namespace Benchmarks { + class Program { + static void Main(string[] args) { + var switcher = new BenchmarkSwitcher(new[] { + typeof(JsonApiDeserializer_Benchmarks), + typeof(JsonApiSerializer_Benchmarks), + typeof(QueryParser_Benchmarks) + }); + switcher.Run(args); + } + } +} diff --git a/benchmarks/Query/QueryParser_Benchmarks.cs b/benchmarks/Query/QueryParser_Benchmarks.cs new file mode 100644 index 0000000000..61fe3b1bc2 --- /dev/null +++ b/benchmarks/Query/QueryParser_Benchmarks.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Attributes.Exporters; +using BenchmarkDotNet.Attributes.Jobs; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Http.Internal; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace Benchmarks.Query { + [MarkdownExporter, SimpleJob(launchCount : 3, warmupCount : 10, targetCount : 20), MemoryDiagnoser] + public class QueryParser_Benchmarks { + private readonly BenchmarkFacade _queryParser; + + private const string ATTRIBUTE = "Attribute"; + private const string ASCENDING_SORT = ATTRIBUTE; + private const string DESCENDING_SORT = "-" + ATTRIBUTE; + + public QueryParser_Benchmarks() { + var controllerContextMock = new Mock(); + controllerContextMock.Setup(m => m.RequestEntity).Returns(new ContextEntity { + Attributes = new List { + new AttrAttribute(ATTRIBUTE) { + InternalAttributeName = ATTRIBUTE + } + } + }); + var options = new JsonApiOptions(); + _queryParser = new BenchmarkFacade(controllerContextMock.Object, options); + } + + [Benchmark] + public void AscendingSort() => _queryParser._ParseSortParameters(ASCENDING_SORT); + + [Benchmark] + public void DescendingSort() => _queryParser._ParseSortParameters(DESCENDING_SORT); + + [Benchmark] + public void ComplexQuery() => Run(100, () => _queryParser.Parse( + new QueryCollection( + new Dictionary { + { $"filter[{ATTRIBUTE}]", new StringValues(new [] { "abc", "eq:abc" }) }, + { $"sort", $"-{ATTRIBUTE}" }, + { $"include", "relationship" }, + { $"page[size]", "1" }, + { $"fields[resource]", ATTRIBUTE }, + } + ) + )); + + private void Run(int iterations, Action action) { + for (int i = 0; i < iterations; i++) + action(); + } + + // this facade allows us to expose and micro-benchmark protected methods + private class BenchmarkFacade : QueryParser { + public BenchmarkFacade( + IControllerContext controllerContext, + JsonApiOptions options) : base(controllerContext, options) { } + + public void _ParseSortParameters(string value) => base.ParseSortParameters(value); + } + } +} diff --git a/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs b/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs new file mode 100644 index 0000000000..c490bee362 --- /dev/null +++ b/benchmarks/Serialization/JsonApiDeserializer_Benchmarks.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Attributes.Exporters; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Generics; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Benchmarks.Serialization { + [MarkdownExporter] + public class JsonApiDeserializer_Benchmarks { + private const string TYPE_NAME = "simple-types"; + private static readonly string Content = JsonConvert.SerializeObject(new Document { + Data = new DocumentData { + Type = TYPE_NAME, + Id = "1", + Attributes = new Dictionary { + { + "name", + Guid.NewGuid().ToString() + } + } + } + }); + + private readonly JsonApiDeSerializer _jsonApiDeSerializer; + + public JsonApiDeserializer_Benchmarks() { + var contextGraphBuilder = new ContextGraphBuilder(); + contextGraphBuilder.AddResource(TYPE_NAME); + var contextGraph = contextGraphBuilder.Build(); + + var jsonApiContextMock = new Mock(); + jsonApiContextMock.SetupAllProperties(); + jsonApiContextMock.Setup(m => m.ContextGraph).Returns(contextGraph); + jsonApiContextMock.Setup(m => m.AttributesToUpdate).Returns(new Dictionary()); + + var jsonApiOptions = new JsonApiOptions(); + jsonApiOptions.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + jsonApiContextMock.Setup(m => m.Options).Returns(jsonApiOptions); + + var genericProcessorFactoryMock = new Mock(); + + _jsonApiDeSerializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); + } + + [Benchmark] + public object DeserializeSimpleObject() => _jsonApiDeSerializer.Deserialize(Content); + + private class SimpleType : Identifiable { + [Attr("name")] + public string Name { get; set; } + } + } +} diff --git a/benchmarks/Serialization/JsonApiSerializer_Benchmarks.cs b/benchmarks/Serialization/JsonApiSerializer_Benchmarks.cs new file mode 100644 index 0000000000..3d5ef7c001 --- /dev/null +++ b/benchmarks/Serialization/JsonApiSerializer_Benchmarks.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Attributes.Exporters; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Generics; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using Moq; +using Newtonsoft.Json.Serialization; + +namespace Benchmarks.Serialization { + [MarkdownExporter] + public class JsonApiSerializer_Benchmarks { + private const string TYPE_NAME = "simple-types"; + private static readonly SimpleType Content = new SimpleType(); + + private readonly JsonApiSerializer _jsonApiSerializer; + + public JsonApiSerializer_Benchmarks() { + var contextGraphBuilder = new ContextGraphBuilder(); + contextGraphBuilder.AddResource(TYPE_NAME); + var contextGraph = contextGraphBuilder.Build(); + + var jsonApiContextMock = new Mock(); + jsonApiContextMock.SetupAllProperties(); + jsonApiContextMock.Setup(m => m.ContextGraph).Returns(contextGraph); + jsonApiContextMock.Setup(m => m.AttributesToUpdate).Returns(new Dictionary()); + + var jsonApiOptions = new JsonApiOptions(); + jsonApiOptions.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + jsonApiContextMock.Setup(m => m.Options).Returns(jsonApiOptions); + + var genericProcessorFactoryMock = new Mock(); + + var documentBuilder = new DocumentBuilder(jsonApiContextMock.Object); + _jsonApiSerializer = new JsonApiSerializer(jsonApiContextMock.Object, documentBuilder); + } + + [Benchmark] + public object SerializeSimpleObject() => _jsonApiSerializer.Serialize(Content); + + private class SimpleType : Identifiable { + [Attr("name")] + public string Name { get; set; } + } + } +} diff --git a/build/dependencies.props b/build/dependencies.props new file mode 100644 index 0000000000..13b9ba4ecb --- /dev/null +++ b/build/dependencies.props @@ -0,0 +1,11 @@ + + + netcoreapp2.0 + netstandard2.0 + + + 4.7.10 + 2.2.0 + 8.0.1-beta-1 + + diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index dcbf2a2841..a2981cffd1 100755 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -1,5 +1,5 @@ - + $(NetCoreAppVersion) true diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index afaf0e7cff..32506808fe 100755 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -1,5 +1,5 @@  - + $(NetCoreAppVersion) diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index 2131c545a4..f8f83e454c 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -1,24 +1,24 @@ - - - - $(NetCoreAppVersion) - - - - - - - - - - - - - - - - - - - - + + + + $(NetCoreAppVersion) + + + + + + + + + + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs index 12e57deadf..f556b7433d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; @@ -14,7 +15,7 @@ public bool CanRead(InputFormatterContext context) var contentTypeString = context.HttpContext.Request.ContentType; - return contentTypeString == "application/vnd.api+json"; + return contentTypeString == Constants.ContentType; } public async Task ReadAsync(InputFormatterContext context) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs index 2431055d1d..b456932fc5 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; @@ -14,7 +15,7 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) var contentTypeString = context.HttpContext.Request.ContentType; - return string.IsNullOrEmpty(contentTypeString) || contentTypeString == "application/vnd.api+json"; + return string.IsNullOrEmpty(contentTypeString) || contentTypeString == Constants.ContentType; } public async Task WriteAsync(OutputFormatterWriteContext context) diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs index 730a88f13e..d644def66d 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs @@ -13,7 +13,8 @@ public class JsonApiWriter : IJsonApiWriter private readonly ILogger _logger; private readonly IJsonApiSerializer _serializer; - public JsonApiWriter(IJsonApiSerializer serializer, + public JsonApiWriter( + IJsonApiSerializer serializer, ILoggerFactory loggerFactory) { _serializer = serializer; @@ -30,7 +31,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) var response = context.HttpContext.Response; using (var writer = context.WriterFactory(response.Body, Encoding.UTF8)) { - response.ContentType = "application/vnd.api+json"; + response.ContentType = Constants.ContentType; string responseContent; try { @@ -55,4 +56,4 @@ private string GetResponseBody(object responseObject) return _serializer.Serialize(responseObject); } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/Constants.cs b/src/JsonApiDotNetCore/Internal/Constants.cs new file mode 100644 index 0000000000..750d94ba07 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Constants.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Internal +{ + public static class Constants + { + public const string AcceptHeader = "Accept"; + public const string ContentType = "application/vnd.api+json"; + } +} diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index fd29794194..163196341b 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -1,7 +1,7 @@ -using System.Reflection; +using System; using System.Collections.Generic; using System.Linq; -using System; +using System.Reflection; namespace JsonApiDotNetCore.Internal { @@ -34,9 +34,9 @@ public string GetRelationshipName(string relationshipName) { var entityType = typeof(TParent); return Entities - .SingleOrDefault(e => e.EntityType == entityType) - .Relationships - .SingleOrDefault(r => string.Equals(r.PublicRelationshipName, relationshipName, StringComparison.OrdinalIgnoreCase)) + .SingleOrDefault(e => e.EntityType == entityType) + ?.Relationships + .SingleOrDefault(r => string.Equals(r.PublicRelationshipName, relationshipName, StringComparison.OrdinalIgnoreCase)) ?.InternalRelationshipName; } } diff --git a/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs index 42f3037f89..36b4969b1d 100644 --- a/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs +++ b/src/JsonApiDotNetCore/Internal/JsonApiExceptionFactory.cs @@ -5,14 +5,17 @@ namespace JsonApiDotNetCore.Internal { public static class JsonApiExceptionFactory { + private const string JsonApiException = nameof(JsonApiException); + private const string InvalidCastException = nameof(InvalidCastException); + public static JsonApiException GetException(Exception exception) { var exceptionType = exception.GetType().ToString().Split('.').Last(); switch(exceptionType) { - case "JsonApiException": + case JsonApiException: return (JsonApiException)exception; - case "InvalidCastException": + case InvalidCastException: return new JsonApiException(409, exception.Message); default: return new JsonApiException(500, exception.Message, GetExceptionDetail(exception.InnerException)); diff --git a/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs index 8af2fe95e1..74db2b342e 100644 --- a/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -9,14 +10,14 @@ public class AttrFilterQuery : BaseFilterQuery private readonly IJsonApiContext _jsonApiContext; public AttrFilterQuery( - IJsonApiContext jsonApiCopntext, + IJsonApiContext jsonApiContext, FilterQuery filterQuery) { - _jsonApiContext = jsonApiCopntext; + _jsonApiContext = jsonApiContext; - var attribute = GetAttribute(filterQuery.Key); + var attribute = GetAttribute(filterQuery.Attribute); - FilteredAttribute = attribute ?? throw new JsonApiException(400, $"{filterQuery.Key} is not a valid property."); + FilteredAttribute = attribute ?? throw new JsonApiException(400, $"'{filterQuery.Attribute}' is not a valid attribute."); PropertyValue = filterQuery.Value; FilterOperation = GetFilterOperation(filterQuery.Operation); } @@ -25,12 +26,9 @@ public AttrFilterQuery( public string PropertyValue { get; set; } public FilterOperations FilterOperation { get; set; } - private AttrAttribute GetAttribute(string propertyName) - { - return _jsonApiContext.RequestEntity.Attributes - .FirstOrDefault(attr => - attr.InternalAttributeName.ToLower() == propertyName.ToLower() + private AttrAttribute GetAttribute(string attribute) => + _jsonApiContext.RequestEntity.Attributes.FirstOrDefault( + attr => string.Equals(attr.PublicAttributeName, attribute, StringComparison.OrdinalIgnoreCase) ); - } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs index 527b842ca8..1c43d84254 100644 --- a/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs @@ -8,10 +8,10 @@ protected FilterOperations GetFilterOperation(string prefix) { if (prefix.Length == 0) return FilterOperations.eq; - if (!Enum.TryParse(prefix, out FilterOperations opertion)) + if (Enum.TryParse(prefix, out FilterOperations opertion) == false) throw new JsonApiException(400, $"Invalid filter prefix '{prefix}'"); return opertion; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs index 7f4f1a40c6..dd72e827ff 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs @@ -1,17 +1,23 @@ +using System; +using JsonApiDotNetCore.Extensions; + namespace JsonApiDotNetCore.Internal.Query { public class FilterQuery { - public FilterQuery(string key, string value, string operation) + public FilterQuery(string attribute, string value, string operation) { - Key = key; + Attribute = attribute; + Key = attribute.ToProperCase(); Value = value; Operation = operation; } + [Obsolete("Key has been replaced by '" + nameof(Attribute) + "'. Members should be located by their public name, not by coercing the provided value to the internal name.")] public string Key { get; set; } + public string Attribute { get; } public string Value { get; set; } public string Operation { get; set; } - public bool IsAttributeOfRelationship => Key.Contains("."); + public bool IsAttributeOfRelationship => Attribute.Contains("."); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs index dc633c5302..3b249c176c 100644 --- a/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -14,7 +15,7 @@ public RelatedAttrFilterQuery( { _jsonApiContext = jsonApiCopntext; - var relationshipArray = filterQuery.Key.Split('.'); + var relationshipArray = filterQuery.Attribute.Split('.'); var relationship = GetRelationship(relationshipArray[0]); if (relationship == null) @@ -36,14 +37,14 @@ public RelatedAttrFilterQuery( private RelationshipAttribute GetRelationship(string propertyName) { return _jsonApiContext.RequestEntity.Relationships - .FirstOrDefault(r => r.InternalRelationshipName.ToLower() == propertyName.ToLower()); + .FirstOrDefault(r => string.Equals(r.PublicRelationshipName, propertyName, StringComparison.OrdinalIgnoreCase)); } private AttrAttribute GetAttribute(RelationshipAttribute relationship, string attribute) { var relatedContextExntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); return relatedContextExntity.Attributes - .FirstOrDefault(a => a.InternalAttributeName.ToLower() == attribute.ToLower()); + .FirstOrDefault(a => string.Equals(a.PublicAttributeName, attribute, StringComparison.OrdinalIgnoreCase)); } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 9ebddb4512..53c81b2ec1 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,4 +1,5 @@  + 2.2.0 $(NetStandardVersion) @@ -20,4 +21,5 @@ + diff --git a/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs index 670def21d6..6e2612c9a6 100644 --- a/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/RequestMiddleware.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using JsonApiDotNetCore.Internal; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -37,7 +38,7 @@ private static bool IsValidContentTypeHeader(HttpContext context) private static bool IsValidAcceptHeader(HttpContext context) { - if (context.Request.Headers.TryGetValue("Accept", out StringValues acceptHeaders) == false) + if (context.Request.Headers.TryGetValue(Constants.AcceptHeader, out StringValues acceptHeaders) == false) return true; foreach (var acceptHeader in acceptHeaders) @@ -54,7 +55,7 @@ private static bool IsValidAcceptHeader(HttpContext context) private static bool ContainsMediaTypeParameters(string mediaType) { var mediaTypeArr = mediaType.Split(';'); - return (mediaTypeArr[0] == "application/vnd.api+json" && mediaTypeArr.Length == 2); + return (mediaTypeArr[0] == Constants.ContentType && mediaTypeArr.Length == 2); } private static void FlushResponse(HttpContext context, int statusCode) diff --git a/src/JsonApiDotNetCore/Models/DocumentBase.cs b/src/JsonApiDotNetCore/Models/DocumentBase.cs index 1cb31595ec..df51301c20 100644 --- a/src/JsonApiDotNetCore/Models/DocumentBase.cs +++ b/src/JsonApiDotNetCore/Models/DocumentBase.cs @@ -15,19 +15,8 @@ public class DocumentBase public Dictionary Meta { get; set; } // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm - public bool ShouldSerializeIncluded() - { - return (Included != null); - } - - public bool ShouldSerializeMeta() - { - return (Meta != null); - } - - public bool ShouldSerializeLinks() - { - return (Links != null); - } + public bool ShouldSerializeIncluded() => (Included != null); + public bool ShouldSerializeMeta() => (Meta != null); + public bool ShouldSerializeLinks() => (Links != null); } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 25021c441e..1c0c5014f7 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -169,9 +169,12 @@ private object SetHasOneRelationship(object entity, if (relationships.TryGetValue(relationshipName, out RelationshipData relationshipData)) { var relationshipAttr = _jsonApiContext.RequestEntity.Relationships - .SingleOrDefault(r => r.PublicRelationshipName == relationshipName); + .SingleOrDefault(r => r.PublicRelationshipName == relationshipName); - var data = (Dictionary)relationshipData.ExposedData; + if (relationshipAttr == null) + throw new JsonApiException(400, $"{_jsonApiContext.RequestEntity.EntityName} does not contain a relationship '{relationshipName}'"); + + var data = (Dictionary) relationshipData.ExposedData; if (data == null) return entity; diff --git a/src/JsonApiDotNetCore/Services/QueryAccessor.cs b/src/JsonApiDotNetCore/Services/QueryAccessor.cs index 09942d4031..41bc64151b 100644 --- a/src/JsonApiDotNetCore/Services/QueryAccessor.cs +++ b/src/JsonApiDotNetCore/Services/QueryAccessor.cs @@ -61,9 +61,22 @@ public bool TryGetValue(string key, out T value) } } - private string GetFilterValue(string key) => _jsonApiContext.QuerySet - .Filters - .FirstOrDefault(f => string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase)) - ?.Value; + private string GetFilterValue(string key) { + var publicValue = _jsonApiContext.QuerySet.Filters + .FirstOrDefault(f => string.Equals(f.Attribute, key, StringComparison.OrdinalIgnoreCase))?.Value; + + if(publicValue != null) + return publicValue; + + var internalValue = _jsonApiContext.QuerySet.Filters + .FirstOrDefault(f => string.Equals(f.Attribute, key, StringComparison.OrdinalIgnoreCase))?.Value; + + if(internalValue != null) { + _logger.LogWarning("Locating filters by the internal propterty name is deprecated. You should use the public attribute name instead."); + return publicValue; + } + + return null; + } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index 297eb246a0..2a97723347 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -3,69 +3,68 @@ using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Services -{ - public interface IQueryParser - { +namespace JsonApiDotNetCore.Services { + public interface IQueryParser { QuerySet Parse(IQueryCollection query); } - public class QueryParser : IQueryParser - { + public class QueryParser : IQueryParser { private readonly IControllerContext _controllerContext; private readonly JsonApiOptions _options; + private const string FILTER = "filter"; + private const string SORT = "sort"; + private const string INCLUDE = "include"; + private const string PAGE = "page"; + private const string FIELDS = "fields"; + private const char OPEN_BRACKET = '['; + private const char CLOSE_BRACKET = ']'; + private const char COMMA = ','; + private const char COLON = ':'; + private const string COLON_STR = ":"; + public QueryParser( IControllerContext controllerContext, - JsonApiOptions options) - { + JsonApiOptions options) { _controllerContext = controllerContext; _options = options; } - public virtual QuerySet Parse(IQueryCollection query) - { + public virtual QuerySet Parse(IQueryCollection query) { var querySet = new QuerySet(); - var disabledQueries = _controllerContext.GetControllerAttribute()?.QueryParams ?? QueryParams.None; + var disabledQueries = _controllerContext.GetControllerAttribute() ? .QueryParams ?? QueryParams.None; - foreach (var pair in query) - { - if (pair.Key.StartsWith("filter")) - { + foreach (var pair in query) { + if (pair.Key.StartsWith(FILTER)) { if (disabledQueries.HasFlag(QueryParams.Filter) == false) querySet.Filters.AddRange(ParseFilterQuery(pair.Key, pair.Value)); continue; } - if (pair.Key.StartsWith("sort")) - { + if (pair.Key.StartsWith(SORT)) { if (disabledQueries.HasFlag(QueryParams.Sort) == false) querySet.SortParameters = ParseSortParameters(pair.Value); continue; } - if (pair.Key.StartsWith("include")) - { + if (pair.Key.StartsWith(INCLUDE)) { if (disabledQueries.HasFlag(QueryParams.Include) == false) querySet.IncludedRelationships = ParseIncludedRelationships(pair.Value); continue; } - if (pair.Key.StartsWith("page")) - { + if (pair.Key.StartsWith(PAGE)) { if (disabledQueries.HasFlag(QueryParams.Page) == false) querySet.PageQuery = ParsePageQuery(querySet.PageQuery, pair.Key, pair.Value); continue; } - if (pair.Key.StartsWith("fields")) - { + if (pair.Key.StartsWith(FIELDS)) { if (disabledQueries.HasFlag(QueryParams.Fields) == false) querySet.Fields = ParseFieldsQuery(pair.Key, pair.Value); continue; @@ -78,17 +77,15 @@ public virtual QuerySet Parse(IQueryCollection query) return querySet; } - protected virtual List ParseFilterQuery(string key, string value) - { + protected virtual List ParseFilterQuery(string key, string value) { // expected input = filter[id]=1 // expected input = filter[id]=eq:1 var queries = new List(); - var propertyName = key.Split('[', ']')[1].ToProperCase(); + var propertyName = key.Split(OPEN_BRACKET, CLOSE_BRACKET) [1]; - var values = value.Split(','); - foreach (var val in values) - { + var values = value.Split(COMMA); + foreach (var val in values) { (var operation, var filterValue) = ParseFilterOperation(val); queries.Add(new FilterQuery(propertyName, filterValue, operation)); } @@ -96,12 +93,11 @@ protected virtual List ParseFilterQuery(string key, string value) return queries; } - protected virtual (string operation, string value) ParseFilterOperation(string value) - { + protected virtual(string operation, string value) ParseFilterOperation(string value) { if (value.Length < 3) return (string.Empty, value); - var operation = value.Split(':'); + var operation = value.Split(COLON); if (operation.Length == 1) return (string.Empty, value); @@ -111,7 +107,7 @@ protected virtual (string operation, string value) ParseFilterOperation(string v return (string.Empty, value); var prefix = operation[0]; - value = string.Join(":", operation.Skip(1)); + value = string.Join(COLON_STR, operation.Skip(1)); return (prefix, value); } @@ -122,12 +118,20 @@ protected virtual PageQuery ParsePageQuery(PageQuery pageQuery, string key, stri // page[number]=1 pageQuery = pageQuery ?? new PageQuery(); - var propertyName = key.Split('[', ']')[1]; + var propertyName = key.Split(OPEN_BRACKET, CLOSE_BRACKET)[1]; + + const string SIZE = "size"; + const string NUMBER = "number"; + + if (propertyName == SIZE) + pageQuery.PageSize = int.TryParse(value, out var pageSize) ? + pageSize : + throw new JsonApiException(400, $"Invalid page size '{value}'"); - if (propertyName == "size") - pageQuery.PageSize = Convert.ToInt32(value); - else if (propertyName == "number") - pageQuery.PageOffset = Convert.ToInt32(value); + else if (propertyName == NUMBER) + pageQuery.PageOffset = int.TryParse(value, out var pageOffset) ? + pageOffset : + throw new JsonApiException(400, $"Invalid page size '{value}'"); return pageQuery; } @@ -137,50 +141,62 @@ protected virtual PageQuery ParsePageQuery(PageQuery pageQuery, string key, stri protected virtual List ParseSortParameters(string value) { var sortParameters = new List(); - value.Split(',').ToList().ForEach(p => + + const char DESCENDING_SORT_OPERATOR = '-'; + var sortSegments = value.Split(COMMA); + + foreach (var sortSegment in sortSegments) { + + var propertyName = sortSegment; var direction = SortDirection.Ascending; - if (p[0] == '-') + + if (sortSegment[0] == DESCENDING_SORT_OPERATOR) { direction = SortDirection.Descending; - p = p.Substring(1); + propertyName = propertyName.Substring(1); } - var attribute = GetAttribute(p.ToProperCase()); + var attribute = GetAttribute(propertyName); sortParameters.Add(new SortQuery(direction, attribute)); - }); + }; return sortParameters; } - protected virtual List ParseIncludedRelationships(string value) - { - if (value.Contains(".")) + protected virtual List ParseIncludedRelationships(string value) { + const string NESTED_DELIMITER = "."; + if (value.Contains(NESTED_DELIMITER)) throw new JsonApiException(400, "Deeply nested relationships are not supported"); return value - .Split(',') + .Split(COMMA) .ToList(); } protected virtual List ParseFieldsQuery(string key, string value) { // expected: fields[TYPE]=prop1,prop2 - var typeName = key.Split('[', ']')[1]; + var typeName = key.Split(OPEN_BRACKET, CLOSE_BRACKET)[1]; - var includedFields = new List { "Id" }; + const string ID = "Id"; + var includedFields = new List { ID }; - if (typeName != _controllerContext.RequestEntity.EntityName) + // this will not support nested inclusions, it requires that the typeName is the current request type + if (string.Equals(typeName, _controllerContext.RequestEntity.EntityName, StringComparison.OrdinalIgnoreCase) == false) return includedFields; - var fields = value.Split(','); + var fields = value.Split(COMMA); foreach (var field in fields) { - var internalAttrName = _controllerContext.RequestEntity + var attr = _controllerContext.RequestEntity .Attributes - .SingleOrDefault(attr => attr.PublicAttributeName == field) - .InternalAttributeName; + .SingleOrDefault(a => string.Equals(a.PublicAttributeName, field, StringComparison.OrdinalIgnoreCase)); + + if (attr == null) throw new JsonApiException(400, $"'{_controllerContext.RequestEntity.EntityName}' does not contain '{field}'."); + + var internalAttrName = attr.InternalAttributeName; includedFields.Add(internalAttrName); } @@ -188,11 +204,20 @@ protected virtual List ParseFieldsQuery(string key, string value) } protected virtual AttrAttribute GetAttribute(string propertyName) - => _controllerContext - .RequestEntity - .Attributes - .FirstOrDefault(attr => - string.Equals(attr.InternalAttributeName, propertyName, StringComparison.OrdinalIgnoreCase) - ); + { + try + { + return _controllerContext + .RequestEntity + .Attributes + .Single(attr => + string.Equals(attr.PublicAttributeName, propertyName, StringComparison.OrdinalIgnoreCase) + ); + } + catch (InvalidOperationException e) + { + throw new JsonApiException(400, $"Attribute '{propertyName}' does not exist on resource '{_controllerContext.RequestEntity.EntityName}'", e); + } + } } -} \ No newline at end of file +} diff --git a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj index b22953dc23..1b40e2dd73 100755 --- a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +++ b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj @@ -1,5 +1,5 @@  - + $(NetCoreAppVersion) true diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index 8daa7dc9af..5553a7c1eb 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -1,5 +1,5 @@  - + $(NetCoreAppVersion) true diff --git a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs index eeefa4d857..5096cbac31 100644 --- a/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs +++ b/test/UnitTests/Serialization/JsonApiDeSerializerTests.cs @@ -11,13 +11,10 @@ using Newtonsoft.Json.Serialization; using Xunit; -namespace UnitTests.Serialization -{ - public class JsonApiDeSerializerTests - { +namespace UnitTests.Serialization { + public class JsonApiDeSerializerTests { [Fact] - public void Can_Deserialize_Complex_Types() - { + public void Can_Deserialize_Complex_Types() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("test-resource"); @@ -36,17 +33,16 @@ public void Can_Deserialize_Complex_Types() var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); - var content = new Document - { - Data = new DocumentData + var content = new Document { + Data = new DocumentData { + Type = "test-resource", + Id = "1", + Attributes = new Dictionary { { - Type = "test-resource", - Id = "1", - Attributes = new Dictionary { - { - "complex-member", new { compoundName = "testName" } - } - } + "complex-member", + new { compoundName = "testName" } + } + } } }; @@ -59,8 +55,7 @@ public void Can_Deserialize_Complex_Types() } [Fact] - public void Can_Deserialize_Complex_List_Types() - { + public void Can_Deserialize_Complex_List_Types() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("test-resource"); @@ -78,19 +73,18 @@ public void Can_Deserialize_Complex_List_Types() var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); - var content = new Document - { - Data = new DocumentData + var content = new Document { + Data = new DocumentData { + Type = "test-resource", + Id = "1", + Attributes = new Dictionary { { - Type = "test-resource", - Id = "1", - Attributes = new Dictionary { - { - "complex-members", new [] { - new { compoundName = "testName" } - } - } - } + "complex-members", + new [] { + new { compoundName = "testName" } + } + } + } } }; @@ -104,8 +98,7 @@ public void Can_Deserialize_Complex_List_Types() } [Fact] - public void Can_Deserialize_Complex_Types_With_Dasherized_Attrs() - { + public void Can_Deserialize_Complex_Types_With_Dasherized_Attrs() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("test-resource"); @@ -124,17 +117,16 @@ public void Can_Deserialize_Complex_Types_With_Dasherized_Attrs() var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); - var content = new Document - { - Data = new DocumentData + var content = new Document { + Data = new DocumentData { + Type = "test-resource", + Id = "1", + Attributes = new Dictionary { { - Type = "test-resource", - Id = "1", - Attributes = new Dictionary { - { - "complex-member", new Dictionary { { "compound-name", "testName" } } - } - } + "complex-member", + new Dictionary { { "compound-name", "testName" } } + } + } } }; @@ -147,8 +139,7 @@ public void Can_Deserialize_Complex_Types_With_Dasherized_Attrs() } [Fact] - public void Immutable_Attrs_Are_Not_Included_In_AttributesToUpdate() - { + public void Immutable_Attrs_Are_Not_Included_In_AttributesToUpdate() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("test-resource"); @@ -169,18 +160,18 @@ public void Immutable_Attrs_Are_Not_Included_In_AttributesToUpdate() var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); - var content = new Document - { - Data = new DocumentData + var content = new Document { + Data = new DocumentData { + Type = "test-resource", + Id = "1", + Attributes = new Dictionary { { - Type = "test-resource", - Id = "1", - Attributes = new Dictionary { - { "complex-member", new Dictionary { - { "compound-name", "testName" } } - }, - { "immutable", "value"} - } + "complex-member", + new Dictionary { { "compound-name", "testName" } + } + }, + { "immutable", "value" } + } } }; @@ -198,8 +189,7 @@ public void Immutable_Attrs_Are_Not_Included_In_AttributesToUpdate() } [Fact] - public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship() - { + public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("independents"); @@ -219,15 +209,12 @@ public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship() var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); var property = Guid.NewGuid().ToString(); - var content = new Document - { - Data = new DocumentData - { - Type = "independents", - Id = "1", - Attributes = new Dictionary { - { "property", property } - } + var content = new Document { + Data = new DocumentData { + Type = "independents", + Id = "1", + Attributes = new Dictionary { { "property", property } + } } }; @@ -242,8 +229,7 @@ public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship() } [Fact] - public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship_With_Relationship_Body() - { + public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship_With_Relationship_Body() { // arrange var contextGraphBuilder = new ContextGraphBuilder(); contextGraphBuilder.AddResource("independents"); @@ -263,19 +249,15 @@ public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship_With_Rel var deserializer = new JsonApiDeSerializer(jsonApiContextMock.Object, genericProcessorFactoryMock.Object); var property = Guid.NewGuid().ToString(); - var content = new Document - { - Data = new DocumentData - { - Type = "independents", - Id = "1", - Attributes = new Dictionary { - { "property", property } - }, - // a common case for this is deserialization in unit tests - Relationships = new Dictionary { - { "dependent", new RelationshipData { } } - } + var content = new Document { + Data = new DocumentData { + Type = "independents", + Id = "1", + Attributes = new Dictionary { { "property", property } + }, + // a common case for this is deserialization in unit tests + Relationships = new Dictionary { { "dependent", new RelationshipData { } } + } } }; @@ -289,34 +271,29 @@ public void Can_Deserialize_Independent_Side_Of_One_To_One_Relationship_With_Rel Assert.Equal(property, result.Property); } - private class TestResource : Identifiable - { + private class TestResource : Identifiable { [Attr("complex-member")] public ComplexType ComplexMember { get; set; } - [Attr("immutable", isImmutable: true)] + [Attr("immutable", isImmutable : true)] public string Immutable { get; set; } } - private class TestResourceWithList : Identifiable - { + private class TestResourceWithList : Identifiable { [Attr("complex-members")] public List ComplexMembers { get; set; } } - private class ComplexType - { + private class ComplexType { public string CompoundName { get; set; } } - private class Independent : Identifiable - { + private class Independent : Identifiable { [Attr("property")] public string Property { get; set; } [HasOne("dependent")] public Dependent Dependent { get; set; } } - private class Dependent : Identifiable - { + private class Dependent : Identifiable { [HasOne("independent")] public Independent Independent { get; set; } public int IndependentId { get; set; } } diff --git a/test/UnitTests/Services/QueryParser_Tests.cs b/test/UnitTests/Services/QueryParser_Tests.cs index a64c5b3692..64c9830f2b 100644 --- a/test/UnitTests/Services/QueryParser_Tests.cs +++ b/test/UnitTests/Services/QueryParser_Tests.cs @@ -2,6 +2,8 @@ using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -43,7 +45,7 @@ public void Can_Build_Filters() var querySet = queryParser.Parse(_queryCollectionMock.Object); // assert - Assert.Equal("value", querySet.Filters.Single(f => f.Key == "Key").Value); + Assert.Equal("value", querySet.Filters.Single(f => f.Attribute == "key").Value); } [Fact] @@ -69,8 +71,8 @@ public void Filters_Properly_Parses_DateTime_With_Operation() var querySet = queryParser.Parse(_queryCollectionMock.Object); // assert - Assert.Equal(dt, querySet.Filters.Single(f => f.Key == "Key").Value); - Assert.Equal("le", querySet.Filters.Single(f => f.Key == "Key").Operation); + Assert.Equal(dt, querySet.Filters.Single(f => f.Attribute == "key").Value); + Assert.Equal("le", querySet.Filters.Single(f => f.Attribute == "key").Operation); } [Fact] @@ -96,8 +98,8 @@ public void Filters_Properly_Parses_DateTime_Without_Operation() var querySet = queryParser.Parse(_queryCollectionMock.Object); // assert - Assert.Equal(dt, querySet.Filters.Single(f => f.Key == "Key").Value); - Assert.Equal(string.Empty, querySet.Filters.Single(f => f.Key == "Key").Operation); + Assert.Equal(dt, querySet.Filters.Single(f => f.Attribute == "key").Value); + Assert.Equal(string.Empty, querySet.Filters.Single(f => f.Attribute == "key").Operation); } [Fact] @@ -224,5 +226,133 @@ public void Can_Disable_Fields() // assert Assert.Empty(querySet.Fields); } + + [Fact] + public void Can_Parse_Fields_Query() + { + // arrange + const string type = "articles"; + const string attrName = "some-field"; + const string internalAttrName = "SomeField"; + + var query = new Dictionary { { $"fields[{type}]", new StringValues(attrName) } }; + + _queryCollectionMock + .Setup(m => m.GetEnumerator()) + .Returns(query.GetEnumerator()); + + _controllerContextMock + .Setup(m => m.RequestEntity) + .Returns(new ContextEntity + { + EntityName = type, + Attributes = new List + { + new AttrAttribute(attrName) + { + InternalAttributeName = internalAttrName + } + } + }); + + var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions()); + + // act + var querySet = queryParser.Parse(_queryCollectionMock.Object); + + // assert + Assert.NotEmpty(querySet.Fields); + Assert.Equal(2, querySet.Fields.Count); + Assert.Equal("Id", querySet.Fields[0]); + Assert.Equal(internalAttrName, querySet.Fields[1]); + } + + [Fact] + public void Throws_JsonApiException_If_Field_DoesNotExist() + { + // arrange + const string type = "articles"; + const string attrName = "dne"; + + var query = new Dictionary { { $"fields[{type}]", new StringValues(attrName) } }; + + _queryCollectionMock + .Setup(m => m.GetEnumerator()) + .Returns(query.GetEnumerator()); + + _controllerContextMock + .Setup(m => m.RequestEntity) + .Returns(new ContextEntity + { + EntityName = type, + Attributes = new List() + }); + + var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions()); + + // act , assert + var ex = Assert.Throws(() => queryParser.Parse(_queryCollectionMock.Object)); + Assert.Equal(400, ex.GetStatusCode()); + } + + [Theory] + [InlineData("1", 1, false)] + [InlineData("abcde", 0, true)] + [InlineData("", 0, true)] + public void Can_Parse_Page_Size_Query(string value, int expectedValue, bool shouldThrow) + { + // arrange + var query = new Dictionary + { { "page[size]", new StringValues(value) } + }; + + _queryCollectionMock + .Setup(m => m.GetEnumerator()) + .Returns(query.GetEnumerator()); + + var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions()); + + // act + if (shouldThrow) + { + var ex = Assert.Throws(() => queryParser.Parse(_queryCollectionMock.Object)); + Assert.Equal(400, ex.GetStatusCode()); + } + else + { + var querySet = queryParser.Parse(_queryCollectionMock.Object); + Assert.Equal(expectedValue, querySet.PageQuery.PageSize); + } + } + + [Theory] + [InlineData("1", 1, false)] + [InlineData("abcde", 0, true)] + [InlineData("", 0, true)] + public void Can_Parse_Page_Number_Query(string value, int expectedValue, bool shouldThrow) + { + // arrange + var query = new Dictionary + { { "page[number]", new StringValues(value) } + }; + + _queryCollectionMock + .Setup(m => m.GetEnumerator()) + .Returns(query.GetEnumerator()); + + var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions()); + + // act + if (shouldThrow) + { + var ex = Assert.Throws(() => queryParser.Parse(_queryCollectionMock.Object)); + Assert.Equal(400, ex.GetStatusCode()); + } + else + { + var querySet = queryParser.Parse(_queryCollectionMock.Object); + Assert.Equal(expectedValue, querySet.PageQuery.PageOffset); + } + } } -} +} \ No newline at end of file diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index dcd659d2df..4848455eac 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -1,4 +1,5 @@ + $(NetCoreAppVersion) false @@ -13,4 +14,4 @@ - \ No newline at end of file +