Skip to content

Commit

Permalink
feat: Implement idempotency for B/P requests (#207)
Browse files Browse the repository at this point in the history
  • Loading branch information
nickevansuk authored Aug 17, 2023
1 parent f8a4150 commit c909ec7
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.2" />
<PackageReference Include="System.Runtime.Caching" Version="7.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting
),
HasSingleSeller = appSettings.FeatureFlags.SingleSeller,

// IdempotencyStore used for storing the response to Order Creation B/P requests
IdempotencyStore = new AcmeIdempotencyStore(),

OpenDataFeeds = new Dictionary<OpportunityType, IOpportunityDataRpdeFeedGenerator> {
{
OpportunityType.ScheduledSession, new AcmeScheduledSessionRpdeGenerator(fakeBookingSystem)
Expand Down
25 changes: 25 additions & 0 deletions Examples/BookingSystem.AspNetCore/Stores/IdempotencyStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using OpenActive.Server.NET.OpenBookingHelper;
using System;
using System.Threading.Tasks;
using System.Runtime.Caching;

namespace BookingSystem
{
public class AcmeIdempotencyStore : IdempotencyStore
{
private readonly ObjectCache _cache = MemoryCache.Default;

protected override ValueTask<string> GetSuccessfulOrderCreationResponse(string idempotencyKey)
{
return new ValueTask<string>((string)_cache.Get(idempotencyKey));
}

protected override ValueTask SetSuccessfulOrderCreationResponse(string idempotencyKey, string responseJson)
{
var policy = new CacheItemPolicy();
policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(5);
_cache.Set(idempotencyKey, responseJson, policy);
return new ValueTask();
}
}
}
56 changes: 44 additions & 12 deletions OpenActive.Server.NET/CustomBookingEngine/CustomBookingEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,6 @@ private async Task<ResponseContent> ProcessCheckpoint(string clientId, Uri selle
}
public async Task<ResponseContent> ProcessOrderCreationB(string clientId, Uri sellerId, string uuidString, string orderJson, Uri customerAccountId = null)
{

// Note B will never contain OrderItem level errors, and any issues that occur will be thrown as exceptions.
// If C1 and C2 are used correctly, B should not fail except in very exceptional cases.
Order order = OpenActiveSerializer.Deserialize<Order>(orderJson);
if (order == null || order.GetType() != typeof(Order))
{
Expand All @@ -425,21 +422,23 @@ public async Task<ResponseContent> ProcessOrderCreationB(string clientId, Uri se
var (orderId, sellerIdComponents, seller, customerAccountIdComponents) = await ConstructIdsFromRequest(clientId, sellerId, customerAccountId, uuidString, OrderType.Order);
using (await asyncDuplicateLock.LockAsync(GetParallelLockKey(orderId)))
{
// Attempt to use idempotency cache if it exists
var cachedResponse = await GetResponseFromIdempotencyStoreIfExists(settings, orderId, orderJson);
if (cachedResponse != null)
{
return cachedResponse;
}

var response = order.OrderProposalVersion != null ?
await ProcessOrderCreationFromOrderProposal(orderId, settings.OrderIdTemplate, seller, sellerIdComponents, customerAccountIdComponents, order) :
await ProcessFlowRequest(ValidateFlowRequest<Order>(orderId, sellerIdComponents, seller, customerAccountIdComponents, FlowStage.B, order), order);

// Return a 409 status code if any OrderItem level errors exist
return ResponseContent.OpenBookingResponse(OpenActiveSerializer.Serialize(response),
response.OrderedItem.Exists(x => x.Error?.Count > 0) ? HttpStatusCode.Conflict : HttpStatusCode.Created);
return await CreateResponseViaIdempotencyStoreIfExists(settings, orderId, orderJson, response);
}
}

public async Task<ResponseContent> ProcessOrderProposalCreationP(string clientId, Uri sellerId, string uuidString, string orderJson, Uri customerAccountId = null)
{

// Note B will never contain OrderItem level errors, and any issues that occur will be thrown as exceptions.
// If C1 and C2 are used correctly, P should not fail except in very exceptional cases.
OrderProposal order = OpenActiveSerializer.Deserialize<OrderProposal>(orderJson);
if (order == null || order.GetType() != typeof(OrderProposal))
{
Expand All @@ -448,14 +447,47 @@ public async Task<ResponseContent> ProcessOrderProposalCreationP(string clientId
var (orderId, sellerIdComponents, seller, customerAccountIdComponents) = await ConstructIdsFromRequest(clientId, sellerId, customerAccountId, uuidString, OrderType.OrderProposal);
using (await asyncDuplicateLock.LockAsync(GetParallelLockKey(orderId)))
{
// Attempt to use idempotency cache if it exists
var cachedResponse = await GetResponseFromIdempotencyStoreIfExists(settings, orderId, orderJson);
if (cachedResponse != null)
{
return cachedResponse;
}

var response = await ProcessFlowRequest(ValidateFlowRequest<OrderProposal>(orderId, sellerIdComponents, seller, customerAccountIdComponents, FlowStage.P, order), order);

// Return a 409 status code if any OrderItem level errors exist
return ResponseContent.OpenBookingResponse(OpenActiveSerializer.Serialize(response),
response.OrderedItem.Exists(x => x.Error?.Count > 0) ? HttpStatusCode.Conflict : HttpStatusCode.Created);
return await CreateResponseViaIdempotencyStoreIfExists(settings, orderId, orderJson, response);
}
}

private async Task<ResponseContent> GetResponseFromIdempotencyStoreIfExists(BookingEngineSettings settings, OrderIdComponents orderId, string orderJson)
{
// Attempt to use idempotency cache if it exists
if (settings.IdempotencyStore != null)
{
var cachedResponse = await settings.IdempotencyStore.GetSuccessfulOrderCreationResponse(orderId, orderJson);
if (cachedResponse != null)
{
return ResponseContent.OpenBookingResponse(cachedResponse, HttpStatusCode.Created);
}
}
return null;
}

private async Task<ResponseContent> CreateResponseViaIdempotencyStoreIfExists(BookingEngineSettings settings, OrderIdComponents orderId, string orderJson, Order response) {
// Return a 409 status code if any OrderItem level errors exist
var httpStatusCode = response.OrderedItem.Exists(x => x.Error?.Count > 0) ? HttpStatusCode.Conflict : HttpStatusCode.Created;
var responseJson = OpenActiveSerializer.Serialize(response);

// Store response in idempotency cache if it exists, and if the response is successful
if (settings.IdempotencyStore != null && httpStatusCode == HttpStatusCode.Created)
{
await settings.IdempotencyStore.SetSuccessfulOrderCreationResponse(orderId, orderJson, responseJson);
}

return ResponseContent.OpenBookingResponse(responseJson, httpStatusCode);
}

private SimpleIdComponents GetSimpleIdComponentsFromApiKey(Uri sellerId)
{
// Return empty SimpleIdComponents in Single Seller mode, as it is not required in the API Key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class BookingEngineSettings
public OrdersRPDEFeedGenerator OrdersFeedGenerator { get; set; }
public OrdersRPDEFeedGenerator OrderProposalsFeedGenerator { get; set; }
public SellerStore SellerStore { get; set; }
public IdempotencyStore IdempotencyStore { get; set; }
public bool HasSingleSeller { get; set; } = false;
/// <summary>
/// TTL in the Cache-Control header for all RPDE pages that contain greater than zero items
Expand All @@ -44,5 +45,5 @@ public class BookingEngineSettings
/// See https://developer.openactive.io/publishing-data/data-feeds/scaling-feeds for CDN configuration instructions
/// </summary>
public TimeSpan DatasetSiteCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
}
}
}
34 changes: 34 additions & 0 deletions OpenActive.Server.NET/OpenBookingHelper/Stores/IdempotencyStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using OpenActive.NET;
using System;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text;

namespace OpenActive.Server.NET.OpenBookingHelper
{
public abstract class IdempotencyStore
{
protected abstract ValueTask<string> GetSuccessfulOrderCreationResponse(string idempotencyKey);
protected abstract ValueTask SetSuccessfulOrderCreationResponse(string idempotencyKey, string responseJson);

internal ValueTask<string> GetSuccessfulOrderCreationResponse(OrderIdComponents orderId, string orderJson) {
return GetSuccessfulOrderCreationResponse(CalculateIdempotencyKey(orderId, orderJson));
}

internal ValueTask SetSuccessfulOrderCreationResponse(OrderIdComponents orderId, string orderJson, string responseJson) {
return SetSuccessfulOrderCreationResponse(CalculateIdempotencyKey(orderId, orderJson), responseJson);
}

protected string CalculateIdempotencyKey(OrderIdComponents orderId, string orderJson) {
return $"{orderId.ClientId}|{orderId.uuid}|{orderId.OrderType.ToString()}|{ComputeSHA256Hash(orderJson)}";
}

protected static string ComputeSHA256Hash(string text)
{
using (var sha256 = SHA256.Create())
{
return BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(text))).Replace("-", "");
}
}
}
}

0 comments on commit c909ec7

Please sign in to comment.