From 55129fc55e322e0bff1b5d686661fe52bfba15fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:05:56 +0000 Subject: [PATCH 1/3] feat(mediator): name generated API operations Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com> --- docs/mediator-framework/design.md | 5 +- docs/mediator-framework/progress/tasks.md | 2 +- .../progress/tasks/README.md | 2 +- .../NET-06-openapi-tags-operation-names.md | 16 ++-- .../GreetingContracts.cs | 1 + .../GrpcEndpointGenerator.cs | 21 +++++- .../MinimalApiEndpointGenerator.cs | 75 +++++++++++++++++-- .../ApiTagAttribute.cs | 19 +++++ .../GeneratorSnapshotTests.cs | 56 ++++++++++++++ 9 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 src/mediator-framework/Ark.Tools.MediatorFramework/ApiTagAttribute.cs diff --git a/docs/mediator-framework/design.md b/docs/mediator-framework/design.md index d7dbe066a..5ae6ab8e4 100644 --- a/docs/mediator-framework/design.md +++ b/docs/mediator-framework/design.md @@ -68,6 +68,8 @@ request type. Each transport is opt-in and declared independently: code-first gRPC method; `[ServiceGroup("Orders")]` groups the service. - `[RebusMessage(OwnerQueue = "orders")]` — expose as a Rebus message and, when an owner is declared, contribute a type-based outbound route. +- `[ApiTag("Orders")]` — override the namespace-derived OpenAPI tag and the + gRPC service-group fallback for the contract. Generated HTTP endpoints require authorization by default. Set `Policy` on `[HttpEndpoint]` to select a named policy, or set `AllowAnonymous = true` for @@ -80,7 +82,8 @@ Generator contract errors are reported at the transport attribute location: `ARKMF010` (unknown HTTP verb), `ARKMF011` (unsupported handler kind), `ARKMF012` (missing route property), `ARKMF013` (invalid HTTP contract shape), `ARKMF014` (duplicate Rebus registration), and `ARKMF015` (conflicting Rebus -owner queue). Invalid contracts are not emitted. +owner queue), and `ARKMF016` (duplicate OpenAPI operation name within an API +version). Invalid contracts are not emitted. HTTP binding treats the request as an **envelope** whose members may combine route, query and body sources (see *HTTP binding* below). These attributes are diff --git a/docs/mediator-framework/progress/tasks.md b/docs/mediator-framework/progress/tasks.md index 7b1738aa0..4fa61750f 100644 --- a/docs/mediator-framework/progress/tasks.md +++ b/docs/mediator-framework/progress/tasks.md @@ -339,7 +339,7 @@ Each item has a self-contained task document under [`tasks/`](tasks/README.md); the acceptance criteria live there and are the authoritative ones. -- [ ] **T12.1** OpenAPI tags and operation names from the contract +- [x] **T12.1** OpenAPI tags and operation names from the contract ([NET-06](tasks/aspnetcore/NET-06-openapi-tags-operation-names.md)). - *Accept:* every generated operation carries a namespace-derived tag (overridable with `[ApiTag]`) and a unique, contract-derived `operationId`; diff --git a/docs/mediator-framework/progress/tasks/README.md b/docs/mediator-framework/progress/tasks/README.md index 8b407c7ab..7fdbf3a24 100644 --- a/docs/mediator-framework/progress/tasks/README.md +++ b/docs/mediator-framework/progress/tasks/README.md @@ -110,7 +110,7 @@ recent security commits `8502585`, `fd4d600`, `938567d`, and `c0fc361`. [ ] [SMP-06](sample-parity/SMP-06-misc-parity.md) [ ] [NET-01](aspnetcore/NET-01-openapi-xml-docs.md) 7. Scope extension (D8) — wire-shape items first, documentation last: - [ ] [NET-06](aspnetcore/NET-06-openapi-tags-operation-names.md) + [x] [NET-06](aspnetcore/NET-06-openapi-tags-operation-names.md) [ ] [FW-05](framework/FW-05-standard-problem-responses.md) [ ] [FW-06](framework/FW-06-async-enumerable-streaming.md) [ ] [FW-07](framework/FW-07-multifile-uploads.md) diff --git a/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md b/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md index cbf8e373f..a61fbe78d 100644 --- a/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md +++ b/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md @@ -72,11 +72,11 @@ See `docs/mediator-framework/design.md` → *OpenAPI operation grouping, naming ## Acceptance -- [ ] `ApiTagAttribute` exists in `Ark.Tools.MediatorFramework` with XML docs. -- [ ] Generated endpoints emit `.WithTags(...)` and `.WithName(...)`; snapshots updated. -- [ ] `operationId` is unique within each versioned document (tested). -- [ ] Duplicate operation names produce a documented generator diagnostic (tested). -- [ ] gRPC service grouping falls back to `[ApiTag]`; explicit `[ServiceGroup]` still wins (tested). -- [ ] `design.md` documents the defaults, the override and the new diagnostic id. -- [ ] `dotnet build Ark.Tools.slnx --configuration Debug` succeeds with zero warnings. -- [ ] `dotnet test Ark.Tools.slnx --no-build --configuration Debug --minimum-expected-tests 1` passes. +- [x] `ApiTagAttribute` exists in `Ark.Tools.MediatorFramework` with XML docs. +- [x] Generated endpoints emit `.WithTags(...)` and `.WithName(...)`; snapshots updated. +- [x] `operationId` is unique within each versioned document (tested). +- [x] Duplicate operation names produce a documented generator diagnostic (tested). +- [x] gRPC service grouping falls back to `[ApiTag]`; explicit `[ServiceGroup]` still wins (tested). +- [x] `design.md` documents the defaults, the override and the new diagnostic id. +- [x] `dotnet build Ark.Tools.slnx --configuration Debug` succeeds with zero warnings. +- [x] `dotnet test Ark.Tools.slnx --no-build --configuration Debug --minimum-expected-tests 1` passes. diff --git a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs index 6978e8875..1a2ba4296 100644 --- a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs +++ b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs @@ -97,6 +97,7 @@ public sealed record CreateGreetingRequest : IRequest } /// HTTP-only request that publishes work to Rebus and returns immediately. +[ApiTag("Greetings")] [HttpEndpoint("POST", "/api/v{version}/greetings/compose")] public sealed record ComposeGreetingRequest : IRequest { diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs index 0ea4bda5b..032be8b5a 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs @@ -27,6 +27,7 @@ public sealed class ArkGrpcEndpointGenerator : IIncrementalGenerator { private const string GrpcMethodAttribute = "Ark.MediatorFramework.GrpcMethodAttribute"; private const string ServiceGroupAttribute = "Ark.MediatorFramework.ServiceGroupAttribute"; + private const string ApiTagAttribute = "Ark.MediatorFramework.ApiTagAttribute"; private const string ServerSetAttribute = "Ark.MediatorFramework.ServerSetAttribute"; private const string ArkAttachment = "Ark.MediatorFramework.IArkAttachment"; @@ -62,12 +63,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var type = (INamedTypeSymbol)context.TargetSymbol; var grpc = context.Attributes[0]; var serviceGroupAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName(ServiceGroupAttribute); + var apiTagAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName(ApiTagAttribute); var attachmentType = context.SemanticModel.Compilation.GetTypeByMetadataName(ArkAttachment); var serviceGroup = serviceGroupAttribute is null ? null : type.GetAttributes().FirstOrDefault( attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, serviceGroupAttribute)); - return Extract(type, grpc, serviceGroup, attachmentType); + return Extract(type, grpc, serviceGroup, apiTagAttribute, attachmentType); } private static string? GetAssemblyName(GeneratorSyntaxContext context, string methodName) @@ -88,6 +90,7 @@ private static ImmutableArray GetReferencedEndpoints( { var grpcAttr = compilation.GetTypeByMetadataName(GrpcMethodAttribute); var serviceGroupAttr = compilation.GetTypeByMetadataName(ServiceGroupAttribute); + var apiTagAttr = compilation.GetTypeByMetadataName(ApiTagAttribute); var attachmentType = compilation.GetTypeByMetadataName(ArkAttachment); if (grpcAttr is null) return ImmutableArray.Empty; @@ -106,7 +109,7 @@ private static ImmutableArray GetReferencedEndpoints( continue; var serviceGroup = attrs.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, serviceGroupAttr)); - var model = Extract(type, grpc, serviceGroup, attachmentType); + var model = Extract(type, grpc, serviceGroup, apiTagAttr, attachmentType); if (model is not null) builder.Add(model.Value); } @@ -157,6 +160,7 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) INamedTypeSymbol type, AttributeData grpc, AttributeData? serviceGroup, + INamedTypeSymbol? apiTagAttribute, INamedTypeSymbol? attachmentType) { string? response = null; @@ -199,7 +203,18 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) var grpcMethod = grpc.ConstructorArguments.FirstOrDefault().Value as string ?? type.Name; var grpcIntroducedIn = NamedInt(grpc, "IntroducedIn", 1); var grpcRetiredIn = NamedInt(grpc, "RetiredIn", 0); - var group = serviceGroup?.ConstructorArguments.FirstOrDefault().Value as string ?? "Ark"; + var apiTag = apiTagAttribute is null + ? null + : type.GetAttributes() + .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, apiTagAttribute)) + .Select(attribute => attribute.ConstructorArguments.FirstOrDefault().Value as string) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + var defaultGroup = type.ContainingNamespace is { IsGlobalNamespace: false } ns + ? ns.ToDisplayString().Split('.').Last() + : "Ark"; + var group = serviceGroup?.ConstructorArguments.FirstOrDefault().Value as string + ?? apiTag + ?? defaultGroup; return new EndpointModel( type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs index 7ba17f4a9..e59cd6f8b 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs @@ -29,6 +29,7 @@ public sealed class ArkMinimalApiEndpointGenerator : IIncrementalGenerator private const string BindFromQueryAttribute = "Ark.MediatorFramework.BindFromQueryAttribute"; private const string ServerSetAttribute = "Ark.MediatorFramework.ServerSetAttribute"; private const string RebusMessageAttribute = "Ark.MediatorFramework.RebusMessageAttribute"; + private const string ApiTagAttribute = "Ark.MediatorFramework.ApiTagAttribute"; private const string ArkAttachment = "Ark.MediatorFramework.IArkAttachment"; private const string Enumerable = "System.Collections.Generic.IEnumerable`1"; private static readonly DiagnosticDescriptor MultipleAttachments = new DiagnosticDescriptor( @@ -52,6 +53,13 @@ public sealed class ArkMinimalApiEndpointGenerator : IIncrementalGenerator "Ark.MediatorFramework", DiagnosticSeverity.Warning, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor DuplicateOperationName = new DiagnosticDescriptor( + "ARKMF016", + "Duplicate operation name", + "HTTP endpoints '{0}' and '{1}' resolve to the same operation name '{2}' in API version {3}", + "Ark.MediatorFramework", + DiagnosticSeverity.Error, + isEnabledByDefault: true); /// public void Initialize(IncrementalGeneratorInitializationContext context) @@ -92,6 +100,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) compilation.GetTypeByMetadataName(ServerSetAttribute), compilation.GetTypeByMetadataName(ArkAttachment), compilation.GetTypeByMetadataName(RebusMessageAttribute), + compilation.GetTypeByMetadataName(ApiTagAttribute), compilation.GetTypeByMetadataName(Enumerable)); } @@ -119,6 +128,7 @@ private static ImmutableArray GetReferencedEndpoints( var bindFromQueryAttr = compilation.GetTypeByMetadataName(BindFromQueryAttribute); var serverSetAttr = compilation.GetTypeByMetadataName(ServerSetAttribute); var rebusMessageAttr = compilation.GetTypeByMetadataName(RebusMessageAttribute); + var apiTagAttr = compilation.GetTypeByMetadataName(ApiTagAttribute); var attachmentType = compilation.GetTypeByMetadataName(ArkAttachment); var enumerableType = compilation.GetTypeByMetadataName(Enumerable); var builder = ImmutableArray.CreateBuilder(); @@ -140,6 +150,7 @@ private static ImmutableArray GetReferencedEndpoints( serverSetAttr, attachmentType, rebusMessageAttr, + apiTagAttr, enumerableType); if (model is not null) builder.Add(model.Value); @@ -186,6 +197,7 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) INamedTypeSymbol? serverSetAttr, INamedTypeSymbol? attachmentType, INamedTypeSymbol? rebusMessageAttr, + INamedTypeSymbol? apiTagAttr, INamedTypeSymbol? enumerableType) { string? response = null; @@ -252,6 +264,15 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, rebusMessageAttr)) .Select(attribute => NamedString(attribute, "OwnerQueue")) .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + var apiTag = apiTagAttr is null + ? null + : type.GetAttributes() + .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, apiTagAttr)) + .Select(attribute => attribute.ConstructorArguments.FirstOrDefault().Value as string) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + var defaultTag = type.ContainingNamespace is { IsGlobalNamespace: false } ns + ? ns.ToDisplayString().Split('.').Last() + : "Ark"; var routeNames = new HashSet( Regex.Matches(template!, "\\{([^}:]+)(?::[^}]+)?\\}") .Cast() @@ -292,6 +313,7 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) return new EndpointModel( type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), type.Name, + apiTag ?? defaultTag, verb, template!, response ?? "global::System.Void", @@ -417,6 +439,27 @@ private static void Emit(SourceProductionContext spc, ImmutableArray Math.Max(x.HttpIntroducedIn, x.HttpRetiredIn > 0 ? x.HttpRetiredIn - 1 : 1)); + for (var version = 1; version <= maxVersion; version++) + { + foreach (var duplicate in items + .Where(endpoint => endpoint.IsValid && ActiveVersions(endpoint, maxVersion).Contains(version)) + .GroupBy(endpoint => OperationName(endpoint, version)) + .Where(group => group.Count() > 1)) + { + var endpoints = duplicate.ToArray(); + for (var index = 0; index < endpoints.Length; index++) + { + var other = endpoints[(index + 1) % endpoints.Length]; + spc.ReportDiagnostic(Diagnostic.Create( + DuplicateOperationName, + endpoints[index].Location, + endpoints[index].TypeName, + other.TypeName, + duplicate.Key, + version)); + } + } + } foreach (var e in items) { foreach (var diagnostic in e.Diagnostics) @@ -510,7 +553,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(\"application/json\", \"application/x-msgpack\").Produces<" + e.Response + ">(" + SuccessStatusCode(e) + ", \"application/json\", \"application/x-msgpack\").Produces(" + NullResultStatusCode(e) - + ").WithGroupName(" + Literal("v" + version) + ")" + AuthorizationMetadata(e) + ";"); + + ")" + OpenApiMetadata(e, version) + AuthorizationMetadata(e) + ";"); continue; } sb.AppendLine(" group." + map + "(" + Literal(template) + ", static async ("); @@ -556,7 +599,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(" + SuccessStatusCode(e) + ").Produces(" + NullResultStatusCode(e) - + ").WithGroupName(" + Literal("v" + version) + ")" + AuthorizationMetadata(e) + ";"); + + ")" + OpenApiMetadata(e, version) + AuthorizationMetadata(e) + ";"); } } } @@ -726,8 +769,7 @@ private static void EmitMultipartEndpoint( sb.AppendLine(" if (result is null)"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + NullResult(endpoint) + ";"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + SuccessResult(endpoint) + ";"); - sb.Append(" }).Accepts(\"multipart/form-data\").WithGroupName(") - .Append(Literal("v" + version)).Append(')').Append(MultipartMetadata(endpoint)) + sb.Append(" }).Accepts(\"multipart/form-data\")").Append(OpenApiMetadata(endpoint, version)).Append(MultipartMetadata(endpoint)) .Append(".Produces<").Append(endpoint.Response).Append(">(").Append(SuccessStatusCode(endpoint)) .Append(").Produces(").Append(NullResultStatusCode(endpoint)).Append(')') .Append(AuthorizationMetadata(endpoint)).AppendLine(";"); @@ -769,8 +811,8 @@ private static void EmitDownloadEndpoint( sb.AppendLine(" if (result is null)"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.TypedResults.NotFound();"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.File(result.OpenRead(), result.ContentType, fileDownloadName: global::Ark.MediatorFramework.ArkAttachmentName.Sanitize(result.Name));"); - sb.Append(" }).Produces(200, contentType: \"application/octet-stream\").Produces(404).WithGroupName(") - .Append(Literal("v" + version)).Append(')').Append(AuthorizationMetadata(endpoint)).AppendLine(";"); + sb.Append(" }).Produces(200, contentType: \"application/octet-stream\").Produces(404)") + .Append(OpenApiMetadata(endpoint, version)).Append(AuthorizationMetadata(endpoint)).AppendLine(";"); } private static void EmitCommandEndpoint( @@ -830,7 +872,7 @@ private static void EmitCommandEndpoint( sb.AppendLine(" await handler.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" return global::Microsoft.AspNetCore.Http.TypedResults.NoContent();"); } - sb.Append(" }).WithGroupName(").Append(Literal("v" + version)).Append(')'); + sb.Append(" })").Append(OpenApiMetadata(endpoint, version)); sb.Append(endpoint.OwnerQueue is null ? ".Produces(204)" : ".Produces(202)"); sb.Append(AuthorizationMetadata(endpoint)).AppendLine(";"); } @@ -879,6 +921,21 @@ private static string AuthorizationMetadata(EndpointModel endpoint) : ".RequireAuthorization(" + Literal(endpoint.Policy!) + ")"; } + private static string OpenApiMetadata(EndpointModel endpoint, int version) + => ".WithGroupName(" + Literal("v" + version) + + ").WithTags(" + Literal(endpoint.ApiTag) + + ").WithName(" + Literal(OperationName(endpoint, version)) + ")"; + + private static string OperationName(EndpointModel endpoint, int version) + => ActiveVersionCount(endpoint) > 1 + ? endpoint.TypeName + "_v" + version + : endpoint.TypeName; + + private static int ActiveVersionCount(EndpointModel endpoint) + => Math.Max(1, endpoint.HttpRetiredIn > 0 + ? endpoint.HttpRetiredIn - endpoint.HttpIntroducedIn + : 1); + private static string MapMethod(string verb) => verb switch { "GET" => "MapGet", @@ -913,6 +970,7 @@ private readonly record struct EndpointModel public EndpointModel( string typeFullName, string typeName, + string apiTag, string verb, string template, string response, @@ -939,6 +997,7 @@ public EndpointModel( { TypeFullName = typeFullName; TypeName = typeName; + ApiTag = apiTag; Verb = verb; Template = template; Response = response; @@ -970,6 +1029,7 @@ private EndpointModel(string typeFullName, string typeName, IReadOnlyListOverrides the namespace-derived API tag used for generated operations. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ApiTagAttribute : Attribute +{ + /// Initializes a new instance of the class. + /// The generated API tag. + public ApiTagAttribute(string name) + { + Name = name; + } + + /// Gets the generated API tag. + public string Name { get; } +} diff --git a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs index 868d6fbce..f7ea48284 100644 --- a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs +++ b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs @@ -91,6 +91,45 @@ public sealed class GetGreeting : IQuery generated.Should().Contain("MapGet(\"/api/v2/greetings/{id}\""); generated.Should().NotContain("MapGet(\"/api/v3/greetings/{id}\""); generated.Should().Contain("WithGroupName(\"v1\")"); + generated.Should().Contain("WithTags(\"Ark\")"); + generated.Should().Contain("WithName(\"GetGreeting_v1\")"); + generated.Should().Contain("WithName(\"GetGreeting_v2\")"); + } + + [TestMethod] + public void MinimalApiGeneratorUsesApiTagAndReportsDuplicateOperationNames() + { + var generated = RunGenerator( + """ + using Ark.MediatorFramework; + using Ark.Tools.Solid; + namespace Api.Contracts; + [ApiTag("Public")] + [HttpEndpoint("GET", "/one")] + public sealed class First : IQuery { } + [HttpEndpoint("GET", "/two")] + public sealed class Second : IQuery { } + """); + + generated.Should().Contain("WithTags(\"Public\")"); + + var result = RunGeneratorResult( + """ + using Ark.MediatorFramework; + using Ark.Tools.Solid; + namespace First + { + [HttpEndpoint("GET", "/one")] + public sealed class Same : IQuery { } + } + namespace Second + { + [HttpEndpoint("GET", "/two")] + public sealed class Same : IQuery { } + } + """); + + result.Diagnostics.Should().Contain(diagnostic => diagnostic.Id == "ARKMF016"); } [TestMethod] @@ -322,6 +361,23 @@ public sealed class CreateGreeting : IRequest versionTwo.Should().NotContain("GetGreetingAsync"); } + [TestMethod] + public void GrpcGeneratorUsesApiTagWhenServiceGroupIsAbsent() + { + var generated = RunGenerator( + """ + using Ark.MediatorFramework; + using Ark.Tools.Solid; + [ApiTag("Greetings")] + [GrpcMethod("GetGreeting")] + public sealed class GetGreeting : IQuery + { + } + """); + + generated.Should().Contain("IGreetingsV1GrpcService"); + } + [TestMethod] public void MinimalApiGeneratorCombinesRouteQueryAndBody() { From 67a388c51abfccbbbbc50d0b9d1542a57397c861 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:14:48 +0000 Subject: [PATCH 2/3] fix(mediator): keep versioned operation names unique Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com> --- .../MinimalApiEndpointGenerator.cs | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs index e59cd6f8b..5538a4632 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs @@ -443,7 +443,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray endpoint.IsValid && ActiveVersions(endpoint, maxVersion).Contains(version)) - .GroupBy(endpoint => OperationName(endpoint, version)) + .GroupBy(endpoint => OperationName(endpoint, version, maxVersion)) .Where(group => group.Count() > 1)) { var endpoints = duplicate.ToArray(); @@ -496,18 +496,18 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(\"application/json\", \"application/x-msgpack\").Produces<" + e.Response + ">(" + SuccessStatusCode(e) + ", \"application/json\", \"application/x-msgpack\").Produces(" + NullResultStatusCode(e) - + ")" + OpenApiMetadata(e, version) + AuthorizationMetadata(e) + ";"); + + ")" + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";"); continue; } sb.AppendLine(" group." + map + "(" + Literal(template) + ", static async ("); @@ -599,7 +599,7 @@ private static void Emit(SourceProductionContext spc, ImmutableArray(" + SuccessStatusCode(e) + ").Produces(" + NullResultStatusCode(e) - + ")" + OpenApiMetadata(e, version) + AuthorizationMetadata(e) + ";"); + + ")" + OpenApiMetadata(e, version, maxVersion) + AuthorizationMetadata(e) + ";"); } } } @@ -727,7 +727,8 @@ private static void EmitMultipartEndpoint( string handlerService, string map, string template, - int version) + int version, + int maxVersion) { var attachment = endpoint.Properties.Single(property => property.TypeFullName == "global::Ark.MediatorFramework.IArkAttachment"); @@ -769,7 +770,7 @@ private static void EmitMultipartEndpoint( sb.AppendLine(" if (result is null)"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + NullResult(endpoint) + ";"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)" + SuccessResult(endpoint) + ";"); - sb.Append(" }).Accepts(\"multipart/form-data\")").Append(OpenApiMetadata(endpoint, version)).Append(MultipartMetadata(endpoint)) + sb.Append(" }).Accepts(\"multipart/form-data\")").Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(MultipartMetadata(endpoint)) .Append(".Produces<").Append(endpoint.Response).Append(">(").Append(SuccessStatusCode(endpoint)) .Append(").Produces(").Append(NullResultStatusCode(endpoint)).Append(')') .Append(AuthorizationMetadata(endpoint)).AppendLine(";"); @@ -781,7 +782,8 @@ private static void EmitDownloadEndpoint( string handlerService, string map, string template, - int version) + int version, + int maxVersion) { var bindings = endpoint.Properties.Where(property => (property.IsRoute || property.IsQuery) && !property.IsServerSet).ToArray(); sb.Append(" group.").Append(map).Append("(").Append(Literal(template)).AppendLine(", static async ("); @@ -812,7 +814,7 @@ private static void EmitDownloadEndpoint( sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.TypedResults.NotFound();"); sb.AppendLine(" return (global::Microsoft.AspNetCore.Http.IResult)global::Microsoft.AspNetCore.Http.Results.File(result.OpenRead(), result.ContentType, fileDownloadName: global::Ark.MediatorFramework.ArkAttachmentName.Sanitize(result.Name));"); sb.Append(" }).Produces(200, contentType: \"application/octet-stream\").Produces(404)") - .Append(OpenApiMetadata(endpoint, version)).Append(AuthorizationMetadata(endpoint)).AppendLine(";"); + .Append(OpenApiMetadata(endpoint, version, maxVersion)).Append(AuthorizationMetadata(endpoint)).AppendLine(";"); } private static void EmitCommandEndpoint( @@ -820,7 +822,8 @@ private static void EmitCommandEndpoint( EndpointModel endpoint, string map, string template, - int version) + int version, + int maxVersion) { var bodyVerb = endpoint.Verb != "GET" && endpoint.Verb != "DELETE"; var explicitBindings = bodyVerb && endpoint.Properties.Any(property => property.IsRoute || property.IsQuery); @@ -872,7 +875,7 @@ private static void EmitCommandEndpoint( sb.AppendLine(" await handler.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);"); sb.AppendLine(" return global::Microsoft.AspNetCore.Http.TypedResults.NoContent();"); } - sb.Append(" })").Append(OpenApiMetadata(endpoint, version)); + sb.Append(" })").Append(OpenApiMetadata(endpoint, version, maxVersion)); sb.Append(endpoint.OwnerQueue is null ? ".Produces(204)" : ".Produces(202)"); sb.Append(AuthorizationMetadata(endpoint)).AppendLine(";"); } @@ -921,21 +924,16 @@ private static string AuthorizationMetadata(EndpointModel endpoint) : ".RequireAuthorization(" + Literal(endpoint.Policy!) + ")"; } - private static string OpenApiMetadata(EndpointModel endpoint, int version) + private static string OpenApiMetadata(EndpointModel endpoint, int version, int maxVersion) => ".WithGroupName(" + Literal("v" + version) + ").WithTags(" + Literal(endpoint.ApiTag) - + ").WithName(" + Literal(OperationName(endpoint, version)) + ")"; + + ").WithName(" + Literal(OperationName(endpoint, version, maxVersion)) + ")"; - private static string OperationName(EndpointModel endpoint, int version) - => ActiveVersionCount(endpoint) > 1 + private static string OperationName(EndpointModel endpoint, int version, int maxVersion) + => ActiveVersions(endpoint, maxVersion).Count() > 1 ? endpoint.TypeName + "_v" + version : endpoint.TypeName; - private static int ActiveVersionCount(EndpointModel endpoint) - => Math.Max(1, endpoint.HttpRetiredIn > 0 - ? endpoint.HttpRetiredIn - endpoint.HttpIntroducedIn - : 1); - private static string MapMethod(string verb) => verb switch { "GET" => "MapGet", From fa5dd67d3a88fac284e7384766511824af702164 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:52:46 +0000 Subject: [PATCH 3/3] =?UTF-8?q?refactor(mediator):=20rename=20ApiTag?= =?UTF-8?q?=E2=86=92ApiGroup,=20ServiceGroup=E2=86=92GrpcService;=20fix=20?= =?UTF-8?q?ARKMF016=20full=20type=20names;=20document=20GrpcMethod=20defau?= =?UTF-8?q?lt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com> --- docs/mediator-framework/design.md | 21 ++++++------ docs/mediator-framework/progress/tasks.md | 8 ++--- .../NET-06-openapi-tags-operation-names.md | 26 +++++++------- .../AttachmentContracts.cs | 2 +- .../GreetingContracts.cs | 10 +++--- .../PolymorphicContracts.cs | 2 +- .../GrpcEndpointGenerator.cs | 34 +++++++++---------- .../GrpcAttributes.cs | 19 ++++++----- .../MinimalApiEndpointGenerator.cs | 30 ++++++++-------- .../ApiGroupAttribute.cs | 23 +++++++++++++ .../ApiTagAttribute.cs | 19 ----------- .../GeneratorSnapshotTests.cs | 14 ++++---- 12 files changed, 108 insertions(+), 100 deletions(-) create mode 100644 src/mediator-framework/Ark.Tools.MediatorFramework/ApiGroupAttribute.cs delete mode 100644 src/mediator-framework/Ark.Tools.MediatorFramework/ApiTagAttribute.cs diff --git a/docs/mediator-framework/design.md b/docs/mediator-framework/design.md index 5ae6ab8e4..f1481a154 100644 --- a/docs/mediator-framework/design.md +++ b/docs/mediator-framework/design.md @@ -65,11 +65,12 @@ request type. Each transport is opt-in and declared independently: - `[HttpEndpoint("POST", "/api/v{version}/orders")]` — expose over Minimal API for each active version. - `[GrpcMethod]` (optionally `[GrpcMethod("CreateOrder")]`) — expose as a - code-first gRPC method; `[ServiceGroup("Orders")]` groups the service. + code-first gRPC method; defaults to the contract type name. `[GrpcService("Orders")]` groups the service. - `[RebusMessage(OwnerQueue = "orders")]` — expose as a Rebus message and, when an owner is declared, contribute a type-based outbound route. -- `[ApiTag("Orders")]` — override the namespace-derived OpenAPI tag and the - gRPC service-group fallback for the contract. +- `[ApiGroup("Orders")]` — override the namespace-derived API group used as the + OpenAPI tag and the gRPC service-group fallback. `[GrpcService]` takes precedence + over `[ApiGroup]` for gRPC service grouping. Generated HTTP endpoints require authorization by default. Set `Policy` on `[HttpEndpoint]` to select a named policy, or set `AllowAnonymous = true` for @@ -151,7 +152,7 @@ return `202 Accepted`. Generated gRPC command methods return The `IntroducedIn` and exclusive `RetiredIn` properties on `HttpEndpointAttribute` expand a `{version}` route once per active version. The generator applies the same lifetime rules to `[GrpcMethod]`, emitting one version-suffixed service per -`[ServiceGroup]` and retaining only methods active in that version. +`[GrpcService]` and retaining only methods active in that version. ### HTTP binding: the request is always the envelope @@ -211,7 +212,7 @@ attachment reaches a handler, including for streamed gRPC uploads. `protobuf-net.Grpc` hosts a `[ServiceContract]` interface. The generator groups the `[GrpcMethod]`-declared requests/queries (by namespace or -`[ServiceGroup("Orders")]`) into one generated `[ServiceContract]` interface plus +`[GrpcService("Orders")]`) into one generated `[ServiceContract]` interface plus a `partial` implementation that resolves the pure handler from the ambient request scope (opened by the SimpleInjector integration in the pipeline). The `partial` lets a developer hand-write any method the generator cannot express. @@ -491,7 +492,7 @@ retirement, mirroring how `Asp.Versioning` treats versions: contract). - OpenAPI documents are still partitioned per version; an endpoint appears in every document of a version it is active in. -- gRPC mirrors the same rule per `[ServiceGroup]`: one `[ServiceContract]` +- gRPC mirrors the same rule per `[GrpcService]`: one `[ServiceContract]` service is generated per active version (`GreetingsV1`, `GreetingsV2`, …), each containing the methods active in that version. - Route parameters (`{id}` etc.) are ordinary `[FromRoute]` bindings and remain @@ -534,7 +535,7 @@ the gRPC generator/package — no hand-maintained schema program: - The gRPC incremental generator (which already knows every `[GrpcMethod]` contract and `[ProtoContract]` message) additionally emits the proto text as - generated C# (`ArkGeneratedProtos`: per-`[ServiceGroup]` `(fileName, content)` + generated C# (`ArkGeneratedProtos`: per-`[GrpcService]` `(fileName, content)` pairs plus a `WriteTo(directory)` helper). - Hosts with hand-written protobuf services can declare ``; the export target @@ -626,9 +627,9 @@ writes only the handler still gets a navigable document: - **Tag** — defaults to the contract's **namespace** (the last namespace segment; `Ark.Sample.Application.Greetings.GetGreetingQuery` → `Greetings`). - Overridable per contract with `[ApiTag("...")]`, and per namespace/assembly by + Overridable per contract with `[ApiGroup("...")]`, and per namespace/assembly by a host-level default. The same value groups gRPC methods when no explicit - `[ServiceGroup]` is declared, so both transports present the same taxonomy. + `[GrpcService]` is declared, so both transports present the same taxonomy. - **Operation name** — defaults to the contract **class name** with any `Request`/`Query`/`Command` suffix preserved (`GetGreetingQuery` → `GetGreetingQuery`), emitted as the OpenAPI `operationId` and as the endpoint @@ -716,7 +717,7 @@ same package): | `Ark.Tools.MediatorFramework` | transport-neutral core: `IArkAttachment`/`ArkAttachment`, shared versioning primitives | | `Ark.Tools.MediatorFramework.MinimalApi` | `[HttpEndpoint]`, HTTP runtime helpers + the Minimal API endpoint generator | | `Ark.Tools.MediatorFramework.Rebus` | `[RebusMessage]`, Rebus runtime helpers + the Rebus wrapper generator | -| `Ark.Tools.MediatorFramework.Grpc` | `[GrpcMethod]`/`[ServiceGroup]`, gRPC runtime helpers (interceptor, upload adapter) + the gRPC service generator | +| `Ark.Tools.MediatorFramework.Grpc` | `[GrpcMethod]`/`[GrpcService]`, gRPC runtime helpers (interceptor, upload adapter) + the gRPC service generator | An application references only the transports it hosts; each generator reacts only to its own attribute, so adding a transport never re-runs the others. diff --git a/docs/mediator-framework/progress/tasks.md b/docs/mediator-framework/progress/tasks.md index 4fa61750f..1f200c562 100644 --- a/docs/mediator-framework/progress/tasks.md +++ b/docs/mediator-framework/progress/tasks.md @@ -43,7 +43,7 @@ project builds under the repo's strict settings and its self-tests pass with ## Epic 4 — Code-first gRPC transport - [x] **T4.1** `[ProtoContract]` contracts + generated `[ServiceContract]` service - (opt-in via `[GrpcMethod]`, grouped by `[ServiceGroup]`), hosted with + (opt-in via `[GrpcMethod]`, grouped by `[GrpcService]`), hosted with `AddCodeFirstGrpc()`; the generated service is `partial` for manual methods. - *Accept:* an in-process `Grpc.Net.Client` self-test calls the service and gets the same result as the HTTP path. @@ -140,7 +140,7 @@ project builds under the repo's strict settings and its self-tests pass with per active version; self-tests call the same contract on two versions and assert a retired contract is absent from later versions; a route-parameter (`{id}`) test passes on every generated version; gRPC generates one - service per active version per `[ServiceGroup]`. + service per active version per `[GrpcService]`. - [x] **T8.6** Split the framework into per-transport packages, each with its own `netstandard2.0` analyzer and transport-only generator output. - *Accept:* `Ark.Tools.MediatorFramework` (core) plus @@ -198,7 +198,7 @@ project builds under the repo's strict settings and its self-tests pass with - [x] **T9.6** `.proto` generated on build as assets; shared protos split per package; delete `ProtoGenerator`. - *Accept:* the gRPC generator emits `ArkGeneratedProtos` (per - `[ServiceGroup]` file content importing `ark/nodatime.proto` and + `[GrpcService]` file content importing `ark/nodatime.proto` and `ark/mediator.proto`); `Ark.Tools.Nodatime.Protobuf` packs `ark/nodatime.proto` and `Ark.Tools.MediatorFramework.Grpc` packs `ark/mediator.proto` as content assets; the Grpc package ships a @@ -342,7 +342,7 @@ authoritative ones. - [x] **T12.1** OpenAPI tags and operation names from the contract ([NET-06](tasks/aspnetcore/NET-06-openapi-tags-operation-names.md)). - *Accept:* every generated operation carries a namespace-derived tag - (overridable with `[ApiTag]`) and a unique, contract-derived `operationId`; + (overridable with `[ApiGroup]`) and a unique, contract-derived `operationId`; gRPC grouping shares the taxonomy; duplicates are a diagnostic. - [ ] **T12.2** Standard 400/403/500 ProblemDetails responses ([FW-05](tasks/framework/FW-05-standard-problem-responses.md)). diff --git a/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md b/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md index a61fbe78d..4e2fe3656 100644 --- a/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md +++ b/docs/mediator-framework/progress/tasks/aspnetcore/NET-06-openapi-tags-operation-names.md @@ -12,7 +12,7 @@ around the `Produces` calls). Consequences: - every operation lands in the default tag of the document, so Scalar/Swagger UI show one flat list; - `operationId` is either missing or an unreadable compiler-generated name, so generated clients (NSwag/openapi-generator) produce meaningless method names; -- gRPC service grouping (`[ServiceGroup]`) and HTTP grouping are unrelated, so the two transports +- gRPC service grouping (`[GrpcService]`) and HTTP grouping are unrelated, so the two transports present different taxonomies for the same contracts. ## Design @@ -26,16 +26,16 @@ See `docs/mediator-framework/design.md` → *OpenAPI operation grouping, naming - Versioned expansion must keep `operationId` unique per document: append the version suffix (`GetGreetingQuery_v2`) **only** when the same contract is expanded into more than one version document. -- Override: new `[ApiTag("Greetings")]` attribute (class-level, `AttributeTargets.Class`), placed in +- Override: new `[ApiGroup("Greetings")]` attribute (class-level, `AttributeTargets.Class`), placed in the transport-neutral core package `Ark.Tools.MediatorFramework` so Application assemblies do not reference ASP.NET Core (same rule as `[BindFromQuery]`/`[ServerSet]`). -- The gRPC generator uses the same `[ApiTag]` value as the service-group fallback when - `[ServiceGroup]` is absent, so both transports group identically. An explicit `[ServiceGroup]` +- The gRPC generator uses the same `[ApiGroup]` value as the service-group fallback when + `[GrpcService]` is absent, so both transports group identically. An explicit `[GrpcService]` still wins for gRPC. ## Steps -1. Add `ApiTagAttribute` (sealed, `[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, +1. Add `ApiGroupAttribute` (sealed, `[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]`, single `string Name` ctor property) to `src/mediator-framework/Ark.Tools.MediatorFramework/`, with XML docs and the standard copyright header. @@ -45,38 +45,38 @@ See `docs/mediator-framework/design.md` → *OpenAPI operation grouping, naming multipart, download and command paths. Escape the literals. Docs: [`WithTags`](https://learn.microsoft.com/aspnet/core/fundamentals/openapi/aspnetcore-openapi#openapi-operation-tags), [`WithName` / operationId](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/openapi#operationid). -3. In `GrpcEndpointGenerator`, fall back to `[ApiTag]` before the namespace default when grouping +3. In `GrpcEndpointGenerator`, fall back to `[ApiGroup]` before the namespace default when grouping methods into a service. 4. Report a generator diagnostic (next free `ARKMF0xx`, documented in `design.md`) when two contracts in the same document/version resolve to the same operation name — a duplicate `operationId` breaks client generation and must not be emitted silently. -5. Add `[ApiTag]` to at least one sample contract and rely on the namespace default for the others, so +5. Add `[ApiGroup]` to at least one sample contract and rely on the namespace default for the others, so both paths are demonstrated in `samples/Ark.MediatorFramework.Sample`. ## Test coverage (required) - `tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs`: snapshot shows `.WithTags` - and `.WithName` for a default-tag contract, an `[ApiTag]`-overridden contract and a versioned + and `.WithName` for a default-tag contract, an `[ApiGroup]`-overridden contract and a versioned contract (unique `operationId` per version). - New generator test asserting the duplicate-operation-name diagnostic is reported. - Sample test that fetches `/openapi/v1.json` and asserts: every operation has a non-empty `tags[0]` - and a unique `operationId`; the `[ApiTag]` override is honoured; the namespace default is applied + and a unique `operationId`; the `[ApiGroup]` override is honoured; the namespace default is applied elsewhere. -- gRPC: an existing exported-proto assertion is extended to prove the `[ApiTag]` fallback groups the +- gRPC: an existing exported-proto assertion is extended to prove the `[ApiGroup]` fallback groups the method into the expected service. ## Outcomes - Every generated HTTP operation carries a deterministic tag and a stable, unique `operationId` - derived from the contract, overridable with `[ApiTag]`; gRPC grouping uses the same taxonomy. + derived from the contract, overridable with `[ApiGroup]`; gRPC grouping uses the same taxonomy. ## Acceptance -- [x] `ApiTagAttribute` exists in `Ark.Tools.MediatorFramework` with XML docs. +- [x] `ApiGroupAttribute` exists in `Ark.Tools.MediatorFramework` with XML docs. - [x] Generated endpoints emit `.WithTags(...)` and `.WithName(...)`; snapshots updated. - [x] `operationId` is unique within each versioned document (tested). - [x] Duplicate operation names produce a documented generator diagnostic (tested). -- [x] gRPC service grouping falls back to `[ApiTag]`; explicit `[ServiceGroup]` still wins (tested). +- [x] gRPC service grouping falls back to `[ApiGroup]`; explicit `[GrpcService]` still wins (tested). - [x] `design.md` documents the defaults, the override and the new diagnostic id. - [x] `dotnet build Ark.Tools.slnx --configuration Debug` succeeds with zero warnings. - [x] `dotnet test Ark.Tools.slnx --no-build --configuration Debug --minimum-expected-tests 1` passes. diff --git a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/AttachmentContracts.cs b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/AttachmentContracts.cs index 89801ce4c..233fb9c83 100644 --- a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/AttachmentContracts.cs +++ b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/AttachmentContracts.cs @@ -48,7 +48,7 @@ public sealed record UploadGreetingCardRequest : IRequest /// Queries a previously uploaded greeting-card attachment. [HttpEndpoint("GET", "/api/v{version}/greeting-cards/{id}/download")] [GrpcMethod("Download")] -[ServiceGroup("GeneratedDocuments")] +[GrpcService("GeneratedDocuments")] public sealed record GetDocumentQuery : IQuery { /// Gets the upload correlation identifier. diff --git a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs index 1a2ba4296..de9454957 100644 --- a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs +++ b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/GreetingContracts.cs @@ -48,7 +48,7 @@ public sealed record GreetingResponse /// Command used to exercise the synchronous command transport contract. [HttpEndpoint("POST", "/api/v{version}/greetings/refresh")] [GrpcMethod("RefreshGreeting")] -[ServiceGroup("Greetings")] +[GrpcService("Greetings")] [ProtoContract] public sealed record RefreshGreetingCommand : ICommand { @@ -65,7 +65,7 @@ public sealed record RefreshGreetingCommand : ICommand [HttpEndpoint("POST", "/api/v{version}/greetings", AcceptsMessagePack = true, SuccessStatusCode = 201)] [RebusMessage] [GrpcMethod("CreateGreeting")] -[ServiceGroup("Greetings")] +[GrpcService("Greetings")] [RequireScopePolicy(ApplicationScopes.GreetingWrite)] [ProtoContract] [MessagePackObject(true)] @@ -97,7 +97,7 @@ public sealed record CreateGreetingRequest : IRequest } /// HTTP-only request that publishes work to Rebus and returns immediately. -[ApiTag("Greetings")] +[ApiGroup("Greetings")] [HttpEndpoint("POST", "/api/v{version}/greetings/compose")] public sealed record ComposeGreetingRequest : IRequest { @@ -140,7 +140,7 @@ public sealed record GreetingCreatedNotification : ICommand /// [HttpEndpoint("GET", "/api/v{version}/greetings/{id}", RetiredIn = 2)] [GrpcMethod("GetGreeting", RetiredIn = 2)] -[ServiceGroup("Greetings")] +[GrpcService("Greetings")] [ProtoContract] public sealed record GetGreetingQuery : IQuery { @@ -172,7 +172,7 @@ public sealed record GreetingResponseV2 /// [HttpEndpoint("GET", "/api/v{version}/greetings-v2/{id}", IntroducedIn = 2)] [GrpcMethod("GetGreeting", IntroducedIn = 2)] -[ServiceGroup("Greetings")] +[GrpcService("Greetings")] [ProtoContract] public sealed record GetGreetingV2Query : IQuery { diff --git a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/PolymorphicContracts.cs b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/PolymorphicContracts.cs index 8fa688540..1b7120d1a 100644 --- a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/PolymorphicContracts.cs +++ b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.Application/PolymorphicContracts.cs @@ -114,7 +114,7 @@ public sealed record ShapeEnvelope /// [HttpEndpoint("POST", "/api/v{version}/shapes/describe", AcceptsMessagePack = true)] [GrpcMethod("DescribeShape")] -[ServiceGroup("Greetings")] +[GrpcService("Greetings")] [ProtoContract] [MessagePackObject] public sealed record DescribeShapeRequest : IRequest diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs index 032be8b5a..e2b40f4d7 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc.Generators/GrpcEndpointGenerator.cs @@ -26,8 +26,8 @@ namespace Ark.MediatorFramework.Generators public sealed class ArkGrpcEndpointGenerator : IIncrementalGenerator { private const string GrpcMethodAttribute = "Ark.MediatorFramework.GrpcMethodAttribute"; - private const string ServiceGroupAttribute = "Ark.MediatorFramework.ServiceGroupAttribute"; - private const string ApiTagAttribute = "Ark.MediatorFramework.ApiTagAttribute"; + private const string GrpcServiceAttribute = "Ark.MediatorFramework.GrpcServiceAttribute"; + private const string ApiGroupAttribute = "Ark.MediatorFramework.ApiGroupAttribute"; private const string ServerSetAttribute = "Ark.MediatorFramework.ServerSetAttribute"; private const string ArkAttachment = "Ark.MediatorFramework.IArkAttachment"; @@ -62,14 +62,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { var type = (INamedTypeSymbol)context.TargetSymbol; var grpc = context.Attributes[0]; - var serviceGroupAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName(ServiceGroupAttribute); - var apiTagAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName(ApiTagAttribute); + var grpcServiceAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName(GrpcServiceAttribute); + var apiGroupAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName(ApiGroupAttribute); var attachmentType = context.SemanticModel.Compilation.GetTypeByMetadataName(ArkAttachment); - var serviceGroup = serviceGroupAttribute is null + var grpcService = grpcServiceAttribute is null ? null : type.GetAttributes().FirstOrDefault( - attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, serviceGroupAttribute)); - return Extract(type, grpc, serviceGroup, apiTagAttribute, attachmentType); + attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, grpcServiceAttribute)); + return Extract(type, grpc, grpcService, apiGroupAttribute, attachmentType); } private static string? GetAssemblyName(GeneratorSyntaxContext context, string methodName) @@ -89,8 +89,8 @@ private static ImmutableArray GetReferencedEndpoints( ImmutableArray endpointAssemblies) { var grpcAttr = compilation.GetTypeByMetadataName(GrpcMethodAttribute); - var serviceGroupAttr = compilation.GetTypeByMetadataName(ServiceGroupAttribute); - var apiTagAttr = compilation.GetTypeByMetadataName(ApiTagAttribute); + var grpcServiceAttr = compilation.GetTypeByMetadataName(GrpcServiceAttribute); + var apiGroupAttr = compilation.GetTypeByMetadataName(ApiGroupAttribute); var attachmentType = compilation.GetTypeByMetadataName(ArkAttachment); if (grpcAttr is null) return ImmutableArray.Empty; @@ -108,8 +108,8 @@ private static ImmutableArray GetReferencedEndpoints( if (grpc is null) continue; - var serviceGroup = attrs.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, serviceGroupAttr)); - var model = Extract(type, grpc, serviceGroup, apiTagAttr, attachmentType); + var grpcService = attrs.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, grpcServiceAttr)); + var model = Extract(type, grpc, grpcService, apiGroupAttr, attachmentType); if (model is not null) builder.Add(model.Value); } @@ -159,8 +159,8 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) private static EndpointModel? Extract( INamedTypeSymbol type, AttributeData grpc, - AttributeData? serviceGroup, - INamedTypeSymbol? apiTagAttribute, + AttributeData? grpcService, + INamedTypeSymbol? apiGroupAttribute, INamedTypeSymbol? attachmentType) { string? response = null; @@ -203,17 +203,17 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) var grpcMethod = grpc.ConstructorArguments.FirstOrDefault().Value as string ?? type.Name; var grpcIntroducedIn = NamedInt(grpc, "IntroducedIn", 1); var grpcRetiredIn = NamedInt(grpc, "RetiredIn", 0); - var apiTag = apiTagAttribute is null + var apiGroup = apiGroupAttribute is null ? null : type.GetAttributes() - .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, apiTagAttribute)) + .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, apiGroupAttribute)) .Select(attribute => attribute.ConstructorArguments.FirstOrDefault().Value as string) .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); var defaultGroup = type.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToDisplayString().Split('.').Last() : "Ark"; - var group = serviceGroup?.ConstructorArguments.FirstOrDefault().Value as string - ?? apiTag + var group = grpcService?.ConstructorArguments.FirstOrDefault().Value as string + ?? apiGroup ?? defaultGroup; return new EndpointModel( diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc/GrpcAttributes.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc/GrpcAttributes.cs index 46290f81e..237aee85c 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc/GrpcAttributes.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.Grpc/GrpcAttributes.cs @@ -5,7 +5,7 @@ namespace Ark.MediatorFramework; /// /// Opt-in declaration that exposes a pure Ark.Tools.Solid request/query as a code-first -/// gRPC method. +/// gRPC method. When no name is specified the generator uses the contract type name as the gRPC method name. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class GrpcMethodAttribute : Attribute @@ -17,7 +17,7 @@ public GrpcMethodAttribute(string? name = null) Name = name; } - /// Gets the explicit gRPC method name, or to use the type name. + /// Gets the explicit gRPC method name, or to use the contract type name. public string? Name { get; } /// Gets or sets the first API version in which the method is available. @@ -27,17 +27,20 @@ public GrpcMethodAttribute(string? name = null) public int RetiredIn { get; set; } } -/// Assigns opt-in gRPC methods to a generated code-first service. +/// +/// Assigns opt-in gRPC methods to a named generated code-first service. +/// Takes precedence over [ApiGroup] for gRPC service grouping. +/// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class ServiceGroupAttribute : Attribute +public sealed class GrpcServiceAttribute : Attribute { - /// Initializes a new instance of the class. - /// The generated service group name. - public ServiceGroupAttribute(string name) + /// Initializes a new instance of the class. + /// The generated gRPC service name. + public GrpcServiceAttribute(string name) { Name = name; } - /// Gets the generated service group name. + /// Gets the generated gRPC service name. public string Name { get; } } diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs index 5538a4632..eeb5bf945 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.MinimalApi.Generators/MinimalApiEndpointGenerator.cs @@ -29,7 +29,7 @@ public sealed class ArkMinimalApiEndpointGenerator : IIncrementalGenerator private const string BindFromQueryAttribute = "Ark.MediatorFramework.BindFromQueryAttribute"; private const string ServerSetAttribute = "Ark.MediatorFramework.ServerSetAttribute"; private const string RebusMessageAttribute = "Ark.MediatorFramework.RebusMessageAttribute"; - private const string ApiTagAttribute = "Ark.MediatorFramework.ApiTagAttribute"; + private const string ApiGroupAttribute = "Ark.MediatorFramework.ApiGroupAttribute"; private const string ArkAttachment = "Ark.MediatorFramework.IArkAttachment"; private const string Enumerable = "System.Collections.Generic.IEnumerable`1"; private static readonly DiagnosticDescriptor MultipleAttachments = new DiagnosticDescriptor( @@ -100,7 +100,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) compilation.GetTypeByMetadataName(ServerSetAttribute), compilation.GetTypeByMetadataName(ArkAttachment), compilation.GetTypeByMetadataName(RebusMessageAttribute), - compilation.GetTypeByMetadataName(ApiTagAttribute), + compilation.GetTypeByMetadataName(ApiGroupAttribute), compilation.GetTypeByMetadataName(Enumerable)); } @@ -128,7 +128,7 @@ private static ImmutableArray GetReferencedEndpoints( var bindFromQueryAttr = compilation.GetTypeByMetadataName(BindFromQueryAttribute); var serverSetAttr = compilation.GetTypeByMetadataName(ServerSetAttribute); var rebusMessageAttr = compilation.GetTypeByMetadataName(RebusMessageAttribute); - var apiTagAttr = compilation.GetTypeByMetadataName(ApiTagAttribute); + var apiGroupAttr = compilation.GetTypeByMetadataName(ApiGroupAttribute); var attachmentType = compilation.GetTypeByMetadataName(ArkAttachment); var enumerableType = compilation.GetTypeByMetadataName(Enumerable); var builder = ImmutableArray.CreateBuilder(); @@ -150,7 +150,7 @@ private static ImmutableArray GetReferencedEndpoints( serverSetAttr, attachmentType, rebusMessageAttr, - apiTagAttr, + apiGroupAttr, enumerableType); if (model is not null) builder.Add(model.Value); @@ -197,7 +197,7 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) INamedTypeSymbol? serverSetAttr, INamedTypeSymbol? attachmentType, INamedTypeSymbol? rebusMessageAttr, - INamedTypeSymbol? apiTagAttr, + INamedTypeSymbol? apiGroupAttr, INamedTypeSymbol? enumerableType) { string? response = null; @@ -264,10 +264,10 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, rebusMessageAttr)) .Select(attribute => NamedString(attribute, "OwnerQueue")) .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); - var apiTag = apiTagAttr is null + var apiGroup = apiGroupAttr is null ? null : type.GetAttributes() - .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, apiTagAttr)) + .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, apiGroupAttr)) .Select(attribute => attribute.ConstructorArguments.FirstOrDefault().Value as string) .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); var defaultTag = type.ContainingNamespace is { IsGlobalNamespace: false } ns @@ -313,7 +313,7 @@ private static IEnumerable _allTypes(INamespaceSymbol ns) return new EndpointModel( type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), type.Name, - apiTag ?? defaultTag, + apiGroup ?? defaultTag, verb, template!, response ?? "global::System.Void", @@ -453,8 +453,8 @@ private static void Emit(SourceProductionContext spc, ImmutableArray ".WithGroupName(" + Literal("v" + version) - + ").WithTags(" + Literal(endpoint.ApiTag) + + ").WithTags(" + Literal(endpoint.ApiGroup) + ").WithName(" + Literal(OperationName(endpoint, version, maxVersion)) + ")"; private static string OperationName(EndpointModel endpoint, int version, int maxVersion) @@ -968,7 +968,7 @@ private readonly record struct EndpointModel public EndpointModel( string typeFullName, string typeName, - string apiTag, + string apiGroup, string verb, string template, string response, @@ -995,7 +995,7 @@ public EndpointModel( { TypeFullName = typeFullName; TypeName = typeName; - ApiTag = apiTag; + ApiGroup = apiGroup; Verb = verb; Template = template; Response = response; @@ -1027,7 +1027,7 @@ private EndpointModel(string typeFullName, string typeName, IReadOnlyList +/// Overrides the namespace-derived API group used for generated operations across transports. +/// The HTTP generator emits this value as the OpenAPI tag and operation-name prefix. +/// The gRPC generator uses it as the service-group name when [GrpcService] is absent. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ApiGroupAttribute : Attribute +{ + /// Initializes a new instance of the class. + /// The API group name used as the OpenAPI tag and gRPC service-group fallback. + public ApiGroupAttribute(string name) + { + Name = name; + } + + /// Gets the API group name. + public string Name { get; } +} diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework/ApiTagAttribute.cs b/src/mediator-framework/Ark.Tools.MediatorFramework/ApiTagAttribute.cs deleted file mode 100644 index 2a838829a..000000000 --- a/src/mediator-framework/Ark.Tools.MediatorFramework/ApiTagAttribute.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (C) 2024 Ark Energy S.r.l. All rights reserved. -// Licensed under the MIT License. See LICENSE file for license information. - -namespace Ark.MediatorFramework; - -/// Overrides the namespace-derived API tag used for generated operations. -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class ApiTagAttribute : Attribute -{ - /// Initializes a new instance of the class. - /// The generated API tag. - public ApiTagAttribute(string name) - { - Name = name; - } - - /// Gets the generated API tag. - public string Name { get; } -} diff --git a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs index f7ea48284..9a5a3d7a8 100644 --- a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs +++ b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs @@ -97,14 +97,14 @@ public sealed class GetGreeting : IQuery } [TestMethod] - public void MinimalApiGeneratorUsesApiTagAndReportsDuplicateOperationNames() + public void MinimalApiGeneratorUsesApiGroupAndReportsDuplicateOperationNames() { var generated = RunGenerator( """ using Ark.MediatorFramework; using Ark.Tools.Solid; namespace Api.Contracts; - [ApiTag("Public")] + [ApiGroup("Public")] [HttpEndpoint("GET", "/one")] public sealed class First : IQuery { } [HttpEndpoint("GET", "/two")] @@ -340,12 +340,12 @@ public void GrpcGeneratorEmitsVersionedServiceMethodSets() """ using Ark.MediatorFramework; using Ark.Tools.Solid; - [ServiceGroup("Greetings")] + [GrpcService("Greetings")] [GrpcMethod("GetGreeting", IntroducedIn = 1, RetiredIn = 2)] public sealed class GetGreeting : IQuery { } - [ServiceGroup("Greetings")] + [GrpcService("Greetings")] [GrpcMethod("CreateGreeting", IntroducedIn = 2)] public sealed class CreateGreeting : IRequest { @@ -362,13 +362,13 @@ public sealed class CreateGreeting : IRequest } [TestMethod] - public void GrpcGeneratorUsesApiTagWhenServiceGroupIsAbsent() + public void GrpcGeneratorUsesApiGroupWhenGrpcServiceIsAbsent() { var generated = RunGenerator( """ using Ark.MediatorFramework; using Ark.Tools.Solid; - [ApiTag("Greetings")] + [ApiGroup("Greetings")] [GrpcMethod("GetGreeting")] public sealed class GetGreeting : IQuery { @@ -617,7 +617,7 @@ public void GrpcGeneratorEmitsImportedProtoAsset() using Ark.MediatorFramework; using Ark.Tools.Solid; using ProtoBuf; - [ServiceGroup("Greetings")] + [GrpcService("Greetings")] [GrpcMethod("GetGreeting")] [ProtoContract] public sealed class GetGreeting : IQuery