Skip to content
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
6 changes: 6 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Sentry.AspNetCore;
using Orbit.Api.Authentication;
using Orbit.Api.Authorization;
using Orbit.Api.Idempotency;
using Orbit.Api.OAuth;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
Expand Down Expand Up @@ -67,6 +68,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
builder.Services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IAccountResetRepository, AccountResetRepository>();
builder.Services.AddScoped<IIdempotencyStore, IdempotencyStore>();
builder.Services.AddScoped<IAppConfigService, AppConfigService>();
builder.Services.AddScoped<IUserDateService, UserDateService>();
builder.Services.AddScoped<IUserStreakService, UserStreakService>();
Expand Down Expand Up @@ -214,11 +216,15 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu

builder.Services.AddValidatorsFromAssemblyContaining<CreateHabitCommandValidator>();

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IIdempotencyContext, HttpIdempotencyContext>();

builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Orbit.Application.Chat.Commands.ProcessUserChatCommand).Assembly);
cfg.AddOpenBehavior(typeof(ConcurrencyRetryBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
cfg.AddOpenBehavior(typeof(IdempotencyBehavior<,>));
});

AddCorsPolicies(builder);
Expand Down
37 changes: 37 additions & 0 deletions src/Orbit.Api/Idempotency/HttpIdempotencyContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Orbit.Application.Common;

namespace Orbit.Api.Idempotency;

/// <summary>
/// Reads the <c>Idempotency-Key</c> header and authenticated user id from the current HTTP request.
/// Only requests that carry the header (the mobile offline queue's replayable mutations) opt into
/// idempotency; every read and un-keyed request bypasses it. See thomasluizon/orbit-ui-mobile#243.
/// </summary>
public sealed class HttpIdempotencyContext(IHttpContextAccessor httpContextAccessor) : IIdempotencyContext
{
private const string IdempotencyKeyHeaderName = "Idempotency-Key";
private const int MaxKeyLength = 200;

public bool TryGetRequestKey(out Guid userId, [NotNullWhen(true)] out string idempotencyKey)
{
userId = Guid.Empty;
idempotencyKey = "";

var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null)
return false;

var key = httpContext.Request.Headers[IdempotencyKeyHeaderName].ToString().Trim();
if (string.IsNullOrEmpty(key) || key.Length > MaxKeyLength)
return false;

var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out userId))
return false;

idempotencyKey = key;
return true;
}
}
72 changes: 72 additions & 0 deletions src/Orbit.Application/Behaviors/IdempotencyBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Orbit.Application.Common;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Behaviors;

/// <summary>
/// Makes replays of opt-in <see cref="IIdempotentCommand"/> mutations that carry an <c>Idempotency-Key</c>
/// header exactly-once: a replayed request (a retry after a lost network ACK, routine on mobile) returns the
/// stored response instead of re-executing the handler. The reservation row is flushed first — isolating a
/// ledger unique-violation from the handler's own constraints — then commits in one transaction with the
/// handler's mutation, so a crash cannot leave the mutation applied without its idempotency record. A
/// concurrent duplicate loses the unique-index race and replays the winner's response. The ledger key is
/// scoped by request type so one key reused across two commands can't cross wires. See
/// thomasluizon/orbit-ui-mobile#243.
/// </summary>
public sealed class IdempotencyBehavior<TRequest, TResponse>(
IIdempotencyContext idempotencyContext,
IIdempotencyStore idempotencyStore,
IUnitOfWork unitOfWork) : IPipelineBehavior<TRequest, TResponse>
where TRequest : class
{
private static readonly JsonSerializerOptions SerializerOptions =
new(JsonSerializerDefaults.Web) { Converters = { new ResultJsonConverterFactory() } };

private static readonly string RequestType = typeof(TRequest).FullName ?? typeof(TRequest).Name;

public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (request is not IIdempotentCommand
|| !idempotencyContext.TryGetRequestKey(out var userId, out var idempotencyKey))
return await next(cancellationToken);

var storedResponse = await idempotencyStore.FindResponseBodyAsync(userId, idempotencyKey, RequestType, cancellationToken);
if (storedResponse is not null)
return Deserialize(storedResponse);

var response = default(TResponse)!;
try
{
await unitOfWork.ExecuteInTransactionAsync(async transactionToken =>
{
var reservation = idempotencyStore.Reserve(userId, idempotencyKey, RequestType);
await unitOfWork.SaveChangesAsync(transactionToken);
response = await next(transactionToken);
reservation.SetResponseBody(Serialize(response));
await unitOfWork.SaveChangesAsync(transactionToken);
}, cancellationToken);
}
catch (DbUpdateException exception) when (DbUniqueViolation.IsUniqueViolation(exception))
{
var racedResponse = await idempotencyStore.FindResponseBodyAsync(userId, idempotencyKey, RequestType, cancellationToken);
if (racedResponse is null)
throw;

return Deserialize(racedResponse);
}

return response;
}

