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