Skip to content

Commit 237dbba

Browse files
author
Bart Koelman
authored
Configurable default attribute capabilities (#721)
* Configurable default attribute capabilities * Removed original constructor
1 parent 6b16ea3 commit 237dbba

File tree

13 files changed

+130
-51
lines changed

13 files changed

+130
-51
lines changed

src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public string BirthCountryName
5252
[EagerLoad]
5353
public Country BirthCountry { get; set; }
5454

55-
[Attr(isImmutable: true)]
55+
[Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)]
5656
[NotMapped]
5757
public string GrantedVisaCountries
5858
{

src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ public string AlwaysChangingValue
3232
[Attr]
3333
public DateTime CreatedDate { get; set; }
3434

35-
[Attr(isFilterable: false, isSortable: false)]
35+
[Attr(AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))]
3636
public DateTime? AchievedDate { get; set; }
3737

3838
[Attr]
3939
public DateTime? UpdatedDate { get; set; }
4040

41-
[Attr(isImmutable: true)]
41+
[Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)]
4242
public string CalculatedValue => "calculated";
4343

4444
[Attr]

src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs

+15-10
Original file line numberDiff line numberDiff line change
@@ -86,30 +86,35 @@ protected virtual List<AttrAttribute> GetAttributes(Type entityType)
8686
{
8787
var attributes = new List<AttrAttribute>();
8888

89-
var properties = entityType.GetProperties();
90-
91-
foreach (var prop in properties)
89+
foreach (var property in entityType.GetProperties())
9290
{
93-
// todo: investigate why this is added in the exposed attributes list
91+
var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute));
92+
93+
// TODO: investigate why this is added in the exposed attributes list
9494
// because it is not really defined attribute considered from the json:api
9595
// spec point of view.
96-
if (prop.Name == nameof(Identifiable.Id))
96+
if (property.Name == nameof(Identifiable.Id) && attribute == null)
9797
{
9898
var idAttr = new AttrAttribute
9999
{
100-
PublicAttributeName = FormatPropertyName(prop),
101-
PropertyInfo = prop
100+
PublicAttributeName = FormatPropertyName(property),
101+
PropertyInfo = property,
102+
Capabilities = _options.DefaultAttrCapabilities
102103
};
103104
attributes.Add(idAttr);
104105
continue;
105106
}
106107

107-
var attribute = (AttrAttribute)prop.GetCustomAttribute(typeof(AttrAttribute));
108108
if (attribute == null)
109109
continue;
110110

111-
attribute.PublicAttributeName ??= FormatPropertyName(prop);
112-
attribute.PropertyInfo = prop;
111+
attribute.PublicAttributeName ??= FormatPropertyName(property);
112+
attribute.PropertyInfo = property;
113+
114+
if (!attribute.HasExplicitCapabilities)
115+
{
116+
attribute.Capabilities = _options.DefaultAttrCapabilities;
117+
}
113118

114119
attributes.Add(attribute);
115120
}

src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs

+7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using JsonApiDotNetCore.Models;
23
using JsonApiDotNetCore.Models.JsonApiDocuments;
34
using Newtonsoft.Json;
45

@@ -59,5 +60,11 @@ public interface IJsonApiOptions : ILinksConfiguration
5960
/// </example>
6061
/// </summary>
6162
JsonSerializerSettings SerializerSettings { get; }
63+
64+
/// <summary>
65+
/// Specifies the default query string capabilities that can be used on exposed json:api attributes.
66+
/// Defaults to <see cref="AttrCapabilities.All"/>.
67+
/// </summary>
68+
AttrCapabilities DefaultAttrCapabilities { get; }
6269
}
6370
}

src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

+4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using JsonApiDotNetCore.Graph;
2+
using JsonApiDotNetCore.Models;
23
using JsonApiDotNetCore.Models.Links;
34
using Newtonsoft.Json;
45
using Newtonsoft.Json.Serialization;
@@ -58,6 +59,9 @@ public class JsonApiOptions : IJsonApiOptions
5859
/// <inheritdoc/>
5960
public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; }
6061

62+
/// <inheritdoc/>
63+
public AttrCapabilities DefaultAttrCapabilities { get; } = AttrCapabilities.All;
64+
6165
/// <summary>
6266
/// The default page size for all resources. The value zero means: no paging.
6367
/// </summary>

src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs

+47-30
Original file line numberDiff line numberDiff line change
@@ -8,63 +8,80 @@ namespace JsonApiDotNetCore.Models
88
public sealed class AttrAttribute : Attribute, IResourceField
99
{
1010
/// <summary>
11-
/// Defines a public attribute exposed by the API
11+
/// Exposes a resource property as a json:api attribute using the configured casing convention and capabilities.
1212
/// </summary>
13-
///
14-
/// <param name="publicName">How this attribute is exposed through the API</param>
15-
/// <param name="isImmutable">Prevent PATCH requests from updating the value</param>
16-
/// <param name="isFilterable">Prevent filters on this attribute</param>
17-
/// <param name="isSortable">Prevent this attribute from being sorted by</param>
18-
///
1913
/// <example>
20-
///
2114
/// <code>
2215
/// public class Author : Identifiable
2316
/// {
2417
/// [Attr]
2518
/// public string Name { get; set; }
2619
/// }
2720
/// </code>
28-
///
2921
/// </example>
30-
public AttrAttribute(string publicName = null, bool isImmutable = false, bool isFilterable = true, bool isSortable = true)
22+
public AttrAttribute()
3123
{
32-
PublicAttributeName = publicName;
33-
IsImmutable = isImmutable;
34-
IsFilterable = isFilterable;
35-
IsSortable = isSortable;
3624
}
3725

38-
public string ExposedInternalMemberName => PropertyInfo.Name;
39-
4026
/// <summary>
41-
/// How this attribute is exposed through the API
27+
/// Exposes a resource property as a json:api attribute with an explicit name, using configured capabilities.
4228
/// </summary>
43-
public string PublicAttributeName { get; internal set; }
29+
public AttrAttribute(string publicName)
30+
{
31+
if (publicName == null)
32+
{
33+
throw new ArgumentNullException(nameof(publicName));
34+
}
35+
36+
if (string.IsNullOrWhiteSpace(publicName))
37+
{
38+
throw new ArgumentException("Exposed name cannot be empty or contain only whitespace.", nameof(publicName));
39+
}
40+
41+
PublicAttributeName = publicName;
42+
}
4443

4544
/// <summary>
46-
/// Prevents PATCH requests from updating the value.
45+
/// Exposes a resource property as a json:api attribute using the configured casing convention and an explicit set of capabilities.
4746
/// </summary>
48-
public bool IsImmutable { get; }
47+
/// <example>
48+
/// <code>
49+
/// public class Author : Identifiable
50+
/// {
51+
/// [Attr(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)]
52+
/// public string Name { get; set; }
53+
/// }
54+
/// </code>
55+
/// </example>
56+
public AttrAttribute(AttrCapabilities capabilities)
57+
{
58+
HasExplicitCapabilities = true;
59+
Capabilities = capabilities;
60+
}
4961

5062
/// <summary>
51-
/// Whether or not this attribute can be filtered on via a query string filters.
52-
/// Attempts to filter on an attribute with `IsFilterable == false` will return
53-
/// an HTTP 400 response.
63+
/// Exposes a resource property as a json:api attribute with an explicit name and capabilities.
5464
/// </summary>
55-
public bool IsFilterable { get; }
65+
public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(publicName)
66+
{
67+
HasExplicitCapabilities = true;
68+
Capabilities = capabilities;
69+
}
70+
71+
public string ExposedInternalMemberName => PropertyInfo.Name;
5672

5773
/// <summary>
58-
/// Whether or not this attribute can be sorted on via a query string sort.
59-
/// Attempts to filter on an attribute with `IsSortable == false` will return
60-
/// an HTTP 400 response.
74+
/// The publicly exposed name of this json:api attribute.
6175
/// </summary>
62-
public bool IsSortable { get; }
76+
public string PublicAttributeName { get; internal set; }
77+
78+
internal bool HasExplicitCapabilities { get; }
79+
public AttrCapabilities Capabilities { get; internal set; }
6380

6481
/// <summary>
65-
/// The member property info
82+
/// Provides access to the property on which this attribute is applied.
6683
/// </summary>
67-
public PropertyInfo PropertyInfo { get; set; }
84+
public PropertyInfo PropertyInfo { get; internal set; }
6885

6986
/// <summary>
7087
/// Get the value of the attribute for the given object.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
3+
namespace JsonApiDotNetCore.Models
4+
{
5+
/// <summary>
6+
/// Indicates query string capabilities that can be performed on an <see cref="AttrAttribute"/>.
7+
/// </summary>
8+
[Flags]
9+
public enum AttrCapabilities
10+
{
11+
None = 0,
12+
13+
/// <summary>
14+
/// Whether or not PATCH requests can update the attribute value.
15+
/// Attempts to update when disabled will return an HTTP 422 response.
16+
/// </summary>
17+
AllowMutate = 1,
18+
19+
/// <summary>
20+
/// Whether or not an attribute can be filtered on via a query string parameter.
21+
/// Attempts to sort when disabled will return an HTTP 400 response.
22+
/// </summary>
23+
AllowFilter = 2,
24+
25+
/// <summary>
26+
/// Whether or not an attribute can be sorted on via a query string parameter.
27+
/// Attempts to sort when disabled will return an HTTP 400 response.
28+
/// </summary>
29+
AllowSort = 4,
30+
31+
All = AllowMutate | AllowFilter | AllowSort
32+
}
33+
}

src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterN
6363
queryContext.Relationship = GetRelationship(parameterName, query.Relationship);
6464
var attribute = GetAttribute(parameterName, query.Attribute, queryContext.Relationship);
6565

66-
if (!attribute.IsFilterable)
66+
if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter))
6767
{
6868
throw new InvalidQueryStringParameterException(parameterName, "Filtering on the requested attribute is not allowed.",
6969
$"Filtering on attribute '{attribute.PublicAttributeName}' is not allowed.");

src/JsonApiDotNetCore/QueryParameterServices/SortService.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using JsonApiDotNetCore.Internal.Contracts;
66
using JsonApiDotNetCore.Internal.Query;
77
using JsonApiDotNetCore.Managers.Contracts;
8+
using JsonApiDotNetCore.Models;
89
using Microsoft.Extensions.Primitives;
910

1011
namespace JsonApiDotNetCore.Query
@@ -90,7 +91,7 @@ private SortQueryContext BuildQueryContext(SortQuery query)
9091
var relationship = GetRelationship("sort", query.Relationship);
9192
var attribute = GetAttribute("sort", query.Attribute, relationship);
9293

93-
if (!attribute.IsSortable)
94+
if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort))
9495
{
9596
throw new InvalidQueryStringParameterException("sort", "Sorting on the requested attribute is not allowed.",
9697
$"Sorting on attribute '{attribute.PublicAttributeName}' is not allowed.");

src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs

+10-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System;
1+
using System;
2+
using JsonApiDotNetCore.Exceptions;
23
using JsonApiDotNetCore.Internal.Contracts;
34
using JsonApiDotNetCore.Models;
45

@@ -34,10 +35,16 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f
3435
{
3536
if (field is AttrAttribute attr)
3637
{
37-
if (!attr.IsImmutable)
38+
if (attr.Capabilities.HasFlag(AttrCapabilities.AllowMutate))
39+
{
3840
_targetedFields.Attributes.Add(attr);
41+
}
3942
else
40-
throw new InvalidOperationException($"Attribute {attr.PublicAttributeName} is immutable and therefore cannot be updated.");
43+
{
44+
throw new InvalidRequestBodyException(
45+
"Changing the value of the requested attribute is not allowed.",
46+
$"Changing the value of '{attr.PublicAttributeName}' is not allowed.", null);
47+
}
4148
}
4249
else if (field is RelationshipAttribute relationship)
4350
_targetedFields.Relationships.Add(relationship);

test/UnitTests/Serialization/Server/RequestDeserializerTests.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
using System;
21
using System.Collections.Generic;
2+
using System.Net;
3+
using JsonApiDotNetCore.Exceptions;
34
using JsonApiDotNetCore.Models;
45
using JsonApiDotNetCore.Serialization;
56
using JsonApiDotNetCore.Serialization.Server;
@@ -55,7 +56,11 @@ public void DeserializeAttributes_UpdatedImmutableMember_ThrowsInvalidOperationE
5556
var body = JsonConvert.SerializeObject(content);
5657

5758
// Act, assert
58-
Assert.Throws<InvalidOperationException>(() => _deserializer.Deserialize(body));
59+
var exception = Assert.Throws<InvalidRequestBodyException>(() => _deserializer.Deserialize(body));
60+
61+
Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.Error.StatusCode);
62+
Assert.Equal("Failed to deserialize request body: Changing the value of the requested attribute is not allowed.", exception.Error.Title);
63+
Assert.Equal("Changing the value of 'immutable' is not allowed.", exception.Error.Detail);
5964
}
6065

6166
[Fact]

test/UnitTests/TestModels.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public sealed class TestResource : Identifiable
1313
[Attr] public int? NullableIntField { get; set; }
1414
[Attr] public Guid GuidField { get; set; }
1515
[Attr] public ComplexType ComplexField { get; set; }
16-
[Attr(isImmutable: true)] public string Immutable { get; set; }
16+
[Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] public string Immutable { get; set; }
1717
}
1818

1919
public class TestResourceWithList : Identifiable

0 commit comments

Comments
 (0)