Skip to content

Break up AttrCapabilities.AllowChange #826

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
Sep 17, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 14 additions & 2 deletions docs/usage/resources/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,21 @@ public class User : Identifiable<int>
}
```

### Mutability
### Createability

Attributes can be marked as mutable, which will allow `PATCH` requests to update them. When immutable, an HTTP 422 response is returned.
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.

```c#
public class Person : Identifiable<int>
{
[Attr(Capabilities = AttrCapabilities.AllowCreate)]
public string CreatorName { get; set; }
}
```

### Changeability

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.

```c#
public class Person : Identifiable<int>
Expand Down
2 changes: 1 addition & 1 deletion src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public string BirthCountryName
[EagerLoad]
public Country BirthCountry { get; set; }

[Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)]
[Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))]
[NotMapped]
public string GrantedVisaCountries => GrantedVisas == null || !GrantedVisas.Any()
? null
Expand Down
6 changes: 3 additions & 3 deletions src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public TodoItem()
[Attr]
public Guid GuidProperty { get; set; }

[Attr]
[Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)]
public string AlwaysChangingValue
{
get => Guid.NewGuid().ToString();
Expand All @@ -39,10 +39,10 @@ public string AlwaysChangingValue
[Attr]
public DateTime? UpdatedDate { get; set; }

[Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)]
[Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))]
public string CalculatedValue => "calculated";

[Attr]
[Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)]
public DateTimeOffset? OffsetDate { get; set; }

public int? OwnerId { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion src/Examples/JsonApiDotNetCoreExample/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class User : Identifiable

[Attr] public string UserName { get; set; }

[Attr(Capabilities = AttrCapabilities.AllowChange)]
[Attr(Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)]
public string Password
{
get => _password;
Expand Down
20 changes: 13 additions & 7 deletions src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,35 @@ public enum AttrCapabilities
None = 0,

/// <summary>
/// Whether or not GET requests can return the attribute.
/// Attempts to retrieve when disabled will return an HTTP 422 response.
/// Whether or not GET requests can retrieve the attribute.
/// Attempts to retrieve when disabled will return an HTTP 400 response.
/// </summary>
AllowView = 1,

/// <summary>
/// Whether or not POST requests can assign the attribute value.
/// Attempts to assign when disabled will return an HTTP 422 response.
/// </summary>
AllowCreate = 2,

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

/// <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.
/// Attempts to filter when disabled will return an HTTP 400 response.
/// </summary>
AllowFilter = 4,
AllowFilter = 8,

/// <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 = 8,
AllowSort = 16,

All = AllowView | AllowChange | AllowFilter | AllowSort
All = AllowView | AllowCreate | AllowChange | AllowFilter | AllowSort
}
}
13 changes: 10 additions & 3 deletions src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,23 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA
{
if (field is AttrAttribute attr)
{
if (attr.Capabilities.HasFlag(AttrCapabilities.AllowChange))
if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Post.Method &&
!attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate))
{
_targetedFields.Attributes.Add(attr);
throw new InvalidRequestBodyException(
"Assigning to the requested attribute is not allowed.",
$"Assigning to '{attr.PublicName}' is not allowed.", null);
}
else

if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method &&
!attr.Capabilities.HasFlag(AttrCapabilities.AllowChange))
{
throw new InvalidRequestBodyException(
"Changing the value of the requested attribute is not allowed.",
$"Changing the value of '{attr.PublicName}' is not allowed.", null);
}

_targetedFields.Attributes.Add(attr);
}
else if (field is RelationshipAttribute relationship)
_targetedFields.Relationships.Add(relationship);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,39 @@ public async Task CreateResource_UnknownResourceType_Fails()
Assert.Contains("Request body: <<", errorDocument.Errors[0].Detail);
}

[Fact]
public async Task CreateResource_Blocked_Fails()
{
// Arrange
var content = new
{
data = new
{
type = "todoItems",
attributes = new Dictionary<string, object>
{
{ "alwaysChangingValue", "X" }
}
}
};

var requestBody = JsonConvert.SerializeObject(content);

// Act
var (body, response) = await Post("/api/v1/todoItems", requestBody);

// Assert
AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response);

var errorDocument = JsonConvert.DeserializeObject<ErrorDocument>(body);
Assert.Single(errorDocument.Errors);

