Skip to content

Commit acebc3d

Browse files
committed
Support custom [Tags] for SignalR hubs in plugin
Enhance the JS plugin to map custom operation tags to hub paths, enabling the connection bar and UI to work when hub methods use custom [Tags] attributes. Add AllCustomTagsHub and new tests to verify correct tag-to-hub mapping and OpenAPI extension data. Also, add a className to the event log status span for improved styling.
1 parent 91bed6a commit acebc3d

4 files changed

Lines changed: 147 additions & 9 deletions

File tree

src/SignalR.OpenApi.SwaggerUi/Resources/signalr-openapi-plugin.js

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -437,17 +437,66 @@ var SignalROpenApiPlugin = function (system) {
437437
});
438438
};
439439

440-
// Get the hub path associated with a given tag name.
441-
// Tags correspond to hub names in the generated OpenAPI spec.
442-
var _getHubPathForTag = function (tagName) {
443-
var hubs = _getHubPaths();
444-
for (var i = 0; i < hubs.length; i++) {
445-
if (hubs[i].hubName === tagName) {
446-
return hubs[i].hubPath;
447-
}
440+
// Build a mapping from tag names to hub paths by scanning all SignalR
441+
// operations in the spec. Each operation's tags are associated with
442+
// the hub path from its x-signalr extension. This handles custom
443+
// [Tags] attributes where the tag name differs from the hub name.
444+
var _cachedTagHubMap = null;
445+
var _cachedTagHubMapVersion = null;
446+
447+
var _getTagHubMap = function () {
448+
var specIm = system.specSelectors.specJson();
449+
if (specIm === _cachedTagHubMapVersion) {
450+
return _cachedTagHubMap;
451+
}
452+
453+
var tagMap = {};
454+
var pathsIm = specIm.get("paths");
455+
if (pathsIm) {
456+
pathsIm.keySeq().forEach(function (path) {
457+
if (!_isSignalRPath(path)) {
458+
return;
459+
}
460+
461+
var methods = ["post", "get"];
462+
for (var mi = 0; mi < methods.length; mi++) {
463+
var opIm = specIm.getIn(["paths", path, methods[mi]]);
464+
if (!opIm) {
465+
continue;
466+
}
467+
468+
var ext = opIm.get("x-signalr");
469+
if (!ext) {
470+
continue;
471+
}
472+
473+
var hubName = ext.get("hub");
474+
var hubPath = ext.get("hubPath") || ("/" + hubName.toLowerCase());
475+
476+
var tagsIm = opIm.get("tags");
477+
if (tagsIm) {
478+
tagsIm.forEach(function (tag) {
479+
var tagStr = typeof tag === "string" ? tag : (tag.get ? tag.get("name") || tag : tag);
480+
if (typeof tagStr === "string" && !tagMap[tagStr]) {
481+
tagMap[tagStr] = hubPath;
482+
}
483+
});
484+
}
485+
}
486+
});
448487
}
449488

450-
return null;
489+
_cachedTagHubMapVersion = specIm;
490+
_cachedTagHubMap = tagMap;
491+
return tagMap;
492+
};
493+
494+
// Get the hub path associated with a given tag name.
495+
// Looks up the tag-to-hubPath mapping built from operation tags,
496+
// which handles both default tags (hub name) and custom [Tags].
497+
var _getHubPathForTag = function (tagName) {
498+
var tagMap = _getTagHubMap();
499+
return tagMap[tagName] || null;
451500
};
452501

453502
// Parse request body from the SwaggerUI OAS3 state.
@@ -689,6 +738,7 @@ var SignalROpenApiPlugin = function (system) {
689738
onClick: clearLog,
690739
}, "Clear Log"),
691740
React.createElement("span", {
741+
className: "signalr-status",
692742
style: { marginLeft: "10px", fontSize: "12px", color: "#888" },
693743
}, logs.length + " event(s) received")
694744
),

test/SignalR.OpenApi.Tests/SignalROpenApiDocumentGeneratorTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,41 @@ public void GenerateDocument_WhenObjectAndStringParametersThenWrappedJsonOnlySch
13791379
Assert.IsFalse(flattenedBody.Value, "flattenedBody should be false for mixed parameters.");
13801380
}
13811381

