Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions docs/mediator-framework/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
`<ArkAdditionalProto Include="path/to/service.proto" />`; the export target
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions docs/mediator-framework/progress/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)).
Expand Down
2 changes: 1 addition & 1 deletion docs/mediator-framework/progress/tasks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public sealed record UploadGreetingCardRequest : IRequest<UploadResponse>
/// <summary>Queries a previously uploaded greeting-card attachment.</summary>
[HttpEndpoint("GET", "/api/v{version}/greeting-cards/{id}/download")]
[GrpcMethod("Download")]
[ServiceGroup("GeneratedDocuments")]
[GrpcService("GeneratedDocuments")]
public sealed record GetDocumentQuery : IQuery<IArkAttachment>
{
/// <summary>Gets the upload correlation identifier.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public sealed record GreetingResponse
/// <summary>Command used to exercise the synchronous command transport contract.</summary>
[HttpEndpoint("POST", "/api/v{version}/greetings/refresh")]
[GrpcMethod("RefreshGreeting")]
[ServiceGroup("Greetings")]
[GrpcService("Greetings")]
[ProtoContract]
public sealed record RefreshGreetingCommand : ICommand
{
Expand All @@ -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)]
Expand Down Expand Up @@ -97,6 +97,7 @@ public sealed record CreateGreetingRequest : IRequest<GreetingResponse>
}

/// <summary>HTTP-only request that publishes work to Rebus and returns immediately.</summary>
[ApiGroup("Greetings")]
[HttpEndpoint("POST", "/api/v{version}/greetings/compose")]
public sealed record ComposeGreetingRequest : IRequest<ComposeGreetingResponse>
{
Expand Down Expand Up @@ -139,7 +140,7 @@ public sealed record GreetingCreatedNotification : ICommand
/// </summary>
[HttpEndpoint("GET", "/api/v{version}/greetings/{id}", RetiredIn = 2)]
[GrpcMethod("GetGreeting", RetiredIn = 2)]
[ServiceGroup("Greetings")]
[GrpcService("Greetings")]
[ProtoContract]
public sealed record GetGreetingQuery : IQuery<GreetingResponse>
{
Expand Down Expand Up @@ -171,7 +172,7 @@ public sealed record GreetingResponseV2
/// </summary>
[HttpEndpoint("GET", "/api/v{version}/greetings-v2/{id}", IntroducedIn = 2)]
[GrpcMethod("GetGreeting", IntroducedIn = 2)]
[ServiceGroup("Greetings")]
[GrpcService("Greetings")]
[ProtoContract]
public sealed record GetGreetingV2Query : IQuery<GreetingResponseV2>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public sealed record ShapeEnvelope
/// </summary>
[HttpEndpoint("POST", "/api/v{version}/shapes/describe", AcceptsMessagePack = true)]
[GrpcMethod("DescribeShape")]
[ServiceGroup("Greetings")]
[GrpcService("Greetings")]
[ProtoContract]
[MessagePackObject]
public sealed record DescribeShapeRequest : IRequest<ShapeDescription>
Expand Down
Loading
Loading