Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement idempotency for B/P requests #207

Merged
merged 2 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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("-", "");
}
}
}
}