private static string Serialize(TResponse response) =>
JsonSerializer.Serialize(response, SerializerOptions);

private static TResponse Deserialize(string responseBody) =>
JsonSerializer.Deserialize<TResponse>(responseBody, SerializerOptions)!;
}
10 changes: 10 additions & 0 deletions src/Orbit.Application/Common/IIdempotencyContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Orbit.Application.Common;

/// <summary>
/// Surfaces the current request's client idempotency key and authenticated user, if both are present,
/// so the idempotency pipeline behavior can dedupe replayed mutations without depending on ASP.NET Core.
/// </summary>
public interface IIdempotencyContext
{
bool TryGetRequestKey(out Guid userId, out string idempotencyKey);
}
32 changes: 32 additions & 0 deletions src/Orbit.Application/Common/IIdempotencyStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Orbit.Application.Common;

/// <summary>
/// Persists and retrieves idempotency-ledger records so a replayed client mutation (identified by an
/// Idempotency-Key) returns its original response instead of re-executing. See
/// thomasluizon/orbit-ui-mobile#243.
/// </summary>
public interface IIdempotencyStore
{
/// <summary>
/// Returns the stored serialized response for a previously-processed (user, key, request type), or
/// <c>null</c> if that combination has not been processed. The request type scopes the key so reusing
/// one key across two different commands never returns the wrong command's cached response.
/// </summary>
Task<string?> FindResponseBodyAsync(Guid userId, string idempotencyKey, string requestType, CancellationToken cancellationToken);

/// <summary>
/// Adds a tracked, uncommitted reservation for the (user, key, request type) so it commits atomically
/// with the wrapped handler's mutation. The response body is filled in via the returned reservation
/// after the handler runs.
/// </summary>
IIdempotencyReservation Reserve(Guid userId, string idempotencyKey, string requestType);
}

/// <summary>
/// A tracked, not-yet-committed idempotency reservation whose response body is set after the wrapped
/// handler runs, so the reservation and the mutation persist in the same transaction.
/// </summary>
public interface IIdempotencyReservation
{
void SetResponseBody(string responseBody);
}
10 changes: 10 additions & 0 deletions src/Orbit.Application/Common/IIdempotentCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Orbit.Application.Common;

/// <summary>
/// Opt-in marker for commands whose replay must be deduped by the idempotency ledger — the small set of
/// non-idempotent, offline-queued mutations where a lost-ACK retry would double-apply (a duplicate entity,
/// a reversed habit-log toggle, a duplicate skip/progress row). Only these commands' responses are cached.
/// Naturally-idempotent commands and any command returning a one-time secret MUST NOT be marked, so a
/// secret can never land in the plaintext ledger. See thomasluizon/orbit-ui-mobile#243.
/// </summary>
public interface IIdempotentCommand;
100 changes: 100 additions & 0 deletions src/Orbit.Application/Common/ResultJsonConverterFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Orbit.Domain.Common;

namespace Orbit.Application.Common;

