Skip to content

Commit 3772b69

Browse files
committed
Improve XML doc inheritdoc support & UI Async suffix handling
Enhance SignalR.OpenApi to resolve XML docs via <inheritdoc /> for hub methods, inheriting summaries, remarks, param, and return docs from interfaces. Add IChatHub interface and update ChatHub to use <inheritdoc />. Add test hubs and tests for inheritdoc resolution. Add options for property naming and discriminator visibility in JSON examples. "Async" suffix is now stripped only in UI display, not in operation IDs or method names. Update README and sample code to reflect these features.
1 parent 361209d commit 3772b69

16 files changed

Lines changed: 409 additions & 141 deletions

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,23 @@ builder.Services.AddSignalROpenApi(options =>
6161
{
6262
options.DocumentTitle = "My SignalR API";
6363
options.DocumentVersion = "v1";
64-
options.StripAsyncSuffix = true;
64+
65+
// Include type discriminator in JSON examples for polymorphic sub-endpoints (default: true)
66+
options.IncludeDiscriminatorInExamples = true;
67+
68+
// Configure JSON property naming (default: camelCase)
69+
options.JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions
70+
{
71+
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
72+
};
6573
});
6674

6775
builder.Services.AddSignalRSwaggerUi(options =>
6876
{
6977
options.RoutePrefix = "signalr-swagger"; // SwaggerUI route (default)
7078
options.SpecUrl = "/openapi/signalr-v1.json"; // Spec endpoint (default)
7179
options.DocumentTitle = "SignalR API"; // Browser tab title (default)
80+
options.StripAsyncSuffix = true; // Strip "Async" from display names (default)
7281
});
7382
```
7483

@@ -151,6 +160,8 @@ public class AlertNotification : Notification
151160
```
152161

153162
> **Note**: `System.Text.Json` polymorphic deserialization requires the type discriminator property to appear **first** in the JSON object. The SwaggerUI plugin handles this automatically for sub-endpoints.
163+
>
164+
> By default, `IncludeDiscriminatorInExamples = true` makes the discriminator visible in JSON request examples but hidden in form-urlencoded inputs. Set to `false` to hide the discriminator from all examples (the plugin still injects it at invocation time).
154165
155166
## Supported Attributes
156167

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

Lines changed: 13 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,90 +8,45 @@
88

99
namespace SignalR.OpenApi.Sample.Hubs;
1010

