Skip to content

Commit 80ad62b

Browse files
committed
Support [Tags] on client events for OpenAPI grouping
Client event methods can now be decorated with [Tags] to control their grouping in the generated OpenAPI document. If present, the specified tag(s) override the default "{HubName} Events" tag. Implementation, documentation, and tests have been updated to reflect and verify this behavior.
1 parent de2ca6b commit 80ad62b

8 files changed

Lines changed: 82 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ Explicit `TagDescriptions` always take precedence over XML summary fallback.
252252
|--------|----------|
253253
| Hub class (default) | Hub name without `Hub` suffix (e.g., `ChatHub``"Chat"`) |
254254
| `[Tags("group")]` on hub class or method | Specified tag name |
255-
| Client events (`Hub<TClient>`) | `"{HubName} Events"` (e.g., `"Chat Events"`) |
255+
| Client events (`Hub<TClient>`) default | `"{HubName} Events"` (e.g., `"Chat Events"`) |
256+
| `[Tags("group")]` on client interface method | Specified tag name (overrides default) |
256257

257258
## Request Body Schema
258259

samples/SignalR.OpenApi.Sample/Hubs/IChatClient.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) SignalR.OpenApi Contributors. Licensed under the MIT License.
22

3+
using Microsoft.AspNetCore.Http;
4+
35
namespace SignalR.OpenApi.Sample.Hubs;
46

57
/// <summary>
@@ -20,6 +22,7 @@ public interface IChatClient
2022
/// </summary>
2123
/// <param name="user">The user who connected.</param>
2224
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
25+
[Tags("Presence")]
2326
Task UserConnected(string user);
2427

2528
/// <summary>
@@ -28,5 +31,6 @@ public interface IChatClient
2831
/// </summary>
2932
/// <param name="notification">The notification that was received.</param>
3033
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
34+
[Tags("Notifications")]
3135
Task ReceiveNotification(Notification notification);
3236
}

