From 75c056c3f2796a94c6488387ef5b4a9d8ce8f88e Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Wed, 21 Dec 2022 13:47:40 +0100
Subject: [PATCH 01/22] Increment version to 5.1.2 (used for pre-release builds
from ci)
---
Directory.Build.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Build.props b/Directory.Build.props
index 1810b9cd46..d641e8d6b6 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,7 +6,7 @@
6.0.*4.3.*2.14.1
- 5.1.1
+ 5.1.2$(MSBuildThisFileDirectory)CodingGuidelines.ruleset9999enable
From be992341d236094e0abbf9d0a8e9bafb788356c8 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Thu, 29 Dec 2022 14:42:26 +0100
Subject: [PATCH 02/22] Add missing nameof() in thrown ArgumentException
---
src/JsonApiDotNetCore/Configuration/ResourceGraph.cs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
index 0dbaeb9623..f15e33a82e 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
@@ -130,7 +130,7 @@ private IReadOnlyCollection FilterFields(Expression ToMemberNames(Expression article.Title' or 'article => new { article.Title, article.PageCount }'.");
+ throw new ArgumentException(
+ $"The expression '{selector}' should select a single property or select multiple properties into an anonymous type. " +
+ "For example: 'article => article.Title' or 'article => new { article.Title, article.PageCount }'.", nameof(selector));
}
}
From 714b09aba8ec0e30111423117a33396bfc8cc1b8 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Wed, 18 Jan 2023 14:52:50 +0100
Subject: [PATCH 03/22] Interrupt on error when building docs locally. Added
switch for faster inner loop.
---
docs/build-dev.ps1 | 34 +++++++++++++++++++++++++++-------
docs/generate-examples.ps1 | 31 +++++++++++++++++++------------
2 files changed, 46 insertions(+), 19 deletions(-)
diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1
index a5ee0ff947..9d3adb970b 100644
--- a/docs/build-dev.ps1
+++ b/docs/build-dev.ps1
@@ -1,17 +1,37 @@
-# This script assumes that you have already installed docfx and httpserver.
+#Requires -Version 7.0
+
+# This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development.
+# It is assumed that you have already installed docfx and httpserver.
# If that's not the case, run the next commands:
# choco install docfx -y
# npm install -g httpserver
-Remove-Item _site -Recurse -ErrorAction Ignore
+param(
+ # Specify -NoBuild to skip code build and examples generation. This runs faster, so handy when only editing Markdown files.
+ [switch] $NoBuild=$False
+)
+
+function VerifySuccessExitCode {
+ if ($LastExitCode -ne 0) {
+ throw "Command failed with exit code $LastExitCode."
+ }
+}
+
+if (-Not $NoBuild -Or -Not (Test-Path -Path _site)) {
+ Remove-Item _site -Recurse -ErrorAction Ignore
-dotnet build .. --configuration Release
-Invoke-Expression ./generate-examples.ps1
+ dotnet build .. --configuration Release
+ VerifySuccessExitCode
+
+ Invoke-Expression ./generate-examples.ps1
+}
docfx ./docfx.json
-Copy-Item home/*.html _site/
-Copy-Item home/*.ico _site/
-Copy-Item -Recurse home/assets/* _site/styles/
+VerifySuccessExitCode
+
+Copy-Item -Force home/*.html _site/
+Copy-Item -Force home/*.ico _site/
+Copy-Item -Force -Recurse home/assets/* _site/styles/
cd _site
$webServerJob = httpserver &
diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1
index 6f7f7dc574..468b8447ac 100644
--- a/docs/generate-examples.ps1
+++ b/docs/generate-examples.ps1
@@ -8,7 +8,7 @@ function Get-WebServer-ProcessId {
$processId = $(lsof -ti:14141)
}
elseif ($IsWindows) {
- $processId = $(Get-NetTCPConnection -LocalPort 14141 -ErrorAction SilentlyContinue).OwningProcess
+ $processId = $(Get-NetTCPConnection -LocalPort 14141 -ErrorAction SilentlyContinue).OwningProcess?[0]
}
else {
throw [System.Exception] "Unsupported operating system."
@@ -22,7 +22,11 @@ function Kill-WebServer {
if ($processId -ne $null) {
Write-Output "Stopping web server"
- Get-Process -Id $processId | Stop-Process
+ Get-Process -Id $processId | Stop-Process -ErrorVariable stopErrorMessage
+
+ if ($stopErrorMessage) {
+ throw "Failed to stop web server: $stopErrorMessage"
+ }
}
}
@@ -40,18 +44,21 @@ function Start-WebServer {
Kill-WebServer
Start-WebServer
-Remove-Item -Force -Path .\request-examples\*.json
+try {
+ Remove-Item -Force -Path .\request-examples\*.json
-$scriptFiles = Get-ChildItem .\request-examples\*.ps1
-foreach ($scriptFile in $scriptFiles) {
- $jsonFileName = [System.IO.Path]::GetFileNameWithoutExtension($scriptFile.Name) + "_Response.json"
+ $scriptFiles = Get-ChildItem .\request-examples\*.ps1
+ foreach ($scriptFile in $scriptFiles) {
+ $jsonFileName = [System.IO.Path]::GetFileNameWithoutExtension($scriptFile.Name) + "_Response.json"
- Write-Output "Writing file: $jsonFileName"
- & $scriptFile.FullName > .\request-examples\$jsonFileName
+ Write-Output "Writing file: $jsonFileName"
+ & $scriptFile.FullName > .\request-examples\$jsonFileName
- if ($LastExitCode -ne 0) {
- throw [System.Exception] "Example request from '$($scriptFile.Name)' failed with exit code $LastExitCode."
+ if ($LastExitCode -ne 0) {
+ throw [System.Exception] "Example request from '$($scriptFile.Name)' failed with exit code $LastExitCode."
+ }
}
}
-
-Kill-WebServer
+finally {
+ Kill-WebServer
+}
From 55d7cb09b30a3e27280194b299b5075e2fc07491 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 20 Jan 2023 02:30:55 +0100
Subject: [PATCH 04/22] Log warning at startup when [ApiController] found on a
JSON:API controller
---
.../Middleware/JsonApiRoutingConvention.cs | 66 +++++++++++++------
.../ApiControllerAttributeLogTests.cs | 47 +++++++++++++
2 files changed, 94 insertions(+), 19 deletions(-)
create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs
diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
index 0f596326f7..5c6b84cba7 100644
--- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
+++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
@@ -7,6 +7,7 @@
using JsonApiDotNetCore.Resources;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.Extensions.Logging;
namespace JsonApiDotNetCore.Middleware;
@@ -30,17 +31,20 @@ public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention
{
private readonly IJsonApiOptions _options;
private readonly IResourceGraph _resourceGraph;
+ private readonly ILogger _logger;
private readonly Dictionary _registeredControllerNameByTemplate = new();
private readonly Dictionary _resourceTypePerControllerTypeMap = new();
private readonly Dictionary _controllerPerResourceTypeMap = new();
- public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph)
+ public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, ILogger logger)
{
ArgumentGuard.NotNull(options);
ArgumentGuard.NotNull(resourceGraph);
+ ArgumentGuard.NotNull(logger);
_options = options;
_resourceGraph = resourceGraph;
+ _logger = logger;
}
///
@@ -64,9 +68,26 @@ public void Apply(ApplicationModel application)
foreach (ControllerModel controller in application.Controllers)
{
- bool isOperationsController = IsOperationsController(controller.ControllerType);
+ if (!IsJsonApiController(controller))
+ {
+ continue;
+ }
+
+ if (HasApiControllerAttribute(controller))
+ {
+ // Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [ApiController] violates the JSON:API specification.
+ // See https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-7.0#apicontroller-attribute for its effects.
+ // JsonApiDotNetCore already handles all of these concerns, but in a JSON:API-compliant way. So the attribute doesn't do any good.
- if (!isOperationsController)
+ // While we try our best when [ApiController] is used, we can't completely avoid a degraded experience. ModelState validation errors are turned into
+ // ProblemDetails, where the origin of the error gets lost. As a result, we can't populate the source pointer in JSON:API error responses.
+ // For backwards-compatibility, we log a warning instead of throwing. But we can't think of any use cases where having [ApiController] makes sense.
+
+ _logger.LogWarning(
+ $"Found JSON:API controller '{controller.ControllerType}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance.");
+ }
+
+ if (!IsOperationsController(controller.ControllerType))
{
Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType);
@@ -74,26 +95,24 @@ public void Apply(ApplicationModel application)
{
ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType);
- if (resourceType != null)
- {
- if (_controllerPerResourceTypeMap.ContainsKey(resourceType))
- {
- throw new InvalidConfigurationException(
- $"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'.");
- }
-
- _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
- _controllerPerResourceTypeMap.Add(resourceType, controller);
- }
- else
+ if (resourceType == null)
{
throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " +
$"resource type '{resourceClrType}', which does not exist in the resource graph.");
}
+
+ if (_controllerPerResourceTypeMap.ContainsKey(resourceType))
+ {
+ throw new InvalidConfigurationException(
+ $"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'.");
+ }
+
+ _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
+ _controllerPerResourceTypeMap.Add(resourceType, controller);
}
}
- if (!IsRoutingConventionEnabled(controller))
+ if (IsRoutingConventionDisabled(controller))
{
continue;
}
@@ -115,10 +134,19 @@ public void Apply(ApplicationModel application)
}
}
- private bool IsRoutingConventionEnabled(ControllerModel controller)
+ private static bool IsJsonApiController(ControllerModel controller)
+ {
+ return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController));
+ }
+
+ private static bool HasApiControllerAttribute(ControllerModel controller)
+ {
+ return controller.ControllerType.GetCustomAttribute() != null;
+ }
+
+ private static bool IsRoutingConventionDisabled(ControllerModel controller)
{
- return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)) &&
- controller.ControllerType.GetCustomAttribute(true) == null;
+ return controller.ControllerType.GetCustomAttribute(true) != null;
}
///
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs
new file mode 100644
index 0000000000..2b4a8c70d6
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs
@@ -0,0 +1,47 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes;
+
+public sealed class ApiControllerAttributeLogTests : IntegrationTestContext, CustomRouteDbContext>
+{
+ private readonly FakeLoggerFactory _loggerFactory;
+
+ public ApiControllerAttributeLogTests()
+ {
+ UseController();
+
+ _loggerFactory = new FakeLoggerFactory(LogLevel.Warning);
+
+ ConfigureLogging(options =>
+ {
+ options.ClearProviders();
+ options.AddProvider(_loggerFactory);
+ });
+
+ ConfigureServicesBeforeStartup(services =>
+ {
+ services.AddSingleton(_loggerFactory);
+ });
+ }
+
+ [Fact]
+ public void Logs_warning_at_startup_when_ApiControllerAttribute_found()
+ {
+ // Arrange
+ _loggerFactory.Logger.Clear();
+
+ // Act
+ _ = Factory;
+
+ // Assert
+ _loggerFactory.Logger.Messages.ShouldHaveCount(1);
+ _loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning);
+
+ _loggerFactory.Logger.Messages.Single().Text.Should().Be(
+ $"Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance.");
+ }
+}
From 79cfb3df29044ac9826b7a9b41bccb654815525e Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 20 Jan 2023 02:46:19 +0100
Subject: [PATCH 05/22] Improve error response from invalid ModelState when
[ApiController] is used
---
.../UnsuccessfulActionResultException.cs | 22 ++++++++--
.../ApiControllerAttributeTests.cs | 44 +++++++++++++++++++
.../IntegrationTests/CustomRoutes/Civilian.cs | 5 +++
3 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs
index e739ec9cbf..b5ab3859cc 100644
--- a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs
+++ b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs
@@ -1,6 +1,7 @@
using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace JsonApiDotNetCore.Errors;
@@ -20,20 +21,35 @@ public UnsuccessfulActionResultException(HttpStatusCode status)
}
public UnsuccessfulActionResultException(ProblemDetails problemDetails)
- : base(ToError(problemDetails))
+ : base(ToErrorObjects(problemDetails))
{
}
- private static ErrorObject ToError(ProblemDetails problemDetails)
+ private static IEnumerable ToErrorObjects(ProblemDetails problemDetails)
{
ArgumentGuard.NotNull(problemDetails);
HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError;
+ if (problemDetails is HttpValidationProblemDetails validationProblemDetails && validationProblemDetails.Errors.Any())
+ {
+ foreach (string errorMessage in validationProblemDetails.Errors.SelectMany(pair => pair.Value))
+ {
+ yield return ToErrorObject(status, validationProblemDetails, errorMessage);
+ }
+ }
+ else
+ {
+ yield return ToErrorObject(status, problemDetails, problemDetails.Detail);
+ }
+ }
+
+ private static ErrorObject ToErrorObject(HttpStatusCode status, ProblemDetails problemDetails, string? detail)
+ {
var error = new ErrorObject(status)
{
Title = problemDetails.Title,
- Detail = problemDetails.Detail
+ Detail = detail
};
if (!string.IsNullOrWhiteSpace(problemDetails.Instance))
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs
index 25404e25d2..28e6ba2439 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs
@@ -35,4 +35,48 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with
error.Links.ShouldNotBeNull();
error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4");
}
+
+ [Fact]
+ public async Task ProblemDetails_from_invalid_ModelState_is_translated_into_error_response()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "civilians",
+ attributes = new
+ {
+ name = (string?)null,
+ yearOfBirth = 1850
+ }
+ }
+ };
+
+ const string route = "/world-civilians";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(2);
+
+ ErrorObject error1 = responseDocument.Errors[0];
+ error1.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error1.Links.ShouldNotBeNull();
+ error1.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1");
+ error1.Title.Should().Be("One or more validation errors occurred.");
+ error1.Detail.Should().Be("The Name field is required.");
+ error1.Source.Should().BeNull();
+
+ ErrorObject error2 = responseDocument.Errors[1];
+ error2.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error2.Links.ShouldNotBeNull();
+ error2.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1");
+ error2.Title.Should().Be("One or more validation errors occurred.");
+ error2.Detail.Should().Be("The field YearOfBirth must be between 1900 and 2050.");
+ error2.Source.Should().BeNull();
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs
index 09fceeca60..00baaaa16c 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
@@ -10,4 +11,8 @@ public sealed class Civilian : Identifiable
{
[Attr]
public string Name { get; set; } = null!;
+
+ [Attr]
+ [Range(1900, 2050)]
+ public int YearOfBirth { get; set; }
}
From 9b6fac90a501ff524f20822073c62545e7a05e11 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 20 Jan 2023 02:55:53 +0100
Subject: [PATCH 06/22] Reduce the number of concurrent testruns in cibuild
---
test/TestBuildingBlocks/IntegrationTest.cs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs
index 92f49879fc..69776e7b5d 100644
--- a/test/TestBuildingBlocks/IntegrationTest.cs
+++ b/test/TestBuildingBlocks/IntegrationTest.cs
@@ -12,10 +12,16 @@ namespace TestBuildingBlocks;
///
public abstract class IntegrationTest : IAsyncLifetime
{
- private static readonly SemaphoreSlim ThrottleSemaphore = new(64);
+ private static readonly SemaphoreSlim ThrottleSemaphore;
protected abstract JsonSerializerOptions SerializerOptions { get; }
+ static IntegrationTest()
+ {
+ int maxConcurrentTestRuns = Environment.GetEnvironmentVariable("APPVEYOR") != null ? 32 : 64;
+ ThrottleSemaphore = new SemaphoreSlim(maxConcurrentTestRuns);
+ }
+
public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl,
Action? setRequestHeaders = null)
{
From 5dad2a510a731e81eef09b74e55c2ed0e7539f66 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 20 Jan 2023 10:42:45 +0100
Subject: [PATCH 07/22] Update docfx, run as .NET tool instead of choco
---
.config/dotnet-tools.json | 6 ++++++
appveyor.yml | 6 +-----
docs/README.md | 6 +-----
docs/build-dev.ps1 | 7 +++----
4 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index f8589a73a7..c4b7aeba5e 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -25,6 +25,12 @@
"commands": [
"reportgenerator"
]
+ },
+ "docfx": {
+ "version": "2.60.2",
+ "commands": [
+ "docfx"
+ ]
}
}
}
diff --git a/appveyor.yml b/appveyor.yml
index 81ba53020c..ce850ffe7c 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -45,15 +45,11 @@ for:
# https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html
git checkout $env:APPVEYOR_REPO_BRANCH -q
}
- choco install docfx -y
- if ($lastexitcode -ne 0) {
- throw "docfx install failed with exit code $lastexitcode."
- }
after_build:
- pwsh: |
CD ./docs
& ./generate-examples.ps1
- & docfx docfx.json
+ & dotnet docfx docfx.json
if ($lastexitcode -ne 0) {
throw "docfx build failed with exit code $lastexitcode."
}
diff --git a/docs/README.md b/docs/README.md
index bd33197f00..eb6175e549 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3,11 +3,7 @@ Documentation for JsonApiDotNetCore is produced using [DocFX](https://dotnet.git
In addition, the example request/response pairs are generated by executing `curl` commands against the GettingStarted project.
# Installation
-Run the following commands once to setup your system:
-
-```
-choco install docfx -y
-```
+Run the following command once to setup your system:
```
npm install -g httpserver
diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1
index 9d3adb970b..42deabb02b 100644
--- a/docs/build-dev.ps1
+++ b/docs/build-dev.ps1
@@ -1,9 +1,8 @@
#Requires -Version 7.0
# This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development.
-# It is assumed that you have already installed docfx and httpserver.
-# If that's not the case, run the next commands:
-# choco install docfx -y
+# It is assumed that you have already installed httpserver.
+# If that's not the case, run the next command:
# npm install -g httpserver
param(
@@ -26,7 +25,7 @@ if (-Not $NoBuild -Or -Not (Test-Path -Path _site)) {
Invoke-Expression ./generate-examples.ps1
}
-docfx ./docfx.json
+dotnet docfx ./docfx.json
VerifySuccessExitCode
Copy-Item -Force home/*.html _site/
From cc7008894ae67809a10652e6ef83a7353fa2a70c Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 20 Jan 2023 11:18:11 +0100
Subject: [PATCH 08/22] Auto-install httpserver npm module
---
docs/README.md | 8 ++------
docs/build-dev.ps1 | 18 +++++++++++++++---
2 files changed, 17 insertions(+), 9 deletions(-)
diff --git a/docs/README.md b/docs/README.md
index eb6175e549..af8b89537f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,13 +1,9 @@
# Intro
-Documentation for JsonApiDotNetCore is produced using [DocFX](https://dotnet.github.io/docfx/) from several files in this directory.
+Documentation for JsonApiDotNetCore is produced using [docfx](https://dotnet.github.io/docfx/) from several files in this directory.
In addition, the example request/response pairs are generated by executing `curl` commands against the GettingStarted project.
# Installation
-Run the following command once to setup your system:
-
-```
-npm install -g httpserver
-```
+You need to have 'npm' installed. Download Node.js from https://nodejs.org/.
# Running
The next command regenerates the documentation website and opens it in your default browser:
diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1
index 42deabb02b..5212429b7d 100644
--- a/docs/build-dev.ps1
+++ b/docs/build-dev.ps1
@@ -1,9 +1,6 @@
#Requires -Version 7.0
# This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development.
-# It is assumed that you have already installed httpserver.
-# If that's not the case, run the next command:
-# npm install -g httpserver
param(
# Specify -NoBuild to skip code build and examples generation. This runs faster, so handy when only editing Markdown files.
@@ -16,6 +13,21 @@ function VerifySuccessExitCode {
}
}
+function EnsureHttpServerIsInstalled {
+ if ((Get-Command "npm" -ErrorAction SilentlyContinue) -eq $null) {
+ throw "Unable to find npm in your PATH. please install Node.js first."
+ }
+
+ npm list --depth 1 --global httpserver >$null
+
+ if ($LastExitCode -eq 1) {
+ npm install -g httpserver
+ }
+}
+
+EnsureHttpServerIsInstalled
+VerifySuccessExitCode
+
if (-Not $NoBuild -Or -Not (Test-Path -Path _site)) {
Remove-Item _site -Recurse -ErrorAction Ignore
From 18cb4eb4fb3f05938c1d395f582e7f581a1f2964 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 20 Jan 2023 11:20:40 +0100
Subject: [PATCH 09/22] Normalize casing of $LastExitCode
---
appveyor.yml | 4 ++--
inspectcode.ps1 | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/appveyor.yml b/appveyor.yml
index ce850ffe7c..ff9191da7c 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -50,8 +50,8 @@ for:
CD ./docs
& ./generate-examples.ps1
& dotnet docfx docfx.json
- if ($lastexitcode -ne 0) {
- throw "docfx build failed with exit code $lastexitcode."
+ if ($LastExitCode -ne 0) {
+ throw "docfx failed with exit code $LastExitCode."
}
# https://www.appveyor.com/docs/how-to/git-push/
diff --git a/inspectcode.ps1 b/inspectcode.ps1
index 16dccfd373..b379bce1c6 100644
--- a/inspectcode.ps1
+++ b/inspectcode.ps1
@@ -4,16 +4,16 @@
dotnet tool restore
-if ($LASTEXITCODE -ne 0) {
- throw "Tool restore failed with exit code $LASTEXITCODE"
+if ($LastExitCode -ne 0) {
+ throw "Tool restore failed with exit code $LastExitCode"
}
$outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml')
$resultPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.html')
dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal
-if ($LASTEXITCODE -ne 0) {
- throw "Code inspection failed with exit code $LASTEXITCODE"
+if ($LastExitCode -ne 0) {
+ throw "Code inspection failed with exit code $LastExitCode"
}
[xml]$xml = Get-Content "$outputPath"
From 13bb8835afab07b61efafa5f1551b956af362232 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Wed, 18 Jan 2023 03:30:35 +0100
Subject: [PATCH 10/22] Add FAQ and Common Pitfalls to docs
---
docs/getting-started/faq.md | 168 ++++++++++++++++++++++++++++++++++
docs/getting-started/toc.md | 5 +
docs/getting-started/toc.yml | 5 -
docs/usage/common-pitfalls.md | 143 +++++++++++++++++++++++++++++
docs/usage/toc.md | 2 +
5 files changed, 318 insertions(+), 5 deletions(-)
create mode 100644 docs/getting-started/faq.md
create mode 100644 docs/getting-started/toc.md
delete mode 100644 docs/getting-started/toc.yml
create mode 100644 docs/usage/common-pitfalls.md
diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md
new file mode 100644
index 0000000000..69f8e61505
--- /dev/null
+++ b/docs/getting-started/faq.md
@@ -0,0 +1,168 @@
+# Frequently Asked Questions
+
+#### Where can I find documentation and examples?
+While the [documentation](~/usage/resources/index.md) covers basic features and a few runnable example projects are available [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples),
+many more advanced use cases are available as integration tests [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests), so be sure to check them out!
+
+#### Why can't I use OpenAPI?
+Due to the mismatch between the JSON:API structure and the shape of ASP.NET controller methods, this does not work out of the box.
+This is high on our agenda and we're steadily making progress, but it's quite complex and far from complete.
+See [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046) for the current status, which includes instructions on trying out the latest build.
+
+#### What's available to implement a JSON:API client?
+It depends on the programming language used. There's an overwhelming list of client libraries at https://jsonapi.org/implementations/#client-libraries.
+
+The JSON object model inside JsonApiDotNetCore is tweaked for server-side handling (be tolerant at inputs and strict at outputs).
+While you technically *could* use our `JsonSerializer` converters from a .NET client application with some hacks, we don't recommend it.
+You'll need to build the resource graph on the client and rely on internal implementation details that are subject to change in future versions.
+
+In the long term, we'd like to solve this through OpenAPI, which enables the generation of a (statically typed) client library in various languages.
+
+#### How can I debug my API project?
+Due to auto-generated controllers, you may find it hard to determine where to put your breakpoints.
+In Visual Studio, controllers are accessible below **Solution Explorer > Project > Dependencies > Analyzers > JsonApiDotNetCore.SourceGenerators**.
+
+After turning on [Source Link](https://devblogs.microsoft.com/dotnet/improving-debug-time-productivity-with-source-link/#enabling-source-link) (which enables to download the JsonApiDotNetCore source code from GitHub), you can step into our source code and add breakpoints there too.
+
+Here are some key places in the execution pipeline to set a breakpoint:
+- `JsonApiRoutingConvention.Apply`: Controllers are registered here (executes once at startup)
+- `JsonApiMiddleware.InvokeAsync`: Content negotiation and `IJsonApiRequest` setup
+- `QueryStringReader.ReadAll`: Parses the query string parameters
+- `JsonApiReader.ReadAsync`: Parses the request body
+- `OperationsProcessor.ProcessAsync`: Entry point for handling atomic operations
+- `JsonApiResourceService`: Called by controllers, delegating to the repository layer
+- `EntityFrameworkCoreRepository.ApplyQueryLayer`: Builds the `IQueryable<>` that is offered to Entity Framework Core (which turns it into SQL)
+- `JsonApiWriter.WriteAsync`: Renders the response body
+- `ExceptionHandler.HandleException`: Interception point for thrown exceptions
+
+Aside from debugging, you can get more info by:
+- Including exception stack traces and incoming request bodies in error responses, as well as writing human-readable JSON:
+
+ ```c#
+ // Program.cs
+ builder.Services.AddJsonApi(options =>
+ {
+ options.IncludeExceptionStackTraceInErrors = true;
+ options.IncludeRequestBodyInErrors = true;
+ options.SerializerOptions.WriteIndented = true;
+ });
+ ```
+- Turning on verbose logging and logging of executed SQL statements, by adding the following to your `appsettings.Development.json`:
+
+ ```json
+ {
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information",
+ "JsonApiDotNetCore": "Verbose"
+ }
+ }
+ }
+ ```
+
+#### What if my JSON:API resources do not exactly match the shape of my database tables?
+We often find users trying to write custom code to solve that. They usually get it wrong or incomplete, and it may not perform well.
+Or it simply fails because it cannot be translated to SQL.
+The good news is that there's an easier solution most of the time: configure Entity Framework Core mappings to do the work.
+
+For example, if your primary key column is named "CustomerId" instead of "Id":
+```c#
+builder.Entity().Property(x => x.Id).HasColumnName("CustomerId");
+```
+
+It certainly pays off to read up on these capabilities at [Creating and Configuring a Model](https://learn.microsoft.com/en-us/ef/core/modeling/).
+Another great resource is [Learn Entity Framework Core](https://www.learnentityframeworkcore.com/configuration).
+
+#### Can I share my resource models with .NET Framework projects?
+Yes, you can. Put your model classes in a separate project that only references [JsonApiDotNetCore.Annotations](https://www.nuget.org/packages/JsonApiDotNetCore.Annotations/).
+This package contains just the JSON:API attributes and targets NetStandard 1.0, which makes it flexible to consume.
+At startup, use [Auto-discovery](~/usage/resource-graph.md#auto-discovery) and point it to your shared project.
+
+#### What's the best place to put my custom business/validation logic?
+For basic input validation, use the attributes from [ASP.NET ModelState Validation](https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?source=recommendations&view=aspnetcore-7.0#built-in-attributes) to get the best experience.
+JsonApiDotNetCore is aware of them and adjusts behavior accordingly. And it produces the best possible error responses.
+
+For non-trivial business rules that require custom code, the place to be is [Resource Definitions](~/usage/extensibility/resource-definitions.md).
+They provide a callback-based model where you can respond to everything going on.
+The great thing is that your callbacks are invoked for various endpoints.
+For example, the filter callback on Author executes at `GET /authors?filter=`, `GET /books/1/authors?filter=` and `GET /books?include=authors?filter[authors]=`.
+Likewise, the callbacks for changing relationships execute for POST/PATCH resource endpoints, as well as POST/PATCH/DELETE relationship endpoints.
+
+#### Can API users send multiple changes in a single request?
+Yes, just activate [atomic operations](~/usage/writing/bulk-batch-operations.md).
+It enables sending multiple changes in a batch request, which are executed in a database transaction.
+If something fails, all changes are rolled back. The error response indicates which operation failed.
+
+#### Is there any way to add `[Authorize(Roles = "...")]` to the generated controllers?
+Sure, this is possible. Simply add the attribute at the class level.
+See the docs on [Augmenting controllers](~/usage/extensibility/controllers.md#augmenting-controllers).
+
+#### How do I expose non-JSON:API endpoints?
+You can add your own controllers that do not derive from `(Base)JsonApiController` or `(Base)JsonApiOperationsController`.
+Whatever you do in those is completely ignored by JsonApiDotNetCore.
+This is useful if you want to add a few RPC-style endpoints or provide binary file uploads/downloads.
+
+A middle-ground approach is to add custom action methods to existing JSON:API controllers.
+While you can route them as you like, they must return JSON:API resources.
+And on error, a JSON:API error response is produced.
+This is useful if you want to stay in the JSON:API-compliant world, but need to expose something on-standard, for example: `GET /users/me`.
+
+#### How do I optimize for high scalability and prevent denial of service?
+Fortunately, JsonApiDotNetCore [scales pretty well](https://github.com/json-api-dotnet/PerformanceReports) under high load and/or large database tables.
+It never executes filtering, sorting, or pagination in-memory and tries pretty hard to produce the most efficient query possible.
+There are a few things to keep in mind, though:
+- Prevent users from executing slow queries by locking down [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities).
+ Ensure the right database indexes are in place for what you enable.
+- Prevent users from fetching lots of data by tweaking [maximum page size/number](~/usage/options.md#pagination) and [maximum include depth](~/usage/options.md#maximum-include-depth).
+- Tell your users to utilize [E-Tags](~/usage/caching.md) to reduce network traffic.
+- Not included in JsonApiDotNetCore: Apply general practices such as rate limiting, load balancing, authentication/authorization, blocking very large URLs/request bodies, etc.
+
+#### Can I offload requests to a background process?
+Yes, that's possible. Override controller methods to return `HTTP 202 Accepted`, with a `Location` HTTP header where users can retrieve the result.
+Your controller method needs to store the request state (URL, query string, and request body) in a queue, which your background process can read from.
+From within your background process job handler, reconstruct the request state, execute the appropriate `JsonApiResourceService` method and store the result.
+There's a basic example available at https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1144, which processes a captured query string.
+
+### What if I want to use something other than Entity Framework Core?
+This basically means you'll need to implement data access yourself. There are two approaches for interception: at the resource service level and at the repository level.
+Either way, you can use the built-in query string and request body parsing, as well as routing, error handling, and rendering of responses.
+
+Here are some injectable request-scoped types to be aware of:
+- `IJsonApiRequest`: This contains routing information, such as whether a primary, secondary, or relationship endpoint is being accessed.
+- `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates).
+- `IEnumerable`: Provides access to the parsed query string parameters.
+- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render, which you need to populate.
+- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the attributes and relationship objects. You need to populate this as well.
+
+You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources and relationships).
+
+So, back to the topic of where to intercept. It helps to familiarize yourself with the [execution pipeline](~/internals/queries.md).
+Replacing at the service level is the simplest. But it means you'll need to read the parsed query string parameters and invoke
+all resource definition callbacks yourself. And you won't get change detection (HTTP 203 Not Modified).
+Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs) to see what you're missing out on.
+
+You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options, analyze query strings or populate caches for the serializer.
+And most resource definition callbacks are handled.
+That's because the built-in resource service translates all JSON:API aspects of the request into a database-agnostic data structure called `QueryLayer`.
+Now the hard part for you becomes reading that data structure and producing data access calls from that.
+If your data store provides a LINQ provider, you may reuse most of [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs),
+which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/).
+Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening.
+We use this for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs).
+
+> [!TIP]
+> [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees!
+
+#### I love JsonApiDotNetCore! How can I support the team?
+The best way to express your gratitude is by starring our repository.
+This increases our leverage when asking for bug fixes in dependent projects, such as the .NET runtime and Entity Framework Core.
+Of course, a simple thank-you message in our [Gitter channel](https://gitter.im/json-api-dotnet-core/Lobby) is appreciated too!
+We don't take monetary contributions at the moment.
+
+If you'd like to do more: try things out, ask questions, create GitHub bug reports or feature requests, or upvote existing issues that are important to you.
+We welcome PRs, but keep in mind: The worst thing in the world is opening a PR that gets rejected after you've put a lot of effort into it.
+So for any non-trivial changes, please open an issue first to discuss your approach and ensure it fits the product vision.
+
+#### Is there anything else I should be aware of?
+See [Common Pitfalls](~/usage/common-pitfalls.md).
diff --git a/docs/getting-started/toc.md b/docs/getting-started/toc.md
new file mode 100644
index 0000000000..12f943b7fa
--- /dev/null
+++ b/docs/getting-started/toc.md
@@ -0,0 +1,5 @@
+# [Installation](install.md)
+
+# [Step By Step](step-by-step.md)
+
+# [FAQ](faq.md)
diff --git a/docs/getting-started/toc.yml b/docs/getting-started/toc.yml
deleted file mode 100644
index 4a2a008591..0000000000
--- a/docs/getting-started/toc.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-- name: Installation
- href: install.md
-
-- name: Step By Step
- href: step-by-step.md
\ No newline at end of file
diff --git a/docs/usage/common-pitfalls.md b/docs/usage/common-pitfalls.md
new file mode 100644
index 0000000000..1a0ee78804
--- /dev/null
+++ b/docs/usage/common-pitfalls.md
@@ -0,0 +1,143 @@
+# Common Pitfalls
+
+This section lists various problems we've seen users run into over the years when using JsonApiDotNetCore.
+See also [Frequently Asked Questions](~/getting-started/faq.md).
+
+#### JSON:API resources are not DTOs or ViewModels
+This is a common misconception.
+Similar to a database model, which consists of tables and foreign keys, JSON:API defines resources that are connected via relationships.
+You're opening up a can of worms when trying to model a single table to multiple JSON:API resources.
+
+This is best clarified using an example. Let's assume we're building a public website and an admin portal, both using the same API.
+The API uses the database tables "Customers" and "LoginAccounts", having a one-to-one relationship between them.
+
+Now let's try to define the resource classes:
+```c#
+[Table("Customers")]
+public sealed class WebCustomer : Identifiable
+{
+ [Attr]
+ public string Name { get; set; } = null!;
+
+ [HasOne]
+ public LoginAccount? Account { get; set; }
+}
+
+[Table("Customers")]
+public sealed class AdminCustomer : Identifiable
+{
+ [Attr]
+ public string Name { get; set; } = null!;
+
+ [Attr]
+ public string? CreditRating { get; set; }
+
+ [HasOne]
+ public LoginAccount? Account { get; set; }
+}
+
+[Table("LoginAccounts")]
+public sealed class LoginAccount : Identifiable
+{
+ [Attr]
+ public string EmailAddress { get; set; } = null!;
+
+ [HasOne]
+ public ??? Customer { get; set; }
+}
+```
+Did you notice the missing type of the `LoginAccount.Customer` property? We must choose between `WebCustomer` or `AdminCustomer`, but neither is correct.
+This is just one of the issues you'll run into. Just don't go there.
+
+The right way to model this is by having only `Customer` instead of `WebCustomer` and `AdminCustomer`. And then:
+- Hide the `CreditRating` property for web users using [this](https://www.jsonapi.net/usage/extensibility/resource-definitions.html#excluding-fields) approach.
+- Block web users from setting the `CreditRating` property from POST/PATCH resource endpoints by either:
+ - Detecting if the `CreditRating` property has changed, such as done [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs).
+ - Injecting `ITargetedFields`, throwing an error when it contains the `CreditRating` property.
+
+#### JSON:API resources are not DDD domain entities
+In [Domain-driven design](https://martinfowler.com/bliki/DomainDrivenDesign.html), it's considered best practice to implement business rules inside entities, with changes being controlled through an aggregate root.
+This paradigm [doesn't work well](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1092#issuecomment-932749676) with JSON:API, because each resource can be changed in isolation.
+So if your API needs to guard invariants such as "the sum of all orders must never exceed 500 dollars", then you're better off with an RPC-style API instead of the REST paradigm that JSON:API follows.
+
+Adding constructors to resource classes that validate incoming parameters before assigning them to properties does not work.
+Entity Framework Core [supports](https://learn.microsoft.com/en-us/ef/core/modeling/constructors#binding-to-mapped-properties) that,
+but does so via internal implementation details that are inaccessible by JsonApiDotNetCore.
+
+In JsonApiDotNetCore, resources are what DDD calls [anemic models](https://thedomaindrivendesign.io/anemic-model/).
+Validation and business rules are typically implemented in [Resource Definitions](~/usage/extensibility/resource-definitions.md).
+
+#### Model relationships instead of foreign key attributes
+It may be tempting to expose numeric resource attributes such as `customerId`, `orderId`, etc. You're better off using relationships instead, because they give you
+the richness of JSON:API. For example, it enables users to include related resources in a single request, apply filters over related resources and use dedicated endpoints for updating relationships.
+As an API developer, you'll benefit from rich input validation and fine-grained control for setting what's permitted when users access relationships.
+
+#### Model relationships instead of complex (JSON) attributes
+Similar to the above, returning a complex object takes away all the relationship features of JSON:API. Users can't filter inside a complex object. Or update
+a nested value, without risking accidentally overwriting another unrelated nested value from a concurrent request. Basically, there's no partial PATCH to prevent that.
+
+#### Stay away from stored procedures
+There are [many reasons](https://stackoverflow.com/questions/1761601/is-the-usage-of-stored-procedures-a-bad-practice/9483781#9483781) to not use stored procedures.
+But with JSON:API, there's an additional concern. Due to its dynamic nature of filtering, sorting, pagination, sparse fieldsets, and including related resources,
+the number of required stored procedures to support all that either explodes, or you'll end up with one extremely complex stored proceduce to handle it all.
+With stored procedures, you're either going to have a lot of work to do, or you'll end up with an API that has very limited capabilities.
+Neither sounds very compelling. If stored procedures is what you need, you're better off creating an RPC-style API that doesn't use JsonApiDotNetCore.
+
+#### Do not use `[ApiController]` on JSON:API controllers
+Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [`[ApiController]`](https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-7.0#apicontroller-attribute) violates the JSON:API specification.
+Despite JsonApiDotNetCore trying its best to deal with it, the experience won't be as good as leaving it out.
+
+#### Replace injectable services *after* calling `AddJsonApi()`
+Registering your own services in the IoC container afterwards increases the chances that your replacements will take effect.
+Also, register with `services.AddResourceDefinition/AddResourceService/AddResourceRepository()` instead of `services.AddScoped()`.
+When using [Auto-discovery](~/usage/resource-graph.md#auto-discovery), you don't need to register these at all.
+
+#### Never use the Entity Framework Core In-Memory Database Provider
+When using this provider, many invalid mappings go unnoticed, leading to strange errors or wrong behavior. A real SQL engine fails to create the schema when mappings are invalid.
+If you're in need of a quick setup, use [SQLite](https://www.sqlite.org/). After adding its [NuGet package](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Sqlite), it's as simple as:
+```c#
+// Program.cs
+builder.Services.AddSqlite("Data Source=temp.db");
+```
+Which creates `temp.db` on disk. Simply deleting the file gives you a clean slate.
+This is a lot more convenient compared to using [SqlLocalDB](https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/sql-server-express-localdb), which runs a background service that breaks if you delete its underlying storage files.
+
+However, even SQLite does not support all queries produced by Entity Framework Core. You'll get the best (and fastest) experience with [PostgreSQL in a docker container](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/run-docker-postgres.ps1).
+
+#### One-to-one relationships require custom Entity Framework Core mappings
+Entity Framework Core has great conventions and sane mapping defaults. But two of them are problematic for JSON:API: identifying foreign keys and default delete behavior.
+See [here](~/usage/resources/relationships.md#one-to-one-relationships-in-entity-framework-core) for how to get it right.
+
+#### Prefer model attributes over fluent mappings
+Validation attributes such as `[Required]` are detected by ASP.NET ModelState validation, Entity Framework Core, OpenAPI, and JsonApiDotNetCore.
+When using a Fluent API instead, the other frameworks cannot know about it, resulting in a less streamlined experience.
+
+#### Validation of `[Required]` value types doesn't work
+This is a limitation of ASP.NET ModelState validation. For example:
+```c#
+[Required] public int Age { get; set; }
+```
+won't cause a validation error when sending `0` or omitting it entirely in the request body.
+This limitation does not apply to reference types.
+The workaround is to make it nullable:
+```c#
+[Required] public int? Age { get; set; }
+```
+Entity Framework Core recognizes this and generates a non-nullable column.
+
+#### Don't change resource property values from POST/PATCH controller methods
+It simply won't work. Without going into details, this has to do with JSON:API partial POST/PATCH.
+Use [Resource Definition](~/usage/extensibility/resource-definitions.md) callback methods to apply such changes from code.
+
+#### You can't mix up pipeline methods
+For example, you can't call `service.UpdateAsync()` from `controller.GetAsync()`, or call `service.SetRelationshipAsync()` from `controller.PatchAsync()`.
+The reason is that various ambient injectable objects are in play, used to track what's going on during the request pipeline internally.
+And they won't match up with the current endpoint when switching to a different pipeline halfway during a request.
+
+If you need such side effects, it's easiest to inject your `DbContext` in the controller, directly apply the changes on it and save.
+A better way is to inject your `DbContext` in a [Resource Definition](~/usage/extensibility/resource-definitions.md) and apply the changes there.
+
+#### Concurrency tokens (timestamp/rowversion/xmin) won't work
+While we'd love to support such [tokens for optimistic concurrency](https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations),
+it turns out that the implementation is far from trivial. We've come a long way, but aren't sure how it should work when relationship endpoints and atomic operations are involved.
+If you're interested, we welcome your feedback at https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1119.
diff --git a/docs/usage/toc.md b/docs/usage/toc.md
index c30a2b0f37..c23d8f7308 100644
--- a/docs/usage/toc.md
+++ b/docs/usage/toc.md
@@ -24,6 +24,8 @@
# [Metadata](meta.md)
# [Caching](caching.md)
+# [Common Pitfalls](common-pitfalls.md)
+
# Extensibility
## [Layer Overview](extensibility/layer-overview.md)
## [Resource Definitions](extensibility/resource-definitions.md)
From 59125c0f6ae40ec3e36c24efd2df5a95146786a0 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Tue, 24 Jan 2023 02:16:42 +0100
Subject: [PATCH 11/22] Added additional setting
---
docs/getting-started/faq.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md
index 69f8e61505..e0b921224d 100644
--- a/docs/getting-started/faq.md
+++ b/docs/getting-started/faq.md
@@ -115,6 +115,7 @@ There are a few things to keep in mind, though:
- Prevent users from executing slow queries by locking down [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities).
Ensure the right database indexes are in place for what you enable.
- Prevent users from fetching lots of data by tweaking [maximum page size/number](~/usage/options.md#pagination) and [maximum include depth](~/usage/options.md#maximum-include-depth).
+- Avoid long-running transactions by tweaking `MaximumOperationsPerRequest` in options.
- Tell your users to utilize [E-Tags](~/usage/caching.md) to reduce network traffic.
- Not included in JsonApiDotNetCore: Apply general practices such as rate limiting, load balancing, authentication/authorization, blocking very large URLs/request bodies, etc.
From 3b034f4e5e0dfe2019944201082d3a3ffd9b3f4d Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Tue, 24 Jan 2023 02:17:23 +0100
Subject: [PATCH 12/22] rewording
---
docs/usage/common-pitfalls.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/usage/common-pitfalls.md b/docs/usage/common-pitfalls.md
index 1a0ee78804..7941face82 100644
--- a/docs/usage/common-pitfalls.md
+++ b/docs/usage/common-pitfalls.md
@@ -47,7 +47,7 @@ public sealed class LoginAccount : Identifiable
}
```
Did you notice the missing type of the `LoginAccount.Customer` property? We must choose between `WebCustomer` or `AdminCustomer`, but neither is correct.
-This is just one of the issues you'll run into. Just don't go there.
+This is only one of the issues you'll run into. Just don't go there.
The right way to model this is by having only `Customer` instead of `WebCustomer` and `AdminCustomer`. And then:
- Hide the `CreditRating` property for web users using [this](https://www.jsonapi.net/usage/extensibility/resource-definitions.html#excluding-fields) approach.
From f0b6a2dbd02655238c9e55d582825260d077e389 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 28 Jan 2023 18:35:05 +0100
Subject: [PATCH 13/22] Package updates; run tests against EF Core 7
---
.config/dotnet-tools.json | 4 ++--
Directory.Build.props | 12 ++++++------
benchmarks/Benchmarks.csproj | 2 +-
src/Examples/JsonApiDotNetCoreExample/Program.cs | 6 +++---
src/Examples/NoEntityFrameworkExample/Program.cs | 6 +++---
test/SourceGeneratorTests/CompilationBuilder.cs | 8 +++++++-
test/TestBuildingBlocks/FakeLoggerFactory.cs | 1 +
test/TestBuildingBlocks/TestBuildingBlocks.csproj | 2 +-
8 files changed, 24 insertions(+), 17 deletions(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index f8589a73a7..da7dc3dede 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -9,7 +9,7 @@
]
},
"regitlint": {
- "version": "6.2.1",
+ "version": "6.3.10",
"commands": [
"regitlint"
]
@@ -21,7 +21,7 @@
]
},
"dotnet-reportgenerator-globaltool": {
- "version": "5.1.11",
+ "version": "5.1.15",
"commands": [
"reportgenerator"
]
diff --git a/Directory.Build.props b/Directory.Build.props
index d641e8d6b6..15a7e94c7c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -2,9 +2,9 @@
net6.06.0.*
- 6.0.*
- 6.0.*
- 4.3.*
+ 7.0.*
+ 7.0.*
+ 4.4.*2.14.15.1.2$(MSBuildThisFileDirectory)CodingGuidelines.ruleset
@@ -33,8 +33,8 @@
- 3.2.0
- 4.18.2
- 17.4.0
+ 3.2.*
+ 4.18.*
+ 17.4.*
diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj
index 3958713af4..185f2919ac 100644
--- a/benchmarks/Benchmarks.csproj
+++ b/benchmarks/Benchmarks.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs
index bda826d131..e2fbc66ffd 100644
--- a/src/Examples/JsonApiDotNetCoreExample/Program.cs
+++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs
@@ -47,7 +47,7 @@ static void ConfigureServices(WebApplicationBuilder builder)
builder.Services.AddDbContext(options =>
{
- string connectionString = GetConnectionString(builder.Configuration);
+ string? connectionString = GetConnectionString(builder.Configuration);
options.UseNpgsql(connectionString);
#if DEBUG
@@ -73,10 +73,10 @@ static void ConfigureServices(WebApplicationBuilder builder)
}
}
-static string GetConnectionString(IConfiguration configuration)
+static string? GetConnectionString(IConfiguration configuration)
{
string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres";
- return configuration["Data:DefaultConnection"].Replace("###", postgresPassword);
+ return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword);
}
static void ConfigurePipeline(WebApplication webApplication)
diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs
index 43a14f7896..363b58b19e 100755
--- a/src/Examples/NoEntityFrameworkExample/Program.cs
+++ b/src/Examples/NoEntityFrameworkExample/Program.cs
@@ -7,7 +7,7 @@
// Add services to the container.
-string connectionString = GetConnectionString(builder.Configuration);
+string? connectionString = GetConnectionString(builder.Configuration);
builder.Services.AddNpgsql(connectionString);
builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add());
@@ -26,10 +26,10 @@
app.Run();
-static string GetConnectionString(IConfiguration configuration)
+static string? GetConnectionString(IConfiguration configuration)
{
string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres";
- return configuration["Data:DefaultConnection"].Replace("###", postgresPassword);
+ return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword);
}
static async Task CreateDatabaseAsync(IServiceProvider serviceProvider)
diff --git a/test/SourceGeneratorTests/CompilationBuilder.cs b/test/SourceGeneratorTests/CompilationBuilder.cs
index 6029b68eae..b4830e25f3 100644
--- a/test/SourceGeneratorTests/CompilationBuilder.cs
+++ b/test/SourceGeneratorTests/CompilationBuilder.cs
@@ -9,7 +9,13 @@ namespace SourceGeneratorTests;
internal sealed class CompilationBuilder
{
- private static readonly CSharpCompilationOptions DefaultOptions = new(OutputKind.DynamicallyLinkedLibrary);
+ private static readonly CSharpCompilationOptions DefaultOptions =
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithSpecificDiagnosticOptions(new Dictionary
+ {
+ // Suppress warning for version conflict on Microsoft.Extensions.Logging.Abstractions:
+ // JsonApiDotNetCore indirectly depends on v6 (via Entity Framework Core 6), whereas Entity Framework Core 7 depends on v7.
+ ["CS1701"] = ReportDiagnostic.Suppress
+ });
private readonly HashSet _syntaxTrees = new();
private readonly HashSet _references = new();
diff --git a/test/TestBuildingBlocks/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs
index e490cdda7b..1a1ac6d402 100644
--- a/test/TestBuildingBlocks/FakeLoggerFactory.cs
+++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs
@@ -59,6 +59,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except
}
public IDisposable BeginScope(TState state)
+ where TState : notnull
{
return NullScope.Instance;
}
diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj
index ce8c54ef3b..999498ebaa 100644
--- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj
+++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj
@@ -10,7 +10,7 @@
-
+
From 6bb377fb71108f0931961c7ff670cd2cbf4f2c16 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Thu, 15 Dec 2022 10:55:23 +0100
Subject: [PATCH 14/22] Fixed: fail on non-leading whitespace in field chains
---
.../Queries/Internal/Parsing/QueryTokenizer.cs | 2 +-
.../UnitTests/QueryStringParameters/FilterParseTests.cs | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs
index cd920554c9..37f29da58d 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs
@@ -87,7 +87,7 @@ public IEnumerable EnumerateTokens()
}
else
{
- if (_textBuffer.Length == 0 && ch == ' ' && !_isInQuotedSection)
+ if (ch == ' ' && !_isInQuotedSection)
{
throw new QueryParseException("Unexpected whitespace.");
}
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs
index 562270a358..140ce94c94 100644
--- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs
+++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs
@@ -66,6 +66,7 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b
[InlineData("filter[posts]", "equals(author,'some')", "Attribute 'author' does not exist on resource type 'blogPosts'.")]
[InlineData("filter[posts]", "lessThan(author,null)", "Attribute 'author' does not exist on resource type 'blogPosts'.")]
[InlineData("filter", " ", "Unexpected whitespace.")]
+ [InlineData("filter", "contains(owner.displayName ,)", "Unexpected whitespace.")]
[InlineData("filter", "contains(owner.displayName, )", "Unexpected whitespace.")]
[InlineData("filter", "some", "Filter function expected.")]
[InlineData("filter", "equals", "( expected.")]
@@ -73,6 +74,8 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b
[InlineData("filter", "equals(", "Count function or field name expected.")]
[InlineData("filter", "equals('1'", "Count function or field name expected.")]
[InlineData("filter", "equals(count(posts),", "Count function, value between quotes, null or field name expected.")]
+ [InlineData("filter", "equals(owner..displayName,'')", "Count function or field name expected.")]
+ [InlineData("filter", "equals(owner.displayName.,'')", "Count function or field name expected.")]
[InlineData("filter", "equals(title,')", "' expected.")]
[InlineData("filter", "equals(title,null", ") expected.")]
[InlineData("filter", "equals(null", "Field 'null' does not exist on resource type 'blogs'.")]
From ab8759cc8444d05157302dda68c63753c3e9a1a8 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Tue, 31 Jan 2023 09:58:13 +0100
Subject: [PATCH 15/22] Added tests for EF Core table-per-concrete-type
inheritance mode
https://learn.microsoft.com/en-us/ef/core/modeling/inheritance#table-per-concrete-type-configuration
---
.../ResourceInheritanceWriteTests.cs | 16 ++++++---
.../TablePerConcreteTypeDbContext.cs | 33 +++++++++++++++++++
.../TablePerConcreteTypeReadTests.cs | 13 ++++++++
.../TablePerConcreteTypeWriteTests.cs | 13 ++++++++
.../TablePerType/TablePerTypeDbContext.cs | 31 ++++++++---------
.../TestBuildingBlocks/DbContextExtensions.cs | 14 ++++++--
6 files changed, 96 insertions(+), 24 deletions(-)
create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeDbContext.cs
create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeReadTests.cs
create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeWriteTests.cs
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs
index 0dd9659593..c3d7f1fb9c 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs
@@ -2298,7 +2298,7 @@ public async Task Can_add_concrete_base_resources_stored_as_derived_at_ToMany_re
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
- dbContext.Set().Add(existingManufacturer);
+ dbContext.VehicleManufacturers.Add(existingManufacturer);
dbContext.Vehicles.Add(existingTandem);
await dbContext.SaveChangesAsync();
});
@@ -2327,9 +2327,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
- VehicleManufacturer manufacturerInDatabase = await dbContext.Set().Include(manufacturer => manufacturer.Vehicles)
+ // @formatter:wrap_chained_method_calls chop_always
+ // @formatter:keep_existing_linebreaks true
+
+ VehicleManufacturer manufacturerInDatabase = await dbContext.VehicleManufacturers
+ .Include(manufacturer => manufacturer.Vehicles
+ .OrderByDescending(vehicle => vehicle.Id))
.FirstWithIdAsync(existingManufacturer.Id);
+ // @formatter:keep_existing_linebreaks restore
+ // @formatter:wrap_chained_method_calls restore
+
manufacturerInDatabase.Vehicles.ShouldHaveCount(2);
manufacturerInDatabase.Vehicles.ElementAt(0).Should().BeOfType();
manufacturerInDatabase.Vehicles.ElementAt(0).Id.Should().Be(existingManufacturer.Vehicles.ElementAt(0).Id);
@@ -2578,7 +2586,7 @@ public async Task Can_remove_concrete_base_resources_stored_as_derived_at_ToMany
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
- dbContext.Set().Add(existingManufacturer);
+ dbContext.VehicleManufacturers.Add(existingManufacturer);
await dbContext.SaveChangesAsync();
});
@@ -2606,7 +2614,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
- VehicleManufacturer manufacturerInDatabase = await dbContext.Set().Include(manufacturer => manufacturer.Vehicles)
+ VehicleManufacturer manufacturerInDatabase = await dbContext.VehicleManufacturers.Include(manufacturer => manufacturer.Vehicles)
.FirstWithIdAsync(existingManufacturer.Id);
manufacturerInDatabase.Vehicles.ShouldHaveCount(1);
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeDbContext.cs
new file mode 100644
index 0000000000..2b5977c8b4
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeDbContext.cs
@@ -0,0 +1,33 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models;
+using Microsoft.EntityFrameworkCore;
+
+// @formatter:wrap_chained_method_calls chop_always
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.TablePerConcreteType;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+public sealed class TablePerConcreteTypeDbContext : ResourceInheritanceDbContext
+{
+ public TablePerConcreteTypeDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ builder.Entity()
+ .UseTpcMappingStrategy();
+
+ builder.Entity()
+ .UseTpcMappingStrategy();
+
+ builder.Entity()
+ .UseTpcMappingStrategy();
+
+ builder.Entity()
+ .UseTpcMappingStrategy();
+
+ base.OnModelCreating(builder);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeReadTests.cs
new file mode 100644
index 0000000000..bcffe2ed5e
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeReadTests.cs
@@ -0,0 +1,13 @@
+using JetBrains.Annotations;
+using TestBuildingBlocks;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.TablePerConcreteType;
+
+[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+public sealed class TablePerConcreteTypeReadTests : ResourceInheritanceReadTests
+{
+ public TablePerConcreteTypeReadTests(IntegrationTestContext, TablePerConcreteTypeDbContext> testContext)
+ : base(testContext)
+ {
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeWriteTests.cs
new file mode 100644
index 0000000000..9f2e3c3cae
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerConcreteType/TablePerConcreteTypeWriteTests.cs
@@ -0,0 +1,13 @@
+using JetBrains.Annotations;
+using TestBuildingBlocks;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.TablePerConcreteType;
+
+[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+public sealed class TablePerConcreteTypeWriteTests : ResourceInheritanceWriteTests
+{
+ public TablePerConcreteTypeWriteTests(IntegrationTestContext, TablePerConcreteTypeDbContext> testContext)
+ : base(testContext)
+ {
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeDbContext.cs
index 7c50ee5573..86a723bfd3 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeDbContext.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/TablePerType/TablePerTypeDbContext.cs
@@ -2,6 +2,8 @@
using JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models;
using Microsoft.EntityFrameworkCore;
+// @formatter:wrap_chained_method_calls chop_always
+
namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.TablePerType;
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
@@ -14,24 +16,17 @@ public TablePerTypeDbContext(DbContextOptions options)
protected override void OnModelCreating(ModelBuilder builder)
{
- builder.Entity().ToTable("Vehicles");
- builder.Entity().ToTable("Bikes");
- builder.Entity().ToTable("Tandems");
- builder.Entity().ToTable("MotorVehicles");
- builder.Entity().ToTable("Cars");
- builder.Entity().ToTable("Trucks");
-
- builder.Entity().ToTable("Wheels");
- builder.Entity().ToTable("CarbonWheels");
- builder.Entity().ToTable("ChromeWheels");
-
- builder.Entity().ToTable("Engines");
- builder.Entity().ToTable("GasolineEngines");
- builder.Entity().ToTable("DieselEngines");
-
- builder.Entity().ToTable("GenericProperties");
- builder.Entity().ToTable("StringProperties");
- builder.Entity().ToTable("NumberProperties");
+ builder.Entity()
+ .UseTptMappingStrategy();
+
+ builder.Entity()
+ .UseTptMappingStrategy();
+
+ builder.Entity()
+ .UseTptMappingStrategy();
+
+ builder.Entity()
+ .UseTptMappingStrategy();
base.OnModelCreating(builder);
}
diff --git a/test/TestBuildingBlocks/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs
index 5bb3f81a14..8ce859f356 100644
--- a/test/TestBuildingBlocks/DbContextExtensions.cs
+++ b/test/TestBuildingBlocks/DbContextExtensions.cs
@@ -34,8 +34,18 @@ private static async Task ClearTablesAsync(this DbContext dbContext, params Type
throw new InvalidOperationException($"Table for '{model.Name}' not found.");
}
- string tableName = entityType.GetTableName()!;
- await dbContext.Database.ExecuteSqlRawAsync($"delete from \"{tableName}\"");
+ string? tableName = entityType.GetTableName();
+
+ if (tableName == null)
+ {
+ // There is no table for the specified abstract base type when using TablePerConcreteType inheritance.
+ IEnumerable derivedTypes = entityType.GetConcreteDerivedTypesInclusive();
+ await ClearTablesAsync(dbContext, derivedTypes.Select(derivedType => derivedType.ClrType).ToArray());
+ }
+ else
+ {
+ await dbContext.Database.ExecuteSqlRawAsync($"delete from \"{tableName}\"");
+ }
}
}
}
From febe39649fc987a820def5d992e3c13bd0f4c2b7 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Tue, 31 Jan 2023 11:21:06 +0100
Subject: [PATCH 16/22] Use docfx Alerts
---
docs/request-examples/index.md | 3 ++-
docs/usage/caching.md | 4 ++--
docs/usage/extensibility/layer-overview.md | 5 +++--
docs/usage/extensibility/repositories.md | 5 +++--
.../extensibility/resource-definitions.md | 20 ++++++++++---------
docs/usage/extensibility/services.md | 5 +++--
docs/usage/options.md | 6 +++++-
docs/usage/reading/filtering.md | 3 ++-
.../reading/sparse-fieldset-selection.md | 3 ++-
docs/usage/resource-graph.md | 3 ++-
docs/usage/resources/index.md | 3 ++-
docs/usage/resources/relationships.md | 3 ++-
docs/usage/writing/creating.md | 4 +++-
docs/usage/writing/updating.md | 14 ++++++++-----
14 files changed, 51 insertions(+), 30 deletions(-)
diff --git a/docs/request-examples/index.md b/docs/request-examples/index.md
index 4d82e95854..c34b3d713a 100644
--- a/docs/request-examples/index.md
+++ b/docs/request-examples/index.md
@@ -4,7 +4,8 @@ These requests have been generated against the "GettingStarted" application and
All of these requests have been created using out-of-the-box features.
-_Note that cURL requires "[" and "]" in URLs to be escaped._
+> [!NOTE]
+> curl requires "[" and "]" in URLs to be escaped.
# Reading data
diff --git a/docs/usage/caching.md b/docs/usage/caching.md
index d5f644997b..537ec70e4b 100644
--- a/docs/usage/caching.md
+++ b/docs/usage/caching.md
@@ -59,8 +59,8 @@ ETag: "356075D903B8FE8D9921201A7E7CD3F9"
"data": [ ... ]
}
```
-
-**Note:** To just poll for changes (without fetching them), send a HEAD request instead:
+> [!TIP]
+> To just poll for changes (without fetching them), send a HEAD request instead.
```http
HEAD /articles?sort=-lastModifiedAt HTTP/1.1
diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md
index 2fe99e2fbd..9233508179 100644
--- a/docs/usage/extensibility/layer-overview.md
+++ b/docs/usage/extensibility/layer-overview.md
@@ -23,8 +23,6 @@ on your needs, you may want to replace other parts by deriving from the built-in
## Replacing injected services
-**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you.
-
Replacing built-in services is done on a per-resource basis and can be done at startup.
For convenience, extension methods are provided to register layers on all their implemented interfaces.
@@ -37,3 +35,6 @@ builder.Services.AddResourceDefinition();
builder.Services.AddScoped();
builder.Services.AddScoped();
```
+
+> [!TIP]
+> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you.
diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md
index 102406e71b..733634bf33 100644
--- a/docs/usage/extensibility/repositories.md
+++ b/docs/usage/extensibility/repositories.md
@@ -13,13 +13,14 @@ builder.Services.AddScoped, ArticleReposi
In v4.0 we introduced an extension method that you can use to register a resource repository on all of its JsonApiDotNetCore interfaces.
This is helpful when you implement (a subset of) the resource interfaces and want to register them all in one go.
-**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically.
-
```c#
// Program.cs
builder.Services.AddResourceRepository();
```
+> [!TIP]
+> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you.
+
A sample implementation that performs authorization might look like this.
All of the methods in EntityFrameworkCoreRepository will use the `GetAll()` method to get the `DbSet`, so this is a good method to apply filters such as user or tenant authorization.
diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md
index 6bc16b869e..cf5400b722 100644
--- a/docs/usage/extensibility/resource-definitions.md
+++ b/docs/usage/extensibility/resource-definitions.md
@@ -7,19 +7,19 @@ They are resolved from the dependency injection container, so you can inject dep
In v4.2 we introduced an extension method that you can use to register your resource definition.
-**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically.
-
```c#
// Program.cs
builder.Services.AddResourceDefinition();
```
-**Note:** Prior to the introduction of auto-discovery (in v3), you needed to register the
-resource definition on the container yourself:
+> [!TIP]
+> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you.
-```c#
-builder.Services.AddScoped, ArticleDefinition>();
-```
+> [!NOTE]
+> Prior to the introduction of auto-discovery (in v3), you needed to register the resource definition on the container yourself:
+> ```c#
+> builder.Services.AddScoped, ArticleDefinition>();
+> ```
## Customizing queries
@@ -37,7 +37,8 @@ from Entity Framework Core `IQueryable` execution.
There are some cases where you want attributes or relationships conditionally excluded from your resource response.
For example, you may accept some sensitive data that should only be exposed to administrators after creation.
-**Note:** to exclude fields unconditionally, [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities) can be used instead.
+> [!NOTE]
+> To exclude fields unconditionally, [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities) can be used instead.
```c#
public class UserDefinition : JsonApiResourceDefinition
@@ -218,7 +219,8 @@ _since v3_
You can define additional query string parameters with the LINQ expression that should be used.
If the key is present in a query string, the supplied LINQ expression will be added to the database query.
-Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators.
+> [!NOTE]
+> This directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators.
But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles).
```c#
diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md
index bc3dd5bff8..6cdea8b783 100644
--- a/docs/usage/extensibility/services.md
+++ b/docs/usage/extensibility/services.md
@@ -135,13 +135,14 @@ builder.Services.AddScoped, ArticleService>();
In v3.0 we introduced an extension method that you can use to register a resource service on all of its JsonApiDotNetCore interfaces.
This is helpful when you implement (a subset of) the resource interfaces and want to register them all in one go.
-**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically.
-
```c#
// Program.cs
builder.Services.AddResourceService();
```
+> [!TIP]
+> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you.
+
Then on your model, pass in the set of endpoints to expose (the ones that you've registered services for):
```c#
diff --git a/docs/usage/options.md b/docs/usage/options.md
index 83d535bce4..919642c5c8 100644
--- a/docs/usage/options.md
+++ b/docs/usage/options.md
@@ -24,7 +24,11 @@ options.AllowClientGeneratedIds = true;
The default page size used for all resources can be overridden in options (10 by default). To disable paging, set it to `null`.
The maximum page size and number allowed from client requests can be set too (unconstrained by default).
-You can also include the total number of resources in each response. Note that when using this feature, it does add some query overhead since we have to also request the total number of resources.
+
+You can also include the total number of resources in each response.
+
+> [!NOTE]
+> Including the total number of resources adds some overhead, because the count is fetched in a separate query.
```c#
options.DefaultPageSize = new PageSize(25);
diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md
index b99a14c3b2..8a568078a0 100644
--- a/docs/usage/reading/filtering.md
+++ b/docs/usage/reading/filtering.md
@@ -69,7 +69,8 @@ GET /articles?include=author,tags&filter=equals(author.lastName,'Smith')&filter[
In the above request, the first filter is applied on the collection of articles, while the second one is applied on the nested collection of tags.
-Note this does **not** hide articles without any matching tags! Use the `has` function with a filter condition (see below) to accomplish that.
+> [!WARNING]
+> The request above does **not** hide articles without any matching tags! Use the `has` function with a filter condition (see below) to accomplish that.
Putting it all together, you can build quite complex filters, such as:
diff --git a/docs/usage/reading/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md
index 5c08bc6ae4..6491cb050b 100644
--- a/docs/usage/reading/sparse-fieldset-selection.md
+++ b/docs/usage/reading/sparse-fieldset-selection.md
@@ -36,7 +36,8 @@ Example for both top-level and relationship:
GET /articles?include=author&fields[articles]=title,body,author&fields[authors]=name HTTP/1.1
```
-Note that in the last example, the `author` relationship is also added to the `articles` fieldset, so that the relationship from article to author is returned.
+> [!NOTE]
+> In the last example, the `author` relationship is also added to the `articles` fieldset, so that the relationship from article to author is returned.
When omitted, you'll get the included resources returned, but without full resource linkage (as described [here](https://jsonapi.org/examples/#sparse-fieldsets)).
## Overriding
diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md
index 4010cbea5f..5e3195ca0b 100644
--- a/docs/usage/resource-graph.md
+++ b/docs/usage/resource-graph.md
@@ -3,7 +3,8 @@
The `ResourceGraph` is a map of all the JSON:API resources and their relationships that your API serves.
It is built at app startup and available as a singleton through Dependency Injection.
-**Note:** Prior to v4 this was called the `ContextGraph`.
+> [!NOTE]
+> Prior to v4, this was called the `ContextGraph`.
## Constructing The Graph
diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md
index 552b3886fa..f8e7d29156 100644
--- a/docs/usage/resources/index.md
+++ b/docs/usage/resources/index.md
@@ -8,7 +8,8 @@ public class Person : Identifiable
}
```
-**Note:** Earlier versions of JsonApiDotNetCore allowed a short-hand notation when `TId` is of type `int`. This was removed in v5.
+> [!NOTE]
+> Earlier versions of JsonApiDotNetCore allowed a short-hand notation when `TId` is of type `int`. This was removed in v5.
If you need to attach annotations or attributes on the `Id` property, you can override the virtual property.
diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md
index 1787bbd8ac..689b3aa4d2 100644
--- a/docs/usage/resources/relationships.md
+++ b/docs/usage/resources/relationships.md
@@ -277,7 +277,8 @@ This can be overridden per relationship.
Indicates whether the relationship can be returned in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response.
Otherwise, the relationship (and its related resources, when included) are silently omitted.
-Note that this setting does not affect retrieving the related resources directly.
+> [!WARNING]
+> This setting does not affect retrieving the related resources directly.
```c#
#nullable enable
diff --git a/docs/usage/writing/creating.md b/docs/usage/writing/creating.md
index 4cbe42602e..ba0a21d52b 100644
--- a/docs/usage/writing/creating.md
+++ b/docs/usage/writing/creating.md
@@ -71,4 +71,6 @@ POST /articles?include=owner&fields[people]=firstName HTTP/1.1
}
```
-After the resource has been created on the server, it is re-fetched from the database using the specified query string parameters and returned to the client.
+> [!NOTE]
+> After the resource has been created on the server, it is re-fetched from the database using the specified query string parameters and returned to the client.
+> However, the used query string parameters only have an effect when `200 OK` is returned.
diff --git a/docs/usage/writing/updating.md b/docs/usage/writing/updating.md
index 132d487cfe..01d0740cba 100644
--- a/docs/usage/writing/updating.md
+++ b/docs/usage/writing/updating.md
@@ -21,12 +21,14 @@ POST /articles HTTP/1.1
This preserves the values of all other unsent attributes and is called a *partial patch*.
When only the attributes that were sent in the request have changed, the server returns `204 No Content`.
-But if additional attributes have changed (for example, by a database trigger that refreshes the last-modified date) the server returns `200 OK`, along with all attributes of the updated resource.
+But if additional attributes have changed (for example, by a database trigger or [resource definition](~/usage/extensibility/resource-definitions.md) that refreshes the last-modified date) the server returns `200 OK`, along with all attributes of the updated resource.
## Updating resource relationships
Besides its attributes, the relationships of a resource can be changed using a PATCH request too.
-Note that all resources being assigned in a relationship must already exist.
+
+> [!NOTE]
+> All resources being assigned in a relationship must already exist.
When updating a HasMany relationship, the existing set is replaced by the new set. See below on how to add/remove resources.
@@ -65,7 +67,8 @@ PATCH /articles/1 HTTP/1.1
A HasOne relationship can be cleared by setting `data` to `null`, while a HasMany relationship can be cleared by setting it to an empty array.
-By combining the examples above, both attributes and relationships can be updated using a single PATCH request.
+> [!TIP]
+> By combining the examples above, both attributes and relationships can be updated using a single PATCH request.
## Response body
@@ -79,8 +82,9 @@ PATCH /articles/1?include=owner&fields[people]=firstName HTTP/1.1
}
```
-After the resource has been updated on the server, it is re-fetched from the database using the specified query string parameters and returned to the client.
-Note this only has an effect when `200 OK` is returned.
+> [!NOTE]
+> After the resource has been updated on the server, it is re-fetched from the database using the specified query string parameters and returned to the client.
+> However, the used query string parameters only have an effect when `200 OK` is returned.
# Updating relationships
From 1d7631097fb2173949b51b38cca7df822df2ea80 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Wed, 1 Feb 2023 12:06:55 +0100
Subject: [PATCH 17/22] Simplify test: since we have deterministic clocks,
there's no more need for a hard-coded value.
---
.../QueryStrings/AtomicQueryStringTests.cs | 17 ++++++-----------
1 file changed, 6 insertions(+), 11 deletions(-)
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs
index 640e6ac3fe..efeb369dc4 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs
@@ -1,6 +1,5 @@
using System.Net;
using FluentAssertions;
-using FluentAssertions.Extensions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Authentication;
@@ -12,8 +11,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings;
public sealed class AtomicQueryStringTests : IClassFixture, OperationsDbContext>>
{
- private static readonly DateTime FrozenTime = 30.July(2018).At(13, 46, 12).AsUtc();
-
private readonly IntegrationTestContext, OperationsDbContext> _testContext;
private readonly OperationsFakers _fakers = new();
@@ -26,11 +23,7 @@ public AtomicQueryStringTests(IntegrationTestContext
{
- services.AddSingleton(new FrozenSystemClock
- {
- UtcNow = FrozenTime
- });
-
+ services.AddSingleton();
services.AddResourceDefinition();
});
}
@@ -279,10 +272,12 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint()
public async Task Can_use_Queryable_handler_on_resource_endpoint()
{
// Arrange
+ var clock = _testContext.Factory.Services.GetRequiredService();
+
List musicTracks = _fakers.MusicTrack.Generate(3);
- musicTracks[0].ReleasedAt = FrozenTime.AddMonths(5);
- musicTracks[1].ReleasedAt = FrozenTime.AddMonths(-5);
- musicTracks[2].ReleasedAt = FrozenTime.AddMonths(-1);
+ musicTracks[0].ReleasedAt = clock.UtcNow.AddMonths(5);
+ musicTracks[1].ReleasedAt = clock.UtcNow.AddMonths(-5);
+ musicTracks[2].ReleasedAt = clock.UtcNow.AddMonths(-1);
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
From c835ff2fb3628ae5d0e1782d36fda0802ece1d1b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Isma=C3=AFl=20Ouazzany?=
Date: Sat, 4 Feb 2023 13:04:17 +0100
Subject: [PATCH 18/22] Fix missing HttpContext on ValidationContext during
atomic operations (#1251)
---
.../BaseJsonApiOperationsController.cs | 3 +-
.../Creating/AtomicCreateResourceTests.cs | 6 +++
...reateResourceWithClientGeneratedIdTests.cs | 2 +
.../DateMustBeInThePastAttribute.cs | 34 ++++++++++++
.../Meta/AtomicResourceMetaTests.cs | 2 +
.../AtomicModelStateValidationTests.cs | 52 +++++++++++++++++++
.../AtomicOperations/MusicTrack.cs | 1 +
.../Resources/AtomicUpdateResourceTests.cs | 2 +
test/TestBuildingBlocks/FakerContainer.cs | 2 +-
test/TestBuildingBlocks/FrozenSystemClock.cs | 2 +-
10 files changed, 103 insertions(+), 3 deletions(-)
create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs
diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs
index 2ea8f89a87..596b22794d 100644
--- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs
+++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs
@@ -166,7 +166,8 @@ private int ValidateOperation(OperationContainer operation, int operationIndex,
ModelState =
{
MaxAllowedErrors = maxErrorsRemaining
- }
+ },
+ HttpContext = HttpContext
};
ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource);
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs
index 048ef5506f..dcd38763c9 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs
@@ -3,6 +3,7 @@
using FluentAssertions.Extensions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
@@ -26,6 +27,11 @@ public AtomicCreateResourceTests(IntegrationTestContext();
testContext.UseController();
+ testContext.ConfigureServicesBeforeStartup(services =>
+ {
+ services.AddSingleton();
+ });
+
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService();
options.AllowUnknownFieldsInRequestBody = false;
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs
index 62907c047a..6e9a21d773 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs
@@ -2,6 +2,7 @@
using FluentAssertions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
using Xunit;
@@ -28,6 +29,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext();
services.AddSingleton();
+ services.AddSingleton();
});
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService();
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs
new file mode 100644
index 0000000000..0421f2e396
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+using JsonApiDotNetCore.Resources;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations;
+
+[AttributeUsage(AttributeTargets.Property)]
+internal sealed class DateMustBeInThePastAttribute : ValidationAttribute
+{
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ var targetedFields = validationContext.GetRequiredService();
+
+ if (targetedFields.Attributes.Any(attribute => attribute.Property.Name == validationContext.MemberName))
+ {
+ PropertyInfo propertyInfo = validationContext.ObjectType.GetProperty(validationContext.MemberName!)!;
+
+ if (propertyInfo.PropertyType == typeof(DateTimeOffset) || propertyInfo.PropertyType == typeof(DateTimeOffset?))
+ {
+ var typedValue = (DateTimeOffset?)propertyInfo.GetValue(validationContext.ObjectInstance);
+ var systemClock = validationContext.GetRequiredService();
+
+ if (typedValue >= systemClock.UtcNow)
+ {
+ return new ValidationResult($"{validationContext.MemberName} must be in the past.");
+ }
+ }
+ }
+
+ return ValidationResult.Success;
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs
index 12875231b6..2bd9dc8edf 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs
@@ -4,6 +4,7 @@
using FluentAssertions.Extensions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
using Xunit;
@@ -27,6 +28,7 @@ public AtomicResourceMetaTests(IntegrationTestContext();
services.AddSingleton();
+ services.AddSingleton();
});
var hitCounter = _testContext.Factory.Services.GetRequiredService();
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs
index 0eadc45f2c..03a04fb431 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs
@@ -1,7 +1,9 @@
using System.Net;
using FluentAssertions;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
using Xunit;
@@ -16,6 +18,11 @@ public AtomicModelStateValidationTests(IntegrationTestContext
+ {
+ services.AddSingleton();
+ });
+
testContext.UseController();
}
@@ -67,6 +74,51 @@ public async Task Cannot_create_resource_with_multiple_violations()
error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds");
}
+ [Fact]
+ public async Task Cannot_create_resource_when_violation_from_custom_ValidationAttribute()
+ {
+ // Arrange
+ var clock = _testContext.Factory.Services.GetRequiredService();
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "add",
+ data = new
+ {
+ type = "musicTracks",
+ attributes = new
+ {
+ title = "some",
+ lengthInSeconds = 120,
+ releasedAt = clock.UtcNow.AddDays(1)
+ }
+ }
+ }
+ }
+ };
+
+ const string route = "/operations";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ error.Title.Should().Be("Input validation failed.");
+ error.Detail.Should().Be("ReleasedAt must be in the past.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/releasedAt");
+ }
+
[Fact]
public async Task Can_create_resource_with_annotated_relationship()
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs
index 646a0d9ed9..52ed0ae98b 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs
@@ -23,6 +23,7 @@ public sealed class MusicTrack : Identifiable
public string? Genre { get; set; }
[Attr]
+ [DateMustBeInThePast]
public DateTimeOffset ReleasedAt { get; set; }
[HasOne]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
index eb9f81b6e6..2371ab092a 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
@@ -3,6 +3,7 @@
using FluentAssertions.Extensions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
@@ -29,6 +30,7 @@ public AtomicUpdateResourceTests(IntegrationTestContext();
services.AddSingleton();
+ services.AddSingleton();
});
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService();
diff --git a/test/TestBuildingBlocks/FakerContainer.cs b/test/TestBuildingBlocks/FakerContainer.cs
index 99cce6e04c..72f9a05567 100644
--- a/test/TestBuildingBlocks/FakerContainer.cs
+++ b/test/TestBuildingBlocks/FakerContainer.cs
@@ -12,7 +12,7 @@ static FakerContainer()
{
// Setting the system DateTime to kind Utc, so that faker calls like PastOffset() don't depend on the system time zone.
// See https://docs.microsoft.com/en-us/dotnet/api/system.datetimeoffset.op_implicit?view=net-6.0#remarks
- Date.SystemClock = () => 1.January(2020).AsUtc();
+ Date.SystemClock = () => 1.January(2020).At(1, 1, 1).AsUtc();
}
protected static int GetFakerSeed()
diff --git a/test/TestBuildingBlocks/FrozenSystemClock.cs b/test/TestBuildingBlocks/FrozenSystemClock.cs
index 0f757c63a5..a1d85e1fcc 100644
--- a/test/TestBuildingBlocks/FrozenSystemClock.cs
+++ b/test/TestBuildingBlocks/FrozenSystemClock.cs
@@ -5,7 +5,7 @@ namespace TestBuildingBlocks;
public sealed class FrozenSystemClock : ISystemClock
{
- private static readonly DateTimeOffset DefaultTime = 1.January(2000).At(1, 1, 1).AsUtc();
+ private static readonly DateTimeOffset DefaultTime = 1.January(2020).At(1, 1, 1).AsUtc();
public DateTimeOffset UtcNow { get; set; } = DefaultTime;
}
From 7d08a55c1fa6b54454c10f84d6c3a06061f39205 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 4 Feb 2023 15:38:50 +0100
Subject: [PATCH 19/22] Bugfix in ModelState validation: before this change,
the JADNC filter would only kick in for required attributes, which is
incorrect. We didn't have a test for an optional property containing a range
validation, where the property default value is not in the required range. In
that case, using such a resource as non-toplevel (for example: update
relationship) would incorrectly produce a validation error.
---
.../JsonApiModelMetadataProvider.cs | 6 +---
.../Configuration/JsonApiValidationFilter.cs | 9 +++---
.../ModelState/ModelStateFakers.cs | 6 ++--
.../ModelState/ModelStateValidationTests.cs | 31 +++++++++----------
.../ModelState/SystemDirectory.cs | 4 ---
.../InputValidation/ModelState/SystemFile.cs | 7 +++--
6 files changed, 27 insertions(+), 36 deletions(-)
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs
index 9ffe2f7641..0f9cbf1fd2 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs
@@ -32,11 +32,7 @@ public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsPro
protected override ModelMetadata CreateModelMetadata(DefaultMetadataDetails entry)
{
var metadata = (DefaultModelMetadata)base.CreateModelMetadata(entry);
-
- if (metadata.ValidationMetadata.IsRequired == true)
- {
- metadata.ValidationMetadata.PropertyValidationFilter = _jsonApiValidationFilter;
- }
+ metadata.ValidationMetadata.PropertyValidationFilter = _jsonApiValidationFilter;
return metadata;
}
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs
index a27acf8ebd..3fd29a9c65 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs
@@ -1,6 +1,7 @@
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.DependencyInjection;
@@ -23,15 +24,13 @@ public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor)
///
public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry)
{
- IServiceProvider serviceProvider = GetScopedServiceProvider();
-
- var request = serviceProvider.GetRequiredService();
-
- if (IsId(entry.Key))
+ if (entry.Metadata.MetadataKind == ModelMetadataKind.Type || IsId(entry.Key))
{
return true;
}
+ IServiceProvider serviceProvider = GetScopedServiceProvider();
+ var request = serviceProvider.GetRequiredService();
bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request);
if (!isTopResourceInPrimaryRequest)
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs
index 75183eaf9f..7d7d7d1acf 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs
@@ -17,14 +17,14 @@ internal sealed class ModelStateFakers : FakerContainer
new Faker()
.UseSeed(GetFakerSeed())
.RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName())
+ .RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly))
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)));
private readonly Lazy> _lazySystemDirectoryFaker = new(() =>
new Faker()
.UseSeed(GetFakerSeed())
- .RuleFor(systemDirectory => systemDirectory.Name, faker => faker.Address.City())
- .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool())
- .RuleFor(systemDirectory => systemDirectory.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)));
+ .RuleFor(systemDirectory => systemDirectory.Name, faker => Path.GetFileNameWithoutExtension(faker.System.FileName()))
+ .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool()));
public Faker SystemVolume => _lazySystemVolumeFaker.Value;
public Faker SystemFile => _lazySystemFileFaker.Value;
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs
index 7df5b15a74..b114962bd3 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs
@@ -166,8 +166,6 @@ public async Task Cannot_create_resource_with_multiple_violations()
type = "systemDirectories",
attributes = new
{
- isCaseSensitive = false,
- sizeInBytes = -1
}
}
};
@@ -192,9 +190,9 @@ public async Task Cannot_create_resource_with_multiple_violations()
ErrorObject error2 = responseDocument.Errors[1];
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error2.Title.Should().Be("Input validation failed.");
- error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807.");
+ error2.Detail.Should().Be("The IsCaseSensitive field is required.");
error2.Source.ShouldNotBeNull();
- error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes");
+ error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive");
}
[Fact]
@@ -205,15 +203,14 @@ public async Task Does_not_exceed_MaxModelValidationErrors()
{
data = new
{
- type = "systemDirectories",
+ type = "systemFiles",
attributes = new
{
- sizeInBytes = -1
}
}
};
- const string route = "/systemDirectories";
+ const string route = "/systemFiles";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody);
@@ -232,16 +229,16 @@ public async Task Does_not_exceed_MaxModelValidationErrors()
ErrorObject error2 = responseDocument.Errors[1];
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error2.Title.Should().Be("Input validation failed.");
- error2.Detail.Should().Be("The Name field is required.");
+ error2.Detail.Should().Be("The FileName field is required.");
error2.Source.ShouldNotBeNull();
- error2.Source.Pointer.Should().Be("/data/attributes/directoryName");
+ error2.Source.Pointer.Should().Be("/data/attributes/fileName");
ErrorObject error3 = responseDocument.Errors[2];
error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error3.Title.Should().Be("Input validation failed.");
- error3.Detail.Should().Be("The IsCaseSensitive field is required.");
+ error3.Detail.Should().Be("The Attributes field is required.");
error3.Source.ShouldNotBeNull();
- error3.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive");
+ error3.Source.Pointer.Should().Be("/data/attributes/attributes");
}
[Fact]
@@ -360,13 +357,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
public async Task Can_update_resource_with_omitted_required_attribute_value()
{
// Arrange
- SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate();
+ SystemFile existingFile = _fakers.SystemFile.Generate();
- long newSizeInBytes = _fakers.SystemDirectory.Generate().SizeInBytes;
+ long? newSizeInBytes = _fakers.SystemFile.Generate().SizeInBytes;
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
- dbContext.Directories.Add(existingDirectory);
+ dbContext.Files.Add(existingFile);
await dbContext.SaveChangesAsync();
});
@@ -374,8 +371,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
{
data = new
{
- type = "systemDirectories",
- id = existingDirectory.StringId,
+ type = "systemFiles",
+ id = existingFile.StringId,
attributes = new
{
sizeInBytes = newSizeInBytes
@@ -383,7 +380,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
}
};
- string route = $"/systemDirectories/{existingDirectory.StringId}";
+ string route = $"/systemFiles/{existingFile.StringId}";
// Act
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody);
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs
index 1712ad103e..4265dc688e 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs
@@ -20,10 +20,6 @@ public sealed class SystemDirectory : Identifiable
[Required]
public bool? IsCaseSensitive { get; set; }
- [Attr]
- [Range(typeof(long), "0", "9223372036854775807")]
- public long SizeInBytes { get; set; }
-
[HasMany]
public ICollection Subdirectories { get; set; } = new List();
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs
index 56bd50d1e7..a5515922e4 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs
@@ -15,6 +15,9 @@ public sealed class SystemFile : Identifiable
[Attr]
[Required]
- [Range(typeof(long), "0", "9223372036854775807")]
- public long? SizeInBytes { get; set; }
+ public FileAttributes? Attributes { get; set; }
+
+ [Attr]
+ [Range(typeof(long), "1", "9223372036854775807")]
+ public long SizeInBytes { get; set; }
}
From 3eb40afea4bf81077c902186ee3dcd5572ce0082 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sun, 5 Feb 2023 13:10:11 +0100
Subject: [PATCH 20/22] Update docs/getting-started/faq.md
Co-authored-by: Maurits Moeys
---
docs/getting-started/faq.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md
index e0b921224d..2acde9d7a8 100644
--- a/docs/getting-started/faq.md
+++ b/docs/getting-started/faq.md
@@ -106,7 +106,7 @@ This is useful if you want to add a few RPC-style endpoints or provide binary fi
A middle-ground approach is to add custom action methods to existing JSON:API controllers.
While you can route them as you like, they must return JSON:API resources.
And on error, a JSON:API error response is produced.
-This is useful if you want to stay in the JSON:API-compliant world, but need to expose something on-standard, for example: `GET /users/me`.
+This is useful if you want to stay in the JSON:API-compliant world, but need to expose something non-standard, for example: `GET /users/me`.
#### How do I optimize for high scalability and prevent denial of service?
Fortunately, JsonApiDotNetCore [scales pretty well](https://github.com/json-api-dotnet/PerformanceReports) under high load and/or large database tables.
From f82892dc9b44989c07c659fe85e2b6000e6ea978 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 4 Feb 2023 16:42:44 +0100
Subject: [PATCH 21/22] Fix culture sensitivity in query strings (added
AppContext switch for backwards-compatibility)
---
.../Internal/RuntimeTypeConverter.cs | 24 +-
.../Filtering/FilterDataTypeTests.cs | 9 +-
.../Filtering/FilterOperatorTests.cs | 227 ++++++++++++++----
.../RuntimeTypeConverterTests.cs | 164 +++++++++++++
.../Internal/RuntimeTypeConverterTests.cs | 147 ------------
5 files changed, 368 insertions(+), 203 deletions(-)
create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs
delete mode 100644 test/UnitTests/Internal/RuntimeTypeConverterTests.cs
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs
index d79ed4c635..a57d07acaa 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs
@@ -8,10 +8,26 @@ namespace JsonApiDotNetCore.Resources.Internal;
[PublicAPI]
public static class RuntimeTypeConverter
{
+ private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture";
+
public static object? ConvertType(object? value, Type type)
{
ArgumentGuard.NotNull(type);
+ // Earlier versions of JsonApiDotNetCore failed to pass CultureInfo.InvariantCulture in the parsing below, which resulted in the 'current'
+ // culture being used. Unlike parsing JSON request/response bodies, this effectively meant that query strings were parsed based on the
+ // OS-level regional settings of the web server.
+ // Because this was fixed in a non-major release, the switch below enables to revert to the old behavior.
+
+ // With the switch activated, API developers can still choose between:
+ // - Requiring localized date/number formats: parsing occurs using the OS-level regional settings (the default).
+ // - Requiring culture-invariant date/number formats: requires setting CultureInfo.DefaultThreadCurrentCulture to CultureInfo.InvariantCulture at startup.
+ // - Allowing clients to choose by sending an Accept-Language HTTP header: requires app.UseRequestLocalization() at startup.
+
+ CultureInfo? cultureInfo = AppContext.TryGetSwitch(ParseQueryStringsUsingCurrentCultureSwitchName, out bool useCurrentCulture) && useCurrentCulture
+ ? null
+ : CultureInfo.InvariantCulture;
+
if (value == null)
{
if (!CanContainNull(type))
@@ -50,19 +66,19 @@ public static class RuntimeTypeConverter
if (nonNullableType == typeof(DateTime))
{
- DateTime convertedValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind);
+ DateTime convertedValue = DateTime.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind);
return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue;
}
if (nonNullableType == typeof(DateTimeOffset))
{
- DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, null, DateTimeStyles.RoundtripKind);
+ DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind);
return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue;
}
if (nonNullableType == typeof(TimeSpan))
{
- TimeSpan convertedValue = TimeSpan.Parse(stringValue);
+ TimeSpan convertedValue = TimeSpan.Parse(stringValue, cultureInfo);
return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue;
}
@@ -75,7 +91,7 @@ public static class RuntimeTypeConverter
}
// https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html
- return Convert.ChangeType(stringValue, nonNullableType);
+ return Convert.ChangeType(stringValue, nonNullableType, cultureInfo);
}
catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException)
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
index 402e6f86af..2308665642 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Net;
using System.Reflection;
using System.Text.Json.Serialization;
@@ -60,7 +61,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
});
string attributeName = propertyName.Camelize();
- string route = $"/filterableResources?filter=equals({attributeName},'{propertyValue}')";
+ string? attributeValue = Convert.ToString(propertyValue, CultureInfo.InvariantCulture);
+
+ string route = $"/filterableResources?filter=equals({attributeName},'{attributeValue}')";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
@@ -88,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});
- string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal}')";
+ string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal.ToString(CultureInfo.InvariantCulture)}')";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
@@ -232,7 +235,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});
- string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan}')";
+ string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan:c}')";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
index 6d000f6433..a135fd569d 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
@@ -15,6 +15,26 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering;
public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>>
{
+ private const string IntLowerBound = "19";
+ private const string IntInTheRange = "20";
+ private const string IntUpperBound = "21";
+
+ private const string DoubleLowerBound = "1.9";
+ private const string DoubleInTheRange = "2.0";
+ private const string DoubleUpperBound = "2.1";
+
+ private const string IsoDateTimeLowerBound = "2000-11-22T09:48:17";
+ private const string IsoDateTimeInTheRange = "2000-11-22T12:34:56";
+ private const string IsoDateTimeUpperBound = "2000-11-22T18:47:32";
+
+ private const string InvariantDateTimeLowerBound = "11/22/2000 9:48:17";
+ private const string InvariantDateTimeInTheRange = "11/22/2000 12:34:56";
+ private const string InvariantDateTimeUpperBound = "11/22/2000 18:47:32";
+
+ private const string TimeSpanLowerBound = "2:15:28:54.997";
+ private const string TimeSpanInTheRange = "2:15:51:42.397";
+ private const string TimeSpanUpperBound = "2:16:22:41.736";
+
private readonly IntegrationTestContext, FilterDbContext> _testContext;
public FilterOperatorTests(IntegrationTestContext, FilterDbContext> testContext)
@@ -257,25 +277,26 @@ public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types
}
[Theory]
- [InlineData(19, 21, ComparisonOperator.LessThan, 20)]
- [InlineData(19, 21, ComparisonOperator.LessThan, 21)]
- [InlineData(19, 21, ComparisonOperator.LessOrEqual, 20)]
- [InlineData(19, 21, ComparisonOperator.LessOrEqual, 19)]
- [InlineData(21, 19, ComparisonOperator.GreaterThan, 20)]
- [InlineData(21, 19, ComparisonOperator.GreaterThan, 19)]
- [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 20)]
- [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 21)]
- public async Task Can_filter_comparison_on_whole_number(int matchingValue, int nonMatchingValue, ComparisonOperator filterOperator, double filterValue)
+ [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessThan, IntInTheRange)]
+ [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessThan, IntUpperBound)]
+ [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessOrEqual, IntInTheRange)]
+ [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessOrEqual, IntLowerBound)]
+ [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterThan, IntInTheRange)]
+ [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterThan, IntLowerBound)]
+ [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterOrEqual, IntInTheRange)]
+ [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterOrEqual, IntUpperBound)]
+ public async Task Can_filter_comparison_on_whole_number(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator,
+ string filterValue)
{
// Arrange
var resource = new FilterableResource
{
- SomeInt32 = matchingValue
+ SomeInt32 = int.Parse(matchingValue, CultureInfo.InvariantCulture)
};
var otherResource = new FilterableResource
{
- SomeInt32 = nonMatchingValue
+ SomeInt32 = int.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
};
await _testContext.RunOnDatabaseAsync(async dbContext =>
@@ -298,26 +319,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
}
[Theory]
- [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.0)]
- [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.1)]
- [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 2.0)]
- [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 1.9)]
- [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 2.0)]
- [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 1.9)]
- [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.0)]
- [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.1)]
- public async Task Can_filter_comparison_on_fractional_number(double matchingValue, double nonMatchingValue, ComparisonOperator filterOperator,
- double filterValue)
+ [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessThan, DoubleInTheRange)]
+ [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessThan, DoubleUpperBound)]
+ [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessOrEqual, DoubleInTheRange)]
+ [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessOrEqual, DoubleLowerBound)]
+ [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterThan, DoubleInTheRange)]
+ [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterThan, DoubleLowerBound)]
+ [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterOrEqual, DoubleInTheRange)]
+ [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterOrEqual, DoubleUpperBound)]
+ public async Task Can_filter_comparison_on_fractional_number(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator,
+ string filterValue)
{
// Arrange
var resource = new FilterableResource
{
- SomeDouble = matchingValue
+ SomeDouble = double.Parse(matchingValue, CultureInfo.InvariantCulture)
};
var otherResource = new FilterableResource
{
- SomeDouble = nonMatchingValue
+ SomeDouble = double.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
};
await _testContext.RunOnDatabaseAsync(async dbContext =>
@@ -340,26 +361,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
}
[Theory]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05")]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09")]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05")]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09")]
- public async Task Can_filter_comparison_on_DateTime_in_local_time_zone(string matchingDateTime, string nonMatchingDateTime,
- ComparisonOperator filterOperator, string filterDateTime)
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)]
+ public async Task Can_filter_comparison_on_DateTime_in_local_time_zone(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator,
+ string filterValue)
{
// Arrange
var resource = new FilterableResource
{
- SomeDateTimeInLocalZone = DateTime.Parse(matchingDateTime, CultureInfo.InvariantCulture).AsLocal()
+ SomeDateTimeInLocalZone = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsLocal()
};
var otherResource = new FilterableResource
{
- SomeDateTimeInLocalZone = DateTime.Parse(nonMatchingDateTime, CultureInfo.InvariantCulture).AsLocal()
+ SomeDateTimeInLocalZone = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsLocal()
};
await _testContext.RunOnDatabaseAsync(async dbContext =>
@@ -369,7 +398,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});
- string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInLocalZone,'{filterDateTime}')";
+ string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInLocalZone,'{filterValue}')";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
@@ -384,26 +413,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
}
[Theory]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05Z")]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09Z")]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05Z")]
- [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01Z")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05Z")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01Z")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05Z")]
- [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09Z")]
- public async Task Can_filter_comparison_on_DateTime_in_UTC_time_zone(string matchingDateTime, string nonMatchingDateTime, ComparisonOperator filterOperator,
- string filterDateTime)
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)]
+ public async Task Can_filter_comparison_on_DateTime_in_UTC_time_zone(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator,
+ string filterValue)
{
// Arrange
var resource = new FilterableResource
{
- SomeDateTimeInUtcZone = DateTime.Parse(matchingDateTime, CultureInfo.InvariantCulture).AsUtc()
+ SomeDateTimeInUtcZone = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsUtc()
};
var otherResource = new FilterableResource
{
- SomeDateTimeInUtcZone = DateTime.Parse(nonMatchingDateTime, CultureInfo.InvariantCulture).AsUtc()
+ SomeDateTimeInUtcZone = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsUtc()
};
await _testContext.RunOnDatabaseAsync(async dbContext =>
@@ -413,7 +450,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});
- string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInUtcZone,'{filterDateTime}')";
+ string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInUtcZone,'{filterValue}Z')";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
@@ -427,6 +464,98 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
.With(value => value.Should().Be(resource.SomeDateTimeInUtcZone));
}
+ [Theory]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)]
+ [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)]
+ [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)]
+ public async Task Can_filter_comparison_on_DateTimeOffset(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator,
+ string filterValue)
+ {
+ // Arrange
+ var resource = new FilterableResource
+ {
+ SomeDateTimeOffset = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsUtc()
+ };
+
+ var otherResource = new FilterableResource
+ {
+ SomeDateTimeOffset = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsUtc()
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.FilterableResources.AddRange(resource, otherResource);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeOffset,'{filterValue}Z')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.ManyValue.ShouldHaveCount(1);
+
+ responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeOffset").With(value => value.Should().Be(resource.SomeDateTimeOffset));
+ }
+
+ [Theory]
+ [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessThan, TimeSpanInTheRange)]
+ [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessThan, TimeSpanUpperBound)]
+ [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessOrEqual, TimeSpanInTheRange)]
+ [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessOrEqual, TimeSpanLowerBound)]
+ [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterThan, TimeSpanInTheRange)]
+ [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterThan, TimeSpanLowerBound)]
+ [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterOrEqual, TimeSpanInTheRange)]
+ [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterOrEqual, TimeSpanUpperBound)]
+ public async Task Can_filter_comparison_on_TimeSpan(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue)
+ {
+ // Arrange
+ var resource = new FilterableResource
+ {
+ SomeTimeSpan = TimeSpan.Parse(matchingValue, CultureInfo.InvariantCulture)
+ };
+
+ var otherResource = new FilterableResource
+ {
+ SomeTimeSpan = TimeSpan.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.FilterableResources.AddRange(resource, otherResource);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeSpan,'{filterValue}')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.ManyValue.ShouldHaveCount(1);
+ responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
+ }
+
[Theory]
[InlineData("The fox jumped over the lazy dog", "Other", TextMatchKind.Contains, "jumped")]
[InlineData("The fox jumped over the lazy dog", "the fox...", TextMatchKind.Contains, "The")]
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs
new file mode 100644
index 0000000000..e60dcefe64
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs
@@ -0,0 +1,164 @@
+using FluentAssertions;
+using JsonApiDotNetCore.Resources.Internal;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.UnitTests.TypeConversion;
+
+public sealed class RuntimeTypeConverterTests
+{
+ [Theory]
+ [InlineData(typeof(bool))]
+ [InlineData(typeof(byte))]
+ [InlineData(typeof(sbyte))]
+ [InlineData(typeof(char))]
+ [InlineData(typeof(short))]
+ [InlineData(typeof(ushort))]
+ [InlineData(typeof(int))]
+ [InlineData(typeof(uint))]
+ [InlineData(typeof(long))]
+ [InlineData(typeof(ulong))]
+ [InlineData(typeof(float))]
+ [InlineData(typeof(double))]
+ [InlineData(typeof(decimal))]
+ [InlineData(typeof(Guid))]
+ [InlineData(typeof(DateTime))]
+ [InlineData(typeof(DateTimeOffset))]
+ [InlineData(typeof(TimeSpan))]
+ [InlineData(typeof(DayOfWeek))]
+ public void Cannot_convert_null_to_value_type(Type type)
+ {
+ // Act
+ Action action = () => RuntimeTypeConverter.ConvertType(null, type);
+
+ // Assert
+ action.Should().ThrowExactly().WithMessage($"Failed to convert 'null' to type '{type.Name}'.");
+ }
+
+ [Theory]
+ [InlineData(typeof(bool?))]
+ [InlineData(typeof(byte?))]
+ [InlineData(typeof(sbyte?))]
+ [InlineData(typeof(char?))]
+ [InlineData(typeof(short?))]
+ [InlineData(typeof(ushort?))]
+ [InlineData(typeof(int?))]
+ [InlineData(typeof(uint?))]
+ [InlineData(typeof(long?))]
+ [InlineData(typeof(ulong?))]
+ [InlineData(typeof(float?))]
+ [InlineData(typeof(double?))]
+ [InlineData(typeof(decimal?))]
+ [InlineData(typeof(Guid?))]
+ [InlineData(typeof(DateTime?))]
+ [InlineData(typeof(DateTimeOffset?))]
+ [InlineData(typeof(TimeSpan?))]
+ [InlineData(typeof(DayOfWeek?))]
+ [InlineData(typeof(string))]
+ [InlineData(typeof(IFace))]
+ [InlineData(typeof(BaseType))]
+ [InlineData(typeof(DerivedType))]
+ public void Can_convert_null_to_nullable_type(Type type)
+ {
+ // Act
+ object? result = RuntimeTypeConverter.ConvertType(null, type);
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void Returns_same_instance_for_exact_type()
+ {
+ // Arrange
+ var instance = new DerivedType();
+ Type type = typeof(DerivedType);
+
+ // Act
+ object? result = RuntimeTypeConverter.ConvertType(instance, type);
+
+ // Assert
+ result.Should().Be(instance);
+ }
+
+ [Fact]
+ public void Returns_same_instance_for_base_type()
+ {
+ // Arrange
+ var instance = new DerivedType();
+ Type type = typeof(BaseType);
+
+ // Act
+ object? result = RuntimeTypeConverter.ConvertType(instance, type);
+
+ // Assert
+ result.Should().Be(instance);
+ }
+
+ [Fact]
+ public void Returns_same_instance_for_interface()
+ {
+ // Arrange
+ var instance = new DerivedType();
+ Type type = typeof(IFace);
+
+ // Act
+ object? result = RuntimeTypeConverter.ConvertType(instance, type);
+
+ // Assert
+ result.Should().Be(instance);
+ }
+
+ [Theory]
+ [InlineData(typeof(bool), default(bool))]
+ [InlineData(typeof(bool?), null)]
+ [InlineData(typeof(byte), default(byte))]
+ [InlineData(typeof(byte?), null)]
+ [InlineData(typeof(sbyte), default(sbyte))]
+ [InlineData(typeof(sbyte?), null)]
+ [InlineData(typeof(char), default(char))]
+ [InlineData(typeof(char?), null)]
+ [InlineData(typeof(short), default(short))]
+ [InlineData(typeof(short?), null)]
+ [InlineData(typeof(ushort), default(ushort))]
+ [InlineData(typeof(ushort?), null)]
+ [InlineData(typeof(int), default(int))]
+ [InlineData(typeof(int?), null)]
+ [InlineData(typeof(uint), default(uint))]
+ [InlineData(typeof(uint?), null)]
+ [InlineData(typeof(long), default(long))]
+ [InlineData(typeof(long?), null)]
+ [InlineData(typeof(ulong), default(ulong))]
+ [InlineData(typeof(ulong?), null)]
+ [InlineData(typeof(float), default(float))]
+ [InlineData(typeof(float?), null)]
+ [InlineData(typeof(double), default(double))]
+ [InlineData(typeof(double?), null)]
+ [InlineData(typeof(decimal), 0)]
+ [InlineData(typeof(decimal?), null)]
+ [InlineData(typeof(DayOfWeek), DayOfWeek.Sunday)]
+ [InlineData(typeof(DayOfWeek?), null)]
+ [InlineData(typeof(string), "")]
+ [InlineData(typeof(IFace), null)]
+ [InlineData(typeof(BaseType), null)]
+ [InlineData(typeof(DerivedType), null)]
+ public void Returns_default_value_for_empty_string(Type type, object expectedValue)
+ {
+ // Act
+ object? result = RuntimeTypeConverter.ConvertType(string.Empty, type);
+
+ // Assert
+ result.Should().Be(expectedValue);
+ }
+
+ private interface IFace
+ {
+ }
+
+ private class BaseType : IFace
+ {
+ }
+
+ private sealed class DerivedType : BaseType
+ {
+ }
+}
diff --git a/test/UnitTests/Internal/RuntimeTypeConverterTests.cs b/test/UnitTests/Internal/RuntimeTypeConverterTests.cs
deleted file mode 100644
index 5bbac8fc4a..0000000000
--- a/test/UnitTests/Internal/RuntimeTypeConverterTests.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-using FluentAssertions;
-using JsonApiDotNetCore.Resources.Internal;
-using Xunit;
-
-namespace UnitTests.Internal;
-
-public sealed class RuntimeTypeConverterTests
-{
- [Fact]
- public void Can_Convert_DateTimeOffsets()
- {
- // Arrange
- var dateTimeOffset = new DateTimeOffset(new DateTime(2002, 2, 2), TimeSpan.FromHours(4));
- string formattedString = dateTimeOffset.ToString("O");
-
- // Act
- object? result = RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset));
-
- // Assert
- result.Should().Be(dateTimeOffset);
- }
-
- [Fact]
- public void Bad_DateTimeOffset_String_Throws()
- {
- // Arrange
- const string formattedString = "this_is_not_a_valid_dto";
-
- // Act
- Action action = () => RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset));
-
- // Assert
- action.Should().ThrowExactly();
- }
-
- [Fact]
- public void Can_Convert_Enums()
- {
- // Arrange
- const string formattedString = "1";
-
- // Act
- object? result = RuntimeTypeConverter.ConvertType(formattedString, typeof(TestEnum));
-
- // Assert
- result.Should().Be(TestEnum.Test);
- }
-
- [Fact]
- public void ConvertType_Returns_Value_If_Type_Is_Same()
- {
- // Arrange
- var complexType = new ComplexType();
- Type type = complexType.GetType();
-
- // Act
- object? result = RuntimeTypeConverter.ConvertType(complexType, type);
-
- // Assert
- result.Should().Be(complexType);
- }
-
- [Fact]
- public void ConvertType_Returns_Value_If_Type_Is_Assignable()
- {
- // Arrange
- var complexType = new ComplexType();
-
- Type baseType = typeof(BaseType);
- Type iType = typeof(IType);
-
- // Act
- object? baseResult = RuntimeTypeConverter.ConvertType(complexType, baseType);
- object? iResult = RuntimeTypeConverter.ConvertType(complexType, iType);
-
- // Assert
- baseResult.Should().Be(complexType);
- iResult.Should().Be(complexType);
- }
-
- [Fact]
- public void ConvertType_Returns_Default_Value_For_Empty_Strings()
- {
- // Arrange
- var data = new Dictionary
- {
- { typeof(int), 0 },
- { typeof(short), (short)0 },
- { typeof(long), (long)0 },
- { typeof(string), "" },
- { typeof(Guid), Guid.Empty }
- };
-
- foreach ((Type key, object value) in data)
- {
- // Act
- object? result = RuntimeTypeConverter.ConvertType(string.Empty, key);
-
- // Assert
- result.Should().Be(value);
- }
- }
-
- [Fact]
- public void Can_Convert_TimeSpans()
- {
- // Arrange
- TimeSpan timeSpan = TimeSpan.FromMinutes(45);
- string stringSpan = timeSpan.ToString();
-
- // Act
- object? result = RuntimeTypeConverter.ConvertType(stringSpan, typeof(TimeSpan));
-
- // Assert
- result.Should().Be(timeSpan);
- }
-
- [Fact]
- public void Bad_TimeSpanString_Throws()
- {
- // Arrange
- const string formattedString = "this_is_not_a_valid_timespan";
-
- // Act
- Action action = () => RuntimeTypeConverter.ConvertType(formattedString, typeof(TimeSpan));
-
- // Assert
- action.Should().ThrowExactly();
- }
-
- private enum TestEnum
- {
- Test = 1
- }
-
- private sealed class ComplexType : BaseType
- {
- }
-
- private class BaseType : IType
- {
- }
-
- private interface IType
- {
- }
-}
From 9d17ce18e1805abc6cb343c2e426507cab580bef Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 4 Feb 2023 16:47:44 +0100
Subject: [PATCH 22/22] Add support for DateOnly/TimeOnly
---
.../Internal/RuntimeTypeConverter.cs | 12 +++
.../ModelState/ModelStateFakers.cs | 11 +-
.../ModelState/ModelStateValidationTests.cs | 54 ++++++++++
.../InputValidation/ModelState/SystemFile.cs | 8 ++
.../Filtering/FilterDataTypeTests.cs | 64 +++++++++++
.../Filtering/FilterOperatorTests.cs | 102 ++++++++++++++++++
.../Filtering/FilterableResource.cs | 12 +++
.../JsonApiDotNetCoreTests.csproj | 4 +-
8 files changed, 265 insertions(+), 2 deletions(-)
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs
index a57d07acaa..c76aa09b82 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs
@@ -82,6 +82,18 @@ public static class RuntimeTypeConverter
return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue;
}
+ if (nonNullableType == typeof(DateOnly))
+ {
+ DateOnly convertedValue = DateOnly.Parse(stringValue, cultureInfo);
+ return isNullableTypeRequested ? (DateOnly?)convertedValue : convertedValue;
+ }
+
+ if (nonNullableType == typeof(TimeOnly))
+ {
+ TimeOnly convertedValue = TimeOnly.Parse(stringValue, cultureInfo);
+ return isNullableTypeRequested ? (TimeOnly?)convertedValue : convertedValue;
+ }
+
if (nonNullableType.IsEnum)
{
object convertedValue = Enum.Parse(nonNullableType, stringValue);
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs
index 7d7d7d1acf..a16e9a97b7 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Bogus;
using TestBuildingBlocks;
@@ -8,6 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState;
internal sealed class ModelStateFakers : FakerContainer
{
+ private static readonly DateOnly MinCreatedOn = DateOnly.Parse("2000-01-01", CultureInfo.InvariantCulture);
+ private static readonly DateOnly MaxCreatedOn = DateOnly.Parse("2050-01-01", CultureInfo.InvariantCulture);
+
+ private static readonly TimeOnly MinCreatedAt = TimeOnly.Parse("09:00:00", CultureInfo.InvariantCulture);
+ private static readonly TimeOnly MaxCreatedAt = TimeOnly.Parse("17:30:00", CultureInfo.InvariantCulture);
+
private readonly Lazy> _lazySystemVolumeFaker = new(() =>
new Faker()
.UseSeed(GetFakerSeed())
@@ -18,7 +25,9 @@ internal sealed class ModelStateFakers : FakerContainer
.UseSeed(GetFakerSeed())
.RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName())
.RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly))
- .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)));
+ .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))
+ .RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn))
+ .RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt)));
private readonly Lazy> _lazySystemDirectoryFaker = new(() =>
new Faker()
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs
index b114962bd3..5a0738b0a9 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs
@@ -1,6 +1,7 @@
using System.Net;
using FluentAssertions;
using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
using Xunit;
@@ -17,6 +18,12 @@ public ModelStateValidationTests(IntegrationTestContext();
testContext.UseController();
+
+ testContext.ConfigureServicesBeforeStartup(services =>
+ {
+ // Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation.
+ services.AddDateOnlyTimeOnlyStringConverters();
+ });
}
[Fact]
@@ -123,6 +130,53 @@ public async Task Cannot_create_resource_with_invalid_attribute_value()
error.Source.Pointer.Should().Be("/data/attributes/directoryName");
}
+ [Fact]
+ public async Task Cannot_create_resource_with_invalid_DateOnly_TimeOnly_attribute_value()
+ {
+ // Arrange
+ SystemFile newFile = _fakers.SystemFile.Generate();
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "systemFiles",
+ attributes = new
+ {
+ fileName = newFile.FileName,
+ attributes = newFile.Attributes,
+ sizeInBytes = newFile.SizeInBytes,
+ createdOn = DateOnly.MinValue,
+ createdAt = TimeOnly.MinValue
+ }
+ }
+ };
+
+ const string route = "/systemFiles";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
+
+ responseDocument.Errors.ShouldHaveCount(2);
+
+ ErrorObject error1 = responseDocument.Errors[0];
+ error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ error1.Title.Should().Be("Input validation failed.");
+ error1.Detail.Should().StartWith("The field CreatedAt must be between ");
+ error1.Source.ShouldNotBeNull();
+ error1.Source.Pointer.Should().Be("/data/attributes/createdAt");
+
+ ErrorObject error2 = responseDocument.Errors[1];
+ error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ error2.Title.Should().Be("Input validation failed.");
+ error2.Detail.Should().StartWith("The field CreatedOn must be between ");
+ error2.Source.ShouldNotBeNull();
+ error2.Source.Pointer.Should().Be("/data/attributes/createdOn");
+ }
+
[Fact]
public async Task Can_create_resource_with_valid_attribute_value()
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs
index a5515922e4..2fa659b6ef 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs
@@ -20,4 +20,12 @@ public sealed class SystemFile : Identifiable
[Attr]
[Range(typeof(long), "1", "9223372036854775807")]
public long SizeInBytes { get; set; }
+
+ [Attr]
+ [Range(typeof(DateOnly), "2000-01-01", "2050-01-01")]
+ public DateOnly CreatedOn { get; set; }
+
+ [Attr]
+ [Range(typeof(TimeOnly), "09:00:00", "17:30:00")]
+ public TimeOnly CreatedAt { get; set; }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
index 2308665642..b464a15083 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
@@ -247,6 +247,62 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
}
+ [Fact]
+ public async Task Can_filter_equality_on_type_DateOnly()
+ {
+ // Arrange
+ var resource = new FilterableResource
+ {
+ SomeDateOnly = DateOnly.FromDateTime(27.January(2003))
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.FilterableResources.AddRange(resource, new FilterableResource());
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/filterableResources?filter=equals(someDateOnly,'{resource.SomeDateOnly:O}')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.ManyValue.ShouldHaveCount(1);
+ responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly));
+ }
+
+ [Fact]
+ public async Task Can_filter_equality_on_type_TimeOnly()
+ {
+ // Arrange
+ var resource = new FilterableResource
+ {
+ SomeTimeOnly = new TimeOnly(23, 59, 59, 999)
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.FilterableResources.AddRange(resource, new FilterableResource());
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/filterableResources?filter=equals(someTimeOnly,'{resource.SomeTimeOnly:O}')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.ManyValue.ShouldHaveCount(1);
+ responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly));
+ }
+
[Fact]
public async Task Cannot_filter_equality_on_incompatible_value()
{
@@ -291,6 +347,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
+ [InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
+ [InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
public async Task Can_filter_is_null_on_type(string propertyName)
{
@@ -311,6 +369,8 @@ public async Task Can_filter_is_null_on_type(string propertyName)
SomeNullableDateTime = 1.January(2001).AsUtc(),
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
SomeNullableTimeSpan = TimeSpan.FromHours(1),
+ SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
+ SomeNullableTimeOnly = new TimeOnly(1, 0),
SomeNullableEnum = DayOfWeek.Friday
};
@@ -345,6 +405,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
+ [InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
+ [InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
public async Task Can_filter_is_not_null_on_type(string propertyName)
{
@@ -361,6 +423,8 @@ public async Task Can_filter_is_not_null_on_type(string propertyName)
SomeNullableDateTime = 1.January(2001).AsUtc(),
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
SomeNullableTimeSpan = TimeSpan.FromHours(1),
+ SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
+ SomeNullableTimeOnly = new TimeOnly(1, 0),
SomeNullableEnum = DayOfWeek.Friday
};
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
index a135fd569d..b9fd8e2b2a 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
@@ -35,6 +35,18 @@ public sealed class FilterOperatorTests : IClassFixture, FilterDbContext> _testContext;
public FilterOperatorTests(IntegrationTestContext, FilterDbContext> testContext)
@@ -556,6 +568,96 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
}
+ [Theory]
+ [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyInTheRange)]
+ [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyUpperBound)]
+ [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyInTheRange)]
+ [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyLowerBound)]
+ [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyInTheRange)]
+ [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyLowerBound)]
+ [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyInTheRange)]
+ [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyUpperBound)]
+ [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyInTheRange)]
+ [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyUpperBound)]
+ [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyInTheRange)]
+ [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyLowerBound)]
+ [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyInTheRange)]
+ [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyLowerBound)]
+ [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyInTheRange)]
+ [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyUpperBound)]
+ public async Task Can_filter_comparison_on_DateOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue)
+ {
+ // Arrange
+ var resource = new FilterableResource
+ {
+ SomeDateOnly = DateOnly.Parse(matchingValue, CultureInfo.InvariantCulture)
+ };
+
+ var otherResource = new FilterableResource
+ {
+ SomeDateOnly = DateOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.FilterableResources.AddRange(resource, otherResource);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateOnly,'{filterValue}')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.ManyValue.ShouldHaveCount(1);
+ responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly));
+ }
+
+ [Theory]
+ [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyInTheRange)]
+ [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyUpperBound)]
+ [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyInTheRange)]
+ [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyLowerBound)]
+ [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyInTheRange)]
+ [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyLowerBound)]
+ [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyInTheRange)]
+ [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyUpperBound)]
+ public async Task Can_filter_comparison_on_TimeOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue)
+ {
+ // Arrange
+ var resource = new FilterableResource
+ {
+ SomeTimeOnly = TimeOnly.Parse(matchingValue, CultureInfo.InvariantCulture)
+ };
+
+ var otherResource = new FilterableResource
+ {
+ SomeTimeOnly = TimeOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture)
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.FilterableResources.AddRange(resource, otherResource);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeOnly,'{filterValue}')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.ManyValue.ShouldHaveCount(1);
+ responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly));
+ }
+
[Theory]
[InlineData("The fox jumped over the lazy dog", "Other", TextMatchKind.Contains, "jumped")]
[InlineData("The fox jumped over the lazy dog", "the fox...", TextMatchKind.Contains, "The")]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs
index dbc0323e9c..52b0ddbdd9 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs
@@ -77,6 +77,18 @@ public sealed class FilterableResource : Identifiable
[Attr]
public TimeSpan? SomeNullableTimeSpan { get; set; }
+ [Attr]
+ public DateOnly SomeDateOnly { get; set; }
+
+ [Attr]
+ public DateOnly? SomeNullableDateOnly { get; set; }
+
+ [Attr]
+ public TimeOnly SomeTimeOnly { get; set; }
+
+ [Attr]
+ public TimeOnly? SomeNullableTimeOnly { get; set; }
+
[Attr]
public DayOfWeek SomeEnum { get; set; }
diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj
index 22d50630ca..3bd3632461 100644
--- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj
+++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj
@@ -1,4 +1,4 @@
-
+$(TargetFrameworkName)
@@ -11,9 +11,11 @@
+
+ pFad - Phonifier reborn
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.