From c909ec728e578a6c78a28d536f9ea516658b2edf Mon Sep 17 00:00:00 2001
From: Nick Evans <2616208+nickevansuk@users.noreply.github.com>
Date: Thu, 17 Aug 2023 16:20:22 +0100
Subject: [PATCH] feat: Implement idempotency for B/P requests (#207)
---
.../BookingSystem.AspNetCore.csproj | 1 +
.../Settings/EngineConfig.cs | 3 +
.../Stores/IdempotencyStore.cs | 25 +++++++++
.../CustomBookingEngine.cs | 56 +++++++++++++++----
.../Settings/BookingEngineSettings.cs | 3 +-
.../Stores/IdempotencyStore.cs | 34 +++++++++++
6 files changed, 109 insertions(+), 13 deletions(-)
create mode 100644 Examples/BookingSystem.AspNetCore/Stores/IdempotencyStore.cs
create mode 100644 OpenActive.Server.NET/OpenBookingHelper/Stores/IdempotencyStore.cs
diff --git a/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj b/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj
index e02b4905..e6d5a34a 100644
--- a/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj
+++ b/Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj
@@ -7,6 +7,7 @@
+
diff --git a/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs b/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs
index 5dafa9fa..70ab090c 100644
--- a/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs
+++ b/Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs
@@ -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.ScheduledSession, new AcmeScheduledSessionRpdeGenerator(fakeBookingSystem)
diff --git a/Examples/BookingSystem.AspNetCore/Stores/IdempotencyStore.cs b/Examples/BookingSystem.AspNetCore/Stores/IdempotencyStore.cs
new file mode 100644
index 00000000..67eaa8eb
--- /dev/null
+++ b/Examples/BookingSystem.AspNetCore/Stores/IdempotencyStore.cs
@@ -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 GetSuccessfulOrderCreationResponse(string idempotencyKey)
+ {
+ return new ValueTask((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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/OpenActive.Server.NET/CustomBookingEngine/CustomBookingEngine.cs b/OpenActive.Server.NET/CustomBookingEngine/CustomBookingEngine.cs
index e3a9ca45..1b9ad070 100644
--- a/OpenActive.Server.NET/CustomBookingEngine/CustomBookingEngine.cs
+++ b/OpenActive.Server.NET/CustomBookingEngine/CustomBookingEngine.cs
@@ -414,9 +414,6 @@ private async Task ProcessCheckpoint(string clientId, Uri selle
}
public async Task 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(orderJson);
if (order == null || order.GetType() != typeof(Order))
{
@@ -425,21 +422,23 @@ public async Task 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(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 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(orderJson);
if (order == null || order.GetType() != typeof(OrderProposal))
{
@@ -448,14 +447,47 @@ public async Task 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(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 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 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
diff --git a/OpenActive.Server.NET/OpenBookingHelper/Settings/BookingEngineSettings.cs b/OpenActive.Server.NET/OpenBookingHelper/Settings/BookingEngineSettings.cs
index 2349c6e2..a9ffe402 100644
--- a/OpenActive.Server.NET/OpenBookingHelper/Settings/BookingEngineSettings.cs
+++ b/OpenActive.Server.NET/OpenBookingHelper/Settings/BookingEngineSettings.cs
@@ -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;
///
/// TTL in the Cache-Control header for all RPDE pages that contain greater than zero items
@@ -44,5 +45,5 @@ public class BookingEngineSettings
/// See https://developer.openactive.io/publishing-data/data-feeds/scaling-feeds for CDN configuration instructions
///
public TimeSpan DatasetSiteCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
- }
+ }
}
diff --git a/OpenActive.Server.NET/OpenBookingHelper/Stores/IdempotencyStore.cs b/OpenActive.Server.NET/OpenBookingHelper/Stores/IdempotencyStore.cs
new file mode 100644
index 00000000..bcadfc57
--- /dev/null
+++ b/OpenActive.Server.NET/OpenBookingHelper/Stores/IdempotencyStore.cs
@@ -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 GetSuccessfulOrderCreationResponse(string idempotencyKey);
+ protected abstract ValueTask SetSuccessfulOrderCreationResponse(string idempotencyKey, string responseJson);
+
+ internal ValueTask 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("-", "");
+ }
+ }
+ }
+}
\ No newline at end of file