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
9 changes: 5 additions & 4 deletions docs/mediator-framework/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,8 +630,9 @@ interceptor, upload adapter, proto export) are in `src/` packages.
follows the "Next implementation order" in `tasks.md` (Phase 8 wire-shape
and packaging steps before the remaining Phase 7 behavioral steps), with the
full-solution build gate on every step.
- **Phase 9** designs the preview follow-ups tracked as Epic 11: Rebus owner
routing, sample STJ source generation, authenticated OpenAPI UIs and gRPCui.
- **Phase 9** implements the first preview follow-up (T11.4 Rebus owner routing);
the remaining Epic 11 work is sample STJ source generation, authenticated
OpenAPI UIs and gRPCui.

The first build attempt on a fresh checkout failed because `--no-restore` was
used before assets existed. The verified sequence is `dotnet restore
Expand All @@ -640,7 +641,7 @@ then `dotnet build Ark.Tools.slnx --configuration Debug --no-restore`.

## Phase 9 — Preview follow-ups

### Step 9.1 — Rebus owner routing (T11.4)
### Step 9.1 — Rebus owner routing (T11.4)

1. Add an optional `OwnerQueue` named argument to `RebusMessageAttribute`; reject
null, empty or whitespace values when supplied and document that it identifies
Expand All @@ -655,7 +656,7 @@ then `dotnet build Ark.Tools.slnx --configuration Debug --no-restore`.
5. Add generator snapshots plus runtime routing tests under
`tests/Ark.Tools.MediatorFramework.Tests`; retain only behavioral workflow
coverage in the sample tests.
6. Restore locked dependencies, build the full solution, and run all tests.
6. Restore locked dependencies, build the full solution, and run all tests.

### Step 9.2 — STJ source generation in the sample (T11.2)

Expand Down
9 changes: 4 additions & 5 deletions docs/mediator-framework/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ Debug`) before the task is marked complete.
can be enabled as a compatibility option; OpenAPI contains tested OAuth2
authorization-code/PKCE and OpenID Connect schemes; no browser client secret
is configured.
- [ ] **T11.4** Generate Rebus owner routing.
- [x] **T11.4** Generate Rebus owner routing.
- *Accept:* `RebusMessageAttribute` accepts an optional owner queue; the
generator emits type-based `Map<TMessage>(queue)` routing configuration;
invalid/blank queues and conflicting mappings produce diagnostics; tests
Expand All @@ -339,10 +339,9 @@ T9.1–T9.6 are complete. Wire-shape and packaging refinements run **before**
the behavioral-test epic so the Reqnroll scenarios are written once against
the final contracts:

1. **T11.4** Rebus owner routing (public contract and generator first).
2. **T11.2** Sample STJ source-generated metadata.
3. **T11.3** Scalar plus optional Swagger UI with OAuth2/OIDC.
4. **T11.1** gRPCui development tooling.
1. **T11.2** Sample STJ source-generated metadata.
2. **T11.3** Scalar plus optional Swagger UI with OAuth2/OIDC.
3. **T11.1** gRPCui development tooling.

## Status legend

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public sealed record DeadLetterAck;
/// demonstrating the Rebus dead-letter behavior: after the delivery attempts are exhausted the message is
/// forwarded to the error queue with the exception serialized into its headers.
/// </summary>
[RebusMessage]
[RebusMessage(OwnerQueue = "ark.mediator.sample")]
public sealed record FailingRebusRequest : IRequest<DeadLetterAck>
{
/// <summary>Gets the reason surfaced in the thrown exception.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public sealed record ComposeGreetingResponse
}