/// <summary>
/// Round-trips <see cref="Result"/> and <see cref="Result{T}"/> through System.Text.Json for the
/// idempotency ledger. The default reflection converter cannot: <see cref="Result{T}.Value"/> throws on
/// a failed result and the non-generic <see cref="Result"/> has no public constructor. This converter
/// reads <c>Value</c> only when the result succeeded and rebuilds via the factory methods, so a cached
/// success or failure replays without crashing. See thomasluizon/orbit-ui-mobile#243.
/// </summary>
public sealed class ResultJsonConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert) =>
typeToConvert == typeof(Result)
|| (typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Result<>));

public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
if (typeToConvert == typeof(Result))
return new ResultConverter();

var valueType = typeToConvert.GetGenericArguments()[0];
return (JsonConverter)Activator.CreateInstance(
typeof(GenericResultConverter<>).MakeGenericType(valueType))!;
}

private static (bool IsSuccess, string Error, string? ErrorCode) ReadEnvelope(JsonElement root)
{
var isSuccess = root.GetProperty("isSuccess").GetBoolean();
var error = root.TryGetProperty("error", out var errorElement) ? errorElement.GetString() ?? "" : "";
var errorCode = root.TryGetProperty("errorCode", out var codeElement) && codeElement.ValueKind == JsonValueKind.String
? codeElement.GetString()
: null;
return (isSuccess, error, errorCode);
}

private static void WriteEnvelope(Utf8JsonWriter writer, Result value)
{
writer.WriteBoolean("isSuccess", value.IsSuccess);
writer.WriteString("error", value.Error);
if (value.ErrorCode is null)
writer.WriteNull("errorCode");
else
writer.WriteString("errorCode", value.ErrorCode);
}

private sealed class ResultConverter : JsonConverter<Result>
{
public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
var (isSuccess, error, errorCode) = ReadEnvelope(document.RootElement);
if (isSuccess)
return Result.Success();
return errorCode is null ? Result.Failure(error) : Result.Failure(error, errorCode);
}

public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options)
{
writer.WriteStartObject();
WriteEnvelope(writer, value);
writer.WriteEndObject();
}
}

private sealed class GenericResultConverter<T> : JsonConverter<Result<T>>
{
public override Result<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
var (isSuccess, error, errorCode) = ReadEnvelope(root);

if (isSuccess)
{
var value = root.TryGetProperty("value", out var valueElement)
? valueElement.Deserialize<T>(options)
: default;
return Result.Success(value!);
}

return errorCode is null ? Result.Failure<T>(error) : Result.Failure<T>(error, errorCode);
}

public override void Write(Utf8JsonWriter writer, Result<T> value, JsonSerializerOptions options)
{
writer.WriteStartObject();
WriteEnvelope(writer, value);
if (value.IsSuccess)
{
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, value.Value, options);
}
writer.WriteEndObject();
}
}
}
2 changes: 1 addition & 1 deletion src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public record CreateGoalCommand(
string Unit,
DateOnly? Deadline,
int Position = 0,
GoalType Type = GoalType.Standard) : IRequest<Result<Guid>>;
GoalType Type = GoalType.Standard) : IRequest<Result<Guid>>, IIdempotentCommand;

public partial class CreateGoalCommandHandler(
IGenericRepository<Goal> goalRepository,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public record UpdateGoalProgressCommand(
Guid UserId,
Guid GoalId,
decimal NewValue,
string? Note = null) : IRequest<Result>;
string? Note = null) : IRequest<Result>, IIdempotentCommand;

public partial class UpdateGoalProgressCommandHandler(
IGenericRepository<Goal> goalRepository,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
HabitCommandOptions? Options = null,
IReadOnlyList<Guid>? TagIds = null,
IReadOnlyList<Guid>? GoalIds = null,
string? Emoji = null) : IRequest<Result<Guid>>;
string? Emoji = null) : IRequest<Result<Guid>>, IIdempotentCommand;

/// <summary>
/// Groups repository dependencies for habit creation to reduce constructor parameter count (S107).
Expand All @@ -41,7 +41,7 @@
IMemoryCache cache,
ILogger<CreateHabitCommandHandler> logger) : IRequestHandler<CreateHabitCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateHabitCommand request, CancellationToken cancellationToken)