src/SignalR.OpenApi/Discovery/ReflectionHubDiscoverer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ private IReadOnlyList<SignalRClientEventInfo> DiscoverClientEvents(Type clientIn
441441
Name = m.Name,
442442
Summary = this.GetXmlSummary(m),
443443
Description = this.GetXmlRemarks(m),
444+
Tags = m.GetCustomAttribute<TagsAttribute>()?.Tags.ToList() ?? [],
444445
Parameters = m.GetParameters()
445446
.Select(p => new SignalRParameterInfo
446447
{

src/SignalR.OpenApi/Generation/SignalROpenApiDocumentGenerator.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,9 @@ private void AddClientEventOperations(OpenApiDocument document, SignalRHubInfo h
10141014

10151015
var operation = new OpenApiOperation
10161016
{
1017-
Tags = [new OpenApiTag { Name = $"{hub.Name} Events" }],
1017+
Tags = clientEvent.Tags.Count > 0
1018+
? clientEvent.Tags.Select(t => new OpenApiTag { Name = t }).ToList()
1019+
: [new OpenApiTag { Name = $"{hub.Name} Events" }],
10181020
Summary = clientEvent.Summary ?? $"Client event: {clientEvent.Name}",
10191021
Description = clientEvent.Description ?? "Server-to-client callback. Subscribe to this event to receive notifications.",
10201022
OperationId = $"{hub.Name}_Event_{clientEvent.Name}",

src/SignalR.OpenApi/Models/SignalRClientEventInfo.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ public sealed class SignalRClientEventInfo
2222
/// </summary>
2323
public string? Description { get; set; }
2424

25+
/// <summary>
26+
/// Gets or sets the tags for grouping in the OpenAPI document.
27+
/// When empty, the generator falls back to the default "{HubName} Events" tag.
28+
/// </summary>
29+
public IReadOnlyList<string> Tags { get; set; } = [];
30+
2531
/// <summary>
2632
/// Gets or sets the parameter types of the client event.
2733
/// </summary>

test/SignalR.OpenApi.Tests/ReflectionHubDiscovererTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,34 @@ public void DiscoverHubs_InheritDoc_ResolvesXmlDocsFromInterface()
309309
Assert.AreEqual("The user's name.", nameParam.Description);
310310
}
311311

312+
/// <summary>
313+
/// Verifies Tags attribute is read from client event methods.
314+
/// </summary>
315+
[TestMethod]
316+
public void DiscoverHubs_ReadsTagsAttributeOnClientEvent()
317+
{
318+
var discoverer = CreateDiscoverer();
319+
var hubs = discoverer.DiscoverHubs();
320+
321+
var typed = hubs.First(h => h.HubType == typeof(TypedChatHub));
322+
var userJoined = typed.ClientEvents.First(e => e.Name == "UserJoined");
323+
CollectionAssert.Contains(userJoined.Tags.ToList(), "Presence");
324+
}
325+
326+
/// <summary>
327+
/// Verifies client events without Tags attribute have empty tags.
328+
/// </summary>
329+
[TestMethod]
330+
public void DiscoverHubs_ClientEventWithoutTagsAttribute_HasEmptyTags()
331+
{
332+
var discoverer = CreateDiscoverer();
333+
var hubs = discoverer.DiscoverHubs();
334+
335+
var typed = hubs.First(h => h.HubType == typeof(TypedChatHub));
336+
var receiveMessage = typed.ClientEvents.First(e => e.Name == "ReceiveMessage");
337+
Assert.AreEqual(0, receiveMessage.Tags.Count);
338+
}
339+
312340
private static ReflectionHubDiscoverer CreateDiscoverer(Action<SignalROpenApiOptions>? configure = null)
313341
{
314342
var options = new SignalROpenApiOptions

test/SignalR.OpenApi.Tests/SignalROpenApiDocumentGeneratorTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,41 @@ public void GenerateDocument_IncludesClientEventTags()
11251125
CollectionAssert.Contains(tagNames, "TypedChat Events");
11261126
}
11271127

1128+
/// <summary>
1129+
/// Verifies that a client event with [Tags] uses the custom tag instead of the default.
1130+
/// </summary>
1131+
[TestMethod]
1132+
public void GenerateDocument_ClientEventWithTagsAttribute_UsesCustomTag()
1133+
{
1134+
var (discoverer, generator) = CreateServices();
1135+
var hubs = discoverer.DiscoverHubs();
1136+
var doc = generator.GenerateDocument(hubs);
1137+
1138+
var userJoinedPath = doc.Paths["/hubs/TypedChat/events/UserJoined"];
1139+
var operation = userJoinedPath.Operations[OperationType.Get];
1140+
var tagNames = operation.Tags.Select(t => t.Name).ToList();
1141+
1142+
CollectionAssert.Contains(tagNames, "Presence");
1143+
CollectionAssert.DoesNotContain(tagNames, "TypedChat Events");
1144+
}
1145+
1146+
/// <summary>
1147+
/// Verifies that a client event without [Tags] falls back to the default "{HubName} Events" tag.
1148+
/// </summary>
1149+
[TestMethod]
1150+
public void GenerateDocument_ClientEventWithoutTagsAttribute_UsesDefaultTag()
1151+
{
1152+
var (discoverer, generator) = CreateServices();
1153+
var hubs = discoverer.DiscoverHubs();
1154+
var doc = generator.GenerateDocument(hubs);
1155+
1156+
var receiveMessagePath = doc.Paths["/hubs/TypedChat/events/ReceiveMessage"];
1157+
var operation = receiveMessagePath.Operations[OperationType.Get];
1158+
var tagNames = operation.Tags.Select(t => t.Name).ToList();
1159+
1160+
CollectionAssert.Contains(tagNames, "TypedChat Events");
1161+
}
1162+
11281163
/// <summary>
11291164
/// Verifies that document tags are deduplicated when multiple methods share the same tag.
11301165
/// </summary>

test/SignalR.OpenApi.Tests/TestHubs/IChatClient.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) SignalR.OpenApi Contributors. Licensed under the MIT License.
22

3+
using Microsoft.AspNetCore.Http;
4+
35
namespace SignalR.OpenApi.Tests.TestHubs;
46

57
/// <summary>
@@ -20,6 +22,7 @@ public interface IChatClient
2022
/// </summary>
2123
/// <param name="user">The user who joined.</param>
2224
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
25+
[Tags("Presence")]
2326
Task UserJoined(string user);
2427

2528
/// <summary>

0 commit comments

Comments
 (0)