diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings
index 1ffdf4a909..e1b5a9290a 100644
--- a/JsonApiDotNetCore.sln.DotSettings
+++ b/JsonApiDotNetCore.sln.DotSettings
@@ -660,5 +660,6 @@ $left$ = $right$;
True
True
True
+ True
True
diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs
index aadeb889cc..e1a5fb1cdc 100644
--- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs
+++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
@@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
public sealed class DbContextARepository : EntityFrameworkCoreRepository
where TResource : class, IIdentifiable
{
- public DbContextARepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph,
- IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory,
- IResourceDefinitionAccessor resourceDefinitionAccessor)
- : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
+ public DbContextARepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver dbContextResolver,
+ IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
+ IEnumerable constraintProviders, ILoggerFactory loggerFactory)
+ : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
{
}
}
diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs
index ac4ce8789c..dce27bb4fa 100644
--- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs
+++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
@@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
public sealed class DbContextBRepository : EntityFrameworkCoreRepository
where TResource : class, IIdentifiable
{
- public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph,
- IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory,
- IResourceDefinitionAccessor resourceDefinitionAccessor)
- : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
+ public DbContextBRepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver dbContextResolver,
+ IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
+ IEnumerable constraintProviders, ILoggerFactory loggerFactory)
+ : base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
{
}
}
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
index 0263958b00..43a45d8a18 100644
--- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
+++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
namespace JsonApiDotNetCore.Configuration;
@@ -38,6 +39,11 @@ public sealed class ResourceType
///
public IReadOnlySet DirectlyDerivedTypes { get; internal set; } = new HashSet();
+ ///
+ /// When true, this resource type uses optimistic concurrency.
+ ///
+ public bool IsVersioned => ClrType.IsOrImplementsInterface();
+
///
/// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. When using resource inheritance, this
/// includes the attributes and relationships from base types.
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs
new file mode 100644
index 0000000000..9479cc20cc
--- /dev/null
+++ b/src/JsonApiDotNetCore.Annotations/Resources/IVersionedIdentifiable.cs
@@ -0,0 +1,38 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Resources;
+
+///
+/// Defines the basic contract for a JSON:API resource that uses optimistic concurrency. All resource classes must implement
+/// .
+///
+public interface IVersionedIdentifiable : IIdentifiable
+{
+ ///
+ /// The value for element 'version' in a JSON:API request or response.
+ ///
+ string? Version { get; set; }
+}
+
+///
+/// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource that uses optimistic concurrency.
+///
+///
+/// The resource identifier type.
+///
+///
+/// The database vendor-specific type that is used to store the concurrency token.
+///
+[PublicAPI]
+public interface IVersionedIdentifiable : IIdentifiable, IVersionedIdentifiable
+{
+ ///
+ /// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved.
+ ///
+ TVersion ConcurrencyToken { get; set; }
+
+ ///
+ /// Represents a database column where random data is written to on updates, in order to force a concurrency check during relationship updates.
+ ///
+ Guid ConcurrencyValue { get; set; }
+}
diff --git a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs
index b31f82d48e..c23c948740 100644
--- a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs
+++ b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs
@@ -13,7 +13,7 @@ public static bool IsOrImplementsInterface(this Type? source)
///
/// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface.
///
- private static bool IsOrImplementsInterface(this Type? source, Type interfaceType)
+ public static bool IsOrImplementsInterface(this Type? source, Type interfaceType)
{
ArgumentGuard.NotNull(interfaceType);
diff --git a/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs
new file mode 100644
index 0000000000..25c7b4e2c5
--- /dev/null
+++ b/src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs
@@ -0,0 +1,13 @@
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Resources;
+
+namespace JsonApiDotNetCore.AtomicOperations;
+
+public interface IVersionTracker
+{
+ bool RequiresVersionTracking();
+
+ void CaptureVersions(ResourceType resourceType, IIdentifiable resource);
+
+ string? GetVersion(ResourceType resourceType, string stringId);
+}
diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
index 6ecdfd6077..4b9f209bce 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
@@ -15,6 +15,7 @@ public class OperationsProcessor : IOperationsProcessor
private readonly IOperationProcessorAccessor _operationProcessorAccessor;
private readonly IOperationsTransactionFactory _operationsTransactionFactory;
private readonly ILocalIdTracker _localIdTracker;
+ private readonly IVersionTracker _versionTracker;
private readonly IResourceGraph _resourceGraph;
private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
@@ -22,12 +23,13 @@ public class OperationsProcessor : IOperationsProcessor
private readonly LocalIdValidator _localIdValidator;
public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory,
- ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields,
+ ILocalIdTracker localIdTracker, IVersionTracker versionTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields,
ISparseFieldSetCache sparseFieldSetCache)
{
ArgumentGuard.NotNull(operationProcessorAccessor);
ArgumentGuard.NotNull(operationsTransactionFactory);
ArgumentGuard.NotNull(localIdTracker);
+ ArgumentGuard.NotNull(versionTracker);
ArgumentGuard.NotNull(resourceGraph);
ArgumentGuard.NotNull(request);
ArgumentGuard.NotNull(targetedFields);
@@ -36,6 +38,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
_operationProcessorAccessor = operationProcessorAccessor;
_operationsTransactionFactory = operationsTransactionFactory;
_localIdTracker = localIdTracker;
+ _versionTracker = versionTracker;
_resourceGraph = resourceGraph;
_request = request;
_targetedFields = targetedFields;
@@ -104,11 +107,15 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
cancellationToken.ThrowIfCancellationRequested();
TrackLocalIdsForOperation(operation);
+ RefreshVersionsForOperation(operation);
_targetedFields.CopyFrom(operation.TargetedFields);
_request.CopyFrom(operation.Request);
return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken);
+
+ // Ideally we'd take the versions from response here and update the version cache, but currently
+ // not all resource service methods return data. Therefore this is handled elsewhere.
}
protected void TrackLocalIdsForOperation(OperationContainer operation)
@@ -144,4 +151,36 @@ private void AssignStringId(IIdentifiable resource)
resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType);
}
}
+
+ private void RefreshVersionsForOperation(OperationContainer operation)
+ {
+ if (operation.Request.PrimaryResourceType!.IsVersioned)
+ {
+ string? requestVersion = operation.Resource.GetVersion();
+
+ if (requestVersion == null)
+ {
+ string? trackedVersion = _versionTracker.GetVersion(operation.Request.PrimaryResourceType, operation.Resource.StringId!);
+ operation.Resource.SetVersion(trackedVersion);
+
+ ((JsonApiRequest)operation.Request).PrimaryVersion = trackedVersion;
+ }
+ }
+
+ foreach (IIdentifiable rightResource in operation.GetSecondaryResources())
+ {
+ ResourceType rightResourceType = _resourceGraph.GetResourceType(rightResource.GetClrType());
+
+ if (rightResourceType.IsVersioned)
+ {
+ string? requestVersion = rightResource.GetVersion();
+
+ if (requestVersion == null)
+ {
+ string? trackedVersion = _versionTracker.GetVersion(rightResourceType, rightResource.StringId!);
+ rightResource.SetVersion(trackedVersion);
+ }
+ }
+ }
+ }
}
diff --git a/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs
new file mode 100644
index 0000000000..4230f8a515
--- /dev/null
+++ b/src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs
@@ -0,0 +1,91 @@
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Resources;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.AtomicOperations;
+
+public sealed class VersionTracker : IVersionTracker
+{
+ private static readonly CollectionConverter CollectionConverter = new();
+
+ private readonly ITargetedFields _targetedFields;
+ private readonly IJsonApiRequest _request;
+ private readonly Dictionary _versionPerResource = new();
+
+ public VersionTracker(ITargetedFields targetedFields, IJsonApiRequest request)
+ {
+ ArgumentGuard.NotNull(targetedFields, nameof(targetedFields));
+ ArgumentGuard.NotNull(request, nameof(request));
+
+ _targetedFields = targetedFields;
+ _request = request;
+ }
+
+ public bool RequiresVersionTracking()
+ {
+ if (_request.Kind != EndpointKind.AtomicOperations)
+ {
+ return false;
+ }
+
+ return _request.PrimaryResourceType!.IsVersioned || _targetedFields.Relationships.Any(relationship => relationship.RightType.IsVersioned);
+ }
+
+ public void CaptureVersions(ResourceType resourceType, IIdentifiable resource)
+ {
+ if (_request.Kind == EndpointKind.AtomicOperations)
+ {
+ if (resourceType.IsVersioned)
+ {
+ string? leftVersion = resource.GetVersion();
+ SetVersion(resourceType, resource.StringId!, leftVersion);
+ }
+
+ foreach (RelationshipAttribute relationship in _targetedFields.Relationships)
+ {
+ if (relationship.RightType.IsVersioned)
+ {
+ CaptureVersionsInRelationship(resource, relationship);
+ }
+ }
+ }
+ }
+
+ private void CaptureVersionsInRelationship(IIdentifiable resource, RelationshipAttribute relationship)
+ {
+ object? afterRightValue = relationship.GetValue(resource);
+ IReadOnlyCollection afterRightResources = CollectionConverter.ExtractResources(afterRightValue);
+
+ foreach (IIdentifiable rightResource in afterRightResources)
+ {
+ string? rightVersion = rightResource.GetVersion();
+ SetVersion(relationship.RightType, rightResource.StringId!, rightVersion);
+ }
+ }
+
+ private void SetVersion(ResourceType resourceType, string stringId, string? version)
+ {
+ string key = GetKey(resourceType, stringId);
+
+ if (version == null)
+ {
+ _versionPerResource.Remove(key);
+ }
+ else
+ {
+ _versionPerResource[key] = version;
+ }
+ }
+
+ public string? GetVersion(ResourceType resourceType, string stringId)
+ {
+ string key = GetKey(resourceType, stringId);
+ return _versionPerResource.TryGetValue(key, out string? version) ? version : null;
+ }
+
+ private string GetKey(ResourceType resourceType, string stringId)
+ {
+ return $"{resourceType.PublicName}::{stringId}";
+ }
+}
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
index 82e0ff52e1..e94d0f3ce7 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
@@ -275,6 +275,7 @@ private void AddOperationsLayer()
_services.AddScoped();
_services.AddScoped();
_services.AddScoped();
+ _services.AddScoped();
}
public void Dispose()
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
index 2af0e63caf..ed2606a8bb 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
@@ -207,6 +207,12 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st
return this;
}
+ if (resourceClrType.IsOrImplementsInterface() && !resourceClrType.IsOrImplementsInterface(typeof(IVersionedIdentifiable<,>)))
+ {
+ throw new InvalidConfigurationException(
+ $"Resource type '{resourceClrType}' implements 'IVersionedIdentifiable', but not 'IVersionedIdentifiable'.");
+ }
+
if (resourceClrType.IsOrImplementsInterface())
{
string effectivePublicName = publicName ?? FormatResourceName(resourceClrType);
diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
index 22efab2840..60650ce2e6 100644
--- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
+++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
@@ -206,8 +206,9 @@ public virtual async Task PostAsync([FromBody] TResource resource
TResource? newResource = await _create.CreateAsync(resource, cancellationToken);
- string resourceId = (newResource ?? resource).StringId!;
- string locationUrl = $"{HttpContext.Request.Path}/{resourceId}";
+ TResource resultResource = newResource ?? resource;
+ string? resourceVersion = resultResource.GetVersion();
+ string locationUrl = $"{HttpContext.Request.Path}/{resultResource.StringId}{(resourceVersion != null ? $";v~{resourceVersion}" : null)}";
if (newResource == null)
{
@@ -221,6 +222,9 @@ public virtual async Task PostAsync([FromBody] TResource resource
///
/// Adds resources to a to-many relationship. Example:
Example:
+ ///
///
///
@@ -262,6 +266,9 @@ public virtual async Task PostRelationshipAsync(TId id, string re
/// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent
/// relationships are replaced. Example:
Example:
+ ///
///
public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken)
@@ -295,7 +302,13 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource
/// PATCH /articles/1/relationships/author HTTP/1.1
/// ]]> Example:
///
Example:
+ ///
Example:
+ ///
///
///
@@ -335,6 +348,9 @@ public virtual async Task PatchRelationshipAsync(TId id, string r
///
/// Deletes an existing resource. Example:
Example:
+ ///
///
public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken)
@@ -357,6 +373,9 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c
///
/// Removes resources from a to-many relationship. Example:
Example:
+ ///
///
///
diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs
index 091bbee47b..6a99dc911b 100644
--- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs
+++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs
@@ -47,14 +47,18 @@ public override async Task GetAsync(CancellationToken cancellatio
}
///
+ // The {version} parameter is allowed, but ignored. It occurs in rendered links, because POST/PATCH/DELETE use it.
[HttpGet("{id}")]
+ [HttpGet("{id};v~{version}")]
[HttpHead("{id}")]
+ [HttpHead("{id};v~{version}")]
public override async Task GetAsync(TId id, CancellationToken cancellationToken)
{
return await base.GetAsync(id, cancellationToken);
}
///
+ // No {version} parameter, because it does not occur in rendered links.
[HttpGet("{id}/{relationshipName}")]
[HttpHead("{id}/{relationshipName}")]
public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken)
@@ -63,8 +67,11 @@ public override async Task GetSecondaryAsync(TId id, string relat
}
///
+ // The {version} parameter is allowed, but ignored. It occurs in rendered links, because POST/PATCH/DELETE use it.
[HttpGet("{id}/relationships/{relationshipName}")]
+ [HttpGet("{id};v~{version}/relationships/{relationshipName}")]
[HttpHead("{id}/relationships/{relationshipName}")]
+ [HttpHead("{id};v~{version}/relationships/{relationshipName}")]
public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken)
{
return await base.GetRelationshipAsync(id, relationshipName, cancellationToken);
@@ -79,6 +86,7 @@ public override async Task PostAsync([FromBody] TResource resourc
///
[HttpPost("{id}/relationships/{relationshipName}")]
+ [HttpPost("{id};v~{version}/relationships/{relationshipName}")]
public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds,
CancellationToken cancellationToken)
{
@@ -87,6 +95,7 @@ public override async Task PostRelationshipAsync(TId id, string r
///
[HttpPatch("{id}")]
+ [HttpPatch("{id};v~{version}")]
public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken)
{
return await base.PatchAsync(id, resource, cancellationToken);
@@ -94,6 +103,7 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc
///
[HttpPatch("{id}/relationships/{relationshipName}")]
+ [HttpPatch("{id};v~{version}/relationships/{relationshipName}")]
public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue,
CancellationToken cancellationToken)
{
@@ -102,6 +112,7 @@ public override async Task PatchRelationshipAsync(TId id, string
///
[HttpDelete("{id}")]
+ [HttpDelete("{id};v~{version}")]
public override async Task DeleteAsync(TId id, CancellationToken cancellationToken)
{
return await base.DeleteAsync(id, cancellationToken);
@@ -109,6 +120,7 @@ public override async Task DeleteAsync(TId id, CancellationToken
///
[HttpDelete("{id}/relationships/{relationshipName}")]
+ [HttpDelete("{id};v~{version}/relationships/{relationshipName}")]
public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds,
CancellationToken cancellationToken)
{
diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs
index 1d66bf517f..60e34dbd4a 100644
--- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs
+++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs
@@ -19,6 +19,12 @@ public interface IJsonApiRequest
///
string? PrimaryId { get; }
+ ///
+ /// The version of the primary resource for this request, when using optimistic concurrency. This would be "abc" in "/blogs/123;v~abc/author". This is
+ /// null when not using optimistic concurrency, and before and after processing operations in an atomic:operations request.
+ ///
+ string? PrimaryVersion { get; }
+
///
/// The primary resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and
/// after processing operations in an atomic:operations request.
diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
index b38ad986dd..76d3392cd0 100644
--- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
+++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
@@ -62,6 +62,11 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request);
+ if (!await ValidateVersionAsync(request, httpContext, options.SerializerWriteOptions))
+ {
+ return;
+ }
+
httpContext.RegisterJsonApiRequest();
}
else if (IsRouteForOperations(routeValues))
@@ -192,6 +197,36 @@ private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
return true;
}
+ private static async Task ValidateVersionAsync(IJsonApiRequest request, HttpContext httpContext, JsonSerializerOptions serializerOptions)
+ {
+ if (!request.IsReadOnly)
+ {
+ if (request.PrimaryResourceType!.IsVersioned && request.WriteOperation != WriteOperationKind.CreateResource && request.PrimaryVersion == null)
+ {
+ await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest)
+ {
+ Title = "The 'version' parameter is required at this endpoint.",
+ Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' require the version to be specified."
+ });
+
+ return false;
+ }
+
+ if (!request.PrimaryResourceType.IsVersioned && request.PrimaryVersion != null)
+ {
+ await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest)
+ {
+ Title = "The 'version' parameter is not supported at this endpoint.",
+ Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' are not versioned."
+ });
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error)
{
httpResponse.ContentType = HeaderConstants.MediaType;
@@ -212,6 +247,7 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr
request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method;
request.PrimaryResourceType = primaryResourceType;
request.PrimaryId = GetPrimaryRequestId(routeValues);
+ request.PrimaryVersion = GetPrimaryRequestVersion(routeValues);
string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues);
@@ -263,6 +299,11 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr
return routeValues.TryGetValue("id", out object? id) ? (string?)id : null;
}
+ private static string? GetPrimaryRequestVersion(RouteValueDictionary routeValues)
+ {
+ return routeValues.TryGetValue("version", out object? id) ? (string?)id : null;
+ }
+
private static string? GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues)
{
return routeValues.TryGetValue("relationshipName", out object? routeValue) ? (string?)routeValue : null;
diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs
index 98e42823a3..22364b8960 100644
--- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs
+++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs
@@ -14,6 +14,9 @@ public sealed class JsonApiRequest : IJsonApiRequest
///
public string? PrimaryId { get; set; }
+ ///
+ public string? PrimaryVersion { get; set; }
+
///
public ResourceType? PrimaryResourceType { get; set; }
@@ -42,6 +45,7 @@ public void CopyFrom(IJsonApiRequest other)
Kind = other.Kind;
PrimaryId = other.PrimaryId;
+ PrimaryVersion = other.PrimaryVersion;
PrimaryResourceType = other.PrimaryResourceType;
SecondaryResourceType = other.SecondaryResourceType;
Relationship = other.Relationship;
diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs
index a9d99c3b13..1d00c42207 100644
--- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs
+++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs
@@ -47,6 +47,11 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc
///
QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType);
+ ///
+ /// Builds a query that retrieves the primary resource, along with the subset of versioned targeted relationships, after a create/update/delete request.
+ ///
+ QueryLayer ComposeForGetVersionsAfterWrite(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection);
+
///
/// Builds a query for each targeted relationship with a filter to match on its right resource IDs.
///
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
index 29e0935954..7e27c1ca52 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
@@ -394,6 +394,46 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType
return primaryLayer;
}
+ public QueryLayer ComposeForGetVersionsAfterWrite(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection)
+ {
+ ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType));
+
+ // @formatter:wrap_chained_method_calls chop_always
+ // @formatter:keep_existing_linebreaks true
+
+ IImmutableSet includeElements = _targetedFields.Relationships
+ .Where(relationship => relationship.RightType.IsVersioned)
+ .Select(relationship => new IncludeElementExpression(relationship))
+ .ToImmutableHashSet();
+
+ // @formatter:keep_existing_linebreaks restore
+ // @formatter:wrap_chained_method_calls restore
+
+ AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType);
+
+ QueryLayer primaryLayer = new(primaryResourceType)
+ {
+ Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty,
+ Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, null)
+ };
+
+ if (fieldSelection == TopFieldSelection.OnlyIdAttribute)
+ {
+ var primarySelection = new FieldSelection();
+ FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryLayer.ResourceType);
+ primarySelectors.IncludeAttribute(primaryIdAttribute);
+
+ foreach (IncludeElementExpression include in includeElements)
+ {
+ primarySelectors.IncludeRelationship(include.Relationship, null);
+ }
+
+ primaryLayer.Selection = primarySelection;
+ }
+
+ return primaryLayer;
+ }
+
///
public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource)
{
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
index 1f1c10301a..39754f023f 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
@@ -143,6 +143,10 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe
}
IncludeFields(fieldSelectors, propertySelectors);
+
+ // Implicitly add concurrency tokens, which we need for rendering links, but may not be exposed as attributes.
+ IncludeConcurrencyTokens(resourceType, elementType, propertySelectors);
+
IncludeEagerLoads(resourceType, propertySelectors);
return propertySelectors.Values;
@@ -169,6 +173,21 @@ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors)
+ {
+ if (resourceType.IsVersioned)
+ {
+ IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
+ IEnumerable tokenProperties = entityModel.GetProperties().Where(property => property.IsConcurrencyToken).ToArray();
+
+ foreach (IProperty tokenProperty in tokenProperties)
+ {
+ var propertySelector = new PropertySelector(tokenProperty.PropertyInfo!);
+ IncludeWritableProperty(propertySelector, propertySelectors);
+ }
+ }
+ }
+
private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors)
{
if (propertySelector.Property.SetMethod != null)
diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs
new file mode 100644
index 0000000000..4b8555c38d
--- /dev/null
+++ b/src/JsonApiDotNetCore/Repositories/DataStoreConcurrencyException.cs
@@ -0,0 +1,16 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Repositories;
+
+///
+/// The error that is thrown when the resource version from the request does not match the server version.
+///
+[PublicAPI]
+public sealed class DataStoreConcurrencyException : DataStoreUpdateException
+{
+ public DataStoreConcurrencyException(Exception? innerException)
+ : base("The resource version does not match the server version. This indicates that data has been modified since the resource was retrieved.",
+ innerException)
+ {
+ }
+}
diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs
index e823b50077..59b1574c81 100644
--- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs
+++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs
@@ -6,10 +6,15 @@ namespace JsonApiDotNetCore.Repositories;
/// The error that is thrown when the underlying data store is unable to persist changes.
///
[PublicAPI]
-public sealed class DataStoreUpdateException : Exception
+public class DataStoreUpdateException : Exception
{
public DataStoreUpdateException(Exception? innerException)
- : base("Failed to persist changes in the underlying data store.", innerException)
+ : this("Failed to persist changes in the underlying data store.", innerException)
+ {
+ }
+
+ protected DataStoreUpdateException(string message, Exception? innerException)
+ : base(message, innerException)
{
}
}
diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
index 653db6129a..9dad68712a 100644
--- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
+++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
@@ -26,6 +26,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository
where TResource : class, IIdentifiable
{
private readonly CollectionConverter _collectionConverter = new();
+ private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
private readonly DbContext _dbContext;
private readonly IResourceGraph _resourceGraph;
@@ -37,24 +38,26 @@ public class EntityFrameworkCoreRepository : IResourceRepository
///
public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString();
- public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph,
- IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory,
- IResourceDefinitionAccessor resourceDefinitionAccessor)
+ public EntityFrameworkCoreRepository(IJsonApiRequest request, ITargetedFields targetedFields, IDbContextResolver dbContextResolver,
+ IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
+ IEnumerable constraintProviders, ILoggerFactory loggerFactory)
{
+ ArgumentGuard.NotNull(request);
ArgumentGuard.NotNull(targetedFields);
ArgumentGuard.NotNull(dbContextResolver);
ArgumentGuard.NotNull(resourceGraph);
ArgumentGuard.NotNull(resourceFactory);
+ ArgumentGuard.NotNull(resourceDefinitionAccessor);
ArgumentGuard.NotNull(constraintProviders);
ArgumentGuard.NotNull(loggerFactory);
- ArgumentGuard.NotNull(resourceDefinitionAccessor);
+ _request = request;
_targetedFields = targetedFields;
_dbContext = dbContextResolver.GetContext();
_resourceGraph = resourceGraph;
_resourceFactory = resourceFactory;
- _constraintProviders = constraintProviders;
_resourceDefinitionAccessor = resourceDefinitionAccessor;
+ _constraintProviders = constraintProviders;
_traceWriter = new TraceLogWriter>(loggerFactory);
}
@@ -245,7 +248,11 @@ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, has
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update");
IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken);
- return resources.FirstOrDefault();
+ TResource? resource = resources.FirstOrDefault();
+
+ resource?.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
+
+ return resource;
}
///
@@ -320,6 +327,7 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C
// If so, we'll reuse the tracked resource instead of this placeholder resource.
TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance();
placeholderResource.Id = id;
+ placeholderResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken);
@@ -529,6 +537,17 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored))
{
+ if (relationship.RightType.IsVersioned)
+ {
+ foreach (IIdentifiable rightResource in rightResourceIdsStored)
+ {
+ string? requestVersion = rightResourceIdsToRemove.Single(resource => resource.StringId == rightResource.StringId).GetVersion();
+
+ rightResource.RestoreConcurrencyToken(_dbContext, requestVersion);
+ rightResource.RefreshConcurrencyValue();
+ }
+ }
+
AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore);
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken);
@@ -590,6 +609,9 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken);
}
+ leftResource.RestoreConcurrencyToken(_dbContext, _request.PrimaryVersion);
+ leftResource.RefreshConcurrencyValue();
+
relationship.SetValue(leftResource, trackedValueToAssign);
}
@@ -603,6 +625,13 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue);
IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray();
+ foreach (IIdentifiable rightResourceTracked in rightResourcesTracked)
+ {
+ string? rightVersion = rightResourceTracked.GetVersion();
+ rightResourceTracked.RestoreConcurrencyToken(_dbContext, rightVersion);
+ rightResourceTracked.RefreshConcurrencyValue();
+ }
+
return rightValue is IEnumerable
? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType)
: rightResourcesTracked.Single();
@@ -628,7 +657,7 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
{
_dbContext.ResetChangeTracker();
- throw new DataStoreUpdateException(exception);
+ throw exception is DbUpdateConcurrencyException ? new DataStoreConcurrencyException(exception) : new DataStoreUpdateException(exception);
}
}
}
diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs
index 9a1c025214..921aa42566 100644
--- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs
+++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs
@@ -1,11 +1,15 @@
using System.Reflection;
using JsonApiDotNetCore.Resources.Internal;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace JsonApiDotNetCore.Resources;
internal static class IdentifiableExtensions
{
private const string IdPropertyName = nameof(Identifiable