1382+
/// <summary>
1383+
/// Verifies that when all hub methods have custom [Tags], the operation tags
1384+
/// differ from the hub name but the x-signalr extension still contains the
1385+
/// correct hub name and hubPath, enabling tag-to-hub mapping in the JS plugin.
1386+
/// </summary>
1387+
[TestMethod]
1388+
public void GenerateDocument_AllMethodsHaveCustomTags_ExtensionStillContainsHubInfo()
1389+
{
1390+
var (discoverer, generator) = CreateServices(o =>
1391+
{
1392+
o.HubRoutes[typeof(AllCustomTagsHub)] = "/hubs/custom-tags";
1393+
});
1394+
1395+
var hubs = discoverer.DiscoverHubs();
1396+
var doc = generator.GenerateDocument(hubs);
1397+
1398+
// Both methods should be tagged with "Greetings", not the hub name
1399+
var sayHello = doc.Paths["/hubs/AllCustomTags/SayHello"]
1400+
.Operations[OperationType.Post];
1401+
var sayHelloTags = sayHello.Tags.Select(t => t.Name).ToList();
1402+
CollectionAssert.Contains(sayHelloTags, "Greetings");
1403+
CollectionAssert.DoesNotContain(sayHelloTags, "AllCustomTags");
1404+
1405+
var sayGoodbye = doc.Paths["/hubs/AllCustomTags/SayGoodbye"]
1406+
.Operations[OperationType.Post];
1407+
var sayGoodbyeTags = sayGoodbye.Tags.Select(t => t.Name).ToList();
1408+
CollectionAssert.Contains(sayGoodbyeTags, "Greetings");
1409+
CollectionAssert.DoesNotContain(sayGoodbyeTags, "AllCustomTags");
1410+
1411+
// x-signalr extension should still reference the hub name and custom hubPath
1412+
var ext = (Microsoft.OpenApi.Any.OpenApiObject)sayHello.Extensions["x-signalr"];
1413+
Assert.AreEqual("AllCustomTags", ((Microsoft.OpenApi.Any.OpenApiString)ext["hub"]).Value);
1414+
Assert.AreEqual("/hubs/custom-tags", ((Microsoft.OpenApi.Any.OpenApiString)ext["hubPath"]).Value);
1415+
}
1416+
13821417
private static (ReflectionHubDiscoverer Discoverer, SignalROpenApiDocumentGenerator Generator) CreateServices(
13831418
Action<SignalROpenApiOptions>? configure = null)
13841419
{

test/SignalR.OpenApi.Tests/SwaggerUiIntegrationTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,23 @@ public async Task PluginJs_ContainsOperationTagWrapper()
531531
Assert.IsTrue(content.Contains("OperationTag"), "Plugin JS should contain OperationTag wrapper component");
532532
}
533533

534+
/// <summary>
535+
/// Verifies that the plugin JS contains the _getTagHubMap function
536+
/// which builds a tag-to-hub mapping for custom [Tags] support.
537+
/// </summary>
538+
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
539+
[TestMethod]
540+
public async Task PluginJs_ContainsTagHubMapFunction()
541+
{
542+
using var host = await CreateTestHost();
543+
using var client = host.GetTestClient();
544+
545+
using var response = await client.GetAsync("/signalr-swagger/_resources/signalr-openapi-plugin.js");
546+
var content = await response.Content.ReadAsStringAsync();
547+
548+
Assert.IsTrue(content.Contains("_getTagHubMap"), "Plugin JS should contain _getTagHubMap function for custom [Tags] support");
549+
}
550+
534551
/// <summary>
535552
/// Verifies that the CSS contains connection bar styles.
536553
/// </summary>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) SignalR.OpenApi Contributors. Licensed under the MIT License.
2+
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.SignalR;
5+
6+
namespace SignalR.OpenApi.Tests.TestHubs;
7+
8+
/// <summary>
9+
/// A hub where every method has a custom <see cref="TagsAttribute"/>
10+
/// that differs from the hub name. Used to verify the connection bar
11+
/// still appears when no operation tag matches the hub name.
12+
/// </summary>
13+
public class AllCustomTagsHub : Hub
14+
{
15+
/// <summary>
16+
/// Sends a greeting.
17+
/// </summary>
18+
/// <param name="name">The name to greet.</param>
19+
/// <returns>A greeting message.</returns>
20+
[Tags("Greetings")]
21+
public Task<string> SayHello(string name)
22+
{
23+
return Task.FromResult($"Hello, {name}!");
24+
}
25+
26+
/// <summary>
27+
/// Sends a farewell.
28+
/// </summary>
29+
/// <param name="name">The name to bid farewell.</param>
30+
/// <returns>A farewell message.</returns>
31+
[Tags("Greetings")]
32+
public Task<string> SayGoodbye(string name)
33+
{
34+
return Task.FromResult($"Goodbye, {name}!");
35+
}
36+
}

0 commit comments

Comments
 (0)