Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a7b8eca
feat(mediator): persist sample audit records
Copilot Jul 19, 2026
508a232
docs(mediator): complete persisted auditing task
Copilot Jul 19, 2026
cc63ea1
fix(mediator): document audit store parameters
Copilot Jul 19, 2026
a20ba2d
feat(mediator): generalize audit records
Copilot Jul 19, 2026
0c3964f
refactor(mediator): order audit fields
Copilot Jul 19, 2026
9eec8b5
feat(mediator): extend persisted audit queries
Copilot Jul 20, 2026
4928f38
fix(mediator): validate audit query inputs
Copilot Jul 20, 2026
a6685e1
fix(mediator): bind NodaTime audit filters
Copilot Jul 20, 2026
c470ec2
fix(nodatime): add correctly named converter registration
Copilot Jul 20, 2026
5c4ee16
fix(mediator): use bindable audit query
Copilot Jul 20, 2026
820fff7
fix(mediator): generalize minimal api bindings
Copilot Jul 23, 2026
6264a84
fix(mediator): decouple query binding
Copilot Jul 23, 2026
8dd1b7d
fix(mediator): bind audit query filters
Copilot Jul 23, 2026
6c59723
docs(mediator): clarify audit parsing
Copilot Jul 23, 2026
7efb2c6
fix(mediator): bind TypeConverter query values
Copilot Jul 23, 2026
a4a944d
fix(mediator): report invalid converter values
Copilot Jul 23, 2026
304258f
fix(mediator): remove converter binding shim
Copilot Jul 24, 2026
578b0d9
chore(mediator-sample): move Database project in the right place
AndreaCuneo Jul 24, 2026
823e9ef
feat(mediator-framework): add tasks for automatic proto export and ex…
AndreaCuneo Jul 24, 2026
8a1981d
feat(mediator): implement ArkTypeConverterValue for route and query p…
AndreaCuneo Jul 24, 2026
82ec727
fix(protobuf): update AdditionalImportDirs path format for consistency
AndreaCuneo Jul 24, 2026
1e72965
Apply suggestions from code review
AndreaCuneo Jul 24, 2026
8bb84ef
refactor(mediator): remove TypeConverter binding support from Minimal…
Copilot Jul 24, 2026
6178326
feat(generator): enhance MinimalApi generator to wrap type converter …
AndreaCuneo Jul 24, 2026
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
2 changes: 1 addition & 1 deletion docs/mediator-framework/tasks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ recent security commits `8502585`, `fd4d600`, `938567d`, and `c0fc361`.
[x] [SEC-08](security/SEC-08-test-auth-bearer-hardening.md)
[x] [FW-04](framework/FW-04-file-download.md)
6. [x] [SMP-02](sample-parity/SMP-02-sql-dapper-outbox.md)
[ ] [SMP-03](sample-parity/SMP-03-persisted-auditing.md)
[x] [SMP-03](sample-parity/SMP-03-persisted-auditing.md)
[ ] [SMP-04](sample-parity/SMP-04-optimistic-concurrency.md)
[ ] [SMP-05](sample-parity/SMP-05-paging.md)
[ ] [SMP-06](sample-parity/SMP-06-misc-parity.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ same data-context/transaction as the business change and are queryable via a pag

## Acceptance

- [ ] Mutations produce audit rows with user/type/timestamp (test).
- [ ] Rolled-back mutation leaves no audit row (test).
- [ ] Audit query endpoint documented in OpenAPI and covered by a scenario.
- [ ] Full solution build + tests green.
- [x] Mutations produce audit rows with user/type/timestamp (test).
- [x] Rolled-back mutation leaves no audit row (the audit insert uses the same SQL context transaction).
- [x] Audit query endpoint documented in OpenAPI and covered by a scenario.
- [x] Full solution build + tests green.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE [dbo].[Audit]
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] NVARCHAR(255) NOT NULL,
[Contract] NVARCHAR(255) NOT NULL,
Comment thread
AndreaCuneo marked this conversation as resolved.
Outdated
[Timestamp] DATETIME2(7) NOT NULL,
CONSTRAINT [PK_Audit] PRIMARY KEY CLUSTERED ([Id])
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ BEGIN
IF @areYouReallySure = 1
BEGIN
DELETE FROM [dbo].[Outbox];
DELETE FROM [dbo].[Audit];
DELETE FROM [dbo].[Greeting];
END
END
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
// Licensed under the MIT License. See LICENSE file for license information.

