Skip to content

Commit a1e27af

Browse files
committed
Add per-hub connection UI and auto-reconnect to plugin
Introduce a connection status bar with Connect/Disconnect buttons for each SignalR hub in SwaggerUI. Automatically detect and handle credential changes by reconnecting hubs as needed. Add auto-connect on operation execution, new CSS styles, and tests for connection management features. Update README with usage and behavior details.
1 parent ff918cc commit a1e27af

4 files changed

Lines changed: 380 additions & 1 deletion

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ OpenAPI 3.1 specification generation and SwaggerUI support for ASP.NET Core Sign
2121
- JWT Bearer token support in SwaggerUI (header or query string)
2222
- Custom HTTP headers (static or user-enterable via SwaggerUI Authorize dialog)
2323
- Connection status indicator with automatic reconnection handling
24+
- Automatic credential change detection with transparent reconnection
25+
- Per-hub Connect / Disconnect buttons in SwaggerUI
2426
- Form-urlencoded input mode for primitive and flat object parameters
2527
- Multiple named request/response examples via custom attributes
2628
- Enum schema generation (integer or string based on `JsonStringEnumConverter`)
@@ -148,6 +150,20 @@ Client events (from `Hub<TClient>` interface methods) appear as **EVENT** operat
148150

149151
Events are automatically subscribed when connecting to a hub via any invoke or stream operation.
150152

153+
### Connection Management
154+
155+
Each hub tag section in SwaggerUI displays a connection control bar showing the current connection status with **Connect** and **Disconnect** buttons.
156+
157+
| Status | Description |
158+
|--------|-------------|
159+
| **Connected** | Hub connection is active; a Disconnect button is available |
160+
| **Disconnected** | No active connection; a Connect button is available |
161+
| **Connecting…** | Connection is being established or reconnecting |
162+
163+
**Auto-connect on Execute**: Clicking Execute on any hub method automatically connects if not already connected — you do not need to click Connect first.
164+
165+
**Credential change detection**: When you change API keys or Bearer tokens in the SwaggerUI Authorize dialog, the plugin automatically detects the change on the next hub method invocation and reconnects with the updated credentials. You can also manually disconnect and reconnect to pick up new credentials immediately.
166+
151167
### Request Body Input Modes
152168

153169
Hub methods with parameters support two input modes, selectable via a content-type dropdown in SwaggerUI:

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

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ var SignalROpenApiPlugin = function (system) {
99
// Active hub connections keyed by hub path (e.g. "/hubs/chat")
1010
var _hubs = {};
1111

12+
// Auth fingerprint at connection time, keyed by hub path.
13+
// Used to detect credential changes so the connection can be recycled.
14+
var _hubAuthFingerprints = {};
15+
1216
// Active stream subscriptions keyed by operation path (e.g. "/hubs/Chat/Countdown")
1317
var _streams = {};
1418

@@ -101,6 +105,26 @@ var SignalROpenApiPlugin = function (system) {
101105
return headers;
102106
};
103107

108+
// Compute a fingerprint of the current auth state (token + apiKey headers).
109+
// Returns a stable string that changes whenever credentials change.
110+
var _computeAuthFingerprint = function () {
111+
var parts = [];
112+
var token = _getAccessToken();
113+
if (token) {
114+
parts.push("token:" + token);
115+
}
116+
117+
var apiKeys = _getApiKeyHeaders();
118+
if (apiKeys) {
119+
var sortedKeys = Object.keys(apiKeys).sort();
120+
for (var i = 0; i < sortedKeys.length; i++) {
121+
parts.push("apiKey:" + sortedKeys[i] + "=" + apiKeys[sortedKeys[i]]);
122+
}
123+
}
124+
125+
return parts.join("|");
126+
};
127+
104128
// Subscribe to all client events on a hub connection
105129
var _subscribeClientEvents = function (hubPath, hub) {
106130
var specJson = system.specSelectors.specJson().toJS();
@@ -224,8 +248,62 @@ var SignalROpenApiPlugin = function (system) {
224248
});
225249
};
226250

