Skip to content

Configurable default attribute capabilities #721

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
4 changes: 2 additions & 2 deletions src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
25 changes: 15 additions & 10 deletions src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,30 +86,35 @@ protected virtual List<AttrAttribute> GetAttributes(Type entityType)
{
var attributes = new List<AttrAttribute>();

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);
}
Expand Down
7 changes: 7 additions & 0 deletions src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using JsonApiDotNetCore.Models;
using JsonApiDotNetCore.Models.JsonApiDocuments;
using Newtonsoft.Json;

Expand Down Expand Up @@ -59,5 +60,11 @@ public interface IJsonApiOptions : ILinksConfiguration
/// </example>
/// </summary>
JsonSerializerSettings SerializerSettings { get; }

/// <summary>
/// Specifies the default query string capabilities that can be used on exposed json:api attributes.
/// Defaults to <see cref="AttrCapabilities.All"/>.
/// </summary>
AttrCapabilities DefaultAttrCapabilities { get; }
}
}
4 changes: 4 additions & 0 deletions src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JsonApiDotNetCore.Graph;
using JsonApiDotNetCore.Models;
using JsonApiDotNetCore.Models.Links;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
Expand Down Expand Up @@ -58,6 +59,9 @@ public class JsonApiOptions : IJsonApiOptions
/// <inheritdoc/>
public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; }

/// <inheritdoc/>
public AttrCapabilities DefaultAttrCapabilities { get; } = AttrCapabilities.All;

/// <summary>
/// The default page size for all resources. The value zero means: no paging.
/// </summary>
Expand Down
77 changes: 47 additions & 30 deletions src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,63 +8,80 @@ namespace JsonApiDotNetCore.Models
public sealed class AttrAttribute : Attribute, IResourceField
{
/// <summary>
/// Defines a public attribute exposed by the API
/// Exposes a resource property as a json:api attribute using the configured casing convention and capabilities.
/// </summary>
///
/// <param name="publicName">How this attribute is exposed through the API</param>
/// <param name="isImmutable">Prevent PATCH requests from updating the value</param>
/// <param name="isFilterable">Prevent filters on this attribute</param>
/// <param name="isSortable">Prevent this attribute from being sorted by</param>
///
/// <example>
///
/// <code>
/// public class Author : Identifiable
/// {
/// [Attr]
/// public string Name { get; set; }
/// }
/// </code>
///
/// </example>
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;

/// <summary>
/// How this attribute is exposed through the API
/// Exposes a resource property as a json:api attribute with an explicit name, using configured capabilities.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
public bool IsImmutable { get; }
/// <example>
/// <code>
/// public class Author : Identifiable
/// {
/// [Attr(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)]
/// public string Name { get; set; }
/// }
/// </code>
/// </example>
public AttrAttribute(AttrCapabilities capabilities)
{
HasExplicitCapabilities = true;
Capabilities = capabilities;
}

/// <summary>
/// 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.
/// </summary>
public bool IsFilterable { get; }
public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(publicName)
{
HasExplicitCapabilities = true;
Capabilities = capabilities;
}

public string ExposedInternalMemberName => PropertyInfo.Name;

/// <summary>
/// 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.
/// </summary>
public bool IsSortable { get; }
public string PublicAttributeName { get; internal set; }

internal bool HasExplicitCapabilities { get; }
public AttrCapabilities Capabilities { get; internal set; }

/// <summary>
/// The member property info
/// Provides access to the property on which this attribute is applied.
/// </summary>
public PropertyInfo PropertyInfo { get; set; }
public PropertyInfo PropertyInfo { get; internal set; }

/// <summary>
/// Get the value of the attribute for the given object.
Expand Down
33 changes: 33 additions & 0 deletions src/JsonApiDotNetCore/Models/AttrCapabilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;

namespace JsonApiDotNetCore.Models
{
/// <summary>
/// Indicates query string capabilities that can be performed on an <see cref="AttrAttribute"/>.
/// </summary>
[Flags]
public enum AttrCapabilities
{
None = 0,

/// <summary>
/// Whether or not PATCH requests can update the attribute value.
/// Attempts to update when disabled will return an HTTP 422 response.
/// </summary>
AllowMutate = 1,

/// <summary>
/// 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.
/// </summary>
AllowFilter = 2,

/// <summary>
/// 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.
/// </summary>
AllowSort = 4,

All = AllowMutate | AllowFilter | AllowSort
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
3 changes: 2 additions & 1 deletion src/JsonApiDotNetCore/QueryParameterServices/SortService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.");
Expand Down
13 changes: 10 additions & 3 deletions src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System;
using JsonApiDotNetCore.Exceptions;
using JsonApiDotNetCore.Internal.Contracts;
using JsonApiDotNetCore.Models;

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -55,7 +56,11 @@ public void DeserializeAttributes_UpdatedImmutableMember_ThrowsInvalidOperationE
var body = JsonConvert.SerializeObject(content);

// Act, assert
Assert.Throws<InvalidOperationException>(() => _deserializer.Deserialize(body));
var exception = Assert.Throws<InvalidRequestBodyException>(() => _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]
Expand Down
2 changes: 1 addition & 1 deletion test/UnitTests/TestModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down