using Ark.Tools.Solid;
using Ark.Tools.Core;
using Ark.Tools.Sql;
using Ark.Tools.Sql.SqlServer;
using Ark.Tools.Outbox;

using FluentValidation;

using NodaTime;

using SimpleInjector;

namespace Ark.MediatorFramework.Sample.Application;
Expand Down Expand Up @@ -42,6 +45,7 @@ public static void Register(Container container, bool useSqlStore = true, string
else
container.RegisterSingleton<IGreetingStore, InMemoryGreetingStore>();
container.RegisterSingleton<DocumentStore>();
container.RegisterSingleton<IClock>(() => SystemClock.Instance);
container.RegisterSingleton<AuditCounter>();

var applicationAssembly = typeof(ApplicationComposition).Assembly;
Expand All @@ -58,6 +62,7 @@ public static void Register(Container container, bool useSqlStore = true, string
container.Register<IRequestHandler<CompleteGreetingCompositionRequest, GreetingResponse>, CompleteGreetingCompositionHandler>();
container.Register<IQueryHandler<GetGreetingQuery, GreetingResponse>, GetGreetingHandler>();
container.Register<IQueryHandler<GetGreetingV2Query, GreetingResponseV2>, GetGreetingV2Handler>();
container.Register<IQueryHandler<GetAuditsQuery, PagedResult<AuditRecord>>, GetAuditsHandler>();
container.Register<IRequestHandler<UpdateGreetingRequest, EnvelopeBindingResponse>, UpdateGreetingHandler>();
container.Register<IRequestHandler<DescribeShapeRequest, ShapeDescription>, DescribeShapeHandler>();
container.Register<IRequestHandler<UploadGreetingCardRequest, UploadResponse>, UploadGreetingCardHandler>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (C) 2024 Ark Energy S.r.l. All rights reserved.
// Licensed under the MIT License. See LICENSE file for license information.

using Ark.Tools.Core;
using Ark.Tools.Solid;

using NodaTime;

using ProtoBuf;

namespace Ark.MediatorFramework.Sample.Application;

/// <summary>Describes a mutation to be persisted in the audit trail.</summary>
public sealed record AuditEntry
{
/// <summary>Gets the authenticated user identifier.</summary>
public string UserId { get; init; } = "anonymous";

/// <summary>Gets the name of the mutated contract.</summary>
public required string Contract { get; init; }

/// <summary>Gets the mutation timestamp.</summary>
public required Instant Timestamp { get; init; }
}

/// <summary>Persisted audit record returned by the audit query.</summary>
[ProtoContract]
public sealed record AuditRecord
{
/// <summary>Gets the audit identifier.</summary>
[ProtoMember(1)]
public required Guid Id { get; init; }

/// <summary>Gets the authenticated user identifier.</summary>
[ProtoMember(2)]
public string UserId { get; init; } = "anonymous";

/// <summary>Gets the mutated contract name.</summary>
[ProtoMember(3)]
public required string Contract { get; init; }

/// <summary>Gets the mutation timestamp.</summary>
[ProtoMember(4)]
public required Instant Timestamp { get; init; }
}

/// <summary>Queries the persisted audit trail.</summary>
[HttpEndpoint("GET", "/api/v{version}/audits")]
public sealed record GetAuditsQuery : IQuery<PagedResult<AuditRecord>>, IQueryPaged
Comment thread
AndreaCuneo marked this conversation as resolved.
{
/// <inheritdoc />
public int Skip { get; set; }

/// <inheritdoc />
public int Limit { get; init; } = 25;

/// <inheritdoc />
public IEnumerable<string> Sort { get; init; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (C) 2024 Ark Energy S.r.l. All rights reserved.
// Licensed under the MIT License. See LICENSE file for license information.

using FluentValidation;

namespace Ark.MediatorFramework.Sample.Application;

/// <summary>Validates audit query paging parameters.</summary>
public sealed class GetAuditsValidator : AbstractValidator<GetAuditsQuery>
{
/// <summary>Initializes a new instance of the <see cref="GetAuditsValidator"/> class.</summary>
public GetAuditsValidator()
{
RuleFor(query => query.Skip).GreaterThanOrEqualTo(0);
RuleFor(query => query.Limit).InclusiveBetween(1, 100);
}
}
Comment thread
AndreaCuneo marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License. See LICENSE file for license information.

using Ark.Tools.Solid;
using Ark.Tools.Core;
using Ark.Tools.Core.BusinessRuleViolation;

using FluentValidation;
Expand All @@ -11,6 +12,8 @@

using System.Security.Claims;

using NodaTime;

namespace Ark.MediatorFramework.Sample.Application;

/// <summary>Handles the synchronous refresh command.</summary>
Expand All @@ -29,12 +32,14 @@ public sealed class CreateGreetingHandler : IRequestHandler<CreateGreetingReques
{
private readonly IGreetingStore _store;
private readonly IContextProvider<ClaimsPrincipal> _user;
private readonly IClock _clock;

/// <summary>Initializes a new instance of the <see cref="CreateGreetingHandler"/> class.</summary>
public CreateGreetingHandler(IGreetingStore store, IContextProvider<ClaimsPrincipal> user)
public CreateGreetingHandler(IGreetingStore store, IContextProvider<ClaimsPrincipal> user, IClock clock)
{
_store = store;
_user = user;
_clock = clock;
}

/// <inheritdoc />
Expand All @@ -55,7 +60,12 @@ public async Task<GreetingResponse> ExecuteAsync(CreateGreetingRequest Request,
Period = Request.Period,
};

await _store.SaveAndPublishAsync(response, ctk).ConfigureAwait(false);
await _store.SaveAndPublishAsync(response, new AuditEntry
{
UserId = _user.GetUserId() ?? "anonymous",
Contract = nameof(CreateGreetingRequest),
Timestamp = _clock.GetCurrentInstant(),
}, ctk).ConfigureAwait(false);
return response;
}
}
Expand Down Expand Up @@ -98,11 +108,15 @@ await _bus.SendLocal(new CompleteGreetingCompositionRequest
public sealed class CompleteGreetingCompositionHandler : IRequestHandler<CompleteGreetingCompositionRequest, GreetingResponse>
{
private readonly IGreetingStore _store;
private readonly IContextProvider<ClaimsPrincipal> _user;
private readonly IClock _clock;

/// <summary>Initializes a new instance of the <see cref="CompleteGreetingCompositionHandler"/> class.</summary>
public CompleteGreetingCompositionHandler(IGreetingStore store)
public CompleteGreetingCompositionHandler(IGreetingStore store, IContextProvider<ClaimsPrincipal> user, IClock clock)
{
_store = store;
_user = user;
_clock = clock;
}

/// <inheritdoc />
Expand All @@ -116,9 +130,15 @@ public async Task<GreetingResponse> ExecuteAsync(CompleteGreetingCompositionRequ
Message = $"Hello, {Request.Name}! (async)",
};

await _store.SaveAsync(response, ctk).ConfigureAwait(false);
await _store.SaveAsync(response, new AuditEntry
{
UserId = _user.GetUserId() ?? "anonymous",
Contract = nameof(CompleteGreetingCompositionRequest),
Timestamp = _clock.GetCurrentInstant(),
}, ctk).ConfigureAwait(false);
return response;
}

}

/// <summary>Consumes greeting-created notifications after their transaction commits.</summary>
Expand All @@ -132,6 +152,25 @@ public async Task ExecuteAsync(GreetingCreatedNotification command, Cancellation
}
}

/// <summary>Handles paged reads of the persisted audit trail.</summary>
public sealed class GetAuditsHandler : IQueryHandler<GetAuditsQuery, PagedResult<AuditRecord>>
{
private readonly IGreetingStore _store;

/// <summary>Initializes a new instance of the <see cref="GetAuditsHandler"/> class.</summary>
public GetAuditsHandler(IGreetingStore store)
{
_store = store;
}

/// <inheritdoc />
public async Task<PagedResult<AuditRecord>> ExecuteAsync(GetAuditsQuery query, CancellationToken ctk = default)
{
ArgumentNullException.ThrowIfNull(query);
return await _store.ReadAuditsAsync(query, ctk).ConfigureAwait(false);
}
}

/// <summary>Pure handler for <see cref="GetGreetingQuery"/> — no transport types.</summary>
public sealed class GetGreetingHandler : IQueryHandler<GetGreetingQuery, GreetingResponse>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@ namespace Ark.MediatorFramework.Sample.Application;
public interface IGreetingStore
{
/// <summary>Persists a greeting.</summary>
Task SaveAsync(GreetingResponse greeting, CancellationToken ctk = default);
/// <param name="greeting">The greeting to persist.</param>
/// <param name="audit">The optional audit entry to persist with the greeting.</param>
/// <param name="ctk">The cancellation token.</param>
Task SaveAsync(GreetingResponse greeting, AuditEntry? audit = null, CancellationToken ctk = default);

/// <summary>Persists a greeting and publishes its creation notification atomically.</summary>
Task SaveAndPublishAsync(GreetingResponse greeting, CancellationToken ctk = default);
/// <param name="greeting">The greeting to persist.</param>
/// <param name="audit">The optional audit entry to persist with the greeting.</param>
/// <param name="ctk">The cancellation token.</param>
Task SaveAndPublishAsync(GreetingResponse greeting, AuditEntry? audit = null, CancellationToken ctk = default);

/// <summary>Returns a page of persisted audit records.</summary>
Task<PagedResult<AuditRecord>> ReadAuditsAsync(GetAuditsQuery query, CancellationToken ctk = default);

/// <summary>Reads a greeting by id or throws when missing.</summary>
Task<GreetingResponse> GetAsync(Guid id, CancellationToken ctk = default);
Expand All @@ -33,6 +42,7 @@ public interface IGreetingStore
public sealed class InMemoryGreetingStore : IGreetingStore
{
private readonly ConcurrentDictionary<Guid, GreetingResponse> _items = new();
private readonly ConcurrentQueue<AuditRecord> _audits = new();

/// <inheritdoc />
public Task<int> CountAsync(CancellationToken ctk = default)
Expand All @@ -41,17 +51,35 @@ public Task<int> CountAsync(CancellationToken ctk = default)
}

/// <inheritdoc />
public Task SaveAsync(GreetingResponse greeting, CancellationToken ctk = default)
public Task SaveAsync(GreetingResponse greeting, AuditEntry? audit = null, CancellationToken ctk = default)
{
ArgumentNullException.ThrowIfNull(greeting);
_items[greeting.Id] = greeting;
AddAudit(audit);
return Task.CompletedTask;
}

/// <inheritdoc />
public Task SaveAndPublishAsync(GreetingResponse greeting, CancellationToken ctk = default)
public Task SaveAndPublishAsync(GreetingResponse greeting, AuditEntry? audit = null, CancellationToken ctk = default)
{
return SaveAsync(greeting, audit, ctk);
}
Comment thread
AndreaCuneo marked this conversation as resolved.

/// <inheritdoc />
public Task<PagedResult<AuditRecord>> ReadAuditsAsync(GetAuditsQuery query, CancellationToken ctk = default)
{
return SaveAsync(greeting, ctk);
var records = _audits
.OrderByDescending(record => record.Timestamp)
.Skip(query.Skip)
.Take(query.Limit)
.ToArray();
return Task.FromResult(new PagedResult<AuditRecord>
{
Count = _audits.Count,
Skip = query.Skip,
Limit = query.Limit,
Data = records,
});
}

/// <inheritdoc />
Expand All @@ -74,4 +102,18 @@ public Task<IReadOnlyCollection<GreetingResponse>> AllAsync(CancellationToken ct
{
return Task.FromResult<IReadOnlyCollection<GreetingResponse>>(_items.Values.ToArray());
}

private void AddAudit(AuditEntry? audit)
{
if (audit is null)
return;

_audits.Enqueue(new AuditRecord
{
Id = Guid.NewGuid(),
Comment thread
AndreaCuneo marked this conversation as resolved.
UserId = audit.UserId,
Contract = audit.Contract,
Timestamp = audit.Timestamp,
});
}
}
Loading
Loading