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