Skip to content

Commit

Permalink
Explores support for idempotency
Browse files Browse the repository at this point in the history
  • Loading branch information
Bart Koelman authored and bkoelman committed Apr 11, 2022
1 parent 71e5398 commit 612cbe3
Show file tree
Hide file tree
Showing 30 changed files with 2,011 additions and 7 deletions.
1 change: 1 addition & 0 deletions JsonApiDotNetCore.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ $left$ = $right$;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=appsettings/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Assignee/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Contoso/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=idempotency/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Injectables/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jsonapi/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=linebreaks/@EntryIndexedValue">True</s:Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction
{
private readonly IDbContextTransaction _transaction;
private readonly DbContext _dbContext;
private readonly bool _ownsTransaction;

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

public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext)
public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext, bool ownsTransaction)
{
ArgumentGuard.NotNull(transaction, nameof(transaction));
ArgumentGuard.NotNull(dbContext, nameof(dbContext));

_transaction = transaction;
_dbContext = dbContext;
_ownsTransaction = ownsTransaction;
}

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

/// <inheritdoc />
public Task CommitAsync(CancellationToken cancellationToken)
public async Task CommitAsync(CancellationToken cancellationToken)
{
return _transaction.CommitAsync(cancellationToken);
if (_ownsTransaction)
{
await _transaction.CommitAsync(cancellationToken);
}
}

/// <inheritdoc />
public ValueTask DisposeAsync()
public async ValueTask DisposeAsync()
{
return _transaction.DisposeAsync();
if (_ownsTransaction)
{
await _transaction.DisposeAsync();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,17 @@ public async Task<IOperationsTransaction> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public static void UseJsonApi(this IApplicationBuilder builder)
options.Conventions.Insert(0, routingConvention);
};

builder.UseMiddleware<IdempotencyMiddleware>();
builder.UseMiddleware<JsonApiMiddleware>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ private void AddMiddlewareLayer()
_services.AddScoped<IJsonApiWriter, JsonApiWriter>();
_services.AddScoped<IJsonApiReader, JsonApiReader>();
_services.AddScoped<ITargetedFields, TargetedFields>();
_services.AddScoped<IIdempotencyProvider, NoIdempotencyProvider>();
}

private void AddResourceLayer()
Expand Down
1 change: 1 addition & 0 deletions src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<PackageReference Include="Humanizer.Core" Version="$(HumanizerVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
<PackageReference Include="SauceControl.InheritDoc" Version="1.3.0" PrivateAssets="All" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
Expand Down
1 change: 1 addition & 0 deletions src/JsonApiDotNetCore/Middleware/HeaderConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
30 changes: 30 additions & 0 deletions src/JsonApiDotNetCore/Middleware/IIdempotencyProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.AtomicOperations;
using Microsoft.AspNetCore.Http;

namespace JsonApiDotNetCore.Middleware;

[PublicAPI]
public interface IIdempotencyProvider
{
/// <summary>
/// Indicates whether the current request supports idempotency.
/// </summary>
bool IsSupported(HttpRequest request);

/// <summary>
/// Looks for a matching response in the idempotency cache for the specified idempotency key.
/// </summary>
Task<IdempotentResponse?> GetResponseFromCacheAsync(string idempotencyKey, CancellationToken cancellationToken);

/// <summary>
/// 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.
/// </summary>
Task<IOperationsTransaction> BeginRequestAsync(string idempotencyKey, string requestFingerprint, CancellationToken cancellationToken);

/// <summary>
/// Saves the produced response in the cache and commits its transaction.
/// </summary>
Task CompleteRequestAsync(string idempotencyKey, IdempotentResponse response, IOperationsTransaction transaction, CancellationToken cancellationToken);
}
249 changes: 249 additions & 0 deletions src/JsonApiDotNetCore/Middleware/IdempotencyMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}

/// <summary>
/// Enables to read the HTTP request stream multiple times, without risking GC Gen2/LOH promotion.
/// </summary>
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<string> 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));
}

/// <summary>
/// Executes the specified action and returns what it wrote to the HTTP response stream.
/// </summary>
private static async Task<string> 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();
}
}
Loading

0 comments on commit 612cbe3

Please sign in to comment.