Skip to content

Commit c909ec7

Browse files
authored
feat: Implement idempotency for B/P requests (#207)
1 parent f8a4150 commit c909ec7

6 files changed

Lines changed: 109 additions & 13 deletions

File tree

Examples/BookingSystem.AspNetCore/BookingSystem.AspNetCore.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<ItemGroup>
99
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.2" />
10+
<PackageReference Include="System.Runtime.Caching" Version="7.0.0" />
1011
</ItemGroup>
1112

1213
<ItemGroup>

Examples/BookingSystem.AspNetCore/Settings/EngineConfig.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ public static StoreBookingEngine CreateStoreBookingEngine(AppSettings appSetting
166166
),
167167
HasSingleSeller = appSettings.FeatureFlags.SingleSeller,
168168

169+
// IdempotencyStore used for storing the response to Order Creation B/P requests
170+
IdempotencyStore = new AcmeIdempotencyStore(),
171+
169172
OpenDataFeeds = new Dictionary<OpportunityType, IOpportunityDataRpdeFeedGenerator> {
170173
{
171174
OpportunityType.ScheduledSession, new AcmeScheduledSessionRpdeGenerator(fakeBookingSystem)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using OpenActive.Server.NET.OpenBookingHelper;
2+
using System;
3+
using System.Threading.Tasks;
4+
using System.Runtime.Caching;
5+
6+
namespace BookingSystem
7+
{
8+
public class AcmeIdempotencyStore : IdempotencyStore
9+
{
10+
private readonly ObjectCache _cache = MemoryCache.Default;
11+
12+
protected override ValueTask<string> GetSuccessfulOrderCreationResponse(string idempotencyKey)
13+
{
14+
return new ValueTask<string>((string)_cache.Get(idempotencyKey));
15+
}
16+
17+
protected override ValueTask SetSuccessfulOrderCreationResponse(string idempotencyKey, string responseJson)
18+
{
19+
var policy = new CacheItemPolicy();
20+
policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(5);
21+
_cache.Set(idempotencyKey, responseJson, policy);
22+
return new ValueTask();
23+
}
24+
}
25+
}

OpenActive.Server.NET/CustomBookingEngine/CustomBookingEngine.cs

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -414,9 +414,6 @@ private async Task<ResponseContent> ProcessCheckpoint(string clientId, Uri selle
414414
}
415415
public async Task<ResponseContent> ProcessOrderCreationB(string clientId, Uri sellerId, string uuidString, string orderJson, Uri customerAccountId = null)
416416
{
417-
418-
// Note B will never contain OrderItem level errors, and any issues that occur will be thrown as exceptions.
419-
// If C1 and C2 are used correctly, B should not fail except in very exceptional cases.
420417
Order order = OpenActiveSerializer.Deserialize<Order>(orderJson);
421418
if (order == null || order.GetType() != typeof(Order))
422419
{
@@ -425,21 +422,23 @@ public async Task<ResponseContent> ProcessOrderCreationB(string clientId, Uri se
425422
var (orderId, sellerIdComponents, seller, customerAccountIdComponents) = await ConstructIdsFromRequest(clientId, sellerId, customerAccountId, uuidString, OrderType.Order);
426423
using (await asyncDuplicateLock.LockAsync(GetParallelLockKey(orderId)))
427424
{
425+
// Attempt to use idempotency cache if it exists
426+
var cachedResponse = await GetResponseFromIdempotencyStoreIfExists(settings, orderId, orderJson);
427+
if (cachedResponse != null)
428+
{
429+
return cachedResponse;
430+
}
431+
428432
var response = order.OrderProposalVersion != null ?
429433
await ProcessOrderCreationFromOrderProposal(orderId, settings.OrderIdTemplate, seller, sellerIdComponents, customerAccountIdComponents, order) :
430434
await ProcessFlowRequest(ValidateFlowRequest<Order>(orderId, sellerIdComponents, seller, customerAccountIdComponents, FlowStage.B, order), order);
431435

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

438440
public async Task<ResponseContent> ProcessOrderProposalCreationP(string clientId, Uri sellerId, string uuidString, string orderJson, Uri customerAccountId = null)
439441
{
440-
441-
// Note B will never contain OrderItem level errors, and any issues that occur will be thrown as exceptions.
442-
// If C1 and C2 are used correctly, P should not fail except in very exceptional cases.
443442
OrderProposal order = OpenActiveSerializer.Deserialize<OrderProposal>(orderJson);
444443
if (order == null || order.GetType() != typeof(OrderProposal))
445444
{
@@ -448,14 +447,47 @@ public async Task<ResponseContent> ProcessOrderProposalCreationP(string clientId
448447
var (orderId, sellerIdComponents, seller, customerAccountIdComponents) = await ConstructIdsFromRequest(clientId, sellerId, customerAccountId, uuidString, OrderType.OrderProposal);
449448
using (await asyncDuplicateLock.LockAsync(GetParallelLockKey(orderId)))
450449
{
450+
// Attempt to use idempotency cache if it exists
451+
var cachedResponse = await GetResponseFromIdempotencyStoreIfExists(settings, orderId, orderJson);
452+
if (cachedResponse != null)
453+
{
454+
return cachedResponse;
455+
}
456+
451457
var response = await ProcessFlowRequest(ValidateFlowRequest<OrderProposal>(orderId, sellerIdComponents, seller, customerAccountIdComponents, FlowStage.P, order), order);
452458

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

463+
private async Task<ResponseContent> GetResponseFromIdempotencyStoreIfExists(BookingEngineSettings settings, OrderIdComponents orderId, string orderJson)
464+
{
465+
// Attempt to use idempotency cache if it exists
466+
if (settings.IdempotencyStore != null)
467+
{
468+
var cachedResponse = await settings.IdempotencyStore.GetSuccessfulOrderCreationResponse(orderId, orderJson);
469+
if (cachedResponse != null)
470+
{
471+
return ResponseContent.OpenBookingResponse(cachedResponse, HttpStatusCode.Created);
472+
}
473+
}
474+
return null;
475+
}
476+
477+
private async Task<ResponseContent> CreateResponseViaIdempotencyStoreIfExists(BookingEngineSettings settings, OrderIdComponents orderId, string orderJson, Order response) {
478+
// Return a 409 status code if any OrderItem level errors exist
479+
var httpStatusCode = response.OrderedItem.Exists(x => x.Error?.Count > 0) ? HttpStatusCode.Conflict : HttpStatusCode.Created;
480+
var responseJson = OpenActiveSerializer.Serialize(response);
481+
482+
// Store response in idempotency cache if it exists, and if the response is successful
483+
if (settings.IdempotencyStore != null && httpStatusCode == HttpStatusCode.Created)
484+
{
485+
await settings.IdempotencyStore.SetSuccessfulOrderCreationResponse(orderId, orderJson, responseJson);
486+
}
487+
488+
return ResponseContent.OpenBookingResponse(responseJson, httpStatusCode);
489+
}
490+
459491
private SimpleIdComponents GetSimpleIdComponentsFromApiKey(Uri sellerId)
460492
{
461493
// Return empty SimpleIdComponents in Single Seller mode, as it is not required in the API Key

OpenActive.Server.NET/OpenBookingHelper/Settings/BookingEngineSettings.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class BookingEngineSettings
2828
public OrdersRPDEFeedGenerator OrdersFeedGenerator { get; set; }
2929
public OrdersRPDEFeedGenerator OrderProposalsFeedGenerator { get; set; }
3030
public SellerStore SellerStore { get; set; }
31+
public IdempotencyStore IdempotencyStore { get; set; }
3132
public bool HasSingleSeller { get; set; } = false;
3233
/// <summary>
3334
/// TTL in the Cache-Control header for all RPDE pages that contain greater than zero items
@@ -44,5 +45,5 @@ public class BookingEngineSettings
4445
/// See https://developer.openactive.io/publishing-data/data-feeds/scaling-feeds for CDN configuration instructions
4546
/// </summary>
4647
public TimeSpan DatasetSiteCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
47-
}
48+
}
4849
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using OpenActive.NET;
2+
using System;
3+
using System.Threading.Tasks;
4+
using System.Security.Cryptography;
5+
using System.Text;
6+
7+
namespace OpenActive.Server.NET.OpenBookingHelper
8+
{
9+
public abstract class IdempotencyStore
10+
{
11+
protected abstract ValueTask<string> GetSuccessfulOrderCreationResponse(string idempotencyKey);
12+
protected abstract ValueTask SetSuccessfulOrderCreationResponse(string idempotencyKey, string responseJson);
13+
14+
internal ValueTask<string> GetSuccessfulOrderCreationResponse(OrderIdComponents orderId, string orderJson) {
15+
return GetSuccessfulOrderCreationResponse(CalculateIdempotencyKey(orderId, orderJson));
16+
}
17+
18+
internal ValueTask SetSuccessfulOrderCreationResponse(OrderIdComponents orderId, string orderJson, string responseJson) {
19+
return SetSuccessfulOrderCreationResponse(CalculateIdempotencyKey(orderId, orderJson), responseJson);
20+
}
21+
22+
protected string CalculateIdempotencyKey(OrderIdComponents orderId, string orderJson) {
23+
return $"{orderId.ClientId}|{orderId.uuid}|{orderId.OrderType.ToString()}|{ComputeSHA256Hash(orderJson)}";
24+
}
25+
26+
protected static string ComputeSHA256Hash(string text)
27+
{
28+
using (var sha256 = SHA256.Create())
29+
{
30+
return BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(text))).Replace("-", "");
31+
}
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)