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/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)
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
{