using Google.Protobuf.Collections; using Grpc.Core; using Grpc.Core.Interceptors; using MariesWonderland.Data; using MariesWonderland.Extensions; using MariesWonderland.Proto.Data; using MariesWonderland.Proto.User; using System.Collections.Concurrent; using System.Reflection; namespace MariesWonderland.Interceptors; /// /// gRPC interceptor that automatically computes and attaches DiffUserData to every response /// that declares the field. Takes a before-snapshot of the user's database prior to service execution, /// then computes the delta after the service mutates state. This means individual services never need /// to populate DiffUserData manually. /// Special-cases the RegisterUser flow where no userId is available in request headers: extracts the /// newly assigned userId from the response to perform a full-state diff against an empty baseline. /// public class DiffInterceptor(UserDataStore store, ILogger logger) : Interceptor { private static readonly ConcurrentDictionary PropertyCache = new(); private static readonly ConcurrentDictionary UserIdPropertyCache = new(); /// /// Intercepts every unary gRPC call. If the response type has a DiffUserData map field, /// snapshots user state before execution, runs the handler, then populates the diff. /// public override async Task UnaryServerHandler( TRequest request, ServerCallContext context, UnaryServerMethod continuation) { long userId = context.GetUserId(); PropertyInfo? diffProp = PropertyCache.GetOrAdd(typeof(TResponse), static t => t.GetProperty(nameof(AuthUserResponse.DiffUserData))); // Response type has no DiffUserData property — pass through without snapshotting if (diffProp is null) { return await continuation(request, context); } if (userId != 0) { // Normal path: userId is known from request headers — snapshot before, diff after Dictionary before = store.TryGet(userId, out DarkUserMemoryDatabase userDb) ? UserDataDiffBuilder.Snapshot(userDb) : []; TResponse response = await continuation(request, context); try { if (diffProp.GetValue(response) is MapField mapField) { Dictionary delta = UserDataDiffBuilder.Delta(before, userDb); foreach ((string key, DiffData value) in delta) { mapField[key] = value; } if (delta.Count > 0) { string[] names = [.. delta.Keys]; Array.Sort(names, StringComparer.Ordinal); context.ResponseTrailers.Add("x-apb-update-user-data-names", string.Join(",", names)); } } } catch (Exception ex) { logger.LogError(ex, "DiffInterceptor failed to populate DiffUserData on {Method}", context.Method); } return response; } else { // RegisterUser path: userId=0 in headers because the user doesn't exist yet. // Run the handler first, then extract the newly assigned userId from the response // and diff against an empty baseline to send all initial state to the client. TResponse response = await continuation(request, context); try { PropertyInfo? userIdProp = UserIdPropertyCache.GetOrAdd(typeof(TResponse), static t => t.GetProperty("UserId")); if (userIdProp?.GetValue(response) is long newUserId && newUserId != 0) { if (store.TryGet(newUserId, out DarkUserMemoryDatabase userDb)) { // Only populate if the map is empty (service didn't set it manually) if (diffProp.GetValue(response) is MapField mapField && mapField.Count == 0) { foreach ((string key, DiffData value) in UserDataDiffBuilder.Delta([], userDb)) { mapField[key] = value; } } } } } catch (Exception ex) { logger.LogError(ex, "DiffInterceptor failed to populate DiffUserData on {Method}", context.Method); } return response; } } }