Skip to content

Commit 1d9e1c8

Browse files
committed
Add document-level OpenAPI tag descriptions support
- Collect all unique tags from hub methods and client events and add them to the OpenAPI document's tags section. - Support tag descriptions via options.TagDescriptions or fallback to hub XML <summary> when tag matches hub name. - Include client event tags (e.g., "Chat Events") in document tags. - Update README with tag grouping/description docs and usage examples. - Update sample code to demonstrate [Tags] and TagDescriptions usage. - Add unit tests for tag collection, deduplication, and description resolution. - Add TagDescriptions property to SignalROpenApiOptions. - Refactor generator to centralize tag collection and description logic.
1 parent 9980da8 commit 1d9e1c8

6 files changed

Lines changed: 255 additions & 1 deletion

File tree

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ OpenAPI 3.1 specification generation and SwaggerUI support for ASP.NET Core Sign
1212
- **Streaming support**: `IAsyncEnumerable<T>` and `ChannelReader<T>` with accumulated item history and stream state tracking
1313
- **Client event monitoring**: Auto-subscribes to typed hub (`Hub<TClient>`) events with real-time event log panel
1414
- Supports standard ASP.NET Core attributes (`[Authorize]`, `[Tags]`, `[EndpointSummary]`, `[Obsolete]`, etc.)
15+
- Document-level tag definitions with descriptions (from options or XML summary fallback)
1516
- XML documentation comments for descriptions and examples
1617
- `[JsonPolymorphic]` / `[JsonDerivedType]` polymorphic schema support with OData-style sub-endpoints
1718
- Data Annotation validation attributes mapped to OpenAPI schema constraints
@@ -65,6 +66,10 @@ builder.Services.AddSignalROpenApi(options =>
6566
// Include type discriminator in JSON examples for polymorphic sub-endpoints (default: true)
6667
options.IncludeDiscriminatorInExamples = true;
6768

69+
// Add descriptions for tags (displayed as group descriptions in SwaggerUI)
70+
options.TagDescriptions["Chat"] = "Real-time chat operations";
71+
options.TagDescriptions["Chat Events"] = "Server-to-client chat notifications";
72+
6873
// Configure JSON property naming (default: camelCase)
6974
options.JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions
7075
{
@@ -167,7 +172,7 @@ public class AlertNotification : Notification
167172

168173
| Attribute | OpenAPI Mapping |
169174
|-----------|----------------|
170-
| `[Tags("group")]` | `tags` on operation |
175+
| `[Tags("group")]` | `tags` on operation + document-level tag definition |
171176
| `[EndpointSummary("...")]` | `summary` on operation |
172177
| `[EndpointDescription("...")]` | `description` on operation |
173178
| `[EndpointName("Name")]` | `operationId` on operation |
@@ -184,6 +189,46 @@ public class AlertNotification : Notification
184189
| `[SignalROpenApiRequestExamples]` | Named request examples |
185190
| `[SignalROpenApiResponseExamples]` | Named response examples |
186191

192+
## Tag Grouping
193+
194+
Operations are grouped by tags in SwaggerUI. The generator automatically collects all unique tags from operations and adds them to the document-level `tags` section.
195+
196+
### Tag Descriptions
197+
198+
Tag descriptions appear as group headers in SwaggerUI. There are two ways to provide them:
199+
200+
**1. Via options** (explicit):
201+
202+
```csharp
203+
builder.Services.AddSignalROpenApi(options =>
204+
{
205+
options.TagDescriptions["Chat"] = "Real-time chat operations";
206+
options.TagDescriptions["Admin"] = "Administrative hub methods";
207+
});
208+
```
209+
210+
**2. Via XML summary** (automatic fallback): When a tag name matches the hub name (e.g., tag `"Chat"` matches `ChatHub`), the hub's XML `<summary>` is used as the tag description automatically.
211+
212+
```csharp
213+
/// <summary>
214+
/// Real-time chat operations.
215+
/// </summary>
216+
public class ChatHub : Hub
217+
{
218+
// Methods default to the "Chat" tag → description comes from XML summary above
219+
}
220+
```
221+
222+
Explicit `TagDescriptions` always take precedence over XML summary fallback.
223+
224+
### Tag Sources
225+
226+
| Source | Tag Name |
227+
|--------|----------|
228+
| Hub class (default) | Hub name without `Hub` suffix (e.g., `ChatHub``"Chat"`) |
229+
| `[Tags("group")]` on hub class or method | Specified tag name |
230+
| Client events (`Hub<TClient>`) | `"{HubName} Events"` (e.g., `"Chat Events"`) |
231+
187232
## Request Body Schema
188233

189234
Hub method parameters are mapped to the OpenAPI request body schema:

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ public async Task ReplyToMessageAsync(ChatMessage originalMessage, ChatMessage r
3434
}
3535

3636
/// <inheritdoc />
37+
[Tags("Groups")]
3738
public async Task SendToGroupAsync(string group, string user, string message)
3839
{
3940
await this.Clients.Group(group).ReceiveMessage(user, message);
4041
}
4142

4243
/// <inheritdoc />
44+
[Tags("Notifications")]
4345
[SignalROpenApiRequestExamples(typeof(NotificationExamplesProvider))]
4446
public async Task SendNotificationAsync(Notification notification)
4547
{
@@ -55,6 +57,7 @@ public async Task SendNotificationAsync(Notification notification)
5557
}
5658

5759
/// <inheritdoc />
60+
[Tags("Streaming")]
5861
public async IAsyncEnumerable<int> Countdown(
5962
int from,
6063
[EnumeratorCancellation] CancellationToken cancellationToken)

samples/SignalR.OpenApi.Sample/Program.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
options.DocumentVersion = "v1";
1919
options.IncludeDiscriminatorInExamples = true;
2020

21+
// Tag descriptions shown as group headers in SwaggerUI
22+
options.TagDescriptions["Chat"] = "Real-time chat operations including messaging, notifications, and streaming.";
23+
options.TagDescriptions["Groups"] = "Send messages to specific SignalR groups.";
24+
options.TagDescriptions["Notifications"] = "Polymorphic notification delivery (text and alert types).";
25+
options.TagDescriptions["Streaming"] = "Server-to-client streaming operations.";
26+
options.TagDescriptions["Chat Events"] = "Server-to-client callbacks. Subscribe to receive real-time notifications.";
27+
2128
// Default: camelCase (matches ASP.NET Core default)
2229
// For PascalCase:
2330
options.JsonSerializerOptions.PropertyNamingPolicy = null;

src/SignalR.OpenApi/Generation/SignalROpenApiDocumentGenerator.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ public OpenApiDocument GenerateDocument(IReadOnlyList<SignalRHubInfo> hubs)
7979
AddSecuritySchemes(document);
8080
}
8181

82+
this.AddDocumentTags(document, hubs);
83+
8284
return document;
8385
}
8486

