diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index d0574f2fa1..795030598c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -125,7 +125,9 @@ private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState s return new ResourceIdentityRequirements { EvaluateIdConstraint = resourceType => - ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration) + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration), + EvaluateAllowLid = resourceType => + ResourceIdentityRequirements.DoEvaluateAllowLid(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration) }; } @@ -135,6 +137,7 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen { ResourceType = refResult.ResourceType, EvaluateIdConstraint = refRequirements.EvaluateIdConstraint, + EvaluateAllowLid = refRequirements.EvaluateAllowLid, IdValue = refResult.Resource.StringId, LidValue = refResult.Resource.LocalId, RelationshipName = refResult.Relationship?.PublicName diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index 5925524e6b..0e7f292394 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -1,4 +1,5 @@ using System.Collections; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -71,6 +72,7 @@ private static SingleOrManyData ToIdentifierData(Singl { ResourceType = relationship.RightType, EvaluateIdConstraint = _ => JsonElementConstraint.Required, + EvaluateAllowLid = _ => state.Request.Kind == EndpointKind.AtomicOperations, RelationshipName = relationship.PublicName }; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index be596dae7c..5e25dba0f7 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -96,18 +96,20 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType, RequestAdapterState state) { - if (state.Request.Kind != EndpointKind.AtomicOperations) + AssertNoIdWithLid(identity, state); + + bool allowLid = requirements.EvaluateAllowLid?.Invoke(resourceType) ?? false; + + if (!allowLid) { AssertHasNoLid(identity, state); } - AssertNoIdWithLid(identity, state); - JsonElementConstraint? idConstraint = requirements.EvaluateIdConstraint?.Invoke(resourceType); if (idConstraint == JsonElementConstraint.Required) { - AssertHasIdOrLid(identity, requirements, state); + AssertHasIdOrLid(identity, requirements, allowLid, state); } else if (idConstraint == JsonElementConstraint.Forbidden) { @@ -128,7 +130,10 @@ private static void AssertHasNoLid(ResourceIdentity identity, RequestAdapterStat if (identity.Lid != null) { using IDisposable _ = state.Position.PushElement("lid"); - throw new ModelConversionException(state.Position, "The 'lid' element is not supported at this endpoint.", null); + + throw state.Request.Kind == EndpointKind.AtomicOperations + ? new ModelConversionException(state.Position, "The 'lid' element cannot be used because a client-generated ID is required.", null) + : new ModelConversionException(state.Position, "The 'lid' element is not supported at this endpoint.", null); } } @@ -140,7 +145,7 @@ private static void AssertNoIdWithLid(ResourceIdentity identity, RequestAdapterS } } - private static void AssertHasIdOrLid(ResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + private static void AssertHasIdOrLid(ResourceIdentity identity, ResourceIdentityRequirements requirements, bool allowLid, RequestAdapterState state) { string? message = null; @@ -154,7 +159,7 @@ private static void AssertHasIdOrLid(ResourceIdentity identity, ResourceIdentity } else if (identity.Id == null && identity.Lid == null) { - message = state.Request.Kind == EndpointKind.AtomicOperations ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; + message = allowLid ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; } if (message != null) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index 0d26b807d6..0168d2d5ea 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -21,6 +21,11 @@ public sealed class ResourceIdentityRequirements /// public Func? EvaluateIdConstraint { get; init; } + /// + /// When not null, provides a callback to indicate whether the "lid" element can be used instead of the "id" element. Defaults to false. + /// + public Func? EvaluateAllowLid { get; init; } + /// /// When not null, indicates what the value of the "id" element must be. /// @@ -50,4 +55,15 @@ public sealed class ResourceIdentityRequirements } : JsonElementConstraint.Required; } + + internal static bool DoEvaluateAllowLid(ResourceType resourceType, WriteOperationKind? writeOperation, ClientIdGenerationMode globalClientIdGeneration) + { + if (writeOperation == null) + { + return false; + } + + ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? globalClientIdGeneration; + return !(writeOperation == WriteOperationKind.CreateResource && clientIdGeneration == ClientIdGenerationMode.Required); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 62013322e5..e69cfb7d1a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -175,7 +175,7 @@ public async Task Cannot_create_resource_for_missing_client_generated_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); error.Detail.Should().BeNull(); error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); @@ -281,6 +281,45 @@ public async Task Cannot_create_resource_for_incompatible_ID() error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } + [Fact] + public async Task Cannot_create_resource_with_local_ID() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + lid = "new-server-id" + } + } + } + }; + + 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: The 'lid' element cannot be used because a client-generated ID is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_resource_for_ID_and_local_ID() {