Skip to content

Commit 08f8141

Browse files
Bart Koelmanbkoelman
Bart Koelman
authored andcommittedOct 5, 2022
Explores support for idempotency
1 parent ea3ab72 commit 08f8141

30 files changed

+2011
-7
lines changed
 

‎JsonApiDotNetCore.sln.DotSettings

+1
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,7 @@ $left$ = $right$;</s:String>
648648
<s:Boolean x:Key="/Default/UserDictionary/Words/=appsettings/@EntryIndexedValue">True</s:Boolean>
649649
<s:Boolean x:Key="/Default/UserDictionary/Words/=Assignee/@EntryIndexedValue">True</s:Boolean>
650650
<s:Boolean x:Key="/Default/UserDictionary/Words/=Contoso/@EntryIndexedValue">True</s:Boolean>
651+
<s:Boolean x:Key="/Default/UserDictionary/Words/=idempotency/@EntryIndexedValue">True</s:Boolean>
651652
<s:Boolean x:Key="/Default/UserDictionary/Words/=Injectables/@EntryIndexedValue">True</s:Boolean>
652653
<s:Boolean x:Key="/Default/UserDictionary/Words/=jsonapi/@EntryIndexedValue">True</s:Boolean>
653654
<s:Boolean x:Key="/Default/UserDictionary/Words/=linebreaks/@EntryIndexedValue">True</s:Boolean>

‎src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs

+13-5
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@ public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction
1313
{
1414
private readonly IDbContextTransaction _transaction;
1515
private readonly DbContext _dbContext;
16+
private readonly bool _ownsTransaction;
1617

1718
/// <inheritdoc />
1819
public string TransactionId => _transaction.TransactionId.ToString();
1920

20-
public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext)
21+
public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext, bool ownsTransaction)
2122
{
2223
ArgumentGuard.NotNull(transaction);
2324
ArgumentGuard.NotNull(dbContext);
2425

2526
_transaction = transaction;
2627
_dbContext = dbContext;
28+
_ownsTransaction = ownsTransaction;
2729
}
2830

2931
/// <summary>
@@ -44,14 +46,20 @@ public Task AfterProcessOperationAsync(CancellationToken cancellationToken)
4446
}
4547

4648
/// <inheritdoc />
47-
public Task CommitAsync(CancellationToken cancellationToken)
49+
public async Task CommitAsync(CancellationToken cancellationToken)
4850
{
49-
return _transaction.CommitAsync(cancellationToken);
51+
if (_ownsTransaction)
52+
{
53+
await _transaction.CommitAsync(cancellationToken);
54+
}
5055
}
5156

5257
/// <inheritdoc />
53-
public ValueTask DisposeAsync()
58+
public async ValueTask DisposeAsync()
5459
{
55-
return _transaction.DisposeAsync();
60+
if (_ownsTransaction)
61+
{
62+
await _transaction.DisposeAsync();
63+
}
5664
}
5765
}

‎src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs

+9-2
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ public async Task<IOperationsTransaction> BeginTransactionAsync(CancellationToke
2727
{
2828
DbContext dbContext = _dbContextResolver.GetContext();
2929

30-
IDbContextTransaction transaction = _options.TransactionIsolationLevel != null
30+
IDbContextTransaction? existingTransaction = dbContext.Database.CurrentTransaction;
31+
32+
if (existingTransaction != null)
33+
{
34+
return new EntityFrameworkCoreTransaction(existingTransaction, dbContext, false);
35+
}
36+
37+
IDbContextTransaction newTransaction = _options.TransactionIsolationLevel != null
3138
? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken)
3239
: await dbContext.Database.BeginTransactionAsync(cancellationToken);
3340

34-
return new EntityFrameworkCoreTransaction(transaction, dbContext);
41+
return new EntityFrameworkCoreTransaction(newTransaction, dbContext, true);
3542
}
3643
}

‎src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public static void UseJsonApi(this IApplicationBuilder builder)
4444
options.Conventions.Insert(0, routingConvention);
4545
};
4646

47+
builder.UseMiddleware<IdempotencyMiddleware>();
4748
builder.UseMiddleware<JsonApiMiddleware>();
4849
}
4950
}

‎src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ private void AddMiddlewareLayer()
178178
_services.AddScoped<IJsonApiWriter, JsonApiWriter>();
179179
_services.AddScoped<IJsonApiReader, JsonApiReader>();
180180
_services.AddScoped<ITargetedFields, TargetedFields>();
181+
_services.AddScoped<IIdempotencyProvider, NoIdempotencyProvider>();
181182
}
182183

183184
private void AddResourceLayer()

‎src/JsonApiDotNetCore/JsonApiDotNetCore.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
<PackageReference Include="Humanizer.Core" Version="$(HumanizerVersion)" />
4242
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
4343
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.0" />
44+
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
4445
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
4546
<PackageReference Include="SauceControl.InheritDoc" Version="1.3.0" PrivateAssets="All" />
4647
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

‎src/JsonApiDotNetCore/Middleware/HeaderConstants.cs