@@ -376,6 +378,80 @@ private static Microsoft.OpenApi.Any.OpenApiArray ConvertJsonArray(JsonElement e
376378
return arr;
377379
}
378380

381+
/// <summary>
382+
/// Collects every unique tag referenced by operations and adds
383+
/// document-level tag definitions with optional descriptions.
384+
/// Descriptions are resolved from <see cref="SignalROpenApiOptions.TagDescriptions"/>
385+
/// first, then fall back to the hub's XML summary when the tag name
386+
/// matches the hub name.
387+
/// </summary>
388+
private void AddDocumentTags(OpenApiDocument document, IReadOnlyList<SignalRHubInfo> hubs)
389+
{
390+
// Build a lookup of hub name → summary for fallback descriptions.
391+
var hubSummaries = new Dictionary<string, string>(StringComparer.Ordinal);
392+
foreach (var hub in hubs)
393+
{
394+
if (!string.IsNullOrWhiteSpace(hub.Summary))
395+
{
396+
hubSummaries.TryAdd(hub.Name, hub.Summary);
397+
}
398+
}
399+
400+
// Collect all unique tag names from operations, preserving first-seen order.
401+
var tagNames = new List<string>();
402+
var tagSet = new HashSet<string>(StringComparer.Ordinal);
403+
404+
foreach (var hub in hubs)
405+
{
406+
foreach (var method in hub.Methods)
407+
{
408+
foreach (var tag in method.Tags)
409+
{
410+
if (tagSet.Add(tag))
411+
{
412+
tagNames.Add(tag);
413+
}
414+
}
415+
}
416+
417+
foreach (var clientEvent in hub.ClientEvents)
418+
{
419+
var eventTag = $"{hub.Name} Events";
420+
if (tagSet.Add(eventTag))
421+
{
422+
tagNames.Add(eventTag);
423+
}
424+
}
425+
}
426+
427+
if (tagNames.Count == 0)
428+
{
429+
return;
430+
}
431+
432+
document.Tags = new List<OpenApiTag>();
433+
434+
foreach (var tagName in tagNames)
435+
{
436+
string? description = null;
437+
438+
if (this.options.TagDescriptions.TryGetValue(tagName, out var configured))
439+
{
440+
description = configured;
441+
}
442+
else if (hubSummaries.TryGetValue(tagName, out var hubSummary))
443+
{
444+
description = hubSummary;
445+
}
446+
447+
document.Tags.Add(new OpenApiTag
448+
{
449+
Name = tagName,
450+
Description = description,
451+
});
452+
}
453+
}
454+
379455
private string ConvertPropertyName(string value)
380456
{
381457
var policy = this.options.JsonSerializerOptions.PropertyNamingPolicy;

src/SignalR.OpenApi/SignalROpenApiOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ public sealed class SignalROpenApiOptions
7171
/// </summary>
7272
public bool IncludeDiscriminatorInExamples { get; set; } = true;
7373

74+
/// <summary>
75+
/// Gets or sets descriptions for OpenAPI tags. Maps tag names to their
76+
/// descriptions, which appear in the document-level <c>tags</c> section.
77+
/// When a tag used by an operation is not present here, the generator
78+
/// falls back to the hub's XML summary if the tag name matches the hub name.
79+
/// </summary>
80+
public IDictionary<string, string> TagDescriptions { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
81+
7482
/// <summary>
7583
/// Gets or sets the <see cref="JsonSerializerOptions"/> used for property naming
7684
/// and example serialization in the generated OpenAPI document.

test/SignalR.OpenApi.Tests/SignalROpenApiDocumentGeneratorTests.cs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,121 @@ public void GenerateDocument_WhenJsonStringEnumConverterConfiguredThenEnumProper
984984
Assert.AreEqual("string", statusProp.Type, "Status property should be string with JsonStringEnumConverter.");
985985
}
986986

987+
/// <summary>
988+
/// Verifies the document-level tags list contains all unique tags from operations.
989+
/// </summary>
990+
[TestMethod]
991+
public void GenerateDocument_PopulatesDocumentLevelTags()
992+
{
993+
var (discoverer, generator) = CreateServices();
994+
var hubs = discoverer.DiscoverHubs();
995+
var doc = generator.GenerateDocument(hubs);
996+
997+
Assert.IsNotNull(doc.Tags, "Document should have tags.");
998+
Assert.IsTrue(doc.Tags.Count > 0, "Document should have at least one tag.");
999+
1000+
var tagNames = doc.Tags.Select(t => t.Name).ToList();
1001+
1002+
// BasicHub has no [Tags] so its methods default to "Basic" tag
1003+
CollectionAssert.Contains(tagNames, "Basic");
1004+
1005+
// AttributeHub methods without [Tags] default to hub name "Attribute"
1006+
CollectionAssert.Contains(tagNames, "Attribute");
1007+
1008+
// GetUserDetailsAsync on AttributeHub uses [Tags("Users")]
1009+
CollectionAssert.Contains(tagNames, "Users");
1010+
}
1011+
1012+
/// <summary>
1013+
/// Verifies that tag descriptions configured via options are applied.
1014+
/// </summary>
1015+
[TestMethod]
1016+
public void GenerateDocument_WhenTagDescriptionConfiguredThenApplied()
1017+
{
1018+
var (discoverer, generator) = CreateServices(o =>
1019+
{
1020+
o.TagDescriptions["Basic"] = "Basic hub operations";
1021+
});
1022+
1023+
var hubs = discoverer.DiscoverHubs();
1024+
var doc = generator.GenerateDocument(hubs);
1025+
1026+
Assert.IsNotNull(doc.Tags);
1027+
var basicTag = doc.Tags.FirstOrDefault(t => t.Name == "Basic");
1028+
Assert.IsNotNull(basicTag, "Document should contain a 'Basic' tag.");
1029+
Assert.AreEqual("Basic hub operations", basicTag.Description);
1030+
}
1031+
1032+
/// <summary>
1033+
/// Verifies that when no description is configured, the hub's XML summary
1034+
/// is used as fallback when the tag name matches the hub name.
1035+
/// </summary>
1036+
[TestMethod]
1037+
public void GenerateDocument_WhenNoTagDescriptionThenFallsBackToHubSummary()
1038+
{
1039+
var (discoverer, generator) = CreateServices();
1040+
var hubs = discoverer.DiscoverHubs();
1041+
var doc = generator.GenerateDocument(hubs);
1042+
1043+
Assert.IsNotNull(doc.Tags);
1044+
1045+
// BasicHub has XML summary "A basic hub for testing." and default tag "Basic"
1046+
var basicTag = doc.Tags.FirstOrDefault(t => t.Name == "Basic");
1047+
Assert.IsNotNull(basicTag, "Document should contain a 'Basic' tag.");
1048+
Assert.AreEqual("A basic hub for testing.", basicTag.Description);
1049+
}
1050+
1051+
/// <summary>
1052+
/// Verifies that configured tag descriptions take precedence over hub XML summaries.
1053+
/// </summary>
1054+
[TestMethod]
1055+
public void GenerateDocument_WhenTagDescriptionConfiguredThenOverridesHubSummary()
1056+
{
1057+
var (discoverer, generator) = CreateServices(o =>
1058+
{
1059+
o.TagDescriptions["Basic"] = "Custom description";
1060+
});
1061+
1062+
var hubs = discoverer.DiscoverHubs();
1063+
var doc = generator.GenerateDocument(hubs);
1064+
1065+
Assert.IsNotNull(doc.Tags);
1066+
var basicTag = doc.Tags.FirstOrDefault(t => t.Name == "Basic");
1067+
Assert.IsNotNull(basicTag, "Document should contain a 'Basic' tag.");
1068+
Assert.AreEqual("Custom description", basicTag.Description);
1069+
}
1070+
1071+
/// <summary>
1072+
/// Verifies that tags from client events (e.g., "TypedChat Events") appear in document tags.
1073+
/// </summary>
1074+
[TestMethod]
1075+
public void GenerateDocument_IncludesClientEventTags()
1076+
{
1077+
var (discoverer, generator) = CreateServices();
1078+
var hubs = discoverer.DiscoverHubs();
1079+
var doc = generator.GenerateDocument(hubs);
1080+
1081+
Assert.IsNotNull(doc.Tags);
1082+
var tagNames = doc.Tags.Select(t => t.Name).ToList();
1083+
CollectionAssert.Contains(tagNames, "TypedChat Events");
1084+
}
1085+
1086+
/// <summary>
1087+
/// Verifies that document tags are deduplicated when multiple methods share the same tag.
1088+
/// </summary>
1089+
[TestMethod]
1090+
public void GenerateDocument_DeduplicatesDocumentTags()
1091+
{
1092+
var (discoverer, generator) = CreateServices();
1093+
var hubs = discoverer.DiscoverHubs();
1094+
var doc = generator.GenerateDocument(hubs);
1095+
1096+
Assert.IsNotNull(doc.Tags);
1097+
var tagNames = doc.Tags.Select(t => t.Name).ToList();
1098+
var distinctNames = tagNames.Distinct().ToList();
1099+
Assert.AreEqual(distinctNames.Count, tagNames.Count, "Document tags should be unique.");
1100+
}
1101+
9871102
private static (ReflectionHubDiscoverer Discoverer, SignalROpenApiDocumentGenerator Generator) CreateServices(
9881103
Action<SignalROpenApiOptions>? configure = null)
9891104
{

0 commit comments

Comments
 (0)