11-
/// <summary>
12-
/// A sample chat hub demonstrating SignalR.OpenApi features.
13-
/// </summary>
11+
/// <inheritdoc cref="IChatHub"/>
1412
[Tags("Chat")]
15-
public class ChatHub : Hub<IChatClient>
13+
public class ChatHub : Hub<IChatClient>, IChatHub
1614
{
17-
/// <summary>
18-
/// Sends a message to all connected clients using separate parameters.
19-
/// </summary>
20-
/// <remarks>
21-
/// This demonstrates hub methods with primitive parameters.
22-
/// FluentValidation does not apply to primitive parameters — use a request object instead.
23-
/// </remarks>
24-
/// <param name="user">The name of the sending user.</param>
25-
/// <param name="message">The message to send.</param>
26-
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
15+
/// <inheritdoc />
2716
[SignalROpenApiRequestExamples(typeof(SendMessageExamplesProvider))]
28-
public async Task SendMessage(string user, string message)
17+
public async Task SendMessageAsync(string user, string message)
2918
{
3019
await this.Clients.All.ReceiveMessage(user, message);
3120
}
3221

33-
/// <summary>
34-
/// Sends a direct message using a request object with FluentValidation.
35-
/// </summary>
36-
/// <remarks>
37-
/// This demonstrates hub methods with a single object parameter (flattened schema).
38-
/// FluentValidation rules from <see cref="SendMessageRequestValidator"/> are applied to the OpenAPI schema.
39-
/// </remarks>
40-
/// <param name="request">The message request containing user and message.</param>
41-
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
22+
/// <inheritdoc />
4223
[SignalROpenApiRequestExamples(typeof(SendMessageExamplesProvider))]
43-
public async Task SendDirectMessage(SendMessageRequest request)
24+
public async Task SendDirectMessageAsync(SendMessageRequest request)
4425
{
4526
await this.Clients.All.ReceiveMessage(request.User, request.Message);
4627
}
4728

48-
/// <summary>
49-
/// Replies to an existing message.
50-
/// </summary>
51-
/// <remarks>
52-
/// This demonstrates hub methods with two object parameters (wrapped schema).
53-
/// Each parameter appears as a named property in the request body.
54-
/// </remarks>
55-
/// <param name="originalMessage">The original message being replied to.</param>
56-
/// <param name="reply">The reply message.</param>
57-
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
29+
/// <inheritdoc />
5830
[SignalROpenApiRequestExamples(typeof(ReplyToMessageExamplesProvider))]
59-
public async Task ReplyToMessage(ChatMessage originalMessage, ChatMessage reply)
31+
public async Task ReplyToMessageAsync(ChatMessage originalMessage, ChatMessage reply)
6032
{
6133
await this.Clients.All.ReceiveMessage(reply.User, $"Re: {originalMessage.Message}{reply.Message}");
6234
}
6335

64-
/// <summary>
65-
/// Sends a message to a specific group.
66-
/// </summary>
67-
/// <param name="group">The target group name.</param>
68-
/// <param name="user">The name of the sending user.</param>
69-
/// <param name="message">The message to send.</param>
70-
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
71-
public async Task SendToGroup(string group, string user, string message)
36+
/// <inheritdoc />
37+
public async Task SendToGroupAsync(string group, string user, string message)
7238
{
7339
await this.Clients.Group(group).ReceiveMessage(user, message);
7440
}
7541

76-
/// <summary>
77-
/// Sends a notification to a user. The notification type is polymorphic —
78-
/// use the "type" discriminator to select between "text" and "alert".
79-
/// </summary>
80-
/// <param name="notification">The notification to send (text or alert).</param>
81-
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
42+
/// <inheritdoc />
8243
[SignalROpenApiRequestExamples(typeof(NotificationExamplesProvider))]
83-
public async Task SendNotification(Notification notification)
44+
public async Task SendNotificationAsync(Notification notification)
8445
{
8546
await this.Clients.All.ReceiveMessage(notification.Recipient, $"Notification: {notification.GetType().Name}");
8647
}
8748

88-
/// <summary>
89-
/// Streams a countdown of numbers.
90-
/// </summary>
91-
/// <param name="from">The starting number.</param>
92-
/// <param name="cancellationToken">Cancellation token.</param>
93-
/// <returns>A stream of countdown numbers.</returns>
94-
/// <example>10.</example>
49+
/// <inheritdoc />
9550
public async IAsyncEnumerable<int> Countdown(
9651
int from,
9752
[EnumeratorCancellation] CancellationToken cancellationToken)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) SignalR.OpenApi Contributors. Licensed under the MIT License.
2+
3+
namespace SignalR.OpenApi.Sample.Hubs;
4+
5+
/// <summary>
6+
/// Defines the server-side methods for the chat hub.
7+
/// </summary>
8+
public interface IChatHub
9+
{
10+
/// <summary>
11+
/// Sends a message to all connected clients using separate parameters.
12+
/// </summary>
13+
/// <remarks>
14+
/// This demonstrates hub methods with primitive parameters.
15+
/// FluentValidation does not apply to primitive parameters — use a request object instead.
16+
/// </remarks>
17+
/// <param name="user">The name of the sending user.</param>
18+
/// <param name="message">The message to send.</param>
19+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
20+
Task SendMessageAsync(string user, string message);
21+
22+
/// <summary>
23+
/// Sends a direct message using a request object with FluentValidation.
24+
/// </summary>
25+
/// <remarks>
26+
/// This demonstrates hub methods with a single object parameter (flattened schema).
27+
/// FluentValidation rules from <see cref="SendMessageRequestValidator"/> are applied to the OpenAPI schema.
28+
/// </remarks>
29+
/// <param name="request">The message request containing user and message.</param>
30+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
31+
Task SendDirectMessageAsync(SendMessageRequest request);
32+
33+
/// <summary>
34+
/// Replies to an existing message.
35+
/// </summary>
36+
/// <remarks>
37+
/// This demonstrates hub methods with two object parameters (wrapped schema).
38+
/// Each parameter appears as a named property in the request body.
39+
/// </remarks>
40+
/// <param name="originalMessage">The original message being replied to.</param>
41+
/// <param name="reply">The reply message.</param>
42+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
43+
Task ReplyToMessageAsync(ChatMessage originalMessage, ChatMessage reply);
44+
45+
/// <summary>
46+
/// Sends a message to a specific group.
47+
/// </summary>
48+
/// <param name="group">The target group name.</param>
49+
/// <param name="user">The name of the sending user.</param>
50+
/// <param name="message">The message to send.</param>
51+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
52+
Task SendToGroupAsync(string group, string user, string message);
53+
54+
/// <summary>
55+
/// Sends a notification to a user. The notification type is polymorphic —
56+
/// use the "type" discriminator to select between "text" and "alert".
57+
/// </summary>
58+
/// <param name="notification">The notification to send (text or alert).</param>
59+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
60+
Task SendNotificationAsync(Notification notification);
61+
62+
/// <summary>
63+
/// Streams a countdown of numbers.
64+
/// </summary>
65+
/// <param name="from">The starting number.</param>
66+
/// <param name="cancellationToken">Cancellation token.</param>
67+
/// <returns>A stream of countdown numbers.</returns>
68+
/// <example>10.</example>
69+
IAsyncEnumerable<int> Countdown(int from, CancellationToken cancellationToken);
70+
}