Check warning on line 44 in src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.
{
var opts = request.Options ?? new HabitCommandOptions();

Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Habits/Commands/LogHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
public record LogHabitCommand(
Guid UserId,
Guid HabitId,
DateOnly? Date = null) : IRequest<Result<LogHabitResponse>>;
DateOnly? Date = null) : IRequest<Result<LogHabitResponse>>, IIdempotentCommand;

/// <summary>
/// Groups repository dependencies for habit logging to reduce constructor parameter count (S107).
Expand Down Expand Up @@ -110,7 +110,7 @@
{
HabitLog unlogEntity;
LinkedGoalSyncResult goalSync;
for (var attempt = 1; ; attempt++)

Check warning on line 113 in src/Orbit.Application/Habits/Commands/LogHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

This loop's stop incrementer updates 'attempt' but the stop condition doesn't test any variables.
{
var unlogResult = habit.Unlog(targetDate);
if (unlogResult.IsFailure)
Expand Down Expand Up @@ -161,7 +161,7 @@
var shouldAdvanceDueDate = targetDate >= today;
HabitLog logEntity;
LinkedGoalSyncResult goalSync;
for (var attempt = 1; ; attempt++)

Check warning on line 164 in src/Orbit.Application/Habits/Commands/LogHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

This loop's stop incrementer updates 'attempt' but the stop condition doesn't test any variables.
{
var logResult = habit.Log(targetDate, advanceDueDate: shouldAdvanceDueDate);
if (logResult.IsFailure)
Expand Down Expand Up @@ -226,7 +226,7 @@

private async Task PersistStreakRecalcAsync(Guid userId, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)

Check warning on line 229 in src/Orbit.Application/Habits/Commands/LogHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

This loop's stop incrementer updates 'attempt' but the stop condition doesn't test any variables.
{
try
{
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
public record SkipHabitCommand(
Guid UserId,
Guid HabitId,
DateOnly? Date = null) : IRequest<Result>, IConcurrencyRetryable;
DateOnly? Date = null) : IRequest<Result>, IConcurrencyRetryable, IIdempotentCommand;

public partial class SkipHabitCommandHandler(

Check warning on line 21 in src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Constructor has 8 parameters, which is greater than the 7 authorized.
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IGenericRepository<Goal> goalRepository,
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Tags/Commands/CreateTagCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Orbit.Application.Tags.Commands;
public record CreateTagCommand(
Guid UserId,
string Name,
string Color) : IRequest<Result<Guid>>;
string Color) : IRequest<Result<Guid>>, IIdempotentCommand;

public class CreateTagCommandHandler(
IGenericRepository<Tag> tagRepository,
Expand Down
40 changes: 40 additions & 0 deletions src/Orbit.Domain/Entities/ProcessedRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Orbit.Domain.Common;

namespace Orbit.Domain.Entities;

/// <summary>
/// Idempotency ledger for client-initiated mutations. Records that a request carrying a given client
/// <c>Idempotency-Key</c> (a mobile offline-queue mutation id) was processed for a user and stores the
/// serialized response, so a replay — a retry after a lost network ACK, routine on mobile — returns the
/// original outcome instead of re-executing the mutation. See thomasluizon/orbit-ui-mobile#243.
/// </summary>
public class ProcessedRequest : Entity
{
public Guid UserId { get; private set; }

public string IdempotencyKey { get; private set; } = "";

public string RequestType { get; private set; } = "";

public string ResponseBody { get; private set; } = "";

public DateTime CreatedAtUtc { get; private set; }

private ProcessedRequest() { }

public static ProcessedRequest Create(Guid userId, string idempotencyKey, string requestType)
{
return new ProcessedRequest
{
UserId = userId,
IdempotencyKey = idempotencyKey,
RequestType = requestType,
CreatedAtUtc = DateTime.UtcNow,
};
}

public void SetResponseBody(string responseBody)
{
ResponseBody = responseBody;
}
}
Loading
Loading