Skip to content

Commit 6b33557

Browse files
author
Bart Koelman
committed
Explores support for concurrency tokens using PostgreSQL
Because we fetch the row before update and apply changes on that, a concurrency violation is only reported when two concurrent requests update the same row in parallel. Instead, we want to produce an error if the token sent by the user does not match the stored token. To do that, we need to convince EF Core to use that as original version. That's not too hard. Now the problem is that there is no way to send the token for relationships or deleting a resource. Skipped tests have been added to demonstrate this. We could fetch such related rows upfront to work around that, but that kinda defeats the purpose of using concurrency tokens in the first place. It may be more correct to fail when a user is trying to add a related resource that has changed since it was fetched. This reasoning may be a bit too puristic and impractical, but at least that's how EF Core seems to handle it. Solutions considerations: - Add 'version' to resource identifier object, so the client can send it. The spec does not explicitly forbid adding custom fields, however 'meta' would probably be the recommended approach. Instead of extending the definition, we could encode it in the StringId. - Once we have access to that token value, we need to somehow map that to 'the' resource property. What if there are multiple concurrency token properties on a resource? And depending on the database used, this could be typed as numeric, guid, timestamp, binary or something else. - Given that PostgreSQL uses a number (uint xmin), should we obfuscate or even encrypt that? If the latter, we need to add an option for api developers to set the encryption key. See also: json-api/json-api#600 json-api/json-api#824
1 parent 04c2beb commit 6b33557

File tree

9 files changed

+823
-0
lines changed

9 files changed

+823
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System;
2+
using System.Net;
3+
using JsonApiDotNetCore.Serialization.Objects;
4+
5+
namespace JsonApiDotNetCore.Errors
6+
{
7+
/// <summary>
8+
/// The error that is thrown when data has been modified on the server since the resource was retrieved.
9+
/// </summary>
10+
public sealed class DataConcurrencyException : JsonApiException
11+
{
12+
public DataConcurrencyException(Exception exception)
13+
: base(new Error(HttpStatusCode.Conflict)
14+
{
15+
Title = "The concurrency token is missing or does not match the server version. This indicates that data has been modified since the resource was retrieved.",
16+
}, exception)
17+
{
18+
}
19+
}
20+
}

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+25
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,30 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r
179179
attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest));
180180
}
181181

182+
RestoreConcurrencyToken(resourceFromRequest, resourceFromDatabase);
183+
182184
await SaveChangesAsync(cancellationToken);
183185
}
184186

187+
private void RestoreConcurrencyToken(TResource resourceFromRequest, TResource resourceFromDatabase)
188+
{
189+
foreach (var propertyEntry in _dbContext.Entry(resourceFromDatabase).Properties)
190+
{
191+
if (propertyEntry.Metadata.IsConcurrencyToken)
192+
{
193+
// Overwrite the ConcurrencyToken coming from database with the one from the request body.
194+
// If they are different, EF Core throws a DbUpdateConcurrencyException on save.
195+
196+
var concurrencyTokenProperty = typeof(TResource).GetProperty(propertyEntry.Metadata.PropertyInfo.Name);
197+
if (concurrencyTokenProperty != null)
198+
{
199+
var concurrencyTokenFromRequest = concurrencyTokenProperty.GetValue(resourceFromRequest);
200+
propertyEntry.OriginalValue = concurrencyTokenFromRequest;
201+
}
202+
}
203+
}
204+
}
205+
185206
protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue)
186207
{
187208
bool relationshipIsRequired = false;
@@ -397,6 +418,10 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
397418
{
398419
await _dbContext.SaveChangesAsync(cancellationToken);
399420
}
421+
catch (DbUpdateConcurrencyException exception)
422+
{
423+
throw new DataConcurrencyException(exception);
424+
}
400425
catch (DbUpdateException exception)
401426
{
402427
throw new DataStoreUpdateException(exception);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ConcurrencyTokens
4+
{
5+
public sealed class ConcurrencyDbContext : DbContext
6+
{
7+
public DbSet<Disk> Disks { get; set; }
8+
public DbSet<Partition> Partitions { get; set; }
9+
10+
public ConcurrencyDbContext(DbContextOptions<ConcurrencyDbContext> options) : base(options)
11+
{
12+
}
13+
14+
protected override void OnModelCreating(ModelBuilder builder)
15+
{
16+
// https://www.npgsql.org/efcore/modeling/concurrency.html
17+
18+
builder.Entity<Disk>()
19+
.UseXminAsConcurrencyToken();
20+
21+
builder.Entity<Partition>()
22+
.UseXminAsConcurrencyToken();
23+
}
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System;
2+
using Bogus;
3+
4+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ConcurrencyTokens
5+
{
6+
internal sealed class ConcurrencyFakers : FakerContainer
7+
{
8+
private const ulong _oneGigabyte = 1024 * 1024 * 1024;
9+
private static readonly string[] _fileSystems = {"NTFS", "FAT32", "ext4", "XFS", "btrfs"};
10+
11+
private readonly Lazy<Faker<Disk>> _lazyDiskFaker = new Lazy<Faker<Disk>>(() =>
12+
new Faker<Disk>()
13+
.UseSeed(GetFakerSeed())
14+
.RuleFor(disk => disk.Manufacturer, f => f.Company.CompanyName())
15+
.RuleFor(disk => disk.SerialCode, f => f.System.ApplePushToken()));
16+
17+
private readonly Lazy<Faker<Partition>> _lazyPartitionFaker = new Lazy<Faker<Partition>>(() =>
18+
new Faker<Partition>()
19+
.UseSeed(GetFakerSeed())
20+
.RuleFor(partition => partition.MountPoint, f => f.System.DirectoryPath())
21+
.RuleFor(partition => partition.FileSystem, f => f.PickRandom(_fileSystems))
22+
.RuleFor(partition => partition.CapacityInBytes, f => f.Random.ULong(_oneGigabyte * 50, _oneGigabyte * 100))
23+
.RuleFor(partition => partition.FreeSpaceInBytes, f => f.Random.ULong(_oneGigabyte * 10, _oneGigabyte * 40)));
24+
25+
public Faker<Disk> Disk => _lazyDiskFaker.Value;
26+
public Faker<Partition> Partition => _lazyPartitionFaker.Value;
27+
}
28+
}

0 commit comments

Comments
 (0)