samples/SignalR.OpenApi.Sample/Program.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@
1515
{
1616
options.DocumentTitle = "SignalR.OpenApi Sample";
1717
options.DocumentVersion = "v1";
18+
options.IncludeDiscriminatorInExamples = true;
19+
20+
// Default: camelCase (matches ASP.NET Core default)
21+
// For PascalCase:
22+
options.JsonSerializerOptions.PropertyNamingPolicy = null;
1823
});
1924
builder.Services.AddSignalRFluentValidation();
20-
builder.Services.AddSignalRSwaggerUi();
25+
builder.Services.AddSignalRSwaggerUi(options =>
26+
{
27+
options.StripAsyncSuffix = true;
28+
});
2129

2230
var app = builder.Build();
2331

src/SignalR.OpenApi.SwaggerUi/Extensions/SwaggerUiApplicationBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public static IApplicationBuilder UseSignalRSwaggerUi(
5151
c.ConfigObject.Plugins ??= [];
5252
c.ConfigObject.Plugins.Add("SignalROpenApiPlugin");
5353

54+
// Pass SignalR options to the JS plugin via ConfigObject
55+
c.ConfigObject.AdditionalItems["signalRStripAsyncSuffix"] = options.StripAsyncSuffix;
56+
5457
// Configure auth UI for JWT Bearer (standard SwaggerUI behavior)
5558
c.OAuthUsePkce();
5659
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,14 @@ var SignalROpenApiPlugin = function (system) {
517517
props.operationProps = props.operationProps.set("method", "invoke");
518518
}
519519
}
520+
521+
// Strip "Async" suffix from the display path if configured
522+
var configs = system.getConfigs ? system.getConfigs() : {};
523+
var stripAsync = configs.signalRStripAsyncSuffix !== false;
524+
if (stripAsync && path.match(/Async(\/[^\/]+)?$/)) {
525+
var displayPath = path.replace(/Async(\/[^\/]+)?$/, "$1");
526+
props.operationProps = props.operationProps.set("path", displayPath);
527+
}
520528
}
521529

522530
return React.createElement(Original, props);

src/SignalR.OpenApi.SwaggerUi/SignalRSwaggerUiOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,11 @@ public class SignalRSwaggerUiOptions
3030
/// for Windows authentication. Defaults to <see langword="false"/>.
3131
/// </summary>
3232
public bool UseDefaultCredentials { get; set; }
33+
34+
/// <summary>
35+
/// Gets or sets a value indicating whether to strip the "Async" suffix
36+
/// from method names in the UI display. The underlying SignalR invocation
37+
/// always uses the real method name. Defaults to <see langword="true"/>.
38+
/// </summary>
39+
public bool StripAsyncSuffix { get; set; } = true;
3340
}

0 commit comments

Comments
 (0)