227-
// Get or create a HubConnection for the given hub path
251+
// Disconnect and clean up a hub connection.
252+
// Cancels active streams, removes the cached connection and fingerprint,
253+
// and notifies UI listeners so components re-render.
254+
var _disconnectHub = function (hubPath) {
255+
// Cancel all active streams on this hub
256+
Object.keys(_streams).forEach(function (streamPath) {
257+
var specIm = system.specSelectors.specJson();
258+
var ext = specIm.getIn(["paths", streamPath, "post", "x-signalr"]);
259+
if (!ext) {
260+
return;
261+
}
262+
263+
var streamHubPath = ext.get("hubPath") || ("/" + ext.get("hub").toLowerCase());
264+
if (streamHubPath === hubPath && _streams[streamPath]) {
265+
_streams[streamPath].dispose();
266+
delete _streams[streamPath];
267+
delete _streamItems[streamPath];
268+
}
269+
});
270+
271+
var hub = _hubs[hubPath];
272+
delete _hubs[hubPath];
273+
delete _hubAuthFingerprints[hubPath];
274+
275+
var _notifyListeners = function () {
276+
if (_eventListeners[hubPath]) {
277+
_eventListeners[hubPath].forEach(function (fn) { fn(); });
278+
}
279+
};
280+
281+
if (hub) {
282+
return hub.stop().then(_notifyListeners).catch(function (err) {
283+
console.error("[SignalR OpenAPI] Disconnect error:", err);
284+
_notifyListeners();
285+
});
286+
}
287+
288+
_notifyListeners();
289+
return Promise.resolve();
290+
};
291+
292+
// Get or create a HubConnection for the given hub path.
293+
// If auth credentials changed since the connection was established,
294+
// the existing connection is torn down and a fresh one is created.
228295
var _getOrCreateHub = function (hubPath) {
296+
var currentFingerprint = _computeAuthFingerprint();
297+
298+
// If a connection exists but auth changed, disconnect first
299+
if (_hubs[hubPath]
300+
&& _hubs[hubPath].state === signalR.HubConnectionState.Connected
301+
&& _hubAuthFingerprints[hubPath] !== currentFingerprint) {
302+
return _disconnectHub(hubPath).then(function () {
303+
return _getOrCreateHub(hubPath);
304+
});
305+
}
306+
229307
if (_hubs[hubPath] && _hubs[hubPath].state === signalR.HubConnectionState.Connected) {
230308
return Promise.resolve(_hubs[hubPath]);
231309
}
@@ -274,6 +352,9 @@ var SignalROpenApiPlugin = function (system) {
274352

275353
_hubs[hubPath] = connection;
276354

355+
// Store the auth fingerprint so we can detect changes later
356+
_hubAuthFingerprints[hubPath] = currentFingerprint;
357+
277358
// Track connection state changes for UI updates
278359
connection.onreconnecting(function () {
279360
if (_eventListeners[hubPath]) {
@@ -325,6 +406,50 @@ var SignalROpenApiPlugin = function (system) {
325406
return signalrExt.hubPath || ("/" + signalrExt.hub.toLowerCase());
326407
};
327408

409+
// Discover all unique hub paths from the spec's x-signalr extensions.
410+
// Returns an array of { hubPath, hubName } objects.
411+
var _getHubPaths = function () {
412+
var specIm = system.specSelectors.specJson();
413+
var pathsIm = specIm.get("paths");
414+
if (!pathsIm) {
415+
return [];
416+
}
417+
418+
var hubSet = {};
419+
pathsIm.keySeq().forEach(function (path) {
420+
if (!_isSignalRPath(path)) {
421+
return;
422+
}
423+
424+
var methods = ["post", "get"];
425+
for (var mi = 0; mi < methods.length; mi++) {
426+
var ext = specIm.getIn(["paths", path, methods[mi], "x-signalr"]);
427+
if (ext) {
428+
var hubName = ext.get("hub");
429+
var hubPath = ext.get("hubPath") || ("/" + hubName.toLowerCase());
430+
hubSet[hubPath] = hubName;
431+
}
432+
}
433+
});
434+
435+
return Object.keys(hubSet).map(function (hp) {
436+
return { hubPath: hp, hubName: hubSet[hp] };
437+
});
438+
};
439+
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+
}
448+
}
449+
450+
return null;
451+
};
452+
328453
// Parse request body from the SwaggerUI OAS3 state.
329454
// The executeRequest wrapper intercepts before the original action reads
330455
// the request body, so we must read it from the OAS3 selectors directly.
@@ -416,6 +541,95 @@ var SignalROpenApiPlugin = function (system) {
416541
return JSON.stringify(result, null, 2);
417542
};
418543

544+
// React component for hub connection control bar.
545+
// Renders inside each tag header to show connection status
546+
// and provide Connect / Disconnect buttons.
547+
function SignalRHubConnectionBar(props) {
548+
var React = system.React;
549+
var hubPath = props.hubPath;
550+
551+
var stateHook = React.useState(0);
552+
var forceUpdate = stateHook[1];
553+
554+
var hub = _hubs[hubPath];
555+
var connectionState = hub ? hub.state : null;
556+
var isConnected = connectionState === signalR.HubConnectionState.Connected;
557+
var isConnecting = connectionState === signalR.HubConnectionState.Connecting
558+
|| connectionState === signalR.HubConnectionState.Reconnecting;
559+
560+
var connectingHook = React.useState(false);
561+
var isManualConnecting = connectingHook[0];
562+
var setManualConnecting = connectingHook[1];
563+
564+
React.useEffect(function () {
565+
var listener = function () { forceUpdate(function (n) { return n + 1; }); };
566+
567+
if (!_eventListeners[hubPath]) {
568+
_eventListeners[hubPath] = [];
569+
}
570+
571+
_eventListeners[hubPath].push(listener);
572+
573+
return function () {
574+
var idx = _eventListeners[hubPath].indexOf(listener);
575+
if (idx >= 0) {
576+
_eventListeners[hubPath].splice(idx, 1);
577+
}
578+
};
579+
}, [hubPath]);
580+
581+
var handleConnect = function () {
582+
setManualConnecting(true);
583+
_getOrCreateHub(hubPath)
584+
.then(function () {
585+
setManualConnecting(false);
586+
forceUpdate(function (n) { return n + 1; });
587+
})
588+
.catch(function (err) {
589+
setManualConnecting(false);
590+
console.error("[SignalR OpenAPI] Manual connect failed:", err);
591+
forceUpdate(function (n) { return n + 1; });
592+
});
593+
};
594+
595+
var handleDisconnect = function () {
596+
_disconnectHub(hubPath).then(function () {
597+
forceUpdate(function (n) { return n + 1; });
598+
});
599+
};
600+
601+
var showConnecting = isConnecting || isManualConnecting;
602+
603+
var statusClass = isConnected
604+
? "signalr-status--connected"
605+
: showConnecting
606+
? "signalr-status--connecting"
607+
: "signalr-status--disconnected";
608+
609+
var statusText = isConnected
610+
? "Connected"
611+
: showConnecting
612+
? "Connecting\u2026"
613+
: "Disconnected";
614+
615+
return React.createElement("div", {
616+
className: "signalr-hub-connection-bar",
617+
onClick: function (e) { e.stopPropagation(); },
618+
},
619+
React.createElement("span", {
620+
className: "signalr-status " + statusClass,
621+
}, statusText),
622+
!isConnected && !showConnecting && React.createElement("button", {
623+
className: "btn signalr-connect-btn",
624+
onClick: handleConnect,
625+
}, "Connect"),
626+
isConnected && React.createElement("button", {
627+
className: "btn signalr-disconnect-btn",
628+
onClick: handleDisconnect,
629+
}, "Disconnect")
630+
);
631+
}
632+
419633
// React component for client event log panel
420634
function SignalREventLog(props) {
421635
var React = system.React;
@@ -709,6 +923,37 @@ var SignalROpenApiPlugin = function (system) {
709923
return React.createElement(Original, props);
710924
};
711925
},
926+
// Inject a connection status bar into each SignalR hub tag header.
927+
// OperationTag renders the collapsible tag section; we append the
928+
// SignalRHubConnectionBar below the original tag header content.
929+
OperationTag: function (Original, system) {
930+
return function (props) {
931+
var React = system.React;
932+
var result = React.createElement(Original, props);
933+
934+
// props.tag is the tag name (ImmutableJS or plain string)
935+
var tagName = props.tag;
936+
if (tagName && tagName.toJS) {
937+
tagName = tagName.toJS();
938+
} else if (tagName && typeof tagName.get === "function") {
939+
tagName = tagName.get(0) || tagName;
940+
}
941+
942+
if (typeof tagName !== "string") {
943+
return result;
944+
}
945+
946+
var hubPath = _getHubPathForTag(tagName);
947+
if (!hubPath) {
948+
return result;
949+
}
950+
951+
return React.createElement("div", null,
952+
React.createElement(SignalRHubConnectionBar, { hubPath: hubPath }),
953+
result
954+
);
955+
};
956+
},
712957
// Hide curl command for SignalR operations
713958
curl: function (Original, system) {
714959
return function (props) {

src/SignalR.OpenApi.SwaggerUi/Resources/signalr-openapi.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,38 @@
8888
.signalr-event-entry:last-child {
8989
border-bottom: none;
9090
}
91+
92+
/* Hub connection control bar displayed within each tag section */
93+
.signalr-hub-connection-bar {
94+
display: flex;
95+
align-items: center;
96+
padding: 6px 20px;
97+
background: #fafafa;
98+
border-bottom: 1px solid #e8e8e8;
99+
font-size: 13px;
100+
gap: 8px;
101+
}
102+
103+
.signalr-connect-btn {
104+
font-size: 12px !important;
105+
padding: 3px 10px !important;
106+
background: #49cc90 !important;
107+
color: #fff !important;
108+
border-color: #49cc90 !important;
109+
}
110+
111+
.signalr-connect-btn:hover {
112+
background: #3bb878 !important;
113+
}
114+
115+
.signalr-disconnect-btn {
116+
font-size: 12px !important;
117+
padding: 3px 10px !important;
118+
background: #f93e3e !important;
119+
color: #fff !important;
120+
border-color: #f93e3e !important;
121+
}
122+
123+
.signalr-disconnect-btn:hover {
124+
background: #e02020 !important;
125+
}

0 commit comments

Comments
 (0)