From 6c11baed406f07e213ffafc0d57a7eeb1ef78a01 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Fri, 30 Sep 2022 18:33:15 +0200
Subject: [PATCH 1/8] Add missing CDATA sections to code fragments
---
.../Resources/Annotations/AttrAttribute.cs | 4 ++--
.../TypeWithAttributeSyntaxReceiver.cs | 3 +--
.../Configuration/IJsonApiOptions.cs | 12 +++++++-----
src/JsonApiDotNetCore/Configuration/TypeLocator.cs | 4 ++--
.../JsonConverters/WriteOnlyDocumentConverter.cs | 2 +-
.../WriteOnlyRelationshipObjectConverter.cs | 2 +-
6 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
index d3d6133f6e..448d6e8ab2 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
@@ -18,13 +18,13 @@ public sealed class AttrAttribute : ResourceFieldAttribute
/// is used.
///
///
- ///
+ ///
+ /// ]]>
///
public AttrCapabilities Capabilities
{
diff --git a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs
index b23de19cc9..ad7b0d6ad5 100644
--- a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs
+++ b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs
@@ -22,8 +22,7 @@ namespace JsonApiDotNetCore.SourceGenerators;
///
/// [AlternateTypeName]
/// public class ExampleResource4 : Identifiable { }
-/// ]]>
-///
+/// ]]>
///
internal sealed class TypeWithAttributeSyntaxReceiver : ISyntaxReceiver
{
diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
index 597d22294d..3c32594ea8 100644
--- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
+++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
@@ -14,7 +14,9 @@ public interface IJsonApiOptions
/// The URL prefix to use for exposed endpoints.
///
///
- /// options.Namespace = "api/v1";
+ ///
///
string? Namespace { get; }
@@ -42,10 +44,10 @@ public interface IJsonApiOptions
/// Use relative links for all resources. False by default.
///
///
- ///
+ ///
- ///
+ /// ]]>
+ ///
+ /// ]]>
///
bool UseRelativeLinks { get; }
diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs
index e4b1da8d01..981c50c4e4 100644
--- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs
+++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs
@@ -147,9 +147,9 @@ public IReadOnlyCollection GetDerivedTypesForUnboundType(Assembly assembly
/// The inherited type.
///
///
- ///
+ ///
+ /// ]]>
///
public IEnumerable GetDerivedTypes(Assembly assembly, Type baseType)
{
diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs
index 623857f5ff..a48b8bd4be 100644
--- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs
+++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs
@@ -25,7 +25,7 @@ public override Document Read(ref Utf8JsonReader reader, Type typeToConvert, Jso
}
///
- /// Conditionally writes "data": null
or omits it, depending on .
+ /// Conditionally writes
or omits it, depending on .
///
public override void Write(Utf8JsonWriter writer, Document value, JsonSerializerOptions options)
{
diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs
index 047e0737c5..b740642868 100644
--- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs
+++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs
@@ -20,7 +20,7 @@ public override RelationshipObject Read(ref Utf8JsonReader reader, Type typeToCo
}
///
- /// Conditionally writes "data": null
or omits it, depending on .
+ /// Conditionally writes
or omits it, depending on .
///
public override void Write(Utf8JsonWriter writer, RelationshipObject value, JsonSerializerOptions options)
{
From 78b1f511a35d2ed4b28c8483a38fa128f671db77 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 1 Oct 2022 16:18:29 +0200
Subject: [PATCH 2/8] Add capabilities for relationships
---
.../extensibility/resource-definitions.md | 4 +-
docs/usage/resources/attributes.md | 43 ++++---
docs/usage/resources/relationships.md | 106 +++++++++++++++++-
.../Models/TodoItem.cs | 4 +-
.../Resources/Annotations/AttrAttribute.cs | 7 +-
.../Annotations/AttrCapabilities.shared.cs | 19 ++--
.../Resources/Annotations/HasManyAttribute.cs | 45 ++++++++
.../HasManyAttribute.netstandard.cs | 2 +
.../Annotations/HasManyCapabilities.shared.cs | 51 +++++++++
.../Resources/Annotations/HasOneAttribute.cs | 45 ++++++++
.../HasOneAttribute.netstandard.cs | 2 +
.../Annotations/HasOneCapabilities.shared.cs | 35 ++++++
.../Annotations/RelationshipAttribute.cs | 27 +++--
.../RelationshipAttribute.netstandard.cs | 1 +
.../Annotations/ResourceFieldAttribute.cs | 2 +-
.../Configuration/IJsonApiOptions.cs | 12 +-
.../Configuration/JsonApiOptions.cs | 6 +
.../Configuration/ResourceGraphBuilder.cs | 47 ++++++++
.../Queries/Internal/Parsing/IncludeParser.cs | 4 +-
.../Queries/Internal/SparseFieldSetCache.cs | 6 +-
.../FilterQueryStringParameterReader.cs | 8 +-
...parseFieldSetQueryStringParameterReader.cs | 8 +-
.../JsonApiQueryStringParameters.cs | 8 +-
.../Annotations/CapabilitiesExtensions.cs | 45 ++++++++
.../Adapters/AtomicReferenceAdapter.cs | 1 +
...tInResourceOrRelationshipRequestAdapter.cs | 1 +
.../Adapters/ResourceIdentityAdapter.cs | 49 ++++++++
.../Request/Adapters/ResourceObjectAdapter.cs | 9 +-
.../Response/ResponseModelAdapter.cs | 7 ++
test/AnnotationTests/Models/TreeNode.cs | 4 +-
.../Creating/AtomicCreateResourceTests.cs | 84 +++++++-------
...eateResourceWithToManyRelationshipTests.cs | 52 +++++++++
...reateResourceWithToOneRelationshipTests.cs | 49 ++++++++
.../AtomicOperations/Lyric.cs | 2 +-
.../AtomicOperations/MusicTrack.cs | 2 +-
.../AtomicAddToToManyRelationshipTests.cs | 53 +++++++++
...AtomicRemoveFromToManyRelationshipTests.cs | 53 +++++++++
.../AtomicReplaceToManyRelationshipTests.cs | 53 +++++++++
.../AtomicUpdateToOneRelationshipTests.cs | 53 +++++++++
.../AtomicReplaceToManyRelationshipTests.cs | 61 ++++++++++
.../Resources/AtomicUpdateResourceTests.cs | 102 ++++++++---------
.../AtomicUpdateToOneRelationshipTests.cs | 58 ++++++++++
.../IntegrationTests/QueryStrings/BlogPost.cs | 2 +
.../IntegrationTests/QueryStrings/Calendar.cs | 5 +-
.../QueryStrings/Filtering/FilterTests.cs | 23 ++++
.../QueryStrings/Includes/IncludeTests.cs | 84 +++++++++++++-
.../SerializerIgnoreConditionTests.cs | 10 +-
.../SparseFieldSets/SparseFieldSetTests.cs | 49 ++++++++
.../QueryStrings/WebAccount.cs | 4 +-
.../ReadWrite/Creating/CreateResourceTests.cs | 70 ++++++------
...eateResourceWithToManyRelationshipTests.cs | 46 ++++++++
...reateResourceWithToOneRelationshipTests.cs | 42 +++++++
.../AddToToManyRelationshipTests.cs | 43 +++++++
.../RemoveFromToManyRelationshipTests.cs | 43 +++++++
.../ReplaceToManyRelationshipTests.cs | 43 +++++++
.../UpdateToOneRelationshipTests.cs | 39 +++++++
.../ReplaceToManyRelationshipTests.cs | 55 +++++++++
.../Updating/Resources/UpdateResourceTests.cs | 88 +++++++--------
.../Resources/UpdateToOneRelationshipTests.cs | 51 +++++++++
.../IntegrationTests/ReadWrite/WorkItem.cs | 4 +-
.../ReadWrite/WorkItemGroup.cs | 2 +-
61 files changed, 1682 insertions(+), 251 deletions(-)
create mode 100644 src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs
create mode 100644 src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs
create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs
diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md
index af4f8a27c5..6bc16b869e 100644
--- a/docs/usage/extensibility/resource-definitions.md
+++ b/docs/usage/extensibility/resource-definitions.md
@@ -34,10 +34,10 @@ from Entity Framework Core `IQueryable` execution.
### Excluding fields
-There are some cases where you want attributes (or relationships) conditionally excluded from your resource response.
+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 attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]` on a resource class property.
+**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
diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md
index 669dba0892..77c6ff9566 100644
--- a/docs/usage/resources/attributes.md
+++ b/docs/usage/resources/attributes.md
@@ -43,9 +43,10 @@ options.DefaultAttrCapabilities = AttrCapabilities.None; // default: All
This can be overridden per attribute.
-### Viewability
+### AllowView
-Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response.
+Indicates whether the attribute value can be returned in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response.
+Otherwise, the attribute is silently omitted.
```c#
#nullable enable
@@ -57,45 +58,59 @@ public class User : Identifiable
}
```
-### Creatability
+### AllowFilter
-Attributes can be marked as creatable, which will allow `POST` requests to assign a value to them. When sent but not allowed, an HTTP 422 response is returned.
+Indicates whether the attribute can be filtered on. When not allowed and used in `?filter=`, an HTTP 400 is returned.
```c#
#nullable enable
public class Person : Identifiable
{
- [Attr(Capabilities = AttrCapabilities.AllowCreate)]
- public string? CreatorName { get; set; }
+ [Attr(Capabilities = AttrCapabilities.AllowFilter)]
+ public string? FirstName { get; set; }
}
```
-### Changeability
+### AllowSort
-Attributes can be marked as changeable, which will allow `PATCH` requests to update them. When sent but not allowed, an HTTP 422 response is returned.
+Indicates whether the attribute can be sorted on. When not allowed and used in `?sort=`, an HTTP 400 is returned.
```c#
#nullable enable
public class Person : Identifiable
{
- [Attr(Capabilities = AttrCapabilities.AllowChange)]
- public string? FirstName { get; set; };
+ [Attr(Capabilities = ~AttrCapabilities.AllowSort)]
+ public string? FirstName { get; set; }
}
```
-### Filter/Sort-ability
+### AllowCreate
-Attributes can be marked to allow filtering and/or sorting. When not allowed, it results in an HTTP 400 response.
+Indicates whether POST requests can assign the attribute value. When sent but not allowed, an HTTP 422 response is returned.
```c#
#nullable enable
public class Person : Identifiable
{
- [Attr(Capabilities = AttrCapabilities.AllowSort | AttrCapabilities.AllowFilter)]
- public string? FirstName { get; set; }
+ [Attr(Capabilities = AttrCapabilities.AllowCreate)]
+ public string? CreatorName { get; set; }
+}
+```
+
+### AllowChange
+
+Indicates whether PATCH requests can update the attribute value. When sent but not allowed, an HTTP 422 response is returned.
+
+```c#
+#nullable enable
+
+public class Person : Identifiable
+{
+ [Attr(Capabilities = AttrCapabilities.AllowChange)]
+ public string? FirstName { get; set; };
}
```
diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md
index 8776041e98..14ff2eb7f5 100644
--- a/docs/usage/resources/relationships.md
+++ b/docs/usage/resources/relationships.md
@@ -160,7 +160,111 @@ public class TodoItem : Identifiable
}
```
-## Includibility
+## Capabilities
+
+_since v5.1_
+
+Default JSON:API relationship capabilities are specified in
+@JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasOneCapabilities and
+@JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasManyCapabilities:
+
+```c#
+options.DefaultHasOneCapabilities = HasOneCapabilities.None; // default: All
+options.DefaultHasManyCapabilities = HasManyCapabilities.None; // default: All
+```
+
+This can be overridden per relationship.
+
+### AllowView
+
+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 this setting does not affect retrieving the related resources directly.
+
+```c#
+#nullable enable
+
+public class User : Identifiable
+{
+ [HasOne(Capabilities = ~HasOneCapabilities.AllowView)]
+ public LoginAccount Account { get; set; } = null!;
+}
+```
+
+### AllowInclude
+
+Indicates whether the relationship can be included. When not allowed and used in `?include=`, an HTTP 400 is returned.
+
+```c#
+#nullable enable
+
+public class User : Identifiable
+{
+ [HasMany(Capabilities = ~HasManyCapabilities.AllowInclude)]
+ public ISet Groups { get; set; } = new HashSet();
+}
+```
+
+### AllowFilter
+
+For to-many relationships only. Indicates whether it can be used in the `count()` and `has()` filter functions. When not allowed and used in `?filter=`, an HTTP 400 is returned.
+
+```c#
+#nullable enable
+
+public class User : Identifiable
+{
+ [HasMany(Capabilities = HasManyCapabilities.AllowFilter)]
+ public ISet Groups { get; set; } = new HashSet();
+}
+```
+
+### AllowSet
+
+Indicates whether POST and PATCH requests can replace the relationship. When sent but not allowed, an HTTP 422 response is returned.
+
+```c#
+#nullable enable
+
+public class User : Identifiable
+{
+ [HasOne(Capabilities = ~HasOneCapabilities.AllowSet)]
+ public LoginAccount Account { get; set; } = null!;
+}
+```
+
+### AllowAdd
+
+For to-many relationships only. Indicates whether POST requests can add resources to the relationship. When sent but not allowed, an HTTP 422 response is returned.
+
+```c#
+#nullable enable
+
+public class User : Identifiable
+{
+ [HasMany(Capabilities = ~HasManyCapabilities.AllowAdd)]
+ public ISet Groups { get; set; } = new HashSet();
+}
+```
+
+### AllowRemove
+
+For to-many relationships only. Indicates whether DELETE requests can remove resources from the relationship. When sent but not allowed, an HTTP 422 response is returned.
+
+```c#
+#nullable enable
+
+public class User : Identifiable
+{
+ [HasMany(Capabilities = ~HasManyCapabilities.AllowRemove)]
+ public ISet Groups { get; set; } = new HashSet();
+}
+```
+
+## CanInclude
+
+_obsolete since v5.1_
Relationships can be marked to disallow including them using the `?include=` query string parameter. When not allowed, it results in an HTTP 400 response.
diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
index 9be7e6e64e..5fe508f7f2 100644
--- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
+++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
@@ -25,9 +25,9 @@ public sealed class TodoItem : Identifiable
[HasOne]
public Person Owner { get; set; } = null!;
- [HasOne]
+ [HasOne(Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowSet)]
public Person? Assignee { get; set; }
- [HasMany]
+ [HasMany(Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter)]
public ISet Tags { get; set; } = new HashSet();
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
index 448d6e8ab2..7a6cbd960f 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
@@ -14,15 +14,14 @@ public sealed class AttrAttribute : ResourceFieldAttribute
internal bool HasExplicitCapabilities => _capabilities != null;
///
- /// The set of capabilities that are allowed to be performed on this attribute. When not explicitly assigned, the configured default set of capabilities
- /// is used.
+ /// The set of allowed capabilities on this attribute. When not explicitly set, the configured default set of capabilities is used.
///
///
///
/// {
/// [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)]
- /// public string Name { get; set; }
+ /// public string Name { get; set; } = null!;
/// }
/// ]]>
///
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs
index 2812be6d39..0951010b3b 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs
@@ -3,7 +3,7 @@
namespace JsonApiDotNetCore.Resources.Annotations;
///
-/// Indicates capabilities that can be performed on an .
+/// Indicates what can be performed on an .
///
[PublicAPI]
[Flags]
@@ -12,29 +12,32 @@ public enum AttrCapabilities
None = 0,
///
- /// Whether or not GET requests can retrieve the attribute. Attempts to retrieve when disabled will return an HTTP 400 response.
+ /// Whether or not the attribute value can be returned in responses. Attempts to explicitly request it via the fields query string parameter when
+ /// disabled will return an HTTP 400 response. Otherwise, the attribute is silently omitted.
///
AllowView = 1,
///
/// Whether or not POST requests can assign the attribute value. Attempts to assign when disabled will return an HTTP 422 response.
///
- AllowCreate = 2,
+ AllowCreate = 1 << 1,
///
/// Whether or not PATCH requests can update the attribute value. Attempts to update when disabled will return an HTTP 422 response.
///
- AllowChange = 4,
+ AllowChange = 1 << 2,
///
- /// Whether or not an attribute can be filtered on via a query string parameter. Attempts to filter when disabled will return an HTTP 400 response.
+ /// Whether or not the attribute can be filtered on. Attempts to use it in the filter query string parameter when disabled will return an HTTP 400
+ /// response.
///
- AllowFilter = 8,
+ AllowFilter = 1 << 3,
///
- /// Whether or not an attribute can be sorted on via a query string parameter. Attempts to sort when disabled will return an HTTP 400 response.
+ /// Whether or not the attribute can be sorted on. Attempts to use it in the sort query string parameter when disabled will return an HTTP 400
+ /// response.
///
- AllowSort = 16,
+ AllowSort = 1 << 4,
All = AllowView | AllowCreate | AllowChange | AllowFilter | AllowSort
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs
index 39bcf34b3f..5792744d5c 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs
@@ -1,5 +1,7 @@
using JetBrains.Annotations;
+// ReSharper disable NonReadonlyMemberInGetHashCode
+
namespace JsonApiDotNetCore.Resources.Annotations;
///
@@ -20,12 +22,33 @@ namespace JsonApiDotNetCore.Resources.Annotations;
public sealed class HasManyAttribute : RelationshipAttribute
{
private readonly Lazy _lazyIsManyToMany;
+ private HasManyCapabilities? _capabilities;
///
/// Inspects to determine if this is a many-to-many relationship.
///
internal bool IsManyToMany => _lazyIsManyToMany.Value;
+ internal bool HasExplicitCapabilities => _capabilities != null;
+
+ ///
+ /// The set of allowed capabilities on this to-many relationship. When not explicitly set, the configured default set of capabilities is used.
+ ///
+ ///
+ ///
+ /// {
+ /// [HasMany(Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowInclude)]
+ /// public ISet Chapters { get; set; } = new HashSet();
+ /// }
+ /// ]]>
+ ///
+ public HasManyCapabilities Capabilities
+ {
+ get => _capabilities ?? default;
+ set => _capabilities = value;
+ }
+
public HasManyAttribute()
{
_lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly);
@@ -41,4 +64,26 @@ private bool EvaluateIsManyToMany()
return false;
}
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj is null || GetType() != obj.GetType())
+ {
+ return false;
+ }
+
+ var other = (HasManyAttribute)obj;
+
+ return _capabilities == other._capabilities && base.Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(_capabilities, base.GetHashCode());
+ }
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs
index 1cdeb9f62f..cf83f0ce17 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs
@@ -9,4 +9,6 @@ namespace JsonApiDotNetCore.Resources.Annotations;
[AttributeUsage(AttributeTargets.Property)]
public sealed class HasManyAttribute : RelationshipAttribute
{
+ ///
+ public HasManyCapabilities Capabilities { get; set; }
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs
new file mode 100644
index 0000000000..cf65951321
--- /dev/null
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs
@@ -0,0 +1,51 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Resources.Annotations;
+
+///
+/// Indicates what can be performed on a .
+///
+[PublicAPI]
+[Flags]
+public enum HasManyCapabilities
+{
+ None = 0,
+
+ ///
+ /// Whether or not the relationship can be returned in responses. Attempts to explicitly request it via the fields query string parameter when
+ /// disabled will return an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted.
+ ///
+ ///
+ /// Note this setting does not affect retrieving the related resources directly.
+ ///
+ AllowView = 1,
+
+ ///
+ /// Whether or not the relationship can be included. Attempts to use it in the include query string parameter when disabled will return an HTTP
+ /// 400 response.
+ ///
+ AllowInclude = 1 << 1,
+
+ ///
+ /// Whether or not the to-many relationship can be used in the count() and has() functions as part of the filter query string
+ /// parameter. Attempts to use it when disabled will return an HTTP 400 response.
+ ///
+ AllowFilter = 1 << 2,
+
+ ///
+ /// Whether or not POST and PATCH requests can replace the relationship. Attempts to replace when disabled will return an HTTP 422 response.
+ ///
+ AllowSet = 1 << 3,
+
+ ///
+ /// Whether or not POST requests can add to the to-many relationship. Attempts to add when disabled will return an HTTP 422 response.
+ ///
+ AllowAdd = 1 << 4,
+
+ ///
+ /// Whether or not DELETE requests can remove from the to-many relationship. Attempts to remove when disabled will return an HTTP 422 response.
+ ///
+ AllowRemove = 1 << 5,
+
+ All = AllowView | AllowInclude | AllowFilter | AllowSet | AllowAdd | AllowRemove
+}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs
index 0a68f702d3..c0416c92fb 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs
@@ -1,5 +1,7 @@
using JetBrains.Annotations;
+// ReSharper disable NonReadonlyMemberInGetHashCode
+
namespace JsonApiDotNetCore.Resources.Annotations;
///
@@ -19,12 +21,33 @@ namespace JsonApiDotNetCore.Resources.Annotations;
public sealed class HasOneAttribute : RelationshipAttribute
{
private readonly Lazy _lazyIsOneToOne;
+ private HasOneCapabilities? _capabilities;
///
/// Inspects to determine if this is a one-to-one relationship.
///
internal bool IsOneToOne => _lazyIsOneToOne.Value;
+ internal bool HasExplicitCapabilities => _capabilities != null;
+
+ ///
+ /// The set of allowed capabilities on this to-one relationship. When not explicitly set, the configured default set of capabilities is used.
+ ///
+ ///
+ ///
+ /// {
+ /// [HasOne(Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude)]
+ /// public Person? Author { get; set; }
+ /// }
+ /// ]]>
+ ///
+ public HasOneCapabilities Capabilities
+ {
+ get => _capabilities ?? default;
+ set => _capabilities = value;
+ }
+
public HasOneAttribute()
{
_lazyIsOneToOne = new Lazy(EvaluateIsOneToOne, LazyThreadSafetyMode.PublicationOnly);
@@ -40,4 +63,26 @@ private bool EvaluateIsOneToOne()
return false;
}
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj is null || GetType() != obj.GetType())
+ {
+ return false;
+ }
+
+ var other = (HasOneAttribute)obj;
+
+ return _capabilities == other._capabilities && base.Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(_capabilities, base.GetHashCode());
+ }
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs
index 1c16fb01b2..42be2f3c5f 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs
@@ -9,4 +9,6 @@ namespace JsonApiDotNetCore.Resources.Annotations;
[AttributeUsage(AttributeTargets.Property)]
public sealed class HasOneAttribute : RelationshipAttribute
{
+ ///
+ public HasOneCapabilities Capabilities { get; set; }
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs
new file mode 100644
index 0000000000..a001e39407
--- /dev/null
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs
@@ -0,0 +1,35 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Resources.Annotations;
+
+///
+/// Indicates what can be performed on a .
+///
+[PublicAPI]
+[Flags]
+public enum HasOneCapabilities
+{
+ None = 0,
+
+ ///
+ /// Whether or not the relationship can be returned in responses. Attempts to explicitly request it via the fields query string parameter when
+ /// disabled will return an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted.
+ ///
+ ///
+ /// Note this setting does not affect retrieving the related resources directly.
+ ///
+ AllowView = 1,
+
+ ///
+ /// Whether or not the relationship can be included. Attempts to use it in the include query string parameter when disabled will return an HTTP
+ /// 400 response.
+ ///
+ AllowInclude = 1 << 1,
+
+ ///
+ /// Whether or not POST and PATCH requests can replace the relationship. Attempts to replace when disabled will return an HTTP 422 response.
+ ///
+ AllowSet = 1 << 2,
+
+ All = AllowView | AllowInclude | AllowSet
+}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
index c1b24e9567..dd94bab221 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
@@ -14,9 +14,13 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute
{
private protected static readonly CollectionConverter CollectionConverter = new();
- // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable.
+ // This field is definitely assigned after building the resource graph, which is why its public equivalent is declared as non-nullable.
private ResourceType? _rightType;
+ private bool? _canInclude;
+
+ internal bool HasExplicitCanInclude => _canInclude != null;
+
///
/// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed
/// as a JSON:API relationship.
@@ -69,13 +73,18 @@ internal set
public LinkTypes Links { get; set; } = LinkTypes.NotConfigured;
///
- /// Whether or not this relationship can be included using the
- ///
- /// ?include=publicName
- ///
- /// query string parameter. This is true by default.
+ /// Whether or not this relationship can be included using the include query string parameter. This is true by default.
///
- public bool CanInclude { get; set; } = true;
+ ///
+ /// When explicitly set, this value takes precedence over Capabilities for backwards-compatibility. Capabilities are adjusted accordingly when building
+ /// the resource graph.
+ ///
+ [Obsolete("Use AllowInclude in Capabilities instead.")]
+ public bool CanInclude
+ {
+ get => _canInclude ?? true;
+ set => _canInclude = value;
+ }
public override bool Equals(object? obj)
{
@@ -91,11 +100,11 @@ public override bool Equals(object? obj)
var other = (RelationshipAttribute)obj;
- return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && CanInclude == other.CanInclude && base.Equals(other);
+ return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && base.Equals(other);
}
public override int GetHashCode()
{
- return HashCode.Combine(_rightType?.ClrType, Links, CanInclude, base.GetHashCode());
+ return HashCode.Combine(_rightType?.ClrType, Links, base.GetHashCode());
}
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs
index 51517b3d51..d7af592564 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs
@@ -12,5 +12,6 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute
public LinkTypes Links { get; set; } = LinkTypes.NotConfigured;
///
+ [Obsolete("Use AllowInclude in Capabilities instead.")]
public bool CanInclude { get; set; } = true;
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
index 9f32610dc9..e8e1d17aca 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
@@ -19,7 +19,7 @@ public abstract class ResourceFieldAttribute : Attribute
private ResourceType? _type;
///
- /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name.
+ /// The publicly exposed name of this JSON:API field. When not explicitly set, the configured naming convention is applied on the property name.
///
public string PublicName
{
diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
index 3c32594ea8..bc7d17d89f 100644
--- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
+++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
@@ -21,10 +21,20 @@ public interface IJsonApiOptions
string? Namespace { get; }
///
- /// Specifies the default query string capabilities that can be used on exposed JSON:API attributes. Defaults to .
+ /// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to .
///
AttrCapabilities DefaultAttrCapabilities { get; }
+ ///
+ /// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to .
+ ///
+ HasOneCapabilities DefaultHasOneCapabilities { get; }
+
+ ///
+ /// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to .
+ ///
+ HasManyCapabilities DefaultHasManyCapabilities { get; }
+
///
/// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default.
///
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
index 46603260cd..778ded8d59 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
@@ -26,6 +26,12 @@ public sealed class JsonApiOptions : IJsonApiOptions
///
public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All;
+ ///
+ public HasOneCapabilities DefaultHasOneCapabilities { get; set; } = HasOneCapabilities.All;
+
+ ///
+ public HasManyCapabilities DefaultHasManyCapabilities { get; set; } = HasManyCapabilities.All;
+
///
public bool IncludeJsonApiVersion { get; set; }
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
index 2af0e63caf..2b6f19acd3 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
@@ -307,6 +307,7 @@ private IReadOnlyCollection GetRelationships(Type resourc
{
relationship.Property = property;
SetPublicName(relationship, property);
+ SetRelationshipCapabilities(relationship);
IncludeField(relationshipsByName, relationship);
}
@@ -321,6 +322,52 @@ private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property)
field.PublicName ??= FormatPropertyName(property);
}
+ private void SetRelationshipCapabilities(RelationshipAttribute relationship)
+ {
+#pragma warning disable CS0618 // Type or member is obsolete
+ bool canInclude = relationship.CanInclude;
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ if (relationship is HasOneAttribute hasOneRelationship)
+ {
+ SetHasOneRelationshipCapabilities(hasOneRelationship, canInclude);
+ }
+ else if (relationship is HasManyAttribute hasManyRelationship)
+ {
+ SetHasManyRelationshipCapabilities(hasManyRelationship, canInclude);
+ }
+ }
+
+ private void SetHasOneRelationshipCapabilities(HasOneAttribute hasOneRelationship, bool canInclude)
+ {
+ if (!hasOneRelationship.HasExplicitCapabilities)
+ {
+ hasOneRelationship.Capabilities = _options.DefaultHasOneCapabilities;
+ }
+
+ if (hasOneRelationship.HasExplicitCanInclude)
+ {
+ hasOneRelationship.Capabilities = canInclude
+ ? hasOneRelationship.Capabilities | HasOneCapabilities.AllowInclude
+ : hasOneRelationship.Capabilities & ~HasOneCapabilities.AllowInclude;
+ }
+ }
+
+ private void SetHasManyRelationshipCapabilities(HasManyAttribute hasManyRelationship, bool canInclude)
+ {
+ if (!hasManyRelationship.HasExplicitCapabilities)
+ {
+ hasManyRelationship.Capabilities = _options.DefaultHasManyCapabilities;
+ }
+
+ if (hasManyRelationship.HasExplicitCanInclude)
+ {
+ hasManyRelationship.Capabilities = canInclude
+ ? hasManyRelationship.Capabilities | HasManyCapabilities.AllowInclude
+ : hasManyRelationship.Capabilities & ~HasManyCapabilities.AllowInclude;
+ }
+ }
+
private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0)
{
AssertNoInfiniteRecursion(recursionDepth);
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs
index 7418be160f..38ee1ffa83 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs
@@ -106,7 +106,7 @@ private ICollection LookupRelationshipName(string relationshipN
{
relationshipsFound.AddRange(relationships);
- RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => relationship.CanInclude).ToArray();
+ RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray();
ICollection affectedChildren = parent.EnsureChildren(relationshipsToInclude);
children.AddRange(affectedChildren);
}
@@ -139,7 +139,7 @@ private static void AssertRelationshipsFound(ISet relatio
private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName,
ICollection parents)
{
- if (relationshipsFound.All(relationship => !relationship.CanInclude))
+ if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked()))
{
string parentPath = parents.First().Path;
ResourceType resourceType = relationshipsFound.First().LeftType;
diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs
index 495af2ebc1..ab1edf9f9e 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs
@@ -148,13 +148,11 @@ private static IImmutableSet GetViewableFields(ResourceT
{
ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder();
- foreach (AttrAttribute attribute in resourceType.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)))
+ foreach (ResourceFieldAttribute field in resourceType.Fields.Where(nextField => !nextField.IsViewBlocked()))
{
- fieldSetBuilder.Add(attribute);
+ fieldSetBuilder.Add(field);
}
- fieldSetBuilder.AddRange(resourceType.Relationships);
-
return fieldSetBuilder.ToImmutable();
}
diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs
index 18167acbc0..60c7f6b75c 100644
--- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs
@@ -40,10 +40,12 @@ public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph
protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path)
{
- if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter))
+ if (field.IsFilterBlocked())
{
- throw new InvalidQueryStringParameterException(_lastParameterName!, "Filtering on the requested attribute is not allowed.",
- $"Filtering on attribute '{attribute.PublicName}' is not allowed.");
+ string kind = field is AttrAttribute ? "attribute" : "relationship";
+
+ throw new InvalidQueryStringParameterException(_lastParameterName!, $"Filtering on the requested {kind} is not allowed.",
+ $"Filtering on {kind} '{field.PublicName}' is not allowed.");
}
}
diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs
index dadf153f21..fb4f665873 100644
--- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs
@@ -36,10 +36,12 @@ public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResour
protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path)
{
- if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowView))
+ if (field.IsViewBlocked())
{
- throw new InvalidQueryStringParameterException(_lastParameterName!, "Retrieving the requested attribute is not allowed.",
- $"Retrieving the attribute '{attribute.PublicName}' is not allowed.");
+ string kind = field is AttrAttribute ? "attribute" : "relationship";
+
+ throw new InvalidQueryStringParameterException(_lastParameterName!, $"Retrieving the requested {kind} is not allowed.",
+ $"Retrieving the {kind} '{field.PublicName}' is not allowed.");
}
}
diff --git a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs
index c1f59ceab6..e7c418d8a7 100644
--- a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs
@@ -10,9 +10,9 @@ public enum JsonApiQueryStringParameters
{
None = 0,
Filter = 1,
- Sort = 2,
- Include = 4,
- Page = 8,
- Fields = 16,
+ Sort = 1 << 1,
+ Include = 1 << 2,
+ Page = 1 << 3,
+ Fields = 1 << 4,
All = Filter | Sort | Include | Page | Fields
}
diff --git a/src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs b/src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs
new file mode 100644
index 0000000000..84352f2206
--- /dev/null
+++ b/src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs
@@ -0,0 +1,45 @@
+namespace JsonApiDotNetCore.Resources.Annotations;
+
+internal static class CapabilitiesExtensions
+{
+ public static bool IsViewBlocked(this ResourceFieldAttribute field)
+ {
+ return field switch
+ {
+ AttrAttribute attrAttribute => !attrAttribute.Capabilities.HasFlag(AttrCapabilities.AllowView),
+ HasOneAttribute hasOneRelationship => !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowView),
+ HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowView),
+ _ => false
+ };
+ }
+
+ public static bool IsIncludeBlocked(this RelationshipAttribute relationship)
+ {
+ return relationship switch
+ {
+ HasOneAttribute hasOneRelationship => !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowInclude),
+ HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowInclude),
+ _ => false
+ };
+ }
+
+ public static bool IsFilterBlocked(this ResourceFieldAttribute field)
+ {
+ return field switch
+ {
+ AttrAttribute attrAttribute => !attrAttribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter),
+ HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowFilter),
+ _ => false
+ };
+ }
+
+ public static bool IsSetBlocked(this RelationshipAttribute relationship)
+ {
+ return relationship switch
+ {
+ HasOneAttribute hasOneRelationship => !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowSet),
+ HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowSet),
+ _ => false
+ };
+ }
+}
diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs
index fd2c274dab..f7a5ad82fa 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs
@@ -39,6 +39,7 @@ private RelationshipAttribute ConvertRelationship(string relationshipName, Resou
AssertIsKnownRelationship(relationship, relationshipName, resourceType, state);
AssertToManyInAddOrRemoveRelationship(relationship, state);
+ AssertRelationshipChangeNotBlocked(relationship, state);
return relationship;
}
diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs
index f5d4cb088c..38a8ce0a29 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs
@@ -48,6 +48,7 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I
}
ResourceIdentityAdapter.AssertToManyInAddOrRemoveRelationship(state.Request.Relationship, state);
+ ResourceIdentityAdapter.AssertRelationshipChangeNotBlocked(state.Request.Relationship, state);
state.WritableTargetedFields.Relationships.Add(state.Request.Relationship);
return _relationshipDataAdapter.Convert(document.Data, state.Request.Relationship, false, state);
diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs
index 9df5215da9..e4c0df21df 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs
@@ -231,4 +231,53 @@ protected internal static void AssertToManyInAddOrRemoveRelationship(Relationshi
HttpStatusCode.Forbidden);
}
}
+
+ internal static void AssertRelationshipChangeNotBlocked(RelationshipAttribute relationship, RequestAdapterState state)
+ {
+ switch (state.Request.WriteOperation)
+ {
+ case WriteOperationKind.AddToRelationship:
+ {
+ AssertAddToRelationshipNotBlocked((HasManyAttribute)relationship, state);
+ break;
+ }
+ case WriteOperationKind.RemoveFromRelationship:
+ {
+ AssertRemoveFromRelationshipNotBlocked((HasManyAttribute)relationship, state);
+ break;
+ }
+ default:
+ {
+ AssertSetRelationshipNotBlocked(relationship, state);
+ break;
+ }
+ }
+ }
+
+ private static void AssertSetRelationshipNotBlocked(RelationshipAttribute relationship, RequestAdapterState state)
+ {
+ if (relationship.IsSetBlocked())
+ {
+ throw new ModelConversionException(state.Position, "Relationship cannot be assigned.",
+ $"The relationship '{relationship.PublicName}' on resource type '{relationship.LeftType.PublicName}' cannot be assigned to.");
+ }
+ }
+
+ private static void AssertAddToRelationshipNotBlocked(HasManyAttribute relationship, RequestAdapterState state)
+ {
+ if (!relationship.Capabilities.HasFlag(HasManyCapabilities.AllowAdd))
+ {
+ throw new ModelConversionException(state.Position, "Relationship cannot be added to.",
+ $"The relationship '{relationship.PublicName}' on resource type '{relationship.LeftType.PublicName}' cannot be added to.");
+ }
+ }
+
+ private static void AssertRemoveFromRelationshipNotBlocked(HasManyAttribute relationship, RequestAdapterState state)
+ {
+ if (!relationship.Capabilities.HasFlag(HasManyCapabilities.AllowRemove))
+ {
+ throw new ModelConversionException(state.Position, "Relationship cannot be removed from.",
+ $"The relationship '{relationship.PublicName}' on resource type '{relationship.LeftType.PublicName}' cannot be removed from.");
+ }
+ }
}
diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs
index b489665a41..1b85b35336 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs
@@ -63,8 +63,8 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje
AssertIsKnownAttribute(attr, attributeName, resourceType, state);
AssertNoInvalidAttribute(attributeValue, state);
- AssertNoBlockedCreate(attr, resourceType, state);
- AssertNoBlockedChange(attr, resourceType, state);
+ AssertSetAttributeInCreateResourceNotBlocked(attr, resourceType, state);
+ AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state);
AssertNotReadOnly(attr, resourceType, state);
attr.SetValue(resource, attributeValue);
@@ -96,7 +96,7 @@ private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdap
}
}
- private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state)
+ private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state)
{
if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate))
{
@@ -105,7 +105,7 @@ private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resou
}
}
- private static void AssertNoBlockedChange(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state)
+ private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state)
{
if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange))
{
@@ -148,6 +148,7 @@ private void ConvertRelationship(string relationshipName, RelationshipObject? re
}
AssertIsKnownRelationship(relationship, relationshipName, resourceType, state);
+ AssertRelationshipChangeNotBlocked(relationship, state);
object? rightValue = _relationshipDataAdapter.Convert(relationshipObject.Data, relationship, true, state);
diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs
index 5dc97f8052..b1398b7cfb 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs
@@ -287,6 +287,13 @@ private void TraverseRelationship(RelationshipAttribute relationship, IIdentifia
? leftTreeNode.ResourceType.GetRelationshipByPropertyName(relationship.Property.Name)
: relationship;
+ if (effectiveRelationship.IsViewBlocked())
+ {
+ // Hide related resources when blocked. According to JSON:API, breaking full linkage is only allowed
+ // when the client explicitly requested it by sending a sparse fieldset.
+ return;
+ }
+
object? rightValue = effectiveRelationship.GetValue(leftResource);
IReadOnlyCollection rightResources = CollectionConverter.ExtractResources(rightValue);
diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs
index 955db81720..9002773680 100644
--- a/test/AnnotationTests/Models/TreeNode.cs
+++ b/test/AnnotationTests/Models/TreeNode.cs
@@ -12,9 +12,9 @@ public sealed class TreeNode : Identifiable
[Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort)]
public string? DisplayName { get; set; }
- [HasOne(PublicName = "orders", CanInclude = true, Links = LinkTypes.All)]
+ [HasOne(PublicName = "orders", Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude, Links = LinkTypes.All)]
public TreeNode? Parent { get; set; }
- [HasMany(PublicName = "orders", CanInclude = true, Links = LinkTypes.All)]
+ [HasMany(PublicName = "orders", Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter, Links = LinkTypes.All)]
public ISet Children { get; set; } = new HashSet();
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs
index ffae461fb0..048ef5506f 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs
@@ -751,48 +751,6 @@ public async Task Cannot_create_resource_for_unknown_type()
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
- [Fact]
- public async Task Cannot_create_resource_attribute_with_blocked_capability()
- {
- // Arrange
- var requestBody = new
- {
- atomic__operations = new[]
- {
- new
- {
- op = "add",
- data = new
- {
- type = "lyrics",
- attributes = new
- {
- createdAt = 12.July(1980)
- }
- }
- }
- }
- };
-
- 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("Failed to deserialize request body: Attribute value cannot be assigned when creating resource.");
- error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to.");
- error.Source.ShouldNotBeNull();
- error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt");
- error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
- }
-
[Fact]
public async Task Cannot_create_resource_with_readonly_attribute()
{
@@ -990,4 +948,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_attribute_with_blocked_capability()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "add",
+ data = new
+ {
+ type = "lyrics",
+ attributes = new
+ {
+ createdAt = 12.July(1980)
+ }
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Attribute value cannot be assigned when creating resource.");
+ error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs
index be9700c3d6..61a1db5164 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs
@@ -684,4 +684,56 @@ public async Task Cannot_create_with_object_data_in_ManyToMany_relationship()
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "add",
+ data = new
+ {
+ type = "musicTracks",
+ relationships = new
+ {
+ occursIn = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "playlists",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/occursIn");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs
index e7ba0d5288..0112dc9ed4 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs
@@ -734,4 +734,53 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "add",
+ data = new
+ {
+ type = "lyrics",
+ relationships = new
+ {
+ language = new
+ {
+ data = new
+ {
+ type = "textLanguages",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/language");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs
index 2baa9ac431..af1ac9e18b 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs
@@ -17,7 +17,7 @@ public sealed class Lyric : Identifiable
[Attr(Capabilities = AttrCapabilities.None)]
public DateTimeOffset CreatedAt { get; set; }
- [HasOne]
+ [HasOne(Capabilities = HasOneCapabilities.All & ~HasOneCapabilities.AllowSet)]
public TextLanguage? Language { get; set; }
[HasOne]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs
index 42bbbdfd3b..646a0d9ed9 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs
@@ -34,6 +34,6 @@ public sealed class MusicTrack : Identifiable
[HasMany]
public IList Performers { get; set; } = new List();
- [HasMany]
+ [HasMany(Capabilities = HasManyCapabilities.All & ~(HasManyCapabilities.AllowSet | HasManyCapabilities.AllowAdd | HasManyCapabilities.AllowRemove))]
public IList OccursIn { get; set; } = new List();
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs
index fc13524e8a..92f1bc638b 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs
@@ -1081,4 +1081,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id);
});
}
+
+ [Fact]
+ public async Task Cannot_add_with_blocked_capability()
+ {
+ // Arrange
+ MusicTrack existingTrack = _fakers.MusicTrack.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.MusicTracks.Add(existingTrack);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "add",
+ @ref = new
+ {
+ type = "musicTracks",
+ id = existingTrack.StringId,
+ relationship = "occursIn"
+ },
+ data = new
+ {
+ type = "playlists",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be added to.");
+ error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be added to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs
index d015cae3fd..bed9b62d99 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs
@@ -1042,4 +1042,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id);
});
}
+
+ [Fact]
+ public async Task Cannot_remove_with_blocked_capability()
+ {
+ // Arrange
+ MusicTrack existingTrack = _fakers.MusicTrack.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.MusicTracks.Add(existingTrack);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "remove",
+ @ref = new
+ {
+ type = "musicTracks",
+ id = existingTrack.StringId,
+ relationship = "occursIn"
+ },
+ data = new
+ {
+ type = "playlists",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be removed from.");
+ error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be removed from.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs
index faf8a9cb8c..be0da69ad9 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs
@@ -1140,4 +1140,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ MusicTrack existingTrack = _fakers.MusicTrack.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.MusicTracks.Add(existingTrack);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "update",
+ @ref = new
+ {
+ type = "musicTracks",
+ id = existingTrack.StringId,
+ relationship = "occursIn"
+ },
+ data = new
+ {
+ type = "playlists",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs
index 4b650a3d85..31b62a7c8e 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs
@@ -1305,4 +1305,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ Lyric existingLyric = _fakers.Lyric.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Lyrics.Add(existingLyric);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "update",
+ @ref = new
+ {
+ type = "lyrics",
+ id = existingLyric.StringId,
+ relationship = "language"
+ },
+ data = new
+ {
+ type = "textLanguages",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs
index fa801c67c1..0d8c5e1d80 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs
@@ -799,4 +799,65 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ MusicTrack existingTrack = _fakers.MusicTrack.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.MusicTracks.Add(existingTrack);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "update",
+ data = new
+ {
+ type = "musicTracks",
+ id = existingTrack.StringId,
+ relationships = new
+ {
+ occursIn = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "playlists",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/occursIn");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
index 336b7d5621..eb9f81b6e6 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
@@ -1503,57 +1503,6 @@ public async Task Cannot_update_resource_for_incompatible_ID()
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
- [Fact]
- public async Task Cannot_update_resource_attribute_with_blocked_capability()
- {
- // Arrange
- Lyric existingLyric = _fakers.Lyric.Generate();
-
- await _testContext.RunOnDatabaseAsync(async dbContext =>
- {
- dbContext.Lyrics.Add(existingLyric);
- await dbContext.SaveChangesAsync();
- });
-
- var requestBody = new
- {
- atomic__operations = new[]
- {
- new
- {
- op = "update",
- data = new
- {
- type = "lyrics",
- id = existingLyric.StringId,
- attributes = new
- {
- createdAt = 12.July(1980)
- }
- }
- }
- }
- };
-
- 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("Failed to deserialize request body: Attribute value cannot be assigned when updating resource.");
- error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to.");
- error.Source.ShouldNotBeNull();
- error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt");
- error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
- }
-
[Fact]
public async Task Cannot_update_resource_with_readonly_attribute()
{
@@ -1815,4 +1764,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_attribute_with_blocked_capability()
+ {
+ // Arrange
+ Lyric existingLyric = _fakers.Lyric.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Lyrics.Add(existingLyric);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "update",
+ data = new
+ {
+ type = "lyrics",
+ id = existingLyric.StringId,
+ attributes = new
+ {
+ createdAt = 12.July(1980)
+ }
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Attribute value cannot be assigned when updating resource.");
+ error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs
index 3996983042..931e75e789 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs
@@ -1046,4 +1046,62 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ Lyric existingLyric = _fakers.Lyric.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Lyrics.Add(existingLyric);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new[]
+ {
+ new
+ {
+ op = "update",
+ data = new
+ {
+ type = "lyrics",
+ id = existingLyric.StringId,
+ relationships = new
+ {
+ language = new
+ {
+ data = new
+ {
+ type = "textLanguages",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ 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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/language");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs
index a628cf9355..1840f5674d 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs
@@ -29,6 +29,8 @@ public sealed class BlogPost : Identifiable
[HasMany]
public ISet Comments { get; set; } = new HashSet();
+#pragma warning disable CS0618 // Type or member is obsolete
[HasOne(CanInclude = false)]
+#pragma warning restore CS0618 // Type or member is obsolete
public Blog? Parent { get; set; }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs
index 46d82cb7fb..eaf091ec07 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs
@@ -17,6 +17,9 @@ public sealed class Calendar : Identifiable
[Attr]
public int DefaultAppointmentDurationInMinutes { get; set; }
- [HasMany]
+ [HasOne]
+ public Appointment? MostRecentAppointment { get; set; }
+
+ [HasMany(Capabilities = HasManyCapabilities.All & ~(HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter))]
public ISet Appointments { get; set; } = new HashSet();
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs
index 1af2251c6b..104f5a4f79 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs
@@ -18,6 +18,7 @@ public FilterTests(IntegrationTestContext,
_testContext = testContext;
testContext.UseController();
+ testContext.UseController();
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService();
options.EnableLegacyFilterNotation = false;
@@ -89,6 +90,28 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability()
error.Source.Parameter.Should().Be("filter");
}
+ [Fact]
+ public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability()
+ {
+ // Arrange
+ const string route = "/calendars?filter=has(appointments)";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be("Filtering on the requested relationship is not allowed.");
+ error.Detail.Should().Be("Filtering on relationship 'appointments' is not allowed.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Parameter.Should().Be("filter");
+ }
+
[Fact]
public async Task Can_filter_on_ID()
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
index c835263fc6..a3fbfe54c7 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
@@ -21,6 +21,7 @@ public IncludeTests(IntegrationTestContext
testContext.UseController();
testContext.UseController();
testContext.UseController();
+ testContext.UseController();
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService();
options.MaximumIncludeDepth = null;
@@ -872,7 +873,7 @@ public async Task Cannot_include_unknown_nested_relationship()
}
[Fact]
- public async Task Cannot_include_relationship_with_blocked_capability()
+ public async Task Cannot_include_relationship_when_inclusion_blocked()
{
// Arrange
const string route = "/blogPosts?include=parent";
@@ -894,7 +895,7 @@ public async Task Cannot_include_relationship_with_blocked_capability()
}
[Fact]
- public async Task Cannot_include_relationship_with_nested_blocked_capability()
+ public async Task Cannot_include_relationship_when_nested_inclusion_blocked()
{
// Arrange
const string route = "/blogs?include=posts.parent";
@@ -915,6 +916,85 @@ public async Task Cannot_include_relationship_with_nested_blocked_capability()
error.Source.Parameter.Should().Be("include");
}
+ [Fact]
+ public async Task Hides_relationship_and_related_resources_when_viewing_blocked()
+ {
+ // Arrange
+ Calendar calendar = _fakers.Calendar.Generate();
+ calendar.Appointments = _fakers.Appointment.Generate(2).ToHashSet();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Calendars.Add(calendar);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/calendars/{calendar.StringId}?include=appointments";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.SingleValue.ShouldNotBeNull();
+ responseDocument.Data.SingleValue.Type.Should().Be("calendars");
+ responseDocument.Data.SingleValue.Id.Should().Be(calendar.StringId);
+
+ responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty();
+ responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("appointments");
+
+ responseDocument.Included.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Hides_relationship_but_includes_related_resource_when_viewing_blocked_but_accessible_via_other_path()
+ {
+ // Arrange
+ Calendar calendar = _fakers.Calendar.Generate();
+ calendar.MostRecentAppointment = _fakers.Appointment.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Calendars.Add(calendar);
+ await dbContext.SaveChangesAsync();
+
+ calendar.Appointments = new[]
+ {
+ _fakers.Appointment.Generate(),
+ calendar.MostRecentAppointment
+ }.ToHashSet();
+
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/calendars/{calendar.StringId}?include=appointments,mostRecentAppointment";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.SingleValue.ShouldNotBeNull();
+ responseDocument.Data.SingleValue.Type.Should().Be("calendars");
+ responseDocument.Data.SingleValue.Id.Should().Be(calendar.StringId);
+
+ responseDocument.Data.SingleValue.Relationships.ShouldContainKey("mostRecentAppointment").With(value =>
+ {
+ value.ShouldNotBeNull();
+ value.Data.SingleValue.ShouldNotBeNull();
+ value.Data.SingleValue.Type.Should().Be("appointments");
+ value.Data.SingleValue.Id.Should().Be(calendar.MostRecentAppointment.StringId);
+ });
+
+ responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("appointments");
+
+ responseDocument.Included.ShouldHaveCount(1);
+ responseDocument.Included[0].Type.Should().Be("appointments");
+ responseDocument.Included[0].Id.Should().Be(calendar.MostRecentAppointment.StringId);
+ }
+
[Fact]
public async Task Ignores_null_parent_in_nested_include()
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs
index 6e72d6923a..0a6204cce4 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs
@@ -34,10 +34,10 @@ public async Task Applies_configuration_for_ignore_condition(JsonIgnoreCondition
calendar.TimeZone = null;
calendar.DefaultAppointmentDurationInMinutes = default;
calendar.ShowWeekNumbers = true;
- calendar.Appointments = _fakers.Appointment.Generate(1).ToHashSet();
- calendar.Appointments.Single().Description = null;
- calendar.Appointments.Single().StartTime = default;
- calendar.Appointments.Single().EndTime = 1.January(2001).AsUtc();
+ calendar.MostRecentAppointment = _fakers.Appointment.Generate();
+ calendar.MostRecentAppointment.Description = null;
+ calendar.MostRecentAppointment.StartTime = default;
+ calendar.MostRecentAppointment.EndTime = 1.January(2001).AsUtc();
await RunOnDatabaseAsync(async dbContext =>
{
@@ -45,7 +45,7 @@ await RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});
- string route = $"/calendars/{calendar.StringId}?include=appointments";
+ string route = $"/calendars/{calendar.StringId}?include=mostRecentAppointment";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route);
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs
index bbcf2cd17a..4036c62f13 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs
@@ -20,6 +20,7 @@ public SparseFieldSetTests(IntegrationTestContext();
testContext.UseController();
testContext.UseController();
+ testContext.UseController();
testContext.ConfigureServicesAfterStartup(services =>
{
@@ -741,6 +742,54 @@ public async Task Cannot_select_attribute_with_blocked_capability()
error.Source.Parameter.Should().Be("fields[webAccounts]");
}
+ [Fact]
+ public async Task Cannot_select_ToOne_relationship_with_blocked_capability()
+ {
+ // Arrange
+ WebAccount account = _fakers.WebAccount.Generate();
+
+ string route = $"/webAccounts/{account.Id}?fields[webAccounts]=person";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be("Retrieving the requested relationship is not allowed.");
+ error.Detail.Should().Be("Retrieving the relationship 'person' is not allowed.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Parameter.Should().Be("fields[webAccounts]");
+ }
+
+ [Fact]
+ public async Task Cannot_select_ToMany_relationship_with_blocked_capability()
+ {
+ // Arrange
+ Calendar calendar = _fakers.Calendar.Generate();
+
+ string route = $"/calendars/{calendar.Id}?fields[calendars]=appointments";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be("Retrieving the requested relationship is not allowed.");
+ error.Detail.Should().Be("Retrieving the relationship 'appointments' is not allowed.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Parameter.Should().Be("fields[calendars]");
+ }
+
[Fact]
public async Task Retrieves_all_properties_when_fieldset_contains_readonly_attribute()
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs
index 1749184fa4..70133fba54 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs
@@ -11,7 +11,7 @@ public sealed class WebAccount : Identifiable
[Attr]
public string UserName { get; set; } = null!;
- [Attr(Capabilities = ~AttrCapabilities.AllowView)]
+ [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowView)]
public string Password { get; set; } = null!;
[Attr]
@@ -23,7 +23,7 @@ public sealed class WebAccount : Identifiable
[Attr]
public string EmailAddress { get; set; } = null!;
- [HasOne]
+ [HasOne(Capabilities = HasOneCapabilities.All & ~HasOneCapabilities.AllowView)]
public Human? Person { get; set; }
[HasMany]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs
index f85eb6579e..587b7d8277 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs
@@ -728,41 +728,6 @@ public async Task Cannot_create_on_resource_type_mismatch_between_url_and_body()
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
- [Fact]
- public async Task Cannot_create_resource_attribute_with_blocked_capability()
- {
- // Arrange
- var requestBody = new
- {
- data = new
- {
- type = "workItems",
- attributes = new
- {
- isImportant = true
- }
- }
- };
-
- const string route = "/workItems";
-
- // Act
- (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(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("Failed to deserialize request body: Attribute value cannot be assigned when creating resource.");
- error.Detail.Should().Be("The attribute 'isImportant' on resource type 'workItems' cannot be assigned to.");
- error.Source.ShouldNotBeNull();
- error.Source.Pointer.Should().Be("/data/attributes/isImportant");
- error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
- }
-
[Fact]
public async Task Cannot_create_resource_with_readonly_attribute()
{
@@ -958,4 +923,39 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.Tags.Single().Id.Should().Be(existingTag.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_attribute_with_blocked_capability()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItems",
+ attributes = new
+ {
+ isImportant = true
+ }
+ }
+ };
+
+ const string route = "/workItems";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(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("Failed to deserialize request body: Attribute value cannot be assigned when creating resource.");
+ error.Detail.Should().Be("The attribute 'isImportant' on resource type 'workItems' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/data/attributes/isImportant");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs
index fb96a8b0f5..0a6ed42c60 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs
@@ -17,6 +17,7 @@ public CreateResourceWithToManyRelationshipTests(IntegrationTestContext();
+ testContext.UseController();
testContext.UseController();
}
@@ -790,4 +791,49 @@ public async Task Cannot_create_resource_with_local_ID()
error.Source.Pointer.Should().Be("/data/lid");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItemGroups",
+ relationships = new
+ {
+ items = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "workItems",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ }
+ };
+
+ const string route = "/workItemGroups";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'items' on resource type 'workItemGroups' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/data/relationships/items");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs
index 07b97c252d..08bbcf1f63 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs
@@ -762,4 +762,46 @@ public async Task Cannot_create_resource_with_local_ID()
error.Source.Pointer.Should().Be("/data/lid");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItems",
+ relationships = new
+ {
+ group = new
+ {
+ data = new
+ {
+ type = "workItemGroups",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ };
+
+ const string route = "/workItems";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'group' on resource type 'workItems' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/data/relationships/group");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs
index 5a4a3b226b..08ac8c3f54 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs
@@ -17,6 +17,7 @@ public AddToToManyRelationshipTests(IntegrationTestContext();
+ testContext.UseController();
}
[Fact]
@@ -933,4 +934,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.RelatedTo.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.RelatedTo[0].Id);
});
}
+
+ [Fact]
+ public async Task Cannot_add_with_blocked_capability()
+ {
+ // Arrange
+ WorkItemGroup existingWorkItemGroup = _fakers.WorkItemGroup.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Groups.Add(existingWorkItemGroup);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "workItems",
+ id = Unknown.StringId.For()
+ }
+ }
+ };
+
+ string route = $"/workItemGroups/{existingWorkItemGroup.StringId}/relationships/items";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(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("Failed to deserialize request body: Relationship cannot be added to.");
+ error.Detail.Should().Be("The relationship 'items' on resource type 'workItemGroups' cannot be added to.");
+ error.Source.Should().BeNull();
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs
index 684575b860..eef972dd1f 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs
@@ -23,6 +23,7 @@ public RemoveFromToManyRelationshipTests(IntegrationTestContext();
+ testContext.UseController();
testContext.ConfigureServicesAfterStartup(services =>
{
@@ -1055,6 +1056,48 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
});
}
+ [Fact]
+ public async Task Cannot_remove_with_blocked_capability()
+ {
+ // Arrange
+ WorkItemGroup existingWorkItemGroup = _fakers.WorkItemGroup.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Groups.Add(existingWorkItemGroup);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "workItems",
+ id = Unknown.StringId.For()
+ }
+ }
+ };
+
+ string route = $"/workItemGroups/{existingWorkItemGroup.StringId}/relationships/items";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(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("Failed to deserialize request body: Relationship cannot be removed from.");
+ error.Detail.Should().Be("The relationship 'items' on resource type 'workItemGroups' cannot be removed from.");
+ error.Source.Should().BeNull();
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
private sealed class RemoveExtraFromWorkItemDefinition : JsonApiResourceDefinition
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs
index fb368fd524..02a7e68292 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs
@@ -18,6 +18,7 @@ public ReplaceToManyRelationshipTests(IntegrationTestContext();
+ testContext.UseController();
}
[Fact]
@@ -1013,4 +1014,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.RelatedTo[0].Id.Should().Be(existingWorkItem.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ WorkItemGroup existingWorkItemGroup = _fakers.WorkItemGroup.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Groups.Add(existingWorkItemGroup);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "workItems",
+ id = Unknown.StringId.For()
+ }
+ }
+ };
+
+ string route = $"/workItemGroups/{existingWorkItemGroup.StringId}/relationships/items";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'items' on resource type 'workItemGroups' cannot be assigned to.");
+ error.Source.Should().BeNull();
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs
index 1ba844be78..a492cc4826 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs
@@ -773,4 +773,43 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ WorkItem existingWorkItem = _fakers.WorkItem.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.WorkItems.Add(existingWorkItem);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItemGroups",
+ id = Unknown.StringId.For()
+ }
+ };
+
+ string route = $"/workItems/{existingWorkItem.StringId}/relationships/group";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'group' on resource type 'workItems' cannot be assigned to.");
+ error.Source.Should().BeNull();
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs
index 0ab3ab93b2..be673f660e 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs
@@ -19,6 +19,7 @@ public ReplaceToManyRelationshipTests(IntegrationTestContext();
+ testContext.UseController();
testContext.UseController();
testContext.ConfigureServicesAfterStartup(services =>
@@ -1134,4 +1135,58 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.RelatedTo[0].Id.Should().Be(existingWorkItem.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ WorkItemGroup existingWorkItemGroup = _fakers.WorkItemGroup.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Groups.Add(existingWorkItemGroup);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItemGroups",
+ id = existingWorkItemGroup.StringId,
+ relationships = new
+ {
+ items = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "workItems",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ }
+ };
+
+ string route = $"/workItemGroups/{existingWorkItemGroup.StringId}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'items' on resource type 'workItemGroups' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/data/relationships/items");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs
index b67090e22f..dc5fb35996 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs
@@ -1085,50 +1085,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
}
- [Fact]
- public async Task Cannot_update_resource_attribute_with_blocked_capability()
- {
- // Arrange
- WorkItem existingWorkItem = _fakers.WorkItem.Generate();
-
- await _testContext.RunOnDatabaseAsync(async dbContext =>
- {
- dbContext.WorkItems.Add(existingWorkItem);
- await dbContext.SaveChangesAsync();
- });
-
- var requestBody = new
- {
- data = new
- {
- type = "workItems",
- id = existingWorkItem.StringId,
- attributes = new
- {
- isImportant = true
- }
- }
- };
-
- string route = $"/workItems/{existingWorkItem.StringId}";
-
- // Act
- (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(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("Failed to deserialize request body: Attribute value cannot be assigned when updating resource.");
- error.Detail.Should().Be("The attribute 'isImportant' on resource type 'workItems' cannot be assigned to.");
- error.Source.ShouldNotBeNull();
- error.Source.Pointer.Should().Be("/data/attributes/isImportant");
- error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
- }
-
[Fact]
public async Task Cannot_update_resource_with_readonly_attribute()
{
@@ -1542,4 +1498,48 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.RelatedTo.Single().Id.Should().Be(existingWorkItem.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_attribute_with_blocked_capability()
+ {
+ // Arrange
+ WorkItem existingWorkItem = _fakers.WorkItem.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.WorkItems.Add(existingWorkItem);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItems",
+ id = existingWorkItem.StringId,
+ attributes = new
+ {
+ isImportant = true
+ }
+ }
+ };
+
+ string route = $"/workItems/{existingWorkItem.StringId}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(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("Failed to deserialize request body: Attribute value cannot be assigned when updating resource.");
+ error.Detail.Should().Be("The attribute 'isImportant' on resource type 'workItems' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/data/attributes/isImportant");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs
index be3c9b89b2..81faaa8297 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs
@@ -941,4 +941,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id);
});
}
+
+ [Fact]
+ public async Task Cannot_assign_relationship_with_blocked_capability()
+ {
+ // Arrange
+ WorkItem existingWorkItem = _fakers.WorkItem.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.WorkItems.Add(existingWorkItem);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "workItems",
+ id = existingWorkItem.StringId,
+ relationships = new
+ {
+ group = new
+ {
+ data = new
+ {
+ type = "workItemGroups",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ }
+ };
+
+ string route = $"/workItems/{existingWorkItem.StringId}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(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("Failed to deserialize request body: Relationship cannot be assigned.");
+ error.Detail.Should().Be("The relationship 'group' on resource type 'workItems' cannot be assigned to.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Pointer.Should().Be("/data/relationships/group");
+ error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
+ }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs
index e7a0a01b5c..c722592e83 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs
@@ -19,7 +19,7 @@ public sealed class WorkItem : Identifiable
public WorkItemPriority Priority { get; set; }
[NotMapped]
- [Attr(Capabilities = ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))]
+ [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))]
public bool IsImportant
{
get => Priority == WorkItemPriority.High;
@@ -47,6 +47,6 @@ public bool IsImportant
[HasMany]
public IList RelatedTo { get; set; } = new List();
- [HasOne]
+ [HasOne(Capabilities = HasOneCapabilities.All & ~HasOneCapabilities.AllowSet)]
public WorkItemGroup? Group { get; set; }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs
index 86c11391ed..7f132c8994 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs
@@ -22,6 +22,6 @@ public sealed class WorkItemGroup : Identifiable
[HasOne]
public RgbColor? Color { get; set; }
- [HasMany]
+ [HasMany(Capabilities = HasManyCapabilities.All & ~(HasManyCapabilities.AllowSet | HasManyCapabilities.AllowAdd | HasManyCapabilities.AllowRemove))]
public IList Items { get; set; } = new List();
}
From 45b1136ce35c9f8a1a0b2508d3f8e4a5a96da30c Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 1 Oct 2022 16:47:52 +0200
Subject: [PATCH 3/8] Allow empty include query string parameter value
---
.../Queries/Internal/Parsing/IncludeParser.cs | 14 ++++++++---
.../IncludeQueryStringParameterReader.cs | 2 +-
.../QueryStrings/Includes/IncludeTests.cs | 25 +++++++++++++++++++
.../QueryStrings/QueryStringTests.cs | 1 -
.../IncludeParseTests.cs | 2 +-
5 files changed, 37 insertions(+), 7 deletions(-)
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs
index 38ee1ffa83..3c8be88e46 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs
@@ -30,12 +30,18 @@ public IncludeExpression Parse(string source, ResourceType resourceTypeInScope,
protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? maximumDepth)
{
var treeRoot = IncludeTreeNode.CreateRoot(resourceTypeInScope);
-
- ParseRelationshipChain(treeRoot);
+ bool isAtStart = true;
while (TokenStack.Any())
{
- EatSingleCharacterToken(TokenKind.Comma);
+ if (!isAtStart)
+ {
+ EatSingleCharacterToken(TokenKind.Comma);
+ }
+ else
+ {
+ isAtStart = false;
+ }
ParseRelationshipChain(treeRoot);
}
@@ -244,7 +250,7 @@ public IncludeExpression ToExpression()
if (element.Relationship is HiddenRootRelationshipAttribute)
{
- return new IncludeExpression(element.Children);
+ return element.Children.Any() ? new IncludeExpression(element.Children) : IncludeExpression.Empty;
}
return new IncludeExpression(ImmutableHashSet.Create(element));
diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs
index 299e8b22b2..a4db6ebd4a 100644
--- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs
@@ -18,7 +18,7 @@ public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIn
private IncludeExpression? _includeExpression;
- public bool AllowEmptyValue => false;
+ public bool AllowEmptyValue => true;
public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options)
: base(request, resourceGraph)
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
index a3fbfe54c7..6dece6f0d1 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
@@ -828,6 +828,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(account.UserName));
}
+ [Fact]
+ public async Task Can_select_empty_includes()
+ {
+ // Arrange
+ WebAccount account = _fakers.WebAccount.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Accounts.Add(account);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = $"/webAccounts/{account.StringId}?include=";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.Data.SingleValue.ShouldNotBeNull();
+
+ responseDocument.Included.Should().BeEmpty();
+ }
+
[Fact]
public async Task Cannot_include_unknown_relationship()
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs
index 70a40d1e4f..0aa955a219 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs
@@ -64,7 +64,6 @@ public async Task Can_use_unknown_query_string_parameter()
}
[Theory]
- [InlineData("include")]
[InlineData("filter")]
[InlineData("sort")]
[InlineData("page[size]")]
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs
index 7c7c28f4e3..b576b58b71 100644
--- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs
+++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs
@@ -50,7 +50,6 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b
}
[Theory]
- [InlineData("includes", "", "Relationship name expected.")]
[InlineData("includes", " ", "Unexpected whitespace.")]
[InlineData("includes", ",", "Relationship name expected.")]
[InlineData("includes", "posts,", "Relationship name expected.")]
@@ -85,6 +84,7 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
}
[Theory]
+ [InlineData("includes", "", "")]
[InlineData("includes", "owner", "owner")]
[InlineData("includes", "posts", "posts")]
[InlineData("includes", "owner.posts", "owner.posts")]
From f2c6029989f3e59616f5edce8f54a291a653ab1e Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Sat, 1 Oct 2022 18:30:21 +0200
Subject: [PATCH 4/8] Fixed: full linkage does not allow primary resources to
occur in included
---
.../Response/ResourceObjectTreeNode.cs | 12 +++++-
.../QueryStrings/Includes/IncludeTests.cs | 22 ++++------
.../Reading/IClientSettingsProvider.cs | 2 +-
.../ResourceDefinitions/Reading/Moon.cs | 3 ++
.../Reading/MoonDefinition.cs | 8 ++--
.../Reading/ResourceDefinitionReadTests.cs | 42 ++++++++++---------
.../Reading/TestClientSettingsProvider.cs | 8 ++--
7 files changed, 54 insertions(+), 43 deletions(-)
diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
index 01743168be..9527f766e1 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
@@ -181,7 +181,17 @@ public IList GetResponseIncluded()
VisitRelationshipChildrenInSubtree(child, visited);
}
- return visited.Select(node => node.ResourceObject).ToArray();
+ List includes = visited.Select(node => node.ResourceObject).ToList();
+
+ foreach (ResourceObject primaryResourceObjects in GetDirectChildren().Select(node => node.ResourceObject))
+ {
+ if (includes.Contains(primaryResourceObjects, ResourceObjectComparer.Instance))
+ {
+ includes.Remove(primaryResourceObjects);
+ }
+ }
+
+ return includes;
}
private IList GetDirectChildren()
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
index 6dece6f0d1..88360bcfa2 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs
@@ -398,29 +398,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.SingleValue.Id.Should().Be(comment.StringId);
responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text));
- responseDocument.Included.ShouldHaveCount(5);
+ responseDocument.Included.ShouldHaveCount(4);
responseDocument.Included[0].Type.Should().Be("blogPosts");
responseDocument.Included[0].Id.Should().Be(comment.Parent.StringId);
responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption));
responseDocument.Included[1].Type.Should().Be("comments");
- responseDocument.Included[1].Id.Should().Be(comment.StringId);
- responseDocument.Included[1].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text));
-
- responseDocument.Included[2].Type.Should().Be("comments");
- responseDocument.Included[2].Id.Should().Be(comment.Parent.Comments.ElementAt(0).StringId);
- responseDocument.Included[2].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(0).Text));
+ responseDocument.Included[1].Id.Should().Be(comment.Parent.Comments.ElementAt(0).StringId);
+ responseDocument.Included[1].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(0).Text));
string userName = comment.Parent.Comments.ElementAt(0).Author!.UserName;
- responseDocument.Included[3].Type.Should().Be("webAccounts");
- responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author!.StringId);
- responseDocument.Included[3].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(userName));
+ responseDocument.Included[2].Type.Should().Be("webAccounts");
+ responseDocument.Included[2].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author!.StringId);
+ responseDocument.Included[2].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(userName));
- responseDocument.Included[4].Type.Should().Be("comments");
- responseDocument.Included[4].Id.Should().Be(comment.Parent.Comments.ElementAt(1).StringId);
- responseDocument.Included[4].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(1).Text));
+ responseDocument.Included[3].Type.Should().Be("comments");
+ responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(1).StringId);
+ responseDocument.Included[3].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(1).Text));
}
[Fact]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs
index 65f32a4f65..f67cd3d993 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/IClientSettingsProvider.cs
@@ -4,5 +4,5 @@ public interface IClientSettingsProvider
{
bool IsIncludePlanetMoonsBlocked { get; }
bool ArePlanetsWithPrivateNameHidden { get; }
- bool IsMoonOrbitingPlanetAutoIncluded { get; }
+ bool IsStarGivingLightToMoonAutoIncluded { get; }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs
index 0a394c6d17..f6d16fed1b 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs
@@ -16,4 +16,7 @@ public sealed class Moon : Identifiable
[HasOne]
public Planet OrbitsAround { get; set; } = null!;
+
+ [HasOne]
+ public Star? IsGivenLightBy { get; set; }
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs
index 7ef545b462..bdd75a9aff 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs
@@ -28,15 +28,15 @@ public override IImmutableSet OnApplyIncludes(IImmutab
{
base.OnApplyIncludes(existingIncludes);
- if (!_clientSettingsProvider.IsMoonOrbitingPlanetAutoIncluded ||
- existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Moon.OrbitsAround)))
+ if (!_clientSettingsProvider.IsStarGivingLightToMoonAutoIncluded ||
+ existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Moon.IsGivenLightBy)))
{
return existingIncludes;
}
- RelationshipAttribute orbitsAroundRelationship = ResourceType.GetRelationshipByPropertyName(nameof(Moon.OrbitsAround));
+ RelationshipAttribute isGivenLightByRelationship = ResourceType.GetRelationshipByPropertyName(nameof(Moon.IsGivenLightBy));
- return existingIncludes.Add(new IncludeElementExpression(orbitsAroundRelationship));
+ return existingIncludes.Add(new IncludeElementExpression(isGivenLightByRelationship));
}
public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters()
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs
index 83dececbec..db80d4c14b 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs
@@ -93,10 +93,11 @@ public async Task Include_from_resource_definition_is_added()
var hitCounter = _testContext.Factory.Services.GetRequiredService();
var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService();
- settingsProvider.AutoIncludeOrbitingPlanetForMoons();
+ settingsProvider.AutoIncludeStarGivingLightToMoon();
Moon moon = _fakers.Moon.Generate();
moon.OrbitsAround = _fakers.Planet.Generate();
+ moon.IsGivenLightBy = _fakers.Star.Generate();
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
@@ -114,18 +115,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.SingleValue.ShouldNotBeNull();
- responseDocument.Data.SingleValue.Relationships.ShouldContainKey("orbitsAround").With(value =>
+ responseDocument.Data.SingleValue.Relationships.ShouldContainKey("isGivenLightBy").With(value =>
{
value.ShouldNotBeNull();
value.Data.SingleValue.ShouldNotBeNull();
- value.Data.SingleValue.Type.Should().Be("planets");
- value.Data.SingleValue.Id.Should().Be(moon.OrbitsAround.StringId);
+ value.Data.SingleValue.Type.Should().Be("stars");
+ value.Data.SingleValue.Id.Should().Be(moon.IsGivenLightBy.StringId);
});
responseDocument.Included.ShouldHaveCount(1);
- responseDocument.Included[0].Type.Should().Be("planets");
- responseDocument.Included[0].Id.Should().Be(moon.OrbitsAround.StringId);
- responseDocument.Included[0].Attributes.ShouldContainKey("publicName").With(value => value.Should().Be(moon.OrbitsAround.PublicName));
+ responseDocument.Included[0].Type.Should().Be("stars");
+ responseDocument.Included[0].Id.Should().Be(moon.IsGivenLightBy.StringId);
+ responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(moon.IsGivenLightBy.Name));
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
@@ -134,12 +135,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySort),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes),
- (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
- (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta),
- (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
- (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta)
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta)
}, options => options.WithStrictOrdering());
}
@@ -150,11 +151,11 @@ public async Task Include_from_included_resource_definition_is_added()
var hitCounter = _testContext.Factory.Services.GetRequiredService();
var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService();
- settingsProvider.AutoIncludeOrbitingPlanetForMoons();
+ settingsProvider.AutoIncludeStarGivingLightToMoon();
Planet planet = _fakers.Planet.Generate();
planet.Moons = _fakers.Moon.Generate(1).ToHashSet();
- planet.Moons.ElementAt(0).OrbitsAround = _fakers.Planet.Generate();
+ planet.Moons.ElementAt(0).IsGivenLightBy = _fakers.Star.Generate();
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
@@ -178,11 +179,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Included[0].Id.Should().Be(planet.Moons.ElementAt(0).StringId);
responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(planet.Moons.ElementAt(0).Name));
- string moonName = planet.Moons.ElementAt(0).OrbitsAround.PublicName;
-
- responseDocument.Included[1].Type.Should().Be("planets");
- responseDocument.Included[1].Id.Should().Be(planet.Moons.ElementAt(0).OrbitsAround.StringId);
- responseDocument.Included[1].Attributes.ShouldContainKey("publicName").With(value => value.Should().Be(moonName));
+ responseDocument.Included[1].Type.Should().Be("stars");
+ responseDocument.Included[1].Id.Should().Be(planet.Moons.ElementAt(0).IsGivenLightBy!.StringId);
+ responseDocument.Included[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be(planet.Moons.ElementAt(0).IsGivenLightBy!.Name));
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
@@ -196,11 +195,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyPagination),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes),
- (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes),
(typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
(typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta),
(typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
- (typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta)
+ (typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet),
+ (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta)
}, options => options.WithStrictOrdering());
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs
index 63f0033119..0efc7a415e 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/TestClientSettingsProvider.cs
@@ -4,13 +4,13 @@ internal sealed class TestClientSettingsProvider : IClientSettingsProvider
{
public bool IsIncludePlanetMoonsBlocked { get; private set; }
public bool ArePlanetsWithPrivateNameHidden { get; private set; }
- public bool IsMoonOrbitingPlanetAutoIncluded { get; private set; }
+ public bool IsStarGivingLightToMoonAutoIncluded { get; private set; }
public void ResetToDefaults()
{
IsIncludePlanetMoonsBlocked = false;
ArePlanetsWithPrivateNameHidden = false;
- IsMoonOrbitingPlanetAutoIncluded = false;
+ IsStarGivingLightToMoonAutoIncluded = false;
}
public void BlockIncludePlanetMoons()
@@ -23,8 +23,8 @@ public void HidePlanetsWithPrivateName()
ArePlanetsWithPrivateNameHidden = true;
}
- public void AutoIncludeOrbitingPlanetForMoons()
+ public void AutoIncludeStarGivingLightToMoon()
{
- IsMoonOrbitingPlanetAutoIncluded = true;
+ IsStarGivingLightToMoonAutoIncluded = true;
}
}
From 935224d963a8a2dde1292890a4ab51fcde3d33c9 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Mon, 3 Oct 2022 22:31:01 +0200
Subject: [PATCH 5/8] Remove unneeded dependencies
---
src/JsonApiDotNetCore/JsonApiDotNetCore.csproj | 1 -
test/TestBuildingBlocks/TestBuildingBlocks.csproj | 1 -
2 files changed, 2 deletions(-)
diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
index e401db38fa..9b73a09253 100644
--- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
+++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
@@ -43,6 +43,5 @@
-
diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj
index ed335f630f..5600104fda 100644
--- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj
+++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj
@@ -13,7 +13,6 @@
-
From 87265184d4a91d438ef94630523b257b0273c497 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Mon, 3 Oct 2022 22:37:22 +0200
Subject: [PATCH 6/8] Fix SourceLink and IntelliSense on doc-comments in
Annotations
---
.../JsonApiDotNetCore.Annotations.csproj | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
index 5f4d7c1d76..1fe6858b96 100644
--- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
+++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
@@ -1,4 +1,4 @@
-
+
$(TargetFrameworkName);netstandard1.0
true
@@ -45,4 +45,9 @@
+
+
+
+
+
From 2113302dd3028768de9821c49be00f982aac4e65 Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Wed, 19 Oct 2022 17:30:01 +0200
Subject: [PATCH 7/8] Update docs/usage/resources/relationships.md
Co-authored-by: Maurits Moeys
---
docs/usage/resources/relationships.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md
index 14ff2eb7f5..d1f36a89a9 100644
--- a/docs/usage/resources/relationships.md
+++ b/docs/usage/resources/relationships.md
@@ -180,7 +180,7 @@ 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 this setting does not affect retrieving the related resources directly.
+Note that this setting does not affect retrieving the related resources directly.
```c#
#nullable enable
From 4b725bd26359af123f4fb1280cde3bb8c67ac1bc Mon Sep 17 00:00:00 2001
From: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
Date: Wed, 19 Oct 2022 19:08:12 +0200
Subject: [PATCH 8/8] Fixed typo
---
.../Serialization/Response/ResourceObjectTreeNode.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
index 9527f766e1..4c3a44fe38 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
@@ -183,11 +183,11 @@ public IList GetResponseIncluded()
List includes = visited.Select(node => node.ResourceObject).ToList();
- foreach (ResourceObject primaryResourceObjects in GetDirectChildren().Select(node => node.ResourceObject))
+ foreach (ResourceObject primaryResourceObject in GetDirectChildren().Select(node => node.ResourceObject))
{
- if (includes.Contains(primaryResourceObjects, ResourceObjectComparer.Instance))
+ if (includes.Contains(primaryResourceObject, ResourceObjectComparer.Instance))
{
- includes.Remove(primaryResourceObjects);
+ includes.Remove(primaryResourceObject);
}
}