Skip to content

Commit

Permalink
Merge pull request #1305 from json-api-dotnet/client-generated-ids-pe…
Browse files Browse the repository at this point in the history
…r-type

Client-generated IDs per resource type
  • Loading branch information
bkoelman committed Sep 17, 2023
2 parents 0ce680c + abf8ad3 commit 2315deb
Show file tree
Hide file tree
Showing 30 changed files with 262 additions and 66 deletions.
1 change: 1 addition & 0 deletions JsonApiDotNetCore.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,7 @@ $left$ = $right$;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Startups/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=unarchive/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Unprocessable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Workflows/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
</wpf:ResourceDictionary>
25 changes: 22 additions & 3 deletions docs/usage/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,35 @@ builder.Services.AddJsonApi<AppDbContext>(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<Guid>
{
// ...
}
```

> [!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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using JetBrains.Annotations;

namespace JsonApiDotNetCore.Configuration;

/// <summary>
/// Indicates how to handle IDs sent by JSON:API clients when creating resources.
/// </summary>
[PublicAPI]
public enum ClientIdGenerationMode
{
/// <summary>
/// Returns an HTTP 403 (Forbidden) response if a client attempts to create a resource with a client-supplied ID.
/// </summary>
Forbidden,

/// <summary>
/// Allows a client to create a resource with a client-supplied ID, but does not require it.
/// </summary>
Allowed,

/// <summary>
/// Returns an HTTP 422 (Unprocessable Content) response if a client attempts to create a resource without a client-supplied ID.
/// </summary>
Required
}
22 changes: 15 additions & 7 deletions src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ public sealed class ResourceType
/// </summary>
public string PublicName { get; }

/// <summary>
/// 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
/// applies.
/// </summary>
public ClientIdGenerationMode? ClientIdGeneration { get; }

/// <summary>
/// The CLR type of the resource.
/// </summary>
Expand Down Expand Up @@ -89,22 +95,24 @@ public sealed class ResourceType
/// </remarks>
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<AttrAttribute>? attributes,
IReadOnlyCollection<RelationshipAttribute>? relationships, IReadOnlyCollection<EagerLoadAttribute>? eagerLoads,
LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured,
LinkTypes relationshipLinks = LinkTypes.NotConfigured)
public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType,
IReadOnlyCollection<AttrAttribute>? attributes, IReadOnlyCollection<RelationshipAttribute>? relationships,
IReadOnlyCollection<EagerLoadAttribute>? 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<AttrAttribute>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;

namespace JsonApiDotNetCore.Resources.Annotations;
Expand All @@ -10,11 +11,23 @@ namespace JsonApiDotNetCore.Resources.Annotations;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class ResourceAttribute : Attribute
{
internal ClientIdGenerationMode? NullableClientIdGeneration { get; set; }

/// <summary>
/// Optional. The publicly exposed name of this resource type.
/// </summary>
public string? PublicName { get; set; }

/// <summary>
/// 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.
/// </summary>
public ClientIdGenerationMode ClientIdGeneration
{
get => NullableClientIdGeneration.GetValueOrDefault();
set => NullableClientIdGeneration = value;
}

/// <summary>
/// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to <see cref="JsonApiEndpoints.All" />. Set to
/// <see cref="JsonApiEndpoints.None" /> to disable controller generation.
Expand Down
44 changes: 30 additions & 14 deletions src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Data;
using System.Text.Json;
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Serialization.Objects;

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

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

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

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

/// <summary>
/// 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. <c>false</c> by default.
/// </summary>
bool IncludeJsonApiVersion { get; }

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

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

/// <summary>
/// Use relative links for all resources. False by default.
/// Whether to use relative links for all resources. <c>false</c> by default.
/// </summary>
/// <example>
/// <code><![CDATA[
Expand Down Expand Up @@ -94,7 +98,7 @@ public interface IJsonApiOptions
LinkTypes RelationshipLinks { get; }

/// <summary>
/// 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. <c>false</c> by default.
/// </summary>
bool IncludeTotalResourceCount { get; }

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

/// <summary>
/// Whether or not to enable ASP.NET ModelState validation. True by default.
/// Whether ASP.NET ModelState validation is enabled. <c>true</c> by default.
/// </summary>
bool ValidateModelState { get; }

/// <summary>
/// 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. <see cref="ClientIdGenerationMode.Forbidden" /> by default. This
/// setting can be overruled per resource type using <see cref="ResourceAttribute.ClientIdGeneration" />.
/// </summary>
ClientIdGenerationMode ClientIdGeneration { get; }

/// <summary>
/// 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. <c>false</c> by default.
/// </summary>
/// <remarks>
/// Setting this to <c>true</c> corresponds to <see cref="ClientIdGenerationMode.Allowed" />, while <c>false</c> corresponds to
/// <see cref="ClientIdGenerationMode.Forbidden" />.
/// </remarks>
[PublicAPI]
[Obsolete("Use ClientIdGeneration instead.")]
bool AllowClientGeneratedIds { get; }

/// <summary>
/// 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. <c>false</c> by default.
/// </summary>
bool AllowUnknownQueryStringParameters { get; }

/// <summary>
/// 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. <c>false</c> by default.
/// </summary>
bool AllowUnknownFieldsInRequestBody { get; }

/// <summary>
/// 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. <c>false</c> by default.
/// </summary>
bool EnableLegacyFilterNotation { get; }

Expand Down
10 changes: 9 additions & 1 deletion src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,15 @@ public sealed class JsonApiOptions : IJsonApiOptions
public bool ValidateModelState { get; set; } = true;

/// <inheritdoc />
public bool AllowClientGeneratedIds { get; set; }
public ClientIdGenerationMode ClientIdGeneration { get; set; }

/// <inheritdoc />
[Obsolete("Use ClientIdGeneration instead.")]
public bool AllowClientGeneratedIds
{
get => ClientIdGeneration is ClientIdGenerationMode.Allowed or ClientIdGenerationMode.Required;
set => ClientIdGeneration = value ? ClientIdGenerationMode.Allowed : ClientIdGenerationMode.Forbidden;
}

/// <inheritdoc />
public bool AllowUnknownQueryStringParameters { get; set; }
Expand Down
12 changes: 10 additions & 2 deletions src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttrAttribute> attributes = GetAttributes(resourceClrType);
IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationships(resourceClrType);
IReadOnlyCollection<EagerLoadAttribute> eagerLoads = GetEagerLoads(resourceClrType);
Expand All @@ -246,11 +248,17 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType,
var linksAttribute = resourceClrType.GetCustomAttribute<ResourceLinksAttribute>(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<ResourceAttribute>(true);
return resourceAttribute?.NullableClientIdGeneration;
}

private IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceClrType)
{
var attributesByName = new Dictionary<string, AttrAttribute>();
Expand Down
2 changes: 2 additions & 0 deletions src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class ResourceDefinitionAccessor : IResourceDefinitionAccessor
private readonly IServiceProvider _serviceProvider;

/// <inheritdoc />
[Obsolete("Use IJsonApiRequest.IsReadOnly.")]
public bool IsReadOnlyRequest
{
get
Expand All @@ -27,6 +28,7 @@ public bool IsReadOnlyRequest
}

/// <inheritdoc />
[Obsolete("Use injected IQueryableBuilder instead.")]
public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService<IQueryableBuilder>();

public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,11 @@ public sealed class DocumentInResourceOrRelationshipRequestAdapter : IDocumentIn

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
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ private static SingleOrManyData<ResourceIdentifierObject> ToIdentifierData(Singl
var requirements = new ResourceIdentityRequirements
{
ResourceType = relationship.RightType,
IdConstraint = JsonElementConstraint.Required,
EvaluateIdConstraint = _ => JsonElementConstraint.Required,
RelationshipName = relationship.PublicName
};

Expand Down

0 comments on commit 2315deb

Please sign in to comment.