Skip to content

Commit ce358fe

Browse files
committed
Enable client-generated IDs per resource type. Obsolete boolean value in favor of enumeration Forbidden/Allowed/Required
1 parent 0ce680c commit ce358fe

29 files changed

+260
-66
lines changed

Diff for: JsonApiDotNetCore.sln.DotSettings

+1
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ $left$ = $right$;</s:String>
665665
<s:Boolean x:Key="/Default/UserDictionary/Words/=Startups/@EntryIndexedValue">True</s:Boolean>
666666
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
667667
<s:Boolean x:Key="/Default/UserDictionary/Words/=unarchive/@EntryIndexedValue">True</s:Boolean>
668+
<s:Boolean x:Key="/Default/UserDictionary/Words/=Unprocessable/@EntryIndexedValue">True</s:Boolean>
668669
<s:Boolean x:Key="/Default/UserDictionary/Words/=Workflows/@EntryIndexedValue">True</s:Boolean>
669670
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
670671
</wpf:ResourceDictionary>

Diff for: docs/usage/options.md

+22-3
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,35 @@ builder.Services.AddJsonApi<AppDbContext>(options =>
1010
});
1111
```
1212

13-
## Client Generated IDs
13+
## Client-generated IDs
1414

1515
By default, the server will respond with a 403 Forbidden HTTP Status Code if a POST request is received with a client-generated ID.
1616

17-
However, this can be allowed by setting the AllowClientGeneratedIds flag in the options:
17+
However, this can be allowed or required globally (for all resource types) by setting `ClientIdGeneration` in options:
1818

1919
```c#
20-
options.AllowClientGeneratedIds = true;
20+
options.ClientIdGeneration = ClientIdGenerationMode.Allowed;
2121
```
2222

23+
or:
24+
25+
```c#
26+
options.ClientIdGeneration = ClientIdGenerationMode.Required;
27+
```
28+
29+
It is possible to overrule this setting per resource type:
30+
31+
```c#
32+
[Resource(ClientIdGeneration = ClientIdGenerationMode.Required)]
33+
public class Article : Identifiable<Guid>
34+
{
35+
// ...
36+
}
37+
```
38+
39+
> [!NOTE]
40+
> JsonApiDotNetCore versions before v5.4.0 only provided the global `AllowClientGeneratedIds` boolean property.
41+
2342
## Pagination
2443

2544
The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using JetBrains.Annotations;
2+
3+
namespace JsonApiDotNetCore.Configuration;
4+
5+
/// <summary>
6+
/// Indicates how to handle IDs sent by JSON:API clients when creating resources.
7+
/// </summary>
8+
[PublicAPI]
9+
public enum ClientIdGenerationMode
10+
{
11+
/// <summary>
12+
/// Returns an HTTP 403 (Forbidden) response if a client attempts to create a resource with a client-supplied ID.
13+
/// </summary>
14+
Forbidden,
15+
16+
/// <summary>
17+
/// Allows a client to create a resource with a client-supplied ID, but does not require it.
18+
/// </summary>
19+
Allowed,
20+
21+
/// <summary>
22+
/// Returns an HTTP 422 (Unprocessable Content) response if a client attempts to create a resource without a client-supplied ID.
23+
/// </summary>
24+
Required
25+
}

Diff for: src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs

+15-7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ public sealed class ResourceType
1818
/// </summary>
1919
public string PublicName { get; }
2020

21+
/// <summary>
22+
/// Whether API clients are allowed or required to provide IDs when creating resources of this type. When <c>null</c>, the value from global options
23+
/// applies.
24+
/// </summary>
25+
public ClientIdGenerationMode? ClientIdGeneration { get; }
26+
2127
/// <summary>
2228
/// The CLR type of the resource.
2329
/// </summary>
@@ -89,22 +95,24 @@ public sealed class ResourceType
8995
/// </remarks>
9096
public LinkTypes RelationshipLinks { get; }
9197

92-
public ResourceType(string publicName, Type clrType, Type identityClrType, LinkTypes topLevelLinks = LinkTypes.NotConfigured,
93-
LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured)
94-
: this(publicName, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks)
98+
public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType,
99+
LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured,
100+
LinkTypes relationshipLinks = LinkTypes.NotConfigured)
101+
: this(publicName, clientIdGeneration, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks)
95102
{
96103
}
97104

98-
public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection<AttrAttribute>? attributes,
99-
IReadOnlyCollection<RelationshipAttribute>? relationships, IReadOnlyCollection<EagerLoadAttribute>? eagerLoads,
100-
LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured,
101-
LinkTypes relationshipLinks = LinkTypes.NotConfigured)
105+
public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType,
106+
IReadOnlyCollection<AttrAttribute>? attributes, IReadOnlyCollection<RelationshipAttribute>? relationships,
107+
IReadOnlyCollection<EagerLoadAttribute>? eagerLoads, LinkTypes topLevelLinks = LinkTypes.NotConfigured,
108+
LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured)
102109
{
103110
ArgumentGuard.NotNullNorEmpty(publicName);
104111
ArgumentGuard.NotNull(clrType);
105112
ArgumentGuard.NotNull(identityClrType);
106113

107114
PublicName = publicName;
115+
ClientIdGeneration = clientIdGeneration;
108116
ClrType = clrType;
109117
IdentityClrType = identityClrType;
110118
Attributes = attributes ?? Array.Empty<AttrAttribute>();

Diff for: src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs

+13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
23
using JsonApiDotNetCore.Controllers;
34

45
namespace JsonApiDotNetCore.Resources.Annotations;
@@ -10,11 +11,23 @@ namespace JsonApiDotNetCore.Resources.Annotations;
1011
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
1112
public sealed class ResourceAttribute : Attribute
1213
{
14+
internal ClientIdGenerationMode? NullableClientIdGeneration { get; set; }
15+
1316
/// <summary>
1417
/// Optional. The publicly exposed name of this resource type.
1518
/// </summary>
1619
public string? PublicName { get; set; }
1720

21+
/// <summary>
22+
/// Optional. Whether API clients are allowed or required to provide IDs when creating resources of this type. When not set, the value from global
23+
/// options applies.
24+
/// </summary>
25+
public ClientIdGenerationMode ClientIdGeneration
26+
{
27+
get => NullableClientIdGeneration.GetValueOrDefault();
28+
set => NullableClientIdGeneration = value;
29+
}
30+
1831
/// <summary>
1932
/// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to <see cref="JsonApiEndpoints.All" />. Set to
2033
/// <see cref="JsonApiEndpoints.None" /> to disable controller generation.

Diff for: src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs

+30-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Data;
22
using System.Text.Json;
3+
using JetBrains.Annotations;
34
using JsonApiDotNetCore.Resources.Annotations;
45
using JsonApiDotNetCore.Serialization.Objects;
56

@@ -21,37 +22,40 @@ public interface IJsonApiOptions
2122
string? Namespace { get; }
2223

2324
/// <summary>
24-
/// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to <see cref="AttrCapabilities.All" />.
25+
/// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to <see cref="AttrCapabilities.All" />. This setting can be
26+
/// overruled per attribute using <see cref="AttrAttribute.Capabilities" />.
2527
/// </summary>
2628
AttrCapabilities DefaultAttrCapabilities { get; }
2729

2830
/// <summary>
29-
/// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to <see cref="HasOneCapabilities.All" />.
31+
/// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to <see cref="HasOneCapabilities.All" />. This setting
32+
/// can be overruled per relationship using <see cref="HasOneAttribute.Capabilities" />.
3033
/// </summary>
3134
HasOneCapabilities DefaultHasOneCapabilities { get; }
3235

3336
/// <summary>
34-
/// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to <see cref="HasManyCapabilities.All" />.
37+
/// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to <see cref="HasManyCapabilities.All" />. This setting
38+
/// can be overruled per relationship using <see cref="HasManyAttribute.Capabilities" />.
3539
/// </summary>
3640
HasManyCapabilities DefaultHasManyCapabilities { get; }
3741

3842
/// <summary>
39-
/// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default.
43+
/// Whether to include a 'jsonapi' object in responses, which contains the highest JSON:API version supported. <c>false</c> by default.
4044
/// </summary>
4145
bool IncludeJsonApiVersion { get; }
4246

4347
/// <summary>
44-
/// Whether or not <see cref="Exception" /> stack traces should be included in <see cref="ErrorObject.Meta" />. False by default.
48+
/// Whether to include <see cref="Exception" /> stack traces in <see cref="ErrorObject.Meta" /> responses. <c>false</c> by default.
4549
/// </summary>
4650
bool IncludeExceptionStackTraceInErrors { get; }
4751

4852
/// <summary>
49-
/// Whether or not the request body should be included in <see cref="Document.Meta" /> when it is invalid. False by default.
53+
/// Whether to include the request body in <see cref="Document.Meta" /> responses when it is invalid. <c>false</c> by default.
5054
/// </summary>
5155
bool IncludeRequestBodyInErrors { get; }
5256

5357
/// <summary>
54-
/// Use relative links for all resources. False by default.
58+
/// Whether to use relative links for all resources. <c>false</c> by default.
5559
/// </summary>
5660
/// <example>
5761
/// <code><![CDATA[
@@ -94,7 +98,7 @@ public interface IJsonApiOptions
9498
LinkTypes RelationshipLinks { get; }
9599

96100
/// <summary>
97-
/// Whether or not the total resource count should be included in top-level meta objects. This requires an additional database query. False by default.
101+
/// Whether to include the total resource count in top-level meta objects. This requires an additional database query. <c>false</c> by default.
98102
/// </summary>
99103
bool IncludeTotalResourceCount { get; }
100104

@@ -114,28 +118,40 @@ public interface IJsonApiOptions
114118
PageNumber? MaximumPageNumber { get; }
115119

116120
/// <summary>
117-
/// Whether or not to enable ASP.NET ModelState validation. True by default.
121+
/// Whether ASP.NET ModelState validation is enabled. <c>true</c> by default.
118122
/// </summary>
119123
bool ValidateModelState { get; }
120124

121125
/// <summary>
122-
/// 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
123-
/// a resource with a defined ID. False by default.
126+
/// Whether clients are allowed or required to provide IDs when creating resources. <see cref="ClientIdGenerationMode.Forbidden" /> by default. This
127+
/// setting can be overruled per resource type using <see cref="ResourceAttribute.ClientIdGeneration" />.
124128
/// </summary>
129+
ClientIdGenerationMode ClientIdGeneration { get; }
130+
131+
/// <summary>
132+
/// Whether clients can provide IDs when creating resources. When not allowed, a 403 Forbidden response is returned if a client attempts to create a
133+
/// resource with a defined ID. <c>false</c> by default.
134+
/// </summary>
135+
/// <remarks>
136+
/// Setting this to <c>true</c> corresponds to <see cref="ClientIdGenerationMode.Allowed" />, while <c>false</c> corresponds to
137+
/// <see cref="ClientIdGenerationMode.Forbidden" />.
138+
/// </remarks>
139+
[PublicAPI]
140+
[Obsolete("Use ClientIdGeneration instead.")]
125141
bool AllowClientGeneratedIds { get; }
126142

127143
/// <summary>
128-
/// Whether or not to produce an error on unknown query string parameters. False by default.
144+
/// Whether to produce an error on unknown query string parameters. <c>false</c> by default.
129145
/// </summary>
130146
bool AllowUnknownQueryStringParameters { get; }
131147

132148
/// <summary>
133-
/// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default.
149+
/// Whether to produce an error on unknown attribute and relationship keys in request bodies. <c>false</c> by default.
134150
/// </summary>
135151
bool AllowUnknownFieldsInRequestBody { get; }
136152

137153
/// <summary>
138-
/// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default.
154+
/// Determines whether legacy filter notation in query strings (such as =eq:, =like:, and =in:) is enabled. <c>false</c> by default.
139155
/// </summary>
140156
bool EnableLegacyFilterNotation { get; }
141157

Diff for: src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

+9-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,15 @@ public sealed class JsonApiOptions : IJsonApiOptions
6969
public bool ValidateModelState { get; set; } = true;
7070

7171
/// <inheritdoc />
72-
public bool AllowClientGeneratedIds { get; set; }
72+
public ClientIdGenerationMode ClientIdGeneration { get; set; }
73+
74+
/// <inheritdoc />
75+
[Obsolete("Use ClientIdGeneration instead.")]
76+
public bool AllowClientGeneratedIds
77+
{
78+
get => ClientIdGeneration is ClientIdGenerationMode.Allowed or ClientIdGenerationMode.Required;
79+
set => ClientIdGeneration = value ? ClientIdGenerationMode.Allowed : ClientIdGenerationMode.Forbidden;
80+
}
7381

7482
/// <inheritdoc />
7583
public bool AllowUnknownQueryStringParameters { get; set; }

Diff for: src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs

+10-2
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st
237237

238238
private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType)
239239
{
240+
ClientIdGenerationMode? clientIdGeneration = GetClientIdGeneration(resourceClrType);
241+
240242
IReadOnlyCollection<AttrAttribute> attributes = GetAttributes(resourceClrType);
241243
IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationships(resourceClrType);
242244
IReadOnlyCollection<EagerLoadAttribute> eagerLoads = GetEagerLoads(resourceClrType);
@@ -246,11 +248,17 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType,
246248
var linksAttribute = resourceClrType.GetCustomAttribute<ResourceLinksAttribute>(true);
247249

248250
return linksAttribute == null
249-
? new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads)
250-
: new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks,
251+
? new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads)
252+
: new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks,
251253
linksAttribute.ResourceLinks, linksAttribute.RelationshipLinks);
252254
}
253255

256+
private ClientIdGenerationMode? GetClientIdGeneration(Type resourceClrType)
257+
{
258+
var resourceAttribute = resourceClrType.GetCustomAttribute<ResourceAttribute>(true);
259+
return resourceAttribute?.NullableClientIdGeneration;
260+
}
261+
254262
private IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceClrType)
255263
{
256264
var attributesByName = new Dictionary<string, AttrAttribute>();

Diff for: src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs

+3-6
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,10 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper
122122

123123
private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state)
124124
{
125-
JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource
126-
? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden
127-
: JsonElementConstraint.Required;
128-
129125
return new ResourceIdentityRequirements
130126
{
131-
IdConstraint = idConstraint
127+
EvaluateIdConstraint = resourceType =>
128+
ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration)
132129
};
133130
}
134131

@@ -137,7 +134,7 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen
137134
return new ResourceIdentityRequirements
138135
{
139136
ResourceType = refResult.ResourceType,
140-
IdConstraint = refRequirements.IdConstraint,
137+
EvaluateIdConstraint = refRequirements.EvaluateIdConstraint,
141138
IdValue = refResult.Resource.StringId,
142139
LidValue = refResult.Resource.LocalId,
143140
RelationshipName = refResult.Relationship?.PublicName

Diff for: src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs

+2-5
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,11 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I
6060

6161
private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state)
6262
{
63-
JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource
64-
? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden
65-
: JsonElementConstraint.Required;
66-
6763
var requirements = new ResourceIdentityRequirements
6864
{
6965
ResourceType = state.Request.PrimaryResourceType,
70-
IdConstraint = idConstraint,
66+
EvaluateIdConstraint = resourceType =>
67+
ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration),
7168
IdValue = state.Request.PrimaryId
7269
};
7370

Diff for: src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ private static SingleOrManyData<ResourceIdentifierObject> ToIdentifierData(Singl
7070
var requirements = new ResourceIdentityRequirements
7171
{
7272
ResourceType = relationship.RightType,
73-
IdConstraint = JsonElementConstraint.Required,
73+
EvaluateIdConstraint = _ => JsonElementConstraint.Required,
7474
RelationshipName = relationship.PublicName
7575
};
7676

Diff for: src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs

+8-5
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory
3333
ArgumentGuard.NotNull(state);
3434

3535
ResourceType resourceType = ResolveType(identity, requirements, state);
36-
IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state);
36+
IIdentifiable resource = CreateResource(identity, requirements, resourceType, state);
3737

3838
return (resource, resourceType);
3939
}
@@ -93,7 +93,8 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource
9393
}
9494
}
9595

96-
private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state)
96+
private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType,
97+
RequestAdapterState state)
9798
{
9899
if (state.Request.Kind != EndpointKind.AtomicOperations)
99100
{
@@ -102,19 +103,21 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity
102103

103104
AssertNoIdWithLid(identity, state);
104105

105-
if (requirements.IdConstraint == JsonElementConstraint.Required)
106+
JsonElementConstraint? idConstraint = requirements.EvaluateIdConstraint?.Invoke(resourceType);
107+
108+
if (idConstraint == JsonElementConstraint.Required)
106109
{
107110
AssertHasIdOrLid(identity, requirements, state);
108111
}
109-
else if (requirements.IdConstraint == JsonElementConstraint.Forbidden)
112+
else if (idConstraint == JsonElementConstraint.Forbidden)
110113
{
111114
AssertHasNoId(identity, state);
112115
}
113116

114117
AssertSameIdValue(identity, requirements.IdValue, state);
115118
AssertSameLidValue(identity, requirements.LidValue, state);
116119

117-
IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType);
120+
IIdentifiable resource = _resourceFactory.CreateInstance(resourceType.ClrType);
118121
AssignStringId(identity, resource, state);
119122
resource.LocalId = identity.Lid;
120123
return resource;

0 commit comments

Comments
 (0)