Skip to content
1 change: 1 addition & 0 deletions JsonApiDotNetCore.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -660,5 +660,6 @@ $left$ = $right$;</s:String>
<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/=Workflows/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmin/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
</wpf:ResourceDictionary>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
Expand All @@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
public sealed class DbContextARepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
where TResource : class, IIdentifiable<int>
{
public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver, IResourceGraph resourceGraph,
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
IResourceDefinitionAccessor resourceDefinitionAccessor)
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
public DbContextARepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver,
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
Expand All @@ -11,10 +12,10 @@ namespace MultiDbContextExample.Repositories;
public sealed class DbContextBRepository<TResource> : EntityFrameworkCoreRepository<TResource, int>
where TResource : class, IIdentifiable<int>
{
public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver, IResourceGraph resourceGraph,
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
IResourceDefinitionAccessor resourceDefinitionAccessor)
: base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
public DbContextBRepository(IJsonApiRequest request, ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver,
IResourceGraph resourceGraph, IResourceFactory resourceFactory, IResourceDefinitionAccessor resourceDefinitionAccessor,
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
: base(request, targetedFields, dbContextResolver, resourceGraph, resourceFactory, resourceDefinitionAccessor, constraintProviders, loggerFactory)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.Configuration;
Expand Down Expand Up @@ -38,6 +39,11 @@ public sealed class ResourceType
/// </summary>
public IReadOnlySet<ResourceType> DirectlyDerivedTypes { get; internal set; } = new HashSet<ResourceType>();

/// <summary>
/// When <c>true</c>, this resource type uses optimistic concurrency.
/// </summary>
public bool IsVersioned => ClrType.IsOrImplementsInterface<IVersionedIdentifiable>();

/// <summary>
/// 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using JetBrains.Annotations;

namespace JsonApiDotNetCore.Resources;

/// <summary>
/// Defines the basic contract for a JSON:API resource that uses optimistic concurrency. All resource classes must implement
/// <see cref="IVersionedIdentifiable{TId, TVersion}" />.
/// </summary>
public interface IVersionedIdentifiable : IIdentifiable
{
/// <summary>
/// The value for element 'version' in a JSON:API request or response.
/// </summary>
string? Version { get; set; }
}

/// <summary>
/// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource that uses optimistic concurrency.
/// </summary>
/// <typeparam name="TId">
/// The resource identifier type.
/// </typeparam>
/// <typeparam name="TVersion">
/// The database vendor-specific type that is used to store the concurrency token.
/// </typeparam>
[PublicAPI]
public interface IVersionedIdentifiable<TId, TVersion> : IIdentifiable<TId>, IVersionedIdentifiable
{
/// <summary>
/// The concurrency token, which is used to detect if the resource was modified by another user since the moment this resource was last retrieved.
/// </summary>
TVersion ConcurrencyToken { get; set; }

/// <summary>
/// Represents a database column where random data is written to on updates, in order to force a concurrency check during relationship updates.
/// </summary>
Guid ConcurrencyValue { get; set; }
}
2 changes: 1 addition & 1 deletion src/JsonApiDotNetCore.Annotations/TypeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public static bool IsOrImplementsInterface<TInterface>(this Type? source)
/// <summary>
/// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface.
/// </summary>
private static bool IsOrImplementsInterface(this Type? source, Type interfaceType)
public static bool IsOrImplementsInterface(this Type? source, Type interfaceType)
{
ArgumentGuard.NotNull(interfaceType);

Expand Down
13 changes: 13 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/IVersionTracker.cs
Original file line number Diff line number Diff line change
@@ -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);
}
41 changes: 40 additions & 1 deletion src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ 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;
private readonly ISparseFieldSetCache _sparseFieldSetCache;
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);
Expand All @@ -36,6 +38,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
_operationProcessorAccessor = operationProcessorAccessor;
_operationsTransactionFactory = operationsTransactionFactory;
_localIdTracker = localIdTracker;
_versionTracker = versionTracker;
_resourceGraph = resourceGraph;
_request = request;
_targetedFields = targetedFields;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
}
}
}
}
91 changes: 91 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/VersionTracker.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> _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<IIdentifiable> 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}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ private void AddOperationsLayer()
_services.AddScoped<IOperationsProcessor, OperationsProcessor>();
_services.AddScoped<IOperationProcessorAccessor, OperationProcessorAccessor>();
_services.AddScoped<ILocalIdTracker, LocalIdTracker>();
_services.AddScoped<IVersionTracker, VersionTracker>();
}

public void Dispose()
Expand Down
6 changes: 6 additions & 0 deletions src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st
return this;
}

if (resourceClrType.IsOrImplementsInterface<IVersionedIdentifiable>() && !resourceClrType.IsOrImplementsInterface(typeof(IVersionedIdentifiable<,>)))
{
throw new InvalidConfigurationException(
$"Resource type '{resourceClrType}' implements 'IVersionedIdentifiable', but not 'IVersionedIdentifiable<TId, TVersion>'.");
}

if (resourceClrType.IsOrImplementsInterface<IIdentifiable>())
{
string effectivePublicName = publicName ?? FormatResourceName(resourceClrType);
Expand Down
23 changes: 21 additions & 2 deletions src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,9 @@ public virtual async Task<IActionResult> 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)
{
Expand All @@ -221,6 +222,9 @@ public virtual async Task<IActionResult> PostAsync([FromBody] TResource resource
/// <summary>
/// Adds resources to a to-many relationship. Example: <code><![CDATA[
/// POST /articles/1/revisions HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// POST /articles/1;v~8/revisions HTTP/1.1
/// ]]></code>
/// </summary>
/// <param name="id">
Expand Down Expand Up @@ -262,6 +266,9 @@ public virtual async Task<IActionResult> 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: <code><![CDATA[
/// PATCH /articles/1 HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1;v~8 HTTP/1.1
/// ]]></code>
/// </summary>
public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken)
Expand Down Expand Up @@ -295,7 +302,13 @@ public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource
/// PATCH /articles/1/relationships/author HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1;v~8/relationships/author HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1/relationships/revisions HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// PATCH /articles/1;v~8/relationships/revisions HTTP/1.1
/// ]]></code>
/// </summary>
/// <param name="id">
Expand Down Expand Up @@ -335,6 +348,9 @@ public virtual async Task<IActionResult> PatchRelationshipAsync(TId id, string r
/// <summary>
/// Deletes an existing resource. Example: <code><![CDATA[
/// DELETE /articles/1 HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// DELETE /articles/1;v~8 HTTP/1.1
/// ]]></code>
/// </summary>
public virtual async Task<IActionResult> DeleteAsync(TId id, CancellationToken cancellationToken)
Expand All @@ -357,6 +373,9 @@ public virtual async Task<IActionResult> DeleteAsync(TId id, CancellationToken c
/// <summary>
/// Removes resources from a to-many relationship. Example: <code><![CDATA[
/// DELETE /articles/1/relationships/revisions HTTP/1.1
/// ]]></code> Example:
/// <code><![CDATA[
/// DELETE /articles/1;v~8/relationships/revisions HTTP/1.1
/// ]]></code>
/// </summary>
/// <param name="id">
Expand Down
Loading