diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings
index 92cce56c47..dd35f32264 100644
--- a/JsonApiDotNetCore.sln.DotSettings
+++ b/JsonApiDotNetCore.sln.DotSettings
@@ -631,6 +631,7 @@ $left$ = $right$;
WARNING
True
True
+ True
True
True
True
diff --git a/ROADMAP.md b/ROADMAP.md
index 559e0bfe7d..2e3f53ff2f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -34,7 +34,7 @@ Aside from the list above, we have interest in the following topics. It's too so
- OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046)
- Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776)
- Resource inheritance [#844](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844)
-- Idempotency
+- Idempotency [#1132](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132)
## Feedback
diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs
index be5125c414..3e319f432f 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs
@@ -13,17 +13,19 @@ public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction
{
private readonly IDbContextTransaction _transaction;
private readonly DbContext _dbContext;
+ private readonly bool _ownsTransaction;
///
public string TransactionId => _transaction.TransactionId.ToString();
- public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext)
+ public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext, bool ownsTransaction)
{
ArgumentGuard.NotNull(transaction, nameof(transaction));
ArgumentGuard.NotNull(dbContext, nameof(dbContext));
_transaction = transaction;
_dbContext = dbContext;
+ _ownsTransaction = ownsTransaction;
}
///
@@ -44,14 +46,20 @@ public Task AfterProcessOperationAsync(CancellationToken cancellationToken)
}
///
- public Task CommitAsync(CancellationToken cancellationToken)
+ public async Task CommitAsync(CancellationToken cancellationToken)
{
- return _transaction.CommitAsync(cancellationToken);
+ if (_ownsTransaction)
+ {
+ await _transaction.CommitAsync(cancellationToken);
+ }
}
///
- public ValueTask DisposeAsync()
+ public async ValueTask DisposeAsync()
{
- return _transaction.DisposeAsync();
+ if (_ownsTransaction)
+ {
+ await _transaction.DisposeAsync();
+ }
}
}
diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs
index 96c66e12ab..8ede99ec4f 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs
@@ -27,10 +27,17 @@ public async Task BeginTransactionAsync(CancellationToke
{
DbContext dbContext = _dbContextResolver.GetContext();
- IDbContextTransaction transaction = _options.TransactionIsolationLevel != null
+ IDbContextTransaction? existingTransaction = dbContext.Database.CurrentTransaction;
+
+ if (existingTransaction != null)
+ {
+ return new EntityFrameworkCoreTransaction(existingTransaction, dbContext, false);
+ }
+
+ IDbContextTransaction newTransaction = _options.TransactionIsolationLevel != null
? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken)
: await dbContext.Database.BeginTransactionAsync(cancellationToken);
- return new EntityFrameworkCoreTransaction(transaction, dbContext);
+ return new EntityFrameworkCoreTransaction(newTransaction, dbContext, true);
}
}
diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs
index a941e27218..f172052a9e 100644
--- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs
+++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs
@@ -44,6 +44,7 @@ public static void UseJsonApi(this IApplicationBuilder builder)
options.Conventions.Insert(0, routingConvention);
};
+ builder.UseMiddleware();
builder.UseMiddleware();
}
}
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
index 7cd4307ad1..5679e1aabc 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
@@ -178,6 +178,7 @@ private void AddMiddlewareLayer()
_services.AddScoped();
_services.AddScoped();
_services.AddScoped();
+ _services.AddScoped();
}
private void AddResourceLayer()
diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
index cc91d0f324..8b0603c3ba 100644
--- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
+++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
@@ -41,6 +41,7 @@
+
diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs
index 43a5989d59..ca7c83ad8d 100644
--- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs
+++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs
@@ -9,4 +9,5 @@ public static class HeaderConstants
{
public const string MediaType = "application/vnd.api+json";
public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\"";
+ public const string IdempotencyKey = "Idempotency-Key";
}
diff --git a/src/JsonApiDotNetCore/Middleware/IIdempotencyProvider.cs b/src/JsonApiDotNetCore/Middleware/IIdempotencyProvider.cs
new file mode 100644
index 0000000000..210a1eea17
--- /dev/null
+++ b/src/JsonApiDotNetCore/Middleware/IIdempotencyProvider.cs
@@ -0,0 +1,30 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.AtomicOperations;
+using Microsoft.AspNetCore.Http;
+
+namespace JsonApiDotNetCore.Middleware;
+
+[PublicAPI]
+public interface IIdempotencyProvider
+{
+ ///
+ /// Indicates whether the current request supports idempotency.
+ ///
+ bool IsSupported(HttpRequest request);
+
+ ///
+ /// Looks for a matching response in the idempotency cache for the specified idempotency key.
+ ///
+ Task GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken);
+
+ ///
+ /// Creates a new cache entry inside a transaction, so that concurrent requests with the same idempotency key will block or fail while the transaction
+ /// hasn't been committed.
+ ///
+ Task BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken);
+
+ ///
+ /// Saves the produced response in the cache and commits its transaction.
+ ///
+ Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction, CancellationToken cancellationToken);
+}
diff --git a/src/JsonApiDotNetCore/Middleware/IdempotencyMiddleware.cs b/src/JsonApiDotNetCore/Middleware/IdempotencyMiddleware.cs
new file mode 100644
index 0000000000..110c7ec750
--- /dev/null
+++ b/src/JsonApiDotNetCore/Middleware/IdempotencyMiddleware.cs
@@ -0,0 +1,249 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using JetBrains.Annotations;
+using JsonApiDotNetCore.AtomicOperations;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Errors;
+using JsonApiDotNetCore.Serialization.Objects;
+using JsonApiDotNetCore.Serialization.Response;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.IO;
+using Microsoft.Net.Http.Headers;
+using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute;
+
+namespace JsonApiDotNetCore.Middleware;
+
+// IMPORTANT: In your Program.cs, make sure app.UseDeveloperExceptionPage() is called BEFORE this!
+
+public sealed class IdempotencyMiddleware
+{
+ private static readonly RecyclableMemoryStreamManager MemoryStreamManager = new();
+
+ private readonly IJsonApiOptions _options;
+ private readonly IFingerprintGenerator _fingerprintGenerator;
+ private readonly RequestDelegate _next;
+
+ public IdempotencyMiddleware(IJsonApiOptions options, IFingerprintGenerator fingerprintGenerator, RequestDelegate next)
+ {
+ ArgumentGuard.NotNull(options, nameof(options));
+ ArgumentGuard.NotNull(fingerprintGenerator, nameof(fingerprintGenerator));
+
+ _options = options;
+ _fingerprintGenerator = fingerprintGenerator;
+ _next = next;
+ }
+
+ public async Task InvokeAsync(HttpContext httpContext, IIdempotencyProvider idempotencyProvider)
+ {
+ try
+ {
+ await InnerInvokeAsync(httpContext, idempotencyProvider);
+ }
+ catch (JsonApiException exception)
+ {
+ await FlushResponseAsync(httpContext.Response, _options.SerializerWriteOptions, exception.Errors.Single());
+ }
+ }
+
+ public async Task InnerInvokeAsync(HttpContext httpContext, IIdempotencyProvider idempotencyProvider)
+ {
+ string? idempotencyKey = GetIdempotencyKey(httpContext.Request.Headers);
+
+ if (idempotencyKey != null && idempotencyProvider is NoIdempotencyProvider)
+ {
+ throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
+ {
+ Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
+ Detail = "Idempotency is currently disabled.",
+ Source = new ErrorSource
+ {
+ Header = HeaderConstants.IdempotencyKey
+ }
+ });
+ }
+
+ if (!idempotencyProvider.IsSupported(httpContext.Request))
+ {
+ await _next(httpContext);
+ return;
+ }
+
+ AssertIdempotencyKeyIsValid(idempotencyKey);
+
+ await BufferRequestBodyAsync(httpContext);
+
+ string requestFingerprint = await GetRequestFingerprintAsync(httpContext);
+ IdempotentResponse? idempotentResponse = await idempotencyProvider.GetResponseFromCacheAsync(idempotencyKey, httpContext.RequestAborted);
+
+ if (idempotentResponse != null)
+ {
+ if (idempotentResponse.RequestFingerprint != requestFingerprint)
+ {
+ throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity)
+ {
+ Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
+ Detail = $"The provided idempotency key '{idempotencyKey}' is in use for another request.",
+ Source = new ErrorSource
+ {
+ Header = HeaderConstants.IdempotencyKey
+ }
+ });
+ }
+
+ httpContext.Response.StatusCode = (int)idempotentResponse.ResponseStatusCode;
+ httpContext.Response.Headers[HeaderConstants.IdempotencyKey] = $"\"{idempotencyKey}\"";
+ httpContext.Response.Headers[HeaderNames.Location] = idempotentResponse.ResponseLocationHeader;
+
+ if (idempotentResponse.ResponseContentTypeHeader != null)
+ {
+ // Workaround for invalid nullability annotation in HttpResponse.ContentType
+ // Fixed after ASP.NET 6 release, see https://github.com/dotnet/aspnetcore/commit/8bb128185b58a26065d0f29e695a2410cf0a3c68#diff-bbfd771a8ef013a9921bff36df0d69f424910e079945992f1dccb24de54ca717
+ httpContext.Response.ContentType = idempotentResponse.ResponseContentTypeHeader;
+ }
+
+ await using TextWriter writer = new HttpResponseStreamWriter(httpContext.Response.Body, Encoding.UTF8);
+ await writer.WriteAsync(idempotentResponse.ResponseBody);
+ await writer.FlushAsync();
+
+ return;
+ }
+
+ await using IOperationsTransaction transaction =
+ await idempotencyProvider.BeginRequestAsync(idempotencyKey, requestFingerprint, httpContext.RequestAborted);
+
+ string responseBody = await CaptureResponseBodyAsync(httpContext, _next);
+
+ idempotentResponse = new IdempotentResponse(requestFingerprint, (HttpStatusCode)httpContext.Response.StatusCode,
+ httpContext.Response.Headers[HeaderNames.Location], httpContext.Response.ContentType, responseBody);
+
+ await idempotencyProvider.CompleteRequestAsync(idempotencyKey, idempotentResponse, transaction, httpContext.RequestAborted);
+ }
+
+ private static string? GetIdempotencyKey(IHeaderDictionary requestHeaders)
+ {
+ if (!requestHeaders.ContainsKey(HeaderConstants.IdempotencyKey))
+ {
+ return null;
+ }
+
+ string headerValue = requestHeaders[HeaderConstants.IdempotencyKey];
+
+ if (headerValue.Length >= 2 && headerValue[0] == '\"' && headerValue[^1] == '\"')
+ {
+ return headerValue[1..^1];
+ }
+
+ return string.Empty;
+ }
+
+ [AssertionMethod]
+ private static void AssertIdempotencyKeyIsValid([SysNotNull] string? idempotencyKey)
+ {
+ if (idempotencyKey == null)
+ {
+ throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
+ {
+ Title = $"Missing '{HeaderConstants.IdempotencyKey}' HTTP header.",
+ Detail = "An idempotency key is a unique value generated by the client, which the server uses to recognize subsequent retries " +
+ "of the same request. This should be a random string with enough entropy to avoid collisions."
+ });
+ }
+
+ if (idempotencyKey == string.Empty)
+ {
+ throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
+ {
+ Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
+ Detail = "Expected non-empty value surrounded by double quotes.",
+ Source = new ErrorSource
+ {
+ Header = HeaderConstants.IdempotencyKey
+ }
+ });
+ }
+ }
+
+ ///
+ /// Enables to read the HTTP request stream multiple times, without risking GC Gen2/LOH promotion.
+ ///
+ private static async Task BufferRequestBodyAsync(HttpContext httpContext)
+ {
+ // Above this threshold, EnableBuffering() switches to a temporary file on disk.
+ // Source: Microsoft.AspNetCore.Http.BufferingHelper.DefaultBufferThreshold
+ const int enableBufferingThreshold = 1024 * 30;
+
+ if (httpContext.Request.ContentLength > enableBufferingThreshold)
+ {
+ httpContext.Request.EnableBuffering(enableBufferingThreshold);
+ }
+ else
+ {
+ MemoryStream memoryRequestBodyStream = MemoryStreamManager.GetStream();
+ await httpContext.Request.Body.CopyToAsync(memoryRequestBodyStream, httpContext.RequestAborted);
+ memoryRequestBodyStream.Seek(0, SeekOrigin.Begin);
+
+ httpContext.Request.Body = memoryRequestBodyStream;
+ httpContext.Response.RegisterForDispose(memoryRequestBodyStream);
+ }
+ }
+
+ private async Task GetRequestFingerprintAsync(HttpContext httpContext)
+ {
+ using var reader = new StreamReader(httpContext.Request.Body, leaveOpen: true);
+ string requestBody = await reader.ReadToEndAsync();
+ httpContext.Request.Body.Seek(0, SeekOrigin.Begin);
+
+ return _fingerprintGenerator.Generate(ArrayFactory.Create(httpContext.Request.GetEncodedUrl(), requestBody));
+ }
+
+ ///
+ /// Executes the specified action and returns what it wrote to the HTTP response stream.
+ ///
+ private static async Task CaptureResponseBodyAsync(HttpContext httpContext, RequestDelegate nextAction)
+ {
+ // Loosely based on https://elanderson.net/2019/12/log-requests-and-responses-in-asp-net-core-3/.
+
+ Stream previousResponseBodyStream = httpContext.Response.Body;
+
+ try
+ {
+ await using MemoryStream memoryResponseBodyStream = MemoryStreamManager.GetStream();
+ httpContext.Response.Body = memoryResponseBodyStream;
+
+ try
+ {
+ await nextAction(httpContext);
+ }
+ finally
+ {
+ memoryResponseBodyStream.Seek(0, SeekOrigin.Begin);
+ await memoryResponseBodyStream.CopyToAsync(previousResponseBodyStream);
+ }
+
+ memoryResponseBodyStream.Seek(0, SeekOrigin.Begin);
+ using var streamReader = new StreamReader(memoryResponseBodyStream, leaveOpen: true);
+ return await streamReader.ReadToEndAsync();
+ }
+ finally
+ {
+ httpContext.Response.Body = previousResponseBodyStream;
+ }
+ }
+
+ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error)
+ {
+ httpResponse.ContentType = HeaderConstants.MediaType;
+ httpResponse.StatusCode = (int)error.StatusCode;
+
+ var errorDocument = new Document
+ {
+ Errors = error.AsList()
+ };
+
+ await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions);
+ await httpResponse.Body.FlushAsync();
+ }
+}
diff --git a/src/JsonApiDotNetCore/Middleware/IdempotentResponse.cs b/src/JsonApiDotNetCore/Middleware/IdempotentResponse.cs
new file mode 100644
index 0000000000..0d12b47072
--- /dev/null
+++ b/src/JsonApiDotNetCore/Middleware/IdempotentResponse.cs
@@ -0,0 +1,25 @@
+using System.Net;
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Middleware;
+
+[PublicAPI]
+public sealed class IdempotentResponse
+{
+ public string RequestFingerprint { get; }
+
+ public HttpStatusCode ResponseStatusCode { get; }
+ public string? ResponseLocationHeader { get; }
+ public string? ResponseContentTypeHeader { get; }
+ public string? ResponseBody { get; }
+
+ public IdempotentResponse(string requestFingerprint, HttpStatusCode responseStatusCode, string? responseLocationHeader, string? responseContentTypeHeader,
+ string? responseBody)
+ {
+ RequestFingerprint = requestFingerprint;
+ ResponseStatusCode = responseStatusCode;
+ ResponseLocationHeader = responseLocationHeader;
+ ResponseContentTypeHeader = responseContentTypeHeader;
+ ResponseBody = responseBody;
+ }
+}
diff --git a/src/JsonApiDotNetCore/Middleware/NoIdempotencyProvider.cs b/src/JsonApiDotNetCore/Middleware/NoIdempotencyProvider.cs
new file mode 100644
index 0000000000..4ee224ef6d
--- /dev/null
+++ b/src/JsonApiDotNetCore/Middleware/NoIdempotencyProvider.cs
@@ -0,0 +1,32 @@
+using JsonApiDotNetCore.AtomicOperations;
+using Microsoft.AspNetCore.Http;
+
+namespace JsonApiDotNetCore.Middleware;
+
+internal sealed class NoIdempotencyProvider : IIdempotencyProvider
+{
+ ///
+ public bool IsSupported(HttpRequest request)
+ {
+ return false;
+ }
+
+ ///
+ public Task GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ ///
+ public Task BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ ///
+ public Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction,
+ CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/AsyncAutoResetEvent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/AsyncAutoResetEvent.cs
new file mode 100644
index 0000000000..f643391240
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/AsyncAutoResetEvent.cs
@@ -0,0 +1,48 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+// Based on https://devblogs.microsoft.com/pfxteam/building-async-coordination-primitives-part-2-asyncautoresetevent/
+[PublicAPI]
+public sealed class AsyncAutoResetEvent
+{
+ private static readonly Task CompletedTask = Task.FromResult(true);
+
+ private readonly Queue> _waiters = new();
+ private bool _isSignaled;
+
+ public Task WaitAsync()
+ {
+ lock (_waiters)
+ {
+ if (_isSignaled)
+ {
+ _isSignaled = false;
+ return CompletedTask;
+ }
+
+ var source = new TaskCompletionSource();
+ _waiters.Enqueue(source);
+ return source.Task;
+ }
+ }
+
+ public void Set()
+ {
+ TaskCompletionSource? sourceToRelease = null;
+
+ lock (_waiters)
+ {
+ if (_waiters.Count > 0)
+ {
+ sourceToRelease = _waiters.Dequeue();
+ }
+ else if (!_isSignaled)
+ {
+ _isSignaled = true;
+ }
+ }
+
+ sourceToRelease?.SetResult(true);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Branch.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Branch.cs
new file mode 100644
index 0000000000..94ce1b6d91
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Branch.cs
@@ -0,0 +1,16 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Idempotency")]
+public sealed class Branch : Identifiable
+{
+ [Attr]
+ public decimal LengthInMeters { get; set; }
+
+ [HasMany]
+ public IList Leaves { get; set; } = new List();
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupJob.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupJob.cs
new file mode 100644
index 0000000000..7bfeb8be1e
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupJob.cs
@@ -0,0 +1,87 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+internal sealed class IdempotencyCleanupJob
+{
+ private static readonly TimeSpan CacheExpirationTime = TimeSpan.FromDays(31);
+ private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1);
+
+ private readonly ISystemClock _systemClock;
+ private readonly IHostApplicationLifetime _hostApplicationLifetime;
+ private readonly IDbContextFactory _dbContextFactory;
+
+ public IdempotencyCleanupJob(ISystemClock systemClock, IHostApplicationLifetime hostApplicationLifetime,
+ IDbContextFactory dbContextFactory)
+ {
+ ArgumentGuard.NotNull(systemClock, nameof(systemClock));
+ ArgumentGuard.NotNull(hostApplicationLifetime, nameof(hostApplicationLifetime));
+ ArgumentGuard.NotNull(dbContextFactory, nameof(dbContextFactory));
+
+ _systemClock = systemClock;
+ _hostApplicationLifetime = hostApplicationLifetime;
+ _dbContextFactory = dbContextFactory;
+ }
+
+ ///
+ /// Schedule this method to run on a pooled background thread from Program.cs, using the code below. See also:
+ /// https://stackoverflow.com/questions/26921191/how-to-pass-longrunning-flag-specifically-to-task-run
+ ///
+ /// ();
+ ///
+ /// WebApplication app = builder.Build();
+ ///
+ /// var job = app.Services.GetRequiredService();
+ ///
+ /// _ = Task.Run(async () =>
+ /// {
+ /// await job.StartPeriodicPurgeOfExpiredItemsAsync();
+ /// });
+ ///
+ /// app.Run();
+ /// ]]>
+ ///
+ ///
+ [PublicAPI]
+ public async Task StartPeriodicPurgeOfExpiredItemsAsync()
+ {
+ await StartPeriodicPurgeOfExpiredItemsAsync(_hostApplicationLifetime.ApplicationStopping);
+ }
+
+ private async Task StartPeriodicPurgeOfExpiredItemsAsync(CancellationToken cancellationToken)
+ {
+ using var timer = new PeriodicTimer(CleanupInterval);
+
+ try
+ {
+ while (await timer.WaitForNextTickAsync(cancellationToken))
+ {
+ await RunIterationAsync(cancellationToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
+ public async Task RunOnceAsync(CancellationToken cancellationToken)
+ {
+ await RunIterationAsync(cancellationToken);
+ }
+
+ private async Task RunIterationAsync(CancellationToken cancellationToken)
+ {
+ DateTimeOffset threshold = _systemClock.UtcNow - CacheExpirationTime;
+
+ await using IdempotencyDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
+ List itemsToDelete = await dbContext.RequestCache.Where(item => item.CreatedAt < threshold).ToListAsync(cancellationToken);
+
+ dbContext.RemoveRange(itemsToDelete);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupTests.cs
new file mode 100644
index 0000000000..c6bc9e8cc0
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupTests.cs
@@ -0,0 +1,63 @@
+using FluentAssertions;
+using FluentAssertions.Extensions;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+public sealed class IdempotencyCleanupTests : IClassFixture, IdempotencyDbContext>>
+{
+ private readonly IntegrationTestContext, IdempotencyDbContext> _testContext;
+
+ public IdempotencyCleanupTests(IntegrationTestContext, IdempotencyDbContext> testContext)
+ {
+ _testContext = testContext;
+
+ _testContext.ConfigureServicesAfterStartup(services =>
+ {
+ services.AddDbContextFactory();
+
+ services.AddScoped();
+ services.AddSingleton();
+ });
+ }
+
+ [Fact]
+ public async Task Removes_expired_items()
+ {
+ // Arrange
+ var clock = (FrozenSystemClock)_testContext.Factory.Services.GetRequiredService();
+ clock.UtcNow = 26.March(2005).At(12, 13, 14, 15, 16).AsUtc();
+
+ var existingItems = new List
+ {
+ new("A", "", 1.January(1960).AsUtc()),
+ new("B", "", 1.January(2005).AsUtc()),
+ new("C", "", 1.January(2009).AsUtc())
+ };
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.RequestCache.AddRange(existingItems);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var job = _testContext.Factory.Services.GetRequiredService();
+
+ // Act
+ await job.RunOnceAsync(CancellationToken.None);
+
+ // Assert
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ List itemsInDatabase = await dbContext.RequestCache.ToListAsync();
+
+ itemsInDatabase.ShouldHaveCount(1);
+ itemsInDatabase[0].Id.Should().Be("C");
+ });
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyConcurrencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyConcurrencyTests.cs
new file mode 100644
index 0000000000..447c1aeefe
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyConcurrencyTests.cs
@@ -0,0 +1,120 @@
+using System.Net;
+using System.Net.Http.Headers;
+using FluentAssertions;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+public sealed class IdempotencyConcurrencyTests : IClassFixture, IdempotencyDbContext>>
+{
+ private readonly IntegrationTestContext, IdempotencyDbContext> _testContext;
+ private readonly IdempotencyFakers _fakers = new();
+
+ public IdempotencyConcurrencyTests(IntegrationTestContext, IdempotencyDbContext> testContext)
+ {
+ _testContext = testContext;
+
+ testContext.UseController();
+
+ testContext.ConfigureServicesAfterStartup(services =>
+ {
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddResourceDefinition();
+ });
+ }
+
+ [Fact]
+ public async Task Cannot_create_resource_concurrently_with_same_idempotency_key()
+ {
+ // Arrange
+ Branch existingBranch = _fakers.Branch.Generate();
+ string newColor = _fakers.Leaf.Generate().Color;
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Branches.Add(existingBranch);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var mediator = _testContext.Factory.Services.GetRequiredService();
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "leaves",
+ attributes = new
+ {
+ color = newColor
+ },
+ relationships = new
+ {
+ branch = new
+ {
+ data = new
+ {
+ type = "branches",
+ id = existingBranch.StringId
+ }
+ }
+ }
+ }
+ };
+
+ const string route = "/leaves";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders1 = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ headers.Add(LeafSignalingDefinition.WaitForResumeSignalHeaderName, "true");
+ };
+
+ Task<(HttpResponseMessage, Document)> request1 = _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders1);
+
+ try
+ {
+ await mediator.WaitForTransactionStartedAsync(TimeSpan.FromSeconds(15));
+ }
+ catch (TimeoutException)
+ {
+ // In case the first request never reaches the signaling point, the assertion below displays why it was unable to get there.
+
+ (HttpResponseMessage httpResponseMessage1, _) = await request1;
+ httpResponseMessage1.Should().HaveStatusCode(HttpStatusCode.Created);
+ }
+
+ Action setRequestHeaders2 = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse2, Document responseDocument2) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders2);
+
+ // Assert
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.Conflict);
+
+ responseDocument2.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument2.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().StartWith($"The request for the provided idempotency key '{idempotencyKey}' is currently being processed.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDbContext.cs
new file mode 100644
index 0000000000..9ea6c73baa
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDbContext.cs
@@ -0,0 +1,19 @@
+using JetBrains.Annotations;
+using Microsoft.EntityFrameworkCore;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+public sealed class IdempotencyDbContext : DbContext
+{
+ public DbSet Trees => Set();
+ public DbSet Branches => Set();
+ public DbSet Leaves => Set();
+
+ public DbSet RequestCache => Set();
+
+ public IdempotencyDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDisabledTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDisabledTests.cs
new file mode 100644
index 0000000000..400f78f8fc
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDisabledTests.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using System.Net.Http.Headers;
+using FluentAssertions;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Serialization.Objects;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+public sealed class IdempotencyDisabledTests : IClassFixture, IdempotencyDbContext>>
+{
+ private readonly IntegrationTestContext, IdempotencyDbContext> _testContext;
+ private readonly IdempotencyFakers _fakers = new();
+
+ public IdempotencyDisabledTests(IntegrationTestContext, IdempotencyDbContext> testContext)
+ {
+ _testContext = testContext;
+
+ testContext.UseController();
+ }
+
+ [Fact]
+ public async Task Cannot_create_resource_with_idempotency_key_when_disabled()
+ {
+ // Arrange
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ };
+
+ const string route = "/trees";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().Be("Idempotency is currently disabled.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyFakers.cs
new file mode 100644
index 0000000000..eac8ece645
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyFakers.cs
@@ -0,0 +1,29 @@
+using Bogus;
+using TestBuildingBlocks;
+
+// @formatter:wrap_chained_method_calls chop_always
+// @formatter:keep_existing_linebreaks true
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+internal sealed class IdempotencyFakers : FakerContainer
+{
+ private readonly Lazy> _lazyTreeFaker = new(() =>
+ new Faker()
+ .UseSeed(GetFakerSeed())
+ .RuleFor(tree => tree.HeightInMeters, faker => faker.Random.Decimal(0.1m, 100)));
+
+ private readonly Lazy> _lazyBranchFaker = new(() =>
+ new Faker()
+ .UseSeed(GetFakerSeed())
+ .RuleFor(branch => branch.LengthInMeters, faker => faker.Random.Decimal(0.1m, 20)));
+
+ private readonly Lazy> _lazyLeafFaker = new(() =>
+ new Faker()
+ .UseSeed(GetFakerSeed())
+ .RuleFor(leaf => leaf.Color, faker => faker.Commerce.Color()));
+
+ public Faker Tree => _lazyTreeFaker.Value;
+ public Faker Branch => _lazyBranchFaker.Value;
+ public Faker Leaf => _lazyLeafFaker.Value;
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyOperationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyOperationTests.cs
new file mode 100644
index 0000000000..a322563b07
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyOperationTests.cs
@@ -0,0 +1,148 @@
+using System.Net;
+using System.Net.Http.Headers;
+using FluentAssertions;
+using JsonApiDotNetCore.Middleware;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.DependencyInjection;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+public sealed class IdempotencyOperationTests : IClassFixture, IdempotencyDbContext>>
+{
+ private readonly IntegrationTestContext, IdempotencyDbContext> _testContext;
+ private readonly IdempotencyFakers _fakers = new();
+
+ public IdempotencyOperationTests(IntegrationTestContext, IdempotencyDbContext> testContext)
+ {
+ _testContext = testContext;
+
+ testContext.UseController();
+
+ testContext.ConfigureServicesAfterStartup(services =>
+ {
+ services.AddScoped();
+ services.AddScoped();
+ });
+ }
+
+ [Fact]
+ public async Task Returns_cached_response_for_operations_request()
+ {
+ // Arrange
+ Branch existingBranch = _fakers.Branch.Generate();
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Branches.Add(existingBranch);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody = new
+ {
+ atomic__operations = new object[]
+ {
+ new
+ {
+ op = "remove",
+ @ref = new
+ {
+ type = "branches",
+ id = existingBranch.StringId
+ }
+ },
+ new
+ {
+ op = "add",
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ }
+ }
+ };
+
+ const string route = "/operations";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.OK);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().Be(idempotencyKey.DoubleQuote());
+
+ httpResponse2.Content.Headers.ContentType.Should().Be(httpResponse1.Content.Headers.ContentType);
+ httpResponse2.Content.Headers.ContentLength.Should().Be(httpResponse1.Content.Headers.ContentLength);
+
+ responseDocument2.Should().Be(responseDocument1);
+ }
+
+ [Fact]
+ public async Task Returns_cached_response_for_failed_operations_request()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ atomic__operations = new object[]
+ {
+ new
+ {
+ op = "remove",
+ @ref = new
+ {
+ type = "branches",
+ id = Unknown.StringId.For()
+ }
+ }
+ }
+ };
+
+ const string route = "/operations";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.NotFound);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.NotFound);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().Be(idempotencyKey.DoubleQuote());
+
+ httpResponse2.Content.Headers.ContentType.Should().Be(httpResponse1.Content.Headers.ContentType);
+ httpResponse2.Content.Headers.ContentLength.Should().Be(httpResponse1.Content.Headers.ContentLength);
+
+ responseDocument2.Should().Be(responseDocument1);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyProvider.cs
new file mode 100644
index 0000000000..e2961a58da
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyProvider.cs
@@ -0,0 +1,110 @@
+using System.Net;
+using JsonApiDotNetCore;
+using JsonApiDotNetCore.AtomicOperations;
+using JsonApiDotNetCore.Errors;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+///
+public sealed class IdempotencyProvider : IIdempotencyProvider
+{
+ private readonly IdempotencyDbContext _dbContext;
+ private readonly ISystemClock _systemClock;
+ private readonly IOperationsTransactionFactory _transactionFactory;
+
+ public IdempotencyProvider(IdempotencyDbContext dbContext, ISystemClock systemClock, IOperationsTransactionFactory transactionFactory)
+ {
+ ArgumentGuard.NotNull(dbContext, nameof(dbContext));
+ ArgumentGuard.NotNull(systemClock, nameof(systemClock));
+ ArgumentGuard.NotNull(transactionFactory, nameof(transactionFactory));
+
+ _dbContext = dbContext;
+ _systemClock = systemClock;
+ _transactionFactory = transactionFactory;
+ }
+
+ ///
+ public bool IsSupported(HttpRequest request)
+ {
+ return request.Method == HttpMethod.Post.Method && !request.RouteValues.ContainsKey("relationshipName");
+ }
+
+ ///
+ public async Task GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken)
+ {
+ RequestCacheItem? cacheItem = await _dbContext.RequestCache.FirstOrDefaultAsync(item => item.Id == idempotencyKey, cancellationToken);
+
+ if (cacheItem == null)
+ {
+ return null;
+ }
+
+ if (cacheItem.ResponseStatusCode == null)
+ {
+ // Unlikely, but depending on the transaction isolation level, we may observe this uncommitted intermediate state.
+ throw CreateErrorForConcurrentRequest(idempotencyKey);
+ }
+
+ return new IdempotentResponse(cacheItem.RequestFingerprint, cacheItem.ResponseStatusCode.Value, cacheItem.ResponseLocationHeader,
+ cacheItem.ResponseContentTypeHeader, cacheItem.ResponseBody);
+ }
+
+ private static JsonApiException CreateErrorForConcurrentRequest(string idempotencyKey)
+ {
+ return new JsonApiException(new ErrorObject(HttpStatusCode.Conflict)
+ {
+ Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
+ Detail = $"The request for the provided idempotency key '{idempotencyKey}' is currently being processed.",
+ Source = new ErrorSource
+ {
+ Header = HeaderConstants.IdempotencyKey
+ }
+ });
+ }
+
+ ///
+ public async Task BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken)
+ {
+ try
+ {
+ IOperationsTransaction transaction = await _transactionFactory.BeginTransactionAsync(cancellationToken);
+
+ var cacheItem = new RequestCacheItem(idempotencyKey, requestFingerprint, _systemClock.UtcNow);
+ await _dbContext.RequestCache.AddAsync(cacheItem, cancellationToken);
+
+ await _dbContext.SaveChangesAsync(cancellationToken);
+
+ return transaction;
+ }
+ catch (DbUpdateException exception) when (IsUniqueConstraintViolation(exception))
+ {
+ throw CreateErrorForConcurrentRequest(idempotencyKey);
+ }
+ }
+
+ private static bool IsUniqueConstraintViolation(DbUpdateException exception)
+ {
+ return exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
+ }
+
+ public async Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction,
+ CancellationToken cancellationToken)
+ {
+ RequestCacheItem cacheItem = await _dbContext.RequestCache.FirstAsync(item => item.Id == idempotencyKey, cancellationToken);
+
+ cacheItem.ResponseStatusCode = response.ResponseStatusCode;
+ cacheItem.ResponseLocationHeader = response.ResponseLocationHeader;
+ cacheItem.ResponseContentTypeHeader = response.ResponseContentTypeHeader;
+ cacheItem.ResponseBody = response.ResponseBody;
+
+ await _dbContext.SaveChangesAsync(cancellationToken);
+
+ await transaction.CommitAsync(cancellationToken);
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyTests.cs
new file mode 100644
index 0000000000..80c7526fd0
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyTests.cs
@@ -0,0 +1,748 @@
+using System.Net;
+using System.Net.Http.Headers;
+using FluentAssertions;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Serialization.Objects;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+public sealed class IdempotencyTests : IClassFixture, IdempotencyDbContext>>
+{
+ private readonly IntegrationTestContext, IdempotencyDbContext> _testContext;
+ private readonly IdempotencyFakers _fakers = new();
+
+ public IdempotencyTests(IntegrationTestContext, IdempotencyDbContext> testContext)
+ {
+ _testContext = testContext;
+
+ testContext.UseController();
+
+ testContext.ConfigureServicesAfterStartup(services =>
+ {
+ services.AddScoped();
+ services.AddScoped();
+ });
+ }
+
+ [Fact]
+ public async Task Returns_cached_response_for_create_resource_request()
+ {
+ // Arrange
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ };
+
+ const string route = "/trees";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, Document responseDocument1) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ long newTreeId = long.Parse(responseDocument1.Data.SingleValue!.Id.ShouldNotBeNull());
+ Tree existingTree = await dbContext.Trees.FirstWithIdAsync(newTreeId);
+
+ existingTree.HeightInMeters *= 2;
+ await dbContext.SaveChangesAsync();
+ });
+
+ // Act
+ (HttpResponseMessage httpResponse2, Document responseDocument2) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.Created);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.Created);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().Be(idempotencyKey.DoubleQuote());
+
+ httpResponse2.Headers.Location.Should().Be(httpResponse1.Headers.Location);
+
+ httpResponse2.Content.Headers.ContentType.Should().Be(httpResponse1.Content.Headers.ContentType);
+ httpResponse2.Content.Headers.ContentLength.Should().Be(httpResponse1.Content.Headers.ContentLength);
+
+ responseDocument1.Data.SingleValue.ShouldNotBeNull();
+ object? height1 = responseDocument1.Data.SingleValue.Attributes.ShouldContainKey("heightInMeters");
+
+ responseDocument2.Data.SingleValue.ShouldNotBeNull();
+ responseDocument2.Data.SingleValue.Id.Should().Be(responseDocument1.Data.SingleValue.Id);
+ responseDocument2.Data.SingleValue.Attributes.ShouldContainKey("heightInMeters").With(value => value.Should().Be(height1));
+ }
+
+ [Fact]
+ public async Task Returns_cached_response_for_failed_create_resource_request()
+ {
+ // Arrange
+ var requestBody = new
+ {
+ data = new object()
+ };
+
+ const string route = "/trees";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().Be(idempotencyKey.DoubleQuote());
+
+ httpResponse2.Content.Headers.ContentType.Should().Be(httpResponse1.Content.Headers.ContentType);
+ httpResponse2.Content.Headers.ContentLength.Should().Be(httpResponse1.Content.Headers.ContentLength);
+
+ responseDocument2.Should().Be(responseDocument1);
+ }
+
+ [Fact]
+ public async Task Cannot_create_resource_without_idempotency_key()
+ {
+ // Arrange
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ };
+
+ const string route = "/trees";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody);
+
+ // Assert
+ httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be($"Missing '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().StartWith("An idempotency key is a unique value generated by the client,");
+ error.Source.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Cannot_create_resource_with_empty_idempotency_key()
+ {
+ // Arrange
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ };
+
+ const string route = "/trees";
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, string.Empty.DoubleQuote());
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().Be("Expected non-empty value surrounded by double quotes.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
+ }
+
+ [Fact]
+ public async Task Cannot_create_resource_with_unquoted_idempotency_key()
+ {
+ // Arrange
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ };
+
+ const string route = "/trees";
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, Guid.NewGuid().ToString());
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) =
+ await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().Be("Expected non-empty value surrounded by double quotes.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
+ }
+
+ [Fact]
+ public async Task Cannot_reuse_idempotency_key_for_different_request_url()
+ {
+ // Arrange
+ decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters
+ }
+ }
+ };
+
+ const string route1 = "/trees";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, _) = await _testContext.ExecutePostAsync(route1, requestBody, setRequestHeaders: setRequestHeaders);
+
+ const string route2 = "/branches";
+
+ // Act
+ (HttpResponseMessage httpResponse2, Document responseDocument2) =
+ await _testContext.ExecutePostAsync(route2, requestBody, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.Created);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
+
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.Location.Should().BeNull();
+ httpResponse2.Content.Headers.ContentType.ShouldNotBeNull();
+ httpResponse2.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
+
+ responseDocument2.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument2.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().Be($"The provided idempotency key '{idempotencyKey}' is in use for another request.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
+ }
+
+ [Fact]
+ public async Task Cannot_reuse_idempotency_key_for_different_request_body()
+ {
+ // Arrange
+ decimal newHeightInMeters1 = _fakers.Tree.Generate().HeightInMeters;
+ decimal newHeightInMeters2 = _fakers.Tree.Generate().HeightInMeters;
+
+ var requestBody1 = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters1
+ }
+ }
+ };
+
+ const string route = "/trees";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, _) = await _testContext.ExecutePostAsync(route, requestBody1, setRequestHeaders: setRequestHeaders);
+
+ var requestBody2 = new
+ {
+ data = new
+ {
+ type = "trees",
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters2
+ }
+ }
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse2, Document responseDocument2) =
+ await _testContext.ExecutePostAsync(route, requestBody2, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.Created);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
+
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.Location.Should().BeNull();
+ httpResponse2.Content.Headers.ContentType.ShouldNotBeNull();
+ httpResponse2.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
+
+ responseDocument2.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument2.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
+ error.Detail.Should().Be($"The provided idempotency key '{idempotencyKey}' is in use for another request.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
+ }
+
+ [Fact]
+ public async Task Ignores_idempotency_key_on_GET_request()
+ {
+ // Arrange
+ Tree tree = _fakers.Tree.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Trees.Add(tree);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = "/trees/" + tree.StringId;
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, Document responseDocument1) = await _testContext.ExecuteGetAsync(route, setRequestHeaders);
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ Tree existingTree = await dbContext.Trees.FirstWithIdAsync(tree.Id);
+
+ existingTree.HeightInMeters *= 2;
+ await dbContext.SaveChangesAsync();
+ });
+
+ // Act
+ (HttpResponseMessage httpResponse2, Document responseDocument2) = await _testContext.ExecuteGetAsync(route, setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.OK);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.OK);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+
+ responseDocument1.Data.SingleValue.ShouldNotBeNull();
+ object? height1 = responseDocument1.Data.SingleValue.Attributes.ShouldContainKey("heightInMeters");
+
+ responseDocument2.Data.SingleValue.ShouldNotBeNull();
+ responseDocument2.Data.SingleValue.Id.Should().Be(responseDocument1.Data.SingleValue.Id);
+ responseDocument2.Data.SingleValue.Attributes.ShouldContainKey("heightInMeters").With(value => value.Should().NotBe(height1));
+ }
+
+ [Fact]
+ public async Task Ignores_idempotency_key_on_PATCH_resource_request()
+ {
+ // Arrange
+ Tree existingTree = _fakers.Tree.Generate();
+
+ decimal newHeightInMeters1 = _fakers.Tree.Generate().HeightInMeters;
+ decimal newHeightInMeters2 = _fakers.Tree.Generate().HeightInMeters;
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Trees.Add(existingTree);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody1 = new
+ {
+ data = new
+ {
+ type = "trees",
+ id = existingTree.StringId,
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters1
+ }
+ }
+ };
+
+ string route = "/trees/" + existingTree.StringId;
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecutePatchAsync(route, requestBody1, setRequestHeaders: setRequestHeaders);
+
+ var requestBody2 = new
+ {
+ data = new
+ {
+ type = "trees",
+ id = existingTree.StringId,
+ attributes = new
+ {
+ heightInMeters = newHeightInMeters2
+ }
+ }
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecutePatchAsync(route, requestBody2, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.NoContent);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.NoContent);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+
+ responseDocument1.Should().BeEmpty();
+ responseDocument2.Should().BeEmpty();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ Tree treeInDatabase = await dbContext.Trees.FirstWithIdAsync(existingTree.Id);
+
+ treeInDatabase.HeightInMeters.Should().BeApproximately(requestBody2.data.attributes.heightInMeters);
+ });
+ }
+
+ [Fact]
+ public async Task Ignores_idempotency_key_on_DELETE_resource_request()
+ {
+ // Arrange
+ Tree existingTree = _fakers.Tree.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Trees.Add(existingTree);
+ await dbContext.SaveChangesAsync();
+ });
+
+ string route = "/trees/" + existingTree.StringId;
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecuteDeleteAsync(route, setRequestHeaders: setRequestHeaders);
+
+ // Act
+ (HttpResponseMessage httpResponse2, Document responseDocument2) =
+ await _testContext.ExecuteDeleteAsync(route, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.NoContent);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.NotFound);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+
+ responseDocument1.Should().BeEmpty();
+ responseDocument2.Errors.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task Ignores_idempotency_key_on_PATCH_relationship_request()
+ {
+ // Arrange
+ Tree existingTree = _fakers.Tree.Generate();
+ existingTree.Branches = _fakers.Branch.Generate(2);
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Trees.Add(existingTree);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody1 = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "branches",
+ id = existingTree.Branches[0].StringId
+ }
+ }
+ };
+
+ string route = $"/trees/{existingTree.StringId}/relationships/branches";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecutePatchAsync(route, requestBody1, setRequestHeaders: setRequestHeaders);
+
+ var requestBody2 = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "branches",
+ id = existingTree.Branches[1].StringId
+ }
+ }
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecutePatchAsync(route, requestBody2, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.NoContent);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.NoContent);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+
+ responseDocument1.Should().BeEmpty();
+ responseDocument2.Should().BeEmpty();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ Tree treeInDatabase = await dbContext.Trees.Include(tree => tree.Branches).FirstWithIdAsync(existingTree.Id);
+
+ treeInDatabase.Branches.Should().HaveCount(1);
+ treeInDatabase.Branches[0].Id.Should().Be(existingTree.Branches[1].Id);
+ });
+ }
+
+ [Fact]
+ public async Task Ignores_idempotency_key_on_POST_relationship_request()
+ {
+ // Arrange
+ Tree existingTree = _fakers.Tree.Generate();
+ List existingBranches = _fakers.Branch.Generate(2);
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Trees.Add(existingTree);
+ dbContext.Branches.AddRange(existingBranches);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody1 = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "branches",
+ id = existingBranches[0].StringId
+ }
+ }
+ };
+
+ string route = $"/trees/{existingTree.StringId}/relationships/branches";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecutePostAsync(route, requestBody1, setRequestHeaders: setRequestHeaders);
+
+ var requestBody2 = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "branches",
+ id = existingBranches[1].StringId
+ }
+ }
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecutePostAsync(route, requestBody2, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.NoContent);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.NoContent);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+
+ responseDocument1.Should().BeEmpty();
+ responseDocument2.Should().BeEmpty();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ Tree treeInDatabase = await dbContext.Trees.Include(tree => tree.Branches).FirstWithIdAsync(existingTree.Id);
+
+ treeInDatabase.Branches.Should().HaveCount(2);
+ });
+ }
+
+ [Fact]
+ public async Task Ignores_idempotency_key_on_DELETE_relationship_request()
+ {
+ // Arrange
+ Tree existingTree = _fakers.Tree.Generate();
+ existingTree.Branches = _fakers.Branch.Generate(2);
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ dbContext.Trees.Add(existingTree);
+ await dbContext.SaveChangesAsync();
+ });
+
+ var requestBody1 = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "branches",
+ id = existingTree.Branches[0].StringId
+ }
+ }
+ };
+
+ string route = $"/trees/{existingTree.StringId}/relationships/branches";
+
+ string idempotencyKey = Guid.NewGuid().ToString();
+
+ Action setRequestHeaders = headers =>
+ {
+ headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
+ };
+
+ (HttpResponseMessage httpResponse1, string responseDocument1) =
+ await _testContext.ExecuteDeleteAsync(route, requestBody1, setRequestHeaders: setRequestHeaders);
+
+ var requestBody2 = new
+ {
+ data = new[]
+ {
+ new
+ {
+ type = "branches",
+ id = existingTree.Branches[1].StringId
+ }
+ }
+ };
+
+ // Act
+ (HttpResponseMessage httpResponse2, string responseDocument2) =
+ await _testContext.ExecuteDeleteAsync(route, requestBody2, setRequestHeaders: setRequestHeaders);
+
+ // Assert
+ httpResponse1.Should().HaveStatusCode(HttpStatusCode.NoContent);
+ httpResponse2.Should().HaveStatusCode(HttpStatusCode.NoContent);
+
+ httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+ httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
+
+ responseDocument1.Should().BeEmpty();
+ responseDocument2.Should().BeEmpty();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ Tree treeInDatabase = await dbContext.Trees.Include(tree => tree.Branches).FirstWithIdAsync(existingTree.Id);
+
+ treeInDatabase.Branches.Should().BeEmpty();
+ });
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Leaf.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Leaf.cs
new file mode 100644
index 0000000000..47d9bbdcbe
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Leaf.cs
@@ -0,0 +1,16 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Idempotency")]
+public sealed class Leaf : Identifiable
+{
+ [Attr]
+ public string Color { get; set; } = null!;
+
+ [HasOne]
+ public Branch Branch { get; set; } = null!;
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/LeafSignalingDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/LeafSignalingDefinition.cs
new file mode 100644
index 0000000000..0672567c4f
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/LeafSignalingDefinition.cs
@@ -0,0 +1,31 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Resources;
+using Microsoft.AspNetCore.Http;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+public sealed class LeafSignalingDefinition : JsonApiResourceDefinition
+{
+ internal const string WaitForResumeSignalHeaderName = "X-WaitForResumeSignal";
+
+ private readonly TestExecutionMediator _mediator;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public LeafSignalingDefinition(IResourceGraph resourceGraph, TestExecutionMediator mediator, IHttpContextAccessor httpContextAccessor)
+ : base(resourceGraph)
+ {
+ _mediator = mediator;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public override async Task OnPrepareWriteAsync(Leaf resource, WriteOperationKind writeOperation, CancellationToken cancellationToken)
+ {
+ if (_httpContextAccessor.HttpContext!.Request.Headers.ContainsKey(WaitForResumeSignalHeaderName))
+ {
+ await _mediator.NotifyTransactionStartedAsync(TimeSpan.FromSeconds(5), cancellationToken);
+ }
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/OperationsController.cs
new file mode 100644
index 0000000000..23db5838b0
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/OperationsController.cs
@@ -0,0 +1,17 @@
+using JsonApiDotNetCore.AtomicOperations;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Controllers;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Resources;
+using Microsoft.Extensions.Logging;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+public sealed class OperationsController : JsonApiOperationsController
+{
+ public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
+ IJsonApiRequest request, ITargetedFields targetedFields)
+ : base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
+ {
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/RequestCacheItem.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/RequestCacheItem.cs
new file mode 100644
index 0000000000..15feaf7460
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/RequestCacheItem.cs
@@ -0,0 +1,26 @@
+using System.Net;
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+[NoResource]
+public sealed class RequestCacheItem
+{
+ public string Id { get; set; }
+ public string RequestFingerprint { get; set; }
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public HttpStatusCode? ResponseStatusCode { get; set; }
+ public string? ResponseLocationHeader { get; set; }
+ public string? ResponseContentTypeHeader { get; set; }
+ public string? ResponseBody { get; set; }
+
+ public RequestCacheItem(string id, string requestFingerprint, DateTimeOffset createdAt)
+ {
+ Id = id;
+ CreatedAt = createdAt;
+ RequestFingerprint = requestFingerprint;
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/TestExecutionMediator.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/TestExecutionMediator.cs
new file mode 100644
index 0000000000..ce9c619dd1
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/TestExecutionMediator.cs
@@ -0,0 +1,54 @@
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+///
+/// Helps to coordinate between API server and test client, with the goal of producing a concurrency conflict.
+///
+public sealed class TestExecutionMediator
+{
+ private readonly AsyncAutoResetEvent _serverNotifyEvent = new();
+
+ ///
+ /// Used by the server to notify the test client that the request being processed has entered a transaction. After notification, this method blocks for
+ /// the duration of to allow the test client to start a second request (and block when entering its own transaction), while
+ /// the current request is still running.
+ ///
+ internal async Task NotifyTransactionStartedAsync(TimeSpan sleepTime, CancellationToken cancellationToken)
+ {
+ _serverNotifyEvent.Set();
+
+ await Task.Delay(sleepTime, cancellationToken);
+ }
+
+ ///
+ /// Used by the test client to wait until the server request being processed has entered a transaction.
+ ///
+ internal async Task WaitForTransactionStartedAsync(TimeSpan timeout)
+ {
+ Task task = _serverNotifyEvent.WaitAsync();
+ await TimeoutAfterAsync(task, timeout);
+ }
+
+ private static async Task TimeoutAfterAsync(Task task, TimeSpan timeout)
+ {
+ // Based on https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md#using-a-timeout
+
+ if (timeout != TimeSpan.Zero)
+ {
+ using var timerCancellation = new CancellationTokenSource();
+ Task timeoutTask = Task.Delay(timeout, timerCancellation.Token);
+
+ Task firstCompletedTask = await Task.WhenAny(task, timeoutTask);
+
+ if (firstCompletedTask == timeoutTask)
+ {
+ throw new TimeoutException();
+ }
+
+ // The timeout did not elapse, so cancel the timer to recover system resources.
+ timerCancellation.Cancel();
+ }
+
+ // Re-throw any exceptions from the completed task.
+ await task;
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Tree.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Tree.cs
new file mode 100644
index 0000000000..18856ceb84
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Tree.cs
@@ -0,0 +1,16 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Members)]
+[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Idempotency")]
+public sealed class Tree : Identifiable
+{
+ [Attr]
+ public decimal HeightInMeters { get; set; }
+
+ [HasMany]
+ public IList Branches { get; set; } = new List();
+}
diff --git a/test/TestBuildingBlocks/HttpResponseHeadersExtensions.cs b/test/TestBuildingBlocks/HttpResponseHeadersExtensions.cs
new file mode 100644
index 0000000000..6bca6883eb
--- /dev/null
+++ b/test/TestBuildingBlocks/HttpResponseHeadersExtensions.cs
@@ -0,0 +1,21 @@
+using System.Net.Http.Headers;
+using Microsoft.Extensions.Primitives;
+
+namespace TestBuildingBlocks;
+
+public static class HttpResponseHeadersExtensions
+{
+ ///
+ /// Returns the value of the specified HTTP response header, or null when not found. If the header occurs multiple times, their values are
+ /// collapsed into a comma-separated string, without changing any surrounding double quotes.
+ ///
+ public static string? GetValue(this HttpResponseHeaders responseHeaders, string name)
+ {
+ if (responseHeaders.TryGetValues(name, out IEnumerable? values))
+ {
+ return new StringValues(values.ToArray());
+ }
+
+ return null;
+ }
+}
diff --git a/test/TestBuildingBlocks/StringExtensions.cs b/test/TestBuildingBlocks/StringExtensions.cs
new file mode 100644
index 0000000000..450bb050b4
--- /dev/null
+++ b/test/TestBuildingBlocks/StringExtensions.cs
@@ -0,0 +1,13 @@
+using JsonApiDotNetCore;
+
+namespace TestBuildingBlocks;
+
+public static class StringExtensions
+{
+ public static string DoubleQuote(this string source)
+ {
+ ArgumentGuard.NotNull(source, nameof(source));
+
+ return '\"' + source + '\"';
+ }
+}