/// <summary>Rebus-only request completed asynchronously by the composition workflow.</summary>
[RebusMessage]
[RebusMessage(OwnerQueue = "ark.mediator.sample")]
public sealed record CompleteGreetingCompositionRequest : IRequest<GreetingResponse>
{
/// <summary>Gets the greeting identifier.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public static Container BuildContainer(InMemNetwork network, bool useProtobufReb
container.ConfigureRebus(cfg =>
{
cfg.Transport(t => t.UseInMemoryTransport(network, "ark.mediator.sample"));
cfg.Routing(ArkGeneratedEndpoints.ConfigureArkRebusRouting);

if (useProtobufRebus)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ namespace Ark.MediatorFramework.Generators
public sealed class ArkRebusEndpointGenerator : IIncrementalGenerator
{
private const string RebusMessageAttribute = "Ark.MediatorFramework.RebusMessageAttribute";

private static readonly DiagnosticDescriptor InvalidOwnerQueue = new(
"ARKMF004", "Invalid Rebus owner queue",
"The Rebus owner queue for '{0}' must not be blank", "Rebus",
DiagnosticSeverity.Error, isEnabledByDefault: true);
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
Expand All @@ -50,11 +53,12 @@ private static ImmutableArray<EndpointModel> GetEndpoints(Compilation compilatio
foreach (var type in _allTypes(assembly.GlobalNamespace))
{
var attrs = type.GetAttributes();
var rebus = attrs.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, rebusAttr));
var rebus = attrs.FirstOrDefault(
a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, rebusAttr));
if (rebus is null)
continue;

var model = Extract(type);
var model = Extract(type, rebus);
if (model is not null)
builder.Add(model.Value);
}
Expand Down Expand Up @@ -95,7 +99,7 @@ private static IEnumerable<INamedTypeSymbol> _allTypes(INamespaceSymbol ns)
}
}

private static EndpointModel? Extract(INamedTypeSymbol type)
private static EndpointModel? Extract(INamedTypeSymbol type, AttributeData rebusAttribute)
{
// Rebus messages are dispatched via IRequestHandler; queries (reads) are not
// meaningful as bus messages.
Expand All @@ -105,21 +109,52 @@ private static IEnumerable<INamedTypeSymbol> _allTypes(INamespaceSymbol ns)
if (def == "global::Ark.Tools.Solid.IRequest<TResponse>")
{
var response = iface.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var diagnostics = new List<DiagnosticInfo>();
var ownerQueue = GetOwnerQueue(rebusAttribute);
if (ownerQueue is null && HasOwnerQueueArgument(rebusAttribute))
{
diagnostics.Add(new DiagnosticInfo(
InvalidOwnerQueue,
type.Name,
GetLocation(rebusAttribute)));
}

return new EndpointModel(
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
type.Name,
response);
response,
ownerQueue is not null && diagnostics.Count == 0 ? ownerQueue : null,
diagnostics);
}
}

return null;
}

private static string? GetOwnerQueue(AttributeData attribute)
{
var argument = attribute.NamedArguments.FirstOrDefault(pair => pair.Key == "OwnerQueue");
var ownerQueue = argument.Value.Value as string;
return string.IsNullOrWhiteSpace(ownerQueue) ? null : ownerQueue;
}

private static bool HasOwnerQueueArgument(AttributeData attribute)
=> attribute.NamedArguments.Any(pair => pair.Key == "OwnerQueue");

private static Location GetLocation(AttributeData attribute)
=> attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None;

private static void Emit(SourceProductionContext spc, ImmutableArray<EndpointModel> items)
{
if (items.IsDefaultOrEmpty)
return;

foreach (var item in items)
{
foreach (var diagnostic in item.Diagnostics)
spc.ReportDiagnostic(Diagnostic.Create(diagnostic.Descriptor, diagnostic.Location, diagnostic.Arguments));
}

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
Expand All @@ -141,6 +176,16 @@ private static void Emit(SourceProductionContext spc, ImmutableArray<EndpointMod
}
}
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>Registers generated owner queues with Rebus type-based routing.</summary>");
sb.AppendLine(" public static void ConfigureArkRebusRouting(global::Rebus.Config.StandardConfigurer<global::Rebus.Routing.IRouter> routing)");
sb.AppendLine(" {");
sb.AppendLine(" var typeBased = global::Rebus.Routing.TypeBased.TypeBasedRouterConfigurationExtensions.TypeBased(routing);");
foreach (var e in items.Where(item => item.OwnerQueue is not null))
{
sb.AppendLine(" typeBased.Map<" + e.TypeFullName + ">(" + StringLiteral(e.OwnerQueue!) + ");");
}
sb.AppendLine(" }");

