Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client-generated IDs per resource type #1305

Merged
merged 2 commits into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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