+1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ public static class HeaderConstants
99
{
1010
public const string MediaType = "application/vnd.api+json";
1111
public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\"";
12+
public const string IdempotencyKey = "Idempotency-Key";
1213
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.AtomicOperations;
3+
using Microsoft.AspNetCore.Http;
4+
5+
namespace JsonApiDotNetCore.Middleware;
6+
7+
[PublicAPI]
8+
public interface IIdempotencyProvider
9+
{
10+
/// <summary>
11+
/// Indicates whether the current request supports idempotency.
12+
/// </summary>
13+
bool IsSupported(HttpRequest request);
14+
15+
/// <summary>
16+
/// Looks for a matching response in the idempotency cache for the specified idempotency key.
17+
/// </summary>
18+
Task<IdempotentResponse?> GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken);
19+
20+
/// <summary>
21+
/// Creates a new cache entry inside a transaction, so that concurrent requests with the same idempotency key will block or fail while the transaction
22+
/// hasn't been committed.
23+
/// </summary>
24+
Task<IOperationsTransaction> BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken);
25+
26+
/// <summary>
27+
/// Saves the produced response in the cache and commits its transaction.
28+
/// </summary>
29+
Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction, CancellationToken cancellationToken);
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
using System.Net;
2+
using System.Text;
3+
using System.Text.Json;
4+
using JetBrains.Annotations;
5+
using JsonApiDotNetCore.AtomicOperations;
6+
using JsonApiDotNetCore.Configuration;
7+
using JsonApiDotNetCore.Errors;
8+
using JsonApiDotNetCore.Serialization.Objects;
9+
using JsonApiDotNetCore.Serialization.Response;
10+
using Microsoft.AspNetCore.Http;
11+
using Microsoft.AspNetCore.Http.Extensions;
12+
using Microsoft.AspNetCore.WebUtilities;
13+
using Microsoft.IO;
14+
using Microsoft.Net.Http.Headers;
15+
using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute;
16+
17+
namespace JsonApiDotNetCore.Middleware;
18+
19+
// IMPORTANT: In your Program.cs, make sure app.UseDeveloperExceptionPage() is called BEFORE this!
20+
21+
public sealed class IdempotencyMiddleware
22+
{
23+
private static readonly RecyclableMemoryStreamManager MemoryStreamManager = new();
24+
25+
private readonly IJsonApiOptions _options;
26+
private readonly IFingerprintGenerator _fingerprintGenerator;
27+
private readonly RequestDelegate _next;
28+
29+
public IdempotencyMiddleware(IJsonApiOptions options, IFingerprintGenerator fingerprintGenerator, RequestDelegate next)
30+
{
31+
ArgumentGuard.NotNull(options, nameof(options));
32+
ArgumentGuard.NotNull(fingerprintGenerator, nameof(fingerprintGenerator));
33+
34+
_options = options;
35+
_fingerprintGenerator = fingerprintGenerator;
36+
_next = next;
37+
}
38+
39+
public async Task InvokeAsync(HttpContext httpContext, IIdempotencyProvider idempotencyProvider)
40+
{
41+
try
42+
{
43+
await InnerInvokeAsync(httpContext, idempotencyProvider);
44+
}
45+
catch (JsonApiException exception)
46+
{
47+
await FlushResponseAsync(httpContext.Response, _options.SerializerWriteOptions, exception.Errors.Single());
48+
}
49+
}
50+
51+
public async Task InnerInvokeAsync(HttpContext httpContext, IIdempotencyProvider idempotencyProvider)
52+
{
53+
string? idempotencyKey = GetIdempotencyKey(httpContext.Request.Headers);
54+
55+
if (idempotencyKey != null && idempotencyProvider is NoIdempotencyProvider)
56+
{
57+
throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
58+
{
59+
Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
60+
Detail = "Idempotency is currently disabled.",
61+
Source = new ErrorSource
62+
{
63+
Header = HeaderConstants.IdempotencyKey
64+
}
65+
});
66+
}
67+
68+
if (!idempotencyProvider.IsSupported(httpContext.Request))
69+
{
70+
await _next(httpContext);
71+
return;
72+
}
73+
74+
AssertIdempotencyKeyIsValid(idempotencyKey);
75+
76+
await BufferRequestBodyAsync(httpContext);
77+
78+
string requestFingerprint = await GetRequestFingerprintAsync(httpContext);
79+
IdempotentResponse? idempotentResponse = await idempotencyProvider.GetResponseFromCacheAsync(idempotencyKey, httpContext.RequestAborted);
80+
81+
if (idempotentResponse != null)
82+
{
83+
if (idempotentResponse.RequestFingerprint != requestFingerprint)
84+
{
85+
throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity)
86+
{
87+
Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
88+
Detail = $"The provided idempotency key '{idempotencyKey}' is in use for another request.",
89+
Source = new ErrorSource
90+
{
91+
Header = HeaderConstants.IdempotencyKey
92+
}
93+
});
94+
}
95+
96+
httpContext.Response.StatusCode = (int)idempotentResponse.ResponseStatusCode;
97+
httpContext.Response.Headers[HeaderConstants.IdempotencyKey] = $"\"{idempotencyKey}\"";
98+
httpContext.Response.Headers[HeaderNames.Location] = idempotentResponse.ResponseLocationHeader;
99+
100+
if (idempotentResponse.ResponseContentTypeHeader != null)
101+
{
102+
// Workaround for invalid nullability annotation in HttpResponse.ContentType
103+
// Fixed after ASP.NET 6 release, see https://github.com/dotnet/aspnetcore/commit/8bb128185b58a26065d0f29e695a2410cf0a3c68#diff-bbfd771a8ef013a9921bff36df0d69f424910e079945992f1dccb24de54ca717
104+
httpContext.Response.ContentType = idempotentResponse.ResponseContentTypeHeader;
105+
}
106+
107+
await using TextWriter writer = new HttpResponseStreamWriter(httpContext.Response.Body, Encoding.UTF8);
108+
await writer.WriteAsync(idempotentResponse.ResponseBody);
109+
await writer.FlushAsync();
110+
111+
return;
112+
}
113+
114+
await using IOperationsTransaction transaction =
115+
await idempotencyProvider.BeginRequestAsync(idempotencyKey, requestFingerprint, httpContext.RequestAborted);
116+
117+
string responseBody = await CaptureResponseBodyAsync(httpContext, _next);
118+
119+
idempotentResponse = new IdempotentResponse(requestFingerprint, (HttpStatusCode)httpContext.Response.StatusCode,
120+
httpContext.Response.Headers[HeaderNames.Location], httpContext.Response.ContentType, responseBody);
121+
122+
await idempotencyProvider.CompleteRequestAsync(idempotencyKey, idempotentResponse, transaction, httpContext.RequestAborted);
123+
}
124+
125+
private static string? GetIdempotencyKey(IHeaderDictionary requestHeaders)
126+
{
127+
if (!requestHeaders.ContainsKey(HeaderConstants.IdempotencyKey))
128+
{
129+
return null;
130+
}
131+
132+
string headerValue = requestHeaders[HeaderConstants.IdempotencyKey];
133+
134+
if (headerValue.Length >= 2 && headerValue[0] == '\"' && headerValue[^1] == '\"')
135+
{
136+
return headerValue[1..^1];
137+
}
138+
139+
return string.Empty;
140+
}
141+
142+
[AssertionMethod]
143+
private static void AssertIdempotencyKeyIsValid([SysNotNull] string? idempotencyKey)
144+
{
145+
if (idempotencyKey == null)
146+
{
147+
throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
148+
{
149+
Title = $"Missing '{HeaderConstants.IdempotencyKey}' HTTP header.",
150+
Detail = "An idempotency key is a unique value generated by the client, which the server uses to recognize subsequent retries " +
151+
"of the same request. This should be a random string with enough entropy to avoid collisions."
152+
});
153+
}
154+
155+
if (idempotencyKey == string.Empty)
156+
{
157+
throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
158+
{
159+
Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
160+
Detail = "Expected non-empty value surrounded by double quotes.",
161+
Source = new ErrorSource
162+
{
163+
Header = HeaderConstants.IdempotencyKey
164+
}
165+
});
166+
}
167+
}
168+
169+
/// <summary>
170+
/// Enables to read the HTTP request stream multiple times, without risking GC Gen2/LOH promotion.
171+
/// </summary>
172+
private static async Task BufferRequestBodyAsync(HttpContext httpContext)
173+
{
174+
// Above this threshold, EnableBuffering() switches to a temporary file on disk.
175+
// Source: Microsoft.AspNetCore.Http.BufferingHelper.DefaultBufferThreshold
176+
const int enableBufferingThreshold = 1024 * 30;
177+
178+
if (httpContext.Request.ContentLength > enableBufferingThreshold)
179+
{
180+
httpContext.Request.EnableBuffering(enableBufferingThreshold);
181+
}
182+
else
183+
{
184+
MemoryStream memoryRequestBodyStream = MemoryStreamManager.GetStream();
185+
await httpContext.Request.Body.CopyToAsync(memoryRequestBodyStream, httpContext.RequestAborted);
186+
memoryRequestBodyStream.Seek(0, SeekOrigin.Begin);
187+
188+
httpContext.Request.Body = memoryRequestBodyStream;
189+
httpContext.Response.RegisterForDispose(memoryRequestBodyStream);
190+
}
191+
}
192+
193+
private async Task<string> GetRequestFingerprintAsync(HttpContext httpContext)
194+
{
195+
using var reader = new StreamReader(httpContext.Request.Body, leaveOpen: true);
196+
string requestBody = await reader.ReadToEndAsync();
197+
httpContext.Request.Body.Seek(0, SeekOrigin.Begin);
198+
199+
return _fingerprintGenerator.Generate(ArrayFactory.Create(httpContext.Request.GetEncodedUrl(), requestBody));
200+
}
201+
202+
/// <summary>
203+
/// Executes the specified action and returns what it wrote to the HTTP response stream.
204+
/// </summary>
205+
private static async Task<string> CaptureResponseBodyAsync(HttpContext httpContext, RequestDelegate nextAction)
206+
{
207+
// Loosely based on https://elanderson.net/2019/12/log-requests-and-responses-in-asp-net-core-3/.
208+
209+
Stream previousResponseBodyStream = httpContext.Response.Body;
210+
211+
try
212+
{
213+
await using MemoryStream memoryResponseBodyStream = MemoryStreamManager.GetStream();
214+
httpContext.Response.Body = memoryResponseBodyStream;
215+
216+
try
217+
{
218+
await nextAction(httpContext);
219+
}
220+
finally
221+
{
222+
memoryResponseBodyStream.Seek(0, SeekOrigin.Begin);
223+
await memoryResponseBodyStream.CopyToAsync(previousResponseBodyStream);
224+
}
225+
226+
memoryResponseBodyStream.Seek(0, SeekOrigin.Begin);
227+
using var streamReader = new StreamReader(memoryResponseBodyStream, leaveOpen: true);
228+
return await streamReader.ReadToEndAsync();
229+
}
230+
finally
231+
{
232+
httpContext.Response.Body = previousResponseBodyStream;
233+
}
234+
}
235+
236+
private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error)
237+
{
238+
httpResponse.ContentType = HeaderConstants.MediaType;
239+
httpResponse.StatusCode = (int)error.StatusCode;
240+
241+
var errorDocument = new Document
242+
{
243+
Errors = error.AsList()
244+
};
245+
246+
await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions);
247+
await httpResponse.Body.FlushAsync();
248+
}
249+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Net;
2+
using JetBrains.Annotations;
3+
4+
namespace JsonApiDotNetCore.Middleware;
5+
6+
[PublicAPI]
7+
public sealed class IdempotentResponse
8+
{
9+
public string RequestFingerprint { get; }
10+
11+
public HttpStatusCode ResponseStatusCode { get; }
12+
public string? ResponseLocationHeader { get; }
13+
public string? ResponseContentTypeHeader { get; }
14+
public string? ResponseBody { get; }
15+
16+
public IdempotentResponse(string requestFingerprint, HttpStatusCode responseStatusCode, string? responseLocationHeader, string? responseContentTypeHeader,
17+
string? responseBody)
18+
{
19+
RequestFingerprint = requestFingerprint;
20+
ResponseStatusCode = responseStatusCode;
21+
ResponseLocationHeader = responseLocationHeader;
22+
ResponseContentTypeHeader = responseContentTypeHeader;
23+
ResponseBody = responseBody;
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using JsonApiDotNetCore.AtomicOperations;
2+
using Microsoft.AspNetCore.Http;
3+
4+
namespace JsonApiDotNetCore.Middleware;
5+
6+
internal sealed class NoIdempotencyProvider : IIdempotencyProvider
7+
{
8+
/// <inheritdoc />
9+
public bool IsSupported(HttpRequest request)
10+
{
11+
return false;
12+
}
13+
14+
/// <inheritdoc />
15+
public Task<IdempotentResponse?> GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken)
16+
{
17+
throw new NotImplementedException();
18+
}
19+
20+
/// <inheritdoc />
21+
public Task<IOperationsTransaction> BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken)
22+
{
23+
throw new NotImplementedException();
24+
}
25+
26+
/// <inheritdoc />
27+
public Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction,
28+
CancellationToken cancellationToken)
29+
{
30+
throw new NotImplementedException();
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using JetBrains.Annotations;
2+
3+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
4+
5+
// Based on https://devblogs.microsoft.com/pfxteam/building-async-coordination-primitives-part-2-asyncautoresetevent/
6+
[PublicAPI]
7+
public sealed class AsyncAutoResetEvent
8+
{
9+
private static readonly Task CompletedTask = Task.FromResult(true);
10+
11+
private readonly Queue<TaskCompletionSource<bool>> _waiters = new();
12+
private bool _isSignaled;
13+
14+
public Task WaitAsync()
15+
{
16+
lock (_waiters)
17+
{
18+
if (_isSignaled)
19+
{
20+
_isSignaled = false;
21+
return CompletedTask;
22+
}
23+
24+
var source = new TaskCompletionSource<bool>();
25+
_waiters.Enqueue(source);
26+
return source.Task;
27+
}
28+
}
29+
30+
public void Set()
31+
{
32+
TaskCompletionSource<bool>? sourceToRelease = null;
33+
34+
lock (_waiters)
35+
{
36+
if (_waiters.Count > 0)
37+
{
38+
sourceToRelease = _waiters.Dequeue();
39+
}
40+
else if (!_isSignaled)
41+
{
42+
_isSignaled = true;
43+
}
44+
}
45+
46+
sourceToRelease?.SetResult(true);
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Idempotency")]
9+
public sealed class Branch : Identifiable<long>
10+
{
11+
[Attr]
12+
public decimal LengthInMeters { get; set; }
13+
14+
[HasMany]
15+
public IList<Leaf> Leaves { get; set; } = new List<Leaf>();
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.Hosting;
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
8+
9+
internal sealed class IdempotencyCleanupJob
10+
{
11+
private static readonly TimeSpan CacheExpirationTime = TimeSpan.FromDays(31);
12+
private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1);
13+
14+
private readonly ISystemClock _systemClock;
15+
private readonly IHostApplicationLifetime _hostApplicationLifetime;
16+
private readonly IDbContextFactory<IdempotencyDbContext> _dbContextFactory;
17+
18+
public IdempotencyCleanupJob(ISystemClock systemClock, IHostApplicationLifetime hostApplicationLifetime,
19+
IDbContextFactory<IdempotencyDbContext> dbContextFactory)
20+
{
21+
ArgumentGuard.NotNull(systemClock, nameof(systemClock));
22+
ArgumentGuard.NotNull(hostApplicationLifetime, nameof(hostApplicationLifetime));
23+
ArgumentGuard.NotNull(dbContextFactory, nameof(dbContextFactory));
24+
25+
_systemClock = systemClock;
26+
_hostApplicationLifetime = hostApplicationLifetime;
27+
_dbContextFactory = dbContextFactory;
28+
}
29+
30+
/// <summary>
31+
/// Schedule this method to run on a pooled background thread from Program.cs, using the code below. See also:
32+
/// https://stackoverflow.com/questions/26921191/how-to-pass-longrunning-flag-specifically-to-task-run
33+
/// <example>
34+
/// <code><![CDATA[
35+
/// builder.Services.AddSingleton<IdempotencyCleanupJob>();
36+
///
37+
/// WebApplication app = builder.Build();
38+
///
39+
/// var job = app.Services.GetRequiredService<IdempotencyCleanupJob>();
40+
///
41+
/// _ = Task.Run(async () =>
42+
/// {
43+
/// await job.StartPeriodicPurgeOfExpiredItemsAsync();
44+
/// });
45+
///
46+
/// app.Run();
47+
/// ]]></code>
48+
/// </example>
49+
/// </summary>
50+
[PublicAPI]
51+
public async Task StartPeriodicPurgeOfExpiredItemsAsync()
52+
{
53+
await StartPeriodicPurgeOfExpiredItemsAsync(_hostApplicationLifetime.ApplicationStopping);
54+
}
55+
56+
private async Task StartPeriodicPurgeOfExpiredItemsAsync(CancellationToken cancellationToken)
57+
{
58+
using var timer = new PeriodicTimer(CleanupInterval);
59+
60+
try
61+
{
62+
while (await timer.WaitForNextTickAsync(cancellationToken))
63+
{
64+
await RunIterationAsync(cancellationToken);
65+
}
66+
}
67+
catch (OperationCanceledException)
68+
{
69+
}
70+
}
71+
72+
public async Task RunOnceAsync(CancellationToken cancellationToken)
73+
{
74+
await RunIterationAsync(cancellationToken);
75+
}
76+
77+
private async Task RunIterationAsync(CancellationToken cancellationToken)
78+
{
79+
DateTimeOffset threshold = _systemClock.UtcNow - CacheExpirationTime;
80+
81+
await using IdempotencyDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
82+
List<RequestCacheItem> itemsToDelete = await dbContext.RequestCache.Where(item => item.CreatedAt < threshold).ToListAsync(cancellationToken);
83+
84+
dbContext.RemoveRange(itemsToDelete);
85+
await dbContext.SaveChangesAsync(cancellationToken);
86+
}
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using FluentAssertions;
2+
using FluentAssertions.Extensions;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using TestBuildingBlocks;
7+
using Xunit;
8+
9+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
10+
11+
public sealed class IdempotencyCleanupTests : IClassFixture<IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext>>
12+
{
13+
private readonly IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> _testContext;
14+
15+
public IdempotencyCleanupTests(IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> testContext)
16+
{
17+
_testContext = testContext;
18+
19+
_testContext.ConfigureServicesAfterStartup(services =>
20+
{
21+
services.AddDbContextFactory<IdempotencyDbContext>();
22+
23+
services.AddScoped<ISystemClock, FrozenSystemClock>();
24+
services.AddSingleton<IdempotencyCleanupJob>();
25+
});
26+
}
27+
28+
[Fact]
29+
public async Task Removes_expired_items()
30+
{
31+
// Arrange
32+
var clock = (FrozenSystemClock)_testContext.Factory.Services.GetRequiredService<ISystemClock>();
33+
clock.UtcNow = 26.March(2005).At(12, 13, 14, 15, 16).AsUtc();
34+
35+
var existingItems = new List<RequestCacheItem>
36+
{
37+
new("A", "", 1.January(1960).AsUtc()),
38+
new("B", "", 1.January(2005).AsUtc()),
39+
new("C", "", 1.January(2009).AsUtc())
40+
};
41+
42+
await _testContext.RunOnDatabaseAsync(async dbContext =>
43+
{
44+
await dbContext.ClearTableAsync<RequestCacheItem>();
45+
dbContext.RequestCache.AddRange(existingItems);
46+
await dbContext.SaveChangesAsync();
47+
});
48+
49+
var job = _testContext.Factory.Services.GetRequiredService<IdempotencyCleanupJob>();
50+
51+
// Act
52+
await job.RunOnceAsync(CancellationToken.None);
53+
54+
// Assert
55+
await _testContext.RunOnDatabaseAsync(async dbContext =>
56+
{
57+
List<RequestCacheItem> itemsInDatabase = await dbContext.RequestCache.ToListAsync();
58+
59+
itemsInDatabase.ShouldHaveCount(1);
60+
itemsInDatabase[0].Id.Should().Be("C");
61+
});
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using FluentAssertions;
4+
using JsonApiDotNetCore.Configuration;
5+
using JsonApiDotNetCore.Middleware;
6+
using JsonApiDotNetCore.Serialization.Objects;
7+
using Microsoft.AspNetCore.Authentication;
8+
using Microsoft.AspNetCore.Http;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using TestBuildingBlocks;
11+
using Xunit;
12+
13+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
14+
15+
public sealed class IdempotencyConcurrencyTests : IClassFixture<IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext>>
16+
{
17+
private readonly IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> _testContext;
18+
private readonly IdempotencyFakers _fakers = new();
19+
20+
public IdempotencyConcurrencyTests(IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> testContext)
21+
{
22+
_testContext = testContext;
23+
24+
testContext.UseController<LeavesController>();
25+
26+
testContext.ConfigureServicesAfterStartup(services =>
27+
{
28+
services.AddScoped<IIdempotencyProvider, IdempotencyProvider>();
29+
services.AddScoped<ISystemClock, FrozenSystemClock>();
30+
31+
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
32+
services.AddSingleton<TestExecutionMediator>();
33+
services.AddResourceDefinition<LeafSignalingDefinition>();
34+
});
35+
}
36+
37+
[Fact]
38+
public async Task Cannot_create_resource_concurrently_with_same_idempotency_key()
39+
{
40+
// Arrange
41+
Branch existingBranch = _fakers.Branch.Generate();
42+
string newColor = _fakers.Leaf.Generate().Color;
43+
44+
await _testContext.RunOnDatabaseAsync(async dbContext =>
45+
{
46+
dbContext.Branches.Add(existingBranch);
47+
await dbContext.SaveChangesAsync();
48+
});
49+
50+
var mediator = _testContext.Factory.Services.GetRequiredService<TestExecutionMediator>();
51+
52+
var requestBody = new
53+
{
54+
data = new
55+
{
56+
type = "leaves",
57+
attributes = new
58+
{
59+
color = newColor
60+
},
61+
relationships = new
62+
{
63+
branch = new
64+
{
65+
data = new
66+
{
67+
type = "branches",
68+
id = existingBranch.StringId
69+
}
70+
}
71+
}
72+
}
73+
};
74+
75+
const string route = "/leaves";
76+
77+
string idempotencyKey = Guid.NewGuid().ToString();
78+
79+
Action<HttpRequestHeaders> setRequestHeaders1 = headers =>
80+
{
81+
headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
82+
headers.Add(LeafSignalingDefinition.WaitForResumeSignalHeaderName, "true");
83+
};
84+
85+
Task<(HttpResponseMessage, Document)> request1 = _testContext.ExecutePostAsync<Document>(route, requestBody, setRequestHeaders: setRequestHeaders1);
86+
87+
try
88+
{
89+
await mediator.WaitForTransactionStartedAsync(TimeSpan.FromSeconds(15));
90+
}
91+
catch (TimeoutException)
92+
{
93+
// In case the first request never reaches the signaling point, the assertion below displays why it was unable to get there.
94+
95+
(HttpResponseMessage httpResponseMessage1, _) = await request1;
96+
httpResponseMessage1.ShouldHaveStatusCode(HttpStatusCode.Created);
97+
}
98+
99+
Action<HttpRequestHeaders> setRequestHeaders2 = headers =>
100+
{
101+
headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
102+
};
103+
104+
// Act
105+
(HttpResponseMessage httpResponse2, Document responseDocument2) =
106+
await _testContext.ExecutePostAsync<Document>(route, requestBody, setRequestHeaders: setRequestHeaders2);
107+
108+
// Assert
109+
httpResponse2.ShouldHaveStatusCode(HttpStatusCode.Conflict);
110+
111+
responseDocument2.Errors.ShouldHaveCount(1);
112+
113+
ErrorObject error = responseDocument2.Errors[0];
114+
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
115+
error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
116+
error.Detail.Should().StartWith($"The request for the provided idempotency key '{idempotencyKey}' is currently being processed.");
117+
error.Source.ShouldNotBeNull();
118+
error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
119+
}
120+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
5+
6+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
7+
public sealed class IdempotencyDbContext : DbContext
8+
{
9+
public DbSet<Tree> Trees => Set<Tree>();
10+
public DbSet<Branch> Branches => Set<Branch>();
11+
public DbSet<Leaf> Leaves => Set<Leaf>();
12+
13+
public DbSet<RequestCacheItem> RequestCache => Set<RequestCacheItem>();
14+
15+
public IdempotencyDbContext(DbContextOptions<IdempotencyDbContext> options)
16+
: base(options)
17+
{
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using FluentAssertions;
4+
using JsonApiDotNetCore.Middleware;
5+
using JsonApiDotNetCore.Serialization.Objects;
6+
using TestBuildingBlocks;
7+
using Xunit;
8+
9+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
10+
11+
public sealed class IdempotencyDisabledTests : IClassFixture<IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext>>
12+
{
13+
private readonly IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> _testContext;
14+
private readonly IdempotencyFakers _fakers = new();
15+
16+
public IdempotencyDisabledTests(IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> testContext)
17+
{
18+
_testContext = testContext;
19+
20+
testContext.UseController<TreesController>();
21+
}
22+
23+
[Fact]
24+
public async Task Cannot_create_resource_with_idempotency_key_when_disabled()
25+
{
26+
// Arrange
27+
decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
28+
29+
var requestBody = new
30+
{
31+
data = new
32+
{
33+
type = "trees",
34+
attributes = new
35+
{
36+
heightInMeters = newHeightInMeters
37+
}
38+
}
39+
};
40+
41+
const string route = "/trees";
42+
43+
string idempotencyKey = Guid.NewGuid().ToString();
44+
45+
Action<HttpRequestHeaders> setRequestHeaders = headers =>
46+
{
47+
headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
48+
};
49+
50+
// Act
51+
(HttpResponseMessage httpResponse, Document responseDocument) =
52+
await _testContext.ExecutePostAsync<Document>(route, requestBody, setRequestHeaders: setRequestHeaders);
53+
54+
// Assert
55+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
56+
57+
responseDocument.Errors.ShouldHaveCount(1);
58+
59+
ErrorObject error = responseDocument.Errors[0];
60+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
61+
error.Title.Should().Be($"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.");
62+
error.Detail.Should().Be("Idempotency is currently disabled.");
63+
error.Source.ShouldNotBeNull();
64+
error.Source.Header.Should().Be(HeaderConstants.IdempotencyKey);
65+
}
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Bogus;
2+
using TestBuildingBlocks;
3+
4+
// @formatter:wrap_chained_method_calls chop_always
5+
// @formatter:keep_existing_linebreaks true
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
8+
9+
internal sealed class IdempotencyFakers : FakerContainer
10+
{
11+
private readonly Lazy<Faker<Tree>> _lazyTreeFaker = new(() =>
12+
new Faker<Tree>()
13+
.UseSeed(GetFakerSeed())
14+
.RuleFor(tree => tree.HeightInMeters, faker => faker.Random.Decimal(0.1m, 100)));
15+
16+
private readonly Lazy<Faker<Branch>> _lazyBranchFaker = new(() =>
17+
new Faker<Branch>()
18+
.UseSeed(GetFakerSeed())
19+
.RuleFor(branch => branch.LengthInMeters, faker => faker.Random.Decimal(0.1m, 20)));
20+
21+
private readonly Lazy<Faker<Leaf>> _lazyLeafFaker = new(() =>
22+
new Faker<Leaf>()
23+
.UseSeed(GetFakerSeed())
24+
.RuleFor(leaf => leaf.Color, faker => faker.Commerce.Color()));
25+
26+
public Faker<Tree> Tree => _lazyTreeFaker.Value;
27+
public Faker<Branch> Branch => _lazyBranchFaker.Value;
28+
public Faker<Leaf> Leaf => _lazyLeafFaker.Value;
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using FluentAssertions;
4+
using JsonApiDotNetCore.Middleware;
5+
using Microsoft.AspNetCore.Authentication;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using TestBuildingBlocks;
8+
using Xunit;
9+
10+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
11+
12+
public sealed class IdempotencyOperationTests : IClassFixture<IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext>>
13+
{
14+
private readonly IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> _testContext;
15+
private readonly IdempotencyFakers _fakers = new();
16+
17+
public IdempotencyOperationTests(IntegrationTestContext<TestableStartup<IdempotencyDbContext>, IdempotencyDbContext> testContext)
18+
{
19+
_testContext = testContext;
20+
21+
testContext.UseController<OperationsController>();
22+
23+
testContext.ConfigureServicesAfterStartup(services =>
24+
{
25+
services.AddScoped<IIdempotencyProvider, IdempotencyProvider>();
26+
services.AddScoped<ISystemClock, FrozenSystemClock>();
27+
});
28+
}
29+
30+
[Fact]
31+
public async Task Returns_cached_response_for_operations_request()
32+
{
33+
// Arrange
34+
Branch existingBranch = _fakers.Branch.Generate();
35+
decimal newHeightInMeters = _fakers.Tree.Generate().HeightInMeters;
36+
37+
await _testContext.RunOnDatabaseAsync(async dbContext =>
38+
{
39+
dbContext.Branches.Add(existingBranch);
40+
await dbContext.SaveChangesAsync();
41+
});
42+
43+
var requestBody = new
44+
{
45+
atomic__operations = new object[]
46+
{
47+
new
48+
{
49+
op = "remove",
50+
@ref = new
51+
{
52+
type = "branches",
53+
id = existingBranch.StringId
54+
}
55+
},
56+
new
57+
{
58+
op = "add",
59+
data = new
60+
{
61+
type = "trees",
62+
attributes = new
63+
{
64+
heightInMeters = newHeightInMeters
65+
}
66+
}
67+
}
68+
}
69+
};
70+
71+
const string route = "/operations";
72+
73+
string idempotencyKey = Guid.NewGuid().ToString();
74+
75+
Action<HttpRequestHeaders> setRequestHeaders = headers =>
76+
{
77+
headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
78+
};
79+
80+
(HttpResponseMessage httpResponse1, string responseDocument1) =
81+
await _testContext.ExecutePostAtomicAsync<string>(route, requestBody, setRequestHeaders: setRequestHeaders);
82+
83+
// Act
84+
(HttpResponseMessage httpResponse2, string responseDocument2) =
85+
await _testContext.ExecutePostAtomicAsync<string>(route, requestBody, setRequestHeaders: setRequestHeaders);
86+
87+
// Assert
88+
httpResponse1.ShouldHaveStatusCode(HttpStatusCode.OK);
89+
httpResponse2.ShouldHaveStatusCode(HttpStatusCode.OK);
90+
91+
httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
92+
httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().Be(idempotencyKey.DoubleQuote());
93+
94+
httpResponse2.Content.Headers.ContentType.Should().Be(httpResponse1.Content.Headers.ContentType);
95+
httpResponse2.Content.Headers.ContentLength.Should().Be(httpResponse1.Content.Headers.ContentLength);
96+
97+
responseDocument2.Should().Be(responseDocument1);
98+
}
99+
100+
[Fact]
101+
public async Task Returns_cached_response_for_failed_operations_request()
102+
{
103+
// Arrange
104+
var requestBody = new
105+
{
106+
atomic__operations = new object[]
107+
{
108+
new
109+
{
110+
op = "remove",
111+
@ref = new
112+
{
113+
type = "branches",
114+
id = Unknown.StringId.For<Branch, long>()
115+
}
116+
}
117+
}
118+
};
119+
120+
const string route = "/operations";
121+
122+
string idempotencyKey = Guid.NewGuid().ToString();
123+
124+
Action<HttpRequestHeaders> setRequestHeaders = headers =>
125+
{
126+
headers.Add(HeaderConstants.IdempotencyKey, idempotencyKey.DoubleQuote());
127+
};
128+
129+
(HttpResponseMessage httpResponse1, string responseDocument1) =
130+
await _testContext.ExecutePostAtomicAsync<string>(route, requestBody, setRequestHeaders: setRequestHeaders);
131+
132+
// Act
133+
(HttpResponseMessage httpResponse2, string responseDocument2) =
134+
await _testContext.ExecutePostAtomicAsync<string>(route, requestBody, setRequestHeaders: setRequestHeaders);
135+
136+
// Assert
137+
httpResponse1.ShouldHaveStatusCode(HttpStatusCode.NotFound);
138+
httpResponse2.ShouldHaveStatusCode(HttpStatusCode.NotFound);
139+
140+
httpResponse1.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().BeNull();
141+
httpResponse2.Headers.GetValue(HeaderConstants.IdempotencyKey).Should().Be(idempotencyKey.DoubleQuote());
142+
143+
httpResponse2.Content.Headers.ContentType.Should().Be(httpResponse1.Content.Headers.ContentType);
144+
httpResponse2.Content.Headers.ContentLength.Should().Be(httpResponse1.Content.Headers.ContentLength);
145+
146+
responseDocument2.Should().Be(responseDocument1);
147+
}
148+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
using System.Net;
2+
using JsonApiDotNetCore;
3+
using JsonApiDotNetCore.AtomicOperations;
4+
using JsonApiDotNetCore.Errors;
5+
using JsonApiDotNetCore.Middleware;
6+
using JsonApiDotNetCore.Serialization.Objects;
7+
using Microsoft.AspNetCore.Authentication;
8+
using Microsoft.AspNetCore.Http;
9+
using Microsoft.EntityFrameworkCore;
10+
using Npgsql;
11+
12+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
13+
14+
/// <inheritdoc />
15+
public sealed class IdempotencyProvider : IIdempotencyProvider
16+
{
17+
private readonly IdempotencyDbContext _dbContext;
18+
private readonly ISystemClock _systemClock;
19+
private readonly IOperationsTransactionFactory _transactionFactory;
20+
21+
public IdempotencyProvider(IdempotencyDbContext dbContext, ISystemClock systemClock, IOperationsTransactionFactory transactionFactory)
22+
{
23+
ArgumentGuard.NotNull(dbContext, nameof(dbContext));
24+
ArgumentGuard.NotNull(systemClock, nameof(systemClock));
25+
ArgumentGuard.NotNull(transactionFactory, nameof(transactionFactory));
26+
27+
_dbContext = dbContext;
28+
_systemClock = systemClock;
29+
_transactionFactory = transactionFactory;
30+
}
31+
32+
/// <inheritdoc />
33+
public bool IsSupported(HttpRequest request)
34+
{
35+
return request.Method == HttpMethod.Post.Method && !request.RouteValues.ContainsKey("relationshipName");
36+
}
37+
38+
/// <inheritdoc />
39+
public async Task<IdempotentResponse?> GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken)
40+
{
41+
RequestCacheItem? cacheItem = await _dbContext.RequestCache.FirstOrDefaultAsync(item => item.Id == idempotencyKey, cancellationToken);
42+
43+
if (cacheItem == null)
44+
{
45+
return null;
46+
}
47+
48+
if (cacheItem.ResponseStatusCode == null)
49+
{
50+
// Unlikely, but depending on the transaction isolation level, we may observe this uncommitted intermediate state.
51+
throw CreateErrorForConcurrentRequest(idempotencyKey);
52+
}
53+
54+
return new IdempotentResponse(cacheItem.RequestFingerprint, cacheItem.ResponseStatusCode.Value, cacheItem.ResponseLocationHeader,
55+
cacheItem.ResponseContentTypeHeader, cacheItem.ResponseBody);
56+
}
57+
58+
private static JsonApiException CreateErrorForConcurrentRequest(string idempotencyKey)
59+
{
60+
return new JsonApiException(new ErrorObject(HttpStatusCode.Conflict)
61+
{
62+
Title = $"Invalid '{HeaderConstants.IdempotencyKey}' HTTP header.",
63+
Detail = $"The request for the provided idempotency key '{idempotencyKey}' is currently being processed.",
64+
Source = new ErrorSource
65+
{
66+
Header = HeaderConstants.IdempotencyKey
67+
}
68+
});
69+
}
70+
71+
/// <inheritdoc />
72+
public async Task<IOperationsTransaction> BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken)
73+
{
74+
try
75+
{
76+
IOperationsTransaction transaction = await _transactionFactory.BeginTransactionAsync(cancellationToken);
77+
78+
var cacheItem = new RequestCacheItem(idempotencyKey, requestFingerprint, _systemClock.UtcNow);
79+
await _dbContext.RequestCache.AddAsync(cacheItem, cancellationToken);
80+
81+
await _dbContext.SaveChangesAsync(cancellationToken);
82+
83+
return transaction;
84+
}
85+
catch (DbUpdateException exception) when (IsUniqueConstraintViolation(exception))
86+
{
87+
throw CreateErrorForConcurrentRequest(idempotencyKey);
88+
}
89+
}
90+
91+
private static bool IsUniqueConstraintViolation(DbUpdateException exception)
92+
{
93+
return exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
94+
}
95+
96+
public async Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction,
97+
CancellationToken cancellationToken)
98+
{
99+
RequestCacheItem cacheItem = await _dbContext.RequestCache.FirstAsync(item => item.Id == idempotencyKey, cancellationToken);
100+
101+
cacheItem.ResponseStatusCode = response.ResponseStatusCode;
102+
cacheItem.ResponseLocationHeader = response.ResponseLocationHeader;
103+
cacheItem.ResponseContentTypeHeader = response.ResponseContentTypeHeader;
104+
cacheItem.ResponseBody = response.ResponseBody;
105+
106+
await _dbContext.SaveChangesAsync(cancellationToken);
107+
108+
await transaction.CommitAsync(cancellationToken);
109+
}
110+
}

‎test/JsonApiDotNetCoreTests/IntegrationTests/Idempotency/IdempotencyTests.cs

+748
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Idempotency")]
9+
public sealed class Leaf : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Color { get; set; } = null!;
13+
14+
[HasOne]
15+
public Branch Branch { get; set; } = null!;
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
4+
using JsonApiDotNetCore.Resources;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
8+
9+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
10+
public sealed class LeafSignalingDefinition : JsonApiResourceDefinition<Leaf, long>
11+
{
12+
internal const string WaitForResumeSignalHeaderName = "X-WaitForResumeSignal";
13+
14+
private readonly TestExecutionMediator _mediator;
15+
private readonly IHttpContextAccessor _httpContextAccessor;
16+
17+
public LeafSignalingDefinition(IResourceGraph resourceGraph, TestExecutionMediator mediator, IHttpContextAccessor httpContextAccessor)
18+
: base(resourceGraph)
19+
{
20+
_mediator = mediator;
21+
_httpContextAccessor = httpContextAccessor;
22+
}
23+
24+
public override async Task OnPrepareWriteAsync(Leaf resource, WriteOperationKind writeOperation, CancellationToken cancellationToken)
25+
{
26+
if (_httpContextAccessor.HttpContext!.Request.Headers.ContainsKey(WaitForResumeSignalHeaderName))
27+
{
28+
await _mediator.NotifyTransactionStartedAsync(TimeSpan.FromSeconds(5), cancellationToken);
29+
}
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using JsonApiDotNetCore.AtomicOperations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Middleware;
5+
using JsonApiDotNetCore.Resources;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
9+
10+
public sealed class OperationsController : JsonApiOperationsController
11+
{
12+
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
13+
IJsonApiRequest request, ITargetedFields targetedFields)
14+
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
15+
{
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System.Net;
2+
using JetBrains.Annotations;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[NoResource]
9+
public sealed class RequestCacheItem
10+
{
11+
public string Id { get; set; }
12+
public string RequestFingerprint { get; set; }
13+
public DateTimeOffset CreatedAt { get; set; }
14+
15+
public HttpStatusCode? ResponseStatusCode { get; set; }
16+
public string? ResponseLocationHeader { get; set; }
17+
public string? ResponseContentTypeHeader { get; set; }
18+
public string? ResponseBody { get; set; }
19+
20+
public RequestCacheItem(string id, string requestFingerprint, DateTimeOffset createdAt)
21+
{
22+
Id = id;
23+
CreatedAt = createdAt;
24+
RequestFingerprint = requestFingerprint;
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
2+
3+
/// <summary>
4+
/// Helps to coordinate between API server and test client, with the goal of producing a concurrency conflict.
5+
/// </summary>
6+
public sealed class TestExecutionMediator
7+
{
8+
private readonly AsyncAutoResetEvent _serverNotifyEvent = new();
9+
10+
/// <summary>
11+
/// Used by the server to notify the test client that the request being processed has entered a transaction. After notification, this method blocks for
12+
/// the duration of <paramref name="sleepTime" /> to allow the test client to start a second request (and block when entering its own transaction), while
13+
/// the current request is still running.
14+
/// </summary>
15+
internal async Task NotifyTransactionStartedAsync(TimeSpan sleepTime, CancellationToken cancellationToken)
16+
{
17+
_serverNotifyEvent.Set();
18+
19+
await Task.Delay(sleepTime, cancellationToken);
20+
}
21+
22+
/// <summary>
23+
/// Used by the test client to wait until the server request being processed has entered a transaction.
24+
/// </summary>
25+
internal async Task WaitForTransactionStartedAsync(TimeSpan timeout)
26+
{
27+
Task task = _serverNotifyEvent.WaitAsync();
28+
await TimeoutAfterAsync(task, timeout);
29+
}
30+
31+
private static async Task TimeoutAfterAsync(Task task, TimeSpan timeout)
32+
{
33+
// Based on https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md#using-a-timeout
34+
35+
if (timeout != TimeSpan.Zero)
36+
{
37+
using var timerCancellation = new CancellationTokenSource();
38+
Task timeoutTask = Task.Delay(timeout, timerCancellation.Token);
39+
40+
Task firstCompletedTask = await Task.WhenAny(task, timeoutTask);
41+
42+
if (firstCompletedTask == timeoutTask)
43+
{
44+
throw new TimeoutException();
45+
}
46+
47+
// The timeout did not elapse, so cancel the timer to recover system resources.
48+
timerCancellation.Cancel();
49+
}
50+
51+
// Re-throw any exceptions from the completed task.
52+
await task;
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Idempotency;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Idempotency")]
9+
public sealed class Tree : Identifiable<long>
10+
{
11+
[Attr]
12+
public decimal HeightInMeters { get; set; }
13+
14+
[HasMany]
15+
public IList<Branch> Branches { get; set; } = new List<Branch>();
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.Net.Http.Headers;
2+
using Microsoft.Extensions.Primitives;
3+
4+
namespace TestBuildingBlocks;
5+
6+
public static class HttpResponseHeadersExtensions
7+
{
8+
/// <summary>
9+
/// Returns the value of the specified HTTP response header, or <c>null</c> when not found. If the header occurs multiple times, their values are
10+
/// collapsed into a comma-separated string, without changing any surrounding double quotes.
11+
/// </summary>
12+
public static string? GetValue(this HttpResponseHeaders responseHeaders, string name)
13+
{
14+
if (responseHeaders.TryGetValues(name, out IEnumerable<string>? values))
15+
{
16+
return new StringValues(values.ToArray());
17+
}
18+
19+
return null;
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using JsonApiDotNetCore;
2+
3+
namespace TestBuildingBlocks;
4+
5+
public static class StringExtensions
6+
{
7+
public static string DoubleQuote(this string source)
8+
{
9+
ArgumentGuard.NotNull(source, nameof(source));
10+
11+
return '\"' + source + '\"';
12+
}
13+
}

0 commit comments

Comments
 (0)
Please sign in to comment.