// Generated Rebus IHandleMessages<T> wrappers.
if (!items.IsDefaultOrEmpty)
Expand Down Expand Up @@ -171,16 +216,42 @@ private static void Emit(SourceProductionContext spc, ImmutableArray<EndpointMod

private readonly struct EndpointModel
{
public EndpointModel(string typeFullName, string typeName, string response)
public EndpointModel(
string typeFullName,
string typeName,
string response,
string? ownerQueue,
IReadOnlyList<DiagnosticInfo> diagnostics)
{
TypeFullName = typeFullName;
TypeName = typeName;
Response = response;
OwnerQueue = ownerQueue;
Diagnostics = diagnostics;
}

public string TypeFullName { get; }
public string TypeName { get; }
public string Response { get; }
public string? OwnerQueue { get; }
public IReadOnlyList<DiagnosticInfo> Diagnostics { get; }
}

private readonly struct DiagnosticInfo
{
public DiagnosticInfo(DiagnosticDescriptor descriptor, string typeName, Location location, string? queues = null)
{
Descriptor = descriptor;
Location = location;
Arguments = queues is null ? [typeName] : [typeName, queues];
}

public DiagnosticDescriptor Descriptor { get; }
public Location Location { get; }
public object[] Arguments { get; }
}

private static string StringLiteral(string value)
=> "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,19 @@ namespace Ark.MediatorFramework;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class RebusMessageAttribute : Attribute
{
private string? _ownerQueue;

/// <summary>Gets or sets the queue that owns this message type.</summary>
/// <exception cref="ArgumentException">Thrown when the value is blank.</exception>
public string? OwnerQueue
{
get => _ownerQueue;
set
{
if (value is not null && string.IsNullOrWhiteSpace(value))
throw new ArgumentException("The owner queue cannot be blank.", nameof(value));

_ownerQueue = value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<ProjectReference Include="../../src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/Ark.Tools.MediatorFramework.MinimalApi.Generators.csproj" />
<ProjectReference Include="../../src/mediator-framework/Ark.Tools.MediatorFramework.Grpc/Ark.Tools.MediatorFramework.Grpc.csproj" />
<ProjectReference Include="../../src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/Ark.Tools.MediatorFramework.Grpc.Generators.csproj" />
<ProjectReference Include="../../src/mediator-framework/Ark.Tools.MediatorFramework.Rebus/Ark.Tools.MediatorFramework.Rebus.csproj" />
<ProjectReference Include="../../src/mediator-framework/Ark.Tools.MediatorFramework.Rebus.Generators/Ark.Tools.MediatorFramework.Rebus.Generators.csproj" />
<ProjectReference Include="../../src/common/Ark.Tools.Solid/Ark.Tools.Solid.csproj" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public void MinimalApiGeneratorExpandsVersionedRoutes()
public sealed class GetGreeting : IQuery<string>
{
}

""");

generated.Should().Contain("MapGet(\"/api/v1/greetings/{id}\"");
Expand All @@ -55,6 +56,49 @@ public sealed class GetGreeting : IQuery<string>
generated.Should().Contain("WithGroupName(\"v1\")");
}

[TestMethod]
public void RebusGeneratorEmitsOwnerQueueRouting()
{
var generated = RunGenerator<ArkRebusEndpointGenerator>(
"""
using Ark.MediatorFramework;
using Ark.Tools.Solid;
[RebusMessage(OwnerQueue = "orders")]
public sealed class CreateOrder : IRequest<string>
{
}
""");

generated.Should().Contain("ConfigureArkRebusRouting");
generated.Should().Contain("Map<global::CreateOrder>(\"orders\")");
}

[TestMethod]
public void RebusMessageAllowsOnlyOneDeclaration()
{
var usage = (AttributeUsageAttribute)typeof(RebusMessageAttribute)
.GetCustomAttributes(typeof(AttributeUsageAttribute), inherit: false)
.Single();

usage.AllowMultiple.Should().BeFalse();
}

[TestMethod]
public void RebusGeneratorReportsInvalidOwnerQueue()
{
var result = RunGeneratorResult<ArkRebusEndpointGenerator>(
"""
using Ark.MediatorFramework;
using Ark.Tools.Solid;
[RebusMessage(OwnerQueue = " ")]
public sealed class CreateOrder : IRequest<string>
{
}
""");

result.Diagnostics.Should().Contain(diagnostic => diagnostic.Id == "ARKMF004");
}

[TestMethod]
public void GrpcGeneratorEmitsVersionedServiceMethodSets()
{
Expand Down Expand Up @@ -209,6 +253,7 @@ private static (string Generated, ImmutableArray<Diagnostic> Diagnostics) RunGen
.Concat(
[
MetadataReference.CreateFromFile(typeof(HttpEndpointAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(RebusMessageAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IRequest<>).Assembly.Location),
MetadataReference.CreateFromFile(typeof(ProtoBuf.ProtoContractAttribute).Assembly.Location),
]);
Expand Down
Loading
Loading