From ce358fe1fbc3e0307728011134bdbb4eac34ab80 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 5 Sep 2023 00:48:41 +0200 Subject: [PATCH 1/2] Enable client-generated IDs per resource type. Obsolete boolean value in favor of enumeration Forbidden/Allowed/Required --- JsonApiDotNetCore.sln.DotSettings | 1 + docs/usage/options.md | 25 ++++++++-- .../ClientIdGenerationMode.shared.cs | 25 ++++++++++ .../Configuration/ResourceType.cs | 22 ++++++--- .../Annotations/ResourceAttribute.shared.cs | 13 ++++++ .../Configuration/IJsonApiOptions.cs | 44 ++++++++++++------ .../Configuration/JsonApiOptions.cs | 10 +++- .../Configuration/ResourceGraphBuilder.cs | 12 ++++- .../Adapters/AtomicOperationObjectAdapter.cs | 9 ++-- ...tInResourceOrRelationshipRequestAdapter.cs | 7 +-- .../Adapters/RelationshipDataAdapter.cs | 2 +- .../Adapters/ResourceIdentityAdapter.cs | 13 ++++-- .../Adapters/ResourceIdentityRequirements.cs | 20 +++++++- .../Response/ResourceObjectTreeNode.cs | 2 +- test/AnnotationTests/Models/TreeNode.cs | 4 +- ...reateResourceWithClientGeneratedIdTests.cs | 46 ++++++++++++++++++- .../Mixed/AtomicSerializationTests.cs | 2 +- .../CompositeKeys/CompositeKeyTests.cs | 4 -- .../ReadWrite/Creating/CreateResourceTests.cs | 1 - ...reateResourceWithClientGeneratedIdTests.cs | 39 +++++++++++++++- ...reateResourceWithToOneRelationshipTests.cs | 2 +- .../Serialization/ETagTests.cs | 2 + .../IntegrationTests/Serialization/Meeting.cs | 3 +- .../Serialization/SerializationTests.cs | 1 - .../ZeroKeys/EmptyGuidAsKeyTests.cs | 1 - .../IntegrationTests/ZeroKeys/Game.cs | 3 +- .../IntegrationTests/ZeroKeys/Map.cs | 3 +- .../ZeroKeys/ZeroAsKeyTests.cs | 1 - .../UnitTests/Links/LinkInclusionTests.cs | 9 ++-- 29 files changed, 260 insertions(+), 66 deletions(-) create mode 100644 src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 2b714c22d3..2602272e97 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -665,6 +665,7 @@ $left$ = $right$; True True True + True True True diff --git a/docs/usage/options.md b/docs/usage/options.md index 549bfc454c..7607ac8a9e 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -10,16 +10,35 @@ builder.Services.AddJsonApi(options => }); ``` -## Client Generated IDs +## Client-generated IDs By default, the server will respond with a 403 Forbidden HTTP Status Code if a POST request is received with a client-generated ID. -However, this can be allowed by setting the AllowClientGeneratedIds flag in the options: +However, this can be allowed or required globally (for all resource types) by setting `ClientIdGeneration` in options: ```c# -options.AllowClientGeneratedIds = true; +options.ClientIdGeneration = ClientIdGenerationMode.Allowed; ``` +or: + +```c# +options.ClientIdGeneration = ClientIdGenerationMode.Required; +``` + +It is possible to overrule this setting per resource type: + +```c# +[Resource(ClientIdGeneration = ClientIdGenerationMode.Required)] +public class Article : Identifiable +{ + // ... +} +``` + +> [!NOTE] +> JsonApiDotNetCore versions before v5.4.0 only provided the global `AllowClientGeneratedIds` boolean property. + ## Pagination The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`. diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs new file mode 100644 index 0000000000..41f856accc --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Indicates how to handle IDs sent by JSON:API clients when creating resources. +/// +[PublicAPI] +public enum ClientIdGenerationMode +{ + /// + /// Returns an HTTP 403 (Forbidden) response if a client attempts to create a resource with a client-supplied ID. + /// + Forbidden, + + /// + /// Allows a client to create a resource with a client-supplied ID, but does not require it. + /// + Allowed, + + /// + /// Returns an HTTP 422 (Unprocessable Content) response if a client attempts to create a resource without a client-supplied ID. + /// + Required +} diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 0263958b00..29bd5559b1 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -18,6 +18,12 @@ public sealed class ResourceType /// public string PublicName { get; } + /// + /// Whether API clients are allowed or required to provide IDs when creating resources of this type. When null, the value from global options + /// applies. + /// + public ClientIdGenerationMode? ClientIdGeneration { get; } + /// /// The CLR type of the resource. /// @@ -89,22 +95,24 @@ public sealed class ResourceType /// public LinkTypes RelationshipLinks { get; } - public ResourceType(string publicName, Type clrType, Type identityClrType, LinkTypes topLevelLinks = LinkTypes.NotConfigured, - LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured) - : this(publicName, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks) + public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType, + LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, + LinkTypes relationshipLinks = LinkTypes.NotConfigured) + : this(publicName, clientIdGeneration, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks) { } - public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes, - IReadOnlyCollection? relationships, IReadOnlyCollection? eagerLoads, - LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, - LinkTypes relationshipLinks = LinkTypes.NotConfigured) + public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType, + IReadOnlyCollection? attributes, IReadOnlyCollection? relationships, + IReadOnlyCollection? eagerLoads, LinkTypes topLevelLinks = LinkTypes.NotConfigured, + LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured) { ArgumentGuard.NotNullNorEmpty(publicName); ArgumentGuard.NotNull(clrType); ArgumentGuard.NotNull(identityClrType); PublicName = publicName; + ClientIdGeneration = clientIdGeneration; ClrType = clrType; IdentityClrType = identityClrType; Attributes = attributes ?? Array.Empty(); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs index 72669de585..e3f4ce97b5 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; namespace JsonApiDotNetCore.Resources.Annotations; @@ -10,11 +11,23 @@ namespace JsonApiDotNetCore.Resources.Annotations; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public sealed class ResourceAttribute : Attribute { + internal ClientIdGenerationMode? NullableClientIdGeneration { get; set; } + /// /// Optional. The publicly exposed name of this resource type. /// public string? PublicName { get; set; } + /// + /// Optional. Whether API clients are allowed or required to provide IDs when creating resources of this type. When not set, the value from global + /// options applies. + /// + public ClientIdGenerationMode ClientIdGeneration + { + get => NullableClientIdGeneration.GetValueOrDefault(); + set => NullableClientIdGeneration = value; + } + /// /// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to . Set to /// to disable controller generation. diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index bd068b5496..b5c03a05a5 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,5 +1,6 @@ using System.Data; using System.Text.Json; +using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -21,37 +22,40 @@ public interface IJsonApiOptions string? Namespace { get; } /// - /// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to . + /// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to . This setting can be + /// overruled per attribute using . /// AttrCapabilities DefaultAttrCapabilities { get; } /// - /// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to . + /// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to . This setting + /// can be overruled per relationship using . /// HasOneCapabilities DefaultHasOneCapabilities { get; } /// - /// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to . + /// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to . This setting + /// can be overruled per relationship using . /// HasManyCapabilities DefaultHasManyCapabilities { get; } /// - /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. + /// Whether to include a 'jsonapi' object in responses, which contains the highest JSON:API version supported. false by default. /// bool IncludeJsonApiVersion { get; } /// - /// Whether or not stack traces should be included in . False by default. + /// Whether to include stack traces in responses. false by default. /// bool IncludeExceptionStackTraceInErrors { get; } /// - /// Whether or not the request body should be included in when it is invalid. False by default. + /// Whether to include the request body in responses when it is invalid. false by default. /// bool IncludeRequestBodyInErrors { get; } /// - /// Use relative links for all resources. False by default. + /// Whether to use relative links for all resources. false by default. /// /// /// - /// Whether or not the total resource count should be included in top-level meta objects. This requires an additional database query. False by default. + /// Whether to include the total resource count in top-level meta objects. This requires an additional database query. false by default. /// bool IncludeTotalResourceCount { get; } @@ -114,28 +118,40 @@ public interface IJsonApiOptions PageNumber? MaximumPageNumber { get; } /// - /// Whether or not to enable ASP.NET ModelState validation. True by default. + /// Whether ASP.NET ModelState validation is enabled. true by default. /// bool ValidateModelState { get; } /// - /// Whether or not clients can provide IDs when creating resources. When not allowed, a 403 Forbidden response is returned if a client attempts to create - /// a resource with a defined ID. False by default. + /// Whether clients are allowed or required to provide IDs when creating resources. by default. This + /// setting can be overruled per resource type using . /// + ClientIdGenerationMode ClientIdGeneration { get; } + + /// + /// Whether clients can provide IDs when creating resources. When not allowed, a 403 Forbidden response is returned if a client attempts to create a + /// resource with a defined ID. false by default. + /// + /// + /// Setting this to true corresponds to , while false corresponds to + /// . + /// + [PublicAPI] + [Obsolete("Use ClientIdGeneration instead.")] bool AllowClientGeneratedIds { get; } /// - /// Whether or not to produce an error on unknown query string parameters. False by default. + /// Whether to produce an error on unknown query string parameters. false by default. /// bool AllowUnknownQueryStringParameters { get; } /// - /// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default. + /// Whether to produce an error on unknown attribute and relationship keys in request bodies. false by default. /// bool AllowUnknownFieldsInRequestBody { get; } /// - /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default. + /// Determines whether legacy filter notation in query strings (such as =eq:, =like:, and =in:) is enabled. false by default. /// bool EnableLegacyFilterNotation { get; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 778ded8d59..fc8b5cc5a4 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -69,7 +69,15 @@ public sealed class JsonApiOptions : IJsonApiOptions public bool ValidateModelState { get; set; } = true; /// - public bool AllowClientGeneratedIds { get; set; } + public ClientIdGenerationMode ClientIdGeneration { get; set; } + + /// + [Obsolete("Use ClientIdGeneration instead.")] + public bool AllowClientGeneratedIds + { + get => ClientIdGeneration is ClientIdGenerationMode.Allowed or ClientIdGenerationMode.Required; + set => ClientIdGeneration = value ? ClientIdGenerationMode.Allowed : ClientIdGenerationMode.Forbidden; + } /// public bool AllowUnknownQueryStringParameters { get; set; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 97548bd8fa..4ea0cb30e6 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -237,6 +237,8 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) { + ClientIdGenerationMode? clientIdGeneration = GetClientIdGeneration(resourceClrType); + IReadOnlyCollection attributes = GetAttributes(resourceClrType); IReadOnlyCollection relationships = GetRelationships(resourceClrType); IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); @@ -246,11 +248,17 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, var linksAttribute = resourceClrType.GetCustomAttribute(true); return linksAttribute == null - ? new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads) - : new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, + ? new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads) + : new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, linksAttribute.ResourceLinks, linksAttribute.RelationshipLinks); } + private ClientIdGenerationMode? GetClientIdGeneration(Type resourceClrType) + { + var resourceAttribute = resourceClrType.GetCustomAttribute(true); + return resourceAttribute?.NullableClientIdGeneration; + } + private IReadOnlyCollection GetAttributes(Type resourceClrType) { var attributesByName = new Dictionary(); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index 071b8d309e..c20cc11f3a 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -122,13 +122,10 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state) { - JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource - ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden - : JsonElementConstraint.Required; - return new ResourceIdentityRequirements { - IdConstraint = idConstraint + EvaluateIdConstraint = resourceType => + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration) }; } @@ -137,7 +134,7 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen return new ResourceIdentityRequirements { ResourceType = refResult.ResourceType, - IdConstraint = refRequirements.IdConstraint, + EvaluateIdConstraint = refRequirements.EvaluateIdConstraint, IdValue = refResult.Resource.StringId, LidValue = refResult.Resource.LocalId, RelationshipName = refResult.Relationship?.PublicName diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs index 38a8ce0a29..77d056ded4 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -60,14 +60,11 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) { - JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource - ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden - : JsonElementConstraint.Required; - var requirements = new ResourceIdentityRequirements { ResourceType = state.Request.PrimaryResourceType, - IdConstraint = idConstraint, + EvaluateIdConstraint = resourceType => + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration), IdValue = state.Request.PrimaryId }; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index 0e90f7df07..5925524e6b 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -70,7 +70,7 @@ private static SingleOrManyData ToIdentifierData(Singl var requirements = new ResourceIdentityRequirements { ResourceType = relationship.RightType, - IdConstraint = JsonElementConstraint.Required, + EvaluateIdConstraint = _ => JsonElementConstraint.Required, RelationshipName = relationship.PublicName }; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index 6964427680..be596dae7c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -33,7 +33,7 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory ArgumentGuard.NotNull(state); ResourceType resourceType = ResolveType(identity, requirements, state); - IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state); + IIdentifiable resource = CreateResource(identity, requirements, resourceType, state); return (resource, resourceType); } @@ -93,7 +93,8 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource } } - private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state) + private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType, + RequestAdapterState state) { if (state.Request.Kind != EndpointKind.AtomicOperations) { @@ -102,11 +103,13 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity AssertNoIdWithLid(identity, state); - if (requirements.IdConstraint == JsonElementConstraint.Required) + JsonElementConstraint? idConstraint = requirements.EvaluateIdConstraint?.Invoke(resourceType); + + if (idConstraint == JsonElementConstraint.Required) { AssertHasIdOrLid(identity, requirements, state); } - else if (requirements.IdConstraint == JsonElementConstraint.Forbidden) + else if (idConstraint == JsonElementConstraint.Forbidden) { AssertHasNoId(identity, state); } @@ -114,7 +117,7 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity AssertSameIdValue(identity, requirements.IdValue, state); AssertSameLidValue(identity, requirements.LidValue, state); - IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); + IIdentifiable resource = _resourceFactory.CreateInstance(resourceType.ClrType); AssignStringId(identity, resource, state); resource.LocalId = identity.Lid; return resource; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index d5498397bf..0d26b807d6 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Request.Adapters; @@ -16,9 +17,9 @@ public sealed class ResourceIdentityRequirements public ResourceType? ResourceType { get; init; } /// - /// When not null, indicates the presence or absence of the "id" element. + /// When not null, provides a callback to indicate the presence or absence of the "id" element. /// - public JsonElementConstraint? IdConstraint { get; init; } + public Func? EvaluateIdConstraint { get; init; } /// /// When not null, indicates what the value of the "id" element must be. @@ -34,4 +35,19 @@ public sealed class ResourceIdentityRequirements /// When not null, indicates the name of the relationship to use in error messages. /// public string? RelationshipName { get; init; } + + internal static JsonElementConstraint? DoEvaluateIdConstraint(ResourceType resourceType, WriteOperationKind? writeOperation, + ClientIdGenerationMode globalClientIdGeneration) + { + ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? globalClientIdGeneration; + + return writeOperation == WriteOperationKind.CreateResource + ? clientIdGeneration switch + { + ClientIdGenerationMode.Required => JsonElementConstraint.Required, + ClientIdGenerationMode.Forbidden => JsonElementConstraint.Forbidden, + _ => null + } + : JsonElementConstraint.Required; + } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 6226d6e597..901c1d94fc 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Serialization.Response; internal sealed class ResourceObjectTreeNode : IEquatable { // Placeholder root node for the tree, which is never emitted itself. - private static readonly ResourceType RootType = new("(root)", typeof(object), typeof(object)); + private static readonly ResourceType RootType = new("(root)", ClientIdGenerationMode.Forbidden, typeof(object), typeof(object)); private static readonly IIdentifiable RootResource = new EmptyResource(); // Direct children from root. These are emitted in 'data'. diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs index 9002773680..269758fef6 100644 --- a/test/AnnotationTests/Models/TreeNode.cs +++ b/test/AnnotationTests/Models/TreeNode.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -6,7 +7,8 @@ namespace AnnotationTests.Models; [PublicAPI] -[Resource(PublicName = "tree-node", ControllerNamespace = "Models", GenerateControllerEndpoints = JsonApiEndpoints.Query)] +[Resource(PublicName = "tree-node", ClientIdGeneration = ClientIdGenerationMode.Required, ControllerNamespace = "Models", + GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class TreeNode : Identifiable { [Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort)] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 6e9a21d773..15dbc19b07 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -33,7 +33,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Required; } [Fact] @@ -139,6 +139,50 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_resource_for_missing_client_generated_ID() + { + // Arrange + string? newIsoCode = _fakers.TextLanguage.Generate().IsoCode; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + attributes = new + { + isoCode = newIsoCode + } + } + } + } + }; + + 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 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_resource_for_existing_client_generated_ID() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index fe94e8f073..0dcc99fdcd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -31,7 +31,7 @@ public AtomicSerializationTests(IntegrationTestContext(); options.IncludeExceptionStackTraceInErrors = false; options.IncludeJsonApiVersion = true; - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Allowed; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 9e273be36e..3ff947d7b4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -27,9 +26,6 @@ public CompositeKeyTests(IntegrationTestContext>(); services.AddResourceRepository>(); }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index a1de409339..3354afd6bb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -27,7 +27,6 @@ public CreateResourceTests(IntegrationTestContext(); options.UseRelativeLinks = false; - options.AllowClientGeneratedIds = false; options.AllowUnknownFieldsInRequestBody = false; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 7fca6b9d63..58c804a073 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -28,7 +28,7 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Required; } [Fact] @@ -210,6 +210,43 @@ await _testContext.RunOnDatabaseAsync(async dbContext => property.PropertyType.Should().Be(typeof(string)); } + [Fact] + public async Task Cannot_create_resource_for_missing_client_generated_ID() + { + // Arrange + string newDisplayName = _fakers.RgbColor.Generate().DisplayName; + + var requestBody = new + { + data = new + { + type = "rgbColors", + attributes = new + { + displayName = newDisplayName + } + } + }; + + const string route = "/rgbColors"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(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 'id' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_resource_for_existing_client_generated_ID() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index 08bbcf1f63..2aaf524543 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -25,7 +25,7 @@ public CreateResourceWithToOneRelationshipTests(IntegrationTestContext(); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Allowed; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs index d5c28a0315..d9006ae629 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs @@ -97,6 +97,7 @@ public async Task Returns_no_ETag_for_failed_GET_request() public async Task Returns_no_ETag_for_POST_request() { // Arrange + var newId = Guid.NewGuid(); string newTitle = _fakers.Meeting.Generate().Title; var requestBody = new @@ -104,6 +105,7 @@ public async Task Returns_no_ETag_for_POST_request() data = new { type = "meetings", + id = newId, attributes = new { title = newTitle diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs index e8ddb054ff..f7599f6f01 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs @@ -1,13 +1,14 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Serialization")] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Serialization", ClientIdGeneration = ClientIdGenerationMode.Required)] public sealed class Meeting : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index fb2a52d584..da87b6b82d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -30,7 +30,6 @@ public SerializationTests(IntegrationTestContext(); options.IncludeExceptionStackTraceInErrors = false; - options.AllowClientGeneratedIds = true; options.IncludeJsonApiVersion = false; options.IncludeTotalResourceCount = true; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 280375f2e7..93881be9eb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -24,7 +24,6 @@ public EmptyGuidAsKeyTests(IntegrationTestContext(); options.UseRelativeLinks = true; - options.AllowClientGeneratedIds = true; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs index b75b5935b8..172ab35a2a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys")] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys", ClientIdGeneration = ClientIdGenerationMode.Allowed)] public sealed class Game : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs index 3bd21bab38..c8226a4009 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs @@ -1,11 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys")] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys", ClientIdGeneration = ClientIdGenerationMode.Allowed)] public sealed class Map : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index 5ce4f3ffed..19baa0cf58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -24,7 +24,6 @@ public ZeroAsKeyTests(IntegrationTestContext, var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.UseRelativeLinks = true; - options.AllowClientGeneratedIds = true; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 4c93248286..52dc5cb19c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -57,7 +57,8 @@ public sealed class LinkInclusionTests public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { // Arrange - var exampleResourceType = new ResourceType(nameof(ExampleResource), typeof(ExampleResource), typeof(int), linksInResourceType); + var exampleResourceType = new ResourceType(nameof(ExampleResource), ClientIdGenerationMode.Forbidden, typeof(ExampleResource), typeof(int), + linksInResourceType); var options = new JsonApiOptions { @@ -156,7 +157,8 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { // Arrange - var exampleResourceType = new ResourceType(nameof(ExampleResource), typeof(ExampleResource), typeof(int), resourceLinks: linksInResourceType); + var exampleResourceType = new ResourceType(nameof(ExampleResource), ClientIdGenerationMode.Forbidden, typeof(ExampleResource), typeof(int), + resourceLinks: linksInResourceType); var options = new JsonApiOptions { @@ -316,7 +318,8 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR LinkTypes linksInOptions, LinkTypes expected) { // Arrange - var exampleResourceType = new ResourceType(nameof(ExampleResource), typeof(ExampleResource), typeof(int), relationshipLinks: linksInResourceType); + var exampleResourceType = new ResourceType(nameof(ExampleResource), ClientIdGenerationMode.Forbidden, typeof(ExampleResource), typeof(int), + relationshipLinks: linksInResourceType); var options = new JsonApiOptions { From abf8ad3c3843972cc0ace4ab37a54b8bcd4c2e9d Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 5 Sep 2023 00:48:56 +0200 Subject: [PATCH 2/2] Add missing Obsolete markers --- src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 4ebe5cf453..8589cb5a77 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -17,6 +17,7 @@ public class ResourceDefinitionAccessor : IResourceDefinitionAccessor private readonly IServiceProvider _serviceProvider; /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] public bool IsReadOnlyRequest { get @@ -27,6 +28,7 @@ public bool IsReadOnlyRequest } /// + [Obsolete("Use injected IQueryableBuilder instead.")] public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService(); public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider)