Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,38 @@ If your hubs are in external assemblies, you can specify them:
app.AddHubDocs(typeof(ExternalHub).Assembly);
```

### Document Metadata Options

You can pass metadata (similar to Swagger `info`) that appears in `hubdocs.json` and in the HubDocs UI header:

```csharp
app.AddHubDocs(options =>
{
options.Title = "My SignalR API";
options.Version = "1.0.0";
options.Description = "Realtime messaging API docs.";
options.ProjectUrl = "https://example.com/project";
options.TermsOfService = "https://example.com/terms";

options.Contact.Name = "API Support";
options.Contact.Email = "support@example.com";
options.Contact.Url = "https://example.com/support";

options.License.Name = "MIT";
options.License.Url = "https://example.com/license";
});
```

You can combine options with custom assembly scanning:

```csharp
app.AddHubDocs(options =>
{
options.Title = "External Hubs API";
options.Version = "2.0.0";
}, typeof(ExternalHub).Assembly);
```

### Opt-in Documentation

Only hubs marked with `[HubDocs]` attribute will appear in the documentation UI. This gives you control over which hubs are publicly documented.
Expand Down
16 changes: 15 additions & 1 deletion samples/HubDocs.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,21 @@
app.MapHub<NotificationHub>("/hubs/notifications");

// Configure HubDocs - discovers hubs with [HubDocs] attribute from registered endpoints
app.AddHubDocs();
app.AddHubDocs(options =>
{
options.Title = "HubDocs Sample SignalR API";
options.Version = "1.0.0";
options.Description = "Sample project showing HubDocs rich JSON export and interactive SignalR hub explorer.";
options.ProjectUrl = "https://github.com/mberrishdev/HubDocs";
options.TermsOfService = "https://github.com/mberrishdev/HubDocs/blob/main/LICENSE";

options.Contact.Name = "HubDocs Team";
options.Contact.Email = "support@hubdocs.dev";
options.Contact.Url = "https://github.com/mberrishdev/HubDocs/issues";

options.License.Name = "MIT";
options.License.Url = "https://github.com/mberrishdev/HubDocs/blob/main/LICENSE";
});

app.MapControllers();

Expand Down
286 changes: 284 additions & 2 deletions src/HubDocs/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ public static class Extensions

public static WebApplication AddHubDocs(this WebApplication app, params Assembly[] additionalAssemblies)
{
return AddHubDocs(app, _ => { }, additionalAssemblies);
}

public static WebApplication AddHubDocs(this WebApplication app, Action<HubDocsDocumentOptions> configureDocument, params Assembly[] additionalAssemblies)
{
var documentOptions = new HubDocsDocumentOptions();
configureDocument(documentOptions);

app.MapGet("/hubdocs/hubdocs.json", () =>
{
var hubRoutes = GetHubRoutesFromEndpoints(app);
var metadata = DiscoverSignalRHubs(hubRoutes, additionalAssemblies);
return Results.Ok(metadata);
var metadata = DiscoverSignalRHubs(hubRoutes, additionalAssemblies).ToList();
var hubDocsDocument = BuildHubDocsDocument(metadata, documentOptions);
return Results.Ok(hubDocsDocument);
})
.ExcludeFromDescription();

Expand Down Expand Up @@ -439,6 +448,279 @@ private static string BuildReturnExample(MethodInfo method)
return CreateExampleLiteral(unwrapped, false);
}

private static Dictionary<string, object?> BuildHubDocsDocument(IReadOnlyList<HubMetadata> hubs)
{
return BuildHubDocsDocument(hubs, new HubDocsDocumentOptions());
}

private static Dictionary<string, object?> BuildHubDocsDocument(IReadOnlyList<HubMetadata> hubs, HubDocsDocumentOptions options)
{
var channels = new Dictionary<string, object?>();
var messages = new Dictionary<string, object?>();
var schemas = new Dictionary<string, object?>();

foreach (var hub in hubs)
{
if (string.IsNullOrWhiteSpace(hub.Path))
continue;

foreach (var schema in hub.Schemas)
{
if (schemas.ContainsKey(schema.Name))
continue;

schemas[schema.Name] = ConvertHubSchemaToProtocolSchema(schema);
}

var publishMessageRefs = new List<object>();
foreach (var method in hub.Methods)
{
var messageName = $"{hub.HubName}.{method.MethodName}.Request";
messages[messageName] = BuildMethodMessage(messageName, method, schemas.Keys);
publishMessageRefs.Add(new Dictionary<string, object?>
{
["$ref"] = $"#/components/messages/{messageName}"
});
}

var subscribeMessageRefs = new List<object>();
foreach (var method in hub.ClientMethods ?? [])
{
var messageName = $"{hub.HubName}.{method.MethodName}.Event";
messages[messageName] = BuildMethodMessage(messageName, method, schemas.Keys);
subscribeMessageRefs.Add(new Dictionary<string, object?>
{
["$ref"] = $"#/components/messages/{messageName}"
});
}

channels[hub.Path] = new Dictionary<string, object?>
{
["publish"] = new Dictionary<string, object?>
{
["operationId"] = $"{hub.HubName}.publish",
["summary"] = $"Client-to-server methods for {hub.HubName}",
["message"] = new Dictionary<string, object?>
{
["oneOf"] = publishMessageRefs
}
},
["subscribe"] = new Dictionary<string, object?>
{
["operationId"] = $"{hub.HubName}.subscribe",
["summary"] = $"Server-to-client methods for {hub.HubName}",
["message"] = new Dictionary<string, object?>
{
["oneOf"] = subscribeMessageRefs
}
}
};
}

return new Dictionary<string, object?>
{
["hubdocs"] = new Dictionary<string, object?>
{
["format"] = "hubdocs-1.0",
["version"] = options.Version,
["title"] = options.Title,
["description"] = options.Description,
["termsOfService"] = options.TermsOfService,
["projectUrl"] = options.ProjectUrl,
["contact"] = new Dictionary<string, object?>
{
["name"] = options.Contact.Name,
["email"] = options.Contact.Email,
["url"] = options.Contact.Url
},
["license"] = new Dictionary<string, object?>
{
["name"] = options.License.Name,
["url"] = options.License.Url
},
["generatedAtUtc"] = DateTime.UtcNow.ToString("O")
},
["hubs"] = hubs,
["channels"] = channels,
["components"] = new Dictionary<string, object?>
{
["messages"] = messages,
["schemas"] = schemas
}
};
}

private static Dictionary<string, object?> BuildMethodMessage(string messageName, HubMethodMetadata method, IEnumerable<string> knownSchemas)
{
var argumentProperties = new Dictionary<string, object?>();

foreach (var parameter in method.Parameters)
{
argumentProperties[parameter.Name] = BuildJsonSchemaForType(parameter.Type, knownSchemas, parameter.Example, parameter.IsNullable);
}

var payloadProperties = new Dictionary<string, object?>
{
["method"] = new Dictionary<string, object?>
{
["type"] = "string",
["example"] = method.MethodName
},
["arguments"] = new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = argumentProperties
},
["returns"] = BuildJsonSchemaForType(method.ReturnType, knownSchemas, method.ReturnExample, false)
};

return new Dictionary<string, object?>
{
["name"] = messageName,
["title"] = method.Signature,
["payload"] = new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = payloadProperties,
["required"] = new[] { "method", "arguments" }
},
["examples"] = new[]
{
new Dictionary<string, object?>
{
["name"] = $"{method.MethodName}Example",
["summary"] = method.Signature,
["payload"] = new Dictionary<string, object?>
{
["method"] = method.MethodName,
["arguments"] = method.Parameters.ToDictionary(p => p.Name, p => (object?)p.Example),
["returns"] = method.ReturnExample
}
}
}
};
}

private static Dictionary<string, object?> BuildJsonSchemaForType(string csharpType, IEnumerable<string> knownSchemas, string? example, bool nullable)
{
var cleaned = csharpType.Trim();
if (cleaned.EndsWith("?", StringComparison.Ordinal))
{
cleaned = cleaned[..^1];
nullable = true;
}

if (cleaned.EndsWith("[]", StringComparison.Ordinal))
{
var itemType = cleaned[..^2];
var itemSchema = BuildJsonSchemaForType(itemType, knownSchemas, null, false);
var arraySchema = new Dictionary<string, object?>
{
["type"] = "array",
["items"] = itemSchema
};

if (example != null)
arraySchema["example"] = example;

if (nullable)
arraySchema["nullable"] = true;

return arraySchema;
}

if (cleaned.StartsWith("List<", StringComparison.Ordinal) && cleaned.EndsWith(">", StringComparison.Ordinal))
{
var inner = cleaned[5..^1];
var itemSchema = BuildJsonSchemaForType(inner, knownSchemas, null, false);
var listSchema = new Dictionary<string, object?>
{
["type"] = "array",
["items"] = itemSchema
};

if (example != null)
listSchema["example"] = example;

if (nullable)
listSchema["nullable"] = true;

return listSchema;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return type schema missing Task wrapper unwrapping

Medium Severity

BuildJsonSchemaForType handles List<T> unwrapping (mapping it to an array schema) but has no equivalent handling for Task<T>. Since FormatMethodReturnType produces strings like "Task<MessageDto>" for async hub methods, the generic fallback at line 651 extracts "Task" as the schema name, fails to match any known schema, and MapPrimitiveJsonSchema maps the entire string to {"type": "string"}. Any hub method returning Task<int>, Task<MyDto>, etc. will have its return type incorrectly represented in the protocol document.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 98d15e6. Configure here.


var schemaName = cleaned.Contains('<')
? cleaned[..cleaned.IndexOf('<')]
: cleaned;

if (knownSchemas.Contains(schemaName, StringComparer.Ordinal))
{
var refSchema = new Dictionary<string, object?>
{
["$ref"] = $"#/components/schemas/{schemaName}"
};

if (nullable)
refSchema["nullable"] = true;

return refSchema;
}

var primitive = MapPrimitiveJsonSchema(cleaned);
if (example != null)
primitive["example"] = example;
if (nullable)
primitive["nullable"] = true;
return primitive;
}

private static Dictionary<string, object?> MapPrimitiveJsonSchema(string csharpType)
{
return csharpType switch
{
"bool" => new Dictionary<string, object?> { ["type"] = "boolean" },
"byte" or "sbyte" or "short" or "ushort" or "int" or "uint" or "long" or "ulong" =>
new Dictionary<string, object?> { ["type"] = "integer", ["format"] = "int64" },
"float" => new Dictionary<string, object?> { ["type"] = "number", ["format"] = "float" },
"double" or "decimal" => new Dictionary<string, object?> { ["type"] = "number", ["format"] = "double" },
"Guid" => new Dictionary<string, object?> { ["type"] = "string", ["format"] = "uuid" },
"DateTime" or "DateTimeOffset" => new Dictionary<string, object?> { ["type"] = "string", ["format"] = "date-time" },
"TimeSpan" => new Dictionary<string, object?> { ["type"] = "string" },
"Task" or "void" => new Dictionary<string, object?> { ["type"] = "null" },
_ => new Dictionary<string, object?> { ["type"] = "string" }
};
}

private static Dictionary<string, object?> ConvertHubSchemaToProtocolSchema(HubTypeSchemaMetadata schema)
{
if (schema.Kind == "enum")
{
var enumNames = (schema.EnumValues ?? [])
.Select(v => v.Split('=')[0].Trim())
.Where(v => !string.IsNullOrWhiteSpace(v))
.ToList();

return new Dictionary<string, object?>
{
["type"] = "string",
["enum"] = enumNames,
["example"] = enumNames.FirstOrDefault()
};
}

var properties = new Dictionary<string, object?>();
foreach (var property in schema.Properties ?? [])
{
properties[property.Name] = BuildJsonSchemaForType(property.Type, [], property.Example, property.IsNullable);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schema property references lost due to empty known schemas

Medium Severity

ConvertHubSchemaToProtocolSchema passes an empty collection [] as the knownSchemas argument to BuildJsonSchemaForType. This means schema properties whose types match other known schemas will never generate a $ref reference — they'll fall through to MapPrimitiveJsonSchema and be incorrectly mapped as "type": "string". The method-level code correctly passes schemas.Keys, so this is inconsistent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 98d15e6. Configure here.

}

return new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = properties,
["example"] = schema.Example
};
}

private static string CreateExampleLiteral(Type type, bool nullable)
{
if (nullable)
Expand Down
2 changes: 1 addition & 1 deletion src/HubDocs/HubDocs.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>HubDocs</PackageId>
<Version>0.0.8</Version>
<Version>0.0.9</Version>
<Company>BerrishDev</Company>
<Product>HubDocs</Product>
<Description>Swagger-like documentation UI for SignalR Hubs</Description>
Expand Down
25 changes: 25 additions & 0 deletions src/HubDocs/HubDocsDocumentOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace HubDocs;

public class HubDocsDocumentOptions
{
public string Title { get; set; } = "HubDocs SignalR Protocol";
public string Version { get; set; } = "1.0.0";
public string? Description { get; set; } = "HubDocs protocol export with channels, messages, and schemas.";
public string? TermsOfService { get; set; }
public string? ProjectUrl { get; set; }
public HubDocsContactOptions Contact { get; set; } = new();
public HubDocsLicenseOptions License { get; set; } = new();
}

public class HubDocsContactOptions
{
public string? Name { get; set; }
public string? Email { get; set; }
public string? Url { get; set; }
}

public class HubDocsLicenseOptions
{
public string? Name { get; set; }
public string? Url { get; set; }
}
Loading
Loading