diff --git a/docs/mediator-framework/design.md b/docs/mediator-framework/design.md
index d7dbe066a..f1481a154 100644
--- a/docs/mediator-framework/design.md
+++ b/docs/mediator-framework/design.md
@@ -65,9 +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.
+- `[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
@@ -80,7 +83,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
@@ -148,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
@@ -208,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.
@@ -488,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
@@ -531,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
@@ -623,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
@@ -713,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 7b1738aa0..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
@@ -339,10 +339,10 @@ 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`;
+ (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/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..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
-- [ ] `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] `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 `[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 6978e8875..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,6 +97,7 @@ public sealed record CreateGreetingRequest : IRequest
}
/// HTTP-only request that publishes work to Rebus and returns immediately.
+[ApiGroup("Greetings")]
[HttpEndpoint("POST", "/api/v{version}/greetings/compose")]
public sealed record ComposeGreetingRequest : IRequest
{
@@ -139,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
{
@@ -171,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 0ea4bda5b..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,7 +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 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";
@@ -61,13 +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 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, attachmentType);
+ attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, grpcServiceAttribute));
+ return Extract(type, grpc, grpcService, apiGroupAttribute, attachmentType);
}
private static string? GetAssemblyName(GeneratorSyntaxContext context, string methodName)
@@ -87,7 +89,8 @@ private static ImmutableArray GetReferencedEndpoints(
ImmutableArray endpointAssemblies)
{
var grpcAttr = compilation.GetTypeByMetadataName(GrpcMethodAttribute);
- var serviceGroupAttr = compilation.GetTypeByMetadataName(ServiceGroupAttribute);
+ var grpcServiceAttr = compilation.GetTypeByMetadataName(GrpcServiceAttribute);
+ var apiGroupAttr = compilation.GetTypeByMetadataName(ApiGroupAttribute);
var attachmentType = compilation.GetTypeByMetadataName(ArkAttachment);
if (grpcAttr is null)
return ImmutableArray.Empty;
@@ -105,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, 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);
}
@@ -156,7 +159,8 @@ private static IEnumerable _allTypes(INamespaceSymbol ns)
private static EndpointModel? Extract(
INamedTypeSymbol type,
AttributeData grpc,
- AttributeData? serviceGroup,
+ AttributeData? grpcService,
+ INamedTypeSymbol? apiGroupAttribute,
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 apiGroup = apiGroupAttribute is null
+ ? null
+ : type.GetAttributes()
+ .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 = grpcService?.ConstructorArguments.FirstOrDefault().Value as string
+ ?? apiGroup
+ ?? defaultGroup;
return new EndpointModel(
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
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 7ba17f4a9..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,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 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(
@@ -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(ApiGroupAttribute),
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 apiGroupAttr = compilation.GetTypeByMetadataName(ApiGroupAttribute);
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,
+ apiGroupAttr,
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? apiGroupAttr,
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 apiGroup = apiGroupAttr is null
+ ? null
+ : type.GetAttributes()
+ .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
+ ? 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,
+ apiGroup ?? 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, maxVersion))
+ .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].TypeFullName,
+ other.TypeFullName,
+ duplicate.Key,
+ version));
+ }
+ }
+ }
foreach (var e in items)
{
foreach (var diagnostic in e.Diagnostics)
@@ -453,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)
- + ").WithGroupName(" + Literal("v" + version) + ")" + AuthorizationMetadata(e) + ";");
+ + ")" + OpenApiMetadata(e, version, maxVersion) + 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, maxVersion) + AuthorizationMetadata(e) + ";");
}
}
}
@@ -684,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");
@@ -726,8 +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\").WithGroupName(")
- .Append(Literal("v" + version)).Append(')').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(";");
@@ -739,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 (");
@@ -769,8 +813,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, maxVersion)).Append(AuthorizationMetadata(endpoint)).AppendLine(";");
}
private static void EmitCommandEndpoint(
@@ -778,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);
@@ -830,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(" }).WithGroupName(").Append(Literal("v" + version)).Append(')');
+ sb.Append(" })").Append(OpenApiMetadata(endpoint, version, maxVersion));
sb.Append(endpoint.OwnerQueue is null ? ".Produces(204)" : ".Produces(202)");
sb.Append(AuthorizationMetadata(endpoint)).AppendLine(";");
}
@@ -879,6 +924,16 @@ private static string AuthorizationMetadata(EndpointModel endpoint)
: ".RequireAuthorization(" + Literal(endpoint.Policy!) + ")";
}
+ private static string OpenApiMetadata(EndpointModel endpoint, int version, int maxVersion)
+ => ".WithGroupName(" + Literal("v" + version)
+ + ").WithTags(" + Literal(endpoint.ApiGroup)
+ + ").WithName(" + Literal(OperationName(endpoint, version, maxVersion)) + ")";
+
+ private static string OperationName(EndpointModel endpoint, int version, int maxVersion)
+ => ActiveVersions(endpoint, maxVersion).Count() > 1
+ ? endpoint.TypeName + "_v" + version
+ : endpoint.TypeName;
+
private static string MapMethod(string verb) => verb switch
{
"GET" => "MapGet",
@@ -913,6 +968,7 @@ private readonly record struct EndpointModel
public EndpointModel(
string typeFullName,
string typeName,
+ string apiGroup,
string verb,
string template,
string response,
@@ -939,6 +995,7 @@ public EndpointModel(
{
TypeFullName = typeFullName;
TypeName = typeName;
+ ApiGroup = apiGroup;
Verb = verb;
Template = template;
Response = response;
@@ -970,6 +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/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs
index 868d6fbce..9a5a3d7a8 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 MinimalApiGeneratorUsesApiGroupAndReportsDuplicateOperationNames()
+ {
+ var generated = RunGenerator(
+ """
+ using Ark.MediatorFramework;
+ using Ark.Tools.Solid;
+ namespace Api.Contracts;
+ [ApiGroup("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]
@@ -301,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
{
@@ -322,6 +361,23 @@ public sealed class CreateGreeting : IRequest
versionTwo.Should().NotContain("GetGreetingAsync");
}
+ [TestMethod]
+ public void GrpcGeneratorUsesApiGroupWhenGrpcServiceIsAbsent()
+ {
+ var generated = RunGenerator(
+ """
+ using Ark.MediatorFramework;
+ using Ark.Tools.Solid;
+ [ApiGroup("Greetings")]
+ [GrpcMethod("GetGreeting")]
+ public sealed class GetGreeting : IQuery
+ {
+ }
+ """);
+
+ generated.Should().Contain("IGreetingsV1GrpcService");
+ }
+
[TestMethod]
public void MinimalApiGeneratorCombinesRouteQueryAndBody()
{
@@ -561,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