From 10a8a979a3563058861dca602ababae64e7b2d69 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 13 Apr 2020 13:54:29 +0200 Subject: [PATCH 1/2] Configurable default attribute capabilities --- .../Models/Passport.cs | 2 +- .../Models/TodoItem.cs | 4 +- .../Builders/ResourceGraphBuilder.cs | 25 +++-- .../Configuration/IJsonApiOptions.cs | 7 ++ .../Configuration/JsonApiOptions.cs | 4 + .../Models/Annotation/AttrAttribute.cs | 96 ++++++++++++++----- .../Models/AttrCapabilities.cs | 33 +++++++ .../Models/{Annotation => }/IResourceField.cs | 0 .../QueryParameterServices/FilterService.cs | 2 +- .../QueryParameterServices/SortService.cs | 3 +- .../Server/RequestDeserializer.cs | 13 ++- .../Server/RequestDeserializerTests.cs | 9 +- test/UnitTests/TestModels.cs | 2 +- 13 files changed, 154 insertions(+), 46 deletions(-) create mode 100644 src/JsonApiDotNetCore/Models/AttrCapabilities.cs rename src/JsonApiDotNetCore/Models/{Annotation => }/IResourceField.cs (100%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 2951a40bbb..24b2dd3011 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -52,7 +52,7 @@ public string BirthCountryName [EagerLoad] public Country BirthCountry { get; set; } - [Attr(isImmutable: true)] + [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] [NotMapped] public string GrantedVisaCountries { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 8566ac80c1..5c1487e842 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -32,13 +32,13 @@ public string AlwaysChangingValue [Attr] public DateTime CreatedDate { get; set; } - [Attr(isFilterable: false, isSortable: false)] + [Attr(AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? AchievedDate { get; set; } [Attr] public DateTime? UpdatedDate { get; set; } - [Attr(isImmutable: true)] + [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] public string CalculatedValue => "calculated"; [Attr] diff --git a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs index f661caf03c..ce30bd5dee 100644 --- a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs @@ -86,30 +86,35 @@ protected virtual List GetAttributes(Type entityType) { var attributes = new List(); - var properties = entityType.GetProperties(); - - foreach (var prop in properties) + foreach (var property in entityType.GetProperties()) { - // todo: investigate why this is added in the exposed attributes list + var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); + + // TODO: investigate why this is added in the exposed attributes list // because it is not really defined attribute considered from the json:api // spec point of view. - if (prop.Name == nameof(Identifiable.Id)) + if (property.Name == nameof(Identifiable.Id) && attribute == null) { var idAttr = new AttrAttribute { - PublicAttributeName = FormatPropertyName(prop), - PropertyInfo = prop + PublicAttributeName = FormatPropertyName(property), + PropertyInfo = property, + Capabilities = _options.DefaultAttrCapabilities }; attributes.Add(idAttr); continue; } - var attribute = (AttrAttribute)prop.GetCustomAttribute(typeof(AttrAttribute)); if (attribute == null) continue; - attribute.PublicAttributeName ??= FormatPropertyName(prop); - attribute.PropertyInfo = prop; + attribute.PublicAttributeName ??= FormatPropertyName(property); + attribute.PropertyInfo = property; + + if (!attribute.HasExplicitCapabilities) + { + attribute.Capabilities = _options.DefaultAttrCapabilities; + } attributes.Add(attribute); } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index cf886b6730..a30d36d083 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,4 +1,5 @@ using System; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; @@ -59,5 +60,11 @@ public interface IJsonApiOptions : ILinksConfiguration /// /// JsonSerializerSettings SerializerSettings { get; } + + /// + /// Specifies the default query string capabilities that can be used on exposed json:api attributes. + /// Defaults to . + /// + AttrCapabilities DefaultAttrCapabilities { get; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index d7bc3d4bd9..37335db261 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Links; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -58,6 +59,9 @@ public class JsonApiOptions : IJsonApiOptions /// public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; } + /// + public AttrCapabilities DefaultAttrCapabilities { get; } = AttrCapabilities.All; + /// /// The default page size for all resources. The value zero means: no paging. /// diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index a399222264..05f086ca30 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -10,14 +10,36 @@ public sealed class AttrAttribute : Attribute, IResourceField /// /// Defines a public attribute exposed by the API /// - /// /// How this attribute is exposed through the API /// Prevent PATCH requests from updating the value /// Prevent filters on this attribute /// Prevent this attribute from being sorted by - /// + [Obsolete("Use one of the overloads with AttrCapabilities.", true)] + public AttrAttribute(string publicName = null, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) + { + PublicAttributeName = publicName; + HasExplicitCapabilities = true; + + if (!isImmutable) + { + Capabilities |= AttrCapabilities.AllowMutate; + } + + if (isFilterable) + { + Capabilities |= AttrCapabilities.AllowFilter; + } + + if (isSortable) + { + Capabilities |= AttrCapabilities.AllowSort; + } + } + + /// + /// Exposes a resource property as a json:api attribute using the configured casing convention and capabilities. + /// /// - /// /// /// public class Author : Identifiable /// { @@ -25,46 +47,70 @@ public sealed class AttrAttribute : Attribute, IResourceField /// public string Name { get; set; } /// } /// - /// /// - public AttrAttribute(string publicName = null, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) + public AttrAttribute() { - PublicAttributeName = publicName; - IsImmutable = isImmutable; - IsFilterable = isFilterable; - IsSortable = isSortable; } - public string ExposedInternalMemberName => PropertyInfo.Name; - /// - /// How this attribute is exposed through the API + /// Exposes a resource property as a json:api attribute with an explicit name, using configured capabilities. /// - public string PublicAttributeName { get; internal set; } + public AttrAttribute(string publicName) + { + if (publicName == null) + { + throw new ArgumentNullException(nameof(publicName)); + } + + if (string.IsNullOrWhiteSpace(publicName)) + { + throw new ArgumentException("Exposed name cannot be empty or contain only whitespace.", nameof(publicName)); + } + + PublicAttributeName = publicName; + } /// - /// Prevents PATCH requests from updating the value. + /// Exposes a resource property as a json:api attribute using the configured casing convention and an explicit set of capabilities. /// - public bool IsImmutable { get; } + /// + /// + /// public class Author : Identifiable + /// { + /// [Attr(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + /// public string Name { get; set; } + /// } + /// + /// + public AttrAttribute(AttrCapabilities capabilities) + { + HasExplicitCapabilities = true; + Capabilities = capabilities; + } /// - /// Whether or not this attribute can be filtered on via a query string filters. - /// Attempts to filter on an attribute with `IsFilterable == false` will return - /// an HTTP 400 response. + /// Exposes a resource property as a json:api attribute with an explicit name and capabilities. /// - public bool IsFilterable { get; } + public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(publicName) + { + HasExplicitCapabilities = true; + Capabilities = capabilities; + } + + public string ExposedInternalMemberName => PropertyInfo.Name; /// - /// Whether or not this attribute can be sorted on via a query string sort. - /// Attempts to filter on an attribute with `IsSortable == false` will return - /// an HTTP 400 response. + /// The publicly exposed name of this json:api attribute. /// - public bool IsSortable { get; } + public string PublicAttributeName { get; internal set; } + + internal bool HasExplicitCapabilities { get; } + public AttrCapabilities Capabilities { get; internal set; } /// - /// The member property info + /// Provides access to the property on which this attribute is applied. /// - public PropertyInfo PropertyInfo { get; set; } + public PropertyInfo PropertyInfo { get; internal set; } /// /// Get the value of the attribute for the given object. diff --git a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs b/src/JsonApiDotNetCore/Models/AttrCapabilities.cs new file mode 100644 index 0000000000..102ba3893a --- /dev/null +++ b/src/JsonApiDotNetCore/Models/AttrCapabilities.cs @@ -0,0 +1,33 @@ +using System; + +namespace JsonApiDotNetCore.Models +{ + /// + /// Indicates query string capabilities that can be performed on an . + /// + [Flags] + public enum AttrCapabilities + { + None = 0, + + /// + /// Whether or not PATCH requests can update the attribute value. + /// Attempts to update when disabled will return an HTTP 422 response. + /// + AllowMutate = 1, + + /// + /// Whether or not an attribute can be filtered on via a query string parameter. + /// Attempts to sort when disabled will return an HTTP 400 response. + /// + AllowFilter = 2, + + /// + /// 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. + /// + AllowSort = 4, + + All = AllowMutate | AllowFilter | AllowSort + } +} diff --git a/src/JsonApiDotNetCore/Models/Annotation/IResourceField.cs b/src/JsonApiDotNetCore/Models/IResourceField.cs similarity index 100% rename from src/JsonApiDotNetCore/Models/Annotation/IResourceField.cs rename to src/JsonApiDotNetCore/Models/IResourceField.cs diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index 94c0a9fecd..0e3c4f3b8d 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -63,7 +63,7 @@ private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterN queryContext.Relationship = GetRelationship(parameterName, query.Relationship); var attribute = GetAttribute(parameterName, query.Attribute, queryContext.Relationship); - if (!attribute.IsFilterable) + if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) { throw new InvalidQueryStringParameterException(parameterName, "Filtering on the requested attribute is not allowed.", $"Filtering on attribute '{attribute.PublicAttributeName}' is not allowed."); diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index 54e0d5553e..e8f6a9d837 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Models; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -90,7 +91,7 @@ private SortQueryContext BuildQueryContext(SortQuery query) var relationship = GetRelationship("sort", query.Relationship); var attribute = GetAttribute("sort", query.Attribute, relationship); - if (!attribute.IsSortable) + if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) { throw new InvalidQueryStringParameterException("sort", "Sorting on the requested attribute is not allowed.", $"Sorting on attribute '{attribute.PublicAttributeName}' is not allowed."); diff --git a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs index 8e28d04c16..1302e2b0bb 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs @@ -1,4 +1,5 @@ -using System; +using System; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; @@ -34,10 +35,16 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f { if (field is AttrAttribute attr) { - if (!attr.IsImmutable) + if (attr.Capabilities.HasFlag(AttrCapabilities.AllowMutate)) + { _targetedFields.Attributes.Add(attr); + } else - throw new InvalidOperationException($"Attribute {attr.PublicAttributeName} is immutable and therefore cannot be updated."); + { + throw new InvalidRequestBodyException( + "Changing the value of the requested attribute is not allowed.", + $"Changing the value of '{attr.PublicAttributeName}' is not allowed.", null); + } } else if (field is RelationshipAttribute relationship) _targetedFields.Relationships.Add(relationship); diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index d1c47e6dca..b8bd224ad2 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,5 +1,6 @@ -using System; using System.Collections.Generic; +using System.Net; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; @@ -55,7 +56,11 @@ public void DeserializeAttributes_UpdatedImmutableMember_ThrowsInvalidOperationE var body = JsonConvert.SerializeObject(content); // Act, assert - Assert.Throws(() => _deserializer.Deserialize(body)); + var exception = Assert.Throws(() => _deserializer.Deserialize(body)); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.Error.StatusCode); + Assert.Equal("Failed to deserialize request body: Changing the value of the requested attribute is not allowed.", exception.Error.Title); + Assert.Equal("Changing the value of 'immutable' is not allowed.", exception.Error.Detail); } [Fact] diff --git a/test/UnitTests/TestModels.cs b/test/UnitTests/TestModels.cs index 208c323e61..398a14b5d8 100644 --- a/test/UnitTests/TestModels.cs +++ b/test/UnitTests/TestModels.cs @@ -13,7 +13,7 @@ public sealed class TestResource : Identifiable [Attr] public int? NullableIntField { get; set; } [Attr] public Guid GuidField { get; set; } [Attr] public ComplexType ComplexField { get; set; } - [Attr(isImmutable: true)] public string Immutable { get; set; } + [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] public string Immutable { get; set; } } public class TestResourceWithList : Identifiable From 4401cb62af7b320c7aa13f7d8896e73f9d039c76 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 13 Apr 2020 15:35:37 +0200 Subject: [PATCH 2/2] Removed original constructor --- .../Models/Annotation/AttrAttribute.cs | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index 05f086ca30..54ae5f2c15 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -7,35 +7,6 @@ namespace JsonApiDotNetCore.Models [AttributeUsage(AttributeTargets.Property)] public sealed class AttrAttribute : Attribute, IResourceField { - /// - /// Defines a public attribute exposed by the API - /// - /// How this attribute is exposed through the API - /// Prevent PATCH requests from updating the value - /// Prevent filters on this attribute - /// Prevent this attribute from being sorted by - [Obsolete("Use one of the overloads with AttrCapabilities.", true)] - public AttrAttribute(string publicName = null, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) - { - PublicAttributeName = publicName; - HasExplicitCapabilities = true; - - if (!isImmutable) - { - Capabilities |= AttrCapabilities.AllowMutate; - } - - if (isFilterable) - { - Capabilities |= AttrCapabilities.AllowFilter; - } - - if (isSortable) - { - Capabilities |= AttrCapabilities.AllowSort; - } - } - /// /// Exposes a resource property as a json:api attribute using the configured casing convention and capabilities. ///