From 08f814186a5e6b50a295e3a5db80f1790f6ee553 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 21 Dec 2021 10:10:12 +0100 Subject: [PATCH] Explores support for idempotency --- JsonApiDotNetCore.sln.DotSettings | 1 + .../EntityFrameworkCoreTransaction.cs | 18 +- .../EntityFrameworkCoreTransactionFactory.cs | 11 +- .../ApplicationBuilderExtensions.cs | 1 + .../JsonApiApplicationBuilder.cs | 1 + .../JsonApiDotNetCore.csproj | 1 + .../Middleware/HeaderConstants.cs | 1 + .../Middleware/IIdempotencyProvider.cs | 30 + .../Middleware/IdempotencyMiddleware.cs | 249 ++++++ .../Middleware/IdempotentResponse.cs | 25 + .../Middleware/NoIdempotencyProvider.cs | 32 + .../Idempotency/AsyncAutoResetEvent.cs | 48 ++ .../IntegrationTests/Idempotency/Branch.cs | 16 + .../Idempotency/IdempotencyCleanupJob.cs | 87 ++ .../Idempotency/IdempotencyCleanupTests.cs | 63 ++ .../IdempotencyConcurrencyTests.cs | 120 +++ .../Idempotency/IdempotencyDbContext.cs | 19 + .../Idempotency/IdempotencyDisabledTests.cs | 66 ++ .../Idempotency/IdempotencyFakers.cs | 29 + .../Idempotency/IdempotencyOperationTests.cs | 148 ++++ .../Idempotency/IdempotencyProvider.cs | 110 +++ .../Idempotency/IdempotencyTests.cs | 748 ++++++++++++++++++ .../IntegrationTests/Idempotency/Leaf.cs | 16 + .../Idempotency/LeafSignalingDefinition.cs | 31 + .../Idempotency/OperationsController.cs | 17 + .../Idempotency/RequestCacheItem.cs | 26 + .../Idempotency/TestExecutionMediator.cs | 54 ++ .../IntegrationTests/Idempotency/Tree.cs | 16 + .../HttpResponseHeadersExtensions.cs | 21 + test/TestBuildingBlocks/StringExtensions.cs | 13 + 30 files changed, 2011 insertions(+), 7 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/IIdempotencyProvider.cs create mode 100644 src/JsonApiDotNetCore/Middleware/IdempotencyMiddleware.cs create mode 100644 src/JsonApiDotNetCore/Middleware/IdempotentResponse.cs create mode 100644 src/JsonApiDotNetCore/Middleware/NoIdempotencyProvider.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/AsyncAutoResetEvent.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Branch.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupJob.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyCleanupTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyConcurrencyTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyDisabledTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyFakers.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyOperationTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyProvider.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Leaf.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/LeafSignalingDefinition.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/OperationsController.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/RequestCacheItem.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/TestExecutionMediator.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/Tree.cs create mode 100644 test/TestBuildingBlocks/HttpResponseHeadersExtensions.cs create mode 100644 test/TestBuildingBlocks/StringExtensions.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 1ffdf4a909..40c663e900 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -648,6 +648,7 @@ $left$ = $right$; True True True + True True True True diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs index f59f86162d..2e1abc1645 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); ArgumentGuard.NotNull(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 8ef44cb627..7147e7ab5c 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 8ed4e42a42..4bfdaa3751 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 82e0ff52e1..2cbf923ddd 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 e401db38fa..8bf19a09f6 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..1e51044d8f --- /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.ShouldHaveStatusCode(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.ShouldHaveStatusCode(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..a183c81680 --- /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.ShouldHaveStatusCode(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..73072c1f87 --- /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.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.NotFound); + httpResponse2.ShouldHaveStatusCode(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..c5aa808c06 --- /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.ShouldHaveStatusCode(HttpStatusCode.Created); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(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.ShouldHaveStatusCode(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.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.Created); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.Created); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); + httpResponse2.ShouldHaveStatusCode(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); + httpResponse2.ShouldHaveStatusCode(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 + '\"'; + } +}