var error = errorDocument.Errors.Single();
Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode);
Assert.Equal("Failed to deserialize request body: Assigning to the requested attribute is not allowed.", error.Title);
Assert.StartsWith("Assigning to 'alwaysChangingValue' is not allowed. - Request body:", error.Detail);
}

[Fact]
public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
Expand Down Expand Up @@ -252,6 +253,51 @@ public async Task Respond_422_If_Broken_JSON_Payload()
Assert.StartsWith("Invalid character after parsing", error.Detail);
}

[Fact]
public async Task Respond_422_If_Blocked_For_Update()
{
// Arrange
var todoItem = _todoItemFaker.Generate();
_context.TodoItems.Add(todoItem);
await _context.SaveChangesAsync();

var content = new
{
data = new
{
type = "todoItems",
id = todoItem.StringId,
attributes = new Dictionary<string, object>
{
{ "offsetDate", "2000-01-01" }
}
}
};

var builder = WebHost.CreateDefaultBuilder()
.UseStartup<TestStartup>();
var server = new TestServer(builder);
var client = server.CreateClient();

var requestBody = JsonConvert.SerializeObject(content);
var request = PrepareRequest(HttpMethod.Patch.Method, "/api/v1/todoItems/" + todoItem.StringId, requestBody);

// Act
var response = await client.SendAsync(request);

// Assert
AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response);

var responseBody = await response.Content.ReadAsStringAsync();
var errorDocument = JsonConvert.DeserializeObject<ErrorDocument>(responseBody);
Assert.Single(errorDocument.Errors);

var error = errorDocument.Errors.Single();
Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode);
Assert.Equal("Failed to deserialize request body: Changing the value of the requested attribute is not allowed.", error.Title);
Assert.StartsWith("Changing the value of 'offsetDate' is not allowed. - Request body:", error.Detail);
}

[Fact]
public async Task Can_Patch_Resource()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ public void SerializeSingle_ResourceWithDefaultTargetFields_CanBuild()
""intField"":0,
""nullableIntField"":123,
""guidField"":""00000000-0000-0000-0000-000000000000"",
""complexField"":null,
""immutable"":null
""complexField"":null
}
}
}";
Expand Down
29 changes: 0 additions & 29 deletions test/UnitTests/Serialization/Server/RequestDeserializerTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System.Collections.Generic;
using System.ComponentModel.Design;
using System.Net;
using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Serialization;
Expand Down Expand Up @@ -37,33 +35,6 @@ public void DeserializeAttributes_VariousUpdatedMembers_RegistersTargetedFields(
Assert.Empty(relationshipsToUpdate);
}

[Fact]
public void DeserializeAttributes_UpdatedImmutableMember_ThrowsInvalidOperationException()
{
// Arrange
SetupFieldsManager(out _, out _);
var content = new Document
{
Data = new ResourceObject
{
Type = "testResource",
Id = "1",
Attributes = new Dictionary<string, object>
{
{ "immutable", "some string" },
}
}
};
var body = JsonConvert.SerializeObject(content);

// Act, assert
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]
public void DeserializeRelationships_MultipleDependentRelationships_RegistersUpdatedRelationships()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ public void SerializeSingle_ResourceWithDefaultTargetFields_CanSerialize()
""intField"":0,
""nullableIntField"":123,
""guidField"":""00000000-0000-0000-0000-000000000000"",
""complexField"":null,
""immutable"":null
""complexField"":null
}
}
}";
Expand Down Expand Up @@ -67,8 +66,7 @@ public void SerializeMany_ResourceWithDefaultTargetFields_CanSerialize()
""intField"":0,
""nullableIntField"":123,
""guidField"":""00000000-0000-0000-0000-000000000000"",
""complexField"":null,
""immutable"":null
""complexField"":null
}
}]
}";
Expand Down
1 change: 0 additions & 1 deletion test/UnitTests/TestModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public sealed class TestResource : Identifiable
[Attr] public int? NullableIntField { get; set; }
[Attr] public Guid GuidField { get; set; }
[Attr] public ComplexType ComplexField { get; set; }
[Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public string Immutable { get; set; }
}

public class TestResourceWithList : Identifiable
Expand Down